mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Compare commits
22 Commits
better-pat
...
room_serve
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cee7103ec6 | ||
|
|
d05312c157 | ||
|
|
5b166c4b66 | ||
|
|
dbe2915635 | ||
|
|
2337d7b592 | ||
|
|
62080424bb | ||
|
|
1ae76848fe | ||
|
|
45ed430580 | ||
|
|
5f8ce16855 | ||
|
|
b79249c4a0 | ||
|
|
85d1a940dc | ||
|
|
b85d451e26 | ||
|
|
41a297c944 | ||
|
|
41d64d86d4 | ||
|
|
bd336e3ee2 | ||
|
|
cf585cdf87 | ||
|
|
417a583696 | ||
|
|
541dba6a75 | ||
|
|
720b8be64f | ||
|
|
2b5083e889 | ||
|
|
5975006cf7 | ||
|
|
69e09378f5 |
21
AGENTS.md
21
AGENTS.md
@@ -138,8 +138,12 @@ MeshCore firmware can encode path hops as 1-byte, 2-byte, or 3-byte identifiers.
|
||||
- `path_hash_mode` values are `0` = 1-byte, `1` = 2-byte, `2` = 3-byte.
|
||||
- `GET /api/radio/config` exposes both the current `path_hash_mode` and `path_hash_mode_supported`.
|
||||
- `PATCH /api/radio/config` may update `path_hash_mode` only when the connected firmware supports it.
|
||||
- Contacts persist `out_path_hash_mode` separately from `last_path` so contact sync and DM send paths can round-trip correctly even when hop bytes are ambiguous.
|
||||
- Contacts may also persist an explicit routing override (`route_override_*`). When set, radio-bound operations use the override instead of the learned `last_path*`, but learned paths still keep updating from adverts.
|
||||
- Contact routing now uses canonical route fields: `direct_path`, `direct_path_len`, `direct_path_hash_mode`, plus optional `route_override_*`.
|
||||
- The contact/API surface also exposes backend-computed `effective_route`, `effective_route_source`, `direct_route`, and `route_override` so send logic and UI do not reimplement precedence rules independently.
|
||||
- Legacy `last_path`, `last_path_len`, and `out_path_hash_mode` are no longer part of the contact model or API contract.
|
||||
- Route precedence for direct-message sends is: explicit override, then learned direct route, then flood.
|
||||
- The learned direct route is sourced from radio contact sync (`out_path`) and PATH/path-discovery updates, matching how firmware updates `ContactInfo.out_path`.
|
||||
- Advertisement paths are informational only. They are retained in `contact_advert_paths` for the contact pane and visualizer, but they are not used as DM send routes.
|
||||
- `path_len` in API payloads is always hop count, not byte count. The actual path byte length is `hop_count * hash_size`.
|
||||
|
||||
## Data Flow
|
||||
@@ -159,12 +163,21 @@ MeshCore firmware can encode path hops as 1-byte, 2-byte, or 3-byte identifiers.
|
||||
4. Message stored in database with `outgoing=true`
|
||||
5. For direct messages: ACK tracked; for channel: repeat detection
|
||||
|
||||
Direct-message send behavior intentionally mirrors the firmware/library `send_msg_with_retry(...)` flow:
|
||||
- We push the contact's effective route to the radio via `add_contact(...)` before sending.
|
||||
- Non-final attempts use the effective route (`override > direct > flood`).
|
||||
- Retry timing follows the radio's `suggested_timeout`.
|
||||
- The final retry is sent as flood by resetting the path on the radio first, even if an override or direct route exists.
|
||||
- Path math is always hop-count based; hop bytes are interpreted using the stored `path_hash_mode`.
|
||||
|
||||
### ACK and Repeat Detection
|
||||
|
||||
**Direct messages**: Expected ACK code is tracked. When ACK event arrives, message marked as acked.
|
||||
|
||||
Outgoing DMs send once immediately, then may retry up to 2 more times in the background if still unacked. Retry timing follows the radio's `suggested_timeout` from `PACKET_MSG_SENT`, and the final retry is sent as flood even when a routing override is configured. DM ACK state is terminal on first ACK: sibling retry ACK codes are cleared so one DM should not accumulate multiple delivery confirmations from different retry attempts.
|
||||
|
||||
ACKs are not a contact-route source. They drive message delivery state and may appear in analytics/detail surfaces, but they do not update `direct_path*` or otherwise influence route selection for future sends.
|
||||
|
||||
**Channel messages**: Flood messages echo back through repeaters. Repeats are identified by the database UNIQUE constraint on `(type, conversation_key, text, sender_timestamp)` — when an INSERT hits a duplicate, `_handle_duplicate_message()` in `packet_processor.py` adds the new path and, for outgoing messages only, increments the ack count. Incoming repeats add path data but do not change the ack count. There is no timestamp-windowed matching; deduplication is exact-match only.
|
||||
|
||||
This message-layer echo/path handling is independent of raw-packet storage deduplication.
|
||||
@@ -294,8 +307,8 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/health` | Connection status, fanout statuses, bots_disabled flag |
|
||||
| GET | `/api/debug` | Support snapshot: recent logs, live radio probe, contact/channel drift audit, and running version/git info |
|
||||
| GET | `/api/radio/config` | Radio configuration, including `path_hash_mode`, `path_hash_mode_supported`, and whether adverts include current node location |
|
||||
| PATCH | `/api/radio/config` | Update name, location, advert-location on/off, radio params, and `path_hash_mode` when supported |
|
||||
| GET | `/api/radio/config` | Radio configuration, including `path_hash_mode`, `path_hash_mode_supported`, advert-location on/off, and `multi_acks_enabled` |
|
||||
| PATCH | `/api/radio/config` | Update name, location, advert-location on/off, `multi_acks_enabled`, radio params, and `path_hash_mode` when supported |
|
||||
| PUT | `/api/radio/private-key` | Import private key to radio |
|
||||
| POST | `/api/radio/advertise` | Send advertisement (`mode`: `flood` or `zero_hop`, default `flood`) |
|
||||
| POST | `/api/radio/discover` | Run a short mesh discovery sweep for nearby repeaters/sensors |
|
||||
|
||||
@@ -104,8 +104,10 @@ app/
|
||||
- Channel sends use a session-local LRU slot cache after startup channel offload clears the radio. Repeated sends to the same room reuse the loaded slot; new rooms fill free slots up to the discovered channel capacity, then evict the least recently used cached room.
|
||||
- TCP radios do not reuse cached slot contents. For TCP, channel sends still force `set_channel(...)` before every send because this backend does not have exclusive device access.
|
||||
- `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true` disables slot reuse on all transports and forces the old always-`set_channel(...)` behavior before every channel send.
|
||||
- Contacts persist `out_path_hash_mode` in the database so contact sync and outbound DM routing reuse the exact stored mode instead of inferring from path bytes.
|
||||
- Contacts may also persist `route_override_path`, `route_override_len`, and `route_override_hash_mode`. `Contact.to_radio_dict()` gives these override fields precedence over learned `last_path*`, while advert processing still updates the learned route for telemetry/fallback.
|
||||
- Contacts persist canonical direct-route fields (`direct_path`, `direct_path_len`, `direct_path_hash_mode`) so contact sync and outbound DM routing reuse the exact stored hop width instead of inferring from path bytes.
|
||||
- Direct-route sources are limited to radio contact sync (`out_path`) and PATH/path-discovery updates. This mirrors firmware `onContactPathRecv(...)`, which replaces `ContactInfo.out_path` when a new returned path is heard.
|
||||
- `route_override_path`, `route_override_len`, and `route_override_hash_mode` take precedence over the learned direct route for radio-bound sends.
|
||||
- Advertisement paths are stored only in `contact_advert_paths` for analytics/visualization. They are not part of `Contact.to_radio_dict()` or DM route selection.
|
||||
- `contact_advert_paths` identity is `(public_key, path_hex, path_len)` because the same hex bytes can represent different routes at different hop widths.
|
||||
|
||||
### Read/unread state
|
||||
@@ -120,8 +122,10 @@ app/
|
||||
- DM ACK tracking is an in-memory pending/buffered map in `services/dm_ack_tracker.py`, with periodic expiry from `radio_sync.py`.
|
||||
- Outgoing DMs send once inline, store/broadcast immediately after the first successful `MSG_SENT`, then may retry up to 2 more times in the background if still unacked.
|
||||
- DM retry timing follows the firmware-provided `suggested_timeout` from `PACKET_MSG_SENT`; do not replace it with a fixed app timeout unless you intentionally want more aggressive duplicate-prone retries.
|
||||
- The final DM retry is intentionally sent as flood via `reset_path(...)`, even when a routing override exists.
|
||||
- Direct-message send behavior is intended to emulate `meshcore_py.commands.send_msg_with_retry(...)`: stage the effective contact route on the radio, send, wait for ACK, and on the final retry force flood via `reset_path(...)`.
|
||||
- Non-final DM attempts use the contact's effective route (`override > direct > flood`). The final retry is intentionally sent as flood even when a routing override exists.
|
||||
- DM ACK state is terminal on first ACK. Retry attempts may register multiple expected ACK codes for the same message, but sibling pending codes are cleared once one ACK wins so a DM should not accrue multiple delivery confirmations from retries.
|
||||
- ACKs are delivery state, not routing state. Bundled ACKs inside PATH packets still satisfy pending DM sends, but ACK history does not feed contact route learning.
|
||||
|
||||
### Echo/repeat dedup
|
||||
|
||||
@@ -165,8 +169,8 @@ app/
|
||||
- `GET /debug` — support snapshot with recent logs, live radio probe, slot/contact audits, and version/git info
|
||||
|
||||
### Radio
|
||||
- `GET /radio/config` — includes `path_hash_mode`, `path_hash_mode_supported`, and advert-location on/off
|
||||
- `PATCH /radio/config` — may update `path_hash_mode` (`0..2`) when firmware supports it
|
||||
- `GET /radio/config` — includes `path_hash_mode`, `path_hash_mode_supported`, advert-location on/off, and `multi_acks_enabled`
|
||||
- `PATCH /radio/config` — may update `path_hash_mode` (`0..2`) when firmware supports it, and `multi_acks_enabled`
|
||||
- `PUT /radio/private-key`
|
||||
- `POST /radio/advertise` — manual advert send; request body may set `mode` to `flood` or `zero_hop` (defaults to `flood`)
|
||||
- `POST /radio/discover` — short mesh discovery sweep for nearby repeaters/sensors
|
||||
@@ -258,7 +262,7 @@ Client sends `"ping"` text; server replies `{"type":"pong"}`.
|
||||
## Data Model Notes
|
||||
|
||||
Main tables:
|
||||
- `contacts` (includes `first_seen` for contact age tracking and `out_path_hash_mode` for route round-tripping)
|
||||
- `contacts` (includes `first_seen` for contact age tracking and `direct_path_hash_mode` / `route_override_*` for DM routing)
|
||||
- `channels`
|
||||
Includes optional `flood_scope_override` for channel-specific regional sends.
|
||||
- `messages` (includes `sender_name`, `sender_key` for per-contact channel message attribution)
|
||||
@@ -267,6 +271,13 @@ Main tables:
|
||||
- `contact_name_history` (tracks name changes over time)
|
||||
- `app_settings`
|
||||
|
||||
Contact route state is canonicalized on the backend:
|
||||
- stored route inputs: `direct_path`, `direct_path_len`, `direct_path_hash_mode`, `direct_path_updated_at`, plus optional `route_override_*`
|
||||
- computed route surface: `effective_route`, `effective_route_source`, `direct_route`, `route_override`
|
||||
- removed legacy names: `last_path`, `last_path_len`, `out_path_hash_mode`
|
||||
|
||||
Frontend and send paths should consume the canonical route surface rather than reconstructing precedence from raw fields.
|
||||
|
||||
Repository writes should prefer typed models such as `ContactUpsert` over ad hoc dict payloads when adding or updating schema-coupled data.
|
||||
|
||||
`max_radio_contacts` is the configured radio contact capacity baseline. Favorites reload first, the app refills non-favorite working-set contacts to about 80% of that capacity, and periodic offload triggers once occupancy reaches about 95%.
|
||||
|
||||
@@ -13,9 +13,10 @@ CREATE TABLE IF NOT EXISTS contacts (
|
||||
name TEXT,
|
||||
type INTEGER DEFAULT 0,
|
||||
flags INTEGER DEFAULT 0,
|
||||
last_path TEXT,
|
||||
last_path_len INTEGER DEFAULT -1,
|
||||
out_path_hash_mode INTEGER DEFAULT 0,
|
||||
direct_path TEXT,
|
||||
direct_path_len INTEGER,
|
||||
direct_path_hash_mode INTEGER,
|
||||
direct_path_updated_at INTEGER,
|
||||
route_override_path TEXT,
|
||||
route_override_len INTEGER,
|
||||
route_override_hash_mode INTEGER,
|
||||
@@ -25,7 +26,8 @@ CREATE TABLE IF NOT EXISTS contacts (
|
||||
last_seen INTEGER,
|
||||
on_radio INTEGER DEFAULT 0,
|
||||
last_contacted INTEGER,
|
||||
first_seen INTEGER
|
||||
first_seen INTEGER,
|
||||
last_read_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channels (
|
||||
@@ -33,7 +35,8 @@ CREATE TABLE IF NOT EXISTS channels (
|
||||
name TEXT NOT NULL,
|
||||
is_hashtag INTEGER DEFAULT 0,
|
||||
on_radio INTEGER DEFAULT 0,
|
||||
flood_scope_override TEXT
|
||||
flood_scope_override TEXT,
|
||||
last_read_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
|
||||
115
app/decoder.py
115
app/decoder.py
@@ -58,6 +58,28 @@ class DecryptedDirectMessage:
|
||||
message: str
|
||||
dest_hash: str # First byte of destination pubkey as hex
|
||||
src_hash: str # First byte of sender pubkey as hex
|
||||
signed_sender_prefix: str | None = None
|
||||
|
||||
@property
|
||||
def txt_type(self) -> int:
|
||||
return self.flags >> 2
|
||||
|
||||
@property
|
||||
def attempt(self) -> int:
|
||||
return self.flags & 0x03
|
||||
|
||||
|
||||
@dataclass
|
||||
class DecryptedPathPayload:
|
||||
"""Result of decrypting a PATH payload."""
|
||||
|
||||
dest_hash: str
|
||||
src_hash: str
|
||||
returned_path: bytes
|
||||
returned_path_len: int
|
||||
returned_path_hash_mode: int
|
||||
extra_type: int
|
||||
extra: bytes
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -485,6 +507,13 @@ def decrypt_direct_message(payload: bytes, shared_secret: bytes) -> DecryptedDir
|
||||
|
||||
# Extract message text (UTF-8, null-padded)
|
||||
message_bytes = decrypted[5:]
|
||||
signed_sender_prefix: str | None = None
|
||||
txt_type = flags >> 2
|
||||
if txt_type == 2:
|
||||
if len(message_bytes) < 4:
|
||||
return None
|
||||
signed_sender_prefix = message_bytes[:4].hex()
|
||||
message_bytes = message_bytes[4:]
|
||||
try:
|
||||
message_text = message_bytes.decode("utf-8")
|
||||
# Truncate at first null terminator (consistent with channel message handling)
|
||||
@@ -500,6 +529,7 @@ def decrypt_direct_message(payload: bytes, shared_secret: bytes) -> DecryptedDir
|
||||
message=message_text,
|
||||
dest_hash=dest_hash,
|
||||
src_hash=src_hash,
|
||||
signed_sender_prefix=signed_sender_prefix,
|
||||
)
|
||||
|
||||
|
||||
@@ -563,3 +593,88 @@ def try_decrypt_dm(
|
||||
return None
|
||||
|
||||
return decrypt_direct_message(packet_info.payload, shared_secret)
|
||||
|
||||
|
||||
def decrypt_path_payload(payload: bytes, shared_secret: bytes) -> DecryptedPathPayload | None:
|
||||
"""Decrypt a PATH payload using the ECDH shared secret."""
|
||||
if len(payload) < 4:
|
||||
return None
|
||||
|
||||
dest_hash = format(payload[0], "02x")
|
||||
src_hash = format(payload[1], "02x")
|
||||
mac = payload[2:4]
|
||||
ciphertext = payload[4:]
|
||||
|
||||
if len(ciphertext) == 0 or len(ciphertext) % 16 != 0:
|
||||
return None
|
||||
|
||||
calculated_mac = hmac.new(shared_secret, ciphertext, hashlib.sha256).digest()[:2]
|
||||
if calculated_mac != mac:
|
||||
return None
|
||||
|
||||
try:
|
||||
cipher = AES.new(shared_secret[:16], AES.MODE_ECB)
|
||||
decrypted = cipher.decrypt(ciphertext)
|
||||
except Exception as e:
|
||||
logger.debug("AES decryption failed for PATH payload: %s", e)
|
||||
return None
|
||||
|
||||
if len(decrypted) < 2:
|
||||
return None
|
||||
|
||||
from app.path_utils import decode_path_byte
|
||||
|
||||
packed_len = decrypted[0]
|
||||
try:
|
||||
returned_path_len, hash_size = decode_path_byte(packed_len)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
path_byte_len = returned_path_len * hash_size
|
||||
if len(decrypted) < 1 + path_byte_len + 1:
|
||||
return None
|
||||
|
||||
offset = 1
|
||||
returned_path = decrypted[offset : offset + path_byte_len]
|
||||
offset += path_byte_len
|
||||
extra_type = decrypted[offset] & 0x0F
|
||||
offset += 1
|
||||
extra = decrypted[offset:]
|
||||
|
||||
return DecryptedPathPayload(
|
||||
dest_hash=dest_hash,
|
||||
src_hash=src_hash,
|
||||
returned_path=returned_path,
|
||||
returned_path_len=returned_path_len,
|
||||
returned_path_hash_mode=hash_size - 1,
|
||||
extra_type=extra_type,
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
|
||||
def try_decrypt_path(
|
||||
raw_packet: bytes,
|
||||
our_private_key: bytes,
|
||||
their_public_key: bytes,
|
||||
our_public_key: bytes,
|
||||
) -> DecryptedPathPayload | None:
|
||||
"""Try to decrypt a raw packet as a PATH packet."""
|
||||
packet_info = parse_packet(raw_packet)
|
||||
if packet_info is None or packet_info.payload_type != PayloadType.PATH:
|
||||
return None
|
||||
|
||||
if len(packet_info.payload) < 4:
|
||||
return None
|
||||
|
||||
dest_hash = packet_info.payload[0]
|
||||
src_hash = packet_info.payload[1]
|
||||
if dest_hash != our_public_key[0] or src_hash != their_public_key[0]:
|
||||
return None
|
||||
|
||||
try:
|
||||
shared_secret = derive_shared_secret(our_private_key, their_public_key)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to derive shared secret for PATH payload: %s", e)
|
||||
return None
|
||||
|
||||
return decrypt_path_payload(packet_info.payload, shared_secret)
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from meshcore import EventType
|
||||
|
||||
from app.models import Contact, ContactUpsert
|
||||
from app.models import CONTACT_TYPE_ROOM, Contact, ContactUpsert
|
||||
from app.packet_processor import process_raw_packet
|
||||
from app.repository import (
|
||||
ContactRepository,
|
||||
@@ -14,11 +14,12 @@ from app.services.contact_reconciliation import (
|
||||
promote_prefix_contacts_for_contact,
|
||||
record_contact_name_and_reconcile,
|
||||
)
|
||||
from app.services.dm_ack_apply import apply_dm_ack_code
|
||||
from app.services.dm_ingest import (
|
||||
ingest_fallback_direct_message,
|
||||
resolve_direct_message_sender_metadata,
|
||||
resolve_fallback_direct_message_context,
|
||||
)
|
||||
from app.services.messages import increment_ack_and_broadcast
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -87,6 +88,23 @@ async def on_contact_message(event: "Event") -> None:
|
||||
sender_timestamp = ts if ts is not None else received_at
|
||||
path = payload.get("path")
|
||||
path_len = payload.get("path_len")
|
||||
sender_name = context.sender_name
|
||||
sender_key = context.sender_key
|
||||
signature = payload.get("signature")
|
||||
if (
|
||||
context.contact is not None
|
||||
and context.contact.type == CONTACT_TYPE_ROOM
|
||||
and txt_type == 2
|
||||
and isinstance(signature, str)
|
||||
and signature
|
||||
):
|
||||
sender_name, sender_key = await resolve_direct_message_sender_metadata(
|
||||
sender_public_key=signature,
|
||||
received_at=received_at,
|
||||
broadcast_fn=broadcast_event,
|
||||
contact_repository=ContactRepository,
|
||||
log=logger,
|
||||
)
|
||||
message = await ingest_fallback_direct_message(
|
||||
conversation_key=context.conversation_key,
|
||||
text=payload.get("text", ""),
|
||||
@@ -95,9 +113,9 @@ async def on_contact_message(event: "Event") -> None:
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
txt_type=txt_type,
|
||||
signature=payload.get("signature"),
|
||||
sender_name=context.sender_name,
|
||||
sender_key=context.sender_key,
|
||||
signature=signature,
|
||||
sender_name=sender_name,
|
||||
sender_key=sender_key,
|
||||
broadcast_fn=broadcast_event,
|
||||
update_last_contacted_key=context.contact.public_key.lower() if context.contact else None,
|
||||
)
|
||||
@@ -197,11 +215,12 @@ async def on_path_update(event: "Event") -> None:
|
||||
)
|
||||
normalized_path_hash_mode = None
|
||||
|
||||
await ContactRepository.update_path(
|
||||
await ContactRepository.update_direct_path(
|
||||
contact.public_key,
|
||||
str(path),
|
||||
normalized_path_len,
|
||||
normalized_path_hash_mode,
|
||||
updated_at=int(time.time()),
|
||||
)
|
||||
|
||||
|
||||
@@ -268,19 +287,10 @@ async def on_ack(event: "Event") -> None:
|
||||
return
|
||||
|
||||
logger.debug("Received ACK with code %s", ack_code)
|
||||
|
||||
cleanup_expired_acks()
|
||||
|
||||
message_id = dm_ack_tracker.pop_pending_ack(ack_code)
|
||||
if message_id is not None:
|
||||
dm_ack_tracker.clear_pending_acks_for_message(message_id)
|
||||
logger.info("ACK received for message %d", message_id)
|
||||
# DM ACKs don't carry path data, so paths is intentionally omitted.
|
||||
# The frontend's mergePendingAck handles the missing field correctly,
|
||||
# preserving any previously known paths.
|
||||
await increment_ack_and_broadcast(message_id=message_id, broadcast_fn=broadcast_event)
|
||||
matched = await apply_dm_ack_code(ack_code, broadcast_fn=broadcast_event)
|
||||
if matched:
|
||||
logger.info("ACK received for code %s", ack_code)
|
||||
else:
|
||||
dm_ack_tracker.buffer_unmatched_ack(ack_code)
|
||||
logger.debug("ACK code %s does not match any pending messages", ack_code)
|
||||
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ Wraps bot code execution via `app/fanout/bot_exec.py`. Config blob:
|
||||
- `code` — Python bot function source code
|
||||
- Executes in a thread pool with timeout and semaphore concurrency control
|
||||
- Rate-limits outgoing messages for repeater compatibility
|
||||
- Channel `message_text` passed to bot code is normalized for human readability by stripping a leading `"{sender_name}: "` prefix when it matches the payload sender.
|
||||
|
||||
### webhook (webhook.py)
|
||||
HTTP webhook delivery. Config blob:
|
||||
@@ -78,6 +79,7 @@ Push notifications via Apprise library. Config blob:
|
||||
- `urls` — newline-separated Apprise notification service URLs
|
||||
- `preserve_identity` — suppress Discord webhook name/avatar override
|
||||
- `include_path` — include routing path in notification body
|
||||
- Channel notifications normalize stored message text by stripping a leading `"{sender_name}: "` prefix when it matches the payload sender so alerts do not duplicate the name.
|
||||
|
||||
### sqs (sqs.py)
|
||||
Amazon SQS delivery. Config blob:
|
||||
|
||||
@@ -6,7 +6,7 @@ import asyncio
|
||||
import logging
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
|
||||
from app.fanout.base import FanoutModule
|
||||
from app.fanout.base import FanoutModule, get_fanout_message_text
|
||||
from app.path_utils import split_path_hex
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -39,7 +39,7 @@ def _normalize_discord_url(url: str) -> str:
|
||||
def _format_body(data: dict, *, include_path: bool) -> str:
|
||||
"""Build a human-readable notification body from message data."""
|
||||
msg_type = data.get("type", "")
|
||||
text = data.get("text", "")
|
||||
text = get_fanout_message_text(data)
|
||||
sender_name = data.get("sender_name") or "Unknown"
|
||||
|
||||
via = ""
|
||||
|
||||
@@ -33,3 +33,30 @@ class FanoutModule:
|
||||
def status(self) -> str:
|
||||
"""Return 'connected', 'disconnected', or 'error'."""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def get_fanout_message_text(data: dict) -> str:
|
||||
"""Return the best human-readable message body for fanout consumers.
|
||||
|
||||
Channel messages are stored with the rendered sender label embedded in the
|
||||
text (for example ``"Alice: hello"``). Human-facing integrations that also
|
||||
carry ``sender_name`` should strip that duplicated prefix when it matches
|
||||
the payload sender exactly.
|
||||
"""
|
||||
|
||||
text = data.get("text", "")
|
||||
if not isinstance(text, str):
|
||||
return ""
|
||||
|
||||
if data.get("type") != "CHAN":
|
||||
return text
|
||||
|
||||
sender_name = data.get("sender_name")
|
||||
if not isinstance(sender_name, str) or not sender_name:
|
||||
return text
|
||||
|
||||
prefix, separator, remainder = text.partition(": ")
|
||||
if separator and prefix == sender_name:
|
||||
return remainder
|
||||
|
||||
return text
|
||||
|
||||
@@ -115,7 +115,7 @@ def _generate_jwt_token(
|
||||
"exp": now + _TOKEN_LIFETIME,
|
||||
"aud": audience,
|
||||
"owner": pubkey_hex,
|
||||
"client": _CLIENT_ID,
|
||||
"client": _get_client_version(),
|
||||
}
|
||||
if email:
|
||||
payload["email"] = email
|
||||
@@ -260,8 +260,10 @@ def _build_radio_info() -> str:
|
||||
|
||||
|
||||
def _get_client_version() -> str:
|
||||
"""Return the app version string for community MQTT payloads."""
|
||||
return get_app_build_info().version
|
||||
"""Return the canonical client/version identifier for community MQTT."""
|
||||
build = get_app_build_info()
|
||||
commit_hash = build.commit_hash or "unknown"
|
||||
return f"{_CLIENT_ID}/{build.version}-{commit_hash}"
|
||||
|
||||
|
||||
class CommunityMqttPublisher(BaseMqttPublisher):
|
||||
|
||||
@@ -32,6 +32,7 @@ from app.routers import (
|
||||
radio,
|
||||
read_state,
|
||||
repeaters,
|
||||
rooms,
|
||||
settings,
|
||||
statistics,
|
||||
ws,
|
||||
@@ -134,6 +135,7 @@ app.include_router(fanout.router, prefix="/api")
|
||||
app.include_router(radio.router, prefix="/api")
|
||||
app.include_router(contacts.router, prefix="/api")
|
||||
app.include_router(repeaters.router, prefix="/api")
|
||||
app.include_router(rooms.router, prefix="/api")
|
||||
app.include_router(channels.router, prefix="/api")
|
||||
app.include_router(messages.router, prefix="/api")
|
||||
app.include_router(packets.router, prefix="/api")
|
||||
|
||||
@@ -346,6 +346,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
|
||||
await set_version(conn, 44)
|
||||
applied += 1
|
||||
|
||||
# Migration 45: Replace legacy contact route columns with direct-route columns
|
||||
if version < 45:
|
||||
logger.info("Applying migration 45: rebuild contacts direct-route columns")
|
||||
await _migrate_045_rebuild_contacts_direct_route_columns(conn)
|
||||
await set_version(conn, 45)
|
||||
applied += 1
|
||||
|
||||
if applied > 0:
|
||||
logger.info(
|
||||
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
|
||||
@@ -2635,3 +2642,134 @@ async def _migrate_044_dedupe_incoming_direct_messages(conn: aiosqlite.Connectio
|
||||
WHERE type = 'PRIV' AND outgoing = 0"""
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def _migrate_045_rebuild_contacts_direct_route_columns(conn: aiosqlite.Connection) -> None:
|
||||
"""Replace legacy contact route columns with canonical direct-route columns."""
|
||||
cursor = await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='contacts'"
|
||||
)
|
||||
if await cursor.fetchone() is None:
|
||||
await conn.commit()
|
||||
return
|
||||
|
||||
cursor = await conn.execute("PRAGMA table_info(contacts)")
|
||||
columns = {row[1] for row in await cursor.fetchall()}
|
||||
|
||||
target_columns = {
|
||||
"public_key",
|
||||
"name",
|
||||
"type",
|
||||
"flags",
|
||||
"direct_path",
|
||||
"direct_path_len",
|
||||
"direct_path_hash_mode",
|
||||
"direct_path_updated_at",
|
||||
"route_override_path",
|
||||
"route_override_len",
|
||||
"route_override_hash_mode",
|
||||
"last_advert",
|
||||
"lat",
|
||||
"lon",
|
||||
"last_seen",
|
||||
"on_radio",
|
||||
"last_contacted",
|
||||
"first_seen",
|
||||
"last_read_at",
|
||||
}
|
||||
if (
|
||||
target_columns.issubset(columns)
|
||||
and "last_path" not in columns
|
||||
and "out_path_hash_mode" not in columns
|
||||
):
|
||||
await conn.commit()
|
||||
return
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE contacts_new (
|
||||
public_key TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
type INTEGER DEFAULT 0,
|
||||
flags INTEGER DEFAULT 0,
|
||||
direct_path TEXT,
|
||||
direct_path_len INTEGER,
|
||||
direct_path_hash_mode INTEGER,
|
||||
direct_path_updated_at INTEGER,
|
||||
route_override_path TEXT,
|
||||
route_override_len INTEGER,
|
||||
route_override_hash_mode INTEGER,
|
||||
last_advert INTEGER,
|
||||
lat REAL,
|
||||
lon REAL,
|
||||
last_seen INTEGER,
|
||||
on_radio INTEGER DEFAULT 0,
|
||||
last_contacted INTEGER,
|
||||
first_seen INTEGER,
|
||||
last_read_at INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
select_expr = {
|
||||
"public_key": "public_key",
|
||||
"name": "NULL",
|
||||
"type": "0",
|
||||
"flags": "0",
|
||||
"direct_path": "NULL",
|
||||
"direct_path_len": "NULL",
|
||||
"direct_path_hash_mode": "NULL",
|
||||
"direct_path_updated_at": "NULL",
|
||||
"route_override_path": "NULL",
|
||||
"route_override_len": "NULL",
|
||||
"route_override_hash_mode": "NULL",
|
||||
"last_advert": "NULL",
|
||||
"lat": "NULL",
|
||||
"lon": "NULL",
|
||||
"last_seen": "NULL",
|
||||
"on_radio": "0",
|
||||
"last_contacted": "NULL",
|
||||
"first_seen": "NULL",
|
||||
"last_read_at": "NULL",
|
||||
}
|
||||
for name in ("name", "type", "flags"):
|
||||
if name in columns:
|
||||
select_expr[name] = name
|
||||
|
||||
if "direct_path" in columns:
|
||||
select_expr["direct_path"] = "direct_path"
|
||||
|
||||
if "direct_path_len" in columns:
|
||||
select_expr["direct_path_len"] = "direct_path_len"
|
||||
|
||||
if "direct_path_hash_mode" in columns:
|
||||
select_expr["direct_path_hash_mode"] = "direct_path_hash_mode"
|
||||
|
||||
for name in (
|
||||
"route_override_path",
|
||||
"route_override_len",
|
||||
"route_override_hash_mode",
|
||||
"last_advert",
|
||||
"lat",
|
||||
"lon",
|
||||
"last_seen",
|
||||
"on_radio",
|
||||
"last_contacted",
|
||||
"first_seen",
|
||||
"last_read_at",
|
||||
):
|
||||
if name in columns:
|
||||
select_expr[name] = name
|
||||
|
||||
ordered_columns = list(select_expr.keys())
|
||||
await conn.execute(
|
||||
f"""
|
||||
INSERT INTO contacts_new ({", ".join(ordered_columns)})
|
||||
SELECT {", ".join(select_expr[name] for name in ordered_columns)}
|
||||
FROM contacts
|
||||
"""
|
||||
)
|
||||
|
||||
await conn.execute("DROP TABLE contacts")
|
||||
await conn.execute("ALTER TABLE contacts_new RENAME TO contacts")
|
||||
await conn.commit()
|
||||
|
||||
120
app/models.py
120
app/models.py
@@ -2,7 +2,17 @@ from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.path_utils import normalize_contact_route
|
||||
from app.path_utils import normalize_contact_route, normalize_route_override
|
||||
|
||||
|
||||
class ContactRoute(BaseModel):
|
||||
"""A normalized contact route."""
|
||||
|
||||
path: str = Field(description="Hex-encoded path bytes (empty string for direct/flood)")
|
||||
path_len: int = Field(description="Hop count (-1=flood, 0=direct, >0=explicit route)")
|
||||
path_hash_mode: int = Field(
|
||||
description="Path hash mode (-1=flood, 0=1-byte, 1=2-byte, 2=3-byte hop identifiers)"
|
||||
)
|
||||
|
||||
|
||||
class ContactUpsert(BaseModel):
|
||||
@@ -12,9 +22,10 @@ class ContactUpsert(BaseModel):
|
||||
name: str | None = None
|
||||
type: int = 0
|
||||
flags: int = 0
|
||||
last_path: str | None = None
|
||||
last_path_len: int = -1
|
||||
out_path_hash_mode: int | None = None
|
||||
direct_path: str | None = None
|
||||
direct_path_len: int | None = None
|
||||
direct_path_hash_mode: int | None = None
|
||||
direct_path_updated_at: int | None = None
|
||||
route_override_path: str | None = None
|
||||
route_override_len: int | None = None
|
||||
route_override_hash_mode: int | None = None
|
||||
@@ -40,7 +51,7 @@ class ContactUpsert(BaseModel):
|
||||
cls, public_key: str, radio_data: dict, on_radio: bool = False
|
||||
) -> "ContactUpsert":
|
||||
"""Convert radio contact data to the contact-row write shape."""
|
||||
last_path, last_path_len, out_path_hash_mode = normalize_contact_route(
|
||||
direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route(
|
||||
radio_data.get("out_path"),
|
||||
radio_data.get("out_path_len", -1),
|
||||
radio_data.get(
|
||||
@@ -53,9 +64,9 @@ class ContactUpsert(BaseModel):
|
||||
name=radio_data.get("adv_name"),
|
||||
type=radio_data.get("type", 0),
|
||||
flags=radio_data.get("flags", 0),
|
||||
last_path=last_path,
|
||||
last_path_len=last_path_len,
|
||||
out_path_hash_mode=out_path_hash_mode,
|
||||
direct_path=direct_path,
|
||||
direct_path_len=direct_path_len,
|
||||
direct_path_hash_mode=direct_path_hash_mode,
|
||||
lat=radio_data.get("adv_lat"),
|
||||
lon=radio_data.get("adv_lon"),
|
||||
last_advert=radio_data.get("last_advert"),
|
||||
@@ -68,9 +79,10 @@ class Contact(BaseModel):
|
||||
name: str | None = None
|
||||
type: int = 0 # 0=unknown, 1=client, 2=repeater, 3=room, 4=sensor
|
||||
flags: int = 0
|
||||
last_path: str | None = None
|
||||
last_path_len: int = -1
|
||||
out_path_hash_mode: int = 0
|
||||
direct_path: str | None = None
|
||||
direct_path_len: int = -1
|
||||
direct_path_hash_mode: int = -1
|
||||
direct_path_updated_at: int | None = None
|
||||
route_override_path: str | None = None
|
||||
route_override_len: int | None = None
|
||||
route_override_hash_mode: int | None = None
|
||||
@@ -82,38 +94,99 @@ class Contact(BaseModel):
|
||||
last_contacted: int | None = None # Last time we sent/received a message
|
||||
last_read_at: int | None = None # Server-side read state tracking
|
||||
first_seen: int | None = None
|
||||
effective_route: ContactRoute | None = None
|
||||
effective_route_source: Literal["override", "direct", "flood"] = "flood"
|
||||
direct_route: ContactRoute | None = None
|
||||
route_override: ContactRoute | None = None
|
||||
|
||||
def model_post_init(self, __context) -> None:
|
||||
direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route(
|
||||
self.direct_path,
|
||||
self.direct_path_len,
|
||||
self.direct_path_hash_mode,
|
||||
)
|
||||
self.direct_path = direct_path or None
|
||||
self.direct_path_len = direct_path_len
|
||||
self.direct_path_hash_mode = direct_path_hash_mode
|
||||
|
||||
route_override_path, route_override_len, route_override_hash_mode = (
|
||||
normalize_route_override(
|
||||
self.route_override_path,
|
||||
self.route_override_len,
|
||||
self.route_override_hash_mode,
|
||||
)
|
||||
)
|
||||
self.route_override_path = route_override_path or None
|
||||
self.route_override_len = route_override_len
|
||||
self.route_override_hash_mode = route_override_hash_mode
|
||||
if (
|
||||
route_override_path is not None
|
||||
and route_override_len is not None
|
||||
and route_override_hash_mode is not None
|
||||
):
|
||||
self.route_override = ContactRoute(
|
||||
path=route_override_path,
|
||||
path_len=route_override_len,
|
||||
path_hash_mode=route_override_hash_mode,
|
||||
)
|
||||
else:
|
||||
self.route_override = None
|
||||
|
||||
if direct_path_len >= 0:
|
||||
self.direct_route = ContactRoute(
|
||||
path=direct_path,
|
||||
path_len=direct_path_len,
|
||||
path_hash_mode=direct_path_hash_mode,
|
||||
)
|
||||
else:
|
||||
self.direct_route = None
|
||||
|
||||
path, path_len, path_hash_mode = self.effective_route_tuple()
|
||||
if self.has_route_override():
|
||||
self.effective_route_source = "override"
|
||||
elif self.direct_route is not None:
|
||||
self.effective_route_source = "direct"
|
||||
else:
|
||||
self.effective_route_source = "flood"
|
||||
self.effective_route = ContactRoute(
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
path_hash_mode=path_hash_mode,
|
||||
)
|
||||
|
||||
def has_route_override(self) -> bool:
|
||||
return self.route_override_len is not None
|
||||
|
||||
def effective_route(self) -> tuple[str, int, int]:
|
||||
def effective_route_tuple(self) -> tuple[str, int, int]:
|
||||
if self.has_route_override():
|
||||
return normalize_contact_route(
|
||||
self.route_override_path,
|
||||
self.route_override_len,
|
||||
self.route_override_hash_mode,
|
||||
)
|
||||
return normalize_contact_route(
|
||||
self.last_path,
|
||||
self.last_path_len,
|
||||
self.out_path_hash_mode,
|
||||
)
|
||||
if self.direct_path_len >= 0:
|
||||
return normalize_contact_route(
|
||||
self.direct_path,
|
||||
self.direct_path_len,
|
||||
self.direct_path_hash_mode,
|
||||
)
|
||||
return "", -1, -1
|
||||
|
||||
def to_radio_dict(self) -> dict:
|
||||
"""Convert to the dict format expected by meshcore radio commands.
|
||||
|
||||
The radio API uses different field names (adv_name, out_path, etc.)
|
||||
than our database schema (name, last_path, etc.).
|
||||
than our database schema (name, direct_path, etc.).
|
||||
"""
|
||||
last_path, last_path_len, out_path_hash_mode = self.effective_route()
|
||||
effective_path, effective_path_len, effective_path_hash_mode = self.effective_route_tuple()
|
||||
return {
|
||||
"public_key": self.public_key,
|
||||
"adv_name": self.name or "",
|
||||
"type": self.type,
|
||||
"flags": self.flags,
|
||||
"out_path": last_path,
|
||||
"out_path_len": last_path_len,
|
||||
"out_path_hash_mode": out_path_hash_mode,
|
||||
"out_path": effective_path,
|
||||
"out_path_len": effective_path_len,
|
||||
"out_path_hash_mode": effective_path_hash_mode,
|
||||
"adv_lat": self.lat if self.lat is not None else 0.0,
|
||||
"adv_lon": self.lon if self.lon is not None else 0.0,
|
||||
"last_advert": self.last_advert if self.last_advert is not None else 0,
|
||||
@@ -149,7 +222,7 @@ class ContactRoutingOverrideRequest(BaseModel):
|
||||
|
||||
route: str = Field(
|
||||
description=(
|
||||
"Blank clears the override and resets learned routing to flood, "
|
||||
"Blank clears the override, "
|
||||
'"-1" forces flood, "0" forces direct, and explicit routes are '
|
||||
"comma-separated 1/2/3-byte hop hex values"
|
||||
)
|
||||
@@ -158,6 +231,7 @@ class ContactRoutingOverrideRequest(BaseModel):
|
||||
|
||||
# Contact type constants
|
||||
CONTACT_TYPE_REPEATER = 2
|
||||
CONTACT_TYPE_ROOM = 3
|
||||
|
||||
|
||||
class ContactAdvertPath(BaseModel):
|
||||
|
||||
@@ -26,6 +26,7 @@ from app.decoder import (
|
||||
parse_packet,
|
||||
try_decrypt_dm,
|
||||
try_decrypt_packet_with_channel_key,
|
||||
try_decrypt_path,
|
||||
)
|
||||
from app.keystore import get_private_key, get_public_key, has_private_key
|
||||
from app.models import (
|
||||
@@ -44,6 +45,7 @@ from app.services.contact_reconciliation import (
|
||||
promote_prefix_contacts_for_contact,
|
||||
record_contact_name_and_reconcile,
|
||||
)
|
||||
from app.services.dm_ack_apply import apply_dm_ack_code
|
||||
from app.services.messages import (
|
||||
create_dm_message_from_decrypted as _create_dm_message_from_decrypted,
|
||||
)
|
||||
@@ -318,8 +320,7 @@ async def process_raw_packet(
|
||||
|
||||
elif payload_type == PayloadType.ADVERT:
|
||||
# Process all advert arrivals (even payload-hash duplicates) so the
|
||||
# path-freshness logic in _process_advertisement can pick the shortest
|
||||
# path heard within the freshness window.
|
||||
# advert-history table retains recent path observations.
|
||||
await _process_advertisement(raw_bytes, ts, packet_info)
|
||||
|
||||
elif payload_type == PayloadType.TEXT_MESSAGE:
|
||||
@@ -328,6 +329,9 @@ async def process_raw_packet(
|
||||
if decrypt_result:
|
||||
result.update(decrypt_result)
|
||||
|
||||
elif payload_type == PayloadType.PATH:
|
||||
await _process_path_packet(raw_bytes, ts, packet_info)
|
||||
|
||||
# Always broadcast raw packet for the packet feed UI (even duplicates)
|
||||
# This enables the frontend cracker to see all incoming packets in real-time
|
||||
broadcast_payload = RawPacketBroadcast(
|
||||
@@ -430,51 +434,20 @@ async def _process_advertisement(
|
||||
logger.debug("Failed to parse advertisement payload")
|
||||
return
|
||||
|
||||
# Extract path info from packet
|
||||
new_path_len = packet_info.path_length
|
||||
new_path_hex = packet_info.path.hex() if packet_info.path else ""
|
||||
|
||||
# Try to find existing contact
|
||||
existing = await ContactRepository.get_by_key(advert.public_key.lower())
|
||||
|
||||
# Determine which path to use: keep shorter path if heard recently (within 60s)
|
||||
# This handles advertisement echoes through different routes
|
||||
PATH_FRESHNESS_SECONDS = 60
|
||||
use_existing_path = False
|
||||
|
||||
if existing and existing.last_advert:
|
||||
path_age = timestamp - existing.last_advert
|
||||
existing_path_len = existing.last_path_len if existing.last_path_len >= 0 else float("inf")
|
||||
|
||||
# Keep existing path if it's fresh and shorter (or equal)
|
||||
if path_age <= PATH_FRESHNESS_SECONDS and existing_path_len <= new_path_len:
|
||||
use_existing_path = True
|
||||
logger.debug(
|
||||
"Keeping existing shorter path for %s (existing=%d, new=%d, age=%ds)",
|
||||
advert.public_key[:12],
|
||||
existing_path_len,
|
||||
new_path_len,
|
||||
path_age,
|
||||
)
|
||||
|
||||
if use_existing_path:
|
||||
assert existing is not None # Guaranteed by the conditions that set use_existing_path
|
||||
path_len = existing.last_path_len if existing.last_path_len is not None else -1
|
||||
path_hex = existing.last_path or ""
|
||||
out_path_hash_mode = existing.out_path_hash_mode
|
||||
else:
|
||||
path_len = new_path_len
|
||||
path_hex = new_path_hex
|
||||
out_path_hash_mode = packet_info.path_hash_size - 1
|
||||
|
||||
logger.debug(
|
||||
"Parsed advertisement from %s: %s (role=%d, lat=%s, lon=%s, path_len=%d)",
|
||||
"Parsed advertisement from %s: %s (role=%d, lat=%s, lon=%s, advert_path_len=%d)",
|
||||
advert.public_key[:12],
|
||||
advert.name,
|
||||
advert.device_role,
|
||||
advert.lat,
|
||||
advert.lon,
|
||||
path_len,
|
||||
new_path_len,
|
||||
)
|
||||
|
||||
# Use device_role from advertisement for contact type (1=Chat, 2=Repeater, 3=Room, 4=Sensor).
|
||||
@@ -501,9 +474,6 @@ async def _process_advertisement(
|
||||
lon=advert.lon,
|
||||
last_advert=timestamp,
|
||||
last_seen=timestamp,
|
||||
last_path=path_hex,
|
||||
last_path_len=path_len,
|
||||
out_path_hash_mode=out_path_hash_mode,
|
||||
first_seen=timestamp, # COALESCE in upsert preserves existing value
|
||||
)
|
||||
|
||||
@@ -667,3 +637,90 @@ async def _process_direct_message(
|
||||
# Couldn't decrypt with any known contact
|
||||
logger.debug("Could not decrypt DM with any of %d candidate contacts", len(candidate_contacts))
|
||||
return None
|
||||
|
||||
|
||||
async def _process_path_packet(
|
||||
raw_bytes: bytes,
|
||||
timestamp: int,
|
||||
packet_info: PacketInfo | None,
|
||||
) -> None:
|
||||
"""Process a PATH packet and update the learned direct route."""
|
||||
if not has_private_key():
|
||||
return
|
||||
|
||||
private_key = get_private_key()
|
||||
our_public_key = get_public_key()
|
||||
if private_key is None or our_public_key is None:
|
||||
return
|
||||
|
||||
if packet_info is None:
|
||||
packet_info = parse_packet(raw_bytes)
|
||||
if packet_info is None or packet_info.payload is None or len(packet_info.payload) < 4:
|
||||
return
|
||||
|
||||
dest_hash = format(packet_info.payload[0], "02x").lower()
|
||||
src_hash = format(packet_info.payload[1], "02x").lower()
|
||||
our_first_byte = format(our_public_key[0], "02x").lower()
|
||||
if dest_hash != our_first_byte:
|
||||
return
|
||||
|
||||
candidate_contacts = await ContactRepository.get_by_pubkey_first_byte(src_hash)
|
||||
if not candidate_contacts:
|
||||
logger.debug("No contacts found matching hash %s for PATH decryption", src_hash)
|
||||
return
|
||||
|
||||
for contact in candidate_contacts:
|
||||
if len(contact.public_key) != 64:
|
||||
continue
|
||||
try:
|
||||
contact_public_key = bytes.fromhex(contact.public_key)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
result = try_decrypt_path(
|
||||
raw_packet=raw_bytes,
|
||||
our_private_key=private_key,
|
||||
their_public_key=contact_public_key,
|
||||
our_public_key=our_public_key,
|
||||
)
|
||||
if result is None:
|
||||
continue
|
||||
|
||||
await ContactRepository.update_direct_path(
|
||||
contact.public_key,
|
||||
result.returned_path.hex(),
|
||||
result.returned_path_len,
|
||||
result.returned_path_hash_mode,
|
||||
updated_at=timestamp,
|
||||
)
|
||||
|
||||
if result.extra_type == PayloadType.ACK and len(result.extra) >= 4:
|
||||
ack_code = result.extra[:4].hex()
|
||||
matched = await apply_dm_ack_code(ack_code, broadcast_fn=broadcast_event)
|
||||
if matched:
|
||||
logger.info(
|
||||
"Applied bundled PATH ACK for %s via contact %s",
|
||||
ack_code,
|
||||
contact.public_key[:12],
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"Buffered bundled PATH ACK %s via contact %s",
|
||||
ack_code,
|
||||
contact.public_key[:12],
|
||||
)
|
||||
elif result.extra_type == PayloadType.RESPONSE and len(result.extra) > 0:
|
||||
logger.debug(
|
||||
"Observed bundled PATH RESPONSE from %s (%d bytes)",
|
||||
contact.public_key[:12],
|
||||
len(result.extra),
|
||||
)
|
||||
|
||||
refreshed_contact = await ContactRepository.get_by_key(contact.public_key)
|
||||
if refreshed_contact is not None:
|
||||
broadcast_event("contact", refreshed_contact.model_dump())
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
"Could not decrypt PATH packet with any of %d candidate contacts", len(candidate_contacts)
|
||||
)
|
||||
|
||||
@@ -153,12 +153,12 @@ def first_hop_hex(path_hex: str, hop_count: int) -> str | None:
|
||||
def normalize_contact_route(
|
||||
path_hex: str | None,
|
||||
path_len: int | None,
|
||||
out_path_hash_mode: int | None,
|
||||
path_hash_mode: int | None,
|
||||
) -> tuple[str, int, int]:
|
||||
"""Normalize stored contact route fields.
|
||||
|
||||
Handles legacy/bad rows where the packed wire path byte was stored directly
|
||||
in `last_path_len` (sometimes as a signed byte, e.g. `-125` for `0x83`).
|
||||
in the hop-count column (sometimes as a signed byte, e.g. `-125` for `0x83`).
|
||||
Returns `(path_hex, hop_count, hash_mode)`.
|
||||
"""
|
||||
normalized_path = path_hex or ""
|
||||
@@ -169,7 +169,7 @@ def normalize_contact_route(
|
||||
normalized_len = -1
|
||||
|
||||
try:
|
||||
normalized_mode = int(out_path_hash_mode) if out_path_hash_mode is not None else None
|
||||
normalized_mode = int(path_hash_mode) if path_hash_mode is not None else None
|
||||
except (TypeError, ValueError):
|
||||
normalized_mode = None
|
||||
|
||||
@@ -207,7 +207,7 @@ def normalize_contact_route(
|
||||
def normalize_route_override(
|
||||
path_hex: str | None,
|
||||
path_len: int | None,
|
||||
out_path_hash_mode: int | None,
|
||||
path_hash_mode: int | None,
|
||||
) -> tuple[str | None, int | None, int | None]:
|
||||
"""Normalize optional route-override fields while preserving the unset state."""
|
||||
if path_len is None:
|
||||
@@ -216,7 +216,7 @@ def normalize_route_override(
|
||||
normalized_path, normalized_len, normalized_mode = normalize_contact_route(
|
||||
path_hex,
|
||||
path_len,
|
||||
out_path_hash_mode,
|
||||
path_hash_mode,
|
||||
)
|
||||
return normalized_path, normalized_len, normalized_mode
|
||||
|
||||
|
||||
@@ -46,9 +46,10 @@ def _contact_sync_debug_fields(contact: Contact) -> dict[str, object]:
|
||||
return {
|
||||
"type": contact.type,
|
||||
"flags": contact.flags,
|
||||
"last_path": contact.last_path,
|
||||
"last_path_len": contact.last_path_len,
|
||||
"out_path_hash_mode": contact.out_path_hash_mode,
|
||||
"direct_path": contact.direct_path,
|
||||
"direct_path_len": contact.direct_path_len,
|
||||
"direct_path_hash_mode": contact.direct_path_hash_mode,
|
||||
"direct_path_updated_at": contact.direct_path_updated_at,
|
||||
"route_override_path": contact.route_override_path,
|
||||
"route_override_len": contact.route_override_len,
|
||||
"route_override_hash_mode": contact.route_override_hash_mode,
|
||||
|
||||
@@ -36,11 +36,20 @@ class ContactRepository:
|
||||
@staticmethod
|
||||
async def upsert(contact: ContactUpsert | Contact | Mapping[str, Any]) -> None:
|
||||
contact_row = ContactRepository._coerce_contact_upsert(contact)
|
||||
last_path, last_path_len, out_path_hash_mode = normalize_contact_route(
|
||||
contact_row.last_path,
|
||||
contact_row.last_path_len,
|
||||
contact_row.out_path_hash_mode,
|
||||
)
|
||||
if (
|
||||
contact_row.direct_path is None
|
||||
and contact_row.direct_path_len is None
|
||||
and contact_row.direct_path_hash_mode is None
|
||||
):
|
||||
direct_path = None
|
||||
direct_path_len = None
|
||||
direct_path_hash_mode = None
|
||||
else:
|
||||
direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route(
|
||||
contact_row.direct_path,
|
||||
contact_row.direct_path_len,
|
||||
contact_row.direct_path_hash_mode,
|
||||
)
|
||||
route_override_path, route_override_len, route_override_hash_mode = (
|
||||
normalize_route_override(
|
||||
contact_row.route_override_path,
|
||||
@@ -51,20 +60,25 @@ class ContactRepository:
|
||||
|
||||
await db.conn.execute(
|
||||
"""
|
||||
INSERT INTO contacts (public_key, name, type, flags, last_path, last_path_len,
|
||||
out_path_hash_mode,
|
||||
INSERT INTO contacts (public_key, name, type, flags, direct_path, direct_path_len,
|
||||
direct_path_hash_mode, direct_path_updated_at,
|
||||
route_override_path, route_override_len,
|
||||
route_override_hash_mode,
|
||||
last_advert, lat, lon, last_seen,
|
||||
on_radio, last_contacted, first_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(public_key) DO UPDATE SET
|
||||
name = COALESCE(excluded.name, contacts.name),
|
||||
type = CASE WHEN excluded.type = 0 THEN contacts.type ELSE excluded.type END,
|
||||
flags = excluded.flags,
|
||||
last_path = COALESCE(excluded.last_path, contacts.last_path),
|
||||
last_path_len = excluded.last_path_len,
|
||||
out_path_hash_mode = excluded.out_path_hash_mode,
|
||||
direct_path = COALESCE(excluded.direct_path, contacts.direct_path),
|
||||
direct_path_len = COALESCE(excluded.direct_path_len, contacts.direct_path_len),
|
||||
direct_path_hash_mode = COALESCE(
|
||||
excluded.direct_path_hash_mode, contacts.direct_path_hash_mode
|
||||
),
|
||||
direct_path_updated_at = COALESCE(
|
||||
excluded.direct_path_updated_at, contacts.direct_path_updated_at
|
||||
),
|
||||
route_override_path = COALESCE(
|
||||
excluded.route_override_path, contacts.route_override_path
|
||||
),
|
||||
@@ -87,9 +101,10 @@ class ContactRepository:
|
||||
contact_row.name,
|
||||
contact_row.type,
|
||||
contact_row.flags,
|
||||
last_path,
|
||||
last_path_len,
|
||||
out_path_hash_mode,
|
||||
direct_path,
|
||||
direct_path_len,
|
||||
direct_path_hash_mode,
|
||||
contact_row.direct_path_updated_at,
|
||||
route_override_path,
|
||||
route_override_len,
|
||||
route_override_hash_mode,
|
||||
@@ -107,12 +122,12 @@ class ContactRepository:
|
||||
@staticmethod
|
||||
def _row_to_contact(row) -> Contact:
|
||||
"""Convert a database row to a Contact model."""
|
||||
last_path, last_path_len, out_path_hash_mode = normalize_contact_route(
|
||||
row["last_path"],
|
||||
row["last_path_len"],
|
||||
row["out_path_hash_mode"],
|
||||
)
|
||||
available_columns = set(row.keys())
|
||||
direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route(
|
||||
row["direct_path"] if "direct_path" in available_columns else None,
|
||||
row["direct_path_len"] if "direct_path_len" in available_columns else None,
|
||||
row["direct_path_hash_mode"] if "direct_path_hash_mode" in available_columns else None,
|
||||
)
|
||||
route_override_path = (
|
||||
row["route_override_path"] if "route_override_path" in available_columns else None
|
||||
)
|
||||
@@ -136,9 +151,14 @@ class ContactRepository:
|
||||
name=row["name"],
|
||||
type=row["type"],
|
||||
flags=row["flags"],
|
||||
last_path=last_path,
|
||||
last_path_len=last_path_len,
|
||||
out_path_hash_mode=out_path_hash_mode,
|
||||
direct_path=direct_path,
|
||||
direct_path_len=direct_path_len,
|
||||
direct_path_hash_mode=direct_path_hash_mode,
|
||||
direct_path_updated_at=(
|
||||
row["direct_path_updated_at"]
|
||||
if "direct_path_updated_at" in available_columns
|
||||
else None
|
||||
),
|
||||
route_override_path=route_override_path,
|
||||
route_override_len=route_override_len,
|
||||
route_override_hash_mode=route_override_hash_mode,
|
||||
@@ -286,26 +306,30 @@ class ContactRepository:
|
||||
return [ContactRepository._row_to_contact(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def update_path(
|
||||
async def update_direct_path(
|
||||
public_key: str,
|
||||
path: str,
|
||||
path_len: int,
|
||||
out_path_hash_mode: int | None = None,
|
||||
path_hash_mode: int | None = None,
|
||||
updated_at: int | None = None,
|
||||
) -> None:
|
||||
normalized_path, normalized_path_len, normalized_hash_mode = normalize_contact_route(
|
||||
path,
|
||||
path_len,
|
||||
out_path_hash_mode,
|
||||
path_hash_mode,
|
||||
)
|
||||
ts = updated_at if updated_at is not None else int(time.time())
|
||||
await db.conn.execute(
|
||||
"""UPDATE contacts SET last_path = ?, last_path_len = ?,
|
||||
out_path_hash_mode = COALESCE(?, out_path_hash_mode),
|
||||
"""UPDATE contacts SET direct_path = ?, direct_path_len = ?,
|
||||
direct_path_hash_mode = COALESCE(?, direct_path_hash_mode),
|
||||
direct_path_updated_at = ?,
|
||||
last_seen = ? WHERE public_key = ?""",
|
||||
(
|
||||
normalized_path,
|
||||
normalized_path_len,
|
||||
normalized_hash_mode,
|
||||
int(time.time()),
|
||||
ts,
|
||||
ts,
|
||||
public_key.lower(),
|
||||
),
|
||||
)
|
||||
@@ -316,12 +340,12 @@ class ContactRepository:
|
||||
public_key: str,
|
||||
path: str | None,
|
||||
path_len: int | None,
|
||||
out_path_hash_mode: int | None = None,
|
||||
path_hash_mode: int | None = None,
|
||||
) -> None:
|
||||
normalized_path, normalized_len, normalized_hash_mode = normalize_route_override(
|
||||
path,
|
||||
path_len,
|
||||
out_path_hash_mode,
|
||||
path_hash_mode,
|
||||
)
|
||||
await db.conn.execute(
|
||||
"""
|
||||
|
||||
@@ -304,7 +304,6 @@ async def create_contact(
|
||||
contact_upsert = ContactUpsert(
|
||||
public_key=lower_key,
|
||||
name=request.name,
|
||||
out_path_hash_mode=-1,
|
||||
on_radio=False,
|
||||
)
|
||||
await ContactRepository.upsert(contact_upsert)
|
||||
@@ -474,7 +473,7 @@ async def request_path_discovery(public_key: str) -> PathDiscoveryResponse:
|
||||
return_len = int(payload.get("in_path_len") or 0)
|
||||
return_mode = _path_hash_mode_from_hop_width(payload.get("in_path_hash_len"))
|
||||
|
||||
await ContactRepository.update_path(
|
||||
await ContactRepository.update_direct_path(
|
||||
contact.public_key,
|
||||
forward_path,
|
||||
forward_len,
|
||||
@@ -524,9 +523,8 @@ async def set_contact_routing_override(
|
||||
route_text = request.route.strip()
|
||||
if route_text == "":
|
||||
await ContactRepository.clear_routing_override(contact.public_key)
|
||||
await ContactRepository.update_path(contact.public_key, "", -1, -1)
|
||||
logger.info(
|
||||
"Cleared routing override and reset learned path to flood for %s",
|
||||
"Cleared routing override for %s",
|
||||
contact.public_key[:12],
|
||||
)
|
||||
elif route_text == "-1":
|
||||
|
||||
@@ -78,6 +78,7 @@ class DebugChannelAudit(BaseModel):
|
||||
class DebugRadioProbe(BaseModel):
|
||||
performed: bool
|
||||
errors: list[str] = Field(default_factory=list)
|
||||
multi_acks_enabled: bool | None = None
|
||||
self_info: dict[str, Any] | None = None
|
||||
device_info: dict[str, Any] | None = None
|
||||
stats_core: dict[str, Any] | None = None
|
||||
@@ -234,6 +235,9 @@ async def _probe_radio() -> DebugRadioProbe:
|
||||
return DebugRadioProbe(
|
||||
performed=True,
|
||||
errors=errors,
|
||||
multi_acks_enabled=bool(mc.self_info.get("multi_acks", 0))
|
||||
if mc.self_info is not None
|
||||
else None,
|
||||
self_info=dict(mc.self_info or {}),
|
||||
device_info=device_info,
|
||||
stats_core=stats_core,
|
||||
|
||||
@@ -81,6 +81,10 @@ class RadioConfigResponse(BaseModel):
|
||||
default="current",
|
||||
description="Whether adverts include the node's current location state",
|
||||
)
|
||||
multi_acks_enabled: bool = Field(
|
||||
default=False,
|
||||
description="Whether the radio sends an extra direct ACK transmission",
|
||||
)
|
||||
|
||||
|
||||
class RadioConfigUpdate(BaseModel):
|
||||
@@ -99,6 +103,10 @@ class RadioConfigUpdate(BaseModel):
|
||||
default=None,
|
||||
description="Whether adverts include the node's current location state",
|
||||
)
|
||||
multi_acks_enabled: bool | None = Field(
|
||||
default=None,
|
||||
description="Whether the radio sends an extra direct ACK transmission",
|
||||
)
|
||||
|
||||
|
||||
class PrivateKeyUpdate(BaseModel):
|
||||
@@ -222,6 +230,7 @@ async def get_radio_config() -> RadioConfigResponse:
|
||||
path_hash_mode=radio_manager.path_hash_mode,
|
||||
path_hash_mode_supported=radio_manager.path_hash_mode_supported,
|
||||
advert_location_source=advert_location_source,
|
||||
multi_acks_enabled=bool(info.get("multi_acks", 0)),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
@@ -28,6 +27,14 @@ from app.models import (
|
||||
)
|
||||
from app.repository import ContactRepository
|
||||
from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404
|
||||
from app.routers.server_control import (
|
||||
_monotonic,
|
||||
batch_cli_fetch,
|
||||
extract_response_text,
|
||||
prepare_authenticated_contact_connection,
|
||||
require_server_capable_contact,
|
||||
send_contact_cli_command,
|
||||
)
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -43,39 +50,11 @@ ACL_PERMISSION_NAMES = {
|
||||
3: "Admin",
|
||||
}
|
||||
router = APIRouter(prefix="/contacts", tags=["repeaters"])
|
||||
|
||||
REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS = 5.0
|
||||
REPEATER_LOGIN_REJECTED_MESSAGE = (
|
||||
"The repeater replied but did not confirm this login. "
|
||||
"Existing access may still allow some repeater operations, but admin actions may fail."
|
||||
)
|
||||
REPEATER_LOGIN_SEND_FAILED_MESSAGE = (
|
||||
"The login request could not be sent to the repeater. "
|
||||
"The dashboard is still available, but repeater operations may fail until a login succeeds."
|
||||
)
|
||||
REPEATER_LOGIN_TIMEOUT_MESSAGE = (
|
||||
"No login confirmation was heard from the repeater. "
|
||||
"On current repeater firmware, that can mean the password was wrong, "
|
||||
"blank-password login was not allowed by the ACL, or the reply was missed in transit. "
|
||||
"The dashboard is still available; try logging in again if admin actions fail."
|
||||
)
|
||||
|
||||
|
||||
def _monotonic() -> float:
|
||||
"""Wrapper around time.monotonic() for testability.
|
||||
|
||||
Patching time.monotonic directly breaks the asyncio event loop which also
|
||||
uses it. This indirection allows tests to control the clock safely.
|
||||
"""
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
def _extract_response_text(event) -> str:
|
||||
"""Extract text from a CLI response event, stripping the firmware '> ' prefix."""
|
||||
text = event.payload.get("text", str(event.payload))
|
||||
if text.startswith("> "):
|
||||
text = text[2:]
|
||||
return text
|
||||
return extract_response_text(event)
|
||||
|
||||
|
||||
async def _fetch_repeater_response(
|
||||
@@ -83,21 +62,6 @@ async def _fetch_repeater_response(
|
||||
target_pubkey_prefix: str,
|
||||
timeout: float = 20.0,
|
||||
) -> "Event | None":
|
||||
"""Fetch a CLI response from a specific repeater via a validated get_msg() loop.
|
||||
|
||||
Calls get_msg() repeatedly until a matching CLI response (txt_type=1) from the
|
||||
target repeater arrives or the wall-clock deadline expires. Unrelated messages
|
||||
are safe to skip — meshcore's event dispatcher already delivers them to the
|
||||
normal subscription handlers (on_contact_message, etc.) when get_msg() returns.
|
||||
|
||||
Args:
|
||||
mc: MeshCore instance
|
||||
target_pubkey_prefix: 12-char hex prefix of the repeater's public key
|
||||
timeout: Wall-clock seconds before giving up
|
||||
|
||||
Returns:
|
||||
The matching Event, or None if no response arrived before the deadline.
|
||||
"""
|
||||
deadline = _monotonic() + timeout
|
||||
|
||||
while _monotonic() < deadline:
|
||||
@@ -105,13 +69,12 @@ async def _fetch_repeater_response(
|
||||
result = await mc.commands.get_msg(timeout=2.0)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug("get_msg() exception: %s", e)
|
||||
except Exception as exc:
|
||||
logger.debug("get_msg() exception: %s", exc)
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
|
||||
if result.type == EventType.NO_MORE_MSGS:
|
||||
# No messages queued yet — wait and retry
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
|
||||
@@ -125,8 +88,6 @@ async def _fetch_repeater_response(
|
||||
txt_type = result.payload.get("txt_type", 0)
|
||||
if msg_prefix == target_pubkey_prefix and txt_type == 1:
|
||||
return result
|
||||
# Not our target — already dispatched to subscribers by meshcore,
|
||||
# so just continue draining the queue.
|
||||
logger.debug(
|
||||
"Skipping non-target message (from=%s, txt_type=%d) while waiting for %s",
|
||||
msg_prefix,
|
||||
@@ -136,7 +97,6 @@ async def _fetch_repeater_response(
|
||||
continue
|
||||
|
||||
if result.type == EventType.CHANNEL_MSG_RECV:
|
||||
# Already dispatched to subscribers by meshcore; skip.
|
||||
logger.debug(
|
||||
"Skipping channel message (channel_idx=%s) during repeater fetch",
|
||||
result.payload.get("channel_idx"),
|
||||
@@ -150,87 +110,13 @@ async def _fetch_repeater_response(
|
||||
|
||||
|
||||
async def prepare_repeater_connection(mc, contact: Contact, password: str) -> RepeaterLoginResponse:
|
||||
"""Prepare connection to a repeater by adding to radio and attempting login.
|
||||
|
||||
Args:
|
||||
mc: MeshCore instance
|
||||
contact: The repeater contact
|
||||
password: Password for login (empty string for no password)
|
||||
"""
|
||||
pubkey_prefix = contact.public_key[:12].lower()
|
||||
loop = asyncio.get_running_loop()
|
||||
login_future = loop.create_future()
|
||||
|
||||
def _resolve_login(event_type: EventType, message: str | None = None) -> None:
|
||||
if login_future.done():
|
||||
return
|
||||
login_future.set_result(
|
||||
RepeaterLoginResponse(
|
||||
status="ok" if event_type == EventType.LOGIN_SUCCESS else "error",
|
||||
authenticated=event_type == EventType.LOGIN_SUCCESS,
|
||||
message=message,
|
||||
)
|
||||
)
|
||||
|
||||
success_subscription = mc.subscribe(
|
||||
EventType.LOGIN_SUCCESS,
|
||||
lambda _event: _resolve_login(EventType.LOGIN_SUCCESS),
|
||||
attribute_filters={"pubkey_prefix": pubkey_prefix},
|
||||
return await prepare_authenticated_contact_connection(
|
||||
mc,
|
||||
contact,
|
||||
password,
|
||||
label="repeater",
|
||||
response_timeout=REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS,
|
||||
)
|
||||
failed_subscription = mc.subscribe(
|
||||
EventType.LOGIN_FAILED,
|
||||
lambda _event: _resolve_login(
|
||||
EventType.LOGIN_FAILED,
|
||||
REPEATER_LOGIN_REJECTED_MESSAGE,
|
||||
),
|
||||
attribute_filters={"pubkey_prefix": pubkey_prefix},
|
||||
)
|
||||
|
||||
# Add contact to radio with path from DB (non-fatal — contact may already be loaded)
|
||||
try:
|
||||
logger.info("Adding repeater %s to radio", contact.public_key[:12])
|
||||
await _ensure_on_radio(mc, contact)
|
||||
|
||||
logger.info("Sending login to repeater %s", contact.public_key[:12])
|
||||
login_result = await mc.commands.send_login(contact.public_key, password)
|
||||
|
||||
if login_result.type == EventType.ERROR:
|
||||
return RepeaterLoginResponse(
|
||||
status="error",
|
||||
authenticated=False,
|
||||
message=f"{REPEATER_LOGIN_SEND_FAILED_MESSAGE} ({login_result.payload})",
|
||||
)
|
||||
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
login_future,
|
||||
timeout=REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
"No login response from repeater %s within %.1fs",
|
||||
contact.public_key[:12],
|
||||
REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS,
|
||||
)
|
||||
return RepeaterLoginResponse(
|
||||
status="timeout",
|
||||
authenticated=False,
|
||||
message=REPEATER_LOGIN_TIMEOUT_MESSAGE,
|
||||
)
|
||||
except HTTPException as exc:
|
||||
logger.warning(
|
||||
"Repeater login setup failed for %s: %s",
|
||||
contact.public_key[:12],
|
||||
exc.detail,
|
||||
)
|
||||
return RepeaterLoginResponse(
|
||||
status="error",
|
||||
authenticated=False,
|
||||
message=f"{REPEATER_LOGIN_SEND_FAILED_MESSAGE} ({exc.detail})",
|
||||
)
|
||||
finally:
|
||||
success_subscription.unsubscribe()
|
||||
failed_subscription.unsubscribe()
|
||||
|
||||
|
||||
def _require_repeater(contact: Contact) -> None:
|
||||
@@ -403,43 +289,7 @@ async def _batch_cli_fetch(
|
||||
operation_name: str,
|
||||
commands: list[tuple[str, str]],
|
||||
) -> dict[str, str | None]:
|
||||
"""Send a batch of CLI commands to a repeater and collect responses.
|
||||
|
||||
Opens a radio operation with polling paused and auto-fetch suspended (since
|
||||
we call get_msg() directly via _fetch_repeater_response), adds the contact
|
||||
to the radio for routing, then sends each command sequentially with a 1-second
|
||||
gap between them.
|
||||
|
||||
Returns a dict mapping field names to response strings (or None on timeout).
|
||||
"""
|
||||
results: dict[str, str | None] = {field: None for _, field in commands}
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
operation_name,
|
||||
pause_polling=True,
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
await _ensure_on_radio(mc, contact)
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
for i, (cmd, field) in enumerate(commands):
|
||||
if i > 0:
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
send_result = await mc.commands.send_cmd(contact.public_key, cmd)
|
||||
if send_result.type == EventType.ERROR:
|
||||
logger.debug("Command '%s' send error: %s", cmd, send_result.payload)
|
||||
continue
|
||||
|
||||
response_event = await _fetch_repeater_response(
|
||||
mc, contact.public_key[:12], timeout=10.0
|
||||
)
|
||||
if response_event is not None:
|
||||
results[field] = _extract_response_text(response_event)
|
||||
else:
|
||||
logger.warning("No response for command '%s' (%s)", cmd, field)
|
||||
|
||||
return results
|
||||
return await batch_cli_fetch(contact, operation_name, commands)
|
||||
|
||||
|
||||
@router.post("/{public_key}/repeater/node-info", response_model=RepeaterNodeInfoResponse)
|
||||
@@ -524,72 +374,13 @@ async def repeater_owner_info(public_key: str) -> RepeaterOwnerInfoResponse:
|
||||
|
||||
@router.post("/{public_key}/command", response_model=CommandResponse)
|
||||
async def send_repeater_command(public_key: str, request: CommandRequest) -> CommandResponse:
|
||||
"""Send a CLI command to a repeater.
|
||||
|
||||
The contact must be a repeater (type=2). The user must have already logged in
|
||||
via the repeater/login endpoint. This endpoint ensures the contact is on the
|
||||
radio before sending commands (the repeater remembers ACL permissions after login).
|
||||
|
||||
Common commands:
|
||||
- get name, set name <value>
|
||||
- get tx, set tx <dbm>
|
||||
- get radio, set radio <freq,bw,sf,cr>
|
||||
- tempradio <freq,bw,sf,cr,minutes>
|
||||
- setperm <pubkey> <permission> (0=guest, 1=read-only, 2=read-write, 3=admin)
|
||||
- clock, clock sync, time <epoch_seconds>
|
||||
- reboot
|
||||
- ver
|
||||
"""
|
||||
"""Send a CLI command to a repeater or room server."""
|
||||
require_connected()
|
||||
|
||||
# Get contact from database
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
"send_repeater_command",
|
||||
pause_polling=True,
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
# Add contact to radio with path from DB (non-fatal — contact may already be loaded)
|
||||
logger.info("Adding repeater %s to radio", contact.public_key[:12])
|
||||
await _ensure_on_radio(mc, contact)
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
# Send the command
|
||||
logger.info("Sending command to repeater %s: %s", contact.public_key[:12], request.command)
|
||||
|
||||
send_result = await mc.commands.send_cmd(contact.public_key, request.command)
|
||||
|
||||
if send_result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to send command: {send_result.payload}"
|
||||
)
|
||||
|
||||
# Wait for response using validated fetch loop
|
||||
response_event = await _fetch_repeater_response(mc, contact.public_key[:12])
|
||||
|
||||
if response_event is None:
|
||||
logger.warning(
|
||||
"No response from repeater %s for command: %s",
|
||||
contact.public_key[:12],
|
||||
request.command,
|
||||
)
|
||||
return CommandResponse(
|
||||
command=request.command,
|
||||
response="(no response - command may have been processed)",
|
||||
)
|
||||
|
||||
# CONTACT_MSG_RECV payloads use sender_timestamp in meshcore.
|
||||
response_text = _extract_response_text(response_event)
|
||||
sender_timestamp = response_event.payload.get(
|
||||
"sender_timestamp",
|
||||
response_event.payload.get("timestamp"),
|
||||
)
|
||||
logger.info("Received response from %s: %s", contact.public_key[:12], response_text)
|
||||
|
||||
return CommandResponse(
|
||||
command=request.command,
|
||||
response=response_text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
)
|
||||
require_server_capable_contact(contact)
|
||||
return await send_contact_cli_command(
|
||||
contact,
|
||||
request.command,
|
||||
operation_name="send_repeater_command",
|
||||
)
|
||||
|
||||
145
app/routers/rooms.py
Normal file
145
app/routers/rooms.py
Normal file
@@ -0,0 +1,145 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.models import (
|
||||
CONTACT_TYPE_ROOM,
|
||||
AclEntry,
|
||||
LppSensor,
|
||||
RepeaterAclResponse,
|
||||
RepeaterLoginRequest,
|
||||
RepeaterLoginResponse,
|
||||
RepeaterLppTelemetryResponse,
|
||||
RepeaterStatusResponse,
|
||||
)
|
||||
from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404
|
||||
from app.routers.server_control import (
|
||||
prepare_authenticated_contact_connection,
|
||||
require_server_capable_contact,
|
||||
)
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
router = APIRouter(prefix="/contacts", tags=["rooms"])
|
||||
|
||||
|
||||
def _require_room(contact) -> None:
|
||||
require_server_capable_contact(contact, allowed_types=(CONTACT_TYPE_ROOM,))
|
||||
|
||||
|
||||
@router.post("/{public_key}/room/login", response_model=RepeaterLoginResponse)
|
||||
async def room_login(public_key: str, request: RepeaterLoginRequest) -> RepeaterLoginResponse:
|
||||
"""Attempt room-server login and report whether auth was confirmed."""
|
||||
require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_room(contact)
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
"room_login",
|
||||
pause_polling=True,
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
return await prepare_authenticated_contact_connection(
|
||||
mc,
|
||||
contact,
|
||||
request.password,
|
||||
label="room server",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{public_key}/room/status", response_model=RepeaterStatusResponse)
|
||||
async def room_status(public_key: str) -> RepeaterStatusResponse:
|
||||
"""Fetch status telemetry from a room server."""
|
||||
require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_room(contact)
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
"room_status", pause_polling=True, suspend_auto_fetch=True
|
||||
) as mc:
|
||||
await _ensure_on_radio(mc, contact)
|
||||
status = await mc.commands.req_status_sync(contact.public_key, timeout=10, min_timeout=5)
|
||||
|
||||
if status is None:
|
||||
raise HTTPException(status_code=504, detail="No status response from room server")
|
||||
|
||||
return RepeaterStatusResponse(
|
||||
battery_volts=status.get("bat", 0) / 1000.0,
|
||||
tx_queue_len=status.get("tx_queue_len", 0),
|
||||
noise_floor_dbm=status.get("noise_floor", 0),
|
||||
last_rssi_dbm=status.get("last_rssi", 0),
|
||||
last_snr_db=status.get("last_snr", 0.0),
|
||||
packets_received=status.get("nb_recv", 0),
|
||||
packets_sent=status.get("nb_sent", 0),
|
||||
airtime_seconds=status.get("airtime", 0),
|
||||
rx_airtime_seconds=status.get("rx_airtime", 0),
|
||||
uptime_seconds=status.get("uptime", 0),
|
||||
sent_flood=status.get("sent_flood", 0),
|
||||
sent_direct=status.get("sent_direct", 0),
|
||||
recv_flood=status.get("recv_flood", 0),
|
||||
recv_direct=status.get("recv_direct", 0),
|
||||
flood_dups=status.get("flood_dups", 0),
|
||||
direct_dups=status.get("direct_dups", 0),
|
||||
full_events=status.get("full_evts", 0),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{public_key}/room/lpp-telemetry", response_model=RepeaterLppTelemetryResponse)
|
||||
async def room_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
|
||||
"""Fetch CayenneLPP telemetry from a room server."""
|
||||
require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_room(contact)
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
"room_lpp_telemetry", pause_polling=True, suspend_auto_fetch=True
|
||||
) as mc:
|
||||
await _ensure_on_radio(mc, contact)
|
||||
telemetry = await mc.commands.req_telemetry_sync(
|
||||
contact.public_key, timeout=10, min_timeout=5
|
||||
)
|
||||
|
||||
if telemetry is None:
|
||||
raise HTTPException(status_code=504, detail="No telemetry response from room server")
|
||||
|
||||
sensors = [
|
||||
LppSensor(
|
||||
channel=entry.get("channel", 0),
|
||||
type_name=str(entry.get("type", "unknown")),
|
||||
value=entry.get("value", 0),
|
||||
)
|
||||
for entry in telemetry
|
||||
]
|
||||
return RepeaterLppTelemetryResponse(sensors=sensors)
|
||||
|
||||
|
||||
@router.post("/{public_key}/room/acl", response_model=RepeaterAclResponse)
|
||||
async def room_acl(public_key: str) -> RepeaterAclResponse:
|
||||
"""Fetch ACL entries from a room server."""
|
||||
require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_room(contact)
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
"room_acl", pause_polling=True, suspend_auto_fetch=True
|
||||
) as mc:
|
||||
await _ensure_on_radio(mc, contact)
|
||||
acl_data = await mc.commands.req_acl_sync(contact.public_key, timeout=10, min_timeout=5)
|
||||
|
||||
acl_entries = []
|
||||
if acl_data and isinstance(acl_data, list):
|
||||
from app.repository import ContactRepository
|
||||
from app.routers.repeaters import ACL_PERMISSION_NAMES
|
||||
|
||||
for entry in acl_data:
|
||||
pubkey_prefix = entry.get("key", "")
|
||||
perm = entry.get("perm", 0)
|
||||
resolved_contact = await ContactRepository.get_by_key_prefix(pubkey_prefix)
|
||||
acl_entries.append(
|
||||
AclEntry(
|
||||
pubkey_prefix=pubkey_prefix,
|
||||
name=resolved_contact.name if resolved_contact else None,
|
||||
permission=perm,
|
||||
permission_name=ACL_PERMISSION_NAMES.get(perm, f"Unknown({perm})"),
|
||||
)
|
||||
)
|
||||
|
||||
return RepeaterAclResponse(acl=acl_entries)
|
||||
317
app/routers/server_control.py
Normal file
317
app/routers/server_control.py
Normal file
@@ -0,0 +1,317 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastapi import HTTPException
|
||||
from meshcore import EventType
|
||||
|
||||
from app.models import (
|
||||
CONTACT_TYPE_REPEATER,
|
||||
CONTACT_TYPE_ROOM,
|
||||
CommandResponse,
|
||||
Contact,
|
||||
RepeaterLoginResponse,
|
||||
)
|
||||
from app.routers.contacts import _ensure_on_radio
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from meshcore.events import Event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SERVER_LOGIN_RESPONSE_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
|
||||
def _monotonic() -> float:
|
||||
"""Wrapper around time.monotonic() for testability."""
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
def get_server_contact_label(contact: Contact) -> str:
|
||||
"""Return a user-facing label for server-capable contacts."""
|
||||
if contact.type == CONTACT_TYPE_REPEATER:
|
||||
return "repeater"
|
||||
if contact.type == CONTACT_TYPE_ROOM:
|
||||
return "room server"
|
||||
return "server"
|
||||
|
||||
|
||||
def require_server_capable_contact(
|
||||
contact: Contact,
|
||||
*,
|
||||
allowed_types: tuple[int, ...] = (CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM),
|
||||
) -> None:
|
||||
"""Raise 400 if the contact does not support server control/login features."""
|
||||
if contact.type not in allowed_types:
|
||||
expected = ", ".join(str(value) for value in allowed_types)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Contact is not a supported server contact (type={contact.type}, expected one of {expected})",
|
||||
)
|
||||
|
||||
|
||||
def _login_rejected_message(label: str) -> str:
|
||||
return (
|
||||
f"The {label} replied but did not confirm this login. "
|
||||
f"Existing access may still allow some {label} operations, but privileged actions may fail."
|
||||
)
|
||||
|
||||
|
||||
def _login_send_failed_message(label: str) -> str:
|
||||
return (
|
||||
f"The login request could not be sent to the {label}. "
|
||||
f"The control panel is still available, but authenticated actions may fail until a login succeeds."
|
||||
)
|
||||
|
||||
|
||||
def _login_timeout_message(label: str) -> str:
|
||||
return (
|
||||
f"No login confirmation was heard from the {label}. "
|
||||
"That can mean the password was wrong or the reply was missed in transit. "
|
||||
"The control panel is still available; try logging in again if authenticated actions fail."
|
||||
)
|
||||
|
||||
|
||||
def extract_response_text(event) -> str:
|
||||
"""Extract text from a CLI response event, stripping the firmware '> ' prefix."""
|
||||
text = event.payload.get("text", str(event.payload))
|
||||
if text.startswith("> "):
|
||||
text = text[2:]
|
||||
return text
|
||||
|
||||
|
||||
async def fetch_contact_cli_response(
|
||||
mc,
|
||||
target_pubkey_prefix: str,
|
||||
timeout: float = 20.0,
|
||||
) -> "Event | None":
|
||||
"""Fetch a CLI response from a specific contact via a validated get_msg() loop."""
|
||||
deadline = _monotonic() + timeout
|
||||
|
||||
while _monotonic() < deadline:
|
||||
try:
|
||||
result = await mc.commands.get_msg(timeout=2.0)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
except Exception as exc:
|
||||
logger.debug("get_msg() exception: %s", exc)
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
|
||||
if result.type == EventType.NO_MORE_MSGS:
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
logger.debug("get_msg() error: %s", result.payload)
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
|
||||
if result.type == EventType.CONTACT_MSG_RECV:
|
||||
msg_prefix = result.payload.get("pubkey_prefix", "")
|
||||
txt_type = result.payload.get("txt_type", 0)
|
||||
if msg_prefix == target_pubkey_prefix and txt_type == 1:
|
||||
return result
|
||||
logger.debug(
|
||||
"Skipping non-target message (from=%s, txt_type=%d) while waiting for %s",
|
||||
msg_prefix,
|
||||
txt_type,
|
||||
target_pubkey_prefix,
|
||||
)
|
||||
continue
|
||||
|
||||
if result.type == EventType.CHANNEL_MSG_RECV:
|
||||
logger.debug(
|
||||
"Skipping channel message (channel_idx=%s) during CLI fetch",
|
||||
result.payload.get("channel_idx"),
|
||||
)
|
||||
continue
|
||||
|
||||
logger.debug("Unexpected event type %s during CLI fetch, skipping", result.type)
|
||||
|
||||
logger.warning("No CLI response from contact %s within %.1fs", target_pubkey_prefix, timeout)
|
||||
return None
|
||||
|
||||
|
||||
async def prepare_authenticated_contact_connection(
|
||||
mc,
|
||||
contact: Contact,
|
||||
password: str,
|
||||
*,
|
||||
label: str | None = None,
|
||||
response_timeout: float = SERVER_LOGIN_RESPONSE_TIMEOUT_SECONDS,
|
||||
) -> RepeaterLoginResponse:
|
||||
"""Prepare connection to a server-capable contact by adding it to the radio and logging in."""
|
||||
pubkey_prefix = contact.public_key[:12].lower()
|
||||
contact_label = label or get_server_contact_label(contact)
|
||||
loop = asyncio.get_running_loop()
|
||||
login_future = loop.create_future()
|
||||
|
||||
def _resolve_login(event_type: EventType, message: str | None = None) -> None:
|
||||
if login_future.done():
|
||||
return
|
||||
login_future.set_result(
|
||||
RepeaterLoginResponse(
|
||||
status="ok" if event_type == EventType.LOGIN_SUCCESS else "error",
|
||||
authenticated=event_type == EventType.LOGIN_SUCCESS,
|
||||
message=message,
|
||||
)
|
||||
)
|
||||
|
||||
success_subscription = mc.subscribe(
|
||||
EventType.LOGIN_SUCCESS,
|
||||
lambda _event: _resolve_login(EventType.LOGIN_SUCCESS),
|
||||
attribute_filters={"pubkey_prefix": pubkey_prefix},
|
||||
)
|
||||
failed_subscription = mc.subscribe(
|
||||
EventType.LOGIN_FAILED,
|
||||
lambda _event: _resolve_login(
|
||||
EventType.LOGIN_FAILED,
|
||||
_login_rejected_message(contact_label),
|
||||
),
|
||||
attribute_filters={"pubkey_prefix": pubkey_prefix},
|
||||
)
|
||||
|
||||
try:
|
||||
logger.info("Adding %s %s to radio", contact_label, contact.public_key[:12])
|
||||
await _ensure_on_radio(mc, contact)
|
||||
|
||||
logger.info("Sending login to %s %s", contact_label, contact.public_key[:12])
|
||||
login_result = await mc.commands.send_login(contact.public_key, password)
|
||||
|
||||
if login_result.type == EventType.ERROR:
|
||||
return RepeaterLoginResponse(
|
||||
status="error",
|
||||
authenticated=False,
|
||||
message=f"{_login_send_failed_message(contact_label)} ({login_result.payload})",
|
||||
)
|
||||
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
login_future,
|
||||
timeout=response_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
"No login response from %s %s within %.1fs",
|
||||
contact_label,
|
||||
contact.public_key[:12],
|
||||
response_timeout,
|
||||
)
|
||||
return RepeaterLoginResponse(
|
||||
status="timeout",
|
||||
authenticated=False,
|
||||
message=_login_timeout_message(contact_label),
|
||||
)
|
||||
except HTTPException as exc:
|
||||
logger.warning(
|
||||
"%s login setup failed for %s: %s",
|
||||
contact_label.capitalize(),
|
||||
contact.public_key[:12],
|
||||
exc.detail,
|
||||
)
|
||||
return RepeaterLoginResponse(
|
||||
status="error",
|
||||
authenticated=False,
|
||||
message=f"{_login_send_failed_message(contact_label)} ({exc.detail})",
|
||||
)
|
||||
finally:
|
||||
success_subscription.unsubscribe()
|
||||
failed_subscription.unsubscribe()
|
||||
|
||||
|
||||
async def batch_cli_fetch(
|
||||
contact: Contact,
|
||||
operation_name: str,
|
||||
commands: list[tuple[str, str]],
|
||||
) -> dict[str, str | None]:
|
||||
"""Send a batch of CLI commands to a server-capable contact and collect responses."""
|
||||
results: dict[str, str | None] = {field: None for _, field in commands}
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
operation_name,
|
||||
pause_polling=True,
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
await _ensure_on_radio(mc, contact)
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
for index, (cmd, field) in enumerate(commands):
|
||||
if index > 0:
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
send_result = await mc.commands.send_cmd(contact.public_key, cmd)
|
||||
if send_result.type == EventType.ERROR:
|
||||
logger.debug("Command '%s' send error: %s", cmd, send_result.payload)
|
||||
continue
|
||||
|
||||
response_event = await fetch_contact_cli_response(
|
||||
mc, contact.public_key[:12], timeout=10.0
|
||||
)
|
||||
if response_event is not None:
|
||||
results[field] = extract_response_text(response_event)
|
||||
else:
|
||||
logger.warning("No response for command '%s' (%s)", cmd, field)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def send_contact_cli_command(
|
||||
contact: Contact,
|
||||
command: str,
|
||||
*,
|
||||
operation_name: str,
|
||||
) -> CommandResponse:
|
||||
"""Send a CLI command to a server-capable contact and return the text response."""
|
||||
label = get_server_contact_label(contact)
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
operation_name,
|
||||
pause_polling=True,
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
logger.info("Adding %s %s to radio", label, contact.public_key[:12])
|
||||
await _ensure_on_radio(mc, contact)
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
logger.info("Sending command to %s %s: %s", label, contact.public_key[:12], command)
|
||||
send_result = await mc.commands.send_cmd(contact.public_key, command)
|
||||
|
||||
if send_result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to send command: {send_result.payload}"
|
||||
)
|
||||
|
||||
response_event = await fetch_contact_cli_response(mc, contact.public_key[:12])
|
||||
|
||||
if response_event is None:
|
||||
logger.warning(
|
||||
"No response from %s %s for command: %s",
|
||||
label,
|
||||
contact.public_key[:12],
|
||||
command,
|
||||
)
|
||||
return CommandResponse(
|
||||
command=command,
|
||||
response="(no response - command may have been processed)",
|
||||
)
|
||||
|
||||
response_text = extract_response_text(response_event)
|
||||
sender_timestamp = response_event.payload.get(
|
||||
"sender_timestamp",
|
||||
response_event.payload.get("timestamp"),
|
||||
)
|
||||
logger.info(
|
||||
"Received response from %s %s: %s",
|
||||
label,
|
||||
contact.public_key[:12],
|
||||
response_text,
|
||||
)
|
||||
|
||||
return CommandResponse(
|
||||
command=command,
|
||||
response=response_text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
)
|
||||
26
app/services/dm_ack_apply.py
Normal file
26
app/services/dm_ack_apply.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Shared direct-message ACK application logic."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from app.services import dm_ack_tracker
|
||||
from app.services.messages import increment_ack_and_broadcast
|
||||
|
||||
BroadcastFn = Callable[..., Any]
|
||||
|
||||
|
||||
async def apply_dm_ack_code(ack_code: str, *, broadcast_fn: BroadcastFn) -> bool:
|
||||
"""Apply a DM ACK code using the shared pending/buffered state machine.
|
||||
|
||||
Returns True when the ACK matched a pending message, False when it was buffered.
|
||||
"""
|
||||
dm_ack_tracker.cleanup_expired_acks()
|
||||
|
||||
message_id = dm_ack_tracker.pop_pending_ack(ack_code)
|
||||
if message_id is None:
|
||||
dm_ack_tracker.buffer_unmatched_ack(ack_code)
|
||||
return False
|
||||
|
||||
dm_ack_tracker.clear_pending_acks_for_message(message_id)
|
||||
await increment_ack_and_broadcast(message_id=message_id, broadcast_fn=broadcast_fn)
|
||||
return True
|
||||
@@ -5,7 +5,7 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.models import CONTACT_TYPE_REPEATER, Contact, ContactUpsert, Message
|
||||
from app.models import CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM, Contact, ContactUpsert, Message
|
||||
from app.repository import (
|
||||
AmbiguousPublicKeyPrefixError,
|
||||
ContactRepository,
|
||||
@@ -92,7 +92,6 @@ async def resolve_fallback_direct_message_context(
|
||||
last_contacted=received_at,
|
||||
first_seen=received_at,
|
||||
on_radio=False,
|
||||
out_path_hash_mode=-1,
|
||||
)
|
||||
await contact_repository.upsert(placeholder_upsert)
|
||||
contact = await contact_repository.get_by_key(normalized_sender)
|
||||
@@ -107,6 +106,35 @@ async def resolve_fallback_direct_message_context(
|
||||
)
|
||||
|
||||
|
||||
async def resolve_direct_message_sender_metadata(
|
||||
*,
|
||||
sender_public_key: str,
|
||||
received_at: int,
|
||||
broadcast_fn: BroadcastFn,
|
||||
contact_repository=ContactRepository,
|
||||
log: logging.Logger | None = None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""Resolve sender attribution for direct-message variants such as room-server posts."""
|
||||
normalized_sender = sender_public_key.lower()
|
||||
|
||||
try:
|
||||
contact = await contact_repository.get_by_key_or_prefix(normalized_sender)
|
||||
except AmbiguousPublicKeyPrefixError:
|
||||
(log or logger).warning(
|
||||
"Sender prefix '%s' is ambiguous; preserving prefix-only attribution",
|
||||
sender_public_key,
|
||||
)
|
||||
contact = None
|
||||
|
||||
if contact is not None:
|
||||
await claim_prefix_messages_for_contact(
|
||||
public_key=contact.public_key.lower(), log=log or logger
|
||||
)
|
||||
return contact.name, contact.public_key.lower()
|
||||
|
||||
return None, normalized_sender or None
|
||||
|
||||
|
||||
async def _store_direct_message(
|
||||
*,
|
||||
packet_id: int | None,
|
||||
@@ -238,8 +266,19 @@ async def ingest_decrypted_direct_message(
|
||||
contact_repository=ContactRepository,
|
||||
) -> Message | None:
|
||||
conversation_key = their_public_key.lower()
|
||||
|
||||
if not outgoing and decrypted.txt_type == 1:
|
||||
logger.debug(
|
||||
"Skipping CLI response from %s (txt_type=1): %s",
|
||||
conversation_key[:12],
|
||||
(decrypted.message or "")[:50],
|
||||
)
|
||||
return None
|
||||
|
||||
contact = await contact_repository.get_by_key(conversation_key)
|
||||
sender_name: str | None = None
|
||||
sender_key: str | None = conversation_key if not outgoing else None
|
||||
signature: str | None = None
|
||||
if contact is not None:
|
||||
conversation_key, skip_storage = await _prepare_resolved_contact(contact, log=logger)
|
||||
if skip_storage:
|
||||
@@ -250,7 +289,17 @@ async def ingest_decrypted_direct_message(
|
||||
)
|
||||
return None
|
||||
if not outgoing:
|
||||
sender_name = contact.name
|
||||
if contact.type == CONTACT_TYPE_ROOM and decrypted.signed_sender_prefix:
|
||||
sender_name, sender_key = await resolve_direct_message_sender_metadata(
|
||||
sender_public_key=decrypted.signed_sender_prefix,
|
||||
received_at=received_at or int(time.time()),
|
||||
broadcast_fn=broadcast_fn,
|
||||
contact_repository=contact_repository,
|
||||
log=logger,
|
||||
)
|
||||
signature = decrypted.signed_sender_prefix
|
||||
else:
|
||||
sender_name = contact.name
|
||||
|
||||
received = received_at or int(time.time())
|
||||
message = await _store_direct_message(
|
||||
@@ -262,10 +311,10 @@ async def ingest_decrypted_direct_message(
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
outgoing=outgoing,
|
||||
txt_type=0,
|
||||
signature=None,
|
||||
txt_type=decrypted.txt_type,
|
||||
signature=signature,
|
||||
sender_name=sender_name,
|
||||
sender_key=conversation_key if not outgoing else None,
|
||||
sender_key=sender_key,
|
||||
realtime=realtime,
|
||||
broadcast_fn=broadcast_fn,
|
||||
update_last_contacted_key=conversation_key,
|
||||
|
||||
@@ -44,6 +44,13 @@ async def apply_radio_config_update(
|
||||
f"Failed to set advert location policy: {result.payload}"
|
||||
)
|
||||
|
||||
if update.multi_acks_enabled is not None:
|
||||
multi_acks = 1 if update.multi_acks_enabled else 0
|
||||
logger.info("Setting multi ACKs to %d", multi_acks)
|
||||
result = await mc.commands.set_multi_acks(multi_acks)
|
||||
if result is not None and result.type == EventType.ERROR:
|
||||
raise RadioCommandRejectedError(f"Failed to set multi ACKs: {result.payload}")
|
||||
|
||||
if update.name is not None:
|
||||
logger.info("Setting radio name to %s", update.name)
|
||||
await mc.commands.set_name(update.name)
|
||||
|
||||
@@ -245,10 +245,12 @@ High-level state is delegated to hooks:
|
||||
- `id`: backend storage row identity (payload-level dedup)
|
||||
- `observation_id`: realtime per-arrival identity (session fidelity)
|
||||
- Packet feed/visualizer render keys and dedup logic should use `observation_id` (fallback to `id` only for older payloads).
|
||||
- The dedicated raw packet feed view now includes a frontend-only stats drawer. It tracks a separate lightweight per-observation session history for charts/rankings, so its windows are not limited by the visible packet list cap. Coverage messaging should stay honest when detailed in-memory stats history has been trimmed or the selected window predates the current browser session.
|
||||
|
||||
### Radio settings behavior
|
||||
|
||||
- `SettingsRadioSection.tsx` surfaces `path_hash_mode` only when `config.path_hash_mode_supported` is true.
|
||||
- `SettingsRadioSection.tsx` also exposes `multi_acks_enabled` as a checkbox for the radio's extra direct-ACK transmission behavior.
|
||||
- Advert-location control is intentionally only `off` vs `include node location`. Companion-radio firmware does not reliably distinguish saved coordinates from live GPS in this path.
|
||||
- The advert action is mode-aware: the radio settings section exposes both flood and zero-hop manual advert buttons, both routed through the same `onAdvertise(mode)` seam.
|
||||
- Mesh discovery in the radio section is limited to node classes that currently answer discovery control-data requests in firmware: repeaters and sensors.
|
||||
@@ -340,9 +342,10 @@ Clicking a contact's avatar in `ChatHeader` or `MessageList` opens a `ContactInf
|
||||
- Name history ("Also Known As") — shown only when the contact has used multiple names
|
||||
- Message stats: DM count, channel message count
|
||||
- Most active rooms (clickable → navigate to channel)
|
||||
- Route details from the canonical backend surface (`effective_route`, `effective_route_source`, `direct_route`, `route_override`)
|
||||
- Advert observation rate
|
||||
- Nearest repeaters (resolved from first-hop path prefixes)
|
||||
- Recent advert paths
|
||||
- Recent advert paths (informational only; not part of DM route selection)
|
||||
|
||||
State: `useConversationNavigation` controls open/close via `infoPaneContactKey`. Live contact data from WebSocket updates is preferred over the initial detail snapshot.
|
||||
|
||||
@@ -422,9 +425,9 @@ PYTHONPATH=. uv run pytest tests/ -v
|
||||
|
||||
## Errata & Known Non-Issues
|
||||
|
||||
### Contacts rollup uses mention styling for unread DMs
|
||||
### Contacts use mention styling for unread DMs
|
||||
|
||||
This is intentional. In the sidebar section headers, unread direct messages are treated as mention-equivalent, so the Contacts rollup uses the highlighted mention-style badge for any unread DM. Row-level mention detection remains separate; this note is only about the section summary styling.
|
||||
This is intentional. In the sidebar, unread direct messages for actual contact conversations are treated as mention-equivalent for badge styling. That means both the Contacts section header and contact unread badges themselves use the highlighted mention-style colors for unread DMs, including when those contacts appear in Favorites. Repeaters do not inherit this rule, and channel badges still use mention styling only for real `@[name]` mentions.
|
||||
|
||||
### RawPacketList always scrolls to bottom
|
||||
|
||||
|
||||
@@ -14,9 +14,11 @@ import {
|
||||
useConversationNavigation,
|
||||
useRealtimeAppState,
|
||||
useBrowserNotifications,
|
||||
useRawPacketStatsSession,
|
||||
} from './hooks';
|
||||
import { AppShell } from './components/AppShell';
|
||||
import type { MessageInputHandle } from './components/MessageInput';
|
||||
import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
|
||||
import { messageContainsMention } from './utils/messageParser';
|
||||
import { getStateKey } from './utils/conversationState';
|
||||
import type { Conversation, Message, RawPacket } from './types';
|
||||
@@ -81,6 +83,7 @@ export function App() {
|
||||
toggleConversationNotifications,
|
||||
notifyIncomingMessage,
|
||||
} = useBrowserNotifications();
|
||||
const { rawPacketStatsSession, recordRawPacketObservation } = useRawPacketStatsSession();
|
||||
const {
|
||||
showNewMessage,
|
||||
showSettings,
|
||||
@@ -89,10 +92,12 @@ export function App() {
|
||||
showCracker,
|
||||
crackerRunning,
|
||||
localLabel,
|
||||
distanceUnit,
|
||||
setSettingsSection,
|
||||
setSidebarOpen,
|
||||
setCrackerRunning,
|
||||
setLocalLabel,
|
||||
setDistanceUnit,
|
||||
handleCloseSettingsView,
|
||||
handleToggleSettingsView,
|
||||
handleOpenNewMessage,
|
||||
@@ -331,6 +336,7 @@ export function App() {
|
||||
removeConversationMessages,
|
||||
receiveMessageAck,
|
||||
notifyIncomingMessage,
|
||||
recordRawPacketObservation,
|
||||
});
|
||||
const handleVisibilityPolicyChanged = useCallback(() => {
|
||||
clearConversationMessages();
|
||||
@@ -413,6 +419,7 @@ export function App() {
|
||||
contacts,
|
||||
channels,
|
||||
rawPackets,
|
||||
rawPacketStatsSession,
|
||||
config,
|
||||
health,
|
||||
favorites,
|
||||
@@ -560,29 +567,31 @@ export function App() {
|
||||
setContactsLoaded,
|
||||
]);
|
||||
return (
|
||||
<AppShell
|
||||
localLabel={localLabel}
|
||||
showNewMessage={showNewMessage}
|
||||
showSettings={showSettings}
|
||||
settingsSection={settingsSection}
|
||||
sidebarOpen={sidebarOpen}
|
||||
showCracker={showCracker}
|
||||
onSettingsSectionChange={setSettingsSection}
|
||||
onSidebarOpenChange={setSidebarOpen}
|
||||
onCrackerRunningChange={setCrackerRunning}
|
||||
onToggleSettingsView={handleToggleSettingsView}
|
||||
onCloseSettingsView={handleCloseSettingsView}
|
||||
onCloseNewMessage={handleCloseNewMessage}
|
||||
onLocalLabelChange={setLocalLabel}
|
||||
statusProps={statusProps}
|
||||
sidebarProps={sidebarProps}
|
||||
conversationPaneProps={conversationPaneProps}
|
||||
searchProps={searchProps}
|
||||
settingsProps={settingsProps}
|
||||
crackerProps={crackerProps}
|
||||
newMessageModalProps={newMessageModalProps}
|
||||
contactInfoPaneProps={contactInfoPaneProps}
|
||||
channelInfoPaneProps={channelInfoPaneProps}
|
||||
/>
|
||||
<DistanceUnitProvider distanceUnit={distanceUnit} setDistanceUnit={setDistanceUnit}>
|
||||
<AppShell
|
||||
localLabel={localLabel}
|
||||
showNewMessage={showNewMessage}
|
||||
showSettings={showSettings}
|
||||
settingsSection={settingsSection}
|
||||
sidebarOpen={sidebarOpen}
|
||||
showCracker={showCracker}
|
||||
onSettingsSectionChange={setSettingsSection}
|
||||
onSidebarOpenChange={setSidebarOpen}
|
||||
onCrackerRunningChange={setCrackerRunning}
|
||||
onToggleSettingsView={handleToggleSettingsView}
|
||||
onCloseSettingsView={handleCloseSettingsView}
|
||||
onCloseNewMessage={handleCloseNewMessage}
|
||||
onLocalLabelChange={setLocalLabel}
|
||||
statusProps={statusProps}
|
||||
sidebarProps={sidebarProps}
|
||||
conversationPaneProps={conversationPaneProps}
|
||||
searchProps={searchProps}
|
||||
settingsProps={settingsProps}
|
||||
crackerProps={crackerProps}
|
||||
newMessageModalProps={newMessageModalProps}
|
||||
contactInfoPaneProps={contactInfoPaneProps}
|
||||
channelInfoPaneProps={channelInfoPaneProps}
|
||||
/>
|
||||
</DistanceUnitProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -383,4 +383,21 @@ export const api = {
|
||||
fetchJson<RepeaterLppTelemetryResponse>(`/contacts/${publicKey}/repeater/lpp-telemetry`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
roomLogin: (publicKey: string, password: string) =>
|
||||
fetchJson<RepeaterLoginResponse>(`/contacts/${publicKey}/room/login`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password }),
|
||||
}),
|
||||
roomStatus: (publicKey: string) =>
|
||||
fetchJson<RepeaterStatusResponse>(`/contacts/${publicKey}/room/status`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
roomAcl: (publicKey: string) =>
|
||||
fetchJson<RepeaterAclResponse>(`/contacts/${publicKey}/room/acl`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
roomLppTelemetry: (publicKey: string) =>
|
||||
fetchJson<RepeaterLppTelemetryResponse>(`/contacts/${publicKey}/room/lpp-telemetry`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
PathDiscoveryResponse,
|
||||
RadioConfig,
|
||||
} from '../types';
|
||||
import { CONTACT_TYPE_ROOM } from '../types';
|
||||
|
||||
interface ChatHeaderProps {
|
||||
conversation: Conversation;
|
||||
@@ -84,6 +85,7 @@ export function ChatHeader({
|
||||
conversation.type === 'contact'
|
||||
? contacts.find((contact) => contact.public_key === conversation.id)
|
||||
: null;
|
||||
const activeContactIsRoomServer = activeContact?.type === CONTACT_TYPE_ROOM;
|
||||
const activeContactIsPrefixOnly = activeContact
|
||||
? isPrefixOnlyContact(activeContact.public_key)
|
||||
: false;
|
||||
@@ -230,7 +232,7 @@ export function ChatHeader({
|
||||
</span>
|
||||
</span>
|
||||
<div className="flex items-center justify-end gap-0.5 flex-shrink-0">
|
||||
{conversation.type === 'contact' && (
|
||||
{conversation.type === 'contact' && !activeContactIsRoomServer && (
|
||||
<button
|
||||
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => setPathDiscoveryOpen(true)}
|
||||
@@ -245,7 +247,7 @@ export function ChatHeader({
|
||||
<Route className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
{conversation.type === 'contact' && (
|
||||
{conversation.type === 'contact' && !activeContactIsRoomServer && (
|
||||
<button
|
||||
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={onTrace}
|
||||
@@ -260,7 +262,7 @@ export function ChatHeader({
|
||||
<DirectTraceIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
{notificationsSupported && (
|
||||
{notificationsSupported && !activeContactIsRoomServer && (
|
||||
<button
|
||||
className="flex items-center gap-1 rounded px-1 py-1 hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={onToggleNotifications}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
calculateDistance,
|
||||
formatDistance,
|
||||
formatRouteLabel,
|
||||
getDirectContactRoute,
|
||||
getEffectiveContactRoute,
|
||||
hasRoutingOverride,
|
||||
parsePathHops,
|
||||
@@ -23,6 +24,7 @@ import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { ContactAvatar } from './ContactAvatar';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
|
||||
import { toast } from './ui/sonner';
|
||||
import { useDistanceUnit } from '../contexts/DistanceUnitContext';
|
||||
import type {
|
||||
Contact,
|
||||
ContactActiveRoom,
|
||||
@@ -81,6 +83,7 @@ export function ContactInfoPane({
|
||||
onToggleBlockedKey,
|
||||
onToggleBlockedName,
|
||||
}: ContactInfoPaneProps) {
|
||||
const { distanceUnit } = useDistanceUnit();
|
||||
const isNameOnly = contactKey?.startsWith('name:') ?? false;
|
||||
const nameOnlyValue = isNameOnly && contactKey ? contactKey.slice(5) : null;
|
||||
|
||||
@@ -134,11 +137,12 @@ export function ContactInfoPane({
|
||||
? calculateDistance(config.lat, config.lon, contact.lat, contact.lon)
|
||||
: null;
|
||||
const effectiveRoute = contact ? getEffectiveContactRoute(contact) : null;
|
||||
const directRoute = contact ? getDirectContactRoute(contact) : null;
|
||||
const pathHashModeLabel =
|
||||
effectiveRoute && effectiveRoute.pathLen >= 0
|
||||
? formatPathHashMode(effectiveRoute.pathHashMode)
|
||||
: null;
|
||||
const learnedRouteLabel = contact ? formatRouteLabel(contact.last_path_len, true) : null;
|
||||
const learnedRouteLabel = directRoute ? formatRouteLabel(directRoute.path_len, true) : null;
|
||||
const isPrefixOnlyResolvedContact = contact ? isPrefixOnlyContact(contact.public_key) : false;
|
||||
const isUnknownFullKeyResolvedContact =
|
||||
contact !== null &&
|
||||
@@ -313,7 +317,7 @@ export function ContactInfoPane({
|
||||
<InfoItem label="Last Contacted" value={formatTime(contact.last_contacted)} />
|
||||
)}
|
||||
{distFromUs !== null && (
|
||||
<InfoItem label="Distance" value={formatDistance(distFromUs)} />
|
||||
<InfoItem label="Distance" value={formatDistance(distFromUs, distanceUnit)} />
|
||||
)}
|
||||
{effectiveRoute && (
|
||||
<InfoItem
|
||||
@@ -330,7 +334,7 @@ export function ContactInfoPane({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{contact && hasRoutingOverride(contact) && learnedRouteLabel && (
|
||||
{hasRoutingOverride(contact) && learnedRouteLabel && (
|
||||
<InfoItem label="Learned Route" value={learnedRouteLabel} />
|
||||
)}
|
||||
{pathHashModeLabel && <InfoItem label="Hop Width" value={pathHashModeLabel} />}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Contact, PathDiscoveryResponse, PathDiscoveryRoute } from '../type
|
||||
import {
|
||||
findContactsByPrefix,
|
||||
formatRouteLabel,
|
||||
getDirectContactRoute,
|
||||
getEffectiveContactRoute,
|
||||
hasRoutingOverride,
|
||||
parsePathHops,
|
||||
@@ -99,16 +100,17 @@ export function ContactPathDiscoveryModal({
|
||||
const [result, setResult] = useState<PathDiscoveryResponse | null>(null);
|
||||
|
||||
const effectiveRoute = useMemo(() => getEffectiveContactRoute(contact), [contact]);
|
||||
const directRoute = useMemo(() => getDirectContactRoute(contact), [contact]);
|
||||
const hasForcedRoute = hasRoutingOverride(contact);
|
||||
const learnedRouteSummary = useMemo(() => {
|
||||
if (contact.last_path_len === -1) {
|
||||
if (!directRoute) {
|
||||
return 'Flood';
|
||||
}
|
||||
const hops = parsePathHops(contact.last_path, contact.last_path_len);
|
||||
const hops = parsePathHops(directRoute.path, directRoute.path_len);
|
||||
return hops.length > 0
|
||||
? `${formatRouteLabel(contact.last_path_len, true)} (${hops.join(' -> ')})`
|
||||
: formatRouteLabel(contact.last_path_len, true);
|
||||
}, [contact.last_path, contact.last_path_len]);
|
||||
? `${formatRouteLabel(directRoute.path_len, true)} (${hops.join(' -> ')})`
|
||||
: formatRouteLabel(directRoute.path_len, true);
|
||||
}, [directRoute]);
|
||||
const forcedRouteSummary = useMemo(() => {
|
||||
if (!hasForcedRoute) {
|
||||
return null;
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Contact } from '../types';
|
||||
import {
|
||||
formatRouteLabel,
|
||||
formatRoutingOverrideInput,
|
||||
getDirectContactRoute,
|
||||
hasRoutingOverride,
|
||||
} from '../utils/pathUtils';
|
||||
import { Button } from './ui/button';
|
||||
@@ -28,7 +29,7 @@ interface ContactRoutingOverrideModalProps {
|
||||
}
|
||||
|
||||
function summarizeLearnedRoute(contact: Contact): string {
|
||||
return formatRouteLabel(contact.last_path_len, true);
|
||||
return formatRouteLabel(getDirectContactRoute(contact)?.path_len ?? -1, true);
|
||||
}
|
||||
|
||||
function summarizeForcedRoute(contact: Contact): string | null {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { getMapFocusHash } from '../utils/urlHash';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import type { Contact } from '../types';
|
||||
import { useDistanceUnit } from '../contexts/DistanceUnitContext';
|
||||
import { ContactRoutingOverrideModal } from './ContactRoutingOverrideModal';
|
||||
|
||||
interface ContactStatusInfoProps {
|
||||
@@ -24,6 +25,7 @@ interface ContactStatusInfoProps {
|
||||
* shared between ChatHeader and RepeaterDashboard.
|
||||
*/
|
||||
export function ContactStatusInfo({ contact, ourLat, ourLon }: ContactStatusInfoProps) {
|
||||
const { distanceUnit } = useDistanceUnit();
|
||||
const [routingModalOpen, setRoutingModalOpen] = useState(false);
|
||||
const parts: ReactNode[] = [];
|
||||
const effectiveRoute = getEffectiveContactRoute(contact);
|
||||
@@ -74,7 +76,7 @@ export function ContactStatusInfo({ contact, ourLat, ourLon }: ContactStatusInfo
|
||||
>
|
||||
{contact.lat!.toFixed(3)}, {contact.lon!.toFixed(3)}
|
||||
</span>
|
||||
{distFromUs !== null && ` (${formatDistance(distFromUs)})`}
|
||||
{distFromUs !== null && ` (${formatDistance(distFromUs, distanceUnit)})`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { lazy, Suspense, useMemo, type Ref } from 'react';
|
||||
import { lazy, Suspense, useEffect, useMemo, useState, type Ref } from 'react';
|
||||
|
||||
import { ChatHeader } from './ChatHeader';
|
||||
import { MessageInput, type MessageInputHandle } from './MessageInput';
|
||||
import { MessageList } from './MessageList';
|
||||
import { RawPacketList } from './RawPacketList';
|
||||
import { RawPacketFeedView } from './RawPacketFeedView';
|
||||
import { RoomServerPanel } from './RoomServerPanel';
|
||||
import type {
|
||||
Channel,
|
||||
Contact,
|
||||
@@ -15,7 +16,8 @@ import type {
|
||||
RawPacket,
|
||||
RadioConfig,
|
||||
} from '../types';
|
||||
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
|
||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
|
||||
import { isPrefixOnlyContact, isUnknownFullKeyContact } from '../utils/pubkey';
|
||||
|
||||
const RepeaterDashboard = lazy(() =>
|
||||
@@ -31,6 +33,7 @@ interface ConversationPaneProps {
|
||||
contacts: Contact[];
|
||||
channels: Channel[];
|
||||
rawPackets: RawPacket[];
|
||||
rawPacketStatsSession: RawPacketStatsSessionState;
|
||||
config: RadioConfig | null;
|
||||
health: HealthStatus | null;
|
||||
notificationsSupported: boolean;
|
||||
@@ -95,6 +98,7 @@ export function ConversationPane({
|
||||
contacts,
|
||||
channels,
|
||||
rawPackets,
|
||||
rawPacketStatsSession,
|
||||
config,
|
||||
health,
|
||||
notificationsSupported,
|
||||
@@ -128,6 +132,7 @@ export function ConversationPane({
|
||||
onSendMessage,
|
||||
onToggleNotifications,
|
||||
}: ConversationPaneProps) {
|
||||
const [roomAuthenticated, setRoomAuthenticated] = useState(false);
|
||||
const activeContactIsRepeater = useMemo(() => {
|
||||
if (!activeConversation || activeConversation.type !== 'contact') return false;
|
||||
const contact = contacts.find((candidate) => candidate.public_key === activeConversation.id);
|
||||
@@ -137,6 +142,10 @@ export function ConversationPane({
|
||||
if (!activeConversation || activeConversation.type !== 'contact') return null;
|
||||
return contacts.find((candidate) => candidate.public_key === activeConversation.id) ?? null;
|
||||
}, [activeConversation, contacts]);
|
||||
const activeContactIsRoom = activeContact?.type === CONTACT_TYPE_ROOM;
|
||||
useEffect(() => {
|
||||
setRoomAuthenticated(false);
|
||||
}, [activeConversation?.id]);
|
||||
const isPrefixOnlyActiveContact = activeContact
|
||||
? isPrefixOnlyContact(activeContact.public_key)
|
||||
: false;
|
||||
@@ -178,14 +187,12 @@ export function ConversationPane({
|
||||
|
||||
if (activeConversation.type === 'raw') {
|
||||
return (
|
||||
<>
|
||||
<h2 className="flex justify-between items-center px-4 py-2.5 border-b border-border font-semibold text-base">
|
||||
Raw Packet Feed
|
||||
</h2>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<RawPacketList packets={rawPackets} />
|
||||
</div>
|
||||
</>
|
||||
<RawPacketFeedView
|
||||
packets={rawPackets}
|
||||
rawPacketStatsSession={rawPacketStatsSession}
|
||||
contacts={contacts}
|
||||
channels={channels}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -217,6 +224,8 @@ export function ConversationPane({
|
||||
);
|
||||
}
|
||||
|
||||
const showRoomChat = !activeContactIsRoom || roomAuthenticated;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChatHeader
|
||||
@@ -244,35 +253,40 @@ export function ConversationPane({
|
||||
{activeConversation.type === 'contact' && isUnknownFullKeyActiveContact && (
|
||||
<ContactResolutionBanner variant="unknown-full-key" />
|
||||
)}
|
||||
<MessageList
|
||||
key={activeConversation.id}
|
||||
messages={messages}
|
||||
contacts={contacts}
|
||||
loading={messagesLoading}
|
||||
loadingOlder={loadingOlder}
|
||||
hasOlderMessages={hasOlderMessages}
|
||||
unreadMarkerLastReadAt={
|
||||
activeConversation.type === 'channel' ? unreadMarkerLastReadAt : undefined
|
||||
}
|
||||
onDismissUnreadMarker={
|
||||
activeConversation.type === 'channel' ? onDismissUnreadMarker : undefined
|
||||
}
|
||||
onSenderClick={activeConversation.type === 'channel' ? onSenderClick : undefined}
|
||||
onLoadOlder={onLoadOlder}
|
||||
onResendChannelMessage={
|
||||
activeConversation.type === 'channel' ? onResendChannelMessage : undefined
|
||||
}
|
||||
radioName={config?.name}
|
||||
config={config}
|
||||
onOpenContactInfo={onOpenContactInfo}
|
||||
targetMessageId={targetMessageId}
|
||||
onTargetReached={onTargetReached}
|
||||
hasNewerMessages={hasNewerMessages}
|
||||
loadingNewer={loadingNewer}
|
||||
onLoadNewer={onLoadNewer}
|
||||
onJumpToBottom={onJumpToBottom}
|
||||
/>
|
||||
{activeConversation.type === 'contact' && isPrefixOnlyActiveContact ? null : (
|
||||
{activeContactIsRoom && activeContact && (
|
||||
<RoomServerPanel contact={activeContact} onAuthenticatedChange={setRoomAuthenticated} />
|
||||
)}
|
||||
{showRoomChat && (
|
||||
<MessageList
|
||||
key={activeConversation.id}
|
||||
messages={messages}
|
||||
contacts={contacts}
|
||||
loading={messagesLoading}
|
||||
loadingOlder={loadingOlder}
|
||||
hasOlderMessages={hasOlderMessages}
|
||||
unreadMarkerLastReadAt={
|
||||
activeConversation.type === 'channel' ? unreadMarkerLastReadAt : undefined
|
||||
}
|
||||
onDismissUnreadMarker={
|
||||
activeConversation.type === 'channel' ? onDismissUnreadMarker : undefined
|
||||
}
|
||||
onSenderClick={activeConversation.type === 'channel' ? onSenderClick : undefined}
|
||||
onLoadOlder={onLoadOlder}
|
||||
onResendChannelMessage={
|
||||
activeConversation.type === 'channel' ? onResendChannelMessage : undefined
|
||||
}
|
||||
radioName={config?.name}
|
||||
config={config}
|
||||
onOpenContactInfo={onOpenContactInfo}
|
||||
targetMessageId={targetMessageId}
|
||||
onTargetReached={onTargetReached}
|
||||
hasNewerMessages={hasNewerMessages}
|
||||
loadingNewer={loadingNewer}
|
||||
onLoadNewer={onLoadNewer}
|
||||
onJumpToBottom={onJumpToBottom}
|
||||
/>
|
||||
)}
|
||||
{showRoomChat && !(activeConversation.type === 'contact' && isPrefixOnlyActiveContact) ? (
|
||||
<MessageInput
|
||||
ref={messageInputRef}
|
||||
onSend={onSendMessage}
|
||||
@@ -285,7 +299,7 @@ export function ConversationPane({
|
||||
: `Message ${activeConversation.name}...`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,9 +9,10 @@ import {
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import type { Contact, Message, MessagePath, RadioConfig } from '../types';
|
||||
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
|
||||
import { formatTime, parseSenderFromText } from '../utils/messageParser';
|
||||
import { formatHopCounts, type SenderInfo } from '../utils/pathUtils';
|
||||
import { getDirectContactRoute } from '../utils/pathUtils';
|
||||
import { ContactAvatar } from './ContactAvatar';
|
||||
import { PathModal } from './PathModal';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
@@ -499,13 +500,41 @@ export function MessageList({
|
||||
contact: Contact | null,
|
||||
parsedSender: string | null
|
||||
): SenderInfo => {
|
||||
if (
|
||||
msg.type === 'PRIV' &&
|
||||
contact?.type === CONTACT_TYPE_ROOM &&
|
||||
(msg.sender_key || msg.sender_name)
|
||||
) {
|
||||
const authorContact =
|
||||
(msg.sender_key
|
||||
? contacts.find((candidate) => candidate.public_key === msg.sender_key)
|
||||
: null) || (msg.sender_name ? getContactByName(msg.sender_name) : null);
|
||||
if (authorContact) {
|
||||
const directRoute = getDirectContactRoute(authorContact);
|
||||
return {
|
||||
name: authorContact.name || msg.sender_name || authorContact.public_key.slice(0, 12),
|
||||
publicKeyOrPrefix: authorContact.public_key,
|
||||
lat: authorContact.lat,
|
||||
lon: authorContact.lon,
|
||||
pathHashMode: directRoute?.path_hash_mode ?? null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: msg.sender_name || msg.sender_key || 'Unknown',
|
||||
publicKeyOrPrefix: msg.sender_key || '',
|
||||
lat: null,
|
||||
lon: null,
|
||||
pathHashMode: null,
|
||||
};
|
||||
}
|
||||
if (msg.type === 'PRIV' && contact) {
|
||||
const directRoute = getDirectContactRoute(contact);
|
||||
return {
|
||||
name: contact.name || contact.public_key.slice(0, 12),
|
||||
publicKeyOrPrefix: contact.public_key,
|
||||
lat: contact.lat,
|
||||
lon: contact.lon,
|
||||
pathHashMode: contact.out_path_hash_mode,
|
||||
pathHashMode: directRoute?.path_hash_mode ?? null,
|
||||
};
|
||||
}
|
||||
if (msg.type === 'CHAN') {
|
||||
@@ -515,12 +544,13 @@ export function MessageList({
|
||||
? contacts.find((candidate) => candidate.public_key === msg.sender_key)
|
||||
: null) || (senderName ? getContactByName(senderName) : null);
|
||||
if (senderContact) {
|
||||
const directRoute = getDirectContactRoute(senderContact);
|
||||
return {
|
||||
name: senderContact.name || senderName || senderContact.public_key.slice(0, 12),
|
||||
publicKeyOrPrefix: senderContact.public_key,
|
||||
lat: senderContact.lat,
|
||||
lon: senderContact.lon,
|
||||
pathHashMode: senderContact.out_path_hash_mode,
|
||||
pathHashMode: directRoute?.path_hash_mode ?? null,
|
||||
};
|
||||
}
|
||||
if (senderName || msg.sender_key) {
|
||||
@@ -538,12 +568,13 @@ export function MessageList({
|
||||
if (parsedSender) {
|
||||
const senderContact = getContactByName(parsedSender);
|
||||
if (senderContact) {
|
||||
const directRoute = getDirectContactRoute(senderContact);
|
||||
return {
|
||||
name: parsedSender,
|
||||
publicKeyOrPrefix: senderContact.public_key,
|
||||
lat: senderContact.lat,
|
||||
lon: senderContact.lon,
|
||||
pathHashMode: senderContact.out_path_hash_mode,
|
||||
pathHashMode: directRoute?.path_hash_mode ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -580,6 +611,8 @@ export function MessageList({
|
||||
isCorruptChannelMessage: boolean
|
||||
): string => {
|
||||
if (msg.outgoing) return '__outgoing__';
|
||||
if (msg.type === 'PRIV' && msg.sender_key) return `key:${msg.sender_key}`;
|
||||
if (msg.type === 'PRIV' && senderName) return `name:${senderName}`;
|
||||
if (msg.type === 'PRIV' && msg.conversation_key) return msg.conversation_key;
|
||||
if (msg.sender_key) return `key:${msg.sender_key}`;
|
||||
if (senderName) return `name:${senderName}`;
|
||||
@@ -608,18 +641,24 @@ export function MessageList({
|
||||
// For DMs, look up contact; for channel messages, use parsed sender
|
||||
const contact = msg.type === 'PRIV' ? getContact(msg.conversation_key) : null;
|
||||
const isRepeater = contact?.type === CONTACT_TYPE_REPEATER;
|
||||
const isRoomServer = contact?.type === CONTACT_TYPE_ROOM;
|
||||
|
||||
// Skip sender parsing for repeater messages (CLI responses often have colons)
|
||||
const { sender, content } = isRepeater
|
||||
? { sender: null, content: msg.text }
|
||||
: parseSenderFromText(msg.text);
|
||||
const { sender, content } =
|
||||
isRepeater || (isRoomServer && msg.type === 'PRIV')
|
||||
? { sender: null, content: msg.text }
|
||||
: parseSenderFromText(msg.text);
|
||||
const directSenderName =
|
||||
msg.type === 'PRIV' && isRoomServer ? msg.sender_name || null : null;
|
||||
const channelSenderName = msg.type === 'CHAN' ? msg.sender_name || sender : null;
|
||||
const channelSenderContact =
|
||||
msg.type === 'CHAN' && channelSenderName ? getContactByName(channelSenderName) : null;
|
||||
const isCorruptChannelMessage = isCorruptUnnamedChannelMessage(msg, sender);
|
||||
const displaySender = msg.outgoing
|
||||
? 'You'
|
||||
: contact?.name ||
|
||||
: directSenderName ||
|
||||
(isRoomServer && msg.sender_key ? msg.sender_key.slice(0, 8) : null) ||
|
||||
contact?.name ||
|
||||
channelSenderName ||
|
||||
(isCorruptChannelMessage
|
||||
? CORRUPT_SENDER_LABEL
|
||||
@@ -632,15 +671,22 @@ export function MessageList({
|
||||
displaySender !== CORRUPT_SENDER_LABEL;
|
||||
|
||||
// Determine if we should show avatar (first message in a chunk from same sender)
|
||||
const currentSenderKey = getSenderKey(msg, channelSenderName, isCorruptChannelMessage);
|
||||
const currentSenderKey = getSenderKey(
|
||||
msg,
|
||||
directSenderName || channelSenderName,
|
||||
isCorruptChannelMessage
|
||||
);
|
||||
const prevMsg = sortedMessages[index - 1];
|
||||
const prevParsedSender = prevMsg ? parseSenderFromText(prevMsg.text).sender : null;
|
||||
const prevSenderKey = prevMsg
|
||||
? getSenderKey(
|
||||
prevMsg,
|
||||
prevMsg.type === 'CHAN'
|
||||
? prevMsg.sender_name || prevParsedSender
|
||||
: prevParsedSender,
|
||||
prevMsg.type === 'PRIV' &&
|
||||
getContact(prevMsg.conversation_key)?.type === CONTACT_TYPE_ROOM
|
||||
? prevMsg.sender_name
|
||||
: prevMsg.type === 'CHAN'
|
||||
? prevMsg.sender_name || prevParsedSender
|
||||
: prevParsedSender,
|
||||
isCorruptUnnamedChannelMessage(prevMsg, prevParsedSender)
|
||||
)
|
||||
: null;
|
||||
@@ -654,9 +700,14 @@ export function MessageList({
|
||||
let avatarVariant: 'default' | 'corrupt' = 'default';
|
||||
if (!msg.outgoing) {
|
||||
if (msg.type === 'PRIV' && msg.conversation_key) {
|
||||
// DM: use conversation_key (sender's public key)
|
||||
avatarName = contact?.name || null;
|
||||
avatarKey = msg.conversation_key;
|
||||
if (isRoomServer) {
|
||||
avatarName = directSenderName;
|
||||
avatarKey =
|
||||
msg.sender_key || (avatarName ? `name:${avatarName}` : msg.conversation_key);
|
||||
} else {
|
||||
avatarName = contact?.name || null;
|
||||
avatarKey = msg.conversation_key;
|
||||
}
|
||||
} else if (isCorruptChannelMessage) {
|
||||
avatarName = CORRUPT_SENDER_LABEL;
|
||||
avatarKey = `corrupt:${msg.id}`;
|
||||
@@ -721,7 +772,12 @@ export function MessageList({
|
||||
type="button"
|
||||
className="avatar-action-button rounded-full border-none bg-transparent p-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={avatarActionLabel}
|
||||
onClick={() => onOpenContactInfo(avatarKey, msg.type === 'CHAN')}
|
||||
onClick={() =>
|
||||
onOpenContactInfo(
|
||||
avatarKey,
|
||||
msg.type === 'CHAN' || (msg.type === 'PRIV' && isRoomServer)
|
||||
)
|
||||
}
|
||||
>
|
||||
<ContactAvatar
|
||||
name={avatarName}
|
||||
@@ -776,7 +832,7 @@ export function MessageList({
|
||||
onClick={() =>
|
||||
setSelectedPath({
|
||||
paths: msg.paths!,
|
||||
senderInfo: getSenderInfo(msg, contact, sender),
|
||||
senderInfo: getSenderInfo(msg, contact, directSenderName || sender),
|
||||
})
|
||||
}
|
||||
/>
|
||||
@@ -802,7 +858,7 @@ export function MessageList({
|
||||
onClick={() =>
|
||||
setSelectedPath({
|
||||
paths: msg.paths!,
|
||||
senderInfo: getSenderInfo(msg, contact, sender),
|
||||
senderInfo: getSenderInfo(msg, contact, directSenderName || sender),
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
} from '../utils/pathUtils';
|
||||
import { formatTime } from '../utils/messageParser';
|
||||
import { getMapFocusHash } from '../utils/urlHash';
|
||||
import { useDistanceUnit } from '../contexts/DistanceUnitContext';
|
||||
import type { DistanceUnit } from '../utils/distanceUnits';
|
||||
|
||||
const PathRouteMap = lazy(() =>
|
||||
import('./PathRouteMap').then((m) => ({ default: m.PathRouteMap }))
|
||||
@@ -44,6 +46,7 @@ export function PathModal({
|
||||
isResendable,
|
||||
onResend,
|
||||
}: PathModalProps) {
|
||||
const { distanceUnit } = useDistanceUnit();
|
||||
const [expandedMaps, setExpandedMaps] = useState<Set<number>>(new Set());
|
||||
const hasResendActions = isOutgoingChan && messageId !== undefined && onResend;
|
||||
const hasPaths = paths.length > 0;
|
||||
@@ -120,7 +123,8 @@ export function PathModal({
|
||||
resolvedPaths[0].resolved.sender.lon,
|
||||
resolvedPaths[0].resolved.receiver.lat,
|
||||
resolvedPaths[0].resolved.receiver.lon
|
||||
)!
|
||||
)!,
|
||||
distanceUnit
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -171,7 +175,11 @@ export function PathModal({
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
<PathVisualization resolved={pathData.resolved} senderInfo={senderInfo} />
|
||||
<PathVisualization
|
||||
resolved={pathData.resolved}
|
||||
senderInfo={senderInfo}
|
||||
distanceUnit={distanceUnit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -227,9 +235,10 @@ export function PathModal({
|
||||
interface PathVisualizationProps {
|
||||
resolved: ResolvedPath;
|
||||
senderInfo: SenderInfo;
|
||||
distanceUnit: DistanceUnit;
|
||||
}
|
||||
|
||||
function PathVisualization({ resolved, senderInfo }: PathVisualizationProps) {
|
||||
function PathVisualization({ resolved, senderInfo, distanceUnit }: PathVisualizationProps) {
|
||||
// Track previous location for each hop to calculate distances
|
||||
// Returns null if previous hop was ambiguous or has invalid location
|
||||
const getPrevLocation = (hopIndex: number): { lat: number | null; lon: number | null } | null => {
|
||||
@@ -264,6 +273,7 @@ function PathVisualization({ resolved, senderInfo }: PathVisualizationProps) {
|
||||
name={resolved.sender.name}
|
||||
prefix={resolved.sender.prefix}
|
||||
distance={null}
|
||||
distanceUnit={distanceUnit}
|
||||
isFirst
|
||||
lat={resolved.sender.lat}
|
||||
lon={resolved.sender.lon}
|
||||
@@ -277,6 +287,7 @@ function PathVisualization({ resolved, senderInfo }: PathVisualizationProps) {
|
||||
hop={hop}
|
||||
hopNumber={index + 1}
|
||||
prevLocation={getPrevLocation(index)}
|
||||
distanceUnit={distanceUnit}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -286,6 +297,7 @@ function PathVisualization({ resolved, senderInfo }: PathVisualizationProps) {
|
||||
name={resolved.receiver.name}
|
||||
prefix={resolved.receiver.prefix}
|
||||
distance={calculateReceiverDistance(resolved)}
|
||||
distanceUnit={distanceUnit}
|
||||
isLast
|
||||
lat={resolved.receiver.lat}
|
||||
lon={resolved.receiver.lon}
|
||||
@@ -300,7 +312,7 @@ function PathVisualization({ resolved, senderInfo }: PathVisualizationProps) {
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{resolved.hasGaps ? '>' : ''}
|
||||
{formatDistance(resolved.totalDistances[0])}
|
||||
{formatDistance(resolved.totalDistances[0], distanceUnit)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -313,6 +325,7 @@ interface PathNodeProps {
|
||||
name: string;
|
||||
prefix: string;
|
||||
distance: number | null;
|
||||
distanceUnit: DistanceUnit;
|
||||
isFirst?: boolean;
|
||||
isLast?: boolean;
|
||||
/** Optional coordinates for map link */
|
||||
@@ -327,6 +340,7 @@ function PathNode({
|
||||
name,
|
||||
prefix,
|
||||
distance,
|
||||
distanceUnit,
|
||||
isFirst,
|
||||
isLast,
|
||||
lat,
|
||||
@@ -353,7 +367,9 @@ function PathNode({
|
||||
<div className="font-medium truncate">
|
||||
{name}
|
||||
{distance !== null && (
|
||||
<span className="text-xs text-muted-foreground ml-1">- {formatDistance(distance)}</span>
|
||||
<span className="text-xs text-muted-foreground ml-1">
|
||||
- {formatDistance(distance, distanceUnit)}
|
||||
</span>
|
||||
)}
|
||||
{hasLocation && <CoordinateLink lat={lat!} lon={lon!} publicKey={publicKey!} />}
|
||||
</div>
|
||||
@@ -366,9 +382,10 @@ interface HopNodeProps {
|
||||
hop: PathHop;
|
||||
hopNumber: number;
|
||||
prevLocation: { lat: number | null; lon: number | null } | null;
|
||||
distanceUnit: DistanceUnit;
|
||||
}
|
||||
|
||||
function HopNode({ hop, hopNumber, prevLocation }: HopNodeProps) {
|
||||
function HopNode({ hop, hopNumber, prevLocation, distanceUnit }: HopNodeProps) {
|
||||
const isAmbiguous = hop.matches.length > 1;
|
||||
const isUnknown = hop.matches.length === 0;
|
||||
|
||||
@@ -417,7 +434,7 @@ function HopNode({ hop, hopNumber, prevLocation }: HopNodeProps) {
|
||||
{contact.name || contact.public_key.slice(0, 12)}
|
||||
{dist !== null && (
|
||||
<span className="text-xs text-muted-foreground ml-1">
|
||||
- {formatDistance(dist)}
|
||||
- {formatDistance(dist, distanceUnit)}
|
||||
</span>
|
||||
)}
|
||||
{hasLocation && (
|
||||
@@ -436,7 +453,7 @@ function HopNode({ hop, hopNumber, prevLocation }: HopNodeProps) {
|
||||
{hop.matches[0].name || hop.matches[0].public_key.slice(0, 12)}
|
||||
{hop.distanceFromPrev !== null && (
|
||||
<span className="text-xs text-muted-foreground ml-1">
|
||||
- {formatDistance(hop.distanceFromPrev)}
|
||||
- {formatDistance(hop.distanceFromPrev, distanceUnit)}
|
||||
</span>
|
||||
)}
|
||||
{isValidLocation(hop.matches[0].lat, hop.matches[0].lon) && (
|
||||
|
||||
644
frontend/src/components/RawPacketDetailModal.tsx
Normal file
644
frontend/src/components/RawPacketDetailModal.tsx
Normal file
@@ -0,0 +1,644 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ChannelCrypto, PayloadType } from '@michaelhart/meshcore-decoder';
|
||||
|
||||
import type { Channel, RawPacket } from '../types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
createDecoderOptions,
|
||||
inspectRawPacketWithOptions,
|
||||
type PacketByteField,
|
||||
} from '../utils/rawPacketInspector';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
|
||||
interface RawPacketDetailModalProps {
|
||||
packet: RawPacket | null;
|
||||
channels: Channel[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface FieldPaletteEntry {
|
||||
box: string;
|
||||
boxActive: string;
|
||||
hex: string;
|
||||
hexActive: string;
|
||||
}
|
||||
|
||||
interface GroupTextResolutionCandidate {
|
||||
key: string;
|
||||
name: string;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
const FIELD_PALETTE: FieldPaletteEntry[] = [
|
||||
{
|
||||
box: 'border-sky-500/30 bg-sky-500/10',
|
||||
boxActive: 'border-sky-600 bg-sky-500/20 shadow-sm shadow-sky-500/20',
|
||||
hex: 'bg-sky-500/20 ring-1 ring-inset ring-sky-500/35',
|
||||
hexActive: 'bg-sky-500/40 ring-1 ring-inset ring-sky-600/70',
|
||||
},
|
||||
{
|
||||
box: 'border-emerald-500/30 bg-emerald-500/10',
|
||||
boxActive: 'border-emerald-600 bg-emerald-500/20 shadow-sm shadow-emerald-500/20',
|
||||
hex: 'bg-emerald-500/20 ring-1 ring-inset ring-emerald-500/35',
|
||||
hexActive: 'bg-emerald-500/40 ring-1 ring-inset ring-emerald-600/70',
|
||||
},
|
||||
{
|
||||
box: 'border-amber-500/30 bg-amber-500/10',
|
||||
boxActive: 'border-amber-600 bg-amber-500/20 shadow-sm shadow-amber-500/20',
|
||||
hex: 'bg-amber-500/20 ring-1 ring-inset ring-amber-500/35',
|
||||
hexActive: 'bg-amber-500/40 ring-1 ring-inset ring-amber-600/70',
|
||||
},
|
||||
{
|
||||
box: 'border-rose-500/30 bg-rose-500/10',
|
||||
boxActive: 'border-rose-600 bg-rose-500/20 shadow-sm shadow-rose-500/20',
|
||||
hex: 'bg-rose-500/20 ring-1 ring-inset ring-rose-500/35',
|
||||
hexActive: 'bg-rose-500/40 ring-1 ring-inset ring-rose-600/70',
|
||||
},
|
||||
{
|
||||
box: 'border-violet-500/30 bg-violet-500/10',
|
||||
boxActive: 'border-violet-600 bg-violet-500/20 shadow-sm shadow-violet-500/20',
|
||||
hex: 'bg-violet-500/20 ring-1 ring-inset ring-violet-500/35',
|
||||
hexActive: 'bg-violet-500/40 ring-1 ring-inset ring-violet-600/70',
|
||||
},
|
||||
{
|
||||
box: 'border-cyan-500/30 bg-cyan-500/10',
|
||||
boxActive: 'border-cyan-600 bg-cyan-500/20 shadow-sm shadow-cyan-500/20',
|
||||
hex: 'bg-cyan-500/20 ring-1 ring-inset ring-cyan-500/35',
|
||||
hexActive: 'bg-cyan-500/40 ring-1 ring-inset ring-cyan-600/70',
|
||||
},
|
||||
{
|
||||
box: 'border-lime-500/30 bg-lime-500/10',
|
||||
boxActive: 'border-lime-600 bg-lime-500/20 shadow-sm shadow-lime-500/20',
|
||||
hex: 'bg-lime-500/20 ring-1 ring-inset ring-lime-500/35',
|
||||
hexActive: 'bg-lime-500/40 ring-1 ring-inset ring-lime-600/70',
|
||||
},
|
||||
{
|
||||
box: 'border-fuchsia-500/30 bg-fuchsia-500/10',
|
||||
boxActive: 'border-fuchsia-600 bg-fuchsia-500/20 shadow-sm shadow-fuchsia-500/20',
|
||||
hex: 'bg-fuchsia-500/20 ring-1 ring-inset ring-fuchsia-500/35',
|
||||
hexActive: 'bg-fuchsia-500/40 ring-1 ring-inset ring-fuchsia-600/70',
|
||||
},
|
||||
];
|
||||
|
||||
function formatTimestamp(timestamp: number): string {
|
||||
return new Date(timestamp * 1000).toLocaleString([], {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatSignal(packet: RawPacket): string {
|
||||
const parts: string[] = [];
|
||||
if (packet.rssi !== null) {
|
||||
parts.push(`${packet.rssi} dBm RSSI`);
|
||||
}
|
||||
if (packet.snr !== null) {
|
||||
parts.push(`${packet.snr.toFixed(1)} dB SNR`);
|
||||
}
|
||||
return parts.length > 0 ? parts.join(' · ') : 'No signal sample';
|
||||
}
|
||||
|
||||
function formatByteRange(field: PacketByteField): string {
|
||||
if (field.absoluteStartByte === field.absoluteEndByte) {
|
||||
return `Byte ${field.absoluteStartByte}`;
|
||||
}
|
||||
return `Bytes ${field.absoluteStartByte}-${field.absoluteEndByte}`;
|
||||
}
|
||||
|
||||
function formatPathMode(hashSize: number | undefined, hopCount: number): string {
|
||||
if (hopCount === 0) {
|
||||
return 'No path hops';
|
||||
}
|
||||
if (!hashSize) {
|
||||
return `${hopCount} hop${hopCount === 1 ? '' : 's'}`;
|
||||
}
|
||||
return `${hopCount} hop${hopCount === 1 ? '' : 's'} · ${hashSize} byte hash${hashSize === 1 ? '' : 'es'}`;
|
||||
}
|
||||
|
||||
function buildGroupTextResolutionCandidates(channels: Channel[]): GroupTextResolutionCandidate[] {
|
||||
return channels.map((channel) => ({
|
||||
key: channel.key,
|
||||
name: channel.name,
|
||||
hash: ChannelCrypto.calculateChannelHash(channel.key).toUpperCase(),
|
||||
}));
|
||||
}
|
||||
|
||||
function resolveGroupTextRoomName(
|
||||
payload: {
|
||||
channelHash?: string;
|
||||
cipherMac?: string;
|
||||
ciphertext?: string;
|
||||
decrypted?: { message?: string };
|
||||
},
|
||||
candidates: GroupTextResolutionCandidate[]
|
||||
): string | null {
|
||||
if (!payload.channelHash) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hashMatches = candidates.filter(
|
||||
(candidate) => candidate.hash === payload.channelHash?.toUpperCase()
|
||||
);
|
||||
if (hashMatches.length === 1) {
|
||||
return hashMatches[0].name;
|
||||
}
|
||||
if (
|
||||
hashMatches.length <= 1 ||
|
||||
!payload.cipherMac ||
|
||||
!payload.ciphertext ||
|
||||
!payload.decrypted?.message
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const decryptMatches = hashMatches.filter(
|
||||
(candidate) =>
|
||||
ChannelCrypto.decryptGroupTextMessage(payload.ciphertext!, payload.cipherMac!, candidate.key)
|
||||
.success
|
||||
);
|
||||
return decryptMatches.length === 1 ? decryptMatches[0].name : null;
|
||||
}
|
||||
|
||||
function packetShowsDecryptedState(
|
||||
packet: RawPacket,
|
||||
inspection: ReturnType<typeof inspectRawPacketWithOptions>
|
||||
): boolean {
|
||||
const payload = inspection.decoded?.payload.decoded as { decrypted?: unknown } | null | undefined;
|
||||
return packet.decrypted || Boolean(packet.decrypted_info) || Boolean(payload?.decrypted);
|
||||
}
|
||||
|
||||
function getPacketContext(
|
||||
packet: RawPacket,
|
||||
inspection: ReturnType<typeof inspectRawPacketWithOptions>,
|
||||
groupTextCandidates: GroupTextResolutionCandidate[]
|
||||
) {
|
||||
const fallbackSender = packet.decrypted_info?.sender ?? null;
|
||||
const fallbackRoom = packet.decrypted_info?.channel_name ?? null;
|
||||
|
||||
if (!inspection.decoded?.payload.decoded) {
|
||||
if (!fallbackSender && !fallbackRoom) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
title: fallbackRoom ? 'Room' : 'Context',
|
||||
primary: fallbackRoom ?? 'Sender metadata available',
|
||||
secondary: fallbackSender ? `Sender: ${fallbackSender}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
if (inspection.decoded.payloadType === PayloadType.GroupText) {
|
||||
const payload = inspection.decoded.payload.decoded as {
|
||||
channelHash?: string;
|
||||
cipherMac?: string;
|
||||
ciphertext?: string;
|
||||
decrypted?: { sender?: string; message?: string };
|
||||
};
|
||||
const roomName = fallbackRoom ?? resolveGroupTextRoomName(payload, groupTextCandidates);
|
||||
return {
|
||||
title: roomName ? 'Room' : 'Channel',
|
||||
primary:
|
||||
roomName ?? (payload.channelHash ? `Channel hash ${payload.channelHash}` : 'GroupText'),
|
||||
secondary: payload.decrypted?.sender
|
||||
? `Sender: ${payload.decrypted.sender}`
|
||||
: fallbackSender
|
||||
? `Sender: ${fallbackSender}`
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (fallbackSender) {
|
||||
return {
|
||||
title: 'Context',
|
||||
primary: fallbackSender,
|
||||
secondary: null,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildDisplayFields(inspection: ReturnType<typeof inspectRawPacketWithOptions>) {
|
||||
return [
|
||||
...inspection.packetFields.filter((field) => field.name !== 'Payload'),
|
||||
...inspection.payloadFields,
|
||||
];
|
||||
}
|
||||
|
||||
function buildFieldColorMap(fields: PacketByteField[]) {
|
||||
return new Map(
|
||||
fields.map((field, index) => [field.id, FIELD_PALETTE[index % FIELD_PALETTE.length]])
|
||||
);
|
||||
}
|
||||
|
||||
function buildByteOwners(totalBytes: number, fields: PacketByteField[]) {
|
||||
const owners = new Array<string | null>(totalBytes).fill(null);
|
||||
for (const field of fields) {
|
||||
for (let index = field.absoluteStartByte; index <= field.absoluteEndByte; index += 1) {
|
||||
if (index >= 0 && index < owners.length) {
|
||||
owners[index] = field.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
return owners;
|
||||
}
|
||||
|
||||
function buildByteRuns(bytes: string[], owners: Array<string | null>) {
|
||||
const runs: Array<{ fieldId: string | null; text: string }> = [];
|
||||
|
||||
for (let index = 0; index < bytes.length; index += 1) {
|
||||
const fieldId = owners[index];
|
||||
const lastRun = runs[runs.length - 1];
|
||||
if (lastRun && lastRun.fieldId === fieldId) {
|
||||
lastRun.text += ` ${bytes[index]}`;
|
||||
continue;
|
||||
}
|
||||
|
||||
runs.push({
|
||||
fieldId,
|
||||
text: bytes[index],
|
||||
});
|
||||
}
|
||||
|
||||
return runs;
|
||||
}
|
||||
|
||||
function CompactMetaCard({
|
||||
label,
|
||||
primary,
|
||||
secondary,
|
||||
}: {
|
||||
label: string;
|
||||
primary: string;
|
||||
secondary?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border/70 bg-card/70 p-2.5">
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 text-sm font-medium leading-tight text-foreground">{primary}</div>
|
||||
{secondary ? (
|
||||
<div className="mt-1 text-xs leading-tight text-muted-foreground">{secondary}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FullPacketHex({
|
||||
packetHex,
|
||||
fields,
|
||||
colorMap,
|
||||
hoveredFieldId,
|
||||
onHoverField,
|
||||
}: {
|
||||
packetHex: string;
|
||||
fields: PacketByteField[];
|
||||
colorMap: Map<string, FieldPaletteEntry>;
|
||||
hoveredFieldId: string | null;
|
||||
onHoverField: (fieldId: string | null) => void;
|
||||
}) {
|
||||
const normalized = packetHex.toUpperCase();
|
||||
const bytes = useMemo(() => normalized.match(/.{1,2}/g) ?? [], [normalized]);
|
||||
const byteOwners = useMemo(() => buildByteOwners(bytes.length, fields), [bytes.length, fields]);
|
||||
const byteRuns = useMemo(() => buildByteRuns(bytes, byteOwners), [byteOwners, bytes]);
|
||||
|
||||
return (
|
||||
<div className="font-mono text-[15px] leading-7 text-foreground">
|
||||
{byteRuns.map((run, index) => {
|
||||
const fieldId = run.fieldId;
|
||||
const palette = fieldId ? colorMap.get(fieldId) : null;
|
||||
const active = fieldId !== null && hoveredFieldId === fieldId;
|
||||
return (
|
||||
<span key={`${fieldId ?? 'plain'}-${index}`}>
|
||||
<span
|
||||
onMouseEnter={() => onHoverField(fieldId)}
|
||||
onMouseLeave={() => onHoverField(null)}
|
||||
className={cn(
|
||||
'inline rounded-sm px-0.5 py-0.5 transition-colors',
|
||||
palette ? (active ? palette.hexActive : palette.hex) : ''
|
||||
)}
|
||||
>
|
||||
{run.text}
|
||||
</span>
|
||||
{index < byteRuns.length - 1 ? ' ' : ''}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderFieldValue(field: PacketByteField) {
|
||||
if (field.name !== 'Path Data') {
|
||||
return field.value.toUpperCase();
|
||||
}
|
||||
|
||||
const parts = field.value
|
||||
.toUpperCase()
|
||||
.split(' → ')
|
||||
.filter((part) => part.length > 0);
|
||||
|
||||
if (parts.length <= 1) {
|
||||
return field.value.toUpperCase();
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex flex-wrap justify-start gap-x-1 sm:justify-end">
|
||||
{parts.map((part, index) => {
|
||||
const isLast = index === parts.length - 1;
|
||||
return (
|
||||
<span key={`${field.id}-${part}-${index}`} className="whitespace-nowrap">
|
||||
{isLast ? part : `${part} →`}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldBox({
|
||||
field,
|
||||
palette,
|
||||
active,
|
||||
onHoverField,
|
||||
}: {
|
||||
field: PacketByteField;
|
||||
palette: FieldPaletteEntry;
|
||||
active: boolean;
|
||||
onHoverField: (fieldId: string | null) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={() => onHoverField(field.id)}
|
||||
onMouseLeave={() => onHoverField(null)}
|
||||
className={cn(
|
||||
'rounded-lg border p-2.5 transition-colors',
|
||||
active ? palette.boxActive : palette.box
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-start gap-2 sm:flex-row sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="text-base font-semibold leading-tight text-foreground">{field.name}</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted-foreground">{formatByteRange(field)}</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'w-full font-mono text-sm leading-5 text-foreground sm:max-w-[14rem] sm:text-right',
|
||||
field.name === 'Path Data' ? 'break-normal' : 'break-all'
|
||||
)}
|
||||
>
|
||||
{renderFieldValue(field)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 whitespace-pre-wrap text-sm leading-5 text-foreground">
|
||||
{field.description}
|
||||
</div>
|
||||
|
||||
{field.decryptedMessage ? (
|
||||
<div className="mt-2 rounded border border-border/50 bg-background/40 p-2">
|
||||
<div className="text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{field.name === 'Ciphertext' ? 'Plaintext' : 'Decoded value'}
|
||||
</div>
|
||||
<PlaintextContent text={field.decryptedMessage} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{field.headerBreakdown ? (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<div className="font-mono text-xs tracking-[0.16em] text-muted-foreground">
|
||||
{field.headerBreakdown.fullBinary}
|
||||
</div>
|
||||
{field.headerBreakdown.fields.map((part) => (
|
||||
<div
|
||||
key={`${field.id}-${part.bits}-${part.field}`}
|
||||
className="rounded border border-border/50 bg-background/40 p-2"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium leading-tight text-foreground">
|
||||
{part.field}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted-foreground">Bits {part.bits}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-mono text-sm text-foreground">{part.binary}</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted-foreground">{part.value}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlaintextContent({ text }: { text: string }) {
|
||||
const lines = text.split('\n');
|
||||
|
||||
return (
|
||||
<div className="mt-1 space-y-1 text-sm leading-5 text-foreground">
|
||||
{lines.map((line, index) => {
|
||||
const separatorIndex = line.indexOf(': ');
|
||||
if (separatorIndex === -1) {
|
||||
return (
|
||||
<div key={`${line}-${index}`} className="font-mono">
|
||||
{line}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const label = line.slice(0, separatorIndex + 1);
|
||||
const value = line.slice(separatorIndex + 2);
|
||||
|
||||
return (
|
||||
<div key={`${line}-${index}`}>
|
||||
<span>{label} </span>
|
||||
<span className="font-mono">{value}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldSection({
|
||||
title,
|
||||
fields,
|
||||
colorMap,
|
||||
hoveredFieldId,
|
||||
onHoverField,
|
||||
}: {
|
||||
title: string;
|
||||
fields: PacketByteField[];
|
||||
colorMap: Map<string, FieldPaletteEntry>;
|
||||
hoveredFieldId: string | null;
|
||||
onHoverField: (fieldId: string | null) => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-lg border border-border/70 bg-card/70 p-3">
|
||||
<div className="mb-2 text-sm font-semibold text-foreground">{title}</div>
|
||||
{fields.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">No decoded fields available.</div>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
{fields.map((field) => (
|
||||
<FieldBox
|
||||
key={field.id}
|
||||
field={field}
|
||||
palette={colorMap.get(field.id) ?? FIELD_PALETTE[0]}
|
||||
active={hoveredFieldId === field.id}
|
||||
onHoverField={onHoverField}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function RawPacketDetailModal({ packet, channels, onClose }: RawPacketDetailModalProps) {
|
||||
const decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]);
|
||||
const groupTextCandidates = useMemo(
|
||||
() => buildGroupTextResolutionCandidates(channels),
|
||||
[channels]
|
||||
);
|
||||
const inspection = useMemo(
|
||||
() => (packet ? inspectRawPacketWithOptions(packet, decoderOptions) : null),
|
||||
[decoderOptions, packet]
|
||||
);
|
||||
const [hoveredFieldId, setHoveredFieldId] = useState<string | null>(null);
|
||||
|
||||
const packetDisplayFields = useMemo(
|
||||
() => (inspection ? inspection.packetFields.filter((field) => field.name !== 'Payload') : []),
|
||||
[inspection]
|
||||
);
|
||||
const fullPacketFields = useMemo(
|
||||
() => (inspection ? buildDisplayFields(inspection) : []),
|
||||
[inspection]
|
||||
);
|
||||
const colorMap = useMemo(() => buildFieldColorMap(fullPacketFields), [fullPacketFields]);
|
||||
const packetContext = useMemo(
|
||||
() => (packet && inspection ? getPacketContext(packet, inspection, groupTextCandidates) : null),
|
||||
[groupTextCandidates, inspection, packet]
|
||||
);
|
||||
const packetIsDecrypted = useMemo(
|
||||
() => (packet && inspection ? packetShowsDecryptedState(packet, inspection) : false),
|
||||
[inspection, packet]
|
||||
);
|
||||
|
||||
if (!packet || !inspection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={packet !== null} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="flex h-[92vh] max-w-[min(96vw,82rem)] flex-col gap-0 overflow-hidden p-0">
|
||||
<DialogHeader className="border-b border-border px-5 py-3">
|
||||
<DialogTitle>Packet Details</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Detailed byte and field breakdown for the selected raw packet.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
<div className="grid gap-2 lg:grid-cols-[minmax(0,1.45fr)_minmax(0,1fr)]">
|
||||
<section className="rounded-lg border border-border/70 bg-card/70 p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Summary
|
||||
</div>
|
||||
<div className="mt-1 text-base font-semibold leading-tight text-foreground">
|
||||
{inspection.summary.summary}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-xs text-muted-foreground">
|
||||
{formatTimestamp(packet.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
{packetContext ? (
|
||||
<div className="mt-2 rounded-md border border-border/60 bg-background/35 px-2.5 py-2">
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{packetContext.title}
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-medium leading-tight text-foreground">
|
||||
{packetContext.primary}
|
||||
</div>
|
||||
{packetContext.secondary ? (
|
||||
<div className="mt-1 text-xs leading-tight text-muted-foreground">
|
||||
{packetContext.secondary}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-2 sm:grid-cols-3 lg:grid-cols-1 xl:grid-cols-3">
|
||||
<CompactMetaCard
|
||||
label="Packet"
|
||||
primary={`${packet.data.length / 2} bytes · ${packetIsDecrypted ? 'Decrypted' : 'Encrypted'}`}
|
||||
secondary={`Storage #${packet.id}${packet.observation_id !== undefined ? ` · Observation #${packet.observation_id}` : ''}`}
|
||||
/>
|
||||
<CompactMetaCard
|
||||
label="Transport"
|
||||
primary={`${inspection.routeTypeName} · ${inspection.payloadTypeName}`}
|
||||
secondary={`${inspection.payloadVersionName} · ${formatPathMode(inspection.decoded?.pathHashSize, inspection.pathTokens.length)}`}
|
||||
/>
|
||||
<CompactMetaCard
|
||||
label="Signal"
|
||||
primary={formatSignal(packet)}
|
||||
secondary={packetContext ? null : undefined}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{inspection.validationErrors.length > 0 ? (
|
||||
<div className="mt-3 rounded-lg border border-warning/40 bg-warning/10 p-2.5">
|
||||
<div className="text-sm font-semibold text-foreground">Validation notes</div>
|
||||
<div className="mt-1.5 space-y-1 text-sm text-foreground">
|
||||
{inspection.validationErrors.map((error) => (
|
||||
<div key={error}>{error}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 rounded-lg border border-border/70 bg-card/70 p-3">
|
||||
<div className="text-xl font-semibold text-foreground">Full packet hex</div>
|
||||
<div className="mt-2.5">
|
||||
<FullPacketHex
|
||||
packetHex={packet.data}
|
||||
fields={fullPacketFields}
|
||||
colorMap={colorMap}
|
||||
hoveredFieldId={hoveredFieldId}
|
||||
onHoverField={setHoveredFieldId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-3 xl:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)]">
|
||||
<FieldSection
|
||||
title="Packet fields"
|
||||
fields={packetDisplayFields}
|
||||
colorMap={colorMap}
|
||||
hoveredFieldId={hoveredFieldId}
|
||||
onHoverField={setHoveredFieldId}
|
||||
/>
|
||||
|
||||
<FieldSection
|
||||
title="Payload fields"
|
||||
fields={inspection.payloadFields}
|
||||
colorMap={colorMap}
|
||||
hoveredFieldId={hoveredFieldId}
|
||||
onHoverField={setHoveredFieldId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
548
frontend/src/components/RawPacketFeedView.tsx
Normal file
548
frontend/src/components/RawPacketFeedView.tsx
Normal file
@@ -0,0 +1,548 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
import { RawPacketList } from './RawPacketList';
|
||||
import { RawPacketDetailModal } from './RawPacketDetailModal';
|
||||
import type { Channel, Contact, RawPacket } from '../types';
|
||||
import {
|
||||
RAW_PACKET_STATS_WINDOWS,
|
||||
buildRawPacketStatsSnapshot,
|
||||
type NeighborStat,
|
||||
type PacketTimelineBin,
|
||||
type RankedPacketStat,
|
||||
type RawPacketStatsSessionState,
|
||||
type RawPacketStatsWindow,
|
||||
} from '../utils/rawPacketStats';
|
||||
import { getContactDisplayName } from '../utils/pubkey';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface RawPacketFeedViewProps {
|
||||
packets: RawPacket[];
|
||||
rawPacketStatsSession: RawPacketStatsSessionState;
|
||||
contacts: Contact[];
|
||||
channels: Channel[];
|
||||
}
|
||||
|
||||
const WINDOW_LABELS: Record<RawPacketStatsWindow, string> = {
|
||||
'1m': '1 min',
|
||||
'5m': '5 min',
|
||||
'10m': '10 min',
|
||||
'30m': '30 min',
|
||||
session: 'Session',
|
||||
};
|
||||
|
||||
const TIMELINE_COLORS = [
|
||||
'bg-sky-500/80',
|
||||
'bg-emerald-500/80',
|
||||
'bg-amber-500/80',
|
||||
'bg-rose-500/80',
|
||||
'bg-violet-500/80',
|
||||
];
|
||||
|
||||
function formatTimestamp(timestampMs: number): string {
|
||||
return new Date(timestampMs).toLocaleString([], {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (seconds < 60) {
|
||||
return `${Math.max(1, Math.round(seconds))} sec`;
|
||||
}
|
||||
if (seconds < 3600) {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainder = Math.round(seconds % 60);
|
||||
return remainder > 0 ? `${minutes}m ${remainder}s` : `${minutes}m`;
|
||||
}
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.round((seconds % 3600) / 60);
|
||||
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
|
||||
}
|
||||
|
||||
function formatRate(value: number): string {
|
||||
if (value >= 100) return value.toFixed(0);
|
||||
if (value >= 10) return value.toFixed(1);
|
||||
return value.toFixed(2);
|
||||
}
|
||||
|
||||
function formatPercent(value: number): string {
|
||||
return `${Math.round(value * 100)}%`;
|
||||
}
|
||||
|
||||
function formatRssi(value: number | null): string {
|
||||
return value === null ? '-' : `${Math.round(value)} dBm`;
|
||||
}
|
||||
|
||||
function resolveContactLabel(sourceKey: string | null, contacts: Contact[]): string | null {
|
||||
if (!sourceKey || sourceKey.startsWith('name:')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedSourceKey = sourceKey.toLowerCase();
|
||||
const matches = contacts.filter((contact) =>
|
||||
contact.public_key.toLowerCase().startsWith(normalizedSourceKey)
|
||||
);
|
||||
if (matches.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contact = matches[0];
|
||||
return getContactDisplayName(contact.name, contact.public_key, contact.last_advert);
|
||||
}
|
||||
|
||||
function resolveNeighbor(item: NeighborStat, contacts: Contact[]): NeighborStat {
|
||||
return {
|
||||
...item,
|
||||
label: resolveContactLabel(item.key, contacts) ?? item.label,
|
||||
};
|
||||
}
|
||||
|
||||
function isNeighborIdentityResolvable(item: NeighborStat, contacts: Contact[]): boolean {
|
||||
if (item.key.startsWith('name:')) {
|
||||
return true;
|
||||
}
|
||||
return resolveContactLabel(item.key, contacts) !== null;
|
||||
}
|
||||
|
||||
function formatStrongestPacketDetail(
|
||||
stats: ReturnType<typeof buildRawPacketStatsSnapshot>,
|
||||
contacts: Contact[]
|
||||
): string | undefined {
|
||||
if (!stats.strongestPacketPayloadType) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const resolvedLabel =
|
||||
resolveContactLabel(stats.strongestPacketSourceKey, contacts) ??
|
||||
stats.strongestPacketSourceLabel;
|
||||
if (resolvedLabel) {
|
||||
return `${resolvedLabel} · ${stats.strongestPacketPayloadType}`;
|
||||
}
|
||||
if (stats.strongestPacketPayloadType === 'GroupText') {
|
||||
return '<unknown sender> · GroupText';
|
||||
}
|
||||
return stats.strongestPacketPayloadType;
|
||||
}
|
||||
|
||||
function getCoverageMessage(
|
||||
stats: ReturnType<typeof buildRawPacketStatsSnapshot>,
|
||||
session: RawPacketStatsSessionState
|
||||
): { tone: 'default' | 'warning'; message: string } {
|
||||
if (session.trimmedObservationCount > 0 && stats.window === 'session') {
|
||||
return {
|
||||
tone: 'warning',
|
||||
message: `Detailed session history was trimmed after ${session.totalObservedPackets.toLocaleString()} observations.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!stats.windowFullyCovered) {
|
||||
return {
|
||||
tone: 'warning',
|
||||
message: `This window is only covered for ${formatDuration(stats.coverageSeconds)} of frontend-collected history.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
tone: 'default',
|
||||
message: `Tracking ${session.observations.length.toLocaleString()} detailed observations from this browser session.`,
|
||||
};
|
||||
}
|
||||
|
||||
function StatTile({ label, value, detail }: { label: string; value: string; detail?: string }) {
|
||||
return (
|
||||
<div className="break-inside-avoid rounded-lg border border-border/70 bg-card/80 p-3">
|
||||
<div className="text-[11px] uppercase tracking-wide text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 text-xl font-semibold tabular-nums text-foreground">{value}</div>
|
||||
{detail ? <div className="mt-1 text-xs text-muted-foreground">{detail}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RankedBars({
|
||||
title,
|
||||
items,
|
||||
emptyLabel,
|
||||
formatter,
|
||||
}: {
|
||||
title: string;
|
||||
items: RankedPacketStat[];
|
||||
emptyLabel: string;
|
||||
formatter?: (item: RankedPacketStat) => string;
|
||||
}) {
|
||||
const maxCount = Math.max(...items.map((item) => item.count), 1);
|
||||
|
||||
return (
|
||||
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
|
||||
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
|
||||
{items.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-muted-foreground">{emptyLabel}</p>
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{items.map((item) => (
|
||||
<div key={item.label}>
|
||||
<div className="mb-1 flex items-center justify-between gap-3 text-xs">
|
||||
<span className="truncate text-foreground">{item.label}</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||
{formatter
|
||||
? formatter(item)
|
||||
: `${item.count.toLocaleString()} · ${formatPercent(item.share)}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary/80"
|
||||
style={{ width: `${(item.count / maxCount) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function NeighborList({
|
||||
title,
|
||||
items,
|
||||
emptyLabel,
|
||||
mode,
|
||||
contacts,
|
||||
}: {
|
||||
title: string;
|
||||
items: NeighborStat[];
|
||||
emptyLabel: string;
|
||||
mode: 'heard' | 'signal' | 'recent';
|
||||
contacts: Contact[];
|
||||
}) {
|
||||
return (
|
||||
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
|
||||
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
|
||||
{items.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-muted-foreground">{emptyLabel}</p>
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className="flex items-center justify-between gap-3 rounded-md bg-background/70 px-2 py-1.5"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm text-foreground">{item.label}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{mode === 'heard'
|
||||
? `${item.count.toLocaleString()} packets`
|
||||
: mode === 'signal'
|
||||
? `${formatRssi(item.bestRssi)} best`
|
||||
: `Last seen ${new Date(item.lastSeen * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`}
|
||||
</div>
|
||||
{!isNeighborIdentityResolvable(item, contacts) ? (
|
||||
<div className="text-[11px] text-warning">Identity not resolvable</div>
|
||||
) : null}
|
||||
</div>
|
||||
{mode !== 'signal' ? (
|
||||
<div className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||
{mode === 'recent' ? formatRssi(item.bestRssi) : formatRssi(item.bestRssi)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineChart({ bins }: { bins: PacketTimelineBin[] }) {
|
||||
const maxTotal = Math.max(...bins.map((bin) => bin.total), 1);
|
||||
const typeOrder = Array.from(new Set(bins.flatMap((bin) => Object.keys(bin.countsByType)))).slice(
|
||||
0,
|
||||
TIMELINE_COLORS.length
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold text-foreground">Traffic Timeline</h3>
|
||||
<div className="flex flex-wrap justify-end gap-2 text-[11px] text-muted-foreground">
|
||||
{typeOrder.map((type, index) => (
|
||||
<span key={type} className="inline-flex items-center gap-1">
|
||||
<span className={cn('h-2 w-2 rounded-full', TIMELINE_COLORS[index])} />
|
||||
<span>{type}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-start gap-1">
|
||||
{bins.map((bin, index) => (
|
||||
<div
|
||||
key={`${bin.label}-${index}`}
|
||||
className="flex min-w-0 flex-1 flex-col items-center gap-1"
|
||||
>
|
||||
<div className="flex h-24 w-full items-end overflow-hidden rounded-sm bg-muted/60">
|
||||
<div className="flex h-full w-full flex-col justify-end">
|
||||
{typeOrder.map((type, index) => {
|
||||
const count = bin.countsByType[type] ?? 0;
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<div
|
||||
key={type}
|
||||
className={cn('w-full', TIMELINE_COLORS[index])}
|
||||
style={{
|
||||
height: `${(count / maxTotal) * 100}%`,
|
||||
}}
|
||||
title={`${bin.label}: ${type} ${count.toLocaleString()}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">{bin.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function RawPacketFeedView({
|
||||
packets,
|
||||
rawPacketStatsSession,
|
||||
contacts,
|
||||
channels,
|
||||
}: RawPacketFeedViewProps) {
|
||||
const [statsOpen, setStatsOpen] = useState(() =>
|
||||
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
||||
? window.matchMedia('(min-width: 768px)').matches
|
||||
: false
|
||||
);
|
||||
const [selectedWindow, setSelectedWindow] = useState<RawPacketStatsWindow>('10m');
|
||||
const [nowSec, setNowSec] = useState(() => Math.floor(Date.now() / 1000));
|
||||
const [selectedPacket, setSelectedPacket] = useState<RawPacket | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = window.setInterval(() => {
|
||||
setNowSec(Math.floor(Date.now() / 1000));
|
||||
}, 30000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setNowSec(Math.floor(Date.now() / 1000));
|
||||
}, [packets, rawPacketStatsSession]);
|
||||
|
||||
const stats = useMemo(
|
||||
() => buildRawPacketStatsSnapshot(rawPacketStatsSession, selectedWindow, nowSec),
|
||||
[nowSec, rawPacketStatsSession, selectedWindow]
|
||||
);
|
||||
const coverageMessage = getCoverageMessage(stats, rawPacketStatsSession);
|
||||
const strongestPacketDetail = useMemo(
|
||||
() => formatStrongestPacketDetail(stats, contacts),
|
||||
[contacts, stats]
|
||||
);
|
||||
const strongestNeighbors = useMemo(
|
||||
() => stats.strongestNeighbors.map((item) => resolveNeighbor(item, contacts)),
|
||||
[contacts, stats.strongestNeighbors]
|
||||
);
|
||||
const mostActiveNeighbors = useMemo(
|
||||
() => stats.mostActiveNeighbors.map((item) => resolveNeighbor(item, contacts)),
|
||||
[contacts, stats.mostActiveNeighbors]
|
||||
);
|
||||
const newestNeighbors = useMemo(
|
||||
() => stats.newestNeighbors.map((item) => resolveNeighbor(item, contacts)),
|
||||
[contacts, stats.newestNeighbors]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-border px-4 py-2.5">
|
||||
<div>
|
||||
<h2 className="font-semibold text-base text-foreground">Raw Packet Feed</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Collecting stats since {formatTimestamp(rawPacketStatsSession.sessionStartedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatsOpen((current) => !current)}
|
||||
aria-expanded={statsOpen}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{statsOpen ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
|
||||
{statsOpen ? 'Hide Stats' : 'Show Stats'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
|
||||
<div className={cn('min-h-0 min-w-0 flex-1', statsOpen && 'md:border-r md:border-border')}>
|
||||
<RawPacketList packets={packets} channels={channels} onPacketClick={setSelectedPacket} />
|
||||
</div>
|
||||
|
||||
<aside
|
||||
className={cn(
|
||||
'shrink-0 overflow-hidden border-t border-border transition-all duration-300 md:border-l md:border-t-0',
|
||||
statsOpen
|
||||
? 'max-h-[42rem] md:max-h-none md:w-1/2 md:min-w-[30rem]'
|
||||
: 'max-h-0 md:w-0 md:min-w-0 border-transparent'
|
||||
)}
|
||||
>
|
||||
{statsOpen ? (
|
||||
<div className="h-full overflow-y-auto bg-background p-4 [contain:layout_paint]">
|
||||
<div className="break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||
Coverage
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'mt-1 text-sm',
|
||||
coverageMessage.tone === 'warning'
|
||||
? 'text-warning'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{coverageMessage.message}
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<span className="text-muted-foreground">Window</span>
|
||||
<select
|
||||
value={selectedWindow}
|
||||
onChange={(event) =>
|
||||
setSelectedWindow(event.target.value as RawPacketStatsWindow)
|
||||
}
|
||||
className="rounded-md border border-input bg-background px-2 py-1 text-sm"
|
||||
aria-label="Stats window"
|
||||
>
|
||||
{RAW_PACKET_STATS_WINDOWS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{WINDOW_LABELS[option]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
{stats.packetCount.toLocaleString()} packets in{' '}
|
||||
{WINDOW_LABELS[selectedWindow].toLowerCase()} window
|
||||
{' · '}
|
||||
{rawPacketStatsSession.totalObservedPackets.toLocaleString()} observed this
|
||||
session
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 md:grid-cols-3">
|
||||
<StatTile
|
||||
label="Packets / min"
|
||||
value={formatRate(stats.packetsPerMinute)}
|
||||
detail={`${stats.packetCount.toLocaleString()} total in window`}
|
||||
/>
|
||||
<StatTile
|
||||
label="Unique Sources"
|
||||
value={stats.uniqueSources.toLocaleString()}
|
||||
detail="Distinct identified senders"
|
||||
/>
|
||||
<StatTile
|
||||
label="Decrypt Rate"
|
||||
value={formatPercent(stats.decryptRate)}
|
||||
detail={`${stats.decryptedCount.toLocaleString()} decrypted / ${stats.undecryptedCount.toLocaleString()} locked`}
|
||||
/>
|
||||
<StatTile
|
||||
label="Path Diversity"
|
||||
value={stats.distinctPaths.toLocaleString()}
|
||||
detail={`${formatPercent(stats.pathBearingRate)} path-bearing packets`}
|
||||
/>
|
||||
<StatTile
|
||||
label="Best RSSI"
|
||||
value={formatRssi(stats.bestRssi)}
|
||||
detail={strongestPacketDetail ?? 'No signal sample in window'}
|
||||
/>
|
||||
<StatTile
|
||||
label="Median RSSI"
|
||||
value={formatRssi(stats.medianRssi)}
|
||||
detail={
|
||||
stats.averageRssi === null
|
||||
? 'No signal sample in window'
|
||||
: `Average ${formatRssi(stats.averageRssi)}`
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<TimelineChart bins={stats.timeline} />
|
||||
</div>
|
||||
|
||||
<div className="md:columns-2 md:gap-4">
|
||||
<RankedBars
|
||||
title="Packet Types"
|
||||
items={stats.payloadBreakdown}
|
||||
emptyLabel="No packets in this window yet."
|
||||
/>
|
||||
|
||||
<RankedBars
|
||||
title="Route Mix"
|
||||
items={stats.routeBreakdown}
|
||||
emptyLabel="No packets in this window yet."
|
||||
/>
|
||||
|
||||
<RankedBars
|
||||
title="Hop Profile"
|
||||
items={stats.hopProfile}
|
||||
emptyLabel="No packets in this window yet."
|
||||
/>
|
||||
|
||||
<RankedBars
|
||||
title="Hop Byte Width"
|
||||
items={stats.hopByteWidthProfile}
|
||||
emptyLabel="No packets in this window yet."
|
||||
/>
|
||||
|
||||
<RankedBars
|
||||
title="Signal Distribution"
|
||||
items={stats.rssiBuckets}
|
||||
emptyLabel="No RSSI samples in this window yet."
|
||||
/>
|
||||
|
||||
<NeighborList
|
||||
title="Most-Heard Neighbors"
|
||||
items={mostActiveNeighbors}
|
||||
emptyLabel="No sender identities resolved in this window yet."
|
||||
mode="heard"
|
||||
contacts={contacts}
|
||||
/>
|
||||
|
||||
<NeighborList
|
||||
title="Strongest Recent Neighbors"
|
||||
items={strongestNeighbors}
|
||||
emptyLabel="No RSSI-tagged neighbors in this window yet."
|
||||
mode="signal"
|
||||
contacts={contacts}
|
||||
/>
|
||||
|
||||
<NeighborList
|
||||
title="Newest Heard Neighbors"
|
||||
items={newestNeighbors}
|
||||
emptyLabel="No newly identified neighbors in this window yet."
|
||||
mode="recent"
|
||||
contacts={contacts}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<RawPacketDetailModal
|
||||
packet={selectedPacket}
|
||||
channels={channels}
|
||||
onClose={() => setSelectedPacket(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useEffect, useRef, useMemo } from 'react';
|
||||
import { MeshCoreDecoder, PayloadType, Utils } from '@michaelhart/meshcore-decoder';
|
||||
import type { RawPacket } from '../types';
|
||||
import type { Channel, RawPacket } from '../types';
|
||||
import { getRawPacketObservationKey } from '../utils/rawPacketIdentity';
|
||||
import { createDecoderOptions, decodePacketSummary } from '../utils/rawPacketInspector';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface RawPacketListProps {
|
||||
packets: RawPacket[];
|
||||
channels?: Channel[];
|
||||
onPacketClick?: (packet: RawPacket) => void;
|
||||
}
|
||||
|
||||
function formatTime(timestamp: number): string {
|
||||
@@ -24,132 +26,6 @@ function formatSignalInfo(packet: RawPacket): string {
|
||||
return parts.join(' | ');
|
||||
}
|
||||
|
||||
// Decrypted info from the packet (validated by backend)
|
||||
interface DecryptedInfo {
|
||||
channel_name: string | null;
|
||||
sender: string | null;
|
||||
}
|
||||
|
||||
// Decode a packet and generate a human-readable summary
|
||||
// Uses backend's decrypted_info when available (validated), falls back to decoder
|
||||
function decodePacketSummary(
|
||||
hexData: string,
|
||||
decryptedInfo: DecryptedInfo | null
|
||||
): {
|
||||
summary: string;
|
||||
routeType: string;
|
||||
details?: string;
|
||||
} {
|
||||
try {
|
||||
const decoded = MeshCoreDecoder.decode(hexData);
|
||||
|
||||
if (!decoded.isValid) {
|
||||
return { summary: 'Invalid packet', routeType: 'Unknown' };
|
||||
}
|
||||
|
||||
const routeType = Utils.getRouteTypeName(decoded.routeType);
|
||||
const payloadTypeName = Utils.getPayloadTypeName(decoded.payloadType);
|
||||
const tracePayload =
|
||||
decoded.payloadType === PayloadType.Trace && decoded.payload.decoded
|
||||
? (decoded.payload.decoded as { pathHashes?: string[] })
|
||||
: null;
|
||||
const pathTokens = tracePayload?.pathHashes || decoded.path || [];
|
||||
|
||||
// Build path string if available
|
||||
const pathStr = pathTokens.length > 0 ? ` via ${pathTokens.join('-')}` : '';
|
||||
|
||||
// Generate summary based on payload type
|
||||
let summary = payloadTypeName;
|
||||
let details: string | undefined;
|
||||
|
||||
switch (decoded.payloadType) {
|
||||
case PayloadType.TextMessage: {
|
||||
const payload = decoded.payload.decoded as {
|
||||
destinationHash?: string;
|
||||
sourceHash?: string;
|
||||
} | null;
|
||||
if (payload?.sourceHash && payload?.destinationHash) {
|
||||
summary = `DM from ${payload.sourceHash} to ${payload.destinationHash}${pathStr}`;
|
||||
} else {
|
||||
summary = `DM${pathStr}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case PayloadType.GroupText: {
|
||||
const payload = decoded.payload.decoded as {
|
||||
channelHash?: string;
|
||||
} | null;
|
||||
// Use backend's validated decrypted_info when available
|
||||
if (decryptedInfo?.channel_name) {
|
||||
if (decryptedInfo.sender) {
|
||||
summary = `GT from ${decryptedInfo.sender} in ${decryptedInfo.channel_name}${pathStr}`;
|
||||
} else {
|
||||
summary = `GT in ${decryptedInfo.channel_name}${pathStr}`;
|
||||
}
|
||||
} else if (payload?.channelHash) {
|
||||
// Fallback to showing channel hash when not decrypted
|
||||
summary = `GT ch:${payload.channelHash}${pathStr}`;
|
||||
} else {
|
||||
summary = `GroupText${pathStr}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case PayloadType.Advert: {
|
||||
const payload = decoded.payload.decoded as {
|
||||
publicKey?: string;
|
||||
appData?: { name?: string; deviceRole?: number };
|
||||
} | null;
|
||||
if (payload?.appData?.name) {
|
||||
const role =
|
||||
payload.appData.deviceRole !== undefined
|
||||
? Utils.getDeviceRoleName(payload.appData.deviceRole)
|
||||
: '';
|
||||
summary = `Advert: ${payload.appData.name}${role ? ` (${role})` : ''}${pathStr}`;
|
||||
} else if (payload?.publicKey) {
|
||||
summary = `Advert: ${payload.publicKey.slice(0, 8)}...${pathStr}`;
|
||||
} else {
|
||||
summary = `Advert${pathStr}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case PayloadType.Ack: {
|
||||
summary = `ACK${pathStr}`;
|
||||
break;
|
||||
}
|
||||
|
||||
case PayloadType.Request: {
|
||||
summary = `Request${pathStr}`;
|
||||
break;
|
||||
}
|
||||
|
||||
case PayloadType.Response: {
|
||||
summary = `Response${pathStr}`;
|
||||
break;
|
||||
}
|
||||
|
||||
case PayloadType.Trace: {
|
||||
summary = `Trace${pathStr}`;
|
||||
break;
|
||||
}
|
||||
|
||||
case PayloadType.Path: {
|
||||
summary = `Path${pathStr}`;
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
summary = `${payloadTypeName}${pathStr}`;
|
||||
}
|
||||
|
||||
return { summary, routeType, details };
|
||||
} catch {
|
||||
return { summary: 'Decode error', routeType: 'Unknown' };
|
||||
}
|
||||
}
|
||||
|
||||
// Get route type badge color
|
||||
function getRouteTypeColor(routeType: string): string {
|
||||
switch (routeType) {
|
||||
@@ -182,16 +58,17 @@ function getRouteTypeLabel(routeType: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function RawPacketList({ packets }: RawPacketListProps) {
|
||||
export function RawPacketList({ packets, channels, onPacketClick }: RawPacketListProps) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]);
|
||||
|
||||
// Decode all packets (memoized to avoid re-decoding on every render)
|
||||
const decodedPackets = useMemo(() => {
|
||||
return packets.map((packet) => ({
|
||||
packet,
|
||||
decoded: decodePacketSummary(packet.data, packet.decrypted_info),
|
||||
decoded: decodePacketSummary(packet, decoderOptions),
|
||||
}));
|
||||
}, [packets]);
|
||||
}, [decoderOptions, packets]);
|
||||
|
||||
// Sort packets by timestamp ascending (oldest first)
|
||||
const sortedPackets = useMemo(
|
||||
@@ -218,54 +95,78 @@ export function RawPacketList({ packets }: RawPacketListProps) {
|
||||
className="h-full overflow-y-auto p-4 flex flex-col gap-2 [contain:layout_paint]"
|
||||
ref={listRef}
|
||||
>
|
||||
{sortedPackets.map(({ packet, decoded }) => (
|
||||
<div
|
||||
key={getRawPacketObservationKey(packet)}
|
||||
className="py-2 px-3 bg-card rounded-md border border-border/50"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Route type badge */}
|
||||
<span
|
||||
className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${getRouteTypeColor(decoded.routeType)}`}
|
||||
title={decoded.routeType}
|
||||
>
|
||||
{getRouteTypeLabel(decoded.routeType)}
|
||||
</span>
|
||||
{sortedPackets.map(({ packet, decoded }) => {
|
||||
const cardContent = (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Route type badge */}
|
||||
<span
|
||||
className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${getRouteTypeColor(decoded.routeType)}`}
|
||||
title={decoded.routeType}
|
||||
>
|
||||
{getRouteTypeLabel(decoded.routeType)}
|
||||
</span>
|
||||
|
||||
{/* Encryption status */}
|
||||
{!packet.decrypted && (
|
||||
<>
|
||||
<span aria-hidden="true">🔒</span>
|
||||
<span className="sr-only">Encrypted</span>
|
||||
</>
|
||||
{/* Encryption status */}
|
||||
{!packet.decrypted && (
|
||||
<>
|
||||
<span aria-hidden="true">🔒</span>
|
||||
<span className="sr-only">Encrypted</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
<span
|
||||
className={cn('text-[13px]', packet.decrypted ? 'text-primary' : 'text-foreground')}
|
||||
>
|
||||
{decoded.summary}
|
||||
</span>
|
||||
|
||||
{/* Time */}
|
||||
<span className="text-muted-foreground ml-auto text-[12px] tabular-nums">
|
||||
{formatTime(packet.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Signal info */}
|
||||
{(packet.snr !== null || packet.rssi !== null) && (
|
||||
<div className="text-[11px] text-muted-foreground mt-0.5 tabular-nums">
|
||||
{formatSignalInfo(packet)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
<span
|
||||
className={cn('text-[13px]', packet.decrypted ? 'text-primary' : 'text-foreground')}
|
||||
>
|
||||
{decoded.summary}
|
||||
</span>
|
||||
|
||||
{/* Time */}
|
||||
<span className="text-muted-foreground ml-auto text-[12px] tabular-nums">
|
||||
{formatTime(packet.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Signal info */}
|
||||
{(packet.snr !== null || packet.rssi !== null) && (
|
||||
<div className="text-[11px] text-muted-foreground mt-0.5 tabular-nums">
|
||||
{formatSignalInfo(packet)}
|
||||
{/* Raw hex data (always visible) */}
|
||||
<div className="font-mono text-[10px] break-all text-muted-foreground mt-1.5 p-1.5 bg-background/60 rounded">
|
||||
{packet.data.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
{/* Raw hex data (always visible) */}
|
||||
<div className="font-mono text-[10px] break-all text-muted-foreground mt-1.5 p-1.5 bg-background/60 rounded">
|
||||
{packet.data.toUpperCase()}
|
||||
const className = cn(
|
||||
'rounded-md border border-border/50 bg-card px-3 py-2 text-left',
|
||||
onPacketClick &&
|
||||
'cursor-pointer transition-colors hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
||||
);
|
||||
|
||||
if (onPacketClick) {
|
||||
return (
|
||||
<button
|
||||
key={getRawPacketObservationKey(packet)}
|
||||
type="button"
|
||||
onClick={() => onPacketClick(packet)}
|
||||
className={className}
|
||||
>
|
||||
{cardContent}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={getRawPacketObservationKey(packet)} className={className}>
|
||||
{cardContent}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from './ui/button';
|
||||
import { Bell, Route, Star, Trash2 } from 'lucide-react';
|
||||
import { DirectTraceIcon } from './DirectTraceIcon';
|
||||
import { RepeaterLogin } from './RepeaterLogin';
|
||||
import { useRememberedServerPassword } from '../hooks/useRememberedServerPassword';
|
||||
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
@@ -81,8 +82,18 @@ export function RepeaterDashboard({
|
||||
rebootRepeater,
|
||||
syncClock,
|
||||
} = useRepeaterDashboard(conversation, { hasAdvertLocation });
|
||||
const { password, setPassword, rememberPassword, setRememberPassword, persistAfterLogin } =
|
||||
useRememberedServerPassword('repeater', conversation.id);
|
||||
|
||||
const isFav = isFavorite(favorites, 'contact', conversation.id);
|
||||
const handleRepeaterLogin = async (nextPassword: string) => {
|
||||
await login(nextPassword);
|
||||
persistAfterLogin(nextPassword);
|
||||
};
|
||||
const handleRepeaterGuestLogin = async () => {
|
||||
await loginAsGuest();
|
||||
persistAfterLogin('');
|
||||
};
|
||||
|
||||
// Loading all panes indicator
|
||||
const anyLoading = Object.values(paneStates).some((s) => s.loading);
|
||||
@@ -221,8 +232,12 @@ export function RepeaterDashboard({
|
||||
repeaterName={conversation.name}
|
||||
loading={loginLoading}
|
||||
error={loginError}
|
||||
onLogin={login}
|
||||
onLoginAsGuest={loginAsGuest}
|
||||
password={password}
|
||||
onPasswordChange={setPassword}
|
||||
rememberPassword={rememberPassword}
|
||||
onRememberPasswordChange={setRememberPassword}
|
||||
onLogin={handleRepeaterLogin}
|
||||
onLoginAsGuest={handleRepeaterGuestLogin}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -1,24 +1,39 @@
|
||||
import { useState, useCallback, type FormEvent } from 'react';
|
||||
import { useCallback, type FormEvent } from 'react';
|
||||
import { Input } from './ui/input';
|
||||
import { Button } from './ui/button';
|
||||
import { Checkbox } from './ui/checkbox';
|
||||
|
||||
interface RepeaterLoginProps {
|
||||
repeaterName: string;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
password: string;
|
||||
onPasswordChange: (password: string) => void;
|
||||
rememberPassword: boolean;
|
||||
onRememberPasswordChange: (checked: boolean) => void;
|
||||
onLogin: (password: string) => Promise<void>;
|
||||
onLoginAsGuest: () => Promise<void>;
|
||||
description?: string;
|
||||
passwordPlaceholder?: string;
|
||||
loginLabel?: string;
|
||||
guestLabel?: string;
|
||||
}
|
||||
|
||||
export function RepeaterLogin({
|
||||
repeaterName,
|
||||
loading,
|
||||
error,
|
||||
password,
|
||||
onPasswordChange,
|
||||
rememberPassword,
|
||||
onRememberPasswordChange,
|
||||
onLogin,
|
||||
onLoginAsGuest,
|
||||
description = 'Log in to access repeater dashboard',
|
||||
passwordPlaceholder = 'Repeater password...',
|
||||
loginLabel = 'Login with Password',
|
||||
guestLabel = 'Login as Guest / ACLs',
|
||||
}: RepeaterLoginProps) {
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -33,7 +48,7 @@ export function RepeaterLogin({
|
||||
<div className="w-full max-w-sm space-y-6">
|
||||
<div className="text-center space-y-1">
|
||||
<h2 className="text-lg font-semibold">{repeaterName}</h2>
|
||||
<p className="text-sm text-muted-foreground">Log in to access repeater dashboard</p>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4" autoComplete="off">
|
||||
@@ -45,13 +60,34 @@ export function RepeaterLogin({
|
||||
data-1p-ignore="true"
|
||||
data-bwignore="true"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Repeater password..."
|
||||
onChange={(e) => onPasswordChange(e.target.value)}
|
||||
placeholder={passwordPlaceholder}
|
||||
aria-label="Repeater password"
|
||||
disabled={loading}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<label
|
||||
htmlFor="remember-server-password"
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<Checkbox
|
||||
id="remember-server-password"
|
||||
checked={rememberPassword}
|
||||
disabled={loading}
|
||||
onCheckedChange={(checked) => onRememberPasswordChange(checked === true)}
|
||||
/>
|
||||
<span>Remember password</span>
|
||||
</label>
|
||||
|
||||
{rememberPassword && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Passwords are stored unencrypted in local browser storage for this domain. It is
|
||||
highly recommended to login via ACLs after your first successful login; saving the
|
||||
password is not recommended.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive text-center" role="alert">
|
||||
{error}
|
||||
@@ -60,7 +96,7 @@ export function RepeaterLogin({
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading ? 'Logging in...' : 'Login with Password'}
|
||||
{loading ? 'Logging in...' : loginLabel}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -69,7 +105,7 @@ export function RepeaterLogin({
|
||||
className="w-full"
|
||||
onClick={onLoginAsGuest}
|
||||
>
|
||||
Login as Guest / ACLs
|
||||
{guestLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
312
frontend/src/components/RoomServerPanel.tsx
Normal file
312
frontend/src/components/RoomServerPanel.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { api } from '../api';
|
||||
import { toast } from './ui/sonner';
|
||||
import { Button } from './ui/button';
|
||||
import type {
|
||||
Contact,
|
||||
PaneState,
|
||||
RepeaterAclResponse,
|
||||
RepeaterLppTelemetryResponse,
|
||||
RepeaterStatusResponse,
|
||||
} from '../types';
|
||||
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
|
||||
import { AclPane } from './repeater/RepeaterAclPane';
|
||||
import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane';
|
||||
import { ConsolePane } from './repeater/RepeaterConsolePane';
|
||||
import { RepeaterLogin } from './RepeaterLogin';
|
||||
import { useRememberedServerPassword } from '../hooks/useRememberedServerPassword';
|
||||
|
||||
interface RoomServerPanelProps {
|
||||
contact: Contact;
|
||||
onAuthenticatedChange?: (authenticated: boolean) => void;
|
||||
}
|
||||
|
||||
type RoomPaneKey = 'status' | 'acl' | 'lppTelemetry';
|
||||
|
||||
type RoomPaneData = {
|
||||
status: RepeaterStatusResponse | null;
|
||||
acl: RepeaterAclResponse | null;
|
||||
lppTelemetry: RepeaterLppTelemetryResponse | null;
|
||||
};
|
||||
|
||||
type RoomPaneStates = Record<RoomPaneKey, PaneState>;
|
||||
|
||||
type ConsoleEntry = {
|
||||
command: string;
|
||||
response: string;
|
||||
timestamp: number;
|
||||
outgoing: boolean;
|
||||
};
|
||||
|
||||
const INITIAL_PANE_STATE: PaneState = {
|
||||
loading: false,
|
||||
attempt: 0,
|
||||
error: null,
|
||||
fetched_at: null,
|
||||
};
|
||||
|
||||
function createInitialPaneStates(): RoomPaneStates {
|
||||
return {
|
||||
status: { ...INITIAL_PANE_STATE },
|
||||
acl: { ...INITIAL_PANE_STATE },
|
||||
lppTelemetry: { ...INITIAL_PANE_STATE },
|
||||
};
|
||||
}
|
||||
|
||||
export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPanelProps) {
|
||||
const { password, setPassword, rememberPassword, setRememberPassword, persistAfterLogin } =
|
||||
useRememberedServerPassword('room', contact.public_key);
|
||||
const [loginLoading, setLoginLoading] = useState(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const [loginMessage, setLoginMessage] = useState<string | null>(null);
|
||||
const [authenticated, setAuthenticated] = useState(false);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [paneData, setPaneData] = useState<RoomPaneData>({
|
||||
status: null,
|
||||
acl: null,
|
||||
lppTelemetry: null,
|
||||
});
|
||||
const [paneStates, setPaneStates] = useState<RoomPaneStates>(createInitialPaneStates);
|
||||
const [consoleHistory, setConsoleHistory] = useState<ConsoleEntry[]>([]);
|
||||
const [consoleLoading, setConsoleLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLoginLoading(false);
|
||||
setLoginError(null);
|
||||
setLoginMessage(null);
|
||||
setAuthenticated(false);
|
||||
setAdvancedOpen(false);
|
||||
setPaneData({
|
||||
status: null,
|
||||
acl: null,
|
||||
lppTelemetry: null,
|
||||
});
|
||||
setPaneStates(createInitialPaneStates());
|
||||
setConsoleHistory([]);
|
||||
setConsoleLoading(false);
|
||||
}, [contact.public_key]);
|
||||
|
||||
useEffect(() => {
|
||||
onAuthenticatedChange?.(authenticated);
|
||||
}, [authenticated, onAuthenticatedChange]);
|
||||
|
||||
const refreshPane = useCallback(
|
||||
async <K extends RoomPaneKey>(pane: K, loader: () => Promise<RoomPaneData[K]>) => {
|
||||
setPaneStates((prev) => ({
|
||||
...prev,
|
||||
[pane]: {
|
||||
...prev[pane],
|
||||
loading: true,
|
||||
attempt: prev[pane].attempt + 1,
|
||||
error: null,
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
const data = await loader();
|
||||
setPaneData((prev) => ({ ...prev, [pane]: data }));
|
||||
setPaneStates((prev) => ({
|
||||
...prev,
|
||||
[pane]: {
|
||||
loading: false,
|
||||
attempt: prev[pane].attempt,
|
||||
error: null,
|
||||
fetched_at: Date.now(),
|
||||
},
|
||||
}));
|
||||
} catch (err) {
|
||||
setPaneStates((prev) => ({
|
||||
...prev,
|
||||
[pane]: {
|
||||
...prev[pane],
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
},
|
||||
}));
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const performLogin = useCallback(
|
||||
async (password: string) => {
|
||||
if (loginLoading) return;
|
||||
|
||||
setLoginLoading(true);
|
||||
setLoginError(null);
|
||||
setLoginMessage(null);
|
||||
try {
|
||||
const result = await api.roomLogin(contact.public_key, password);
|
||||
setAuthenticated(true);
|
||||
setLoginMessage(
|
||||
result.message ??
|
||||
(result.authenticated
|
||||
? 'Login confirmed. You can now send room messages and open admin tools.'
|
||||
: 'Login request sent, but authentication was not confirmed.')
|
||||
);
|
||||
if (result.authenticated) {
|
||||
toast.success('Room login confirmed');
|
||||
} else {
|
||||
toast(result.message ?? 'Room login was not confirmed');
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setAuthenticated(true);
|
||||
setLoginError(message);
|
||||
toast.error('Room login failed', { description: message });
|
||||
} finally {
|
||||
setLoginLoading(false);
|
||||
}
|
||||
},
|
||||
[contact.public_key, loginLoading]
|
||||
);
|
||||
|
||||
const handleLogin = useCallback(
|
||||
async (password: string) => {
|
||||
await performLogin(password);
|
||||
persistAfterLogin(password);
|
||||
},
|
||||
[performLogin, persistAfterLogin]
|
||||
);
|
||||
|
||||
const handleLoginAsGuest = useCallback(async () => {
|
||||
await performLogin('');
|
||||
persistAfterLogin('');
|
||||
}, [performLogin, persistAfterLogin]);
|
||||
|
||||
const handleConsoleCommand = useCallback(
|
||||
async (command: string) => {
|
||||
setConsoleLoading(true);
|
||||
const timestamp = Date.now();
|
||||
setConsoleHistory((prev) => [
|
||||
...prev,
|
||||
{ command, response: command, timestamp, outgoing: true },
|
||||
]);
|
||||
try {
|
||||
const response = await api.sendRepeaterCommand(contact.public_key, command);
|
||||
setConsoleHistory((prev) => [
|
||||
...prev,
|
||||
{
|
||||
command,
|
||||
response: response.response,
|
||||
timestamp: Date.now(),
|
||||
outgoing: false,
|
||||
},
|
||||
]);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setConsoleHistory((prev) => [
|
||||
...prev,
|
||||
{
|
||||
command,
|
||||
response: `(error) ${message}`,
|
||||
timestamp: Date.now(),
|
||||
outgoing: false,
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
setConsoleLoading(false);
|
||||
}
|
||||
},
|
||||
[contact.public_key]
|
||||
);
|
||||
|
||||
const panelTitle = useMemo(() => contact.name || contact.public_key.slice(0, 12), [contact]);
|
||||
|
||||
if (!authenticated) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="mx-auto flex w-full max-w-sm flex-col gap-4">
|
||||
<div className="rounded-md border border-warning/30 bg-warning/10 px-4 py-3 text-sm text-warning">
|
||||
Room server access is experimental and in public alpha. Please report any issues on{' '}
|
||||
<a
|
||||
href="https://github.com/jkingsman/Remote-Terminal-for-MeshCore/issues"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium underline underline-offset-2 hover:text-warning/80"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
<RepeaterLogin
|
||||
repeaterName={panelTitle}
|
||||
loading={loginLoading}
|
||||
error={loginError}
|
||||
password={password}
|
||||
onPasswordChange={setPassword}
|
||||
rememberPassword={rememberPassword}
|
||||
onRememberPasswordChange={setRememberPassword}
|
||||
onLogin={handleLogin}
|
||||
onLoginAsGuest={handleLoginAsGuest}
|
||||
description="Log in with the room password or use ACL/guest access to enter this room server"
|
||||
passwordPlaceholder="Room server password..."
|
||||
guestLabel="Login with ACL / Guest"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="border-b border-border bg-muted/20 px-4 py-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">Room Server Controls</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Room access is active. Use the chat history and message box below to participate, and
|
||||
open admin tools when needed.
|
||||
</p>
|
||||
{loginMessage && <p className="text-xs text-muted-foreground">{loginMessage}</p>}
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-2 sm:flex-row lg:w-auto">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleLoginAsGuest}
|
||||
disabled={loginLoading}
|
||||
>
|
||||
Refresh ACL Login
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setAdvancedOpen((prev) => !prev)}
|
||||
>
|
||||
{advancedOpen ? 'Hide Tools' : 'Show Tools'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{advancedOpen && (
|
||||
<div className="grid gap-3 xl:grid-cols-2">
|
||||
<TelemetryPane
|
||||
data={paneData.status}
|
||||
state={paneStates.status}
|
||||
onRefresh={() => refreshPane('status', () => api.roomStatus(contact.public_key))}
|
||||
/>
|
||||
<AclPane
|
||||
data={paneData.acl}
|
||||
state={paneStates.acl}
|
||||
onRefresh={() => refreshPane('acl', () => api.roomAcl(contact.public_key))}
|
||||
/>
|
||||
<LppTelemetryPane
|
||||
data={paneData.lppTelemetry}
|
||||
state={paneStates.lppTelemetry}
|
||||
onRefresh={() =>
|
||||
refreshPane('lppTelemetry', () => api.roomLppTelemetry(contact.public_key))
|
||||
}
|
||||
/>
|
||||
<ConsolePane
|
||||
history={consoleHistory}
|
||||
loading={consoleLoading}
|
||||
onSend={handleConsoleCommand}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
CONTACT_TYPE_ROOM,
|
||||
CONTACT_TYPE_REPEATER,
|
||||
type Contact,
|
||||
type Channel,
|
||||
@@ -57,6 +58,7 @@ type CollapseState = {
|
||||
favorites: boolean;
|
||||
channels: boolean;
|
||||
contacts: boolean;
|
||||
rooms: boolean;
|
||||
repeaters: boolean;
|
||||
};
|
||||
|
||||
@@ -67,6 +69,7 @@ const DEFAULT_COLLAPSE_STATE: CollapseState = {
|
||||
favorites: false,
|
||||
channels: false,
|
||||
contacts: false,
|
||||
rooms: false,
|
||||
repeaters: false,
|
||||
};
|
||||
|
||||
@@ -80,6 +83,7 @@ function loadCollapsedState(): CollapseState {
|
||||
favorites: parsed.favorites ?? DEFAULT_COLLAPSE_STATE.favorites,
|
||||
channels: parsed.channels ?? DEFAULT_COLLAPSE_STATE.channels,
|
||||
contacts: parsed.contacts ?? DEFAULT_COLLAPSE_STATE.contacts,
|
||||
rooms: parsed.rooms ?? DEFAULT_COLLAPSE_STATE.rooms,
|
||||
repeaters: parsed.repeaters ?? DEFAULT_COLLAPSE_STATE.repeaters,
|
||||
};
|
||||
} catch {
|
||||
@@ -157,6 +161,7 @@ export function Sidebar({
|
||||
const [favoritesCollapsed, setFavoritesCollapsed] = useState(initialCollapsedState.favorites);
|
||||
const [channelsCollapsed, setChannelsCollapsed] = useState(initialCollapsedState.channels);
|
||||
const [contactsCollapsed, setContactsCollapsed] = useState(initialCollapsedState.contacts);
|
||||
const [roomsCollapsed, setRoomsCollapsed] = useState(initialCollapsedState.rooms);
|
||||
const [repeatersCollapsed, setRepeatersCollapsed] = useState(initialCollapsedState.repeaters);
|
||||
const collapseSnapshotRef = useRef<CollapseState | null>(null);
|
||||
const sectionSortSourceRef = useRef(initialSectionSortState.source);
|
||||
@@ -314,16 +319,61 @@ export function Sidebar({
|
||||
[getContactHeardTime]
|
||||
);
|
||||
|
||||
const getFavoriteItemName = useCallback(
|
||||
(item: FavoriteItem) =>
|
||||
item.type === 'channel'
|
||||
? item.channel.name
|
||||
: getContactDisplayName(
|
||||
item.contact.name,
|
||||
item.contact.public_key,
|
||||
item.contact.last_advert
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const sortFavoriteItemsByOrder = useCallback(
|
||||
(items: FavoriteItem[], order: SortOrder) =>
|
||||
[...items].sort((a, b) => {
|
||||
if (order === 'recent') {
|
||||
const timeA =
|
||||
a.type === 'channel'
|
||||
? getLastMessageTime('channel', a.channel.key)
|
||||
: getContactRecentTime(a.contact);
|
||||
const timeB =
|
||||
b.type === 'channel'
|
||||
? getLastMessageTime('channel', b.channel.key)
|
||||
: getContactRecentTime(b.contact);
|
||||
if (timeA && timeB) return timeB - timeA;
|
||||
if (timeA && !timeB) return -1;
|
||||
if (!timeA && timeB) return 1;
|
||||
}
|
||||
|
||||
return getFavoriteItemName(a).localeCompare(getFavoriteItemName(b));
|
||||
}),
|
||||
[getContactRecentTime, getFavoriteItemName, getLastMessageTime]
|
||||
);
|
||||
|
||||
// Split non-repeater contacts and repeater contacts into separate sorted lists
|
||||
const sortedNonRepeaterContacts = useMemo(
|
||||
() =>
|
||||
sortContactsByOrder(
|
||||
uniqueContacts.filter((c) => c.type !== CONTACT_TYPE_REPEATER),
|
||||
uniqueContacts.filter(
|
||||
(c) => c.type !== CONTACT_TYPE_REPEATER && c.type !== CONTACT_TYPE_ROOM
|
||||
),
|
||||
sectionSortOrders.contacts
|
||||
),
|
||||
[uniqueContacts, sectionSortOrders.contacts, sortContactsByOrder]
|
||||
);
|
||||
|
||||
const sortedRooms = useMemo(
|
||||
() =>
|
||||
sortContactsByOrder(
|
||||
uniqueContacts.filter((c) => c.type === CONTACT_TYPE_ROOM),
|
||||
sectionSortOrders.rooms
|
||||
),
|
||||
[uniqueContacts, sectionSortOrders.rooms, sortContactsByOrder]
|
||||
);
|
||||
|
||||
const sortedRepeaters = useMemo(
|
||||
() =>
|
||||
sortRepeatersByOrder(
|
||||
@@ -358,6 +408,17 @@ export function Sidebar({
|
||||
[sortedNonRepeaterContacts, query]
|
||||
);
|
||||
|
||||
const filteredRooms = useMemo(
|
||||
() =>
|
||||
query
|
||||
? sortedRooms.filter(
|
||||
(c) =>
|
||||
c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
||||
)
|
||||
: sortedRooms,
|
||||
[sortedRooms, query]
|
||||
);
|
||||
|
||||
const filteredRepeaters = useMemo(
|
||||
() =>
|
||||
query
|
||||
@@ -378,6 +439,7 @@ export function Sidebar({
|
||||
favorites: favoritesCollapsed,
|
||||
channels: channelsCollapsed,
|
||||
contacts: contactsCollapsed,
|
||||
rooms: roomsCollapsed,
|
||||
repeaters: repeatersCollapsed,
|
||||
};
|
||||
}
|
||||
@@ -387,12 +449,14 @@ export function Sidebar({
|
||||
favoritesCollapsed ||
|
||||
channelsCollapsed ||
|
||||
contactsCollapsed ||
|
||||
roomsCollapsed ||
|
||||
repeatersCollapsed
|
||||
) {
|
||||
setToolsCollapsed(false);
|
||||
setFavoritesCollapsed(false);
|
||||
setChannelsCollapsed(false);
|
||||
setContactsCollapsed(false);
|
||||
setRoomsCollapsed(false);
|
||||
setRepeatersCollapsed(false);
|
||||
}
|
||||
return;
|
||||
@@ -405,6 +469,7 @@ export function Sidebar({
|
||||
setFavoritesCollapsed(prev.favorites);
|
||||
setChannelsCollapsed(prev.channels);
|
||||
setContactsCollapsed(prev.contacts);
|
||||
setRoomsCollapsed(prev.rooms);
|
||||
setRepeatersCollapsed(prev.repeaters);
|
||||
}
|
||||
}, [
|
||||
@@ -413,6 +478,7 @@ export function Sidebar({
|
||||
favoritesCollapsed,
|
||||
channelsCollapsed,
|
||||
contactsCollapsed,
|
||||
roomsCollapsed,
|
||||
repeatersCollapsed,
|
||||
]);
|
||||
|
||||
@@ -424,6 +490,7 @@ export function Sidebar({
|
||||
favorites: favoritesCollapsed,
|
||||
channels: channelsCollapsed,
|
||||
contacts: contactsCollapsed,
|
||||
rooms: roomsCollapsed,
|
||||
repeaters: repeatersCollapsed,
|
||||
};
|
||||
|
||||
@@ -438,62 +505,56 @@ export function Sidebar({
|
||||
favoritesCollapsed,
|
||||
channelsCollapsed,
|
||||
contactsCollapsed,
|
||||
roomsCollapsed,
|
||||
repeatersCollapsed,
|
||||
]);
|
||||
|
||||
// Separate favorites from regular items, and build combined favorites list
|
||||
const { favoriteItems, nonFavoriteChannels, nonFavoriteContacts, nonFavoriteRepeaters } =
|
||||
useMemo(() => {
|
||||
const favChannels = filteredChannels.filter((c) => isFavorite(favorites, 'channel', c.key));
|
||||
const favContacts = [...filteredNonRepeaterContacts, ...filteredRepeaters].filter((c) =>
|
||||
isFavorite(favorites, 'contact', c.public_key)
|
||||
);
|
||||
const nonFavChannels = filteredChannels.filter(
|
||||
(c) => !isFavorite(favorites, 'channel', c.key)
|
||||
);
|
||||
const nonFavContacts = filteredNonRepeaterContacts.filter(
|
||||
(c) => !isFavorite(favorites, 'contact', c.public_key)
|
||||
);
|
||||
const nonFavRepeaters = filteredRepeaters.filter(
|
||||
(c) => !isFavorite(favorites, 'contact', c.public_key)
|
||||
);
|
||||
const {
|
||||
favoriteItems,
|
||||
nonFavoriteChannels,
|
||||
nonFavoriteContacts,
|
||||
nonFavoriteRooms,
|
||||
nonFavoriteRepeaters,
|
||||
} = useMemo(() => {
|
||||
const favChannels = filteredChannels.filter((c) => isFavorite(favorites, 'channel', c.key));
|
||||
const favContacts = [
|
||||
...filteredNonRepeaterContacts,
|
||||
...filteredRooms,
|
||||
...filteredRepeaters,
|
||||
].filter((c) => isFavorite(favorites, 'contact', c.public_key));
|
||||
const nonFavChannels = filteredChannels.filter((c) => !isFavorite(favorites, 'channel', c.key));
|
||||
const nonFavContacts = filteredNonRepeaterContacts.filter(
|
||||
(c) => !isFavorite(favorites, 'contact', c.public_key)
|
||||
);
|
||||
const nonFavRooms = filteredRooms.filter(
|
||||
(c) => !isFavorite(favorites, 'contact', c.public_key)
|
||||
);
|
||||
const nonFavRepeaters = filteredRepeaters.filter(
|
||||
(c) => !isFavorite(favorites, 'contact', c.public_key)
|
||||
);
|
||||
|
||||
const items: FavoriteItem[] = [
|
||||
...favChannels.map((channel) => ({ type: 'channel' as const, channel })),
|
||||
...favContacts.map((contact) => ({ type: 'contact' as const, contact })),
|
||||
].sort((a, b) => {
|
||||
const timeA =
|
||||
a.type === 'channel'
|
||||
? getLastMessageTime('channel', a.channel.key)
|
||||
: getContactRecentTime(a.contact);
|
||||
const timeB =
|
||||
b.type === 'channel'
|
||||
? getLastMessageTime('channel', b.channel.key)
|
||||
: getContactRecentTime(b.contact);
|
||||
if (timeA && timeB) return timeB - timeA;
|
||||
if (timeA && !timeB) return -1;
|
||||
if (!timeA && timeB) return 1;
|
||||
const nameA =
|
||||
a.type === 'channel' ? a.channel.name : a.contact.name || a.contact.public_key;
|
||||
const nameB =
|
||||
b.type === 'channel' ? b.channel.name : b.contact.name || b.contact.public_key;
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
const items: FavoriteItem[] = [
|
||||
...favChannels.map((channel) => ({ type: 'channel' as const, channel })),
|
||||
...favContacts.map((contact) => ({ type: 'contact' as const, contact })),
|
||||
];
|
||||
|
||||
return {
|
||||
favoriteItems: items,
|
||||
nonFavoriteChannels: nonFavChannels,
|
||||
nonFavoriteContacts: nonFavContacts,
|
||||
nonFavoriteRepeaters: nonFavRepeaters,
|
||||
};
|
||||
}, [
|
||||
filteredChannels,
|
||||
filteredNonRepeaterContacts,
|
||||
filteredRepeaters,
|
||||
favorites,
|
||||
getContactRecentTime,
|
||||
getLastMessageTime,
|
||||
]);
|
||||
return {
|
||||
favoriteItems: sortFavoriteItemsByOrder(items, sectionSortOrders.favorites),
|
||||
nonFavoriteChannels: nonFavChannels,
|
||||
nonFavoriteContacts: nonFavContacts,
|
||||
nonFavoriteRooms: nonFavRooms,
|
||||
nonFavoriteRepeaters: nonFavRepeaters,
|
||||
};
|
||||
}, [
|
||||
filteredChannels,
|
||||
filteredNonRepeaterContacts,
|
||||
filteredRooms,
|
||||
filteredRepeaters,
|
||||
favorites,
|
||||
sectionSortOrders.favorites,
|
||||
sortFavoriteItemsByOrder,
|
||||
]);
|
||||
|
||||
const buildChannelRow = (channel: Channel, keyPrefix: string): ConversationRow => ({
|
||||
key: `${keyPrefix}-${channel.key}`,
|
||||
@@ -517,57 +578,65 @@ export function Sidebar({
|
||||
contact,
|
||||
});
|
||||
|
||||
const renderConversationRow = (row: ConversationRow) => (
|
||||
<div
|
||||
key={row.key}
|
||||
className={cn(
|
||||
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
isActive(row.type, row.id) && 'bg-accent border-l-primary',
|
||||
row.unreadCount > 0 && '[&_.name]:font-semibold [&_.name]:text-foreground'
|
||||
)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-current={isActive(row.type, row.id) ? 'page' : undefined}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={() =>
|
||||
handleSelectConversation({
|
||||
type: row.type,
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
})
|
||||
}
|
||||
>
|
||||
{row.type === 'contact' && row.contact && (
|
||||
<ContactAvatar
|
||||
name={row.contact.name}
|
||||
publicKey={row.contact.public_key}
|
||||
size={24}
|
||||
contactType={row.contact.type}
|
||||
/>
|
||||
)}
|
||||
<span className="name flex-1 truncate text-[13px]">{row.name}</span>
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
{row.notificationsEnabled && (
|
||||
<span aria-label="Notifications enabled" title="Notifications enabled">
|
||||
<Bell className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</span>
|
||||
const renderConversationRow = (row: ConversationRow) => {
|
||||
const highlightUnread =
|
||||
row.isMention ||
|
||||
(row.type === 'contact' &&
|
||||
row.contact?.type !== CONTACT_TYPE_REPEATER &&
|
||||
row.unreadCount > 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={row.key}
|
||||
className={cn(
|
||||
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
isActive(row.type, row.id) && 'bg-accent border-l-primary',
|
||||
row.unreadCount > 0 && '[&_.name]:font-semibold [&_.name]:text-foreground'
|
||||
)}
|
||||
{row.unreadCount > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
|
||||
row.isMention
|
||||
? 'bg-badge-mention text-badge-mention-foreground'
|
||||
: 'bg-badge-unread/90 text-badge-unread-foreground'
|
||||
)}
|
||||
aria-label={`${row.unreadCount} unread message${row.unreadCount !== 1 ? 's' : ''}`}
|
||||
>
|
||||
{row.unreadCount}
|
||||
</span>
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-current={isActive(row.type, row.id) ? 'page' : undefined}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={() =>
|
||||
handleSelectConversation({
|
||||
type: row.type,
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
})
|
||||
}
|
||||
>
|
||||
{row.type === 'contact' && row.contact && (
|
||||
<ContactAvatar
|
||||
name={row.contact.name}
|
||||
publicKey={row.contact.public_key}
|
||||
size={24}
|
||||
contactType={row.contact.type}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
<span className="name flex-1 truncate text-[13px]">{row.name}</span>
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
{row.notificationsEnabled && (
|
||||
<span aria-label="Notifications enabled" title="Notifications enabled">
|
||||
<Bell className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</span>
|
||||
)}
|
||||
{row.unreadCount > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
|
||||
highlightUnread
|
||||
? 'bg-badge-mention text-badge-mention-foreground'
|
||||
: 'bg-badge-unread/90 text-badge-unread-foreground'
|
||||
)}
|
||||
aria-label={`${row.unreadCount} unread message${row.unreadCount !== 1 ? 's' : ''}`}
|
||||
>
|
||||
{row.unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSidebarActionRow = ({
|
||||
key,
|
||||
@@ -613,11 +682,13 @@ export function Sidebar({
|
||||
);
|
||||
const channelRows = nonFavoriteChannels.map((channel) => buildChannelRow(channel, 'chan'));
|
||||
const contactRows = nonFavoriteContacts.map((contact) => buildContactRow(contact, 'contact'));
|
||||
const roomRows = nonFavoriteRooms.map((contact) => buildContactRow(contact, 'room'));
|
||||
const repeaterRows = nonFavoriteRepeaters.map((contact) => buildContactRow(contact, 'repeater'));
|
||||
|
||||
const favoritesUnreadCount = getSectionUnreadCount(favoriteRows);
|
||||
const channelsUnreadCount = getSectionUnreadCount(channelRows);
|
||||
const contactsUnreadCount = getSectionUnreadCount(contactRows);
|
||||
const roomsUnreadCount = getSectionUnreadCount(roomRows);
|
||||
const repeatersUnreadCount = getSectionUnreadCount(repeaterRows);
|
||||
const favoritesHasMention = sectionHasMention(favoriteRows);
|
||||
const channelsHasMention = sectionHasMention(channelRows);
|
||||
@@ -833,7 +904,7 @@ export function Sidebar({
|
||||
'Favorites',
|
||||
favoritesCollapsed,
|
||||
() => setFavoritesCollapsed((prev) => !prev),
|
||||
null,
|
||||
'favorites',
|
||||
favoritesUnreadCount,
|
||||
favoritesHasMention
|
||||
)}
|
||||
@@ -874,6 +945,21 @@ export function Sidebar({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Room Servers */}
|
||||
{nonFavoriteRooms.length > 0 && (
|
||||
<>
|
||||
{renderSectionHeader(
|
||||
'Room Servers',
|
||||
roomsCollapsed,
|
||||
() => setRoomsCollapsed((prev) => !prev),
|
||||
'rooms',
|
||||
roomsUnreadCount,
|
||||
roomsUnreadCount > 0
|
||||
)}
|
||||
{(isSearching || !roomsCollapsed) && roomRows.map((row) => renderConversationRow(row))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Repeaters */}
|
||||
{nonFavoriteRepeaters.length > 0 && (
|
||||
<>
|
||||
@@ -891,6 +977,7 @@ export function Sidebar({
|
||||
|
||||
{/* Empty state */}
|
||||
{nonFavoriteContacts.length === 0 &&
|
||||
nonFavoriteRooms.length === 0 &&
|
||||
nonFavoriteChannels.length === 0 &&
|
||||
nonFavoriteRepeaters.length === 0 &&
|
||||
favoriteItems.length === 0 && (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo, lazy, Suspense } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { RepeaterPane, NotFetched, formatDuration } from './repeaterPaneShared';
|
||||
import { isValidLocation, calculateDistance, formatDistance } from '../../utils/pathUtils';
|
||||
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
|
||||
import type {
|
||||
Contact,
|
||||
RepeaterNeighborsResponse,
|
||||
@@ -35,6 +36,7 @@ export function NeighborsPane({
|
||||
nodeInfoState: PaneState;
|
||||
repeaterName: string | null;
|
||||
}) {
|
||||
const { distanceUnit } = useDistanceUnit();
|
||||
const advertLat = repeaterContact?.lat ?? null;
|
||||
const advertLon = repeaterContact?.lon ?? null;
|
||||
|
||||
@@ -93,7 +95,7 @@ export function NeighborsPane({
|
||||
if (hasValidRepeaterGps && isValidLocation(nLat, nLon)) {
|
||||
const distKm = calculateDistance(positionSource.lat, positionSource.lon, nLat, nLon);
|
||||
if (distKm != null) {
|
||||
dist = formatDistance(distKm);
|
||||
dist = formatDistance(distKm, distanceUnit);
|
||||
anyDist = true;
|
||||
}
|
||||
}
|
||||
@@ -111,7 +113,7 @@ export function NeighborsPane({
|
||||
sorted: enriched,
|
||||
hasDistances: anyDist,
|
||||
};
|
||||
}, [contacts, data, hasValidRepeaterGps, positionSource.lat, positionSource.lon]);
|
||||
}, [contacts, data, distanceUnit, hasValidRepeaterGps, positionSource.lat, positionSource.lon]);
|
||||
|
||||
return (
|
||||
<RepeaterPane
|
||||
|
||||
@@ -11,6 +11,12 @@ import {
|
||||
} from '../../utils/lastViewedConversation';
|
||||
import { ThemeSelector } from './ThemeSelector';
|
||||
import { getLocalLabel, setLocalLabel, type LocalLabel } from '../../utils/localLabel';
|
||||
import {
|
||||
DISTANCE_UNIT_LABELS,
|
||||
DISTANCE_UNITS,
|
||||
setSavedDistanceUnit,
|
||||
} from '../../utils/distanceUnits';
|
||||
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
|
||||
|
||||
export function SettingsLocalSection({
|
||||
onLocalLabelChange,
|
||||
@@ -19,6 +25,7 @@ export function SettingsLocalSection({
|
||||
onLocalLabelChange?: (label: LocalLabel) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { distanceUnit, setDistanceUnit } = useDistanceUnit();
|
||||
const [reopenLastConversation, setReopenLastConversation] = useState(
|
||||
getReopenLastConversationEnabled
|
||||
);
|
||||
@@ -82,6 +89,31 @@ export function SettingsLocalSection({
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="distance-units">Distance Units</Label>
|
||||
<select
|
||||
id="distance-units"
|
||||
value={distanceUnit}
|
||||
onChange={(event) => {
|
||||
const nextUnit = event.target.value as (typeof DISTANCE_UNITS)[number];
|
||||
setSavedDistanceUnit(nextUnit);
|
||||
setDistanceUnit(nextUnit);
|
||||
}}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
{DISTANCE_UNITS.map((unit) => (
|
||||
<option key={unit} value={unit}>
|
||||
{DISTANCE_UNIT_LABELS[unit]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Controls how distances are shown throughout the app.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Label } from '../ui/label';
|
||||
import { Button } from '../ui/button';
|
||||
import { Separator } from '../ui/separator';
|
||||
import { toast } from '../ui/sonner';
|
||||
import { Checkbox } from '../ui/checkbox';
|
||||
import { RADIO_PRESETS } from '../../utils/radioPresets';
|
||||
import { stripRegionScopePrefix } from '../../utils/regionScope';
|
||||
import type {
|
||||
@@ -64,6 +65,7 @@ export function SettingsRadioSection({
|
||||
const [cr, setCr] = useState('');
|
||||
const [pathHashMode, setPathHashMode] = useState('0');
|
||||
const [advertLocationSource, setAdvertLocationSource] = useState<'off' | 'current'>('current');
|
||||
const [multiAcksEnabled, setMultiAcksEnabled] = useState(false);
|
||||
const [gettingLocation, setGettingLocation] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [rebooting, setRebooting] = useState(false);
|
||||
@@ -98,6 +100,7 @@ export function SettingsRadioSection({
|
||||
setCr(String(config.radio.cr));
|
||||
setPathHashMode(String(config.path_hash_mode));
|
||||
setAdvertLocationSource(config.advert_location_source ?? 'current');
|
||||
setMultiAcksEnabled(config.multi_acks_enabled ?? false);
|
||||
}, [config]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -190,6 +193,9 @@ export function SettingsRadioSection({
|
||||
...(advertLocationSource !== (config.advert_location_source ?? 'current')
|
||||
? { advert_location_source: advertLocationSource }
|
||||
: {}),
|
||||
...(multiAcksEnabled !== (config.multi_acks_enabled ?? false)
|
||||
? { multi_acks_enabled: multiAcksEnabled }
|
||||
: {}),
|
||||
radio: {
|
||||
freq: parsedFreq,
|
||||
bw: parsedBw,
|
||||
@@ -579,6 +585,24 @@ export function SettingsRadioSection({
|
||||
library.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3">
|
||||
<Checkbox
|
||||
id="multi-acks-enabled"
|
||||
checked={multiAcksEnabled}
|
||||
onCheckedChange={(checked) => setMultiAcksEnabled(checked === true)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="multi-acks-enabled">Extra Direct ACK Transmission</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When enabled, the radio sends one extra direct ACK transmission before the normal
|
||||
ACK for received direct messages. This is a firmware-level receive behavior, not a
|
||||
RemoteTerm retry setting.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.path_hash_mode_supported && (
|
||||
|
||||
31
frontend/src/contexts/DistanceUnitContext.tsx
Normal file
31
frontend/src/contexts/DistanceUnitContext.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createContext, useContext, type ReactNode } from 'react';
|
||||
|
||||
import type { DistanceUnit } from '../utils/distanceUnits';
|
||||
|
||||
interface DistanceUnitContextValue {
|
||||
distanceUnit: DistanceUnit;
|
||||
setDistanceUnit: (unit: DistanceUnit) => void;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const DistanceUnitContext = createContext<DistanceUnitContextValue>({
|
||||
distanceUnit: 'metric',
|
||||
setDistanceUnit: noop,
|
||||
});
|
||||
|
||||
export function DistanceUnitProvider({
|
||||
distanceUnit,
|
||||
setDistanceUnit,
|
||||
children,
|
||||
}: DistanceUnitContextValue & { children: ReactNode }) {
|
||||
return (
|
||||
<DistanceUnitContext.Provider value={{ distanceUnit, setDistanceUnit }}>
|
||||
{children}
|
||||
</DistanceUnitContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDistanceUnit() {
|
||||
return useContext(DistanceUnitContext);
|
||||
}
|
||||
@@ -10,3 +10,4 @@ export { useRealtimeAppState } from './useRealtimeAppState';
|
||||
export { useConversationActions } from './useConversationActions';
|
||||
export { useConversationNavigation } from './useConversationNavigation';
|
||||
export { useBrowserNotifications } from './useBrowserNotifications';
|
||||
export { useRawPacketStatsSession } from './useRawPacketStatsSession';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { startTransition, useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { getLocalLabel, type LocalLabel } from '../utils/localLabel';
|
||||
import { getSavedDistanceUnit, type DistanceUnit } from '../utils/distanceUnits';
|
||||
import type { SettingsSection } from '../components/settings/settingsConstants';
|
||||
import { parseHashSettingsSection, updateSettingsHash } from '../utils/urlHash';
|
||||
|
||||
@@ -12,10 +13,12 @@ interface UseAppShellResult {
|
||||
showCracker: boolean;
|
||||
crackerRunning: boolean;
|
||||
localLabel: LocalLabel;
|
||||
distanceUnit: DistanceUnit;
|
||||
setSettingsSection: (section: SettingsSection) => void;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
setCrackerRunning: (running: boolean) => void;
|
||||
setLocalLabel: (label: LocalLabel) => void;
|
||||
setDistanceUnit: (unit: DistanceUnit) => void;
|
||||
handleCloseSettingsView: () => void;
|
||||
handleToggleSettingsView: () => void;
|
||||
handleOpenNewMessage: () => void;
|
||||
@@ -34,6 +37,7 @@ export function useAppShell(): UseAppShellResult {
|
||||
const [showCracker, setShowCracker] = useState(false);
|
||||
const [crackerRunning, setCrackerRunning] = useState(false);
|
||||
const [localLabel, setLocalLabel] = useState(getLocalLabel);
|
||||
const [distanceUnit, setDistanceUnit] = useState(getSavedDistanceUnit);
|
||||
const previousHashRef = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -87,10 +91,12 @@ export function useAppShell(): UseAppShellResult {
|
||||
showCracker,
|
||||
crackerRunning,
|
||||
localLabel,
|
||||
distanceUnit,
|
||||
setSettingsSection,
|
||||
setSidebarOpen,
|
||||
setCrackerRunning,
|
||||
setLocalLabel,
|
||||
setDistanceUnit,
|
||||
handleCloseSettingsView,
|
||||
handleToggleSettingsView,
|
||||
handleOpenNewMessage,
|
||||
|
||||
52
frontend/src/hooks/useRawPacketStatsSession.ts
Normal file
52
frontend/src/hooks/useRawPacketStatsSession.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import type { RawPacket } from '../types';
|
||||
import {
|
||||
MAX_RAW_PACKET_STATS_OBSERVATIONS,
|
||||
summarizeRawPacketForStats,
|
||||
type RawPacketStatsSessionState,
|
||||
} from '../utils/rawPacketStats';
|
||||
|
||||
export function useRawPacketStatsSession() {
|
||||
const [session, setSession] = useState<RawPacketStatsSessionState>(() => ({
|
||||
sessionStartedAt: Date.now(),
|
||||
totalObservedPackets: 0,
|
||||
trimmedObservationCount: 0,
|
||||
observations: [],
|
||||
}));
|
||||
|
||||
const recordRawPacketObservation = useCallback((packet: RawPacket) => {
|
||||
setSession((prev) => {
|
||||
const observation = summarizeRawPacketForStats(packet);
|
||||
if (
|
||||
prev.observations.some(
|
||||
(candidate) => candidate.observationKey === observation.observationKey
|
||||
)
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const observations = [...prev.observations, observation];
|
||||
if (observations.length <= MAX_RAW_PACKET_STATS_OBSERVATIONS) {
|
||||
return {
|
||||
...prev,
|
||||
totalObservedPackets: prev.totalObservedPackets + 1,
|
||||
observations,
|
||||
};
|
||||
}
|
||||
|
||||
const overflow = observations.length - MAX_RAW_PACKET_STATS_OBSERVATIONS;
|
||||
return {
|
||||
...prev,
|
||||
totalObservedPackets: prev.totalObservedPackets + 1,
|
||||
trimmedObservationCount: prev.trimmedObservationCount + overflow,
|
||||
observations: observations.slice(overflow),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
rawPacketStatsSession: session,
|
||||
recordRawPacketObservation,
|
||||
};
|
||||
}
|
||||
@@ -50,6 +50,7 @@ interface UseRealtimeAppStateArgs {
|
||||
removeConversationMessages: (conversationId: string) => void;
|
||||
receiveMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
|
||||
notifyIncomingMessage?: (msg: Message) => void;
|
||||
recordRawPacketObservation?: (packet: RawPacket) => void;
|
||||
maxRawPackets?: number;
|
||||
}
|
||||
|
||||
@@ -97,6 +98,7 @@ export function useRealtimeAppState({
|
||||
removeConversationMessages,
|
||||
receiveMessageAck,
|
||||
notifyIncomingMessage,
|
||||
recordRawPacketObservation,
|
||||
maxRawPackets = 500,
|
||||
}: UseRealtimeAppStateArgs): UseWebSocketOptions {
|
||||
const mergeChannelIntoList = useCallback(
|
||||
@@ -241,6 +243,7 @@ export function useRealtimeAppState({
|
||||
}
|
||||
},
|
||||
onRawPacket: (packet: RawPacket) => {
|
||||
recordRawPacketObservation?.(packet);
|
||||
setRawPackets((prev) => appendRawPacketUnique(prev, packet, maxRawPackets));
|
||||
},
|
||||
onMessageAcked: (messageId: number, ackCount: number, paths?: MessagePath[]) => {
|
||||
@@ -261,6 +264,7 @@ export function useRealtimeAppState({
|
||||
pendingDeleteFallbackRef,
|
||||
prevHealthRef,
|
||||
recordMessageEvent,
|
||||
recordRawPacketObservation,
|
||||
receiveMessageAck,
|
||||
observeMessage,
|
||||
refreshUnreads,
|
||||
|
||||
79
frontend/src/hooks/useRememberedServerPassword.ts
Normal file
79
frontend/src/hooks/useRememberedServerPassword.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
type ServerLoginKind = 'repeater' | 'room';
|
||||
|
||||
const STORAGE_KEY_PREFIX = 'remoteterm-server-password';
|
||||
|
||||
type StoredPassword = {
|
||||
password: string;
|
||||
};
|
||||
|
||||
function getStorageKey(kind: ServerLoginKind, publicKey: string): string {
|
||||
return `${STORAGE_KEY_PREFIX}:${kind}:${publicKey}`;
|
||||
}
|
||||
|
||||
function loadStoredPassword(kind: ServerLoginKind, publicKey: string): StoredPassword | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(getStorageKey(kind, publicKey));
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<StoredPassword>;
|
||||
if (typeof parsed.password !== 'string' || parsed.password.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return { password: parsed.password };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function useRememberedServerPassword(kind: ServerLoginKind, publicKey: string) {
|
||||
const storageKey = useMemo(() => getStorageKey(kind, publicKey), [kind, publicKey]);
|
||||
const [password, setPassword] = useState('');
|
||||
const [rememberPassword, setRememberPassword] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = loadStoredPassword(kind, publicKey);
|
||||
if (!stored) {
|
||||
setPassword('');
|
||||
setRememberPassword(false);
|
||||
return;
|
||||
}
|
||||
setPassword(stored.password);
|
||||
setRememberPassword(true);
|
||||
}, [kind, publicKey]);
|
||||
|
||||
const persistAfterLogin = useCallback(
|
||||
(submittedPassword: string) => {
|
||||
if (!rememberPassword) {
|
||||
try {
|
||||
localStorage.removeItem(storageKey);
|
||||
} catch {
|
||||
// localStorage may be unavailable
|
||||
}
|
||||
setPassword('');
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedPassword = submittedPassword.trim();
|
||||
if (!trimmedPassword) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify({ password: trimmedPassword }));
|
||||
} catch {
|
||||
// localStorage may be unavailable
|
||||
}
|
||||
setPassword(trimmedPassword);
|
||||
},
|
||||
[rememberPassword, storageKey]
|
||||
);
|
||||
|
||||
return {
|
||||
password,
|
||||
setPassword,
|
||||
rememberPassword,
|
||||
setRememberPassword,
|
||||
persistAfterLogin,
|
||||
};
|
||||
}
|
||||
@@ -293,8 +293,8 @@ describe('App startup hash resolution', () => {
|
||||
name: 'Alice',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ChatHeader } from '../components/ChatHeader';
|
||||
import type { Channel, Contact, Conversation, Favorite, PathDiscoveryResponse } from '../types';
|
||||
import { CONTACT_TYPE_ROOM } from '../types';
|
||||
import { PUBLIC_CHANNEL_KEY } from '../utils/publicChannel';
|
||||
|
||||
function makeChannel(key: string, name: string, isHashtag: boolean): Channel {
|
||||
@@ -170,6 +171,38 @@ describe('ChatHeader key visibility', () => {
|
||||
expect(onToggleNotifications).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('hides trace and notification controls for room-server contacts', () => {
|
||||
const pubKey = '41'.repeat(32);
|
||||
const contact: Contact = {
|
||||
public_key: pubKey,
|
||||
name: 'Ops Board',
|
||||
type: CONTACT_TYPE_ROOM,
|
||||
flags: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: -1,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
};
|
||||
const conversation: Conversation = { type: 'contact', id: pubKey, name: 'Ops Board' };
|
||||
|
||||
render(
|
||||
<ChatHeader {...baseProps} conversation={conversation} channels={[]} contacts={[contact]} />
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Path Discovery' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Direct Trace' })).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Enable notifications for this conversation' })
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the delete button for the canonical Public channel', () => {
|
||||
const channel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public', false);
|
||||
const conversation: Conversation = { type: 'channel', id: PUBLIC_CHANNEL_KEY, name: 'Public' };
|
||||
@@ -196,9 +229,9 @@ describe('ChatHeader key visibility', () => {
|
||||
name: 'Alice',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: 'AA',
|
||||
last_path_len: 1,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: 'AA',
|
||||
direct_path_len: 1,
|
||||
direct_path_hash_mode: 0,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
@@ -242,9 +275,9 @@ describe('ChatHeader key visibility', () => {
|
||||
name: 'Alice',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: 'AA',
|
||||
last_path_len: 1,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: 'AA',
|
||||
direct_path_len: 1,
|
||||
direct_path_hash_mode: 0,
|
||||
route_override_path: 'BBDD',
|
||||
route_override_len: 2,
|
||||
route_override_hash_mode: 0,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getContactAvatar } from '../utils/contactAvatar';
|
||||
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
|
||||
|
||||
describe('getContactAvatar', () => {
|
||||
it('returns complete avatar info', () => {
|
||||
@@ -30,6 +30,13 @@ describe('getContactAvatar', () => {
|
||||
expect(avatar1.background).toBe(avatar2.background);
|
||||
});
|
||||
|
||||
it('returns room avatar for type=3', () => {
|
||||
const avatar = getContactAvatar('Ops Board', 'abc123def456', CONTACT_TYPE_ROOM);
|
||||
expect(avatar.text).toBe('🛖');
|
||||
expect(avatar.background).toBe('#6b4f2a');
|
||||
expect(avatar.textColor).toBe('#ffffff');
|
||||
});
|
||||
|
||||
it('non-repeater types use normal avatar', () => {
|
||||
const avatar0 = getContactAvatar('John', 'abc123', 0);
|
||||
const avatar1 = getContactAvatar('John', 'abc123', 1);
|
||||
|
||||
@@ -39,9 +39,9 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
|
||||
name: 'Alice',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: 0,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: 0,
|
||||
direct_path_hash_mode: 0,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
@@ -104,7 +104,7 @@ describe('ContactInfoPane', () => {
|
||||
});
|
||||
|
||||
it('shows hop width when contact has a stored path hash mode', async () => {
|
||||
const contact = createContact({ out_path_hash_mode: 1 });
|
||||
const contact = createContact({ direct_path_hash_mode: 1, direct_path_len: 1 });
|
||||
getContactAnalytics.mockResolvedValue(createAnalytics(contact));
|
||||
|
||||
render(<ContactInfoPane {...baseProps} contactKey={contact.public_key} />);
|
||||
@@ -117,7 +117,7 @@ describe('ContactInfoPane', () => {
|
||||
});
|
||||
|
||||
it('does not show hop width for flood-routed contacts', async () => {
|
||||
const contact = createContact({ last_path_len: -1, out_path_hash_mode: -1 });
|
||||
const contact = createContact({ direct_path_len: -1, direct_path_hash_mode: -1 });
|
||||
getContactAnalytics.mockResolvedValue(createAnalytics(contact));
|
||||
|
||||
render(<ContactInfoPane {...baseProps} contactKey={contact.public_key} />);
|
||||
@@ -131,8 +131,8 @@ describe('ContactInfoPane', () => {
|
||||
|
||||
it('shows forced routing override and learned route separately', async () => {
|
||||
const contact = createContact({
|
||||
last_path_len: 1,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path_len: 1,
|
||||
direct_path_hash_mode: 0,
|
||||
route_override_path: 'ae92f13e',
|
||||
route_override_len: 2,
|
||||
route_override_hash_mode: 1,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ConversationPane } from '../components/ConversationPane';
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
Message,
|
||||
RadioConfig,
|
||||
} from '../types';
|
||||
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
messageList: vi.fn(() => <div data-testid="message-list" />),
|
||||
@@ -40,6 +41,21 @@ vi.mock('../components/RepeaterDashboard', () => ({
|
||||
RepeaterDashboard: () => <div data-testid="repeater-dashboard" />,
|
||||
}));
|
||||
|
||||
vi.mock('../components/RoomServerPanel', () => ({
|
||||
RoomServerPanel: ({
|
||||
onAuthenticatedChange,
|
||||
}: {
|
||||
onAuthenticatedChange?: (value: boolean) => void;
|
||||
}) => (
|
||||
<div>
|
||||
<div data-testid="room-server-panel" />
|
||||
<button type="button" onClick={() => onAuthenticatedChange?.(true)}>
|
||||
Authenticate room
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../components/MapView', () => ({
|
||||
MapView: () => <div data-testid="map-view" />,
|
||||
}));
|
||||
@@ -95,12 +111,20 @@ const message: Message = {
|
||||
sender_name: null,
|
||||
};
|
||||
|
||||
const rawPacketStatsSession: RawPacketStatsSessionState = {
|
||||
sessionStartedAt: 1_700_000_000_000,
|
||||
totalObservedPackets: 0,
|
||||
trimmedObservationCount: 0,
|
||||
observations: [],
|
||||
};
|
||||
|
||||
function createProps(overrides: Partial<React.ComponentProps<typeof ConversationPane>> = {}) {
|
||||
return {
|
||||
activeConversation: null as Conversation | null,
|
||||
contacts: [] as Contact[],
|
||||
channels: [channel],
|
||||
rawPackets: [],
|
||||
rawPacketStatsSession,
|
||||
config,
|
||||
health,
|
||||
notificationsSupported: true,
|
||||
@@ -166,9 +190,9 @@ describe('ConversationPane', () => {
|
||||
name: 'Repeater',
|
||||
type: 2,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: 0,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: 0,
|
||||
direct_path_hash_mode: 0,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
@@ -207,6 +231,54 @@ describe('ConversationPane', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('gates room chat behind room login controls until authenticated', async () => {
|
||||
render(
|
||||
<ConversationPane
|
||||
{...createProps({
|
||||
activeConversation: {
|
||||
type: 'contact',
|
||||
id: 'cc'.repeat(32),
|
||||
name: 'Ops Board',
|
||||
},
|
||||
contacts: [
|
||||
{
|
||||
public_key: 'cc'.repeat(32),
|
||||
name: 'Ops Board',
|
||||
type: 3,
|
||||
flags: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: -1,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
},
|
||||
],
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('room-server-panel')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('chat-header')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('message-list')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('message-input')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Authenticate room' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('message-list')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('message-input')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('passes unread marker props to MessageList only for channel conversations', async () => {
|
||||
render(
|
||||
<ConversationPane
|
||||
@@ -268,9 +340,9 @@ describe('ConversationPane', () => {
|
||||
name: null,
|
||||
type: 0,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_path_hash_mode: -1,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: -1,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
@@ -304,9 +376,9 @@ describe('ConversationPane', () => {
|
||||
name: null,
|
||||
type: 0,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_path_hash_mode: -1,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: -1,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
|
||||
32
frontend/src/test/distanceUnits.test.ts
Normal file
32
frontend/src/test/distanceUnits.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
DISTANCE_UNIT_KEY,
|
||||
getSavedDistanceUnit,
|
||||
setSavedDistanceUnit,
|
||||
} from '../utils/distanceUnits';
|
||||
|
||||
describe('distanceUnits utilities', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('defaults to metric when unset', () => {
|
||||
expect(getSavedDistanceUnit()).toBe('metric');
|
||||
});
|
||||
|
||||
it('returns the stored unit when valid', () => {
|
||||
localStorage.setItem(DISTANCE_UNIT_KEY, 'metric');
|
||||
expect(getSavedDistanceUnit()).toBe('metric');
|
||||
});
|
||||
|
||||
it('falls back to metric for invalid stored values', () => {
|
||||
localStorage.setItem(DISTANCE_UNIT_KEY, 'parsecs');
|
||||
expect(getSavedDistanceUnit()).toBe('metric');
|
||||
});
|
||||
|
||||
it('stores the selected distance unit', () => {
|
||||
setSavedDistanceUnit('smoots');
|
||||
expect(localStorage.getItem(DISTANCE_UNIT_KEY)).toBe('smoots');
|
||||
});
|
||||
});
|
||||
@@ -274,8 +274,8 @@ function makeContact(overrides: Partial<Contact> = {}): Contact {
|
||||
name: 'TestNode',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: 0,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
@@ -285,7 +285,7 @@ function makeContact(overrides: Partial<Contact> = {}): Contact {
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
...overrides,
|
||||
out_path_hash_mode: overrides.out_path_hash_mode ?? 0,
|
||||
direct_path_hash_mode: overrides.direct_path_hash_mode ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -29,9 +29,9 @@ describe('MapView', () => {
|
||||
name: 'Mystery Node',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_path_hash_mode: -1,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: -1,
|
||||
route_override_path: null,
|
||||
route_override_len: null,
|
||||
route_override_hash_mode: null,
|
||||
@@ -63,9 +63,9 @@ describe('MapView', () => {
|
||||
name: 'Almost Stale',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_path_hash_mode: -1,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: -1,
|
||||
route_override_path: null,
|
||||
route_override_len: null,
|
||||
route_override_hash_mode: null,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { MessageList } from '../components/MessageList';
|
||||
import type { Message } from '../types';
|
||||
import { CONTACT_TYPE_ROOM, type Contact, type Message } from '../types';
|
||||
|
||||
const scrollIntoViewMock = vi.fn();
|
||||
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
|
||||
@@ -81,6 +81,46 @@ describe('MessageList channel sender rendering', () => {
|
||||
expect(screen.getByText('A')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders room-server DM messages using stored sender attribution instead of the room contact', () => {
|
||||
const roomContact: Contact = {
|
||||
public_key: 'ab'.repeat(32),
|
||||
name: 'Ops Board',
|
||||
type: CONTACT_TYPE_ROOM,
|
||||
flags: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: -1,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
};
|
||||
|
||||
render(
|
||||
<MessageList
|
||||
messages={[
|
||||
createMessage({
|
||||
type: 'PRIV',
|
||||
conversation_key: roomContact.public_key,
|
||||
text: 'status update: ready',
|
||||
sender_name: 'Alice',
|
||||
sender_key: '12'.repeat(32),
|
||||
}),
|
||||
]}
|
||||
contacts={[roomContact]}
|
||||
loading={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Alice')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Ops Board')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('status update: ready')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('gives clickable sender avatars an accessible label', () => {
|
||||
render(
|
||||
<MessageList
|
||||
|
||||
@@ -23,9 +23,9 @@ const mockContact: Contact = {
|
||||
name: 'Alice',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: 0,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
|
||||
@@ -51,9 +51,9 @@ function createContact(publicKey: string, name: string, type = 1): Contact {
|
||||
name,
|
||||
type,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: 0,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: 0,
|
||||
direct_path_hash_mode: 0,
|
||||
route_override_path: null,
|
||||
route_override_len: null,
|
||||
route_override_hash_mode: null,
|
||||
|
||||
@@ -22,8 +22,9 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
|
||||
name: 'Test Contact',
|
||||
type: CONTACT_TYPE_REPEATER,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: -1,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
@@ -33,7 +34,6 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
...overrides,
|
||||
out_path_hash_mode: overrides.out_path_hash_mode ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -139,9 +139,9 @@ describe('contact routing helpers', () => {
|
||||
it('prefers routing override over learned route', () => {
|
||||
const effective = getEffectiveContactRoute(
|
||||
createContact({
|
||||
last_path: 'AABB',
|
||||
last_path_len: 1,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: 'AABB',
|
||||
direct_path_len: 1,
|
||||
direct_path_hash_mode: 0,
|
||||
route_override_path: 'AE92F13E',
|
||||
route_override_len: 2,
|
||||
route_override_hash_mode: 1,
|
||||
@@ -685,22 +685,39 @@ describe('isValidLocation', () => {
|
||||
});
|
||||
|
||||
describe('formatDistance', () => {
|
||||
it('formats distances under 1km in meters', () => {
|
||||
expect(formatDistance(0.5)).toBe('500m');
|
||||
expect(formatDistance(0.123)).toBe('123m');
|
||||
expect(formatDistance(0.9999)).toBe('1000m');
|
||||
const formatInteger = (value: number) => value.toLocaleString();
|
||||
const formatOneDecimal = (value: number) =>
|
||||
value.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
|
||||
it('defaults to imperial formatting', () => {
|
||||
expect(formatDistance(0.01)).toBe(`${formatInteger(33)}ft`);
|
||||
expect(formatDistance(0.5)).toBe(`${formatOneDecimal(0.5 * 0.621371)}mi`);
|
||||
expect(formatDistance(1)).toBe(`${formatOneDecimal(0.621371)}mi`);
|
||||
});
|
||||
|
||||
it('formats distances at or above 1km with one decimal', () => {
|
||||
expect(formatDistance(1)).toBe('1.0km');
|
||||
expect(formatDistance(1.5)).toBe('1.5km');
|
||||
expect(formatDistance(12.34)).toBe('12.3km');
|
||||
expect(formatDistance(100)).toBe('100.0km');
|
||||
it('formats metric distances in meters and kilometers', () => {
|
||||
expect(formatDistance(0.5, 'metric')).toBe(`${formatInteger(500)}m`);
|
||||
expect(formatDistance(0.123, 'metric')).toBe(`${formatInteger(123)}m`);
|
||||
expect(formatDistance(0.9999, 'metric')).toBe(`${formatInteger(1000)}m`);
|
||||
expect(formatDistance(1, 'metric')).toBe(`${formatOneDecimal(1)}km`);
|
||||
expect(formatDistance(12.34, 'metric')).toBe(`${formatOneDecimal(12.34)}km`);
|
||||
});
|
||||
|
||||
it('rounds meters to nearest integer', () => {
|
||||
expect(formatDistance(0.4567)).toBe('457m');
|
||||
expect(formatDistance(0.001)).toBe('1m');
|
||||
it('formats smoot distances using 1.7018 meters per smoot', () => {
|
||||
expect(formatDistance(0.0017018, 'smoots')).toBe(`${formatOneDecimal(1)} smoot`);
|
||||
expect(formatDistance(0.001, 'smoots')).toBe(`${formatOneDecimal(0.6)} smoots`);
|
||||
expect(formatDistance(1, 'smoots')).toBe(`${formatInteger(588)} smoots`);
|
||||
});
|
||||
|
||||
it('applies locale separators to large values', () => {
|
||||
expect(formatDistance(1.234, 'metric')).toBe(`${formatOneDecimal(1.234)}km`);
|
||||
expect(formatDistance(1234, 'metric')).toBe(`${formatOneDecimal(1234)}km`);
|
||||
expect(formatDistance(2.1, 'smoots')).toContain(
|
||||
formatInteger(Math.round((2.1 * 1000) / 1.7018))
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
51
frontend/src/test/rawPacketDetailModal.test.tsx
Normal file
51
frontend/src/test/rawPacketDetailModal.test.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { RawPacketDetailModal } from '../components/RawPacketDetailModal';
|
||||
import type { Channel, RawPacket } from '../types';
|
||||
|
||||
const BOT_CHANNEL: Channel = {
|
||||
key: 'eb50a1bcb3e4e5d7bf69a57c9dada211',
|
||||
name: '#bot',
|
||||
is_hashtag: true,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
};
|
||||
|
||||
const BOT_PACKET: RawPacket = {
|
||||
id: 1,
|
||||
observation_id: 10,
|
||||
timestamp: 1_700_000_000,
|
||||
data: '15833fa002860ccae0eed9ca78b9ab0775d477c1f6490a398bf4edc75240',
|
||||
decrypted: false,
|
||||
payload_type: 'GroupText',
|
||||
rssi: -72,
|
||||
snr: 5.5,
|
||||
decrypted_info: null,
|
||||
};
|
||||
|
||||
describe('RawPacketDetailModal', () => {
|
||||
it('renders path hops as nowrap arrow-delimited groups and links hover state to the full packet hex', () => {
|
||||
render(<RawPacketDetailModal packet={BOT_PACKET} channels={[BOT_CHANNEL]} onClose={vi.fn()} />);
|
||||
|
||||
const pathDescription = screen.getByText(
|
||||
'Historical route taken (3-byte hashes added as packet floods through network)'
|
||||
);
|
||||
const pathFieldBox = pathDescription.closest('[class*="rounded-lg"]');
|
||||
expect(pathFieldBox).not.toBeNull();
|
||||
|
||||
const pathField = within(pathFieldBox as HTMLElement);
|
||||
expect(pathField.getByText('3FA002 →')).toHaveClass('whitespace-nowrap');
|
||||
expect(pathField.getByText('860CCA →')).toHaveClass('whitespace-nowrap');
|
||||
expect(pathField.getByText('E0EED9')).toHaveClass('whitespace-nowrap');
|
||||
|
||||
const pathRun = screen.getByText('3F A0 02 86 0C CA E0 EE D9');
|
||||
const idleClassName = pathRun.className;
|
||||
|
||||
fireEvent.mouseEnter(pathFieldBox as HTMLElement);
|
||||
expect(pathRun.className).not.toBe(idleClassName);
|
||||
|
||||
fireEvent.mouseLeave(pathFieldBox as HTMLElement);
|
||||
expect(pathRun.className).toBe(idleClassName);
|
||||
});
|
||||
});
|
||||
355
frontend/src/test/rawPacketFeedView.test.tsx
Normal file
355
frontend/src/test/rawPacketFeedView.test.tsx
Normal file
@@ -0,0 +1,355 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { RawPacketFeedView } from '../components/RawPacketFeedView';
|
||||
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
|
||||
import type { Channel, Contact, RawPacket } from '../types';
|
||||
|
||||
const GROUP_TEXT_PACKET_HEX =
|
||||
'1500E69C7A89DD0AF6A2D69F5823B88F9720731E4B887C56932BF889255D8D926D99195927144323A42DD8A158F878B518B8304DF55E80501C7D02A9FFD578D3518283156BBA257BF8413E80A237393B2E4149BBBC864371140A9BBC4E23EB9BF203EF0D029214B3E3AAC3C0295690ACDB89A28619E7E5F22C83E16073AD679D25FA904D07E5ACF1DB5A7C77D7E1719FB9AE5BF55541EE0D7F59ED890E12CF0FEED6700818';
|
||||
|
||||
const TEST_CHANNEL: Channel = {
|
||||
key: '7ABA109EDCF304A84433CB71D0F3AB73',
|
||||
name: '#six77',
|
||||
is_hashtag: true,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
};
|
||||
|
||||
const COLLIDING_TEST_CHANNEL: Channel = {
|
||||
...TEST_CHANNEL,
|
||||
name: '#collision',
|
||||
};
|
||||
|
||||
function createSession(
|
||||
overrides: Partial<RawPacketStatsSessionState> = {}
|
||||
): RawPacketStatsSessionState {
|
||||
return {
|
||||
sessionStartedAt: 1_700_000_000_000,
|
||||
totalObservedPackets: 3,
|
||||
trimmedObservationCount: 0,
|
||||
observations: [
|
||||
{
|
||||
observationKey: 'obs-1',
|
||||
timestamp: 1_700_000_000,
|
||||
payloadType: 'Advert',
|
||||
routeType: 'Flood',
|
||||
decrypted: false,
|
||||
rssi: -70,
|
||||
snr: 6,
|
||||
sourceKey: 'AA11',
|
||||
sourceLabel: 'AA11',
|
||||
pathTokenCount: 1,
|
||||
pathSignature: '01',
|
||||
},
|
||||
{
|
||||
observationKey: 'obs-2',
|
||||
timestamp: 1_700_000_030,
|
||||
payloadType: 'TextMessage',
|
||||
routeType: 'Direct',
|
||||
decrypted: true,
|
||||
rssi: -66,
|
||||
snr: 7,
|
||||
sourceKey: 'BB22',
|
||||
sourceLabel: 'BB22',
|
||||
pathTokenCount: 0,
|
||||
pathSignature: null,
|
||||
},
|
||||
{
|
||||
observationKey: 'obs-3',
|
||||
timestamp: 1_700_000_050,
|
||||
payloadType: 'Ack',
|
||||
routeType: 'Direct',
|
||||
decrypted: true,
|
||||
rssi: -80,
|
||||
snr: 4,
|
||||
sourceKey: 'BB22',
|
||||
sourceLabel: 'BB22',
|
||||
pathTokenCount: 0,
|
||||
pathSignature: null,
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createContact(overrides: Partial<Contact> = {}): Contact {
|
||||
return {
|
||||
public_key: 'aa11bb22cc33' + '0'.repeat(52),
|
||||
name: 'Alpha',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: 0,
|
||||
direct_path_hash_mode: 0,
|
||||
last_advert: 1_700_000_000,
|
||||
lat: null,
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderView({
|
||||
packets = [],
|
||||
contacts = [],
|
||||
channels = [],
|
||||
rawPacketStatsSession = createSession(),
|
||||
}: {
|
||||
packets?: RawPacket[];
|
||||
contacts?: Contact[];
|
||||
channels?: Channel[];
|
||||
rawPacketStatsSession?: RawPacketStatsSessionState;
|
||||
} = {}) {
|
||||
return render(
|
||||
<RawPacketFeedView
|
||||
packets={packets}
|
||||
rawPacketStatsSession={rawPacketStatsSession}
|
||||
contacts={contacts}
|
||||
channels={channels}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('RawPacketFeedView', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('opens a stats drawer with window controls and grouped summaries', () => {
|
||||
renderView();
|
||||
|
||||
expect(screen.getByText('Raw Packet Feed')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Packet Types')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /show stats/i }));
|
||||
|
||||
expect(screen.getByLabelText('Stats window')).toBeInTheDocument();
|
||||
expect(screen.getByText('Packet Types')).toBeInTheDocument();
|
||||
expect(screen.getByText('Hop Byte Width')).toBeInTheDocument();
|
||||
expect(screen.getByText('Most-Heard Neighbors')).toBeInTheDocument();
|
||||
expect(screen.getByText('Traffic Timeline')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows stats by default on desktop', () => {
|
||||
vi.stubGlobal(
|
||||
'matchMedia',
|
||||
vi.fn().mockImplementation((query: string) => ({
|
||||
matches: query === '(min-width: 768px)',
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}))
|
||||
);
|
||||
|
||||
renderView();
|
||||
|
||||
expect(screen.getByText('Packet Types')).toBeInTheDocument();
|
||||
expect(screen.getByText('Hop Byte Width')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /hide stats/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('refreshes coverage when packet or session props update without counter deltas', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2024-01-01T00:00:30Z'));
|
||||
|
||||
const initialPackets: RawPacket[] = [];
|
||||
const nextPackets: RawPacket[] = [
|
||||
{
|
||||
id: 1,
|
||||
timestamp: 1_704_067_255,
|
||||
data: '00',
|
||||
decrypted: false,
|
||||
payload_type: 'Unknown',
|
||||
rssi: null,
|
||||
snr: null,
|
||||
observation_id: 1,
|
||||
decrypted_info: null,
|
||||
},
|
||||
];
|
||||
const initialSession = createSession({
|
||||
sessionStartedAt: Date.parse('2024-01-01T00:00:00Z'),
|
||||
totalObservedPackets: 10,
|
||||
trimmedObservationCount: 1,
|
||||
observations: [
|
||||
{
|
||||
observationKey: 'obs-1',
|
||||
timestamp: 1_704_067_220,
|
||||
payloadType: 'Advert',
|
||||
routeType: 'Flood',
|
||||
decrypted: false,
|
||||
rssi: -70,
|
||||
snr: 6,
|
||||
sourceKey: 'AA11',
|
||||
sourceLabel: 'AA11',
|
||||
pathTokenCount: 1,
|
||||
pathSignature: '01',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { rerender } = renderView({
|
||||
packets: initialPackets,
|
||||
rawPacketStatsSession: initialSession,
|
||||
contacts: [],
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /show stats/i }));
|
||||
fireEvent.change(screen.getByLabelText('Stats window'), { target: { value: '1m' } });
|
||||
expect(screen.getByText(/only covered for 10 sec/i)).toBeInTheDocument();
|
||||
|
||||
vi.setSystemTime(new Date('2024-01-01T00:01:10Z'));
|
||||
rerender(
|
||||
<RawPacketFeedView
|
||||
packets={nextPackets}
|
||||
rawPacketStatsSession={initialSession}
|
||||
contacts={[]}
|
||||
channels={[]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/only covered for 50 sec/i)).toBeInTheDocument();
|
||||
|
||||
vi.setSystemTime(new Date('2024-01-01T00:01:30Z'));
|
||||
const nextSession = {
|
||||
...initialSession,
|
||||
sessionStartedAt: Date.parse('2024-01-01T00:01:00Z'),
|
||||
observations: [
|
||||
{
|
||||
...initialSession.observations[0],
|
||||
timestamp: 1_704_067_280,
|
||||
},
|
||||
],
|
||||
};
|
||||
rerender(
|
||||
<RawPacketFeedView
|
||||
packets={nextPackets}
|
||||
rawPacketStatsSession={nextSession}
|
||||
contacts={[]}
|
||||
channels={[]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/only covered for 10 sec/i)).toBeInTheDocument();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('resolves neighbor labels from matching contacts when identity is available', () => {
|
||||
renderView({
|
||||
rawPacketStatsSession: createSession({
|
||||
totalObservedPackets: 1,
|
||||
observations: [
|
||||
{
|
||||
observationKey: 'obs-1',
|
||||
timestamp: 1_700_000_000,
|
||||
payloadType: 'Advert',
|
||||
routeType: 'Flood',
|
||||
decrypted: false,
|
||||
rssi: -70,
|
||||
snr: 6,
|
||||
sourceKey: 'AA11BB22CC33',
|
||||
sourceLabel: 'AA11BB22CC33',
|
||||
pathTokenCount: 1,
|
||||
pathSignature: '01',
|
||||
},
|
||||
],
|
||||
}),
|
||||
contacts: [createContact()],
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /show stats/i }));
|
||||
fireEvent.change(screen.getByLabelText('Stats window'), { target: { value: 'session' } });
|
||||
expect(screen.getAllByText('Alpha').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('marks unresolved neighbor identities explicitly', () => {
|
||||
renderView({
|
||||
rawPacketStatsSession: createSession({
|
||||
totalObservedPackets: 1,
|
||||
observations: [
|
||||
{
|
||||
observationKey: 'obs-1',
|
||||
timestamp: 1_700_000_000,
|
||||
payloadType: 'Advert',
|
||||
routeType: 'Flood',
|
||||
decrypted: false,
|
||||
rssi: -70,
|
||||
snr: 6,
|
||||
sourceKey: 'DEADBEEF1234',
|
||||
sourceLabel: 'DEADBEEF1234',
|
||||
pathTokenCount: 1,
|
||||
pathSignature: '01',
|
||||
},
|
||||
],
|
||||
}),
|
||||
contacts: [],
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /show stats/i }));
|
||||
fireEvent.change(screen.getByLabelText('Stats window'), { target: { value: 'session' } });
|
||||
expect(screen.getAllByText('Identity not resolvable').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('opens a packet detail modal from the raw feed and decrypts room messages when a key is loaded', () => {
|
||||
renderView({
|
||||
packets: [
|
||||
{
|
||||
id: 1,
|
||||
observation_id: 10,
|
||||
timestamp: 1_700_000_000,
|
||||
data: GROUP_TEXT_PACKET_HEX,
|
||||
decrypted: false,
|
||||
payload_type: 'GroupText',
|
||||
rssi: -72,
|
||||
snr: 5.5,
|
||||
decrypted_info: null,
|
||||
},
|
||||
],
|
||||
channels: [TEST_CHANNEL],
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /gt from flightless/i }));
|
||||
|
||||
expect(screen.getByText('Packet Details')).toBeInTheDocument();
|
||||
expect(screen.getByText('Payload fields')).toBeInTheDocument();
|
||||
expect(screen.getByText('Full packet hex')).toBeInTheDocument();
|
||||
expect(screen.getByText('#six77')).toBeInTheDocument();
|
||||
expect(screen.getByText(/bytes · decrypted/i)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/sender: flightless/i).length).toBeGreaterThan(0);
|
||||
expect(
|
||||
screen.getByText(/hello there; this hashtag room is essentially public/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not guess a room name when multiple loaded channels collide on the group hash', () => {
|
||||
renderView({
|
||||
packets: [
|
||||
{
|
||||
id: 1,
|
||||
observation_id: 10,
|
||||
timestamp: 1_700_000_000,
|
||||
data: GROUP_TEXT_PACKET_HEX,
|
||||
decrypted: false,
|
||||
payload_type: 'GroupText',
|
||||
rssi: -72,
|
||||
snr: 5.5,
|
||||
decrypted_info: null,
|
||||
},
|
||||
],
|
||||
channels: [TEST_CHANNEL, COLLIDING_TEST_CHANNEL],
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /gt from flightless/i }));
|
||||
|
||||
expect(screen.getByText(/channel hash e6/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText('#six77')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('#collision')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
28
frontend/src/test/rawPacketInspector.test.ts
Normal file
28
frontend/src/test/rawPacketInspector.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { PayloadType } from '@michaelhart/meshcore-decoder';
|
||||
|
||||
import { describeCiphertextStructure, formatHexByHop } from '../utils/rawPacketInspector';
|
||||
|
||||
describe('rawPacketInspector helpers', () => {
|
||||
it('formats path hex as hop-delimited groups', () => {
|
||||
expect(formatHexByHop('A1B2C3D4E5F6', 2)).toBe('A1B2 → C3D4 → E5F6');
|
||||
expect(formatHexByHop('AABBCC', 1)).toBe('AA → BB → CC');
|
||||
});
|
||||
|
||||
it('leaves non-hop-aligned hex unchanged', () => {
|
||||
expect(formatHexByHop('A1B2C3', 2)).toBe('A1B2C3');
|
||||
expect(formatHexByHop('A1B2', null)).toBe('A1B2');
|
||||
});
|
||||
|
||||
it('describes undecryptable ciphertext with multiline bullets', () => {
|
||||
expect(describeCiphertextStructure(PayloadType.GroupText, 9, 'fallback')).toContain(
|
||||
'\n• Timestamp (4 bytes)'
|
||||
);
|
||||
expect(describeCiphertextStructure(PayloadType.GroupText, 9, 'fallback')).toContain(
|
||||
'\n• Flags (1 byte)'
|
||||
);
|
||||
expect(describeCiphertextStructure(PayloadType.TextMessage, 12, 'fallback')).toContain(
|
||||
'\n• Message (remaining bytes)'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { RawPacketList } from '../components/RawPacketList';
|
||||
import type { RawPacket } from '../types';
|
||||
@@ -23,5 +23,17 @@ describe('RawPacketList', () => {
|
||||
render(<RawPacketList packets={[createPacket()]} />);
|
||||
|
||||
expect(screen.getByText('TF')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('makes packet cards clickable only when an inspector handler is provided', () => {
|
||||
const packet = createPacket({ id: 9, observation_id: 22 });
|
||||
const onPacketClick = vi.fn();
|
||||
|
||||
render(<RawPacketList packets={[packet]} onPacketClick={onPacketClick} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
|
||||
expect(onPacketClick).toHaveBeenCalledWith(packet);
|
||||
});
|
||||
});
|
||||
|
||||
137
frontend/src/test/rawPacketStats.test.ts
Normal file
137
frontend/src/test/rawPacketStats.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildRawPacketStatsSnapshot,
|
||||
type RawPacketStatsSessionState,
|
||||
} from '../utils/rawPacketStats';
|
||||
|
||||
function createSession(
|
||||
overrides: Partial<RawPacketStatsSessionState> = {}
|
||||
): RawPacketStatsSessionState {
|
||||
return {
|
||||
sessionStartedAt: 700_000,
|
||||
totalObservedPackets: 4,
|
||||
trimmedObservationCount: 0,
|
||||
observations: [
|
||||
{
|
||||
observationKey: 'obs-1',
|
||||
timestamp: 850,
|
||||
payloadType: 'Advert',
|
||||
routeType: 'Flood',
|
||||
decrypted: false,
|
||||
rssi: -68,
|
||||
snr: 7,
|
||||
sourceKey: 'AA11',
|
||||
sourceLabel: 'AA11',
|
||||
pathTokenCount: 2,
|
||||
pathSignature: '01>02',
|
||||
hopByteWidth: 1,
|
||||
},
|
||||
{
|
||||
observationKey: 'obs-2',
|
||||
timestamp: 910,
|
||||
payloadType: 'TextMessage',
|
||||
routeType: 'Direct',
|
||||
decrypted: true,
|
||||
rssi: -74,
|
||||
snr: 5,
|
||||
sourceKey: 'BB22',
|
||||
sourceLabel: 'BB22',
|
||||
pathTokenCount: 0,
|
||||
pathSignature: null,
|
||||
hopByteWidth: null,
|
||||
},
|
||||
{
|
||||
observationKey: 'obs-3',
|
||||
timestamp: 960,
|
||||
payloadType: 'Advert',
|
||||
routeType: 'Flood',
|
||||
decrypted: false,
|
||||
rssi: -64,
|
||||
snr: 8,
|
||||
sourceKey: 'AA11',
|
||||
sourceLabel: 'AA11',
|
||||
pathTokenCount: 1,
|
||||
pathSignature: '02',
|
||||
hopByteWidth: 2,
|
||||
},
|
||||
{
|
||||
observationKey: 'obs-4',
|
||||
timestamp: 990,
|
||||
payloadType: 'Ack',
|
||||
routeType: 'Direct',
|
||||
decrypted: true,
|
||||
rssi: -88,
|
||||
snr: 3,
|
||||
sourceKey: null,
|
||||
sourceLabel: null,
|
||||
pathTokenCount: 0,
|
||||
pathSignature: null,
|
||||
hopByteWidth: null,
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildRawPacketStatsSnapshot', () => {
|
||||
it('computes counts, rankings, and rolling-window coverage from session observations', () => {
|
||||
const stats = buildRawPacketStatsSnapshot(createSession(), '5m', 1_000);
|
||||
|
||||
expect(stats.packetCount).toBe(4);
|
||||
expect(stats.uniqueSources).toBe(2);
|
||||
expect(stats.pathBearingCount).toBe(2);
|
||||
expect(stats.payloadBreakdown.slice(0, 3).map((item) => item.label)).toEqual([
|
||||
'Advert',
|
||||
'Ack',
|
||||
'TextMessage',
|
||||
]);
|
||||
expect(stats.payloadBreakdown).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ label: 'GroupText', count: 0 }),
|
||||
expect.objectContaining({ label: 'Control', count: 0 }),
|
||||
])
|
||||
);
|
||||
expect(stats.hopProfile.map((item) => item.label)).toEqual([
|
||||
'0',
|
||||
'1',
|
||||
'2-5',
|
||||
'6-10',
|
||||
'11-15',
|
||||
'16+',
|
||||
]);
|
||||
expect(stats.hopProfile).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ label: '0', count: 2 }),
|
||||
expect.objectContaining({ label: '1', count: 1 }),
|
||||
expect.objectContaining({ label: '2-5', count: 1 }),
|
||||
expect.objectContaining({ label: '6-10', count: 0 }),
|
||||
expect.objectContaining({ label: '11-15', count: 0 }),
|
||||
expect.objectContaining({ label: '16+', count: 0 }),
|
||||
])
|
||||
);
|
||||
expect(stats.hopByteWidthProfile).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ label: 'No path', count: 2 }),
|
||||
expect.objectContaining({ label: '1 byte / hop', count: 1 }),
|
||||
expect.objectContaining({ label: '2 bytes / hop', count: 1 }),
|
||||
])
|
||||
);
|
||||
expect(stats.strongestNeighbors[0]).toMatchObject({ label: 'AA11', bestRssi: -64 });
|
||||
expect(stats.mostActiveNeighbors[0]).toMatchObject({ label: 'AA11', count: 2 });
|
||||
expect(stats.windowFullyCovered).toBe(true);
|
||||
});
|
||||
|
||||
it('flags incomplete session coverage when detailed history has been trimmed', () => {
|
||||
const stats = buildRawPacketStatsSnapshot(
|
||||
createSession({
|
||||
trimmedObservationCount: 25,
|
||||
}),
|
||||
'session',
|
||||
1_000
|
||||
);
|
||||
|
||||
expect(stats.windowFullyCovered).toBe(false);
|
||||
expect(stats.packetCount).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -82,9 +82,9 @@ const contacts: Contact[] = [
|
||||
name: 'TestRepeater',
|
||||
type: 2,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: 0,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
@@ -305,9 +305,9 @@ describe('RepeaterDashboard', () => {
|
||||
name: 'Neighbor',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: 0,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: 0,
|
||||
direct_path_hash_mode: 0,
|
||||
route_override_path: null,
|
||||
route_override_len: null,
|
||||
route_override_hash_mode: null,
|
||||
@@ -365,9 +365,9 @@ describe('RepeaterDashboard', () => {
|
||||
name: 'Neighbor',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: 0,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: 0,
|
||||
direct_path_hash_mode: 0,
|
||||
route_override_path: null,
|
||||
route_override_len: null,
|
||||
route_override_hash_mode: null,
|
||||
@@ -520,15 +520,15 @@ describe('RepeaterDashboard', () => {
|
||||
});
|
||||
|
||||
describe('path type display and reset', () => {
|
||||
it('shows flood when last_path_len is -1', () => {
|
||||
it('shows flood when direct_path_len is -1', () => {
|
||||
render(<RepeaterDashboard {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('flood')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows direct when last_path_len is 0', () => {
|
||||
it('shows direct when direct_path_len is 0', () => {
|
||||
const directContacts: Contact[] = [
|
||||
{ ...contacts[0], last_path_len: 0, last_seen: 1700000000 },
|
||||
{ ...contacts[0], direct_path_len: 0, last_seen: 1700000000 },
|
||||
];
|
||||
|
||||
render(<RepeaterDashboard {...defaultProps} contacts={directContacts} />);
|
||||
@@ -536,9 +536,9 @@ describe('RepeaterDashboard', () => {
|
||||
expect(screen.getByText('direct')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows N hops when last_path_len > 0', () => {
|
||||
it('shows N hops when direct_path_len > 0', () => {
|
||||
const hoppedContacts: Contact[] = [
|
||||
{ ...contacts[0], last_path_len: 3, last_seen: 1700000000 },
|
||||
{ ...contacts[0], direct_path_len: 3, last_seen: 1700000000 },
|
||||
];
|
||||
|
||||
render(<RepeaterDashboard {...defaultProps} contacts={hoppedContacts} />);
|
||||
@@ -548,7 +548,7 @@ describe('RepeaterDashboard', () => {
|
||||
|
||||
it('shows 1 hop (singular) for single hop', () => {
|
||||
const oneHopContacts: Contact[] = [
|
||||
{ ...contacts[0], last_path_len: 1, last_seen: 1700000000 },
|
||||
{ ...contacts[0], direct_path_len: 1, last_seen: 1700000000 },
|
||||
];
|
||||
|
||||
render(<RepeaterDashboard {...defaultProps} contacts={oneHopContacts} />);
|
||||
@@ -558,7 +558,7 @@ describe('RepeaterDashboard', () => {
|
||||
|
||||
it('direct path is clickable, underlined, and marked as editable', () => {
|
||||
const directContacts: Contact[] = [
|
||||
{ ...contacts[0], last_path_len: 0, last_seen: 1700000000 },
|
||||
{ ...contacts[0], direct_path_len: 0, last_seen: 1700000000 },
|
||||
];
|
||||
|
||||
render(<RepeaterDashboard {...defaultProps} contacts={directContacts} />);
|
||||
@@ -573,7 +573,7 @@ describe('RepeaterDashboard', () => {
|
||||
const forcedContacts: Contact[] = [
|
||||
{
|
||||
...contacts[0],
|
||||
last_path_len: 1,
|
||||
direct_path_len: 1,
|
||||
last_seen: 1700000000,
|
||||
route_override_path: 'ae92f13e',
|
||||
route_override_len: 2,
|
||||
@@ -589,7 +589,7 @@ describe('RepeaterDashboard', () => {
|
||||
|
||||
it('clicking direct path opens modal and can force direct routing', async () => {
|
||||
const directContacts: Contact[] = [
|
||||
{ ...contacts[0], last_path_len: 0, last_seen: 1700000000 },
|
||||
{ ...contacts[0], direct_path_len: 0, last_seen: 1700000000 },
|
||||
];
|
||||
|
||||
const { api } = await import('../api');
|
||||
@@ -613,7 +613,7 @@ describe('RepeaterDashboard', () => {
|
||||
|
||||
it('closing the routing override modal does not call the API', async () => {
|
||||
const directContacts: Contact[] = [
|
||||
{ ...contacts[0], last_path_len: 0, last_seen: 1700000000 },
|
||||
{ ...contacts[0], direct_path_len: 0, last_seen: 1700000000 },
|
||||
];
|
||||
|
||||
const { api } = await import('../api');
|
||||
|
||||
@@ -7,6 +7,10 @@ describe('RepeaterLogin', () => {
|
||||
repeaterName: 'TestRepeater',
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
password: '',
|
||||
onPasswordChange: vi.fn(),
|
||||
rememberPassword: false,
|
||||
onRememberPasswordChange: vi.fn(),
|
||||
onLogin: vi.fn(),
|
||||
onLoginAsGuest: vi.fn(),
|
||||
};
|
||||
@@ -26,20 +30,45 @@ describe('RepeaterLogin', () => {
|
||||
render(<RepeaterLogin {...defaultProps} />);
|
||||
|
||||
expect(screen.getByPlaceholderText('Repeater password...')).toBeInTheDocument();
|
||||
expect(screen.getByText('Remember password')).toBeInTheDocument();
|
||||
expect(screen.getByText('Login with Password')).toBeInTheDocument();
|
||||
expect(screen.getByText('Login as Guest / ACLs')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onLogin with trimmed password on submit', () => {
|
||||
render(<RepeaterLogin {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Repeater password...');
|
||||
fireEvent.change(input, { target: { value: ' secret ' } });
|
||||
render(<RepeaterLogin {...defaultProps} password=" secret " />);
|
||||
fireEvent.submit(screen.getByText('Login with Password').closest('form')!);
|
||||
|
||||
expect(defaultProps.onLogin).toHaveBeenCalledWith('secret');
|
||||
});
|
||||
|
||||
it('propagates password changes', () => {
|
||||
render(<RepeaterLogin {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Repeater password...');
|
||||
fireEvent.change(input, { target: { value: 'new secret' } });
|
||||
|
||||
expect(defaultProps.onPasswordChange).toHaveBeenCalledWith('new secret');
|
||||
});
|
||||
|
||||
it('toggles remember password checkbox', () => {
|
||||
render(<RepeaterLogin {...defaultProps} />);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Remember password'));
|
||||
|
||||
expect(defaultProps.onRememberPasswordChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('shows storage warning when remember password is enabled', () => {
|
||||
render(<RepeaterLogin {...defaultProps} rememberPassword={true} />);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Passwords are stored unencrypted in local browser storage for this domain\./
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onLoginAsGuest when guest button clicked', () => {
|
||||
render(<RepeaterLogin {...defaultProps} />);
|
||||
|
||||
|
||||
71
frontend/src/test/roomServerPanel.test.tsx
Normal file
71
frontend/src/test/roomServerPanel.test.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
|
||||
|
||||
import { RoomServerPanel } from '../components/RoomServerPanel';
|
||||
import type { Contact } from '../types';
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
api: {
|
||||
roomLogin: vi.fn(),
|
||||
roomStatus: vi.fn(),
|
||||
roomAcl: vi.fn(),
|
||||
roomLppTelemetry: vi.fn(),
|
||||
sendRepeaterCommand: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../components/ui/sonner', () => ({
|
||||
toast: Object.assign(vi.fn(), {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { api: _rawApi } = await import('../api');
|
||||
const mockApi = _rawApi as unknown as Record<string, Mock>;
|
||||
|
||||
const roomContact: Contact = {
|
||||
public_key: 'aa'.repeat(32),
|
||||
name: 'Ops Board',
|
||||
type: 3,
|
||||
flags: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: 0,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
};
|
||||
|
||||
describe('RoomServerPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('keeps room controls available when login is not confirmed', async () => {
|
||||
mockApi.roomLogin.mockResolvedValueOnce({
|
||||
status: 'timeout',
|
||||
authenticated: false,
|
||||
message:
|
||||
'No login confirmation was heard from the room server. The control panel is still available; try logging in again if authenticated actions fail.',
|
||||
});
|
||||
const onAuthenticatedChange = vi.fn();
|
||||
|
||||
render(<RoomServerPanel contact={roomContact} onAuthenticatedChange={onAuthenticatedChange} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Login with ACL / Guest'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Room Server Controls')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/control panel is still available/i)).toBeInTheDocument();
|
||||
expect(onAuthenticatedChange).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
});
|
||||
@@ -231,9 +231,9 @@ describe('SearchView', () => {
|
||||
name: 'Bob',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: 0,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { SettingsModal } from '../components/SettingsModal';
|
||||
import type {
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
REOPEN_LAST_CONVERSATION_KEY,
|
||||
} from '../utils/lastViewedConversation';
|
||||
import { api } from '../api';
|
||||
import { DISTANCE_UNIT_KEY } from '../utils/distanceUnits';
|
||||
|
||||
const baseConfig: RadioConfig = {
|
||||
public_key: 'aa'.repeat(32),
|
||||
@@ -36,6 +37,7 @@ const baseConfig: RadioConfig = {
|
||||
path_hash_mode: 0,
|
||||
path_hash_mode_supported: false,
|
||||
advert_location_source: 'current',
|
||||
multi_acks_enabled: false,
|
||||
};
|
||||
|
||||
const baseHealth: HealthStatus = {
|
||||
@@ -176,6 +178,10 @@ function openDatabaseSection() {
|
||||
}
|
||||
|
||||
describe('SettingsModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(api, 'getFanoutConfigs').mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
localStorage.clear();
|
||||
@@ -327,6 +333,18 @@ describe('SettingsModal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('saves multi-acks through radio config save', async () => {
|
||||
const { onSave } = renderModal();
|
||||
openRadioSection();
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Extra Direct ACK Transmission'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ multi_acks_enabled: true }));
|
||||
});
|
||||
});
|
||||
|
||||
it('saves changed max contacts value through onSaveAppSettings', async () => {
|
||||
const { onSaveAppSettings } = renderModal();
|
||||
openRadioSection();
|
||||
@@ -364,17 +382,21 @@ describe('SettingsModal', () => {
|
||||
desktopSection: 'fanout',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.getFanoutConfigs).toHaveBeenCalled();
|
||||
});
|
||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Local Configuration/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Preset')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not clip the fanout add-integration menu in external desktop mode', () => {
|
||||
it('does not clip the fanout add-integration menu in external desktop mode', async () => {
|
||||
renderModal({
|
||||
externalSidebarNav: true,
|
||||
desktopSection: 'fanout',
|
||||
});
|
||||
|
||||
const addIntegrationButton = screen.getByRole('button', { name: 'Add Integration' });
|
||||
const addIntegrationButton = await screen.findByRole('button', { name: 'Add Integration' });
|
||||
const wrapperSection = addIntegrationButton.closest('section');
|
||||
expect(wrapperSection).not.toHaveClass('overflow-hidden');
|
||||
});
|
||||
@@ -427,30 +449,35 @@ describe('SettingsModal', () => {
|
||||
expect(screen.getByText('Save failed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
view.rerender(
|
||||
<SettingsModal
|
||||
open
|
||||
externalSidebarNav
|
||||
desktopSection="fanout"
|
||||
config={baseConfig}
|
||||
health={baseHealth}
|
||||
appSettings={baseSettings}
|
||||
onClose={vi.fn()}
|
||||
onSave={vi.fn(async () => {})}
|
||||
onSaveAppSettings={onSaveAppSettings}
|
||||
onSetPrivateKey={vi.fn(async () => {})}
|
||||
onReboot={vi.fn(async () => {})}
|
||||
onDisconnect={vi.fn(async () => {})}
|
||||
onReconnect={vi.fn(async () => {})}
|
||||
onAdvertise={vi.fn(async () => {})}
|
||||
meshDiscovery={null}
|
||||
meshDiscoveryLoadingTarget={null}
|
||||
onDiscoverMesh={vi.fn(async () => {})}
|
||||
onHealthRefresh={vi.fn(async () => {})}
|
||||
onRefreshAppSettings={vi.fn(async () => {})}
|
||||
/>
|
||||
);
|
||||
await act(async () => {
|
||||
view.rerender(
|
||||
<SettingsModal
|
||||
open
|
||||
externalSidebarNav
|
||||
desktopSection="fanout"
|
||||
config={baseConfig}
|
||||
health={baseHealth}
|
||||
appSettings={baseSettings}
|
||||
onClose={vi.fn()}
|
||||
onSave={vi.fn(async () => {})}
|
||||
onSaveAppSettings={onSaveAppSettings}
|
||||
onSetPrivateKey={vi.fn(async () => {})}
|
||||
onReboot={vi.fn(async () => {})}
|
||||
onDisconnect={vi.fn(async () => {})}
|
||||
onReconnect={vi.fn(async () => {})}
|
||||
onAdvertise={vi.fn(async () => {})}
|
||||
meshDiscovery={null}
|
||||
meshDiscoveryLoadingTarget={null}
|
||||
onDiscoverMesh={vi.fn(async () => {})}
|
||||
onHealthRefresh={vi.fn(async () => {})}
|
||||
onRefreshAppSettings={vi.fn(async () => {})}
|
||||
/>
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(api.getFanoutConfigs).toHaveBeenCalled();
|
||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
|
||||
expect(screen.queryByText('Save failed')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -509,6 +536,18 @@ describe('SettingsModal', () => {
|
||||
expect(localStorage.getItem(LAST_VIEWED_CONVERSATION_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('defaults distance units to metric and stores local changes', () => {
|
||||
renderModal();
|
||||
openLocalSection();
|
||||
|
||||
const select = screen.getByLabelText('Distance Units');
|
||||
expect(select).toHaveValue('metric');
|
||||
|
||||
fireEvent.change(select, { target: { value: 'smoots' } });
|
||||
|
||||
expect(localStorage.getItem(DISTANCE_UNIT_KEY)).toBe('smoots');
|
||||
});
|
||||
|
||||
it('purges decrypted raw packets via maintenance endpoint action', async () => {
|
||||
const runMaintenanceSpy = vi.spyOn(api, 'runMaintenance').mockResolvedValue({
|
||||
packets_deleted: 12,
|
||||
|
||||
@@ -1 +1,9 @@
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
globalThis.ResizeObserver = ResizeObserver;
|
||||
|
||||
@@ -2,7 +2,13 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { Sidebar } from '../components/Sidebar';
|
||||
import { CONTACT_TYPE_REPEATER, type Channel, type Contact, type Favorite } from '../types';
|
||||
import {
|
||||
CONTACT_TYPE_REPEATER,
|
||||
CONTACT_TYPE_ROOM,
|
||||
type Channel,
|
||||
type Contact,
|
||||
type Favorite,
|
||||
} from '../types';
|
||||
import { getStateKey, type ConversationTimes } from '../utils/conversationState';
|
||||
import { PUBLIC_CHANNEL_KEY } from '../utils/publicChannel';
|
||||
|
||||
@@ -27,9 +33,9 @@ function makeContact(
|
||||
name,
|
||||
type,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: 0,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
@@ -51,16 +57,19 @@ function renderSidebar(overrides?: {
|
||||
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
|
||||
}) {
|
||||
const aliceName = 'Alice';
|
||||
const roomName = 'Ops Board';
|
||||
const publicChannel = makeChannel('AA'.repeat(16), 'Public');
|
||||
const flightChannel = makeChannel('BB'.repeat(16), '#flight');
|
||||
const opsChannel = makeChannel('CC'.repeat(16), '#ops');
|
||||
const alice = makeContact('11'.repeat(32), aliceName);
|
||||
const board = makeContact('33'.repeat(32), roomName, CONTACT_TYPE_ROOM);
|
||||
const relay = makeContact('22'.repeat(32), 'Relay', CONTACT_TYPE_REPEATER);
|
||||
|
||||
const unreadCounts = overrides?.unreadCounts ?? {
|
||||
[getStateKey('channel', flightChannel.key)]: 2,
|
||||
[getStateKey('channel', opsChannel.key)]: 1,
|
||||
[getStateKey('contact', alice.public_key)]: 3,
|
||||
[getStateKey('contact', board.public_key)]: 5,
|
||||
[getStateKey('contact', relay.public_key)]: 4,
|
||||
};
|
||||
|
||||
@@ -69,7 +78,7 @@ function renderSidebar(overrides?: {
|
||||
|
||||
const view = render(
|
||||
<Sidebar
|
||||
contacts={[alice, relay]}
|
||||
contacts={[alice, board, relay]}
|
||||
channels={channels}
|
||||
activeConversation={null}
|
||||
onSelectConversation={vi.fn()}
|
||||
@@ -87,7 +96,7 @@ function renderSidebar(overrides?: {
|
||||
/>
|
||||
);
|
||||
|
||||
return { ...view, flightChannel, opsChannel, aliceName };
|
||||
return { ...view, flightChannel, opsChannel, aliceName, roomName };
|
||||
}
|
||||
|
||||
function getSectionHeaderContainer(title: string): HTMLElement {
|
||||
@@ -108,6 +117,7 @@ describe('Sidebar section summaries', () => {
|
||||
expect(within(getSectionHeaderContainer('Favorites')).getByText('2')).toBeInTheDocument();
|
||||
expect(within(getSectionHeaderContainer('Channels')).getByText('1')).toBeInTheDocument();
|
||||
expect(within(getSectionHeaderContainer('Contacts')).getByText('3')).toBeInTheDocument();
|
||||
expect(within(getSectionHeaderContainer('Room Servers')).getByText('5')).toBeInTheDocument();
|
||||
expect(within(getSectionHeaderContainer('Repeaters')).getByText('4')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -129,7 +139,7 @@ describe('Sidebar section summaries', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps contact row badges normal while the contacts rollup is always red', () => {
|
||||
it('turns contact row badges red while the contacts rollup remains red', () => {
|
||||
const { aliceName } = renderSidebar();
|
||||
|
||||
expect(within(getSectionHeaderContainer('Contacts')).getByText('3')).toHaveClass(
|
||||
@@ -140,21 +150,54 @@ describe('Sidebar section summaries', () => {
|
||||
const aliceRow = screen.getByText(aliceName).closest('div');
|
||||
if (!aliceRow) throw new Error('Missing Alice row');
|
||||
expect(within(aliceRow).getByText('3')).toHaveClass(
|
||||
'bg-badge-mention',
|
||||
'text-badge-mention-foreground'
|
||||
);
|
||||
});
|
||||
|
||||
it('turns favorite contact row badges red', () => {
|
||||
const { aliceName } = renderSidebar({
|
||||
favorites: [{ type: 'contact', id: '11'.repeat(32) }],
|
||||
});
|
||||
|
||||
const aliceRow = screen.getByText(aliceName).closest('div');
|
||||
if (!aliceRow) throw new Error('Missing Alice row');
|
||||
expect(within(aliceRow).getByText('3')).toHaveClass(
|
||||
'bg-badge-mention',
|
||||
'text-badge-mention-foreground'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps repeater row badges neutral', () => {
|
||||
renderSidebar();
|
||||
|
||||
const relayRow = screen.getByText('Relay').closest('div');
|
||||
if (!relayRow) throw new Error('Missing Relay row');
|
||||
expect(within(relayRow).getByText('4')).toHaveClass(
|
||||
'bg-badge-unread/90',
|
||||
'text-badge-unread-foreground'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders room servers in their own section', () => {
|
||||
const { roomName } = renderSidebar();
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Room Servers' })).toBeInTheDocument();
|
||||
expect(screen.getByText(roomName)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('expands collapsed sections during search and restores collapse state after clearing search', async () => {
|
||||
const { opsChannel, aliceName } = renderSidebar();
|
||||
const { opsChannel, aliceName, roomName } = renderSidebar();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Tools' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Channels' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Contacts' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Room Servers' }));
|
||||
|
||||
expect(screen.queryByText('Packet Feed')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(aliceName)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(roomName)).not.toBeInTheDocument();
|
||||
|
||||
const search = screen.getByLabelText('Search conversations');
|
||||
fireEvent.change(search, { target: { value: 'alice' } });
|
||||
@@ -169,19 +212,22 @@ describe('Sidebar section summaries', () => {
|
||||
expect(screen.queryByText('Packet Feed')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(aliceName)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(roomName)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('persists collapsed section state across unmount and remount', () => {
|
||||
const { opsChannel, aliceName, unmount } = renderSidebar();
|
||||
const { opsChannel, aliceName, roomName, unmount } = renderSidebar();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Tools' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Channels' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Contacts' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Room Servers' }));
|
||||
|
||||
expect(screen.queryByText('Packet Feed')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(aliceName)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(roomName)).not.toBeInTheDocument();
|
||||
|
||||
unmount();
|
||||
renderSidebar();
|
||||
@@ -189,6 +235,7 @@ describe('Sidebar section summaries', () => {
|
||||
expect(screen.queryByText('Packet Feed')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(aliceName)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(roomName)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders same-name channels when keys differ and allows selecting both', () => {
|
||||
@@ -265,6 +312,12 @@ describe('Sidebar section summaries', () => {
|
||||
const alphaChannel = makeChannel('CC'.repeat(16), '#alpha');
|
||||
const zed = makeContact('11'.repeat(32), 'Zed', 1, { last_advert: 150 });
|
||||
const amy = makeContact('22'.repeat(32), 'Amy');
|
||||
const zebraRoom = makeContact('55'.repeat(32), 'Zebra Room', CONTACT_TYPE_ROOM, {
|
||||
last_seen: 100,
|
||||
});
|
||||
const alphaRoom = makeContact('66'.repeat(32), 'Alpha Room', CONTACT_TYPE_ROOM, {
|
||||
last_advert: 300,
|
||||
});
|
||||
const relayZulu = makeContact('33'.repeat(32), 'Zulu Relay', CONTACT_TYPE_REPEATER, {
|
||||
last_seen: 100,
|
||||
});
|
||||
@@ -273,7 +326,7 @@ describe('Sidebar section summaries', () => {
|
||||
});
|
||||
|
||||
const props = {
|
||||
contacts: [zed, amy, relayZulu, relayAlpha],
|
||||
contacts: [zed, amy, zebraRoom, alphaRoom, relayZulu, relayAlpha],
|
||||
channels: [publicChannel, zebraChannel, alphaChannel],
|
||||
activeConversation: null,
|
||||
onSelectConversation: vi.fn(),
|
||||
@@ -282,6 +335,7 @@ describe('Sidebar section summaries', () => {
|
||||
[getStateKey('channel', zebraChannel.key)]: 300,
|
||||
[getStateKey('channel', alphaChannel.key)]: 100,
|
||||
[getStateKey('contact', zed.public_key)]: 200,
|
||||
[getStateKey('contact', zebraRoom.public_key)]: 350,
|
||||
},
|
||||
unreadCounts: {},
|
||||
mentions: {},
|
||||
@@ -304,27 +358,76 @@ describe('Sidebar section summaries', () => {
|
||||
.getAllByText(/Relay$/)
|
||||
.map((node) => node.textContent)
|
||||
.filter((text): text is string => Boolean(text));
|
||||
const getRoomsOrder = () =>
|
||||
screen
|
||||
.getAllByText(/Room$/)
|
||||
.map((node) => node.textContent)
|
||||
.filter((text): text is string => Boolean(text));
|
||||
|
||||
const { unmount } = render(<Sidebar {...props} />);
|
||||
|
||||
expect(getChannelsOrder()).toEqual(['#zebra', '#alpha']);
|
||||
expect(getContactsOrder()).toEqual(['Zed', 'Amy']);
|
||||
expect(getRoomsOrder()).toEqual(['Zebra Room', 'Alpha Room']);
|
||||
expect(getRepeatersOrder()).toEqual(['Alpha Relay', 'Zulu Relay']);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sort Channels alphabetically' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sort Contacts alphabetically' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sort Room Servers alphabetically' }));
|
||||
|
||||
expect(getChannelsOrder()).toEqual(['#alpha', '#zebra']);
|
||||
expect(getContactsOrder()).toEqual(['Zed', 'Amy']);
|
||||
expect(getContactsOrder()).toEqual(['Amy', 'Zed']);
|
||||
expect(getRoomsOrder()).toEqual(['Alpha Room', 'Zebra Room']);
|
||||
expect(getRepeatersOrder()).toEqual(['Alpha Relay', 'Zulu Relay']);
|
||||
|
||||
unmount();
|
||||
render(<Sidebar {...props} />);
|
||||
|
||||
expect(getChannelsOrder()).toEqual(['#alpha', '#zebra']);
|
||||
expect(getContactsOrder()).toEqual(['Zed', 'Amy']);
|
||||
expect(getContactsOrder()).toEqual(['Amy', 'Zed']);
|
||||
expect(getRoomsOrder()).toEqual(['Alpha Room', 'Zebra Room']);
|
||||
expect(getRepeatersOrder()).toEqual(['Alpha Relay', 'Zulu Relay']);
|
||||
});
|
||||
|
||||
it('sorts room servers like contacts by DM recency first, then advert recency', () => {
|
||||
const publicChannel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public');
|
||||
const dmRecentRoom = makeContact('77'.repeat(32), 'DM Recent Room', CONTACT_TYPE_ROOM, {
|
||||
last_advert: 100,
|
||||
});
|
||||
const advertOnlyRoom = makeContact('88'.repeat(32), 'Advert Only Room', CONTACT_TYPE_ROOM, {
|
||||
last_seen: 300,
|
||||
});
|
||||
const noRecencyRoom = makeContact('99'.repeat(32), 'No Recency Room', CONTACT_TYPE_ROOM);
|
||||
|
||||
render(
|
||||
<Sidebar
|
||||
contacts={[noRecencyRoom, advertOnlyRoom, dmRecentRoom]}
|
||||
channels={[publicChannel]}
|
||||
activeConversation={null}
|
||||
onSelectConversation={vi.fn()}
|
||||
onNewMessage={vi.fn()}
|
||||
lastMessageTimes={{
|
||||
[getStateKey('contact', dmRecentRoom.public_key)]: 400,
|
||||
}}
|
||||
unreadCounts={{}}
|
||||
mentions={{}}
|
||||
showCracker={false}
|
||||
crackerRunning={false}
|
||||
onToggleCracker={vi.fn()}
|
||||
onMarkAllRead={vi.fn()}
|
||||
favorites={[]}
|
||||
legacySortOrder="recent"
|
||||
/>
|
||||
);
|
||||
|
||||
const roomRows = screen
|
||||
.getAllByText(/Room$/)
|
||||
.map((node) => node.textContent)
|
||||
.filter((text): text is string => Boolean(text));
|
||||
|
||||
expect(roomRows).toEqual(['DM Recent Room', 'Advert Only Room', 'No Recency Room']);
|
||||
});
|
||||
|
||||
it('sorts contacts by DM recency first, then advert recency, then no-recency at the bottom', () => {
|
||||
const publicChannel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public');
|
||||
const dmRecent = makeContact('11'.repeat(32), 'DM Recent', 1, { last_advert: 100 });
|
||||
@@ -442,4 +545,90 @@ describe('Sidebar section summaries', () => {
|
||||
name: 'Public',
|
||||
});
|
||||
});
|
||||
|
||||
it('sorts favorites independently and persists the favorites sort preference', () => {
|
||||
const publicChannel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public');
|
||||
const zed = makeContact('11'.repeat(32), 'Zed', 1, { last_advert: 150 });
|
||||
const amy = makeContact('22'.repeat(32), 'Amy');
|
||||
|
||||
const props = {
|
||||
contacts: [zed, amy],
|
||||
channels: [publicChannel],
|
||||
activeConversation: null,
|
||||
onSelectConversation: vi.fn(),
|
||||
onNewMessage: vi.fn(),
|
||||
lastMessageTimes: {
|
||||
[getStateKey('contact', zed.public_key)]: 200,
|
||||
},
|
||||
unreadCounts: {},
|
||||
mentions: {},
|
||||
showCracker: false,
|
||||
crackerRunning: false,
|
||||
onToggleCracker: vi.fn(),
|
||||
onMarkAllRead: vi.fn(),
|
||||
favorites: [
|
||||
{ type: 'contact', id: zed.public_key },
|
||||
{ type: 'contact', id: amy.public_key },
|
||||
] satisfies Favorite[],
|
||||
legacySortOrder: 'recent' as const,
|
||||
};
|
||||
|
||||
const getFavoritesOrder = () =>
|
||||
screen
|
||||
.getAllByText(/^(Amy|Zed)$/)
|
||||
.map((node) => node.textContent)
|
||||
.filter((text): text is string => Boolean(text));
|
||||
|
||||
const { unmount } = render(<Sidebar {...props} />);
|
||||
|
||||
expect(getFavoritesOrder()).toEqual(['Zed', 'Amy']);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sort Favorites alphabetically' }));
|
||||
|
||||
expect(getFavoritesOrder()).toEqual(['Amy', 'Zed']);
|
||||
|
||||
unmount();
|
||||
render(<Sidebar {...props} />);
|
||||
|
||||
expect(getFavoritesOrder()).toEqual(['Amy', 'Zed']);
|
||||
});
|
||||
|
||||
it('seeds favorites sort from the legacy global sort order when section prefs are missing', () => {
|
||||
localStorage.setItem('remoteterm-sortOrder', 'alpha');
|
||||
|
||||
const publicChannel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public');
|
||||
const zed = makeContact('11'.repeat(32), 'Zed', 1, { last_advert: 150 });
|
||||
const amy = makeContact('22'.repeat(32), 'Amy');
|
||||
|
||||
render(
|
||||
<Sidebar
|
||||
contacts={[zed, amy]}
|
||||
channels={[publicChannel]}
|
||||
activeConversation={null}
|
||||
onSelectConversation={vi.fn()}
|
||||
onNewMessage={vi.fn()}
|
||||
lastMessageTimes={{
|
||||
[getStateKey('contact', zed.public_key)]: 200,
|
||||
}}
|
||||
unreadCounts={{}}
|
||||
mentions={{}}
|
||||
showCracker={false}
|
||||
crackerRunning={false}
|
||||
onToggleCracker={vi.fn()}
|
||||
onMarkAllRead={vi.fn()}
|
||||
favorites={[
|
||||
{ type: 'contact', id: zed.public_key },
|
||||
{ type: 'contact', id: amy.public_key },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
const favoriteRows = screen
|
||||
.getAllByText(/^(Amy|Zed)$/)
|
||||
.map((node) => node.textContent)
|
||||
.filter((text): text is string => Boolean(text));
|
||||
|
||||
expect(favoriteRows).toEqual(['Amy', 'Zed']);
|
||||
expect(screen.getByRole('button', { name: 'Sort Favorites by recent' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -225,9 +225,9 @@ describe('resolveContactFromHashToken', () => {
|
||||
name: 'Alice',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: 0,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
@@ -242,9 +242,9 @@ describe('resolveContactFromHashToken', () => {
|
||||
name: 'Alice',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: 0,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
@@ -259,9 +259,9 @@ describe('resolveContactFromHashToken', () => {
|
||||
name: null,
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: 0,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
|
||||
@@ -42,9 +42,9 @@ function makeContact(suffix: string): Contact {
|
||||
name: `Contact-${suffix}`,
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: 0,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
|
||||
@@ -200,9 +200,9 @@ describe('useConversationActions', () => {
|
||||
name: 'Alice',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: 'AABB',
|
||||
last_path_len: 2,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: 'AABB',
|
||||
direct_path_len: 2,
|
||||
direct_path_hash_mode: 0,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
|
||||
@@ -217,7 +217,9 @@ describe('useConversationMessages conversation switch', () => {
|
||||
|
||||
// Switch to conv B while older-messages fetch is still pending
|
||||
mockGetMessages.mockResolvedValueOnce([createMessage({ id: 999, conversation_key: 'conv_b' })]);
|
||||
rerender({ conv: convB });
|
||||
await act(async () => {
|
||||
rerender({ conv: convB });
|
||||
});
|
||||
|
||||
// loadingOlder must reset immediately — no phantom spinner in conv B
|
||||
await waitFor(() => expect(result.current.loadingOlder).toBe(false));
|
||||
@@ -226,12 +228,13 @@ describe('useConversationMessages conversation switch', () => {
|
||||
expect(result.current.messages[0].conversation_key).toBe('conv_b');
|
||||
|
||||
// Resolve the stale older-messages fetch — should not affect conv B's state
|
||||
olderDeferred.resolve([
|
||||
createMessage({ id: 500, conversation_key: 'conv_a', text: 'stale-old' }),
|
||||
]);
|
||||
await act(async () => {
|
||||
olderDeferred.resolve([
|
||||
createMessage({ id: 500, conversation_key: 'conv_a', text: 'stale-old' }),
|
||||
]);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Give the stale response time to be processed (it should be discarded)
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0].conversation_key).toBe('conv_b');
|
||||
});
|
||||
|
||||
@@ -100,9 +100,9 @@ describe('useRealtimeAppState', () => {
|
||||
name: 'Bob',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: 0,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: 0,
|
||||
direct_path_hash_mode: 0,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
@@ -142,9 +142,9 @@ describe('useRealtimeAppState', () => {
|
||||
name: 'Bob',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: 0,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: 0,
|
||||
direct_path_hash_mode: 0,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
@@ -232,9 +232,9 @@ describe('useRealtimeAppState', () => {
|
||||
name: null,
|
||||
type: 0,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_path_hash_mode: -1,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: -1,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
|
||||
77
frontend/src/test/useRememberedServerPassword.test.ts
Normal file
77
frontend/src/test/useRememberedServerPassword.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { useRememberedServerPassword } from '../hooks/useRememberedServerPassword';
|
||||
|
||||
describe('useRememberedServerPassword', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('loads remembered passwords from localStorage', () => {
|
||||
localStorage.setItem(
|
||||
'remoteterm-server-password:repeater:abc123',
|
||||
JSON.stringify({ password: 'stored-secret' })
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useRememberedServerPassword('repeater', 'abc123'));
|
||||
|
||||
expect(result.current.password).toBe('stored-secret');
|
||||
expect(result.current.rememberPassword).toBe(true);
|
||||
});
|
||||
|
||||
it('stores passwords after login when remember is enabled', () => {
|
||||
const { result } = renderHook(() => useRememberedServerPassword('room', 'room-key'));
|
||||
|
||||
act(() => {
|
||||
result.current.setRememberPassword(true);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.persistAfterLogin(' hello ');
|
||||
});
|
||||
|
||||
expect(localStorage.getItem('remoteterm-server-password:room:room-key')).toBe(
|
||||
JSON.stringify({ password: 'hello' })
|
||||
);
|
||||
expect(result.current.password).toBe('hello');
|
||||
});
|
||||
|
||||
it('clears stored passwords when login is done with remember disabled', () => {
|
||||
localStorage.setItem(
|
||||
'remoteterm-server-password:repeater:abc123',
|
||||
JSON.stringify({ password: 'stored-secret' })
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useRememberedServerPassword('repeater', 'abc123'));
|
||||
|
||||
act(() => {
|
||||
result.current.setRememberPassword(false);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.persistAfterLogin('new-secret');
|
||||
});
|
||||
|
||||
expect(localStorage.getItem('remoteterm-server-password:repeater:abc123')).toBeNull();
|
||||
expect(result.current.password).toBe('');
|
||||
});
|
||||
|
||||
it('preserves remembered passwords on guest login when remember stays enabled', () => {
|
||||
localStorage.setItem(
|
||||
'remoteterm-server-password:room:room-key',
|
||||
JSON.stringify({ password: 'stored-secret' })
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useRememberedServerPassword('room', 'room-key'));
|
||||
|
||||
act(() => {
|
||||
result.current.persistAfterLogin('');
|
||||
});
|
||||
|
||||
expect(localStorage.getItem('remoteterm-server-password:room:room-key')).toBe(
|
||||
JSON.stringify({ password: 'stored-secret' })
|
||||
);
|
||||
expect(result.current.password).toBe('stored-secret');
|
||||
});
|
||||
});
|
||||
@@ -44,9 +44,9 @@ function makeContact(pubkey: string): Contact {
|
||||
name: `Contact-${pubkey.slice(0, 6)}`,
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: 0,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
|
||||
@@ -44,9 +44,9 @@ function createContact(publicKey: string, name: string, type = 1): Contact {
|
||||
name,
|
||||
type,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: 0,
|
||||
out_path_hash_mode: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: 0,
|
||||
direct_path_hash_mode: 0,
|
||||
route_override_path: null,
|
||||
route_override_len: null,
|
||||
route_override_hash_mode: null,
|
||||
|
||||
@@ -112,9 +112,9 @@ describe('useWebSocket dispatch', () => {
|
||||
name: null,
|
||||
type: 0,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_path_hash_mode: -1,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: -1,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
|
||||
@@ -25,9 +25,9 @@ describe('wsEvents', () => {
|
||||
name: null,
|
||||
type: 0,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_path_hash_mode: -1,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: -1,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
@@ -50,9 +50,9 @@ describe('wsEvents', () => {
|
||||
name: null,
|
||||
type: 0,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_path_hash_mode: -1,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: -1,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface RadioConfig {
|
||||
path_hash_mode: number;
|
||||
path_hash_mode_supported: boolean;
|
||||
advert_location_source?: 'off' | 'current';
|
||||
multi_acks_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface RadioConfigUpdate {
|
||||
@@ -26,6 +27,7 @@ export interface RadioConfigUpdate {
|
||||
radio?: RadioSettings;
|
||||
path_hash_mode?: number;
|
||||
advert_location_source?: 'off' | 'current';
|
||||
multi_acks_enabled?: boolean;
|
||||
}
|
||||
|
||||
export type RadioDiscoveryTarget = 'repeaters' | 'sensors' | 'all';
|
||||
@@ -99,12 +101,17 @@ export interface Contact {
|
||||
name: string | null;
|
||||
type: number;
|
||||
flags: number;
|
||||
last_path: string | null;
|
||||
last_path_len: number;
|
||||
out_path_hash_mode: number;
|
||||
direct_path: string | null;
|
||||
direct_path_len: number;
|
||||
direct_path_hash_mode: number;
|
||||
direct_path_updated_at?: number | null;
|
||||
route_override_path?: string | null;
|
||||
route_override_len?: number | null;
|
||||
route_override_hash_mode?: number | null;
|
||||
effective_route?: ContactRoute | null;
|
||||
effective_route_source?: 'override' | 'direct' | 'flood';
|
||||
direct_route?: ContactRoute | null;
|
||||
route_override?: ContactRoute | null;
|
||||
last_advert: number | null;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
@@ -115,6 +122,12 @@ export interface Contact {
|
||||
first_seen: number | null;
|
||||
}
|
||||
|
||||
export interface ContactRoute {
|
||||
path: string;
|
||||
path_len: number;
|
||||
path_hash_mode: number;
|
||||
}
|
||||
|
||||
export interface ContactAdvertPath {
|
||||
path: string;
|
||||
path_len: number;
|
||||
@@ -339,6 +352,7 @@ export interface MigratePreferencesResponse {
|
||||
|
||||
/** Contact type constants */
|
||||
export const CONTACT_TYPE_REPEATER = 2;
|
||||
export const CONTACT_TYPE_ROOM = 3;
|
||||
|
||||
export interface NeighborInfo {
|
||||
pubkey_prefix: string;
|
||||
|
||||
@@ -3,18 +3,24 @@
|
||||
*
|
||||
* Uses the contact's public key to generate a consistent background color,
|
||||
* and extracts initials or emoji from the name for display.
|
||||
* Repeaters (type=2) always show 🛜 with a gray background.
|
||||
* Repeaters (type=2) and room servers (type=3) always show a fixed glyph.
|
||||
*/
|
||||
|
||||
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
|
||||
|
||||
// Repeater avatar styling
|
||||
// Fixed contact-type avatar styling
|
||||
const REPEATER_AVATAR = {
|
||||
text: '🛜',
|
||||
background: '#444444',
|
||||
textColor: '#ffffff',
|
||||
};
|
||||
|
||||
const ROOM_AVATAR = {
|
||||
text: '🛖',
|
||||
background: '#6b4f2a',
|
||||
textColor: '#ffffff',
|
||||
};
|
||||
|
||||
// DJB2 hash function for strings
|
||||
export function hashString(str: string): number {
|
||||
let hash = 0;
|
||||
@@ -103,7 +109,7 @@ function getAvatarColor(publicKey: string): {
|
||||
|
||||
/**
|
||||
* Get all avatar properties for a contact.
|
||||
* Repeaters (type=2) always get a special gray avatar with 🛜.
|
||||
* Repeaters and room servers always get a special fixed avatar.
|
||||
*/
|
||||
export function getContactAvatar(
|
||||
name: string | null,
|
||||
@@ -114,10 +120,12 @@ export function getContactAvatar(
|
||||
background: string;
|
||||
textColor: string;
|
||||
} {
|
||||
// Repeaters always get the repeater avatar
|
||||
if (contactType === CONTACT_TYPE_REPEATER) {
|
||||
return REPEATER_AVATAR;
|
||||
}
|
||||
if (contactType === CONTACT_TYPE_ROOM) {
|
||||
return ROOM_AVATAR;
|
||||
}
|
||||
|
||||
const text = getAvatarText(name, publicKey);
|
||||
const colors = getAvatarColor(publicKey);
|
||||
|
||||
@@ -15,7 +15,7 @@ const SIDEBAR_SECTION_SORT_ORDERS_KEY = 'remoteterm-sidebar-section-sort-orders'
|
||||
|
||||
export type ConversationTimes = Record<string, number>;
|
||||
export type SortOrder = 'recent' | 'alpha';
|
||||
export type SidebarSortableSection = 'channels' | 'contacts' | 'repeaters';
|
||||
export type SidebarSortableSection = 'favorites' | 'channels' | 'contacts' | 'rooms' | 'repeaters';
|
||||
export type SidebarSectionSortOrders = Record<SidebarSortableSection, SortOrder>;
|
||||
|
||||
// In-memory cache of last message times (loaded from server on init)
|
||||
@@ -113,8 +113,10 @@ export function buildSidebarSectionSortOrders(
|
||||
defaultOrder: SortOrder = 'recent'
|
||||
): SidebarSectionSortOrders {
|
||||
return {
|
||||
favorites: defaultOrder,
|
||||
channels: defaultOrder,
|
||||
contacts: defaultOrder,
|
||||
rooms: defaultOrder,
|
||||
repeaters: defaultOrder,
|
||||
};
|
||||
}
|
||||
@@ -129,8 +131,10 @@ export function loadLocalStorageSidebarSectionSortOrders(): SidebarSectionSortOr
|
||||
|
||||
const parsed = JSON.parse(stored) as Partial<SidebarSectionSortOrders>;
|
||||
return {
|
||||
favorites: parsed.favorites === 'alpha' ? 'alpha' : 'recent',
|
||||
channels: parsed.channels === 'alpha' ? 'alpha' : 'recent',
|
||||
contacts: parsed.contacts === 'alpha' ? 'alpha' : 'recent',
|
||||
rooms: parsed.rooms === 'alpha' ? 'alpha' : 'recent',
|
||||
repeaters: parsed.repeaters === 'alpha' ? 'alpha' : 'recent',
|
||||
};
|
||||
} catch {
|
||||
|
||||
32
frontend/src/utils/distanceUnits.ts
Normal file
32
frontend/src/utils/distanceUnits.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export const DISTANCE_UNIT_KEY = 'remoteterm-distance-unit';
|
||||
|
||||
export const DISTANCE_UNITS = ['imperial', 'metric', 'smoots'] as const;
|
||||
|
||||
export type DistanceUnit = (typeof DISTANCE_UNITS)[number];
|
||||
|
||||
export const DISTANCE_UNIT_LABELS: Record<DistanceUnit, string> = {
|
||||
imperial: 'Imperial',
|
||||
metric: 'Metric',
|
||||
smoots: 'Smoots',
|
||||
};
|
||||
|
||||
function isDistanceUnit(value: unknown): value is DistanceUnit {
|
||||
return typeof value === 'string' && DISTANCE_UNITS.includes(value as DistanceUnit);
|
||||
}
|
||||
|
||||
export function getSavedDistanceUnit(): DistanceUnit {
|
||||
try {
|
||||
const raw = localStorage.getItem(DISTANCE_UNIT_KEY);
|
||||
return isDistanceUnit(raw) ? raw : 'metric';
|
||||
} catch {
|
||||
return 'metric';
|
||||
}
|
||||
}
|
||||
|
||||
export function setSavedDistanceUnit(unit: DistanceUnit): void {
|
||||
try {
|
||||
localStorage.setItem(DISTANCE_UNIT_KEY, unit);
|
||||
} catch {
|
||||
// localStorage may be unavailable
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Contact, RadioConfig, MessagePath } from '../types';
|
||||
import type { Contact, ContactRoute, RadioConfig, MessagePath } from '../types';
|
||||
import type { DistanceUnit } from './distanceUnits';
|
||||
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||
|
||||
const MAX_PATH_BYTES = 64;
|
||||
@@ -37,6 +38,7 @@ export interface EffectiveContactRoute {
|
||||
pathLen: number;
|
||||
pathHashMode: number;
|
||||
forced: boolean;
|
||||
source: 'override' | 'direct' | 'flood';
|
||||
}
|
||||
|
||||
function normalizePathHashMode(mode: number | null | undefined): number | null {
|
||||
@@ -114,29 +116,86 @@ export function parsePathHops(path: string | null | undefined, hopCount?: number
|
||||
}
|
||||
|
||||
export function hasRoutingOverride(contact: Contact): boolean {
|
||||
return contact.route_override_len !== null && contact.route_override_len !== undefined;
|
||||
return (
|
||||
(contact.route_override !== null && contact.route_override !== undefined) ||
|
||||
(contact.route_override_len !== null && contact.route_override_len !== undefined)
|
||||
);
|
||||
}
|
||||
|
||||
export function getDirectContactRoute(contact: Contact): ContactRoute | null {
|
||||
if (contact.direct_route) {
|
||||
return contact.direct_route;
|
||||
}
|
||||
|
||||
if (contact.direct_path_len < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
path: contact.direct_path ?? '',
|
||||
path_len: contact.direct_path_len,
|
||||
path_hash_mode:
|
||||
normalizePathHashMode(contact.direct_path_hash_mode) ??
|
||||
inferPathHashMode(contact.direct_path, contact.direct_path_len) ??
|
||||
0,
|
||||
};
|
||||
}
|
||||
|
||||
function getRouteOverride(contact: Contact): ContactRoute | null {
|
||||
if (contact.route_override) {
|
||||
return contact.route_override;
|
||||
}
|
||||
|
||||
if (!hasRoutingOverride(contact)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pathLen = contact.route_override_len ?? -1;
|
||||
let pathHashMode = normalizePathHashMode(contact.route_override_hash_mode);
|
||||
if (pathLen === -1) {
|
||||
pathHashMode = -1;
|
||||
} else if (pathHashMode == null) {
|
||||
pathHashMode = inferPathHashMode(contact.route_override_path, pathLen) ?? 0;
|
||||
}
|
||||
|
||||
return {
|
||||
path: contact.route_override_path ?? '',
|
||||
path_len: pathLen,
|
||||
path_hash_mode: pathHashMode,
|
||||
};
|
||||
}
|
||||
|
||||
export function getEffectiveContactRoute(contact: Contact): EffectiveContactRoute {
|
||||
const forced = hasRoutingOverride(contact);
|
||||
const pathLen = forced ? (contact.route_override_len ?? -1) : contact.last_path_len;
|
||||
const path = forced ? (contact.route_override_path ?? '') : (contact.last_path ?? '');
|
||||
const route = contact.effective_route;
|
||||
if (route) {
|
||||
return {
|
||||
path: route.path || null,
|
||||
pathLen: route.path_len,
|
||||
pathHashMode: route.path_hash_mode,
|
||||
forced: contact.effective_route_source === 'override',
|
||||
source: contact.effective_route_source ?? 'flood',
|
||||
};
|
||||
}
|
||||
|
||||
let pathHashMode = forced
|
||||
? (contact.route_override_hash_mode ?? null)
|
||||
: (contact.out_path_hash_mode ?? null);
|
||||
const directRoute = getDirectContactRoute(contact);
|
||||
const overrideRoute = getRouteOverride(contact);
|
||||
const resolvedRoute = overrideRoute ?? directRoute;
|
||||
const source = overrideRoute ? 'override' : directRoute ? 'direct' : 'flood';
|
||||
const pathLen = resolvedRoute?.path_len ?? -1;
|
||||
let pathHashMode = resolvedRoute?.path_hash_mode ?? null;
|
||||
|
||||
if (pathLen === -1) {
|
||||
pathHashMode = -1;
|
||||
} else if (pathHashMode == null || pathHashMode < 0 || pathHashMode > 2) {
|
||||
pathHashMode = inferPathHashMode(path, pathLen) ?? 0;
|
||||
pathHashMode = inferPathHashMode(resolvedRoute?.path, pathLen) ?? 0;
|
||||
}
|
||||
|
||||
return {
|
||||
path: path || null,
|
||||
path: resolvedRoute?.path || null,
|
||||
pathLen,
|
||||
pathHashMode,
|
||||
forced,
|
||||
forced: source === 'override',
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -151,16 +210,17 @@ export function formatRouteLabel(pathLen: number, capitalize: boolean = false):
|
||||
}
|
||||
|
||||
export function formatRoutingOverrideInput(contact: Contact): string {
|
||||
if (!hasRoutingOverride(contact)) {
|
||||
const routeOverride = getRouteOverride(contact);
|
||||
if (!routeOverride) {
|
||||
return '';
|
||||
}
|
||||
if (contact.route_override_len === -1) {
|
||||
if (routeOverride.path_len === -1) {
|
||||
return '-1';
|
||||
}
|
||||
if (contact.route_override_len === 0) {
|
||||
if (routeOverride.path_len === 0) {
|
||||
return '0';
|
||||
}
|
||||
return parsePathHops(contact.route_override_path, contact.route_override_len)
|
||||
return parsePathHops(routeOverride.path, routeOverride.path_len)
|
||||
.map((hop) => hop.toLowerCase())
|
||||
.join(',');
|
||||
}
|
||||
@@ -284,13 +344,35 @@ export function isValidLocation(lat: number | null, lon: number | null): boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Format distance in human-readable form (m or km)
|
||||
* Format distance in human-readable form using the selected display unit.
|
||||
*/
|
||||
export function formatDistance(km: number): string {
|
||||
if (km < 1) {
|
||||
return `${Math.round(km * 1000)}m`;
|
||||
export function formatDistance(km: number, unit: DistanceUnit = 'imperial'): string {
|
||||
const formatInteger = (value: number) => value.toLocaleString();
|
||||
const formatOneDecimal = (value: number) =>
|
||||
value.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
|
||||
if (unit === 'metric') {
|
||||
if (km < 1) {
|
||||
return `${formatInteger(Math.round(km * 1000))}m`;
|
||||
}
|
||||
return `${formatOneDecimal(km)}km`;
|
||||
}
|
||||
return `${km.toFixed(1)}km`;
|
||||
|
||||
if (unit === 'smoots') {
|
||||
const smoots = (km * 1000) / 1.7018;
|
||||
const rounded = smoots < 10 ? Number(smoots.toFixed(1)) : Math.round(smoots);
|
||||
const display = smoots < 10 ? formatOneDecimal(rounded) : formatInteger(rounded);
|
||||
return `${display} ${rounded === 1 ? 'smoot' : 'smoots'}`;
|
||||
}
|
||||
|
||||
const miles = km * 0.621371;
|
||||
if (miles < 0.1) {
|
||||
return `${formatInteger(Math.round(km * 3280.839895))}ft`;
|
||||
}
|
||||
return `${formatOneDecimal(miles)}mi`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
388
frontend/src/utils/rawPacketInspector.ts
Normal file
388
frontend/src/utils/rawPacketInspector.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
import {
|
||||
MeshCoreDecoder,
|
||||
PayloadType,
|
||||
Utils,
|
||||
type DecodedPacket,
|
||||
type DecryptionOptions,
|
||||
type HeaderBreakdown,
|
||||
type PacketStructure,
|
||||
} from '@michaelhart/meshcore-decoder';
|
||||
|
||||
import type { Channel, RawPacket } from '../types';
|
||||
|
||||
export interface RawPacketSummary {
|
||||
summary: string;
|
||||
routeType: string;
|
||||
details?: string;
|
||||
}
|
||||
|
||||
export interface PacketByteField {
|
||||
id: string;
|
||||
scope: 'packet' | 'payload';
|
||||
name: string;
|
||||
description: string;
|
||||
value: string;
|
||||
decryptedMessage?: string;
|
||||
startByte: number;
|
||||
endByte: number;
|
||||
absoluteStartByte: number;
|
||||
absoluteEndByte: number;
|
||||
headerBreakdown?: HeaderBreakdown;
|
||||
}
|
||||
|
||||
export interface RawPacketInspection {
|
||||
decoded: DecodedPacket | null;
|
||||
structure: PacketStructure | null;
|
||||
routeTypeName: string;
|
||||
payloadTypeName: string;
|
||||
payloadVersionName: string;
|
||||
pathTokens: string[];
|
||||
summary: RawPacketSummary;
|
||||
validationErrors: string[];
|
||||
packetFields: PacketByteField[];
|
||||
payloadFields: PacketByteField[];
|
||||
}
|
||||
|
||||
export function formatHexByHop(hex: string, hashSize: number | null | undefined): string {
|
||||
const normalized = hex.trim().toUpperCase();
|
||||
if (!normalized || !hashSize || hashSize < 1) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const charsPerHop = hashSize * 2;
|
||||
if (normalized.length <= charsPerHop || normalized.length % charsPerHop !== 0) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const hops = normalized.match(new RegExp(`.{1,${charsPerHop}}`, 'g'));
|
||||
return hops && hops.length > 1 ? hops.join(' → ') : normalized;
|
||||
}
|
||||
|
||||
export function describeCiphertextStructure(
|
||||
payloadType: PayloadType,
|
||||
byteLength: number,
|
||||
fallbackDescription: string
|
||||
): string {
|
||||
switch (payloadType) {
|
||||
case PayloadType.GroupText:
|
||||
return `Encrypted message content (${byteLength} bytes). Contains encrypted plaintext with this structure:
|
||||
• Timestamp (4 bytes) - send time as unix timestamp
|
||||
• Flags (1 byte) - room-message flags byte
|
||||
• Message (remaining bytes) - UTF-8 room message text`;
|
||||
case PayloadType.TextMessage:
|
||||
return `Encrypted message data (${byteLength} bytes). Contains encrypted plaintext with this structure:
|
||||
• Timestamp (4 bytes) - send time as unix timestamp
|
||||
• Message (remaining bytes) - UTF-8 direct message text`;
|
||||
case PayloadType.Response:
|
||||
return `Encrypted response data (${byteLength} bytes). Contains encrypted plaintext with this structure:
|
||||
• Tag (4 bytes) - request/response correlation tag
|
||||
• Content (remaining bytes) - response body`;
|
||||
default:
|
||||
return fallbackDescription;
|
||||
}
|
||||
}
|
||||
|
||||
function getPathTokens(decoded: DecodedPacket): string[] {
|
||||
const tracePayload =
|
||||
decoded.payloadType === PayloadType.Trace && decoded.payload.decoded
|
||||
? (decoded.payload.decoded as { pathHashes?: string[] })
|
||||
: null;
|
||||
return tracePayload?.pathHashes || decoded.path || [];
|
||||
}
|
||||
|
||||
function formatUnixTimestamp(timestamp: number): string {
|
||||
return `${timestamp} (${new Date(timestamp * 1000).toLocaleString()})`;
|
||||
}
|
||||
|
||||
function createPacketField(
|
||||
scope: 'packet' | 'payload',
|
||||
id: string,
|
||||
field: {
|
||||
name: string;
|
||||
description: string;
|
||||
value: string;
|
||||
decryptedMessage?: string;
|
||||
startByte: number;
|
||||
endByte: number;
|
||||
headerBreakdown?: HeaderBreakdown;
|
||||
},
|
||||
absoluteOffset: number
|
||||
): PacketByteField {
|
||||
return {
|
||||
id,
|
||||
scope,
|
||||
name: field.name,
|
||||
description: field.description,
|
||||
value: field.value,
|
||||
decryptedMessage: field.decryptedMessage,
|
||||
startByte: field.startByte,
|
||||
endByte: field.endByte,
|
||||
absoluteStartByte: absoluteOffset + field.startByte,
|
||||
absoluteEndByte: absoluteOffset + field.endByte,
|
||||
headerBreakdown: field.headerBreakdown,
|
||||
};
|
||||
}
|
||||
|
||||
export function createDecoderOptions(
|
||||
channels: Channel[] | null | undefined
|
||||
): DecryptionOptions | undefined {
|
||||
const channelSecrets =
|
||||
channels
|
||||
?.map((channel) => channel.key?.trim())
|
||||
.filter((key): key is string => Boolean(key && key.length > 0)) ?? [];
|
||||
|
||||
if (channelSecrets.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
keyStore: MeshCoreDecoder.createKeyStore({ channelSecrets }),
|
||||
attemptDecryption: true,
|
||||
};
|
||||
}
|
||||
|
||||
function safeValidate(hexData: string): string[] {
|
||||
try {
|
||||
const validation = MeshCoreDecoder.validate(hexData);
|
||||
return validation.errors ?? [];
|
||||
} catch (error) {
|
||||
return [error instanceof Error ? error.message : 'Packet validation failed'];
|
||||
}
|
||||
}
|
||||
|
||||
export function decodePacketSummary(
|
||||
packet: RawPacket,
|
||||
decoderOptions?: DecryptionOptions
|
||||
): RawPacketSummary {
|
||||
try {
|
||||
const decoded = MeshCoreDecoder.decode(packet.data, decoderOptions);
|
||||
|
||||
if (!decoded.isValid) {
|
||||
return { summary: 'Invalid packet', routeType: 'Unknown' };
|
||||
}
|
||||
|
||||
const routeType = Utils.getRouteTypeName(decoded.routeType);
|
||||
const payloadTypeName = Utils.getPayloadTypeName(decoded.payloadType);
|
||||
const pathTokens = getPathTokens(decoded);
|
||||
const pathStr = pathTokens.length > 0 ? ` via ${pathTokens.join(', ')}` : '';
|
||||
|
||||
let summary = payloadTypeName;
|
||||
let details: string | undefined;
|
||||
|
||||
switch (decoded.payloadType) {
|
||||
case PayloadType.TextMessage: {
|
||||
const payload = decoded.payload.decoded as {
|
||||
destinationHash?: string;
|
||||
sourceHash?: string;
|
||||
} | null;
|
||||
if (payload?.sourceHash && payload?.destinationHash) {
|
||||
summary = `DM from ${payload.sourceHash} to ${payload.destinationHash}${pathStr}`;
|
||||
} else {
|
||||
summary = `DM${pathStr}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case PayloadType.GroupText: {
|
||||
const payload = decoded.payload.decoded as {
|
||||
channelHash?: string;
|
||||
decrypted?: { sender?: string; message?: string };
|
||||
} | null;
|
||||
if (packet.decrypted_info?.channel_name) {
|
||||
if (packet.decrypted_info.sender) {
|
||||
summary = `GT from ${packet.decrypted_info.sender} in ${packet.decrypted_info.channel_name}${pathStr}`;
|
||||
} else {
|
||||
summary = `GT in ${packet.decrypted_info.channel_name}${pathStr}`;
|
||||
}
|
||||
} else if (payload?.decrypted?.sender) {
|
||||
summary = `GT from ${payload.decrypted.sender}${pathStr}`;
|
||||
} else if (payload?.decrypted?.message) {
|
||||
summary = `GT decrypted${pathStr}`;
|
||||
} else if (payload?.channelHash) {
|
||||
summary = `GT ch:${payload.channelHash}${pathStr}`;
|
||||
} else {
|
||||
summary = `GroupText${pathStr}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case PayloadType.Advert: {
|
||||
const payload = decoded.payload.decoded as {
|
||||
publicKey?: string;
|
||||
appData?: { name?: string; deviceRole?: number };
|
||||
} | null;
|
||||
if (payload?.appData?.name) {
|
||||
const role =
|
||||
payload.appData.deviceRole !== undefined
|
||||
? Utils.getDeviceRoleName(payload.appData.deviceRole)
|
||||
: '';
|
||||
summary = `Advert: ${payload.appData.name}${role ? ` (${role})` : ''}${pathStr}`;
|
||||
} else if (payload?.publicKey) {
|
||||
summary = `Advert: ${payload.publicKey.slice(0, 8)}...${pathStr}`;
|
||||
} else {
|
||||
summary = `Advert${pathStr}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case PayloadType.Ack:
|
||||
summary = `ACK${pathStr}`;
|
||||
break;
|
||||
case PayloadType.Request:
|
||||
summary = `Request${pathStr}`;
|
||||
break;
|
||||
case PayloadType.Response:
|
||||
summary = `Response${pathStr}`;
|
||||
break;
|
||||
case PayloadType.Trace:
|
||||
summary = `Trace${pathStr}`;
|
||||
break;
|
||||
case PayloadType.Path:
|
||||
summary = `Path${pathStr}`;
|
||||
break;
|
||||
default:
|
||||
summary = `${payloadTypeName}${pathStr}`;
|
||||
break;
|
||||
}
|
||||
|
||||
return { summary, routeType, details };
|
||||
} catch {
|
||||
return { summary: 'Decode error', routeType: 'Unknown' };
|
||||
}
|
||||
}
|
||||
|
||||
export function inspectRawPacket(packet: RawPacket): RawPacketInspection {
|
||||
return inspectRawPacketWithOptions(packet);
|
||||
}
|
||||
|
||||
export function inspectRawPacketWithOptions(
|
||||
packet: RawPacket,
|
||||
decoderOptions?: DecryptionOptions
|
||||
): RawPacketInspection {
|
||||
const summary = decodePacketSummary(packet, decoderOptions);
|
||||
const validationErrors = safeValidate(packet.data);
|
||||
|
||||
let decoded: DecodedPacket | null = null;
|
||||
let structure: PacketStructure | null = null;
|
||||
|
||||
try {
|
||||
decoded = MeshCoreDecoder.decode(packet.data, decoderOptions);
|
||||
} catch {
|
||||
decoded = null;
|
||||
}
|
||||
|
||||
try {
|
||||
structure = MeshCoreDecoder.analyzeStructure(packet.data, decoderOptions);
|
||||
} catch {
|
||||
structure = null;
|
||||
}
|
||||
|
||||
const routeTypeName = decoded?.isValid
|
||||
? Utils.getRouteTypeName(decoded.routeType)
|
||||
: summary.routeType;
|
||||
const payloadTypeName = decoded?.isValid
|
||||
? Utils.getPayloadTypeName(decoded.payloadType)
|
||||
: packet.payload_type;
|
||||
const payloadVersionName = decoded?.isValid
|
||||
? Utils.getPayloadVersionName(decoded.payloadVersion)
|
||||
: 'Unknown';
|
||||
const pathTokens = decoded?.isValid ? getPathTokens(decoded) : [];
|
||||
|
||||
const packetFields =
|
||||
structure?.segments
|
||||
.map((segment, index) => createPacketField('packet', `packet-${index}`, segment, 0))
|
||||
.map((field) => {
|
||||
if (field.name !== 'Path Data') {
|
||||
return field;
|
||||
}
|
||||
const hashSize =
|
||||
decoded?.pathHashSize ??
|
||||
(decoded?.pathLength && decoded.pathLength > 0
|
||||
? Math.max(1, field.value.length / 2 / decoded.pathLength)
|
||||
: null);
|
||||
return {
|
||||
...field,
|
||||
value: formatHexByHop(field.value, hashSize),
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
const payloadFields =
|
||||
structure == null
|
||||
? []
|
||||
: (structure.payload.segments.length > 0
|
||||
? structure.payload.segments
|
||||
: structure.payload.hex.length > 0
|
||||
? [
|
||||
{
|
||||
name: 'Payload Bytes',
|
||||
description:
|
||||
'Field-level payload breakdown is not available for this packet type.',
|
||||
startByte: 0,
|
||||
endByte: Math.max(0, structure.payload.hex.length / 2 - 1),
|
||||
value: structure.payload.hex,
|
||||
},
|
||||
]
|
||||
: []
|
||||
).map((segment, index) =>
|
||||
createPacketField('payload', `payload-${index}`, segment, structure.payload.startByte)
|
||||
);
|
||||
|
||||
const enrichedPayloadFields =
|
||||
decoded?.isValid && decoded.payloadType === PayloadType.GroupText && decoded.payload.decoded
|
||||
? payloadFields.map((field) => {
|
||||
if (field.name !== 'Ciphertext') {
|
||||
return field;
|
||||
}
|
||||
const payload = decoded.payload.decoded as {
|
||||
decrypted?: { timestamp?: number; flags?: number; sender?: string; message?: string };
|
||||
};
|
||||
if (!payload.decrypted?.message) {
|
||||
return field;
|
||||
}
|
||||
const detailLines = [
|
||||
payload.decrypted.timestamp != null
|
||||
? `Timestamp: ${formatUnixTimestamp(payload.decrypted.timestamp)}`
|
||||
: null,
|
||||
payload.decrypted.flags != null
|
||||
? `Flags: 0x${payload.decrypted.flags.toString(16).padStart(2, '0')}`
|
||||
: null,
|
||||
payload.decrypted.sender ? `Sender: ${payload.decrypted.sender}` : null,
|
||||
`Message: ${payload.decrypted.message}`,
|
||||
].filter((line): line is string => line !== null);
|
||||
return {
|
||||
...field,
|
||||
description: describeCiphertextStructure(
|
||||
decoded.payloadType,
|
||||
field.endByte - field.startByte + 1,
|
||||
field.description
|
||||
),
|
||||
decryptedMessage: detailLines.join('\n'),
|
||||
};
|
||||
})
|
||||
: payloadFields.map((field) => {
|
||||
if (!decoded?.isValid || field.name !== 'Ciphertext') {
|
||||
return field;
|
||||
}
|
||||
return {
|
||||
...field,
|
||||
description: describeCiphertextStructure(
|
||||
decoded.payloadType,
|
||||
field.endByte - field.startByte + 1,
|
||||
field.description
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
decoded,
|
||||
structure,
|
||||
routeTypeName,
|
||||
payloadTypeName,
|
||||
payloadVersionName,
|
||||
pathTokens,
|
||||
summary,
|
||||
validationErrors:
|
||||
validationErrors.length > 0
|
||||
? validationErrors
|
||||
: (decoded?.errors ?? (decoded || structure ? [] : ['Unable to decode packet'])),
|
||||
packetFields,
|
||||
payloadFields: enrichedPayloadFields,
|
||||
};
|
||||
}
|
||||
531
frontend/src/utils/rawPacketStats.ts
Normal file
531
frontend/src/utils/rawPacketStats.ts
Normal file
@@ -0,0 +1,531 @@
|
||||
import { MeshCoreDecoder, PayloadType, Utils } from '@michaelhart/meshcore-decoder';
|
||||
|
||||
import type { RawPacket } from '../types';
|
||||
import { getRawPacketObservationKey } from './rawPacketIdentity';
|
||||
|
||||
export const RAW_PACKET_STATS_WINDOWS = ['1m', '5m', '10m', '30m', 'session'] as const;
|
||||
export type RawPacketStatsWindow = (typeof RAW_PACKET_STATS_WINDOWS)[number];
|
||||
|
||||
export const RAW_PACKET_STATS_WINDOW_SECONDS: Record<
|
||||
Exclude<RawPacketStatsWindow, 'session'>,
|
||||
number
|
||||
> = {
|
||||
'1m': 60,
|
||||
'5m': 5 * 60,
|
||||
'10m': 10 * 60,
|
||||
'30m': 30 * 60,
|
||||
};
|
||||
|
||||
export const MAX_RAW_PACKET_STATS_OBSERVATIONS = 20000;
|
||||
|
||||
const KNOWN_PAYLOAD_TYPES = [
|
||||
'Advert',
|
||||
'GroupText',
|
||||
'TextMessage',
|
||||
'Ack',
|
||||
'Request',
|
||||
'Response',
|
||||
'Trace',
|
||||
'Path',
|
||||
'Control',
|
||||
'Unknown',
|
||||
] as const;
|
||||
|
||||
const KNOWN_ROUTE_TYPES = [
|
||||
'Flood',
|
||||
'Direct',
|
||||
'TransportFlood',
|
||||
'TransportDirect',
|
||||
'Unknown',
|
||||
] as const;
|
||||
|
||||
export interface RawPacketStatsObservation {
|
||||
observationKey: string;
|
||||
timestamp: number;
|
||||
payloadType: string;
|
||||
routeType: string;
|
||||
decrypted: boolean;
|
||||
rssi: number | null;
|
||||
snr: number | null;
|
||||
sourceKey: string | null;
|
||||
sourceLabel: string | null;
|
||||
pathTokenCount: number;
|
||||
pathSignature: string | null;
|
||||
hopByteWidth?: number | null;
|
||||
}
|
||||
|
||||
export interface RawPacketStatsSessionState {
|
||||
sessionStartedAt: number;
|
||||
totalObservedPackets: number;
|
||||
trimmedObservationCount: number;
|
||||
observations: RawPacketStatsObservation[];
|
||||
}
|
||||
|
||||
export interface RankedPacketStat {
|
||||
label: string;
|
||||
count: number;
|
||||
share: number;
|
||||
}
|
||||
|
||||
export interface NeighborStat {
|
||||
key: string;
|
||||
label: string;
|
||||
count: number;
|
||||
bestRssi: number | null;
|
||||
lastSeen: number;
|
||||
}
|
||||
|
||||
export interface PacketTimelineBin {
|
||||
label: string;
|
||||
total: number;
|
||||
countsByType: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface RawPacketStatsSnapshot {
|
||||
window: RawPacketStatsWindow;
|
||||
nowSec: number;
|
||||
packets: RawPacketStatsObservation[];
|
||||
packetCount: number;
|
||||
packetsPerMinute: number;
|
||||
uniqueSources: number;
|
||||
decryptedCount: number;
|
||||
undecryptedCount: number;
|
||||
decryptRate: number;
|
||||
pathBearingCount: number;
|
||||
pathBearingRate: number;
|
||||
distinctPaths: number;
|
||||
payloadBreakdown: RankedPacketStat[];
|
||||
routeBreakdown: RankedPacketStat[];
|
||||
topPacketTypes: RankedPacketStat[];
|
||||
hopProfile: RankedPacketStat[];
|
||||
hopByteWidthProfile: RankedPacketStat[];
|
||||
strongestNeighbors: NeighborStat[];
|
||||
mostActiveNeighbors: NeighborStat[];
|
||||
newestNeighbors: NeighborStat[];
|
||||
averageRssi: number | null;
|
||||
medianRssi: number | null;
|
||||
bestRssi: number | null;
|
||||
rssiBuckets: RankedPacketStat[];
|
||||
strongestPacketSourceKey: string | null;
|
||||
strongestPacketSourceLabel: string | null;
|
||||
strongestPacketPayloadType: string | null;
|
||||
coverageSeconds: number;
|
||||
windowFullyCovered: boolean;
|
||||
oldestStoredTimestamp: number | null;
|
||||
timeline: PacketTimelineBin[];
|
||||
}
|
||||
|
||||
function toSourceLabel(sourceKey: string): string {
|
||||
if (sourceKey.startsWith('name:')) {
|
||||
return sourceKey.slice(5);
|
||||
}
|
||||
return sourceKey.slice(0, 12).toUpperCase();
|
||||
}
|
||||
|
||||
function getPathTokens(decoded: ReturnType<typeof MeshCoreDecoder.decode>): string[] {
|
||||
const tracePayload =
|
||||
decoded.payloadType === PayloadType.Trace && decoded.payload.decoded
|
||||
? (decoded.payload.decoded as { pathHashes?: string[] })
|
||||
: null;
|
||||
return tracePayload?.pathHashes || decoded.path || [];
|
||||
}
|
||||
|
||||
function getSourceInfo(
|
||||
packet: RawPacket,
|
||||
decoded: ReturnType<typeof MeshCoreDecoder.decode>
|
||||
): Pick<RawPacketStatsObservation, 'sourceKey' | 'sourceLabel'> {
|
||||
if (!decoded.isValid || !decoded.payload.decoded) {
|
||||
const fallbackContactKey = packet.decrypted_info?.contact_key?.toUpperCase() ?? null;
|
||||
if (fallbackContactKey) {
|
||||
return {
|
||||
sourceKey: fallbackContactKey,
|
||||
sourceLabel: packet.decrypted_info?.sender || toSourceLabel(fallbackContactKey),
|
||||
};
|
||||
}
|
||||
if (packet.decrypted_info?.sender) {
|
||||
return {
|
||||
sourceKey: `name:${packet.decrypted_info.sender.toLowerCase()}`,
|
||||
sourceLabel: packet.decrypted_info.sender,
|
||||
};
|
||||
}
|
||||
return { sourceKey: null, sourceLabel: null };
|
||||
}
|
||||
|
||||
switch (decoded.payloadType) {
|
||||
case PayloadType.Advert: {
|
||||
const publicKey = (decoded.payload.decoded as { publicKey?: string }).publicKey;
|
||||
if (!publicKey) return { sourceKey: null, sourceLabel: null };
|
||||
return {
|
||||
sourceKey: publicKey.toUpperCase(),
|
||||
sourceLabel: publicKey.slice(0, 12).toUpperCase(),
|
||||
};
|
||||
}
|
||||
case PayloadType.TextMessage:
|
||||
case PayloadType.Request:
|
||||
case PayloadType.Response: {
|
||||
const sourceHash = (decoded.payload.decoded as { sourceHash?: string }).sourceHash;
|
||||
if (!sourceHash) return { sourceKey: null, sourceLabel: null };
|
||||
return {
|
||||
sourceKey: sourceHash.toUpperCase(),
|
||||
sourceLabel: sourceHash.toUpperCase(),
|
||||
};
|
||||
}
|
||||
case PayloadType.GroupText: {
|
||||
const contactKey = packet.decrypted_info?.contact_key?.toUpperCase() ?? null;
|
||||
if (contactKey) {
|
||||
return {
|
||||
sourceKey: contactKey,
|
||||
sourceLabel: packet.decrypted_info?.sender || toSourceLabel(contactKey),
|
||||
};
|
||||
}
|
||||
if (packet.decrypted_info?.sender) {
|
||||
return {
|
||||
sourceKey: `name:${packet.decrypted_info.sender.toLowerCase()}`,
|
||||
sourceLabel: packet.decrypted_info.sender,
|
||||
};
|
||||
}
|
||||
return { sourceKey: null, sourceLabel: null };
|
||||
}
|
||||
case PayloadType.AnonRequest: {
|
||||
const senderPublicKey = (decoded.payload.decoded as { senderPublicKey?: string })
|
||||
.senderPublicKey;
|
||||
if (!senderPublicKey) return { sourceKey: null, sourceLabel: null };
|
||||
return {
|
||||
sourceKey: senderPublicKey.toUpperCase(),
|
||||
sourceLabel: senderPublicKey.slice(0, 12).toUpperCase(),
|
||||
};
|
||||
}
|
||||
default: {
|
||||
const fallbackContactKey = packet.decrypted_info?.contact_key?.toUpperCase() ?? null;
|
||||
if (fallbackContactKey) {
|
||||
return {
|
||||
sourceKey: fallbackContactKey,
|
||||
sourceLabel: packet.decrypted_info?.sender || toSourceLabel(fallbackContactKey),
|
||||
};
|
||||
}
|
||||
return { sourceKey: null, sourceLabel: null };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function summarizeRawPacketForStats(packet: RawPacket): RawPacketStatsObservation {
|
||||
try {
|
||||
const decoded = MeshCoreDecoder.decode(packet.data);
|
||||
const pathTokens = decoded.isValid ? getPathTokens(decoded) : [];
|
||||
const payloadType = decoded.isValid
|
||||
? Utils.getPayloadTypeName(decoded.payloadType)
|
||||
: packet.payload_type;
|
||||
const routeType = decoded.isValid ? Utils.getRouteTypeName(decoded.routeType) : 'Unknown';
|
||||
const sourceInfo = getSourceInfo(packet, decoded);
|
||||
|
||||
return {
|
||||
observationKey: getRawPacketObservationKey(packet),
|
||||
timestamp: packet.timestamp,
|
||||
payloadType,
|
||||
routeType,
|
||||
decrypted: packet.decrypted,
|
||||
rssi: packet.rssi,
|
||||
snr: packet.snr,
|
||||
sourceKey: sourceInfo.sourceKey,
|
||||
sourceLabel: sourceInfo.sourceLabel,
|
||||
pathTokenCount: pathTokens.length,
|
||||
pathSignature: pathTokens.length > 0 ? pathTokens.join('>') : null,
|
||||
hopByteWidth: pathTokens.length > 0 ? (decoded.pathHashSize ?? 1) : null,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
observationKey: getRawPacketObservationKey(packet),
|
||||
timestamp: packet.timestamp,
|
||||
payloadType: packet.payload_type,
|
||||
routeType: 'Unknown',
|
||||
decrypted: packet.decrypted,
|
||||
rssi: packet.rssi,
|
||||
snr: packet.snr,
|
||||
sourceKey: null,
|
||||
sourceLabel: null,
|
||||
pathTokenCount: 0,
|
||||
pathSignature: null,
|
||||
hopByteWidth: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function inferHopByteWidth(packet: RawPacketStatsObservation): number | null {
|
||||
if (packet.pathTokenCount <= 0) {
|
||||
return null;
|
||||
}
|
||||
if (packet.hopByteWidth && packet.hopByteWidth > 0) {
|
||||
return packet.hopByteWidth;
|
||||
}
|
||||
const firstToken = packet.pathSignature?.split('>')[0] ?? null;
|
||||
if (!firstToken || firstToken.length % 2 !== 0) {
|
||||
return null;
|
||||
}
|
||||
const inferred = firstToken.length / 2;
|
||||
return inferred >= 1 && inferred <= 3 ? inferred : null;
|
||||
}
|
||||
|
||||
function share(count: number, total: number): number {
|
||||
if (total <= 0) return 0;
|
||||
return count / total;
|
||||
}
|
||||
|
||||
function createCountsMap(labels: readonly string[]): Map<string, number> {
|
||||
return new Map(labels.map((label) => [label, 0]));
|
||||
}
|
||||
|
||||
function rankedBreakdown(counts: Map<string, number>, total: number): RankedPacketStat[] {
|
||||
return Array.from(counts.entries())
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||
.map(([label, count]) => ({ label, count, share: share(count, total) }));
|
||||
}
|
||||
|
||||
function orderedBreakdown(counts: Map<string, number>, total: number): RankedPacketStat[] {
|
||||
return Array.from(counts.entries()).map(([label, count]) => ({
|
||||
label,
|
||||
count,
|
||||
share: share(count, total),
|
||||
}));
|
||||
}
|
||||
|
||||
function median(values: number[]): number | null {
|
||||
if (values.length === 0) return null;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
if (sorted.length % 2 === 1) {
|
||||
return sorted[mid];
|
||||
}
|
||||
return (sorted[mid - 1] + sorted[mid]) / 2;
|
||||
}
|
||||
|
||||
function formatTimelineLabel(timestamp: number): string {
|
||||
return new Date(timestamp * 1000).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function getHopProfileBucket(pathTokenCount: number): string {
|
||||
if (pathTokenCount <= 0) {
|
||||
return '0';
|
||||
}
|
||||
if (pathTokenCount === 1) {
|
||||
return '1';
|
||||
}
|
||||
if (pathTokenCount <= 5) {
|
||||
return '2-5';
|
||||
}
|
||||
if (pathTokenCount <= 10) {
|
||||
return '6-10';
|
||||
}
|
||||
if (pathTokenCount <= 15) {
|
||||
return '11-15';
|
||||
}
|
||||
return '16+';
|
||||
}
|
||||
|
||||
export function buildRawPacketStatsSnapshot(
|
||||
session: RawPacketStatsSessionState,
|
||||
window: RawPacketStatsWindow,
|
||||
nowSec: number = Math.floor(Date.now() / 1000)
|
||||
): RawPacketStatsSnapshot {
|
||||
const sessionStartedSec = Math.floor(session.sessionStartedAt / 1000);
|
||||
const windowSeconds = window === 'session' ? null : RAW_PACKET_STATS_WINDOW_SECONDS[window];
|
||||
const windowStart = windowSeconds === null ? sessionStartedSec : nowSec - windowSeconds;
|
||||
const packets = session.observations.filter((packet) => packet.timestamp >= windowStart);
|
||||
const packetCount = packets.length;
|
||||
const uniqueSources = new Set(packets.map((packet) => packet.sourceKey).filter(Boolean)).size;
|
||||
const decryptedCount = packets.filter((packet) => packet.decrypted).length;
|
||||
const undecryptedCount = packetCount - decryptedCount;
|
||||
const pathBearingCount = packets.filter((packet) => packet.pathTokenCount > 0).length;
|
||||
const distinctPaths = new Set(
|
||||
packets.map((packet) => packet.pathSignature).filter((value): value is string => Boolean(value))
|
||||
).size;
|
||||
const effectiveCoverageSeconds =
|
||||
windowSeconds ?? Math.max(1, nowSec - Math.min(sessionStartedSec, nowSec));
|
||||
const packetsPerMinute = packetCount / Math.max(effectiveCoverageSeconds / 60, 1 / 60);
|
||||
|
||||
const payloadCounts = createCountsMap(KNOWN_PAYLOAD_TYPES);
|
||||
const routeCounts = createCountsMap(KNOWN_ROUTE_TYPES);
|
||||
const hopCounts = new Map<string, number>([
|
||||
['0', 0],
|
||||
['1', 0],
|
||||
['2-5', 0],
|
||||
['6-10', 0],
|
||||
['11-15', 0],
|
||||
['16+', 0],
|
||||
]);
|
||||
const hopByteWidthCounts = new Map<string, number>([
|
||||
['No path', 0],
|
||||
['1 byte / hop', 0],
|
||||
['2 bytes / hop', 0],
|
||||
['3 bytes / hop', 0],
|
||||
['Unknown width', 0],
|
||||
]);
|
||||
const neighborMap = new Map<string, NeighborStat>();
|
||||
const rssiValues: number[] = [];
|
||||
const rssiBucketCounts = new Map<string, number>([
|
||||
['Strong (>-70 dBm)', 0],
|
||||
['Okay (-70 to -85 dBm)', 0],
|
||||
['Weak (<-85 dBm)', 0],
|
||||
]);
|
||||
|
||||
let strongestPacket: RawPacketStatsObservation | null = null;
|
||||
|
||||
for (const packet of packets) {
|
||||
payloadCounts.set(packet.payloadType, (payloadCounts.get(packet.payloadType) ?? 0) + 1);
|
||||
routeCounts.set(packet.routeType, (routeCounts.get(packet.routeType) ?? 0) + 1);
|
||||
|
||||
const hopProfileBucket = getHopProfileBucket(packet.pathTokenCount);
|
||||
hopCounts.set(hopProfileBucket, (hopCounts.get(hopProfileBucket) ?? 0) + 1);
|
||||
|
||||
const hopByteWidth = inferHopByteWidth(packet);
|
||||
if (packet.pathTokenCount <= 0) {
|
||||
hopByteWidthCounts.set('No path', (hopByteWidthCounts.get('No path') ?? 0) + 1);
|
||||
} else if (hopByteWidth === 1) {
|
||||
hopByteWidthCounts.set('1 byte / hop', (hopByteWidthCounts.get('1 byte / hop') ?? 0) + 1);
|
||||
} else if (hopByteWidth === 2) {
|
||||
hopByteWidthCounts.set('2 bytes / hop', (hopByteWidthCounts.get('2 bytes / hop') ?? 0) + 1);
|
||||
} else if (hopByteWidth === 3) {
|
||||
hopByteWidthCounts.set('3 bytes / hop', (hopByteWidthCounts.get('3 bytes / hop') ?? 0) + 1);
|
||||
} else {
|
||||
hopByteWidthCounts.set('Unknown width', (hopByteWidthCounts.get('Unknown width') ?? 0) + 1);
|
||||
}
|
||||
|
||||
if (packet.sourceKey && packet.sourceLabel) {
|
||||
const existing = neighborMap.get(packet.sourceKey);
|
||||
if (!existing) {
|
||||
neighborMap.set(packet.sourceKey, {
|
||||
key: packet.sourceKey,
|
||||
label: packet.sourceLabel,
|
||||
count: 1,
|
||||
bestRssi: packet.rssi,
|
||||
lastSeen: packet.timestamp,
|
||||
});
|
||||
} else {
|
||||
existing.count += 1;
|
||||
existing.lastSeen = Math.max(existing.lastSeen, packet.timestamp);
|
||||
if (
|
||||
packet.rssi !== null &&
|
||||
(existing.bestRssi === null || packet.rssi > existing.bestRssi)
|
||||
) {
|
||||
existing.bestRssi = packet.rssi;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (packet.rssi !== null) {
|
||||
rssiValues.push(packet.rssi);
|
||||
if (packet.rssi > -70) {
|
||||
rssiBucketCounts.set(
|
||||
'Strong (>-70 dBm)',
|
||||
(rssiBucketCounts.get('Strong (>-70 dBm)') ?? 0) + 1
|
||||
);
|
||||
} else if (packet.rssi >= -85) {
|
||||
rssiBucketCounts.set(
|
||||
'Okay (-70 to -85 dBm)',
|
||||
(rssiBucketCounts.get('Okay (-70 to -85 dBm)') ?? 0) + 1
|
||||
);
|
||||
} else {
|
||||
rssiBucketCounts.set('Weak (<-85 dBm)', (rssiBucketCounts.get('Weak (<-85 dBm)') ?? 0) + 1);
|
||||
}
|
||||
|
||||
if (!strongestPacket || strongestPacket.rssi === null || packet.rssi > strongestPacket.rssi) {
|
||||
strongestPacket = packet;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const averageRssi =
|
||||
rssiValues.length > 0
|
||||
? rssiValues.reduce((sum, value) => sum + value, 0) / rssiValues.length
|
||||
: null;
|
||||
const bestRssi = rssiValues.length > 0 ? Math.max(...rssiValues) : null;
|
||||
const medianRssi = median(rssiValues);
|
||||
const neighbors = Array.from(neighborMap.values());
|
||||
const strongestNeighbors = [...neighbors]
|
||||
.filter((neighbor) => neighbor.bestRssi !== null)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(b.bestRssi ?? Number.NEGATIVE_INFINITY) - (a.bestRssi ?? Number.NEGATIVE_INFINITY) ||
|
||||
b.count - a.count ||
|
||||
a.label.localeCompare(b.label)
|
||||
)
|
||||
.slice(0, 5);
|
||||
const mostActiveNeighbors = [...neighbors]
|
||||
.sort((a, b) => b.count - a.count || b.lastSeen - a.lastSeen || a.label.localeCompare(b.label))
|
||||
.slice(0, 5);
|
||||
const newestNeighbors = [...neighbors]
|
||||
.sort((a, b) => b.lastSeen - a.lastSeen || b.count - a.count || a.label.localeCompare(b.label))
|
||||
.slice(0, 5);
|
||||
|
||||
const oldestStoredTimestamp = session.observations[0]?.timestamp ?? null;
|
||||
const detailedCoverageStart =
|
||||
session.trimmedObservationCount > 0 ? (oldestStoredTimestamp ?? nowSec) : sessionStartedSec;
|
||||
const windowFullyCovered =
|
||||
window === 'session'
|
||||
? session.trimmedObservationCount === 0
|
||||
: detailedCoverageStart <= windowStart;
|
||||
const coverageStart = Math.max(windowStart, detailedCoverageStart);
|
||||
const coverageSeconds =
|
||||
window === 'session'
|
||||
? Math.max(1, nowSec - detailedCoverageStart)
|
||||
: Math.max(1, nowSec - coverageStart);
|
||||
|
||||
const timelineSpanSeconds = Math.max(
|
||||
windowSeconds ?? Math.max(60, nowSec - sessionStartedSec),
|
||||
60
|
||||
);
|
||||
const timelineBinCount = 10;
|
||||
const binWidth = Math.max(1, timelineSpanSeconds / timelineBinCount);
|
||||
const timeline = Array.from({ length: timelineBinCount }, (_, index) => {
|
||||
const start = Math.floor(windowStart + index * binWidth);
|
||||
return {
|
||||
label: formatTimelineLabel(start),
|
||||
total: 0,
|
||||
countsByType: {} as Record<string, number>,
|
||||
};
|
||||
});
|
||||
|
||||
for (const packet of packets) {
|
||||
const rawIndex = Math.floor((packet.timestamp - windowStart) / binWidth);
|
||||
const index = Math.max(0, Math.min(timelineBinCount - 1, rawIndex));
|
||||
const bin = timeline[index];
|
||||
bin.total += 1;
|
||||
bin.countsByType[packet.payloadType] = (bin.countsByType[packet.payloadType] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
window,
|
||||
nowSec,
|
||||
packets,
|
||||
packetCount,
|
||||
packetsPerMinute,
|
||||
uniqueSources,
|
||||
decryptedCount,
|
||||
undecryptedCount,
|
||||
decryptRate: share(decryptedCount, packetCount),
|
||||
pathBearingCount,
|
||||
pathBearingRate: share(pathBearingCount, packetCount),
|
||||
distinctPaths,
|
||||
payloadBreakdown: rankedBreakdown(payloadCounts, packetCount),
|
||||
routeBreakdown: rankedBreakdown(routeCounts, packetCount),
|
||||
topPacketTypes: rankedBreakdown(payloadCounts, packetCount).slice(0, 5),
|
||||
hopProfile: orderedBreakdown(hopCounts, packetCount),
|
||||
hopByteWidthProfile: rankedBreakdown(hopByteWidthCounts, packetCount),
|
||||
strongestNeighbors,
|
||||
mostActiveNeighbors,
|
||||
newestNeighbors,
|
||||
averageRssi,
|
||||
medianRssi,
|
||||
bestRssi,
|
||||
rssiBuckets: rankedBreakdown(rssiBucketCounts, rssiValues.length),
|
||||
strongestPacketSourceKey: strongestPacket?.sourceKey ?? null,
|
||||
strongestPacketSourceLabel: strongestPacket?.sourceLabel ?? null,
|
||||
strongestPacketPayloadType: strongestPacket?.payloadType ?? null,
|
||||
coverageSeconds,
|
||||
windowFullyCovered,
|
||||
oldestStoredTimestamp,
|
||||
timeline,
|
||||
};
|
||||
}
|
||||
@@ -12,7 +12,7 @@ dependencies = [
|
||||
"httpx>=0.28.1",
|
||||
"pycryptodome>=3.20.0",
|
||||
"pynacl>=1.5.0",
|
||||
"meshcore==2.2.29",
|
||||
"meshcore==2.3.1",
|
||||
"aiomqtt>=2.0",
|
||||
"apprise>=1.9.7",
|
||||
"boto3>=1.38.0",
|
||||
|
||||
@@ -12,10 +12,14 @@ SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
NODE_VERSIONS=("20" "22" "24")
|
||||
# Use explicit npm patch versions so resolver regressions are caught.
|
||||
NPM_VERSIONS=("9.1.1" "9.9.4" "10.9.5" "11.6.2")
|
||||
|
||||
echo -e "${YELLOW}=== Frontend Docker CI Matrix ===${NC}"
|
||||
echo -e "${BLUE}Repo:${NC} $SCRIPT_DIR"
|
||||
echo
|
||||
EXTRA_CASES=(
|
||||
"18|9.1.1"
|
||||
"20|8.19.4"
|
||||
"18|8.19.4"
|
||||
"24|11.12.0"
|
||||
"25|11.6.2"
|
||||
"25|11.12.0"
|
||||
)
|
||||
|
||||
run_combo() {
|
||||
local node_version="$1"
|
||||
@@ -36,9 +40,33 @@ run_combo() {
|
||||
npm ci
|
||||
npm run build
|
||||
"
|
||||
|
||||
}
|
||||
|
||||
declare -a TEST_CASES=()
|
||||
declare -A SEEN_CASES=()
|
||||
|
||||
add_case() {
|
||||
local node_version="$1"
|
||||
local npm_version="$2"
|
||||
local key="${node_version}|${npm_version}"
|
||||
if [[ -n "${SEEN_CASES[$key]:-}" ]]; then
|
||||
return
|
||||
fi
|
||||
SEEN_CASES["$key"]=1
|
||||
TEST_CASES+=("$key")
|
||||
}
|
||||
|
||||
for node_version in "${NODE_VERSIONS[@]}"; do
|
||||
for npm_version in "${NPM_VERSIONS[@]}"; do
|
||||
add_case "$node_version" "$npm_version"
|
||||
done
|
||||
done
|
||||
|
||||
for case_spec in "${EXTRA_CASES[@]}"; do
|
||||
IFS='|' read -r node_version npm_version <<<"$case_spec"
|
||||
add_case "$node_version" "$npm_version"
|
||||
done
|
||||
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
declare -a JOB_PIDS=()
|
||||
declare -a JOB_LABELS=()
|
||||
@@ -50,21 +78,25 @@ cleanup() {
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
for node_version in "${NODE_VERSIONS[@]}"; do
|
||||
for npm_version in "${NPM_VERSIONS[@]}"; do
|
||||
label="Node ${node_version} / npm ${npm_version}"
|
||||
log_file="$TMP_DIR/node-${node_version}-npm-${npm_version}.log"
|
||||
echo -e "${YELLOW}=== Frontend Docker CI Matrix ===${NC}"
|
||||
echo -e "${BLUE}Repo:${NC} $SCRIPT_DIR"
|
||||
echo
|
||||
|
||||
echo -e "${BLUE}Starting:${NC} ${label}"
|
||||
(
|
||||
echo -e "${YELLOW}=== ${label} ===${NC}"
|
||||
run_combo "$node_version" "$npm_version"
|
||||
) >"$log_file" 2>&1 &
|
||||
for case_spec in "${TEST_CASES[@]}"; do
|
||||
IFS='|' read -r node_version npm_version <<<"$case_spec"
|
||||
label="Node ${node_version} / npm ${npm_version}"
|
||||
safe_npm="${npm_version//./-}"
|
||||
log_file="$TMP_DIR/node-${node_version}-npm-${safe_npm}.log"
|
||||
|
||||
JOB_PIDS+=("$!")
|
||||
JOB_LABELS+=("$label")
|
||||
JOB_LOGS+=("$log_file")
|
||||
done
|
||||
echo -e "${BLUE}Starting:${NC} ${label}"
|
||||
(
|
||||
echo -e "${YELLOW}=== ${label} ===${NC}"
|
||||
run_combo "$node_version" "$npm_version"
|
||||
) >"$log_file" 2>&1 &
|
||||
|
||||
JOB_PIDS+=("$!")
|
||||
JOB_LABELS+=("$label")
|
||||
JOB_LOGS+=("$log_file")
|
||||
done
|
||||
|
||||
echo
|
||||
|
||||
@@ -43,6 +43,8 @@ export interface RadioConfig {
|
||||
cr: number;
|
||||
}
|
||||
|
||||
export type RadioAdvertMode = 'flood' | 'zero_hop';
|
||||
|
||||
export function getRadioConfig(): Promise<RadioConfig> {
|
||||
return fetchJson('/radio/config');
|
||||
}
|
||||
@@ -58,6 +60,13 @@ export function rebootRadio(): Promise<{ status: string; message: string }> {
|
||||
return fetchJson('/radio/reboot', { method: 'POST' });
|
||||
}
|
||||
|
||||
export function sendAdvertisement(mode: RadioAdvertMode = 'flood'): Promise<{ status: string }> {
|
||||
return fetchJson('/radio/advertise', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mode }),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Channels ---
|
||||
|
||||
export interface Channel {
|
||||
@@ -90,9 +99,9 @@ export interface Contact {
|
||||
name: string | null;
|
||||
type: number;
|
||||
flags: number;
|
||||
last_path: string | null;
|
||||
last_path_len: number;
|
||||
out_path_hash_mode: number;
|
||||
direct_path: string | null;
|
||||
direct_path_len: number;
|
||||
direct_path_hash_mode: number;
|
||||
route_override_path?: string | null;
|
||||
route_override_len?: number | null;
|
||||
route_override_hash_mode?: number | null;
|
||||
@@ -128,6 +137,22 @@ export function deleteContact(publicKey: string): Promise<{ status: string }> {
|
||||
return fetchJson(`/contacts/${publicKey}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function getContactByKey(publicKey: string): Promise<Contact | undefined> {
|
||||
const normalized = publicKey.toLowerCase();
|
||||
const contacts = await getContacts(500, 0);
|
||||
return contacts.find((contact) => contact.public_key.toLowerCase() === normalized);
|
||||
}
|
||||
|
||||
export function setContactRoutingOverride(
|
||||
publicKey: string,
|
||||
route: string
|
||||
): Promise<{ status: string; public_key: string }> {
|
||||
return fetchJson(`/contacts/${publicKey}/routing-override`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ route }),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Messages ---
|
||||
|
||||
export interface MessagePath {
|
||||
|
||||
135
tests/e2e/specs/dev-flightless-direct-route.spec.ts
Normal file
135
tests/e2e/specs/dev-flightless-direct-route.spec.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
createContact,
|
||||
deleteContact,
|
||||
getContactByKey,
|
||||
getMessages,
|
||||
setContactRoutingOverride,
|
||||
} from '../helpers/api';
|
||||
|
||||
const DEV_ONLY_ENV = 'MESHCORE_ENABLE_DEV_FLIGHTLESS_ROUTE_E2E';
|
||||
const FLIGHTLESS_NAME = 'FlightlessDt🥝';
|
||||
const FLIGHTLESS_PUBLIC_KEY =
|
||||
'ae92577bae6c269a1da3c87b5333e1bdb007e372b66e94204b9f92a6b52a62b1';
|
||||
const DEVELOPER_ONLY_NOTICE =
|
||||
`Developer-only hardware test. This scenario assumes ${FLIGHTLESS_NAME} ` +
|
||||
`(${FLIGHTLESS_PUBLIC_KEY.slice(0, 12)}...) is a nearby reachable node for the author's test radio. ` +
|
||||
`Set ${DEV_ONLY_ENV}=1 to run it intentionally.`;
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
test.describe('Developer-only direct-route learning for FlightlessDt🥝', () => {
|
||||
test('zero-hop adverts then DM ACK learns a direct route', { tag: '@developer-only' }, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
testInfo.annotations.push({ type: 'notice', description: DEVELOPER_ONLY_NOTICE });
|
||||
if (process.env[DEV_ONLY_ENV] !== '1') {
|
||||
test.skip(true, DEVELOPER_ONLY_NOTICE);
|
||||
}
|
||||
|
||||
test.setTimeout(180_000);
|
||||
console.warn(`[developer-only e2e] ${DEVELOPER_ONLY_NOTICE}`);
|
||||
|
||||
try {
|
||||
await deleteContact(FLIGHTLESS_PUBLIC_KEY);
|
||||
} catch {
|
||||
// Best-effort reset; the contact may not exist yet in the temp E2E DB.
|
||||
}
|
||||
|
||||
await createContact(FLIGHTLESS_PUBLIC_KEY, FLIGHTLESS_NAME);
|
||||
await setContactRoutingOverride(FLIGHTLESS_PUBLIC_KEY, '');
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const contact = await getContactByKey(FLIGHTLESS_PUBLIC_KEY);
|
||||
return contact?.direct_path_len ?? null;
|
||||
},
|
||||
{
|
||||
timeout: 10_000,
|
||||
message: 'Waiting for recreated FlightlessDt contact to start in flood mode',
|
||||
}
|
||||
)
|
||||
.toBe(-1);
|
||||
|
||||
await page.goto('/#settings/radio');
|
||||
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
|
||||
|
||||
const zeroHopButton = page.getByRole('button', { name: 'Send Zero-Hop Advertisement' });
|
||||
await expect(zeroHopButton).toBeVisible();
|
||||
|
||||
await zeroHopButton.click();
|
||||
await expect(page.getByText('Zero-hop advertisement sent')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.waitForTimeout(5_000);
|
||||
|
||||
await zeroHopButton.click();
|
||||
await expect(page.getByText('Zero-hop advertisement sent')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.getByRole('button', { name: /Back to Chat/i }).click();
|
||||
await expect(page.getByRole('button', { name: /Back to Chat/i })).toBeHidden({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
const searchInput = page.getByLabel('Search conversations');
|
||||
await searchInput.fill(FLIGHTLESS_PUBLIC_KEY.slice(0, 12));
|
||||
await expect(page.getByText(FLIGHTLESS_NAME, { exact: true })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await page.getByText(FLIGHTLESS_NAME, { exact: true }).click();
|
||||
await expect
|
||||
.poll(() => page.url(), {
|
||||
timeout: 15_000,
|
||||
message: 'Waiting for FlightlessDt conversation route to load',
|
||||
})
|
||||
.toContain(`#contact/${encodeURIComponent(FLIGHTLESS_PUBLIC_KEY)}`);
|
||||
await expect(
|
||||
page.getByPlaceholder(new RegExp(`message\\s+${escapeRegex(FLIGHTLESS_NAME)}`, 'i'))
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const text = `dev-flightless-direct-${Date.now()}`;
|
||||
const input = page.getByPlaceholder(/message/i);
|
||||
await input.fill(text);
|
||||
await page.getByRole('button', { name: 'Send', exact: true }).click();
|
||||
await expect(page.getByText(text)).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const messages = await getMessages({
|
||||
type: 'PRIV',
|
||||
conversation_key: FLIGHTLESS_PUBLIC_KEY,
|
||||
limit: 25,
|
||||
});
|
||||
const match = messages.find((message) => message.outgoing && message.text === text);
|
||||
return match?.acked ?? 0;
|
||||
},
|
||||
{
|
||||
timeout: 90_000,
|
||||
message: 'Waiting for FlightlessDt DM ACK',
|
||||
}
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const contact = await getContactByKey(FLIGHTLESS_PUBLIC_KEY);
|
||||
return contact?.direct_path_len ?? null;
|
||||
},
|
||||
{
|
||||
timeout: 90_000,
|
||||
message: 'Waiting for FlightlessDt route to update from flood to direct',
|
||||
}
|
||||
)
|
||||
.toBe(0);
|
||||
|
||||
const learnedContact = await getContactByKey(FLIGHTLESS_PUBLIC_KEY);
|
||||
expect(learnedContact?.direct_path ?? '').toBe('');
|
||||
|
||||
await page.locator('[title="View contact info"]').click();
|
||||
await expect(page.getByLabel('Contact Info')).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
@@ -49,8 +49,9 @@ async def _insert_contact(public_key, name="Alice"):
|
||||
"name": name,
|
||||
"type": 0,
|
||||
"flags": 0,
|
||||
"last_path": None,
|
||||
"last_path_len": -1,
|
||||
"direct_path": None,
|
||||
"direct_path_len": -1,
|
||||
"direct_path_hash_mode": -1,
|
||||
"last_advert": None,
|
||||
"lat": None,
|
||||
"lon": None,
|
||||
|
||||
@@ -62,8 +62,9 @@ async def _insert_contact(public_key, name="Alice", **overrides):
|
||||
"name": name,
|
||||
"type": 0,
|
||||
"flags": 0,
|
||||
"last_path": None,
|
||||
"last_path_len": -1,
|
||||
"direct_path": None,
|
||||
"direct_path_len": -1,
|
||||
"direct_path_hash_mode": -1,
|
||||
"last_advert": None,
|
||||
"lat": None,
|
||||
"lon": None,
|
||||
|
||||
@@ -108,20 +108,24 @@ class TestJwtGeneration:
|
||||
|
||||
def test_payload_contains_required_fields(self):
|
||||
private_key, public_key = _make_test_keys()
|
||||
token = _generate_jwt_token(private_key, public_key)
|
||||
payload_b64 = token.split(".")[1]
|
||||
import base64
|
||||
with patch(
|
||||
"app.fanout.community_mqtt.get_app_build_info",
|
||||
return_value=SimpleNamespace(version="1.2.3", commit_hash="abcdef"),
|
||||
):
|
||||
token = _generate_jwt_token(private_key, public_key)
|
||||
payload_b64 = token.split(".")[1]
|
||||
import base64
|
||||
|
||||
padded = payload_b64 + "=" * (4 - len(payload_b64) % 4)
|
||||
payload = json.loads(base64.urlsafe_b64decode(padded))
|
||||
assert payload["publicKey"] == public_key.hex().upper()
|
||||
assert "iat" in payload
|
||||
assert "exp" in payload
|
||||
assert payload["exp"] - payload["iat"] == 86400
|
||||
assert payload["aud"] == _DEFAULT_BROKER
|
||||
assert payload["owner"] == public_key.hex().upper()
|
||||
assert payload["client"] == _CLIENT_ID
|
||||
assert "email" not in payload # omitted when empty
|
||||
padded = payload_b64 + "=" * (4 - len(payload_b64) % 4)
|
||||
payload = json.loads(base64.urlsafe_b64decode(padded))
|
||||
assert payload["publicKey"] == public_key.hex().upper()
|
||||
assert "iat" in payload
|
||||
assert "exp" in payload
|
||||
assert payload["exp"] - payload["iat"] == 86400
|
||||
assert payload["aud"] == _DEFAULT_BROKER
|
||||
assert payload["owner"] == public_key.hex().upper()
|
||||
assert payload["client"] == f"{_CLIENT_ID}/1.2.3-abcdef"
|
||||
assert "email" not in payload # omitted when empty
|
||||
|
||||
def test_payload_includes_email_when_provided(self):
|
||||
private_key, public_key = _make_test_keys()
|
||||
@@ -822,7 +826,10 @@ class TestLwtAndStatusPublish:
|
||||
pub, "_fetch_stats", new_callable=AsyncMock, return_value={"battery_mv": 4200}
|
||||
),
|
||||
patch("app.fanout.community_mqtt._build_radio_info", return_value="915.0,250.0,10,8"),
|
||||
patch("app.fanout.community_mqtt._get_client_version", return_value="RemoteTerm 2.4.0"),
|
||||
patch(
|
||||
"app.fanout.community_mqtt._get_client_version",
|
||||
return_value="RemoteTerm/2.4.0-abcdef",
|
||||
),
|
||||
patch.object(pub, "publish", new_callable=AsyncMock) as mock_publish,
|
||||
):
|
||||
await pub._on_connected_async(settings)
|
||||
@@ -842,7 +849,7 @@ class TestLwtAndStatusPublish:
|
||||
assert payload["model"] == "T-Deck"
|
||||
assert payload["firmware_version"] == "v2.2.2 (Build: 2025-01-15)"
|
||||
assert payload["radio"] == "915.0,250.0,10,8"
|
||||
assert payload["client_version"] == "RemoteTerm 2.4.0"
|
||||
assert payload["client_version"] == "RemoteTerm/2.4.0-abcdef"
|
||||
assert payload["stats"] == {"battery_mv": 4200}
|
||||
|
||||
def test_lwt_and_online_share_same_topic(self):
|
||||
@@ -902,7 +909,8 @@ class TestLwtAndStatusPublish:
|
||||
patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=None),
|
||||
patch("app.fanout.community_mqtt._build_radio_info", return_value="0,0,0,0"),
|
||||
patch(
|
||||
"app.fanout.community_mqtt._get_client_version", return_value="RemoteTerm unknown"
|
||||
"app.fanout.community_mqtt._get_client_version",
|
||||
return_value="RemoteTerm/0.0.0-unknown",
|
||||
),
|
||||
patch.object(pub, "publish", new_callable=AsyncMock) as mock_publish,
|
||||
):
|
||||
@@ -1215,18 +1223,21 @@ class TestBuildRadioInfo:
|
||||
|
||||
|
||||
class TestGetClientVersion:
|
||||
def test_returns_plain_version(self):
|
||||
"""Should return a bare version string with no product prefix."""
|
||||
result = _get_client_version()
|
||||
assert result
|
||||
assert "RemoteTerm" not in result
|
||||
|
||||
def test_returns_version_from_build_helper(self):
|
||||
"""Should use the shared backend build-info helper."""
|
||||
def test_returns_canonical_client_identifier(self):
|
||||
"""Should return the canonical client/version/hash identifier."""
|
||||
with patch("app.fanout.community_mqtt.get_app_build_info") as mock_build_info:
|
||||
mock_build_info.return_value.version = "1.2.3"
|
||||
mock_build_info.return_value.commit_hash = "abcdef"
|
||||
result = _get_client_version()
|
||||
assert result == "1.2.3"
|
||||
assert result == "RemoteTerm/1.2.3-abcdef"
|
||||
|
||||
def test_falls_back_to_unknown_hash_when_commit_missing(self):
|
||||
"""Should keep the canonical shape even when the commit hash is unavailable."""
|
||||
with patch("app.fanout.community_mqtt.get_app_build_info") as mock_build_info:
|
||||
mock_build_info.return_value.version = "1.2.3"
|
||||
mock_build_info.return_value.commit_hash = None
|
||||
result = _get_client_version()
|
||||
assert result == "RemoteTerm/1.2.3-unknown"
|
||||
|
||||
|
||||
class TestPublishStatus:
|
||||
@@ -1255,7 +1266,10 @@ class TestPublishStatus:
|
||||
),
|
||||
patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=stats),
|
||||
patch("app.fanout.community_mqtt._build_radio_info", return_value="915.0,250.0,10,8"),
|
||||
patch("app.fanout.community_mqtt._get_client_version", return_value="RemoteTerm 2.4.0"),
|
||||
patch(
|
||||
"app.fanout.community_mqtt._get_client_version",
|
||||
return_value="RemoteTerm/2.4.0-abcdef",
|
||||
),
|
||||
patch.object(pub, "publish", new_callable=AsyncMock) as mock_publish,
|
||||
):
|
||||
await pub._publish_status(settings)
|
||||
@@ -1268,7 +1282,7 @@ class TestPublishStatus:
|
||||
assert payload["model"] == "T-Deck"
|
||||
assert payload["firmware_version"] == "v2.2.2 (Build: 2025-01-15)"
|
||||
assert payload["radio"] == "915.0,250.0,10,8"
|
||||
assert payload["client_version"] == "RemoteTerm 2.4.0"
|
||||
assert payload["client_version"] == "RemoteTerm/2.4.0-abcdef"
|
||||
assert payload["stats"] == stats
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -1293,7 +1307,8 @@ class TestPublishStatus:
|
||||
patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=None),
|
||||
patch("app.fanout.community_mqtt._build_radio_info", return_value="0,0,0,0"),
|
||||
patch(
|
||||
"app.fanout.community_mqtt._get_client_version", return_value="RemoteTerm unknown"
|
||||
"app.fanout.community_mqtt._get_client_version",
|
||||
return_value="RemoteTerm/0.0.0-unknown",
|
||||
),
|
||||
patch.object(pub, "publish", new_callable=AsyncMock) as mock_publish,
|
||||
):
|
||||
@@ -1326,7 +1341,8 @@ class TestPublishStatus:
|
||||
patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=None),
|
||||
patch("app.fanout.community_mqtt._build_radio_info", return_value="0,0,0,0"),
|
||||
patch(
|
||||
"app.fanout.community_mqtt._get_client_version", return_value="RemoteTerm unknown"
|
||||
"app.fanout.community_mqtt._get_client_version",
|
||||
return_value="RemoteTerm/0.0.0-unknown",
|
||||
),
|
||||
patch.object(pub, "publish", new_callable=AsyncMock),
|
||||
):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user