Compare commits

...

34 Commits

Author SHA1 Message Date
Jack Kingsman c33eb469ac Updating changelog + build for 3.8.0 2026-04-03 19:36:27 -07:00
Jack Kingsman 0fe6584e7a Add packet display to map & add map dark mode 2026-04-03 19:18:22 -07:00
Jack Kingsman 557d79d437 Add packets to general map 2026-04-03 18:57:34 -07:00
Jack Kingsman daff3dcb4a Drop low value tests 2026-04-03 17:55:02 -07:00
Jack Kingsman 77db7287d6 Drop lame imports 2026-04-03 17:51:26 -07:00
Jack Kingsman 67873e8dd9 Drop some duplicated logic and defns 2026-04-03 17:47:44 -07:00
Jack Kingsman e2ddf5f79f Move require connected down into the manager 2026-04-03 17:37:30 -07:00
Jack Kingsman 4a93641f04 Axe some dead code 2026-04-03 17:22:04 -07:00
Jack Kingsman d5922a214b Clear out old migration logic and replace with thin shim for favorites; sort order is lost 2026-04-03 17:15:41 -07:00
Jack Kingsman 7ad1ee26a4 Add RSSI/SNR to received messages. Closes #148. 2026-04-03 15:20:44 -07:00
Jack Kingsman 08238aa464 Add close button to modal. Closes #156 (and modals lol), ish. 2026-04-03 14:54:59 -07:00
Jack Kingsman 1046baf741 Add auto-resend option for not-heard-repeated messages. Closes #154. 2026-04-03 14:43:52 -07:00
Jack Kingsman 42e1b7b5d9 Add canonical style reference. Closes #155. 2026-04-03 14:27:44 -07:00
Jack Kingsman 3ca4f7edf7 Fix missing test failures and patch double declared model 2026-04-03 14:15:19 -07:00
Jack Kingsman 55081d4a2d Add hop width to channel info. Closes #153. 2026-04-03 14:04:35 -07:00
Jack Kingsman be2b2604df Add intervalized repeater metrics collection. Closes #151. 2026-04-03 13:45:39 -07:00
Jack Kingsman 35981d8f8b Be more aggressive about resetting the hop width and warning if that doesn't work. This and the prior work closes #152. 2026-04-03 13:16:43 -07:00
Jack Kingsman 8e998c03ba Add channel path hash width override 2026-04-03 13:05:58 -07:00
Jack Kingsman d802dd4212 Fix table display in primary agents.md 2026-04-02 20:31:54 -07:00
Jack Kingsman 7557eb1fa6 Merge pull request #150 from jkingsman/bugbash-v7
Bugbash v7
2026-04-02 20:20:23 -07:00
Jack Kingsman 6a4af5e602 More complete message lifecycle tests 2026-04-02 20:17:51 -07:00
Jack Kingsman 1895e6a919 Clean up legacy sort order 2026-04-02 20:16:16 -07:00
Jack Kingsman 975bf7f03f Docs, dead code, and schema updates 2026-04-02 19:03:02 -07:00
Jack Kingsman c7d5d3887d Yield radio lock on build repeater ops and use INSERT OR IGNORE instead of check-then-act on packet ops 2026-04-02 18:53:34 -07:00
Jack Kingsman 5c93d8487e Stop using db ops to do casing; unify on write and then our indices are happy once more 2026-04-02 18:50:56 -07:00
Jack Kingsman 5d2834a9fb Add some tests around cascade deletion behaving now that we have FK pragma turned on 2026-04-02 18:46:37 -07:00
Jack Kingsman cfe485bf29 Be kinder about streaming volume in memory 2026-04-02 18:43:48 -07:00
Jack Kingsman e7f6bd0397 Bump python requirement so as not to hit toml issues 2026-04-02 18:41:03 -07:00
Jack Kingsman 1e7dc6af46 Don't clobber sort order 2026-04-02 18:40:25 -07:00
Jack Kingsman af40cc3c8e Add more recent screenshot 2026-04-02 18:06:29 -07:00
Jack Kingsman 2561b70fed Fix tests for apprise redaction 2026-04-02 18:03:34 -07:00
Jack Kingsman 44f145b646 Updating changelog + build for 3.7.1 2026-04-02 18:01:22 -07:00
Jack Kingsman 55e2dc478d Redact Apprise URLs 2026-04-02 17:59:41 -07:00
Jack Kingsman 0932800e1f Fix lint 2026-04-02 17:38:35 -07:00
110 changed files with 3053 additions and 1930 deletions
+5 -2
View File
@@ -327,6 +327,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| GET | `/api/contacts/analytics` | Unified keyed-or-name contact analytics payload | | GET | `/api/contacts/analytics` | Unified keyed-or-name contact analytics payload |
| GET | `/api/contacts/repeaters/advert-paths` | List recent unique advert paths for all contacts | | GET | `/api/contacts/repeaters/advert-paths` | List recent unique advert paths for all contacts |
| POST | `/api/contacts` | Create contact (optionally trigger historical DM decrypt) | | POST | `/api/contacts` | Create contact (optionally trigger historical DM decrypt) |
| POST | `/api/contacts/bulk-delete` | Delete multiple contacts |
| DELETE | `/api/contacts/{public_key}` | Delete contact | | DELETE | `/api/contacts/{public_key}` | Delete contact |
| POST | `/api/contacts/{public_key}/mark-read` | Mark contact conversation as read | | POST | `/api/contacts/{public_key}/mark-read` | Mark contact conversation as read |
| POST | `/api/contacts/{public_key}/command` | Send CLI command to repeater | | POST | `/api/contacts/{public_key}/command` | Send CLI command to repeater |
@@ -346,12 +347,13 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| POST | `/api/contacts/{public_key}/room/status` | Fetch room-server status telemetry | | POST | `/api/contacts/{public_key}/room/status` | Fetch room-server status telemetry |
| POST | `/api/contacts/{public_key}/room/lpp-telemetry` | Fetch room-server CayenneLPP sensor data | | POST | `/api/contacts/{public_key}/room/lpp-telemetry` | Fetch room-server CayenneLPP sensor data |
| POST | `/api/contacts/{public_key}/room/acl` | Fetch room-server ACL entries | | POST | `/api/contacts/{public_key}/room/acl` | Fetch room-server ACL entries |
| GET | `/api/channels` | List channels | | GET | `/api/channels` | List channels |
| GET | `/api/channels/{key}/detail` | Comprehensive channel profile (message stats, top senders) | | GET | `/api/channels/{key}/detail` | Comprehensive channel profile (message stats, top senders) |
| POST | `/api/channels` | Create channel | | POST | `/api/channels` | Create channel |
| POST | `/api/channels/bulk-hashtag` | Create multiple hashtag channels |
| DELETE | `/api/channels/{key}` | Delete channel | | DELETE | `/api/channels/{key}` | Delete channel |
| POST | `/api/channels/{key}/flood-scope-override` | Set or clear a per-channel regional flood-scope override | | POST | `/api/channels/{key}/flood-scope-override` | Set or clear a per-channel regional flood-scope override |
| POST | `/api/channels/{key}/path-hash-mode-override` | Set or clear a per-channel path hash mode override |
| POST | `/api/channels/{key}/mark-read` | Mark channel as read | | POST | `/api/channels/{key}/mark-read` | Mark channel as read |
| GET | `/api/messages` | List with filters (`q`, `after`/`after_id` for forward pagination) | | GET | `/api/messages` | List with filters (`q`, `after`/`after_id` for forward pagination) |
| GET | `/api/messages/around/{id}` | Get messages around a specific message (for jump-to-message) | | GET | `/api/messages/around/{id}` | Get messages around a specific message (for jump-to-message) |
@@ -400,6 +402,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
- Hashtag channels: `SHA256("#name")[:16]` converted to hex - Hashtag channels: `SHA256("#name")[:16]` converted to hex
- Custom channels: User-provided or generated - Custom channels: User-provided or generated
- Channels may also persist `flood_scope_override`; when set, channel sends temporarily switch the radio flood scope to that value for the duration of the send, then restore the global app setting. - Channels may also persist `flood_scope_override`; when set, channel sends temporarily switch the radio flood scope to that value for the duration of the send, then restore the global app setting.
- Channels may persist `path_hash_mode_override` (0/1/2); when set, channel sends temporarily switch the radio path hash mode for the duration of the send, then restore the radio default.
### Message Types ### Message Types
@@ -475,7 +478,7 @@ mc.subscribe(EventType.ACK, handler)
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | `false` | Switch the always-on radio audit task from hourly checks to aggressive 10-second polling; the audit checks both missed message drift and channel-slot cache drift | | `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | `false` | Switch the always-on radio audit task from hourly checks to aggressive 10-second polling; the audit checks both missed message drift and channel-slot cache drift |
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | `false` | Disable channel-slot reuse and force `set_channel(...)` before every channel send, even on serial/BLE | | `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | `false` | Disable channel-slot reuse and force `set_channel(...)` before every channel send, even on serial/BLE |
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `flood_scope`, `blocked_keys`, and `blocked_names`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. The backend still carries `sidebar_sort_order` for compatibility and migration, but the current frontend sidebar stores sort order per section (`Channels`, `Contacts`, `Repeaters`) in localStorage rather than treating it as one shared server-backed preference. MQTT, bot, webhook, Apprise, and SQS configs are stored in the `fanout_configs` table, managed via `/api/fanout`. If the radio's channel slots appear unstable or another client is mutating them underneath this app, operators can force the old always-reconfigure send path with `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true`. **Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `flood_scope`, `blocked_keys`, `blocked_names`, and `discovery_blocked_types`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, Apprise, and SQS configs are stored in the `fanout_configs` table, managed via `/api/fanout`. If the radio's channel slots appear unstable or another client is mutating them underneath this app, operators can force the old always-reconfigure send path with `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true`.
Byte-perfect channel retries are user-triggered via `POST /api/messages/channel/{message_id}/resend` and are allowed for 30 seconds after the original send. Byte-perfect channel retries are user-triggered via `POST /api/messages/channel/{message_id}/resend` and are allowed for 30 seconds after the original send.
+19
View File
@@ -1,3 +1,22 @@
## [3.8.0] - 2026-04-03
* Feature: Per-channel hop width override
* Feature: Intervalized repeater telemetry collection
* Feature: Auto-resend option for byte-perfect resends on no repeater echo
* Feature: Attach RSSI/SNR to received packets
* Feature: Add motion packet display to map
* Feature: Map dark mode
* Bugfix: Make DB indices more useful around capitalization
* Misc: Bump required Python to 3.11
* Misc: Performance, documentation, and test improvements
* Misc: More yields during long radio operations
* Misc: Dead code & crufty test removal
* Misc: Remove all but stub frontend favorites migration for very very old versions
## [3.7.1] - 2026-04-02
* Feature: Redact Apprise URLs to prevent sensitive information disclosure
## [3.7.0] - 2026-04-02 ## [3.7.0] - 2026-04-02
* Feature: Repeater battery tracking * Feature: Repeater battery tracking
+5 -5
View File
@@ -190,6 +190,7 @@ app/
- `GET /contacts/analytics` — unified keyed-or-name analytics payload - `GET /contacts/analytics` — unified keyed-or-name analytics payload
- `GET /contacts/repeaters/advert-paths` — recent advert paths for all contacts - `GET /contacts/repeaters/advert-paths` — recent advert paths for all contacts
- `POST /contacts` - `POST /contacts`
- `POST /contacts/bulk-delete`
- `DELETE /contacts/{public_key}` - `DELETE /contacts/{public_key}`
- `POST /contacts/{public_key}/mark-read` - `POST /contacts/{public_key}/mark-read`
- `POST /contacts/{public_key}/command` - `POST /contacts/{public_key}/command`
@@ -214,8 +215,10 @@ app/
- `GET /channels` - `GET /channels`
- `GET /channels/{key}/detail` - `GET /channels/{key}/detail`
- `POST /channels` - `POST /channels`
- `POST /channels/bulk-hashtag`
- `DELETE /channels/{key}` - `DELETE /channels/{key}`
- `POST /channels/{key}/flood-scope-override` - `POST /channels/{key}/flood-scope-override`
- `POST /channels/{key}/path-hash-mode-override`
- `POST /channels/{key}/mark-read` - `POST /channels/{key}/mark-read`
### Messages ### Messages
@@ -278,7 +281,7 @@ Client sends `"ping"` text; server replies `{"type":"pong"}`.
Main tables: Main tables:
- `contacts` (includes `first_seen` for contact age tracking and `direct_path_hash_mode` / `route_override_*` for DM routing) - `contacts` (includes `first_seen` for contact age tracking and `direct_path_hash_mode` / `route_override_*` for DM routing)
- `channels` - `channels`
Includes optional `flood_scope_override` for channel-specific regional sends. Includes optional `flood_scope_override` for channel-specific regional sends and optional `path_hash_mode_override` for per-channel path hop width.
- `messages` (includes `sender_name`, `sender_key` for per-contact channel message attribution) - `messages` (includes `sender_name`, `sender_key` for per-contact channel message attribution)
- `raw_packets` - `raw_packets`
- `contact_advert_paths` (recent unique advertisement paths per contact, keyed by contact + path bytes + hop count) - `contact_advert_paths` (recent unique advertisement paths per contact, keyed by contact + path bytes + hop count)
@@ -300,15 +303,12 @@ Repository writes should prefer typed models such as `ContactUpsert` over ad hoc
- `max_radio_contacts` - `max_radio_contacts`
- `favorites` - `favorites`
- `auto_decrypt_dm_on_advert` - `auto_decrypt_dm_on_advert`
- `sidebar_sort_order`
- `last_message_times` - `last_message_times`
- `preferences_migrated` - `preferences_migrated`
- `advert_interval` - `advert_interval`
- `last_advert_time` - `last_advert_time`
- `flood_scope` - `flood_scope`
- `blocked_keys`, `blocked_names` - `blocked_keys`, `blocked_names`, `discovery_blocked_types`
Note: `sidebar_sort_order` remains in the backend model for compatibility and migration, but the current frontend sidebar uses per-section localStorage sort preferences instead of a single shared server-backed sort mode.
Note: MQTT, community MQTT, and bot configs were migrated to the `fanout_configs` table (migrations 36-38). Note: MQTT, community MQTT, and bot configs were migrated to the `fanout_configs` table (migrations 36-38).
+49 -4
View File
@@ -36,6 +36,7 @@ CREATE TABLE IF NOT EXISTS channels (
is_hashtag INTEGER DEFAULT 0, is_hashtag INTEGER DEFAULT 0,
on_radio INTEGER DEFAULT 0, on_radio INTEGER DEFAULT 0,
flood_scope_override TEXT, flood_scope_override TEXT,
path_hash_mode_override INTEGER,
last_read_at INTEGER last_read_at INTEGER
); );
@@ -46,7 +47,7 @@ CREATE TABLE IF NOT EXISTS messages (
text TEXT NOT NULL, text TEXT NOT NULL,
sender_timestamp INTEGER, sender_timestamp INTEGER,
received_at INTEGER NOT NULL, received_at INTEGER NOT NULL,
path TEXT, paths TEXT,
txt_type INTEGER DEFAULT 0, txt_type INTEGER DEFAULT 0,
signature TEXT, signature TEXT,
outgoing INTEGER DEFAULT 0, outgoing INTEGER DEFAULT 0,
@@ -91,23 +92,67 @@ CREATE TABLE IF NOT EXISTS contact_name_history (
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
); );
CREATE TABLE IF NOT EXISTS app_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
max_radio_contacts INTEGER DEFAULT 200,
favorites TEXT DEFAULT '[]',
auto_decrypt_dm_on_advert INTEGER DEFAULT 1,
last_message_times TEXT DEFAULT '{}',
preferences_migrated INTEGER DEFAULT 0,
advert_interval INTEGER DEFAULT 0,
last_advert_time INTEGER DEFAULT 0,
flood_scope TEXT DEFAULT '',
blocked_keys TEXT DEFAULT '[]',
blocked_names TEXT DEFAULT '[]',
discovery_blocked_types TEXT DEFAULT '[]',
tracked_telemetry_repeaters TEXT DEFAULT '[]',
auto_resend_channel INTEGER DEFAULT 0
);
INSERT OR IGNORE INTO app_settings (id) VALUES (1);
CREATE TABLE IF NOT EXISTS fanout_configs (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
name TEXT NOT NULL,
enabled INTEGER DEFAULT 0,
config TEXT NOT NULL DEFAULT '{}',
scope TEXT NOT NULL DEFAULT '{}',
sort_order INTEGER DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS repeater_telemetry_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
timestamp INTEGER NOT NULL,
data TEXT NOT NULL,
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_messages_received ON messages(received_at); CREATE INDEX IF NOT EXISTS idx_messages_received ON messages(received_at);
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_dedup_null_safe CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_dedup_null_safe
ON messages(type, conversation_key, text, COALESCE(sender_timestamp, 0)) ON messages(type, conversation_key, text, COALESCE(sender_timestamp, 0))
WHERE type = 'CHAN'; WHERE type = 'CHAN';
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_incoming_priv_dedup
ON messages(type, conversation_key, text, COALESCE(sender_timestamp, 0))
WHERE type = 'PRIV' AND outgoing = 0;
CREATE INDEX IF NOT EXISTS idx_messages_sender_key ON messages(sender_key);
CREATE INDEX IF NOT EXISTS idx_messages_pagination
ON messages(type, conversation_key, received_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_messages_unread_covering
ON messages(type, conversation_key, outgoing, received_at);
CREATE INDEX IF NOT EXISTS idx_raw_packets_message_id ON raw_packets(message_id); CREATE INDEX IF NOT EXISTS idx_raw_packets_message_id ON raw_packets(message_id);
CREATE INDEX IF NOT EXISTS idx_raw_packets_timestamp ON raw_packets(timestamp); CREATE INDEX IF NOT EXISTS idx_raw_packets_timestamp ON raw_packets(timestamp);
CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_packets_payload_hash ON raw_packets(payload_hash); CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_packets_payload_hash ON raw_packets(payload_hash);
CREATE INDEX IF NOT EXISTS idx_contacts_on_radio ON contacts(on_radio);
CREATE INDEX IF NOT EXISTS idx_contacts_type_last_seen ON contacts(type, last_seen); CREATE INDEX IF NOT EXISTS idx_contacts_type_last_seen ON contacts(type, last_seen);
CREATE INDEX IF NOT EXISTS idx_messages_type_received_conversation CREATE INDEX IF NOT EXISTS idx_messages_type_received_conversation
ON messages(type, received_at, conversation_key); ON messages(type, received_at, conversation_key);
-- idx_messages_sender_key is created by migration 25 (after adding the sender_key column)
-- idx_messages_incoming_priv_dedup is created by migration 44 after legacy rows are reconciled
CREATE INDEX IF NOT EXISTS idx_contact_advert_paths_recent CREATE INDEX IF NOT EXISTS idx_contact_advert_paths_recent
ON contact_advert_paths(public_key, last_seen DESC); ON contact_advert_paths(public_key, last_seen DESC);
CREATE INDEX IF NOT EXISTS idx_contact_name_history_key CREATE INDEX IF NOT EXISTS idx_contact_name_history_key
ON contact_name_history(public_key, last_seen DESC); ON contact_name_history(public_key, last_seen DESC);
CREATE INDEX IF NOT EXISTS idx_repeater_telemetry_pk_ts
ON repeater_telemetry_history(public_key, timestamp);
""" """
-8
View File
@@ -1,8 +0,0 @@
"""Shared dependencies for FastAPI routers."""
from app.services.radio_runtime import radio_runtime as radio_manager
def require_connected():
"""Dependency that ensures radio is connected and returns meshcore instance."""
return radio_manager.require_connected()
-1
View File
@@ -202,7 +202,6 @@ async def on_path_update(event: "Event") -> None:
# Legacy firmware/library payloads only support 1-byte hop hashes. # Legacy firmware/library payloads only support 1-byte hop hashes.
normalized_path_hash_mode = -1 if normalized_path_len == -1 else 0 normalized_path_hash_mode = -1 if normalized_path_len == -1 else 0
else: else:
normalized_path_hash_mode = None
try: try:
normalized_path_hash_mode = int(path_hash_mode) normalized_path_hash_mode = int(path_hash_mode)
except (TypeError, ValueError): except (TypeError, ValueError):
-31
View File
@@ -52,19 +52,6 @@ class ToastPayload(TypedDict):
details: NotRequired[str] details: NotRequired[str]
WsEventPayload = (
HealthResponse
| Message
| Contact
| ContactResolvedPayload
| Channel
| ContactDeletedPayload
| ChannelDeletedPayload
| RawPacketBroadcast
| MessageAckedPayload
| ToastPayload
)
_PAYLOAD_ADAPTERS: dict[WsEventType, TypeAdapter[Any]] = { _PAYLOAD_ADAPTERS: dict[WsEventType, TypeAdapter[Any]] = {
"health": TypeAdapter(HealthResponse), "health": TypeAdapter(HealthResponse),
"message": TypeAdapter(Message), "message": TypeAdapter(Message),
@@ -80,14 +67,6 @@ _PAYLOAD_ADAPTERS: dict[WsEventType, TypeAdapter[Any]] = {
} }
def validate_ws_event_payload(event_type: str, data: Any) -> WsEventPayload | Any:
"""Validate known WebSocket payloads; pass unknown events through unchanged."""
adapter = _PAYLOAD_ADAPTERS.get(event_type) # type: ignore[arg-type]
if adapter is None:
return data
return adapter.validate_python(data)
def dump_ws_event(event_type: str, data: Any) -> str: def dump_ws_event(event_type: str, data: Any) -> str:
"""Serialize a WebSocket event envelope with validation for known event types.""" """Serialize a WebSocket event envelope with validation for known event types."""
adapter = _PAYLOAD_ADAPTERS.get(event_type) # type: ignore[arg-type] adapter = _PAYLOAD_ADAPTERS.get(event_type) # type: ignore[arg-type]
@@ -104,13 +83,3 @@ def dump_ws_event(event_type: str, data: Any) -> str:
event_type, event_type,
) )
return json.dumps({"type": event_type, "data": data}) return json.dumps({"type": event_type, "data": data})
def dump_ws_event_payload(event_type: str, data: Any) -> Any:
"""Return the JSON-serializable payload for a WebSocket event."""
adapter = _PAYLOAD_ADAPTERS.get(event_type) # type: ignore[arg-type]
if adapter is None:
return data
validated = adapter.validate_python(data)
return adapter.dump_python(validated, mode="json")
+2 -5
View File
@@ -144,11 +144,8 @@ class MapUploadModule(FanoutModule):
if advert is None: if advert is None:
return return
# TODO: advert Ed25519 signature verification is skipped here. # Advert Ed25519 signature verification is intentionally skipped.
# The radio has already validated the packet before passing it to RT, # The radio validates packets before passing them to RT.
# so re-verification is redundant in practice. If added, verify that
# nacl.bindings.crypto_sign_open(sig + (pubkey_bytes || timestamp_bytes),
# advert.public_key_bytes) succeeds before proceeding.
# Only process repeaters (2) and rooms (3) — any other role is rejected # Only process repeaters (2) and rooms (3) — any other role is rejected
if advert.device_role not in _ALLOWED_DEVICE_ROLES: if advert.device_role not in _ALLOWED_DEVICE_ROLES:
+2
View File
@@ -21,6 +21,7 @@ from app.radio_sync import (
stop_message_polling, stop_message_polling,
stop_periodic_advert, stop_periodic_advert,
stop_periodic_sync, stop_periodic_sync,
stop_telemetry_collect,
) )
from app.routers import ( from app.routers import (
channels, channels,
@@ -103,6 +104,7 @@ async def lifespan(app: FastAPI):
await stop_noise_floor_sampling() await stop_noise_floor_sampling()
await stop_periodic_advert() await stop_periodic_advert()
await stop_periodic_sync() await stop_periodic_sync()
await stop_telemetry_collect()
if radio_manager.meshcore: if radio_manager.meshcore:
await radio_manager.meshcore.stop_auto_message_fetching() await radio_manager.meshcore.stop_auto_message_fetching()
await radio_manager.disconnect() await radio_manager.disconnect()
+92 -7
View File
@@ -389,6 +389,30 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 50) await set_version(conn, 50)
applied += 1 applied += 1
if version < 51:
logger.info("Applying migration 51: drop sidebar_sort_order from app_settings")
await _migrate_051_drop_sidebar_sort_order(conn)
await set_version(conn, 51)
applied += 1
if version < 52:
logger.info("Applying migration 52: add path_hash_mode_override to channels")
await _migrate_052_add_channel_path_hash_mode_override(conn)
await set_version(conn, 52)
applied += 1
if version < 53:
logger.info("Applying migration 53: add tracked_telemetry_repeaters to app_settings")
await _migrate_053_tracked_telemetry_repeaters(conn)
await set_version(conn, 53)
applied += 1
if version < 54:
logger.info("Applying migration 54: add auto_resend_channel to app_settings")
await _migrate_054_auto_resend_channel(conn)
await set_version(conn, 54)
applied += 1
if applied > 0: if applied > 0:
logger.info( logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn) "Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
@@ -859,13 +883,9 @@ async def _migrate_009_create_app_settings_table(conn: aiosqlite.Connection) ->
""" """
) )
# Initialize with default row # Initialize with default row (use only the id column so this works
await conn.execute( # regardless of which columns exist — defaults fill the rest).
""" await conn.execute("INSERT OR IGNORE INTO app_settings (id) VALUES (1)")
INSERT OR IGNORE INTO app_settings (id, max_radio_contacts, favorites, auto_decrypt_dm_on_advert, sidebar_sort_order, last_message_times, preferences_migrated)
VALUES (1, 200, '[]', 1, 'recent', '{}', 0)
"""
)
await conn.commit() await conn.commit()
logger.debug("Created app_settings table with default values") logger.debug("Created app_settings table with default values")
@@ -3128,3 +3148,68 @@ async def _migrate_050_repeater_telemetry_history(conn: aiosqlite.Connection) ->
""" """
) )
await conn.commit() await conn.commit()
async def _migrate_051_drop_sidebar_sort_order(conn: aiosqlite.Connection) -> None:
"""Remove vestigial sidebar_sort_order column from app_settings."""
col_cursor = await conn.execute("PRAGMA table_info(app_settings)")
columns = {row[1] for row in await col_cursor.fetchall()}
if "sidebar_sort_order" in columns:
try:
await conn.execute("ALTER TABLE app_settings DROP COLUMN sidebar_sort_order")
await conn.commit()
except Exception as e:
error_msg = str(e).lower()
if "syntax error" in error_msg or "drop column" in error_msg:
logger.debug(
"SQLite doesn't support DROP COLUMN, sidebar_sort_order column will remain"
)
await conn.commit()
else:
raise
async def _migrate_052_add_channel_path_hash_mode_override(conn: aiosqlite.Connection) -> None:
"""Add nullable per-channel path hash mode override column."""
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
if "channels" not in {row[0] for row in await tables_cursor.fetchall()}:
await conn.commit()
return
try:
await conn.execute("ALTER TABLE channels ADD COLUMN path_hash_mode_override INTEGER")
await conn.commit()
except Exception as e:
if "duplicate column" in str(e).lower():
await conn.commit()
else:
raise
async def _migrate_053_tracked_telemetry_repeaters(conn: aiosqlite.Connection) -> None:
"""Add tracked_telemetry_repeaters JSON list column to app_settings."""
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
if "app_settings" not in {row[0] for row in await tables_cursor.fetchall()}:
await conn.commit()
return
col_cursor = await conn.execute("PRAGMA table_info(app_settings)")
columns = {row[1] for row in await col_cursor.fetchall()}
if "tracked_telemetry_repeaters" not in columns:
await conn.execute(
"ALTER TABLE app_settings ADD COLUMN tracked_telemetry_repeaters TEXT DEFAULT '[]'"
)
await conn.commit()
async def _migrate_054_auto_resend_channel(conn: aiosqlite.Connection) -> None:
"""Add auto_resend_channel boolean column to app_settings."""
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
if "app_settings" not in {row[0] for row in await tables_cursor.fetchall()}:
await conn.commit()
return
col_cursor = await conn.execute("PRAGMA table_info(app_settings)")
columns = {row[1] for row in await col_cursor.fetchall()}
if "auto_resend_channel" not in columns:
await conn.execute(
"ALTER TABLE app_settings ADD COLUMN auto_resend_channel INTEGER DEFAULT 0"
)
await conn.commit()
+30 -64
View File
@@ -196,15 +196,6 @@ class Contact(BaseModel):
"""Convert the stored contact to the repository's write contract.""" """Convert the stored contact to the repository's write contract."""
return ContactUpsert.from_contact(self, **changes) return ContactUpsert.from_contact(self, **changes)
@staticmethod
def from_radio_dict(public_key: str, radio_data: dict, on_radio: bool = False) -> dict:
"""Backward-compatible dict wrapper over ContactUpsert.from_radio_dict()."""
return ContactUpsert.from_radio_dict(
public_key,
radio_data,
on_radio=on_radio,
).model_dump()
class CreateContactRequest(BaseModel): class CreateContactRequest(BaseModel):
"""Request to create a new contact.""" """Request to create a new contact."""
@@ -283,30 +274,6 @@ class NearestRepeater(BaseModel):
heard_count: int heard_count: int
class ContactDetail(BaseModel):
"""Comprehensive contact profile data."""
contact: Contact
name_history: list[ContactNameHistory] = Field(default_factory=list)
dm_message_count: int = 0
channel_message_count: int = 0
most_active_rooms: list[ContactActiveRoom] = Field(default_factory=list)
advert_paths: list[ContactAdvertPath] = Field(default_factory=list)
advert_frequency: float | None = Field(
default=None,
description="Advert observations per hour (includes multi-path arrivals of same advert)",
)
nearest_repeaters: list[NearestRepeater] = Field(default_factory=list)
class NameOnlyContactDetail(BaseModel):
"""Channel activity summary for a sender name that is not tied to a known key."""
name: str
channel_message_count: int = 0
most_active_rooms: list[ContactActiveRoom] = Field(default_factory=list)
class ContactAnalyticsHourlyBucket(BaseModel): class ContactAnalyticsHourlyBucket(BaseModel):
"""A single hourly activity bucket for contact analytics.""" """A single hourly activity bucket for contact analytics."""
@@ -354,6 +321,10 @@ class Channel(BaseModel):
default=None, default=None,
description="Per-channel outbound flood scope override (null = use global app setting)", description="Per-channel outbound flood scope override (null = use global app setting)",
) )
path_hash_mode_override: int | None = Field(
default=None,
description="Per-channel path hash mode override (0=1-byte, 1=2-byte, 2=3-byte, null = use radio default)",
)
last_read_at: int | None = None # Server-side read state tracking last_read_at: int | None = None # Server-side read state tracking
@@ -375,6 +346,18 @@ class ChannelTopSender(BaseModel):
message_count: int message_count: int
class PathHashWidthStats(BaseModel):
"""Hop byte width distribution for parsed raw packets."""
total_packets: int = 0
single_byte: int = 0
double_byte: int = 0
triple_byte: int = 0
single_byte_pct: float = 0.0
double_byte_pct: float = 0.0
triple_byte_pct: float = 0.0
class ChannelDetail(BaseModel): class ChannelDetail(BaseModel):
"""Comprehensive channel profile data.""" """Comprehensive channel profile data."""
@@ -383,6 +366,7 @@ class ChannelDetail(BaseModel):
first_message_at: int | None = None first_message_at: int | None = None
unique_sender_count: int = 0 unique_sender_count: int = 0
top_senders_24h: list[ChannelTopSender] = Field(default_factory=list) top_senders_24h: list[ChannelTopSender] = Field(default_factory=list)
path_hash_width_24h: PathHashWidthStats = Field(default_factory=PathHashWidthStats)
class MessagePath(BaseModel): class MessagePath(BaseModel):
@@ -394,6 +378,8 @@ class MessagePath(BaseModel):
default=None, default=None,
description="Hop count. None = legacy (infer as len(path)//2, i.e. 1-byte hops)", description="Hop count. None = legacy (infer as len(path)//2, i.e. 1-byte hops)",
) )
rssi: int | None = Field(default=None, description="Last-hop RSSI in dBm")
snr: float | None = Field(default=None, description="Last-hop SNR in dB")
class Message(BaseModel): class Message(BaseModel):
@@ -811,18 +797,10 @@ class AppSettings(BaseModel):
default=True, default=True,
description="Whether to attempt historical DM decryption on new contact advertisement", description="Whether to attempt historical DM decryption on new contact advertisement",
) )
sidebar_sort_order: Literal["recent", "alpha"] = Field(
default="recent",
description="Sidebar sort order: 'recent' or 'alpha'",
)
last_message_times: dict[str, int] = Field( last_message_times: dict[str, int] = Field(
default_factory=dict, default_factory=dict,
description="Map of conversation state keys to last message timestamps", description="Map of conversation state keys to last message timestamps",
) )
preferences_migrated: bool = Field(
default=False,
description="Whether preferences have been migrated from localStorage",
)
advert_interval: int = Field( advert_interval: int = Field(
default=0, default=0,
description="Periodic advertisement interval in seconds (0 = disabled)", description="Periodic advertisement interval in seconds (0 = disabled)",
@@ -850,19 +828,17 @@ class AppSettings(BaseModel):
"advertisements should not create new contacts; existing contacts are still updated" "advertisements should not create new contacts; existing contacts are still updated"
), ),
) )
tracked_telemetry_repeaters: list[str] = Field(
default_factory=list,
class FanoutConfig(BaseModel): description="Public keys of repeaters opted into periodic telemetry collection (max 8)",
"""Configuration for a single fanout integration.""" )
auto_resend_channel: bool = Field(
id: str default=False,
type: str # 'mqtt_private' | 'mqtt_community' | 'bot' | 'webhook' | 'apprise' | 'sqs' description=(
name: str "When enabled, outgoing channel messages that receive no echo within 2 seconds "
enabled: bool "are automatically byte-perfect resent once (within the 30-second dedup window)"
config: dict ),
scope: dict )
sort_order: int = 0
created_at: int = 0
class BusyChannel(BaseModel): class BusyChannel(BaseModel):
@@ -877,16 +853,6 @@ class ContactActivityCounts(BaseModel):
last_week: int last_week: int
class PathHashWidthStats(BaseModel):
total_packets: int
single_byte: int
double_byte: int
triple_byte: int
single_byte_pct: float
double_byte_pct: float
triple_byte_pct: float
class NoiseFloorSample(BaseModel): class NoiseFloorSample(BaseModel):
timestamp: int = Field(description="Unix timestamp of the sampled reading") timestamp: int = Field(description="Unix timestamp of the sampled reading")
noise_floor_dbm: int = Field(description="Noise floor in dBm") noise_floor_dbm: int = Field(description="Noise floor in dBm")
+22 -2
View File
@@ -68,6 +68,8 @@ async def create_message_from_decrypted(
received_at: int | None = None, received_at: int | None = None,
path: str | None = None, path: str | None = None,
path_len: int | None = None, path_len: int | None = None,
rssi: int | None = None,
snr: float | None = None,
channel_name: str | None = None, channel_name: str | None = None,
realtime: bool = True, realtime: bool = True,
) -> int | None: ) -> int | None:
@@ -81,6 +83,8 @@ async def create_message_from_decrypted(
received_at=received_at, received_at=received_at,
path=path, path=path,
path_len=path_len, path_len=path_len,
rssi=rssi,
snr=snr,
channel_name=channel_name, channel_name=channel_name,
realtime=realtime, realtime=realtime,
broadcast_fn=broadcast_event, broadcast_fn=broadcast_event,
@@ -95,6 +99,8 @@ async def create_dm_message_from_decrypted(
received_at: int | None = None, received_at: int | None = None,
path: str | None = None, path: str | None = None,
path_len: int | None = None, path_len: int | None = None,
rssi: int | None = None,
snr: float | None = None,
outgoing: bool = False, outgoing: bool = False,
realtime: bool = True, realtime: bool = True,
) -> int | None: ) -> int | None:
@@ -107,6 +113,8 @@ async def create_dm_message_from_decrypted(
received_at=received_at, received_at=received_at,
path=path, path=path,
path_len=path_len, path_len=path_len,
rssi=rssi,
snr=snr,
outgoing=outgoing, outgoing=outgoing,
realtime=realtime, realtime=realtime,
broadcast_fn=broadcast_event, broadcast_fn=broadcast_event,
@@ -319,7 +327,9 @@ async def process_raw_packet(
# deduplication in create_message_from_decrypted handles adding paths to existing messages. # deduplication in create_message_from_decrypted handles adding paths to existing messages.
# This is more reliable than trying to look up the message via raw packet linking. # This is more reliable than trying to look up the message via raw packet linking.
if payload_type == PayloadType.GROUP_TEXT: if payload_type == PayloadType.GROUP_TEXT:
decrypt_result = await _process_group_text(raw_bytes, packet_id, ts, packet_info) decrypt_result = await _process_group_text(
raw_bytes, packet_id, ts, packet_info, rssi=rssi, snr=snr
)
if decrypt_result: if decrypt_result:
result.update(decrypt_result) result.update(decrypt_result)
@@ -330,7 +340,9 @@ async def process_raw_packet(
elif payload_type == PayloadType.TEXT_MESSAGE: elif payload_type == PayloadType.TEXT_MESSAGE:
# Try to decrypt direct messages using stored private key and known contacts # Try to decrypt direct messages using stored private key and known contacts
decrypt_result = await _process_direct_message(raw_bytes, packet_id, ts, packet_info) decrypt_result = await _process_direct_message(
raw_bytes, packet_id, ts, packet_info, rssi=rssi, snr=snr
)
if decrypt_result: if decrypt_result:
result.update(decrypt_result) result.update(decrypt_result)
@@ -367,6 +379,8 @@ async def _process_group_text(
packet_id: int, packet_id: int,
timestamp: int, timestamp: int,
packet_info: PacketInfo | None, packet_info: PacketInfo | None,
rssi: int | None = None,
snr: float | None = None,
) -> dict | None: ) -> dict | None:
""" """
Process a GroupText (channel message) packet. Process a GroupText (channel message) packet.
@@ -403,6 +417,8 @@ async def _process_group_text(
received_at=timestamp, received_at=timestamp,
path=packet_info.path.hex() if packet_info else None, path=packet_info.path.hex() if packet_info else None,
path_len=packet_info.path_length if packet_info else None, path_len=packet_info.path_length if packet_info else None,
rssi=rssi,
snr=snr,
) )
return { return {
@@ -544,6 +560,8 @@ async def _process_direct_message(
packet_id: int, packet_id: int,
timestamp: int, timestamp: int,
packet_info: PacketInfo | None, packet_info: PacketInfo | None,
rssi: int | None = None,
snr: float | None = None,
) -> dict | None: ) -> dict | None:
""" """
Process a TEXT_MESSAGE (direct message) packet. Process a TEXT_MESSAGE (direct message) packet.
@@ -644,6 +662,8 @@ async def _process_direct_message(
received_at=timestamp, received_at=timestamp,
path=packet_info.path.hex() if packet_info else None, path=packet_info.path.hex() if packet_info else None,
path_len=packet_info.path_length if packet_info else None, path_len=packet_info.path_length if packet_info else None,
rssi=rssi,
snr=snr,
outgoing=is_outgoing, outgoing=is_outgoing,
) )
+48
View File
@@ -244,3 +244,51 @@ def parse_explicit_hop_route(route_text: str) -> tuple[str, int, int]:
raise ValueError(f"Explicit path exceeds MAX_PATH_SIZE={MAX_PATH_SIZE} bytes") raise ValueError(f"Explicit path exceeds MAX_PATH_SIZE={MAX_PATH_SIZE} bytes")
return "".join(hops), len(hops), hash_size - 1 return "".join(hops), len(hops), hash_size - 1
async def bucket_path_hash_widths(cursor, *, batch_size: int = 500) -> dict[str, int | float]:
"""Bucket raw packet rows by hop hash width and return counts + percentages.
*cursor* must be an already-executed async cursor whose rows have a ``data``
column containing raw packet bytes.
"""
single_byte = 0
double_byte = 0
triple_byte = 0
while True:
rows = await cursor.fetchmany(batch_size)
if not rows:
break
for row in rows:
envelope = parse_packet_envelope(bytes(row["data"]))
if envelope is None:
continue
if envelope.hash_size == 1:
single_byte += 1
elif envelope.hash_size == 2:
double_byte += 1
elif envelope.hash_size == 3:
triple_byte += 1
total = single_byte + double_byte + triple_byte
if total == 0:
return {
"total_packets": 0,
"single_byte": 0,
"double_byte": 0,
"triple_byte": 0,
"single_byte_pct": 0.0,
"double_byte_pct": 0.0,
"triple_byte_pct": 0.0,
}
return {
"total_packets": total,
"single_byte": single_byte,
"double_byte": double_byte,
"triple_byte": triple_byte,
"single_byte_pct": (single_byte / total) * 100,
"double_byte_pct": (double_byte / total) * 100,
"triple_byte_pct": (triple_byte / total) * 100,
}
+165 -64
View File
@@ -28,6 +28,7 @@ from app.repository import (
AppSettingsRepository, AppSettingsRepository,
ChannelRepository, ChannelRepository,
ContactRepository, ContactRepository,
RepeaterTelemetryRepository,
) )
from app.services.contact_reconciliation import ( from app.services.contact_reconciliation import (
promote_prefix_contacts_for_contact, promote_prefix_contacts_for_contact,
@@ -155,6 +156,15 @@ ADVERT_CHECK_INTERVAL = 60
# more frequently than this. # more frequently than this.
MIN_ADVERT_INTERVAL = 3600 MIN_ADVERT_INTERVAL = 3600
# Periodic telemetry collection task handle
_telemetry_collect_task: asyncio.Task | None = None
# Telemetry collection interval (8 hours)
TELEMETRY_COLLECT_INTERVAL = 8 * 3600
# Initial delay before the first telemetry collection cycle (let radio settle)
TELEMETRY_COLLECT_INITIAL_DELAY = 60
# Counter to pause polling during repeater operations (supports nested pauses) # Counter to pause polling during repeater operations (supports nested pauses)
_polling_pause_count: int = 0 _polling_pause_count: int = 0
@@ -253,70 +263,6 @@ async def should_run_full_periodic_sync(mc: MeshCore) -> bool:
return False return False
async def sync_and_offload_contacts(mc: MeshCore) -> dict:
"""
Sync contacts from radio to database, then remove them from radio.
Returns counts of synced and removed contacts.
"""
synced = 0
removed = 0
try:
# Get all contacts from radio
result = await mc.commands.get_contacts()
if result is None or result.type == EventType.ERROR:
logger.error(
"Failed to get contacts from radio: %s. "
"If you see this repeatedly, the radio may be visible on the "
"serial/TCP/BLE port but not responding to commands. Check for "
"another process with the serial port open (other RemoteTerm "
"instances, serial monitors, etc.), verify the firmware is "
"up-to-date and in client mode (not repeater), or try a "
"power cycle.",
result,
)
return {"synced": 0, "removed": 0, "error": str(result)}
contacts = result.payload or {}
logger.info("Found %d contacts on radio", len(contacts))
# Sync each contact to database, then remove from radio
for public_key, contact_data in contacts.items():
# Save to database
await ContactRepository.upsert(
ContactUpsert.from_radio_dict(public_key, contact_data, on_radio=False)
)
asyncio.create_task(
_reconcile_contact_messages_background(
public_key,
contact_data.get("adv_name"),
)
)
synced += 1
# Remove from radio
try:
remove_result = await mc.commands.remove_contact(contact_data)
if remove_result.type == EventType.OK:
removed += 1
_evict_removed_contact_from_library_cache(mc, public_key)
else:
logger.warning(
"Failed to remove contact %s: %s", public_key[:12], remove_result.payload
)
except Exception as e:
logger.warning("Error removing contact %s: %s", public_key[:12], e)
logger.info("Synced %d contacts, removed %d from radio", synced, removed)
except Exception as e:
logger.error("Error during contact sync: %s", e)
return {"synced": synced, "removed": removed, "error": str(e)}
return {"synced": synced, "removed": removed}
async def sync_and_offload_channels(mc: MeshCore, max_channels: int | None = None) -> dict: async def sync_and_offload_channels(mc: MeshCore, max_channels: int | None = None) -> dict:
""" """
Sync channels from radio to database, then clear them from radio. Sync channels from radio to database, then clear them from radio.
@@ -1588,3 +1534,158 @@ async def sync_recent_contacts_to_radio(force: bool = False, mc: MeshCore | None
except Exception as e: except Exception as e:
logger.error("Error syncing contacts to radio: %s", e, exc_info=True) logger.error("Error syncing contacts to radio: %s", e, exc_info=True)
return {"loaded": 0, "error": str(e)} return {"loaded": 0, "error": str(e)}
# ---------------------------------------------------------------------------
# Periodic repeater telemetry collection
# ---------------------------------------------------------------------------
async def _collect_repeater_telemetry(mc: MeshCore, contact: Contact) -> bool:
"""Fetch status telemetry from a single repeater and record it.
Returns True on success, False on failure (logged, not raised).
"""
try:
await mc.commands.add_contact(contact.to_radio_dict())
status = await mc.commands.req_status_sync(contact.public_key, timeout=10, min_timeout=5)
except Exception as e:
logger.debug(
"Telemetry collect: radio command failed for %s: %s",
contact.public_key[:12],
e,
)
return False
if status is None:
logger.debug("Telemetry collect: no response from %s", contact.public_key[:12])
return False
# Map to the same field names as the manual repeater status endpoint
data = {
"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),
}
try:
await RepeaterTelemetryRepository.record(
public_key=contact.public_key,
timestamp=int(time.time()),
data=data,
)
logger.info(
"Telemetry collect: recorded snapshot for %s (%s)",
contact.name or contact.public_key[:12],
contact.public_key[:12],
)
return True
except Exception as e:
logger.warning(
"Telemetry collect: failed to record for %s: %s",
contact.public_key[:12],
e,
)
return False
async def _telemetry_collect_loop() -> None:
"""Background task that collects telemetry from tracked repeaters every 8 hours.
Runs a first cycle after a short initial delay (so newly tracked repeaters
get a sample promptly), then sleeps the full interval between subsequent cycles.
Acquires the radio lock per-repeater (non-blocking) so manual operations can
interleave. Failures are logged and skipped.
"""
first_run = True
while True:
try:
delay = TELEMETRY_COLLECT_INITIAL_DELAY if first_run else TELEMETRY_COLLECT_INTERVAL
await asyncio.sleep(delay)
first_run = False
if not radio_manager.is_connected:
logger.debug("Telemetry collect: radio not connected, skipping cycle")
continue
app_settings = await AppSettingsRepository.get()
tracked = app_settings.tracked_telemetry_repeaters
if not tracked:
continue
logger.info("Telemetry collect: starting cycle for %d repeater(s)", len(tracked))
collected = 0
for pub_key in tracked:
contact = await ContactRepository.get_by_key(pub_key)
if not contact or contact.type != 2:
logger.debug(
"Telemetry collect: skipping %s (not found or not repeater)",
pub_key[:12],
)
continue
try:
async with radio_manager.radio_operation(
"telemetry_collect",
blocking=False,
suspend_auto_fetch=True,
) as mc:
if await _collect_repeater_telemetry(mc, contact):
collected += 1
except RadioOperationBusyError:
logger.debug(
"Telemetry collect: radio busy, skipping %s",
pub_key[:12],
)
logger.info(
"Telemetry collect: cycle complete, %d/%d successful",
collected,
len(tracked),
)
except asyncio.CancelledError:
logger.info("Telemetry collect task cancelled")
break
except Exception as e:
logger.error("Error in telemetry collect loop: %s", e, exc_info=True)
def start_telemetry_collect() -> None:
"""Start the periodic telemetry collection background task."""
global _telemetry_collect_task
if _telemetry_collect_task is None or _telemetry_collect_task.done():
_telemetry_collect_task = asyncio.create_task(_telemetry_collect_loop())
logger.info(
"Started periodic telemetry collection (interval: %ds)",
TELEMETRY_COLLECT_INTERVAL,
)
async def stop_telemetry_collect() -> None:
"""Stop the periodic telemetry collection background task."""
global _telemetry_collect_task
if _telemetry_collect_task and not _telemetry_collect_task.done():
_telemetry_collect_task.cancel()
try:
await _telemetry_collect_task
except asyncio.CancelledError:
pass
_telemetry_collect_task = None
logger.info("Stopped periodic telemetry collection")
+14 -26
View File
@@ -26,7 +26,7 @@ class ChannelRepository:
"""Get a channel by its key (32-char hex string).""" """Get a channel by its key (32-char hex string)."""
cursor = await db.conn.execute( cursor = await db.conn.execute(
""" """
SELECT key, name, is_hashtag, on_radio, flood_scope_override, last_read_at SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at
FROM channels FROM channels
WHERE key = ? WHERE key = ?
""", """,
@@ -40,6 +40,7 @@ class ChannelRepository:
is_hashtag=bool(row["is_hashtag"]), is_hashtag=bool(row["is_hashtag"]),
on_radio=bool(row["on_radio"]), on_radio=bool(row["on_radio"]),
flood_scope_override=row["flood_scope_override"], flood_scope_override=row["flood_scope_override"],
path_hash_mode_override=row["path_hash_mode_override"],
last_read_at=row["last_read_at"], last_read_at=row["last_read_at"],
) )
return None return None
@@ -48,7 +49,7 @@ class ChannelRepository:
async def get_all() -> list[Channel]: async def get_all() -> list[Channel]:
cursor = await db.conn.execute( cursor = await db.conn.execute(
""" """
SELECT key, name, is_hashtag, on_radio, flood_scope_override, last_read_at SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at
FROM channels FROM channels
ORDER BY name ORDER BY name
""" """
@@ -61,30 +62,7 @@ class ChannelRepository:
is_hashtag=bool(row["is_hashtag"]), is_hashtag=bool(row["is_hashtag"]),
on_radio=bool(row["on_radio"]), on_radio=bool(row["on_radio"]),
flood_scope_override=row["flood_scope_override"], flood_scope_override=row["flood_scope_override"],
last_read_at=row["last_read_at"], path_hash_mode_override=row["path_hash_mode_override"],
)
for row in rows
]
@staticmethod
async def get_on_radio() -> list[Channel]:
"""Return channels currently marked as resident on the radio in the database."""
cursor = await db.conn.execute(
"""
SELECT key, name, is_hashtag, on_radio, flood_scope_override, last_read_at
FROM channels
WHERE on_radio = 1
ORDER BY name
"""
)
rows = await cursor.fetchall()
return [
Channel(
key=row["key"],
name=row["name"],
is_hashtag=bool(row["is_hashtag"]),
on_radio=bool(row["on_radio"]),
flood_scope_override=row["flood_scope_override"],
last_read_at=row["last_read_at"], last_read_at=row["last_read_at"],
) )
for row in rows for row in rows
@@ -123,6 +101,16 @@ class ChannelRepository:
await db.conn.commit() await db.conn.commit()
return cursor.rowcount > 0 return cursor.rowcount > 0
@staticmethod
async def update_path_hash_mode_override(key: str, path_hash_mode_override: int | None) -> bool:
"""Set or clear a channel's path hash mode override."""
cursor = await db.conn.execute(
"UPDATE channels SET path_hash_mode_override = ? WHERE key = ?",
(path_hash_mode_override, key.upper()),
)
await db.conn.commit()
return cursor.rowcount > 0
@staticmethod @staticmethod
async def mark_all_read(timestamp: int) -> None: async def mark_all_read(timestamp: int) -> None:
"""Mark all channels as read at the given timestamp.""" """Mark all channels as read at the given timestamp."""
+3 -6
View File
@@ -395,12 +395,9 @@ class ContactRepository:
@staticmethod @staticmethod
async def delete(public_key: str) -> None: async def delete(public_key: str) -> None:
normalized = public_key.lower() normalized = public_key.lower()
await db.conn.execute( # contact_name_history and contact_advert_paths cascade via FK.
"DELETE FROM contact_name_history WHERE public_key = ?", (normalized,) # Messages are intentionally preserved so history re-surfaces
) # if the contact is re-added later.
await db.conn.execute(
"DELETE FROM contact_advert_paths WHERE public_key = ?", (normalized,)
)
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (normalized,)) await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (normalized,))
await db.conn.commit() await db.conn.commit()
+45 -16
View File
@@ -29,8 +29,7 @@ class MessageRepository:
def _contact_activity_filter(public_key: str) -> tuple[str, list[Any]]: def _contact_activity_filter(public_key: str) -> tuple[str, list[Any]]:
lower_key = public_key.lower() lower_key = public_key.lower()
return ( return (
"((type = 'PRIV' AND LOWER(conversation_key) = ?)" "((type = 'PRIV' AND conversation_key = ?) OR (type = 'CHAN' AND sender_key = ?))",
" OR (type = 'CHAN' AND LOWER(sender_key) = ?))",
[lower_key, lower_key], [lower_key, lower_key],
) )
@@ -58,6 +57,8 @@ class MessageRepository:
sender_timestamp: int | None = None, sender_timestamp: int | None = None,
path: str | None = None, path: str | None = None,
path_len: int | None = None, path_len: int | None = None,
rssi: int | None = None,
snr: float | None = None,
txt_type: int = 0, txt_type: int = 0,
signature: str | None = None, signature: str | None = None,
outgoing: bool = False, outgoing: bool = False,
@@ -79,8 +80,15 @@ class MessageRepository:
entry: dict = {"path": path, "received_at": received_at} entry: dict = {"path": path, "received_at": received_at}
if path_len is not None: if path_len is not None:
entry["path_len"] = path_len entry["path_len"] = path_len
if rssi is not None:
entry["rssi"] = rssi
if snr is not None:
entry["snr"] = snr
paths_json = json.dumps([entry]) paths_json = json.dumps([entry])
# Normalize sender_key to lowercase so queries can match without LOWER().
normalized_sender_key = sender_key.lower() if sender_key else sender_key
cursor = await db.conn.execute( cursor = await db.conn.execute(
""" """
INSERT OR IGNORE INTO messages (type, conversation_key, text, sender_timestamp, INSERT OR IGNORE INTO messages (type, conversation_key, text, sender_timestamp,
@@ -99,7 +107,7 @@ class MessageRepository:
signature, signature,
outgoing, outgoing,
sender_name, sender_name,
sender_key, normalized_sender_key,
), ),
) )
await db.conn.commit() await db.conn.commit()
@@ -114,6 +122,8 @@ class MessageRepository:
path: str, path: str,
received_at: int | None = None, received_at: int | None = None,
path_len: int | None = None, path_len: int | None = None,
rssi: int | None = None,
snr: float | None = None,
) -> list[MessagePath]: ) -> list[MessagePath]:
"""Add a new path to an existing message. """Add a new path to an existing message.
@@ -127,6 +137,10 @@ class MessageRepository:
entry: dict = {"path": path, "received_at": ts} entry: dict = {"path": path, "received_at": ts}
if path_len is not None: if path_len is not None:
entry["path_len"] = path_len entry["path_len"] = path_len
if rssi is not None:
entry["rssi"] = rssi
if snr is not None:
entry["snr"] = snr
new_entry = json.dumps(entry) new_entry = json.dumps(entry)
await db.conn.execute( await db.conn.execute(
"""UPDATE messages SET paths = json_insert( """UPDATE messages SET paths = json_insert(
@@ -259,10 +273,10 @@ class MessageRepository:
if MessageRepository._looks_like_hex_prefix(value): if MessageRepository._looks_like_hex_prefix(value):
if len(value) == 32: if len(value) == 32:
clause += " OR UPPER(messages.conversation_key) = ?" clause += " OR messages.conversation_key = ?"
params.append(value.upper()) params.append(value.upper())
else: else:
clause += " OR UPPER(messages.conversation_key) LIKE ? ESCAPE '\\'" clause += " OR messages.conversation_key LIKE ? ESCAPE '\\'"
params.append(f"{MessageRepository._escape_like(value.upper())}%") params.append(f"{MessageRepository._escape_like(value.upper())}%")
clause += "))" clause += "))"
@@ -281,13 +295,13 @@ class MessageRepository:
priv_key_clause: str priv_key_clause: str
chan_key_clause: str chan_key_clause: str
if len(value) == 64: if len(value) == 64:
priv_key_clause = "LOWER(messages.conversation_key) = ?" priv_key_clause = "messages.conversation_key = ?"
chan_key_clause = "LOWER(sender_key) = ?" chan_key_clause = "sender_key = ?"
params.extend([lower_value, lower_value]) params.extend([lower_value, lower_value])
else: else:
escaped_prefix = f"{MessageRepository._escape_like(lower_value)}%" escaped_prefix = f"{MessageRepository._escape_like(lower_value)}%"
priv_key_clause = "LOWER(messages.conversation_key) LIKE ? ESCAPE '\\'" priv_key_clause = "messages.conversation_key LIKE ? ESCAPE '\\'"
chan_key_clause = "LOWER(sender_key) LIKE ? ESCAPE '\\'" chan_key_clause = "sender_key LIKE ? ESCAPE '\\'"
params.extend([escaped_prefix, escaped_prefix]) params.extend([escaped_prefix, escaped_prefix])
clause += ( clause += (
@@ -311,12 +325,12 @@ class MessageRepository:
if blocked_keys: if blocked_keys:
placeholders = ",".join("?" for _ in blocked_keys) placeholders = ",".join("?" for _ in blocked_keys)
blocked_matchers.append( blocked_matchers.append(
f"({prefix}type = 'PRIV' AND LOWER({prefix}conversation_key) IN ({placeholders}))" f"({prefix}type = 'PRIV' AND {prefix}conversation_key IN ({placeholders}))"
) )
params.extend(blocked_keys) params.extend(blocked_keys)
blocked_matchers.append( blocked_matchers.append(
f"({prefix}type = 'CHAN' AND {prefix}sender_key IS NOT NULL" f"({prefix}type = 'CHAN' AND {prefix}sender_key IS NOT NULL"
f" AND LOWER({prefix}sender_key) IN ({placeholders}))" f" AND {prefix}sender_key IN ({placeholders}))"
) )
params.extend(blocked_keys) params.extend(blocked_keys)
@@ -383,9 +397,9 @@ class MessageRepository:
query = ( query = (
f"SELECT {MessageRepository._message_select('messages')} FROM messages " f"SELECT {MessageRepository._message_select('messages')} FROM messages "
"LEFT JOIN contacts ON messages.type = 'PRIV' " "LEFT JOIN contacts ON messages.type = 'PRIV' "
"AND LOWER(messages.conversation_key) = LOWER(contacts.public_key) " "AND messages.conversation_key = contacts.public_key "
"LEFT JOIN channels ON messages.type = 'CHAN' " "LEFT JOIN channels ON messages.type = 'CHAN' "
"AND UPPER(messages.conversation_key) = UPPER(channels.key) " "AND messages.conversation_key = channels.key "
"WHERE 1=1" "WHERE 1=1"
) )
params: list[Any] = [] params: list[Any] = []
@@ -673,7 +687,7 @@ class MessageRepository:
ELSE 0 ELSE 0
END) > 0 as has_mention END) > 0 as has_mention
FROM messages m FROM messages m
JOIN contacts ct ON m.conversation_key = ct.public_key LEFT JOIN contacts ct ON m.conversation_key = ct.public_key
WHERE m.type = 'PRIV' AND m.outgoing = 0 WHERE m.type = 'PRIV' AND m.outgoing = 0
AND m.received_at > COALESCE(ct.last_read_at, 0) AND m.received_at > COALESCE(ct.last_read_at, 0)
{blocked_sql} {blocked_sql}
@@ -784,12 +798,14 @@ class MessageRepository:
@staticmethod @staticmethod
async def get_channel_stats(conversation_key: str) -> dict: async def get_channel_stats(conversation_key: str) -> dict:
"""Get channel message statistics: time-windowed counts, first message, unique senders, top senders. """Get channel message statistics: time-windowed counts, first message, unique senders, top senders, path hash widths.
Returns a dict with message_counts, first_message_at, unique_sender_count, top_senders_24h. Returns a dict with message_counts, first_message_at, unique_sender_count, top_senders_24h, path_hash_width_24h.
""" """
import time as _time import time as _time
from app.path_utils import bucket_path_hash_widths
now = int(_time.time()) now = int(_time.time())
t_1h = now - 3600 t_1h = now - 3600
t_24h = now - 86400 t_24h = now - 86400
@@ -841,11 +857,24 @@ class MessageRepository:
for r in top_rows for r in top_rows
] ]
# Path hash width distribution for last 24h (in-Python parse of raw packet envelopes)
cursor3 = await db.conn.execute(
"""
SELECT rp.data FROM raw_packets rp
JOIN messages m ON rp.message_id = m.id
WHERE m.type = 'CHAN' AND m.conversation_key = ?
AND rp.timestamp >= ?
""",
(conversation_key, t_24h),
)
path_hash_width_24h = await bucket_path_hash_widths(cursor3)
return { return {
"message_counts": message_counts, "message_counts": message_counts,
"first_message_at": row["first_message_at"], "first_message_at": row["first_message_at"],
"unique_sender_count": row["unique_sender_count"] or 0, "unique_sender_count": row["unique_sender_count"] or 0,
"top_senders_24h": top_senders, "top_senders_24h": top_senders,
"path_hash_width_24h": path_hash_width_24h,
} }
@staticmethod @staticmethod
+26 -50
View File
@@ -1,5 +1,4 @@
import logging import logging
import sqlite3
import time import time
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from hashlib import sha256 from hashlib import sha256
@@ -35,46 +34,23 @@ class RawPacketRepository:
# For malformed packets, hash the full data # For malformed packets, hash the full data
payload_hash = sha256(data).digest() payload_hash = sha256(data).digest()
# Check if this payload already exists cursor = await db.conn.execute(
"INSERT OR IGNORE INTO raw_packets (timestamp, data, payload_hash) VALUES (?, ?, ?)",
(ts, data, payload_hash),
)
await db.conn.commit()
if cursor.rowcount > 0:
assert cursor.lastrowid is not None
return (cursor.lastrowid, True)
# Duplicate payload — look up the existing row.
cursor = await db.conn.execute( cursor = await db.conn.execute(
"SELECT id FROM raw_packets WHERE payload_hash = ?", (payload_hash,) "SELECT id FROM raw_packets WHERE payload_hash = ?", (payload_hash,)
) )
existing = await cursor.fetchone() existing = await cursor.fetchone()
assert existing is not None
if existing: return (existing["id"], False)
# Duplicate - return existing packet ID
logger.debug(
"Duplicate payload detected (hash=%s..., existing_id=%d)",
payload_hash.hex()[:12],
existing["id"],
)
return (existing["id"], False)
# New packet - insert with hash
try:
cursor = await db.conn.execute(
"INSERT INTO raw_packets (timestamp, data, payload_hash) VALUES (?, ?, ?)",
(ts, data, payload_hash),
)
await db.conn.commit()
assert cursor.lastrowid is not None # INSERT always returns a row ID
return (cursor.lastrowid, True)
except sqlite3.IntegrityError:
# Race condition: another insert with same payload_hash happened between
# our SELECT and INSERT. This is expected for duplicate packets arriving
# close together. Query again to get the existing ID.
logger.debug(
"Duplicate packet detected via race condition (payload_hash=%s), dropping",
payload_hash.hex()[:16],
)
cursor = await db.conn.execute(
"SELECT id FROM raw_packets WHERE payload_hash = ?", (payload_hash,)
)
existing = await cursor.fetchone()
if existing:
return (existing["id"], False)
# This shouldn't happen, but if it does, re-raise
raise
@staticmethod @staticmethod
async def get_undecrypted_count() -> int: async def get_undecrypted_count() -> int:
@@ -95,13 +71,22 @@ class RawPacketRepository:
return row["oldest"] if row and row["oldest"] is not None else None return row["oldest"] if row and row["oldest"] is not None else None
@staticmethod @staticmethod
async def get_all_undecrypted() -> list[tuple[int, bytes, int]]: async def stream_all_undecrypted(
"""Get all undecrypted packets as (id, data, timestamp) tuples.""" batch_size: int = UNDECRYPTED_PACKET_BATCH_SIZE,
) -> AsyncIterator[tuple[int, bytes, int]]:
"""Yield all undecrypted packets as (id, data, timestamp) in bounded batches."""
cursor = await db.conn.execute( cursor = await db.conn.execute(
"SELECT id, data, timestamp FROM raw_packets WHERE message_id IS NULL ORDER BY timestamp ASC" "SELECT id, data, timestamp FROM raw_packets WHERE message_id IS NULL ORDER BY timestamp ASC"
) )
rows = await cursor.fetchall() try:
return [(row["id"], bytes(row["data"]), row["timestamp"]) for row in rows] while True:
rows = await cursor.fetchmany(batch_size)
if not rows:
break
for row in rows:
yield (row["id"], bytes(row["data"]), row["timestamp"])
finally:
await cursor.close()
@staticmethod @staticmethod
async def stream_undecrypted_text_messages( async def stream_undecrypted_text_messages(
@@ -187,12 +172,3 @@ class RawPacketRepository:
cursor = await db.conn.execute("DELETE FROM raw_packets WHERE message_id IS NOT NULL") cursor = await db.conn.execute("DELETE FROM raw_packets WHERE message_id IS NOT NULL")
await db.conn.commit() await db.conn.commit()
return cursor.rowcount return cursor.rowcount
@staticmethod
async def get_undecrypted_text_messages() -> list[tuple[int, bytes, int]]:
"""Get all undecrypted TEXT_MESSAGE packets as (id, data, timestamp) tuples.
Filters raw packets to only include those with PayloadType.TEXT_MESSAGE (0x02).
These are direct messages that can be decrypted with contact ECDH keys.
"""
return [packet async for packet in RawPacketRepository.stream_undecrypted_text_messages()]
+31 -94
View File
@@ -5,7 +5,7 @@ from typing import Any, Literal
from app.database import db from app.database import db
from app.models import AppSettings, Favorite from app.models import AppSettings, Favorite
from app.path_utils import parse_packet_envelope from app.path_utils import bucket_path_hash_widths
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -27,9 +27,10 @@ class AppSettingsRepository:
cursor = await db.conn.execute( cursor = await db.conn.execute(
""" """
SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert, SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert,
sidebar_sort_order, last_message_times, preferences_migrated, last_message_times,
advert_interval, last_advert_time, flood_scope, advert_interval, last_advert_time, flood_scope,
blocked_keys, blocked_names, discovery_blocked_types blocked_keys, blocked_names, discovery_blocked_types,
tracked_telemetry_repeaters, auto_resend_channel
FROM app_settings WHERE id = 1 FROM app_settings WHERE id = 1
""" """
) )
@@ -89,24 +90,34 @@ class AppSettingsRepository:
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
discovery_blocked_types = [] discovery_blocked_types = []
# Validate sidebar_sort_order (fallback to "recent" if invalid) # Parse tracked_telemetry_repeaters JSON
sort_order = row["sidebar_sort_order"] tracked_telemetry_repeaters: list[str] = []
if sort_order not in ("recent", "alpha"): try:
sort_order = "recent" raw_tracked = row["tracked_telemetry_repeaters"]
if raw_tracked:
tracked_telemetry_repeaters = json.loads(raw_tracked)
except (json.JSONDecodeError, TypeError, KeyError):
tracked_telemetry_repeaters = []
# Parse auto_resend_channel boolean
try:
auto_resend_channel = bool(row["auto_resend_channel"])
except (KeyError, TypeError):
auto_resend_channel = False
return AppSettings( return AppSettings(
max_radio_contacts=row["max_radio_contacts"], max_radio_contacts=row["max_radio_contacts"],
favorites=favorites, favorites=favorites,
auto_decrypt_dm_on_advert=bool(row["auto_decrypt_dm_on_advert"]), auto_decrypt_dm_on_advert=bool(row["auto_decrypt_dm_on_advert"]),
sidebar_sort_order=sort_order,
last_message_times=last_message_times, last_message_times=last_message_times,
preferences_migrated=bool(row["preferences_migrated"]),
advert_interval=row["advert_interval"] or 0, advert_interval=row["advert_interval"] or 0,
last_advert_time=row["last_advert_time"] or 0, last_advert_time=row["last_advert_time"] or 0,
flood_scope=row["flood_scope"] or "", flood_scope=row["flood_scope"] or "",
blocked_keys=blocked_keys, blocked_keys=blocked_keys,
blocked_names=blocked_names, blocked_names=blocked_names,
discovery_blocked_types=discovery_blocked_types, discovery_blocked_types=discovery_blocked_types,
tracked_telemetry_repeaters=tracked_telemetry_repeaters,
auto_resend_channel=auto_resend_channel,
) )
@staticmethod @staticmethod
@@ -114,15 +125,15 @@ class AppSettingsRepository:
max_radio_contacts: int | None = None, max_radio_contacts: int | None = None,
favorites: list[Favorite] | None = None, favorites: list[Favorite] | None = None,
auto_decrypt_dm_on_advert: bool | None = None, auto_decrypt_dm_on_advert: bool | None = None,
sidebar_sort_order: str | None = None,
last_message_times: dict[str, int] | None = None, last_message_times: dict[str, int] | None = None,
preferences_migrated: bool | None = None,
advert_interval: int | None = None, advert_interval: int | None = None,
last_advert_time: int | None = None, last_advert_time: int | None = None,
flood_scope: str | None = None, flood_scope: str | None = None,
blocked_keys: list[str] | None = None, blocked_keys: list[str] | None = None,
blocked_names: list[str] | None = None, blocked_names: list[str] | None = None,
discovery_blocked_types: list[int] | None = None, discovery_blocked_types: list[int] | None = None,
tracked_telemetry_repeaters: list[str] | None = None,
auto_resend_channel: bool | None = None,
) -> AppSettings: ) -> AppSettings:
"""Update app settings. Only provided fields are updated.""" """Update app settings. Only provided fields are updated."""
updates = [] updates = []
@@ -141,18 +152,10 @@ class AppSettingsRepository:
updates.append("auto_decrypt_dm_on_advert = ?") updates.append("auto_decrypt_dm_on_advert = ?")
params.append(1 if auto_decrypt_dm_on_advert else 0) params.append(1 if auto_decrypt_dm_on_advert else 0)
if sidebar_sort_order is not None:
updates.append("sidebar_sort_order = ?")
params.append(sidebar_sort_order)
if last_message_times is not None: if last_message_times is not None:
updates.append("last_message_times = ?") updates.append("last_message_times = ?")
params.append(json.dumps(last_message_times)) params.append(json.dumps(last_message_times))
if preferences_migrated is not None:
updates.append("preferences_migrated = ?")
params.append(1 if preferences_migrated else 0)
if advert_interval is not None: if advert_interval is not None:
updates.append("advert_interval = ?") updates.append("advert_interval = ?")
params.append(advert_interval) params.append(advert_interval)
@@ -177,6 +180,14 @@ class AppSettingsRepository:
updates.append("discovery_blocked_types = ?") updates.append("discovery_blocked_types = ?")
params.append(json.dumps(discovery_blocked_types)) params.append(json.dumps(discovery_blocked_types))
if tracked_telemetry_repeaters is not None:
updates.append("tracked_telemetry_repeaters = ?")
params.append(json.dumps(tracked_telemetry_repeaters))
if auto_resend_channel is not None:
updates.append("auto_resend_channel = ?")
params.append(1 if auto_resend_channel else 0)
if updates: if updates:
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1" query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
await db.conn.execute(query, params) await db.conn.execute(query, params)
@@ -226,39 +237,6 @@ class AppSettingsRepository:
new_names = settings.blocked_names + [name] new_names = settings.blocked_names + [name]
return await AppSettingsRepository.update(blocked_names=new_names) return await AppSettingsRepository.update(blocked_names=new_names)
@staticmethod
async def migrate_preferences_from_frontend(
favorites: list[dict],
sort_order: str,
last_message_times: dict[str, int],
) -> tuple[AppSettings, bool]:
"""Migrate all preferences from frontend localStorage.
This is a one-time migration. If already migrated, returns current settings
without overwriting. Returns (settings, did_migrate) tuple.
"""
settings = await AppSettingsRepository.get()
if settings.preferences_migrated:
# Already migrated, don't overwrite
return settings, False
# Convert frontend favorites format to Favorite objects
new_favorites = []
for f in favorites:
if f.get("type") in ("channel", "contact") and f.get("id"):
new_favorites.append(Favorite(type=f["type"], id=f["id"]))
# Update with migrated preferences and mark as migrated
settings = await AppSettingsRepository.update(
favorites=new_favorites,
sidebar_sort_order=sort_order if sort_order in ("recent", "alpha") else "recent",
last_message_times=last_message_times,
preferences_migrated=True,
)
return settings, True
class StatisticsRepository: class StatisticsRepository:
@staticmethod @staticmethod
@@ -346,48 +324,7 @@ class StatisticsRepository:
"SELECT data FROM raw_packets WHERE timestamp >= ?", "SELECT data FROM raw_packets WHERE timestamp >= ?",
(now - SECONDS_24H,), (now - SECONDS_24H,),
) )
return await bucket_path_hash_widths(cursor, batch_size=RAW_PACKET_STATS_BATCH_SIZE)
single_byte = 0
double_byte = 0
triple_byte = 0
while True:
rows = await cursor.fetchmany(RAW_PACKET_STATS_BATCH_SIZE)
if not rows:
break
for row in rows:
envelope = parse_packet_envelope(bytes(row["data"]))
if envelope is None:
continue
if envelope.hash_size == 1:
single_byte += 1
elif envelope.hash_size == 2:
double_byte += 1
elif envelope.hash_size == 3:
triple_byte += 1
total_packets = single_byte + double_byte + triple_byte
if total_packets == 0:
return {
"total_packets": 0,
"single_byte": 0,
"double_byte": 0,
"triple_byte": 0,
"single_byte_pct": 0.0,
"double_byte_pct": 0.0,
"triple_byte_pct": 0.0,
}
return {
"total_packets": total_packets,
"single_byte": single_byte,
"double_byte": double_byte,
"triple_byte": triple_byte,
"single_byte_pct": (single_byte / total_packets) * 100,
"double_byte_pct": (double_byte / total_packets) * 100,
"triple_byte_pct": (triple_byte / total_packets) * 100,
}
@staticmethod @staticmethod
async def get_all() -> dict: async def get_all() -> dict:
+39 -3
View File
@@ -60,6 +60,15 @@ class ChannelFloodScopeOverrideRequest(BaseModel):
) )
class ChannelPathHashModeOverrideRequest(BaseModel):
path_hash_mode_override: int | None = Field(
default=None,
ge=0,
le=2,
description="Path hash mode override (0=1-byte, 1=2-byte, 2=3-byte, null = use radio default)",
)
def _derive_channel_identity( def _derive_channel_identity(
requested_name: str, requested_name: str,
request_key: str | None = None, request_key: str | None = None,
@@ -122,8 +131,7 @@ def _normalize_bulk_hashtag_name(name: str) -> str | None:
async def _run_historical_channel_decryption_for_channels( async def _run_historical_channel_decryption_for_channels(
channels: list[tuple[bytes, str, str]], channels: list[tuple[bytes, str, str]],
) -> None: ) -> None:
packets = await RawPacketRepository.get_all_undecrypted() total = await RawPacketRepository.get_undecrypted_count()
total = len(packets)
decrypted_count = 0 decrypted_count = 0
matched_channel_names: set[str] = set() matched_channel_names: set[str] = set()
@@ -137,7 +145,11 @@ async def _run_historical_channel_decryption_for_channels(
len(channels), len(channels),
) )
for packet_id, packet_data, packet_timestamp in packets: async for (
packet_id,
packet_data,
packet_timestamp,
) in RawPacketRepository.stream_all_undecrypted():
packet_info = parse_packet(packet_data) packet_info = parse_packet(packet_data)
path_hex = packet_info.path.hex() if packet_info else None path_hex = packet_info.path.hex() if packet_info else None
path_len = packet_info.path_length if packet_info else None path_len = packet_info.path_length if packet_info else None
@@ -203,6 +215,7 @@ async def get_channel_detail(key: str) -> ChannelDetail:
first_message_at=stats["first_message_at"], first_message_at=stats["first_message_at"],
unique_sender_count=stats["unique_sender_count"], unique_sender_count=stats["unique_sender_count"],
top_senders_24h=[ChannelTopSender(**s) for s in stats["top_senders_24h"]], top_senders_24h=[ChannelTopSender(**s) for s in stats["top_senders_24h"]],
path_hash_width_24h=stats["path_hash_width_24h"],
) )
@@ -345,6 +358,29 @@ async def set_channel_flood_scope_override(
return refreshed return refreshed
@router.post("/{key}/path-hash-mode-override", response_model=Channel)
async def set_channel_path_hash_mode_override(
key: str, request: ChannelPathHashModeOverrideRequest
) -> Channel:
"""Set or clear a per-channel path hash mode override."""
channel = await ChannelRepository.get_by_key(key)
if not channel:
raise HTTPException(status_code=404, detail="Channel not found")
updated = await ChannelRepository.update_path_hash_mode_override(
channel.key, request.path_hash_mode_override
)
if not updated:
raise HTTPException(status_code=500, detail="Failed to update path-hash-mode override")
refreshed = await ChannelRepository.get_by_key(channel.key)
if refreshed is None:
raise HTTPException(status_code=500, detail="Channel disappeared after update")
broadcast_event("channel", refreshed.model_dump())
return refreshed
@router.delete("/{key}") @router.delete("/{key}")
async def delete_channel(key: str) -> dict: async def delete_channel(key: str) -> dict:
"""Delete a channel from the database by key. """Delete a channel from the database by key.
+2 -3
View File
@@ -8,7 +8,6 @@ from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
from meshcore import EventType from meshcore import EventType
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.dependencies import require_connected
from app.models import ( from app.models import (
Contact, Contact,
ContactActiveRoom, ContactActiveRoom,
@@ -428,7 +427,7 @@ async def request_trace(public_key: str) -> TraceResponse:
(no intermediate repeaters). This uses TRACE's dedicated width flags rather (no intermediate repeaters). This uses TRACE's dedicated width flags rather
than the radio's normal path_hash_mode setting. than the radio's normal path_hash_mode setting.
""" """
require_connected() radio_manager.require_connected()
contact = await _resolve_contact_or_404(public_key) contact = await _resolve_contact_or_404(public_key)
@@ -487,7 +486,7 @@ async def request_trace(public_key: str) -> TraceResponse:
@router.post("/{public_key}/path-discovery", response_model=PathDiscoveryResponse) @router.post("/{public_key}/path-discovery", response_model=PathDiscoveryResponse)
async def request_path_discovery(public_key: str) -> PathDiscoveryResponse: async def request_path_discovery(public_key: str) -> PathDiscoveryResponse:
"""Discover the current forward and return paths to a known contact.""" """Discover the current forward and return paths to a known contact."""
require_connected() radio_manager.require_connected()
contact = await _resolve_contact_or_404(public_key) contact = await _resolve_contact_or_404(public_key)
pubkey_prefix = contact.public_key[:12] pubkey_prefix = contact.public_key[:12]
+3 -4
View File
@@ -3,7 +3,6 @@ import time
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from app.dependencies import require_connected
from app.event_handlers import track_pending_ack from app.event_handlers import track_pending_ack
from app.models import ( from app.models import (
Message, Message,
@@ -89,7 +88,7 @@ async def list_messages(
@router.post("/direct", response_model=Message) @router.post("/direct", response_model=Message)
async def send_direct_message(request: SendDirectMessageRequest) -> Message: async def send_direct_message(request: SendDirectMessageRequest) -> Message:
"""Send a direct message to a contact.""" """Send a direct message to a contact."""
require_connected() radio_manager.require_connected()
# First check our database for the contact # First check our database for the contact
from app.repository import ContactRepository from app.repository import ContactRepository
@@ -136,7 +135,7 @@ TEMP_RADIO_SLOT = 0
@router.post("/channel", response_model=Message) @router.post("/channel", response_model=Message)
async def send_channel_message(request: SendChannelMessageRequest) -> Message: async def send_channel_message(request: SendChannelMessageRequest) -> Message:
"""Send a message to a channel.""" """Send a message to a channel."""
require_connected() radio_manager.require_connected()
# Get channel info from our database # Get channel info from our database
from app.repository import ChannelRepository from app.repository import ChannelRepository
@@ -189,7 +188,7 @@ async def resend_channel_message(
When new_timestamp=True: resend with a fresh timestamp so repeaters treat it as a When new_timestamp=True: resend with a fresh timestamp so repeaters treat it as a
new packet. Creates a new message row in the database. No time window restriction. new packet. Creates a new message row in the database. No time window restriction.
""" """
require_connected() radio_manager.require_connected()
from app.repository import ChannelRepository from app.repository import ChannelRepository
+6 -3
View File
@@ -49,8 +49,7 @@ async def _run_historical_channel_decryption(
channel_key_bytes: bytes, channel_key_hex: str, display_name: str | None = None channel_key_bytes: bytes, channel_key_hex: str, display_name: str | None = None
) -> None: ) -> None:
"""Background task to decrypt historical packets with a channel key.""" """Background task to decrypt historical packets with a channel key."""
packets = await RawPacketRepository.get_all_undecrypted() total = await RawPacketRepository.get_undecrypted_count()
total = len(packets)
decrypted_count = 0 decrypted_count = 0
if total == 0: if total == 0:
@@ -59,7 +58,11 @@ async def _run_historical_channel_decryption(
logger.info("Starting historical channel decryption of %d packets", total) logger.info("Starting historical channel decryption of %d packets", total)
for packet_id, packet_data, packet_timestamp in packets: async for (
packet_id,
packet_data,
packet_timestamp,
) in RawPacketRepository.stream_all_undecrypted():
result = try_decrypt_packet_with_channel_key(packet_data, channel_key_bytes) result = try_decrypt_packet_with_channel_key(packet_data, channel_key_bytes)
if result is not None: if result is not None:
+7 -11
View File
@@ -9,7 +9,6 @@ from fastapi import APIRouter, HTTPException
from meshcore import EventType from meshcore import EventType
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.dependencies import require_connected
from app.models import ( from app.models import (
CONTACT_TYPE_REPEATER, CONTACT_TYPE_REPEATER,
ContactUpsert, ContactUpsert,
@@ -24,6 +23,7 @@ from app.models import (
from app.radio_sync import send_advertisement as do_send_advertisement from app.radio_sync import send_advertisement as do_send_advertisement
from app.radio_sync import sync_radio_time from app.radio_sync import sync_radio_time
from app.repository import ContactRepository from app.repository import ContactRepository
from app.routers.server_control import _monotonic
from app.services.contact_reconciliation import ( from app.services.contact_reconciliation import (
promote_prefix_contacts_for_contact, promote_prefix_contacts_for_contact,
reconcile_contact_messages, reconcile_contact_messages,
@@ -136,10 +136,6 @@ class RadioAdvertiseRequest(BaseModel):
) )
def _monotonic() -> float:
return time.monotonic()
def _better_signal(first: float | None, second: float | None) -> float | None: def _better_signal(first: float | None, second: float | None) -> float | None:
if first is None: if first is None:
return second return second
@@ -338,7 +334,7 @@ async def _resolve_trace_hops(
@router.get("/config", response_model=RadioConfigResponse) @router.get("/config", response_model=RadioConfigResponse)
async def get_radio_config() -> RadioConfigResponse: async def get_radio_config() -> RadioConfigResponse:
"""Get the current radio configuration.""" """Get the current radio configuration."""
mc = require_connected() mc = radio_manager.require_connected()
info = mc.self_info info = mc.self_info
if not info: if not info:
@@ -370,7 +366,7 @@ async def get_radio_config() -> RadioConfigResponse:
@router.patch("/config", response_model=RadioConfigResponse) @router.patch("/config", response_model=RadioConfigResponse)
async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse: async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
"""Update radio configuration. Only provided fields will be updated.""" """Update radio configuration. Only provided fields will be updated."""
require_connected() radio_manager.require_connected()
async with radio_manager.radio_operation("update_radio_config") as mc: async with radio_manager.radio_operation("update_radio_config") as mc:
try: try:
@@ -392,7 +388,7 @@ async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
@router.put("/private-key") @router.put("/private-key")
async def set_private_key(update: PrivateKeyUpdate) -> dict: async def set_private_key(update: PrivateKeyUpdate) -> dict:
"""Set the radio's private key. This is write-only.""" """Set the radio's private key. This is write-only."""
require_connected() radio_manager.require_connected()
try: try:
key_bytes = bytes.fromhex(update.private_key) key_bytes = bytes.fromhex(update.private_key)
@@ -426,7 +422,7 @@ async def send_advertisement(request: RadioAdvertiseRequest | None = None) -> di
Returns: Returns:
status: "ok" if sent successfully status: "ok" if sent successfully
""" """
require_connected() radio_manager.require_connected()
mode: RadioAdvertMode = request.mode if request is not None else "flood" mode: RadioAdvertMode = request.mode if request is not None else "flood"
logger.info("Sending %s advertisement", mode.replace("_", "-")) logger.info("Sending %s advertisement", mode.replace("_", "-"))
@@ -442,7 +438,7 @@ async def send_advertisement(request: RadioAdvertiseRequest | None = None) -> di
@router.post("/discover", response_model=RadioDiscoveryResponse) @router.post("/discover", response_model=RadioDiscoveryResponse)
async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryResponse: async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryResponse:
"""Run a short node-discovery sweep from the local radio.""" """Run a short node-discovery sweep from the local radio."""
require_connected() radio_manager.require_connected()
target_bits = _DISCOVERY_TARGET_BITS[request.target] target_bits = _DISCOVERY_TARGET_BITS[request.target]
tag = random.randint(1, 0xFFFFFFFF) tag = random.randint(1, 0xFFFFFFFF)
@@ -509,7 +505,7 @@ async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryRespons
@router.post("/trace", response_model=RadioTraceResponse) @router.post("/trace", response_model=RadioTraceResponse)
async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse: async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse:
"""Send a multi-hop trace loop through known repeaters and back to the local radio.""" """Send a multi-hop trace loop through known repeaters and back to the local radio."""
require_connected() radio_manager.require_connected()
trace_nodes, requested_hashes = await _resolve_trace_hops(request.hops, request.hop_hash_bytes) trace_nodes, requested_hashes = await _resolve_trace_hops(request.hops, request.hop_hash_bytes)
tag = random.randint(1, 0xFFFFFFFF) tag = random.randint(1, 0xFFFFFFFF)
+10 -16
View File
@@ -3,7 +3,6 @@ import time
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from app.dependencies import require_connected
from app.models import ( from app.models import (
CONTACT_TYPE_REPEATER, CONTACT_TYPE_REPEATER,
AclEntry, AclEntry,
@@ -28,7 +27,6 @@ from app.repository import ContactRepository, RepeaterTelemetryRepository
from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404 from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404
from app.routers.server_control import ( from app.routers.server_control import (
batch_cli_fetch, batch_cli_fetch,
extract_response_text,
prepare_authenticated_contact_connection, prepare_authenticated_contact_connection,
require_server_capable_contact, require_server_capable_contact,
send_contact_cli_command, send_contact_cli_command,
@@ -48,10 +46,6 @@ router = APIRouter(prefix="/contacts", tags=["repeaters"])
REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS = 5.0 REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS = 5.0
def _extract_response_text(event) -> str:
return extract_response_text(event)
async def prepare_repeater_connection(mc, contact: Contact, password: str) -> RepeaterLoginResponse: async def prepare_repeater_connection(mc, contact: Contact, password: str) -> RepeaterLoginResponse:
return await prepare_authenticated_contact_connection( return await prepare_authenticated_contact_connection(
mc, mc,
@@ -80,7 +74,7 @@ def _require_repeater(contact: Contact) -> None:
@router.post("/{public_key}/repeater/login", response_model=RepeaterLoginResponse) @router.post("/{public_key}/repeater/login", response_model=RepeaterLoginResponse)
async def repeater_login(public_key: str, request: RepeaterLoginRequest) -> RepeaterLoginResponse: async def repeater_login(public_key: str, request: RepeaterLoginRequest) -> RepeaterLoginResponse:
"""Attempt repeater login and report whether auth was confirmed.""" """Attempt repeater login and report whether auth was confirmed."""
require_connected() radio_manager.require_connected()
contact = await _resolve_contact_or_404(public_key) contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact) _require_repeater(contact)
@@ -95,7 +89,7 @@ async def repeater_login(public_key: str, request: RepeaterLoginRequest) -> Repe
@router.post("/{public_key}/repeater/status", response_model=RepeaterStatusResponse) @router.post("/{public_key}/repeater/status", response_model=RepeaterStatusResponse)
async def repeater_status(public_key: str) -> RepeaterStatusResponse: async def repeater_status(public_key: str) -> RepeaterStatusResponse:
"""Fetch status telemetry from a repeater (single attempt, 10s timeout).""" """Fetch status telemetry from a repeater (single attempt, 10s timeout)."""
require_connected() radio_manager.require_connected()
contact = await _resolve_contact_or_404(public_key) contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact) _require_repeater(contact)
@@ -170,7 +164,7 @@ async def repeater_telemetry_history(public_key: str) -> list[TelemetryHistoryEn
@router.post("/{public_key}/repeater/lpp-telemetry", response_model=RepeaterLppTelemetryResponse) @router.post("/{public_key}/repeater/lpp-telemetry", response_model=RepeaterLppTelemetryResponse)
async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse: async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
"""Fetch CayenneLPP sensor telemetry from a repeater (single attempt, 10s timeout).""" """Fetch CayenneLPP sensor telemetry from a repeater (single attempt, 10s timeout)."""
require_connected() radio_manager.require_connected()
contact = await _resolve_contact_or_404(public_key) contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact) _require_repeater(contact)
@@ -199,7 +193,7 @@ async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryRespons
@router.post("/{public_key}/repeater/neighbors", response_model=RepeaterNeighborsResponse) @router.post("/{public_key}/repeater/neighbors", response_model=RepeaterNeighborsResponse)
async def repeater_neighbors(public_key: str) -> RepeaterNeighborsResponse: async def repeater_neighbors(public_key: str) -> RepeaterNeighborsResponse:
"""Fetch neighbors from a repeater (single attempt, 10s timeout).""" """Fetch neighbors from a repeater (single attempt, 10s timeout)."""
require_connected() radio_manager.require_connected()
contact = await _resolve_contact_or_404(public_key) contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact) _require_repeater(contact)
@@ -233,7 +227,7 @@ async def repeater_neighbors(public_key: str) -> RepeaterNeighborsResponse:
@router.post("/{public_key}/repeater/acl", response_model=RepeaterAclResponse) @router.post("/{public_key}/repeater/acl", response_model=RepeaterAclResponse)
async def repeater_acl(public_key: str) -> RepeaterAclResponse: async def repeater_acl(public_key: str) -> RepeaterAclResponse:
"""Fetch ACL from a repeater (single attempt, 10s timeout).""" """Fetch ACL from a repeater (single attempt, 10s timeout)."""
require_connected() radio_manager.require_connected()
contact = await _resolve_contact_or_404(public_key) contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact) _require_repeater(contact)
@@ -274,7 +268,7 @@ async def _batch_cli_fetch(
@router.post("/{public_key}/repeater/node-info", response_model=RepeaterNodeInfoResponse) @router.post("/{public_key}/repeater/node-info", response_model=RepeaterNodeInfoResponse)
async def repeater_node_info(public_key: str) -> RepeaterNodeInfoResponse: async def repeater_node_info(public_key: str) -> RepeaterNodeInfoResponse:
"""Fetch repeater identity/location info via a small CLI batch.""" """Fetch repeater identity/location info via a small CLI batch."""
require_connected() radio_manager.require_connected()
contact = await _resolve_contact_or_404(public_key) contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact) _require_repeater(contact)
@@ -294,7 +288,7 @@ async def repeater_node_info(public_key: str) -> RepeaterNodeInfoResponse:
@router.post("/{public_key}/repeater/radio-settings", response_model=RepeaterRadioSettingsResponse) @router.post("/{public_key}/repeater/radio-settings", response_model=RepeaterRadioSettingsResponse)
async def repeater_radio_settings(public_key: str) -> RepeaterRadioSettingsResponse: async def repeater_radio_settings(public_key: str) -> RepeaterRadioSettingsResponse:
"""Fetch radio settings from a repeater via radio/config CLI commands.""" """Fetch radio settings from a repeater via radio/config CLI commands."""
require_connected() radio_manager.require_connected()
contact = await _resolve_contact_or_404(public_key) contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact) _require_repeater(contact)
@@ -318,7 +312,7 @@ async def repeater_radio_settings(public_key: str) -> RepeaterRadioSettingsRespo
) )
async def repeater_advert_intervals(public_key: str) -> RepeaterAdvertIntervalsResponse: async def repeater_advert_intervals(public_key: str) -> RepeaterAdvertIntervalsResponse:
"""Fetch advertisement intervals from a repeater via CLI commands.""" """Fetch advertisement intervals from a repeater via CLI commands."""
require_connected() radio_manager.require_connected()
contact = await _resolve_contact_or_404(public_key) contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact) _require_repeater(contact)
@@ -336,7 +330,7 @@ async def repeater_advert_intervals(public_key: str) -> RepeaterAdvertIntervalsR
@router.post("/{public_key}/repeater/owner-info", response_model=RepeaterOwnerInfoResponse) @router.post("/{public_key}/repeater/owner-info", response_model=RepeaterOwnerInfoResponse)
async def repeater_owner_info(public_key: str) -> RepeaterOwnerInfoResponse: async def repeater_owner_info(public_key: str) -> RepeaterOwnerInfoResponse:
"""Fetch owner info and guest password from a repeater via CLI commands.""" """Fetch owner info and guest password from a repeater via CLI commands."""
require_connected() radio_manager.require_connected()
contact = await _resolve_contact_or_404(public_key) contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact) _require_repeater(contact)
@@ -354,7 +348,7 @@ async def repeater_owner_info(public_key: str) -> RepeaterOwnerInfoResponse:
@router.post("/{public_key}/command", response_model=CommandResponse) @router.post("/{public_key}/command", response_model=CommandResponse)
async def send_repeater_command(public_key: str, request: CommandRequest) -> CommandResponse: async def send_repeater_command(public_key: str, request: CommandRequest) -> CommandResponse:
"""Send a CLI command to a repeater or room server.""" """Send a CLI command to a repeater or room server."""
require_connected() radio_manager.require_connected()
contact = await _resolve_contact_or_404(public_key) contact = await _resolve_contact_or_404(public_key)
require_server_capable_contact(contact) require_server_capable_contact(contact)
+4 -5
View File
@@ -1,6 +1,5 @@
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from app.dependencies import require_connected
from app.models import ( from app.models import (
CONTACT_TYPE_ROOM, CONTACT_TYPE_ROOM,
AclEntry, AclEntry,
@@ -28,7 +27,7 @@ def _require_room(contact) -> None:
@router.post("/{public_key}/room/login", response_model=RepeaterLoginResponse) @router.post("/{public_key}/room/login", response_model=RepeaterLoginResponse)
async def room_login(public_key: str, request: RepeaterLoginRequest) -> RepeaterLoginResponse: async def room_login(public_key: str, request: RepeaterLoginRequest) -> RepeaterLoginResponse:
"""Attempt room-server login and report whether auth was confirmed.""" """Attempt room-server login and report whether auth was confirmed."""
require_connected() radio_manager.require_connected()
contact = await _resolve_contact_or_404(public_key) contact = await _resolve_contact_or_404(public_key)
_require_room(contact) _require_room(contact)
@@ -48,7 +47,7 @@ async def room_login(public_key: str, request: RepeaterLoginRequest) -> Repeater
@router.post("/{public_key}/room/status", response_model=RepeaterStatusResponse) @router.post("/{public_key}/room/status", response_model=RepeaterStatusResponse)
async def room_status(public_key: str) -> RepeaterStatusResponse: async def room_status(public_key: str) -> RepeaterStatusResponse:
"""Fetch status telemetry from a room server.""" """Fetch status telemetry from a room server."""
require_connected() radio_manager.require_connected()
contact = await _resolve_contact_or_404(public_key) contact = await _resolve_contact_or_404(public_key)
_require_room(contact) _require_room(contact)
@@ -85,7 +84,7 @@ async def room_status(public_key: str) -> RepeaterStatusResponse:
@router.post("/{public_key}/room/lpp-telemetry", response_model=RepeaterLppTelemetryResponse) @router.post("/{public_key}/room/lpp-telemetry", response_model=RepeaterLppTelemetryResponse)
async def room_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse: async def room_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
"""Fetch CayenneLPP telemetry from a room server.""" """Fetch CayenneLPP telemetry from a room server."""
require_connected() radio_manager.require_connected()
contact = await _resolve_contact_or_404(public_key) contact = await _resolve_contact_or_404(public_key)
_require_room(contact) _require_room(contact)
@@ -114,7 +113,7 @@ async def room_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
@router.post("/{public_key}/room/acl", response_model=RepeaterAclResponse) @router.post("/{public_key}/room/acl", response_model=RepeaterAclResponse)
async def room_acl(public_key: str) -> RepeaterAclResponse: async def room_acl(public_key: str) -> RepeaterAclResponse:
"""Fetch ACL entries from a room server.""" """Fetch ACL entries from a room server."""
require_connected() radio_manager.require_connected()
contact = await _resolve_contact_or_404(public_key) contact = await _resolve_contact_or_404(public_key)
_require_room(contact) _require_room(contact)
+18 -11
View File
@@ -230,20 +230,27 @@ async def batch_cli_fetch(
operation_name: str, operation_name: str,
commands: list[tuple[str, str]], commands: list[tuple[str, str]],
) -> dict[str, str | None]: ) -> dict[str, str | None]:
"""Send a batch of CLI commands to a server-capable contact and collect responses.""" """Send a batch of CLI commands to a server-capable contact and collect responses.
Each command acquires and releases the radio lock independently so that
other operations (sends, syncs) can slip in between commands.
"""
results: dict[str, str | None] = {field: None for _, field in commands} results: dict[str, str | None] = {field: None for _, field in commands}
async with radio_manager.radio_operation( for index, (cmd, field) in enumerate(commands):
operation_name, if index > 0:
pause_polling=True, # Yield briefly so queued operations can acquire the lock.
suspend_auto_fetch=True, await asyncio.sleep(0.25)
) as mc:
await _ensure_on_radio(mc, contact)
await asyncio.sleep(1.0)
for index, (cmd, field) in enumerate(commands): async with radio_manager.radio_operation(
if index > 0: operation_name,
await asyncio.sleep(1.0) pause_polling=True,
suspend_auto_fetch=True,
) as mc:
# Re-ensure contact is loaded each iteration; another operation
# may have evicted it while we didn't hold the lock.
await _ensure_on_radio(mc, contact)
await asyncio.sleep(1.0) # settle after add_contact
send_result = await mc.commands.send_cmd(contact.public_key, cmd) send_result = await mc.commands.send_cmd(contact.public_key, cmd)
if send_result.type == EventType.ERROR: if send_result.type == EventType.ERROR:
+68 -58
View File
@@ -2,16 +2,18 @@ import asyncio
import logging import logging
from typing import Literal from typing import Literal
from fastapi import APIRouter from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.models import AppSettings from app.models import CONTACT_TYPE_REPEATER, AppSettings
from app.region_scope import normalize_region_scope from app.region_scope import normalize_region_scope
from app.repository import AppSettingsRepository from app.repository import AppSettingsRepository, ContactRepository
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/settings", tags=["settings"]) router = APIRouter(prefix="/settings", tags=["settings"])
MAX_TRACKED_TELEMETRY_REPEATERS = 8
class AppSettingsUpdate(BaseModel): class AppSettingsUpdate(BaseModel):
max_radio_contacts: int | None = Field( max_radio_contacts: int | None = Field(
@@ -27,10 +29,6 @@ class AppSettingsUpdate(BaseModel):
default=None, default=None,
description="Whether to attempt historical DM decryption on new contact advertisement", description="Whether to attempt historical DM decryption on new contact advertisement",
) )
sidebar_sort_order: Literal["recent", "alpha"] | None = Field(
default=None,
description="Sidebar sort order: 'recent' or 'alpha'",
)
advert_interval: int | None = Field( advert_interval: int | None = Field(
default=None, default=None,
ge=0, ge=0,
@@ -55,6 +53,10 @@ class AppSettingsUpdate(BaseModel):
"advertisements should not create new contacts" "advertisements should not create new contacts"
), ),
) )
auto_resend_channel: bool | None = Field(
default=None,
description="Auto-resend channel messages once if no echo heard within 2 seconds",
)
class BlockKeyRequest(BaseModel): class BlockKeyRequest(BaseModel):
@@ -70,24 +72,17 @@ class FavoriteRequest(BaseModel):
id: str = Field(description="Channel key or contact public key") id: str = Field(description="Channel key or contact public key")
class MigratePreferencesRequest(BaseModel): class TrackedTelemetryRequest(BaseModel):
favorites: list[FavoriteRequest] = Field( public_key: str = Field(description="Public key of the repeater to toggle tracking")
default_factory=list,
description="List of favorites from localStorage",
)
sort_order: str = Field(
default="recent",
description="Sort order preference from localStorage",
)
last_message_times: dict[str, int] = Field(
default_factory=dict,
description="Map of conversation state keys to timestamps from localStorage",
)
class MigratePreferencesResponse(BaseModel): class TrackedTelemetryResponse(BaseModel):
migrated: bool = Field(description="Whether migration occurred (false if already migrated)") tracked_telemetry_repeaters: list[str] = Field(
settings: AppSettings = Field(description="Current settings after migration attempt") description="Current list of tracked repeater public keys"
)
names: dict[str, str] = Field(
description="Map of public key to display name for tracked repeaters"
)
@router.get("", response_model=AppSettings) @router.get("", response_model=AppSettings)
@@ -111,10 +106,6 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
logger.info("Updating auto_decrypt_dm_on_advert to %s", update.auto_decrypt_dm_on_advert) logger.info("Updating auto_decrypt_dm_on_advert to %s", update.auto_decrypt_dm_on_advert)
kwargs["auto_decrypt_dm_on_advert"] = update.auto_decrypt_dm_on_advert kwargs["auto_decrypt_dm_on_advert"] = update.auto_decrypt_dm_on_advert
if update.sidebar_sort_order is not None:
logger.info("Updating sidebar_sort_order to %s", update.sidebar_sort_order)
kwargs["sidebar_sort_order"] = update.sidebar_sort_order
if update.advert_interval is not None: if update.advert_interval is not None:
# Enforce minimum 1-hour interval; 0 means disabled # Enforce minimum 1-hour interval; 0 means disabled
interval = update.advert_interval interval = update.advert_interval
@@ -135,6 +126,10 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
valid = [t for t in update.discovery_blocked_types if t in (1, 2, 3, 4)] valid = [t for t in update.discovery_blocked_types if t in (1, 2, 3, 4)]
kwargs["discovery_blocked_types"] = sorted(set(valid)) kwargs["discovery_blocked_types"] = sorted(set(valid))
# Auto-resend channel
if update.auto_resend_channel is not None:
kwargs["auto_resend_channel"] = update.auto_resend_channel
# Flood scope # Flood scope
flood_scope_changed = False flood_scope_changed = False
if update.flood_scope is not None: if update.flood_scope is not None:
@@ -199,41 +194,56 @@ async def toggle_blocked_name(request: BlockNameRequest) -> AppSettings:
return await AppSettingsRepository.toggle_blocked_name(request.name) return await AppSettingsRepository.toggle_blocked_name(request.name)
@router.post("/migrate", response_model=MigratePreferencesResponse) @router.post("/tracked-telemetry/toggle", response_model=TrackedTelemetryResponse)
async def migrate_preferences(request: MigratePreferencesRequest) -> MigratePreferencesResponse: async def toggle_tracked_telemetry(request: TrackedTelemetryRequest) -> TrackedTelemetryResponse:
"""Migrate all preferences from frontend localStorage to database. """Toggle periodic telemetry collection for a repeater.
This is a one-time migration. If preferences have already been migrated, Max 8 repeaters may be tracked. Returns 409 if the limit is reached and
this endpoint will not overwrite them and will return migrated=false. the requested repeater is not already tracked.
Call this on frontend startup to ensure preferences are moved to the database.
After successful migration, the frontend should clear localStorage preferences.
Migrates:
- favorites (remoteterm-favorites)
- sort_order (remoteterm-sortOrder)
- last_message_times (remoteterm-lastMessageTime)
""" """
# Convert to dict format for the repository method key = request.public_key.lower()
frontend_favorites = [{"type": f.type, "id": f.id} for f in request.favorites] settings = await AppSettingsRepository.get()
current = settings.tracked_telemetry_repeaters
settings, did_migrate = await AppSettingsRepository.migrate_preferences_from_frontend( async def _resolve_names(keys: list[str]) -> dict[str, str]:
favorites=frontend_favorites, names: dict[str, str] = {}
sort_order=request.sort_order, for k in keys:
last_message_times=request.last_message_times, contact = await ContactRepository.get_by_key(k)
) names[k] = contact.name if contact and contact.name else k[:12]
return names
if did_migrate: if key in current:
logger.info( # Remove
"Migrated preferences from frontend: %d favorites, sort_order=%s, %d message times", new_list = [k for k in current if k != key]
len(frontend_favorites), logger.info("Removing repeater %s from tracked telemetry", key[:12])
request.sort_order, await AppSettingsRepository.update(tracked_telemetry_repeaters=new_list)
len(request.last_message_times), return TrackedTelemetryResponse(
tracked_telemetry_repeaters=new_list,
names=await _resolve_names(new_list),
) )
else:
logger.debug("Preferences already migrated, skipping")
return MigratePreferencesResponse( # Validate it's a repeater
migrated=did_migrate, contact = await ContactRepository.get_by_key(key)
settings=settings, if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
if contact.type != CONTACT_TYPE_REPEATER:
raise HTTPException(status_code=400, detail="Contact is not a repeater")
if len(current) >= MAX_TRACKED_TELEMETRY_REPEATERS:
names = await _resolve_names(current)
raise HTTPException(
status_code=409,
detail={
"message": f"Limit of {MAX_TRACKED_TELEMETRY_REPEATERS} tracked repeaters reached",
"tracked_telemetry_repeaters": current,
"names": names,
},
)
new_list = current + [key]
logger.info("Adding repeater %s to tracked telemetry", key[:12])
await AppSettingsRepository.update(tracked_telemetry_repeaters=new_list)
return TrackedTelemetryResponse(
tracked_telemetry_repeaters=new_list,
names=await _resolve_names(new_list),
) )
+1 -6
View File
@@ -1,12 +1,7 @@
"""Shared direct-message ACK application logic.""" """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 import dm_ack_tracker
from app.services.messages import increment_ack_and_broadcast from app.services.messages import BroadcastFn, increment_ack_and_broadcast
BroadcastFn = Callable[..., Any]
async def apply_dm_ack_code(ack_code: str, *, broadcast_fn: BroadcastFn) -> bool: async def apply_dm_ack_code(ack_code: str, *, broadcast_fn: BroadcastFn) -> bool:
+17 -5
View File
@@ -1,9 +1,8 @@
import asyncio import asyncio
import logging import logging
import time import time
from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING
from app.models import CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM, Contact, ContactUpsert, Message from app.models import CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM, Contact, ContactUpsert, Message
from app.repository import ( from app.repository import (
@@ -14,6 +13,7 @@ from app.repository import (
) )
from app.services.contact_reconciliation import claim_prefix_messages_for_contact from app.services.contact_reconciliation import claim_prefix_messages_for_contact
from app.services.messages import ( from app.services.messages import (
BroadcastFn,
broadcast_message, broadcast_message,
build_message_model, build_message_model,
build_message_paths, build_message_paths,
@@ -27,8 +27,6 @@ if TYPE_CHECKING:
from app.decoder import DecryptedDirectMessage from app.decoder import DecryptedDirectMessage
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
BroadcastFn = Callable[..., Any]
_decrypted_dm_store_lock = asyncio.Lock() _decrypted_dm_store_lock = asyncio.Lock()
@@ -144,6 +142,8 @@ async def _store_direct_message(
received_at: int, received_at: int,
path: str | None, path: str | None,
path_len: int | None, path_len: int | None,
rssi: int | None = None,
snr: float | None = None,
outgoing: bool, outgoing: bool,
txt_type: int, txt_type: int,
signature: str | None, signature: str | None,
@@ -170,6 +170,8 @@ async def _store_direct_message(
path=path, path=path,
received_at=received_at, received_at=received_at,
path_len=path_len, path_len=path_len,
rssi=rssi,
snr=snr,
broadcast_fn=broadcast_fn, broadcast_fn=broadcast_fn,
) )
return None return None
@@ -189,6 +191,8 @@ async def _store_direct_message(
path=path, path=path,
received_at=received_at, received_at=received_at,
path_len=path_len, path_len=path_len,
rssi=rssi,
snr=snr,
broadcast_fn=broadcast_fn, broadcast_fn=broadcast_fn,
) )
return None return None
@@ -201,6 +205,8 @@ async def _store_direct_message(
received_at=received_at, received_at=received_at,
path=path, path=path,
path_len=path_len, path_len=path_len,
rssi=rssi,
snr=snr,
txt_type=txt_type, txt_type=txt_type,
signature=signature, signature=signature,
outgoing=outgoing, outgoing=outgoing,
@@ -218,6 +224,8 @@ async def _store_direct_message(
path=path, path=path,
received_at=received_at, received_at=received_at,
path_len=path_len, path_len=path_len,
rssi=rssi,
snr=snr,
broadcast_fn=broadcast_fn, broadcast_fn=broadcast_fn,
) )
return None return None
@@ -232,7 +240,7 @@ async def _store_direct_message(
text=text, text=text,
sender_timestamp=sender_timestamp, sender_timestamp=sender_timestamp,
received_at=received_at, received_at=received_at,
paths=build_message_paths(path, received_at, path_len), paths=build_message_paths(path, received_at, path_len, rssi=rssi, snr=snr),
txt_type=txt_type, txt_type=txt_type,
signature=signature, signature=signature,
sender_key=sender_key, sender_key=sender_key,
@@ -261,6 +269,8 @@ async def ingest_decrypted_direct_message(
received_at: int | None = None, received_at: int | None = None,
path: str | None = None, path: str | None = None,
path_len: int | None = None, path_len: int | None = None,
rssi: int | None = None,
snr: float | None = None,
outgoing: bool = False, outgoing: bool = False,
realtime: bool = True, realtime: bool = True,
broadcast_fn: BroadcastFn, broadcast_fn: BroadcastFn,
@@ -311,6 +321,8 @@ async def ingest_decrypted_direct_message(
received_at=received, received_at=received,
path=path, path=path,
path_len=path_len, path_len=path_len,
rssi=rssi,
snr=snr,
outgoing=outgoing, outgoing=outgoing,
txt_type=decrypted.txt_type, txt_type=decrypted.txt_type,
signature=signature, signature=signature,
+184 -4
View File
@@ -2,6 +2,7 @@
import asyncio import asyncio
import logging import logging
import time as _time
from collections.abc import Callable from collections.abc import Callable
from typing import Any from typing import Any
@@ -9,10 +10,17 @@ from fastapi import HTTPException
from meshcore import EventType from meshcore import EventType
from app.models import ResendChannelMessageResponse from app.models import ResendChannelMessageResponse
from app.radio import RadioOperationBusyError
from app.region_scope import normalize_region_scope from app.region_scope import normalize_region_scope
from app.repository import AppSettingsRepository, ContactRepository, MessageRepository from app.repository import (
AppSettingsRepository,
ChannelRepository,
ContactRepository,
MessageRepository,
)
from app.services import dm_ack_tracker from app.services import dm_ack_tracker
from app.services.messages import ( from app.services.messages import (
BroadcastFn,
broadcast_message, broadcast_message,
build_stored_outgoing_channel_message, build_stored_outgoing_channel_message,
create_outgoing_channel_message, create_outgoing_channel_message,
@@ -26,13 +34,20 @@ NO_RADIO_RESPONSE_AFTER_SEND_DETAIL = (
"Send command was issued to the radio, but no response was heard back. " "Send command was issued to the radio, but no response was heard back. "
"The message may or may not have sent successfully." "The message may or may not have sent successfully."
) )
BroadcastFn = Callable[..., Any]
TrackAckFn = Callable[[str, int, int], bool] TrackAckFn = Callable[[str, int, int], bool]
NowFn = Callable[[], float] NowFn = Callable[[], float]
OutgoingReservationKey = tuple[str, str, str] OutgoingReservationKey = tuple[str, str, str]
RetryTaskScheduler = Callable[[Any], Any] RetryTaskScheduler = Callable[[Any], Any]
# Channel echo watchdog: delay before checking for echoes
ECHO_WATCHDOG_DELAY_SECONDS = 2.0
# Byte-perfect resend window (must match router's RESEND_WINDOW_SECONDS)
RESEND_WINDOW_SECONDS = 30
# Temp radio slot used by the router for channel sends
WATCHDOG_TEMP_RADIO_SLOT = 0
_pending_outgoing_timestamp_reservations: dict[OutgoingReservationKey, set[int]] = {} _pending_outgoing_timestamp_reservations: dict[OutgoingReservationKey, set[int]] = {}
_outgoing_timestamp_reservations_lock = asyncio.Lock() _outgoing_timestamp_reservations_lock = asyncio.Lock()
@@ -122,7 +137,7 @@ async def send_channel_message_with_effective_scope(
error_broadcast_fn: BroadcastFn, error_broadcast_fn: BroadcastFn,
app_settings_repository=AppSettingsRepository, app_settings_repository=AppSettingsRepository,
) -> Any: ) -> Any:
"""Send a channel message, temporarily overriding flood scope when configured.""" """Send a channel message, temporarily overriding flood scope and/or path hash mode."""
override_scope = normalize_region_scope(channel.flood_scope_override) override_scope = normalize_region_scope(channel.flood_scope_override)
baseline_scope = "" baseline_scope = ""
@@ -151,6 +166,36 @@ async def send_channel_message_with_effective_scope(
), ),
) )
# Path hash mode per-channel override
override_phm = channel.path_hash_mode_override
baseline_phm = radio_manager.path_hash_mode
apply_phm = (
override_phm is not None
and radio_manager.path_hash_mode_supported
and override_phm != baseline_phm
)
if apply_phm:
logger.info(
"Temporarily applying channel path_hash_mode override for %s: %d",
channel.name,
override_phm,
)
phm_result = await mc.commands.set_path_hash_mode(override_phm)
if phm_result is not None and phm_result.type == EventType.ERROR:
logger.warning(
"Failed to apply channel path_hash_mode override for %s: %s",
channel.name,
phm_result.payload,
)
raise HTTPException(
status_code=500,
detail=(
f"Failed to apply path hash mode override before {action_label}: "
f"{phm_result.payload}"
),
)
try: try:
channel_slot, needs_configure, evicted_channel_key = radio_manager.plan_channel_send_slot( channel_slot, needs_configure, evicted_channel_key = radio_manager.plan_channel_send_slot(
channel_key, channel_key,
@@ -254,6 +299,46 @@ async def send_channel_message_with_effective_scope(
), ),
) )
if apply_phm:
restored = False
for attempt in range(3):
try:
restore_phm = await mc.commands.set_path_hash_mode(baseline_phm)
if restore_phm is not None and restore_phm.type == EventType.ERROR:
logger.warning(
"Attempt %d/3: failed to restore path_hash_mode after sending to %s: %s",
attempt + 1,
channel.name,
restore_phm.payload,
)
else:
radio_manager.path_hash_mode = baseline_phm
logger.debug(
"Restored baseline path_hash_mode after channel send: %d",
baseline_phm,
)
restored = True
break
except Exception:
logger.exception(
"Attempt %d/3: exception restoring path_hash_mode after sending to %s",
attempt + 1,
channel.name,
)
if not restored:
logger.error(
"All 3 attempts to restore path_hash_mode failed for %s",
channel.name,
)
error_broadcast_fn(
"Path hash mode restore failed",
(
f"Sent to {channel.name}, but restoring path hash mode failed "
f"after 3 attempts. The radio is still using a non-default hop "
f"width. Set it back manually in Radio settings."
),
)
def _extract_expected_ack_code(result: Any) -> str | None: def _extract_expected_ack_code(result: Any) -> str | None:
if result is None or result.type == EventType.ERROR: if result is None or result.type == EventType.ERROR:
@@ -550,6 +635,85 @@ async def send_direct_message_to_contact(
return message return message
async def _channel_echo_watchdog(
message_id: int,
radio_manager,
broadcast_fn: BroadcastFn,
error_broadcast_fn: BroadcastFn,
) -> None:
"""One-shot watchdog: if no echo heard after delay, attempt one byte-perfect resend.
Spawned as a fire-and-forget task after a channel send when auto_resend_channel is enabled.
Uses non-blocking radio lock so it never stalls user actions.
"""
try:
await asyncio.sleep(ECHO_WATCHDOG_DELAY_SECONDS)
msg = await MessageRepository.get_by_id(message_id)
if not msg:
return
if msg.acked > 0:
logger.debug(
"Echo watchdog: message %d already has %d echo(s), skipping", message_id, msg.acked
)
return
if msg.sender_timestamp is None:
return
elapsed = int(_time.time()) - msg.sender_timestamp
if elapsed > RESEND_WINDOW_SECONDS:
logger.debug(
"Echo watchdog: message %d outside resend window (%ds)", message_id, elapsed
)
return
channel = await ChannelRepository.get_by_key(msg.conversation_key)
if not channel:
return
logger.info(
"Echo watchdog: no echo for message %d after %.0fs, attempting byte-perfect resend",
message_id,
ECHO_WATCHDOG_DELAY_SECONDS,
)
try:
key_bytes = bytes.fromhex(msg.conversation_key)
except ValueError:
return
timestamp_bytes = msg.sender_timestamp.to_bytes(4, "little")
# Strip sender name prefix to get the raw text for the radio
async with radio_manager.radio_operation("echo_watchdog_resend", blocking=False) as mc:
radio_name = mc.self_info.get("name", "") if mc.self_info else ""
text_to_send = msg.text
if radio_name and text_to_send.startswith(f"{radio_name}: "):
text_to_send = text_to_send[len(f"{radio_name}: ") :]
result = await send_channel_message_with_effective_scope(
mc=mc,
channel=channel,
channel_key=msg.conversation_key,
key_bytes=key_bytes,
text=text_to_send,
timestamp_bytes=timestamp_bytes,
action_label="echo watchdog resend",
radio_manager=radio_manager,
temp_radio_slot=WATCHDOG_TEMP_RADIO_SLOT,
error_broadcast_fn=error_broadcast_fn,
)
if result is not None and result.type != EventType.ERROR:
logger.info("Echo watchdog: resent message %d successfully", message_id)
else:
logger.debug("Echo watchdog: resend got no/error result for message %d", message_id)
except RadioOperationBusyError:
logger.debug("Echo watchdog: radio busy, skipping resend for message %d", message_id)
except Exception:
logger.debug("Echo watchdog: resend failed for message %d", message_id, exc_info=True)
async def send_channel_message_to_channel( async def send_channel_message_to_channel(
*, *,
channel, channel,
@@ -658,6 +822,22 @@ async def send_channel_message_to_channel(
message_repository=message_repository, message_repository=message_repository,
) )
broadcast_message(message=outgoing_message, broadcast_fn=broadcast_fn) broadcast_message(message=outgoing_message, broadcast_fn=broadcast_fn)
# Spawn echo watchdog if auto-resend is enabled
try:
settings = await AppSettingsRepository.get()
if settings.auto_resend_channel:
asyncio.create_task(
_channel_echo_watchdog(
message_id=outgoing_message.id,
radio_manager=radio_manager,
broadcast_fn=broadcast_fn,
error_broadcast_fn=error_broadcast_fn,
)
)
except Exception:
pass # Never let watchdog setup failure break the send
return outgoing_message return outgoing_message
+27 -3
View File
@@ -37,10 +37,16 @@ def build_message_paths(
path: str | None, path: str | None,
received_at: int, received_at: int,
path_len: int | None = None, path_len: int | None = None,
rssi: int | None = None,
snr: float | None = None,
) -> list[MessagePath] | None: ) -> list[MessagePath] | None:
"""Build the single-path list used by message payloads.""" """Build the single-path list used by message payloads."""
return ( return (
[MessagePath(path=path or "", received_at=received_at, path_len=path_len)] [
MessagePath(
path=path or "", received_at=received_at, path_len=path_len, rssi=rssi, snr=snr
)
]
if path is not None if path is not None
else None else None
) )
@@ -166,6 +172,8 @@ async def reconcile_duplicate_message(
path: str | None, path: str | None,
received_at: int, received_at: int,
path_len: int | None, path_len: int | None,
rssi: int | None = None,
snr: float | None = None,
broadcast_fn: BroadcastFn, broadcast_fn: BroadcastFn,
) -> None: ) -> None:
logger.debug( logger.debug(
@@ -177,7 +185,9 @@ async def reconcile_duplicate_message(
) )
if path is not None: if path is not None:
paths = await MessageRepository.add_path(existing_msg.id, path, received_at, path_len) paths = await MessageRepository.add_path(
existing_msg.id, path, received_at, path_len, rssi=rssi, snr=snr
)
else: else:
paths = existing_msg.paths or [] paths = existing_msg.paths or []
@@ -214,6 +224,8 @@ async def handle_duplicate_message(
path: str | None, path: str | None,
received_at: int, received_at: int,
path_len: int | None = None, path_len: int | None = None,
rssi: int | None = None,
snr: float | None = None,
broadcast_fn: BroadcastFn, broadcast_fn: BroadcastFn,
) -> None: ) -> None:
"""Handle a duplicate message by updating paths/acks on the existing record.""" """Handle a duplicate message by updating paths/acks on the existing record."""
@@ -239,6 +251,8 @@ async def handle_duplicate_message(
path=path, path=path,
received_at=received_at, received_at=received_at,
path_len=path_len, path_len=path_len,
rssi=rssi,
snr=snr,
broadcast_fn=broadcast_fn, broadcast_fn=broadcast_fn,
) )
@@ -253,6 +267,8 @@ async def create_message_from_decrypted(
received_at: int | None = None, received_at: int | None = None,
path: str | None = None, path: str | None = None,
path_len: int | None = None, path_len: int | None = None,
rssi: int | None = None,
snr: float | None = None,
channel_name: str | None = None, channel_name: str | None = None,
realtime: bool = True, realtime: bool = True,
broadcast_fn: BroadcastFn, broadcast_fn: BroadcastFn,
@@ -276,6 +292,8 @@ async def create_message_from_decrypted(
received_at=received, received_at=received,
path=path, path=path,
path_len=path_len, path_len=path_len,
rssi=rssi,
snr=snr,
sender_name=sender, sender_name=sender,
sender_key=resolved_sender_key, sender_key=resolved_sender_key,
) )
@@ -291,6 +309,8 @@ async def create_message_from_decrypted(
path=path, path=path,
received_at=received, received_at=received,
path_len=path_len, path_len=path_len,
rssi=rssi,
snr=snr,
broadcast_fn=broadcast_fn, broadcast_fn=broadcast_fn,
) )
return None return None
@@ -312,7 +332,7 @@ async def create_message_from_decrypted(
text=text, text=text,
sender_timestamp=timestamp, sender_timestamp=timestamp,
received_at=received, received_at=received,
paths=build_message_paths(path, received, path_len), paths=build_message_paths(path, received, path_len, rssi=rssi, snr=snr),
sender_name=sender, sender_name=sender,
sender_key=resolved_sender_key, sender_key=resolved_sender_key,
channel_name=channel_name, channel_name=channel_name,
@@ -334,6 +354,8 @@ async def create_dm_message_from_decrypted(
received_at: int | None = None, received_at: int | None = None,
path: str | None = None, path: str | None = None,
path_len: int | None = None, path_len: int | None = None,
rssi: int | None = None,
snr: float | None = None,
outgoing: bool = False, outgoing: bool = False,
realtime: bool = True, realtime: bool = True,
broadcast_fn: BroadcastFn, broadcast_fn: BroadcastFn,
@@ -348,6 +370,8 @@ async def create_dm_message_from_decrypted(
received_at=received_at, received_at=received_at,
path=path, path=path,
path_len=path_len, path_len=path_len,
rssi=rssi,
snr=snr,
outgoing=outgoing, outgoing=outgoing,
realtime=realtime, realtime=realtime,
broadcast_fn=broadcast_fn, broadcast_fn=broadcast_fn,
+5 -1
View File
@@ -33,6 +33,7 @@ async def run_post_connect_setup(radio_manager) -> None:
start_message_polling, start_message_polling,
start_periodic_advert, start_periodic_advert,
start_periodic_sync, start_periodic_sync,
start_telemetry_collect,
sync_and_offload_all, sync_and_offload_all,
sync_radio_time, sync_radio_time,
) )
@@ -207,7 +208,9 @@ async def run_post_connect_setup(radio_manager) -> None:
from app.config import settings as app_settings_config from app.config import settings as app_settings_config
if app_settings_config.skip_post_connect_sync: if app_settings_config.skip_post_connect_sync:
logger.info("Skipping sync/offload/advert/drain (MESHCORE_SKIP_POST_CONNECT_SYNC)") logger.info(
"Skipping sync/offload/advert/drain (MESHCORE_SKIP_POST_CONNECT_SYNC)"
)
else: else:
# Sync contacts/channels from radio to DB and clear radio # Sync contacts/channels from radio to DB and clear radio
logger.info("Syncing and offloading radio data...") logger.info("Syncing and offloading radio data...")
@@ -239,6 +242,7 @@ async def run_post_connect_setup(radio_manager) -> None:
start_periodic_sync() start_periodic_sync()
start_periodic_advert() start_periodic_advert()
start_message_polling() start_message_polling()
start_telemetry_collect()
radio_manager._setup_complete = True radio_manager._setup_complete = True
finally: finally:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 109 KiB

+13 -3
View File
@@ -350,15 +350,13 @@ LocalStorage migration helpers for favorites; canonical favorites are server-sid
- `max_radio_contacts` - `max_radio_contacts`
- `favorites` - `favorites`
- `auto_decrypt_dm_on_advert` - `auto_decrypt_dm_on_advert`
- `sidebar_sort_order`
- `last_message_times` - `last_message_times`
- `preferences_migrated` - `preferences_migrated`
- `advert_interval` - `advert_interval`
- `last_advert_time` - `last_advert_time`
- `flood_scope` - `flood_scope`
- `blocked_keys`, `blocked_names` - `blocked_keys`, `blocked_names`, `discovery_blocked_types`
The backend still carries `sidebar_sort_order` for compatibility and old preference migration, but the current sidebar UI stores sort order per section (`Channels`, `Contacts`, `Repeaters`) in frontend localStorage rather than treating it as one global server-backed setting.
Note: MQTT, bot, and community MQTT settings were migrated to the `fanout_configs` table (managed via `/api/fanout`). They are no longer part of `AppSettings`. Note: MQTT, bot, and community MQTT settings were migrated to the `fanout_configs` table (managed via `/api/fanout`). They are no longer part of `AppSettings`.
@@ -436,6 +434,18 @@ The `SearchView` component (`components/SearchView.tsx`) provides full-text sear
UI styling is mostly utility-class driven (Tailwind-style classes in JSX) plus shared globals in `index.css` and `styles.css`. UI styling is mostly utility-class driven (Tailwind-style classes in JSX) plus shared globals in `index.css` and `styles.css`.
Do not rely on old class-only layout assumptions. Do not rely on old class-only layout assumptions.
### Canonical style reference
`SettingsLocalSection.tsx` contains a **ThemePreview** component with a collapsible "Canonical style reference" section. This is the authoritative catalog of text sizes, button variants, badge patterns, and interactive elements used throughout the app. **When adding or modifying UI, match the patterns shown there rather than inventing new ones.**
Key conventions documented in the reference:
- **Text sizes** use `rem`-based Tailwind values so they scale with the user's font-size slider. Do not use hard-locked `px` values (e.g., `text-[10px]`). The canonical sizes are `text-[0.625rem]` (10px), `text-[0.6875rem]` (11px), `text-[0.8125rem]` (13px), plus standard Tailwind `text-xs`/`text-sm`/`text-base`/`text-lg`/`text-xl`.
- **Section labels** use `text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium`.
- **Buttons** use the shadcn `<Button>` component. Semantic color overrides (danger, warning, success) use `variant="outline"` with `className="border-{color}/50 text-{color} hover:bg-{color}/10"`.
- **Badges/tags** use `text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded` with `bg-muted` (neutral) or `bg-primary/10` (active).
- **Clickable text** (copy-to-clipboard, navigational links) uses `role="button" tabIndex={0}` with `cursor-pointer hover:text-primary transition-colors`.
## Security Posture (intentional) ## Security Posture (intentional)
- No authentication UI. - No authentication UI.
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "remoteterm-meshcore-frontend", "name": "remoteterm-meshcore-frontend",
"private": true, "private": true,
"version": "3.7.0", "version": "3.8.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+8 -1
View File
@@ -156,6 +156,7 @@ export function App() {
handleToggleFavorite, handleToggleFavorite,
handleToggleBlockedKey, handleToggleBlockedKey,
handleToggleBlockedName, handleToggleBlockedName,
handleToggleTrackedTelemetry,
} = useAppSettings(); } = useAppSettings();
// Keep user's name in ref for mention detection in WebSocket callback // Keep user's name in ref for mention detection in WebSocket callback
@@ -397,6 +398,7 @@ export function App() {
handleSendMessage, handleSendMessage,
handleResendChannelMessage, handleResendChannelMessage,
handleSetChannelFloodScopeOverride, handleSetChannelFloodScopeOverride,
handleSetChannelPathHashModeOverride,
handleSenderClick, handleSenderClick,
handleTrace, handleTrace,
handlePathDiscovery, handlePathDiscovery,
@@ -490,7 +492,6 @@ export function App() {
void markAllRead(); void markAllRead();
}, },
favorites, favorites,
legacySortOrder: appSettings?.sidebar_sort_order,
isConversationNotificationsEnabled, isConversationNotificationsEnabled,
blockedKeys: appSettings?.blocked_keys ?? [], blockedKeys: appSettings?.blocked_keys ?? [],
blockedNames: appSettings?.blocked_names ?? [], blockedNames: appSettings?.blocked_names ?? [],
@@ -508,6 +509,7 @@ export function App() {
health, health,
favorites, favorites,
messages: sortedMessages, messages: sortedMessages,
preSorted: activeContactIsRoom,
messagesLoading, messagesLoading,
loadingOlder, loadingOlder,
hasOlderMessages, hasOlderMessages,
@@ -527,6 +529,7 @@ export function App() {
onDeleteContact: handleDeleteContact, onDeleteContact: handleDeleteContact,
onDeleteChannel: handleDeleteChannel, onDeleteChannel: handleDeleteChannel,
onSetChannelFloodScopeOverride: handleSetChannelFloodScopeOverride, onSetChannelFloodScopeOverride: handleSetChannelFloodScopeOverride,
onSetChannelPathHashModeOverride: handleSetChannelPathHashModeOverride,
onOpenContactInfo: handleOpenContactInfo, onOpenContactInfo: handleOpenContactInfo,
onOpenChannelInfo: handleOpenChannelInfo, onOpenChannelInfo: handleOpenChannelInfo,
onSenderClick: handleSenderClick, onSenderClick: handleSenderClick,
@@ -553,6 +556,8 @@ export function App() {
); );
} }
}, },
trackedTelemetryRepeaters: appSettings?.tracked_telemetry_repeaters ?? [],
onToggleTrackedTelemetry: handleToggleTrackedTelemetry,
}; };
const searchProps = { const searchProps = {
contacts, contacts,
@@ -586,6 +591,8 @@ export function App() {
const keySet = new Set(deletedKeys.map((k) => k.toLowerCase())); const keySet = new Set(deletedKeys.map((k) => k.toLowerCase()));
setContacts((prev) => prev.filter((c) => !keySet.has(c.public_key.toLowerCase()))); setContacts((prev) => prev.filter((c) => !keySet.has(c.public_key.toLowerCase())));
}, },
trackedTelemetryRepeaters: appSettings?.tracked_telemetry_repeaters ?? [],
onToggleTrackedTelemetry: handleToggleTrackedTelemetry,
}; };
const crackerProps = { const crackerProps = {
packets: rawPackets, packets: rawPackets,
+14 -9
View File
@@ -14,8 +14,6 @@ import type {
MaintenanceResult, MaintenanceResult,
Message, Message,
MessagesAroundResponse, MessagesAroundResponse,
MigratePreferencesRequest,
MigratePreferencesResponse,
RawPacket, RawPacket,
RadioAdvertMode, RadioAdvertMode,
RadioConfig, RadioConfig,
@@ -36,6 +34,7 @@ import type {
RepeaterRadioSettingsResponse, RepeaterRadioSettingsResponse,
RepeaterStatusResponse, RepeaterStatusResponse,
TelemetryHistoryEntry, TelemetryHistoryEntry,
TrackedTelemetryResponse,
StatisticsResponse, StatisticsResponse,
TraceResponse, TraceResponse,
UnreadCounts, UnreadCounts,
@@ -210,6 +209,12 @@ export const api = {
body: JSON.stringify({ flood_scope_override: floodScopeOverride }), body: JSON.stringify({ flood_scope_override: floodScopeOverride }),
}), }),
setChannelPathHashModeOverride: (key: string, pathHashModeOverride: number | null) =>
fetchJson<Channel>(`/channels/${key}/path-hash-mode-override`, {
method: 'POST',
body: JSON.stringify({ path_hash_mode_override: pathHashModeOverride }),
}),
// Messages // Messages
getMessages: ( getMessages: (
params?: { params?: {
@@ -321,6 +326,13 @@ export const api = {
body: JSON.stringify({ name }), body: JSON.stringify({ name }),
}), }),
// Tracked telemetry
toggleTrackedTelemetry: (publicKey: string) =>
fetchJson<TrackedTelemetryResponse>('/settings/tracked-telemetry/toggle', {
method: 'POST',
body: JSON.stringify({ public_key: publicKey }),
}),
// Favorites // Favorites
toggleFavorite: (type: Favorite['type'], id: string) => toggleFavorite: (type: Favorite['type'], id: string) =>
fetchJson<AppSettings>('/settings/favorites/toggle', { fetchJson<AppSettings>('/settings/favorites/toggle', {
@@ -328,13 +340,6 @@ export const api = {
body: JSON.stringify({ type, id }), body: JSON.stringify({ type, id }),
}), }),
// Preferences migration (one-time, from localStorage to database)
migratePreferences: (request: MigratePreferencesRequest) =>
fetchJson<MigratePreferencesResponse>('/settings/migrate', {
method: 'POST',
body: JSON.stringify(request),
}),
// Fanout // Fanout
getFanoutConfigs: () => fetchJson<FanoutConfig[]>('/fanout'), getFanoutConfigs: () => fetchJson<FanoutConfig[]>('/fanout'),
createFanoutConfig: (config: { createFanoutConfig: (config: {
+2 -2
View File
@@ -135,7 +135,7 @@ export function AppShell({
aria-label="Settings" aria-label="Settings"
> >
<div className="flex justify-between items-center px-3 py-2.5 border-b border-border"> <div className="flex justify-between items-center px-3 py-2.5 border-b border-border">
<h2 className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium"> <h2 className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
Settings Settings
</h2> </h2>
<button <button
@@ -158,7 +158,7 @@ export function AppShell({
type="button" type="button"
disabled={disabled} disabled={disabled}
className={cn( className={cn(
'w-full px-3 py-2 text-left text-[13px] border-l-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset disabled:cursor-not-allowed disabled:opacity-50', 'w-full px-3 py-2 text-left text-[0.8125rem] border-l-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset disabled:cursor-not-allowed disabled:opacity-50',
!disabled && 'hover:bg-accent', !disabled && 'hover:bg-accent',
settingsSection === section && !disabled && 'bg-accent border-l-primary' settingsSection === section && !disabled && 'bg-accent border-l-primary'
)} )}
@@ -49,11 +49,13 @@ export function BulkAddChannelResultModal({
{result && ( {result && (
<div className="grid grid-cols-2 gap-3 text-sm"> <div className="grid grid-cols-2 gap-3 text-sm">
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2"> <div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2">
<div className="text-xs uppercase tracking-wide text-muted-foreground">Created</div> <div className="text-[0.625rem] uppercase tracking-wider font-medium text-muted-foreground">
Created
</div>
<div className="mt-1 font-medium">{createdChannels.length}</div> <div className="mt-1 font-medium">{createdChannels.length}</div>
</div> </div>
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2"> <div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2">
<div className="text-xs uppercase tracking-wide text-muted-foreground"> <div className="text-[0.625rem] uppercase tracking-wider font-medium text-muted-foreground">
Already Present Already Present
</div> </div>
<div className="mt-1 font-medium">{result.existing_count}</div> <div className="mt-1 font-medium">{result.existing_count}</div>
+93 -5
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip as RechartsTooltip } from 'recharts';
import { Star } from 'lucide-react'; import { Star } from 'lucide-react';
import { api } from '../api'; import { api } from '../api';
import { formatTime } from '../utils/messageParser'; import { formatTime } from '../utils/messageParser';
@@ -6,7 +7,7 @@ import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y'; import { handleKeyboardActivate } from '../utils/a11y';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
import { toast } from './ui/sonner'; import { toast } from './ui/sonner';
import type { Channel, ChannelDetail, Favorite } from '../types'; import type { Channel, ChannelDetail, Favorite, PathHashWidthStats } from '../types';
interface ChannelInfoPaneProps { interface ChannelInfoPaneProps {
channelKey: string | null; channelKey: string | null;
@@ -106,11 +107,11 @@ export function ChannelInfoPane({
</span> </span>
)} )}
<div className="flex items-center gap-2 mt-1.5"> <div className="flex items-center gap-2 mt-1.5">
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium"> <span className="text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
{channel.is_hashtag ? 'Hashtag' : 'Private Key'} {channel.is_hashtag ? 'Hashtag' : 'Private Key'}
</span> </span>
{channel.on_radio && ( {channel.on_radio && (
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium"> <span className="text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">
On Radio On Radio
</span> </span>
)} )}
@@ -179,6 +180,14 @@ export function ChannelInfoPane({
</div> </div>
)} )}
{/* Hop Byte Widths (24h) */}
{detail && detail.path_hash_width_24h.total_packets > 0 && (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Hop Byte Widths (24h)</SectionLabel>
<HopWidthChart stats={detail.path_hash_width_24h} />
</div>
)}
{/* Top Senders 24h */} {/* Top Senders 24h */}
{detail && detail.top_senders_24h.length > 0 && ( {detail && detail.top_senders_24h.length > 0 && (
<div className="px-5 py-3"> <div className="px-5 py-3">
@@ -212,7 +221,7 @@ export function ChannelInfoPane({
function SectionLabel({ children }: { children: React.ReactNode }) { function SectionLabel({ children }: { children: React.ReactNode }) {
return ( return (
<h3 className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium mb-1.5"> <h3 className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium mb-1.5">
{children} {children}
</h3> </h3>
); );
@@ -226,3 +235,82 @@ function InfoItem({ label, value }: { label: string; value: string }) {
</div> </div>
); );
} }
const HOP_WIDTH_SEGMENTS = [
{ key: 'single_byte', label: '1-byte', color: '#22c55e' },
{ key: 'double_byte', label: '2-byte', color: '#0ea5e9' },
{ key: 'triple_byte', label: '3-byte', color: '#8b5cf6' },
] as const;
const TOOLTIP_STYLE = {
contentStyle: {
backgroundColor: 'hsl(var(--popover))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
fontSize: '11px',
color: 'hsl(var(--popover-foreground))',
},
} as const;
function HopWidthChart({ stats }: { stats: PathHashWidthStats }) {
const data = useMemo(
() =>
HOP_WIDTH_SEGMENTS.map(({ key, label, color }) => ({
name: label,
value: stats[key] as number,
color,
})).filter((d) => d.value > 0),
[stats]
);
return (
<div className="flex items-center gap-3">
<div className="flex-shrink-0" style={{ width: 90, height: 90 }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data}
dataKey="value"
cx="50%"
cy="50%"
innerRadius={22}
outerRadius={40}
strokeWidth={1.5}
stroke="hsl(var(--background))"
>
{data.map((d) => (
<Cell key={d.name} fill={d.color} />
))}
</Pie>
<RechartsTooltip
{...TOOLTIP_STYLE}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
formatter={(value: any, name: any) => {
const v = typeof value === 'number' ? value : Number(value);
return [`${v.toLocaleString()} pkt${v !== 1 ? 's' : ''}`, name];
}}
/>
</PieChart>
</ResponsiveContainer>
</div>
<div className="flex-1 space-y-1">
{data.map((d) => (
<div key={d.name} className="flex items-center gap-1.5">
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: d.color }}
/>
<span className="text-[0.6875rem] text-muted-foreground flex-1">{d.name}</span>
<span className="text-[0.6875rem] font-medium tabular-nums">
{d.value.toLocaleString()}
</span>
</div>
))}
<p className="text-[0.625rem] text-muted-foreground pt-0.5">
{stats.total_packets.toLocaleString()} total
</p>
</div>
</div>
);
}
@@ -0,0 +1,132 @@
import { useEffect, useState } from 'react';
import { Button } from './ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Label } from './ui/label';
const PATH_HASH_MODE_LABELS: Record<number, string> = {
0: '1-byte',
1: '2-byte',
2: '3-byte',
};
interface ChannelPathHashModeOverrideModalProps {
open: boolean;
onClose: () => void;
channelName: string;
currentOverride: number | null;
radioDefault: number;
onSetOverride: (value: number | null) => void;
}
export function ChannelPathHashModeOverrideModal({
open,
onClose,
channelName,
currentOverride,
radioDefault,
onSetOverride,
}: ChannelPathHashModeOverrideModalProps) {
const [selected, setSelected] = useState<number | null>(null);
useEffect(() => {
if (open) {
setSelected(currentOverride);
}
}, [currentOverride, open]);
const radioDefaultLabel = PATH_HASH_MODE_LABELS[radioDefault] ?? `${radioDefault}`;
const options: { value: number | null; label: string; description: string }[] = [
{
value: null,
label: `Radio default (${radioDefaultLabel})`,
description: 'Use the radio-wide path hash mode setting',
},
{
value: 0,
label: '1-byte hop identifiers',
description: 'Shortest paths, least repeater disambiguation',
},
{
value: 1,
label: '2-byte hop identifiers',
description: 'Better repeater disambiguation',
},
{
value: 2,
label: '3-byte hop identifiers',
description: 'Best repeater disambiguation, longest paths',
},
];
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>Path Hop Width Override</DialogTitle>
<DialogDescription>
Override the path hash mode for this channel. Wider hop identifiers improve repeater
disambiguation but extend send time and will prevent users on old (&lt;1.14) firmware
from receiving the message.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-md border border-border bg-muted/20 p-3 text-sm">
<div className="font-medium">{channelName}</div>
<div className="mt-1 text-muted-foreground">
Current override:{' '}
{currentOverride != null
? (PATH_HASH_MODE_LABELS[currentOverride] ?? `mode ${currentOverride}`)
: `none (using radio default: ${radioDefaultLabel})`}
</div>
</div>
<div className="space-y-2">
<Label>Hop width for this channel</Label>
<div className="space-y-1.5">
{options.map((opt) => (
<button
key={String(opt.value)}
type="button"
className={`w-full rounded-md border px-3 py-2 text-left text-sm transition-colors ${
selected === opt.value
? 'border-primary bg-primary/10 text-foreground'
: 'border-border hover:bg-accent'
}`}
onClick={() => setSelected(opt.value)}
>
<div className="font-medium">{opt.label}</div>
<div className="text-xs text-muted-foreground">{opt.description}</div>
</button>
))}
</div>
</div>
</div>
<DialogFooter className="gap-2 sm:block sm:space-x-0">
<Button
type="button"
className="w-full"
onClick={() => {
onSetOverride(selected);
onClose();
}}
>
{selected == null
? `Use radio default for ${channelName}`
: `Use ${PATH_HASH_MODE_LABELS[selected]} hops for ${channelName}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+46 -7
View File
@@ -1,9 +1,10 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Bell, Globe2, Info, Route, Star, Trash2 } from 'lucide-react'; import { Bell, ChevronsLeftRight, Globe2, Info, Route, Star, Trash2 } from 'lucide-react';
import { toast } from './ui/sonner'; import { toast } from './ui/sonner';
import { DirectTraceIcon } from './DirectTraceIcon'; import { DirectTraceIcon } from './DirectTraceIcon';
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal'; import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
import { ChannelFloodScopeOverrideModal } from './ChannelFloodScopeOverrideModal'; import { ChannelFloodScopeOverrideModal } from './ChannelFloodScopeOverrideModal';
import { ChannelPathHashModeOverrideModal } from './ChannelPathHashModeOverrideModal';
import { isFavorite } from '../utils/favorites'; import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y'; import { handleKeyboardActivate } from '../utils/a11y';
import { isPublicChannelKey } from '../utils/publicChannel'; import { isPublicChannelKey } from '../utils/publicChannel';
@@ -36,6 +37,7 @@ interface ChatHeaderProps {
onToggleNotifications: () => void; onToggleNotifications: () => void;
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void; onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
onSetChannelFloodScopeOverride?: (key: string, floodScopeOverride: string) => void; onSetChannelFloodScopeOverride?: (key: string, floodScopeOverride: string) => void;
onSetChannelPathHashModeOverride?: (key: string, pathHashModeOverride: number | null) => void;
onDeleteChannel: (key: string) => void; onDeleteChannel: (key: string) => void;
onDeleteContact: (publicKey: string) => void; onDeleteContact: (publicKey: string) => void;
onOpenContactInfo?: (publicKey: string) => void; onOpenContactInfo?: (publicKey: string) => void;
@@ -56,6 +58,7 @@ export function ChatHeader({
onToggleNotifications, onToggleNotifications,
onToggleFavorite, onToggleFavorite,
onSetChannelFloodScopeOverride, onSetChannelFloodScopeOverride,
onSetChannelPathHashModeOverride,
onDeleteChannel, onDeleteChannel,
onDeleteContact, onDeleteContact,
onOpenContactInfo, onOpenContactInfo,
@@ -64,11 +67,13 @@ export function ChatHeader({
const [showKey, setShowKey] = useState(false); const [showKey, setShowKey] = useState(false);
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false); const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
const [channelOverrideOpen, setChannelOverrideOpen] = useState(false); const [channelOverrideOpen, setChannelOverrideOpen] = useState(false);
const [pathHashModeOverrideOpen, setPathHashModeOverrideOpen] = useState(false);
useEffect(() => { useEffect(() => {
setShowKey(false); setShowKey(false);
setPathDiscoveryOpen(false); setPathDiscoveryOpen(false);
setChannelOverrideOpen(false); setChannelOverrideOpen(false);
setPathHashModeOverrideOpen(false);
}, [conversation.id]); }, [conversation.id]);
const activeChannel = const activeChannel =
@@ -81,6 +86,12 @@ export function ChatHeader({
? stripRegionScopePrefix(activeFloodScopeOverride) ? stripRegionScopePrefix(activeFloodScopeOverride)
: null; : null;
const activeFloodScopeDisplay = activeFloodScopeOverride ? activeFloodScopeOverride : null; const activeFloodScopeDisplay = activeFloodScopeOverride ? activeFloodScopeOverride : null;
const activePathHashModeOverride =
conversation.type === 'channel' ? (activeChannel?.path_hash_mode_override ?? null) : null;
const showPathHashModeOverride =
conversation.type === 'channel' &&
onSetChannelPathHashModeOverride &&
config?.path_hash_mode_supported;
const isPrivateChannel = conversation.type === 'channel' && !activeChannel?.is_hashtag; const isPrivateChannel = conversation.type === 'channel' && !activeChannel?.is_hashtag;
const activeContact = const activeContact =
conversation.type === 'contact' conversation.type === 'contact'
@@ -108,6 +119,11 @@ export function ChatHeader({
setChannelOverrideOpen(true); setChannelOverrideOpen(true);
}; };
const handleEditPathHashModeOverride = () => {
if (conversation.type !== 'channel' || !onSetChannelPathHashModeOverride) return;
setPathHashModeOverrideOpen(true);
};
const handleOpenConversationInfo = () => { const handleOpenConversationInfo = () => {
if (conversation.type === 'contact' && onOpenContactInfo) { if (conversation.type === 'contact' && onOpenContactInfo) {
onOpenContactInfo(conversation.id); onOpenContactInfo(conversation.id);
@@ -182,7 +198,7 @@ export function ChatHeader({
</h2> </h2>
{isPrivateChannel && !showKey ? ( {isPrivateChannel && !showKey ? (
<button <button
className="min-w-0 flex-shrink text-[11px] font-mono text-muted-foreground transition-colors hover:text-primary" className="min-w-0 flex-shrink text-[0.6875rem] font-mono text-muted-foreground transition-colors hover:text-primary"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setShowKey(true); setShowKey(true);
@@ -193,7 +209,7 @@ export function ChatHeader({
</button> </button>
) : ( ) : (
<span <span
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary" className="min-w-0 flex-1 truncate font-mono text-[0.6875rem] text-muted-foreground transition-colors hover:text-primary"
role="button" role="button"
tabIndex={0} tabIndex={0}
onKeyDown={handleKeyboardActivate} onKeyDown={handleKeyboardActivate}
@@ -228,7 +244,7 @@ export function ChatHeader({
className="h-3.5 w-3.5 flex-shrink-0 text-[hsl(var(--region-override))]" className="h-3.5 w-3.5 flex-shrink-0 text-[hsl(var(--region-override))]"
aria-hidden="true" aria-hidden="true"
/> />
<span className="min-w-0 truncate text-[11px] font-medium text-[hsl(var(--region-override))]"> <span className="min-w-0 truncate text-[0.6875rem] font-medium text-[hsl(var(--region-override))]">
{activeFloodScopeDisplay} {activeFloodScopeDisplay}
</span> </span>
</button> </button>
@@ -237,7 +253,7 @@ export function ChatHeader({
</span> </span>
</span> </span>
{conversation.type === 'contact' && activeContact && ( {conversation.type === 'contact' && activeContact && (
<div className="col-span-2 row-start-2 min-w-0 text-[11px] text-muted-foreground min-[1100px]:col-span-1 min-[1100px]:col-start-2 min-[1100px]:row-start-1"> <div className="col-span-2 row-start-2 min-w-0 text-[0.6875rem] text-muted-foreground min-[1100px]:col-span-1 min-[1100px]:col-start-2 min-[1100px]:row-start-1">
<ContactStatusInfo <ContactStatusInfo
contact={activeContact} contact={activeContact}
ourLat={config?.lat ?? null} ourLat={config?.lat ?? null}
@@ -299,7 +315,7 @@ export function ChatHeader({
aria-hidden="true" aria-hidden="true"
/> />
{notificationsEnabled && ( {notificationsEnabled && (
<span className="hidden md:inline text-[11px] font-medium text-status-connected"> <span className="hidden md:inline text-[0.6875rem] font-medium text-status-connected">
Notifications On Notifications On
</span> </span>
)} )}
@@ -317,12 +333,25 @@ export function ChatHeader({
aria-hidden="true" aria-hidden="true"
/> />
{activeFloodScopeDisplay && ( {activeFloodScopeDisplay && (
<span className="hidden text-[11px] font-medium text-[hsl(var(--region-override))] sm:inline"> <span className="hidden text-[0.6875rem] font-medium text-[hsl(var(--region-override))] sm:inline">
{activeFloodScopeDisplay} {activeFloodScopeDisplay}
</span> </span>
)} )}
</button> </button>
)} )}
{showPathHashModeOverride && (
<button
className="flex shrink-0 items-center gap-1 rounded px-1 py-1 text-lg leading-none transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={handleEditPathHashModeOverride}
title="Set path hop width override"
aria-label="Set path hop width override"
>
<ChevronsLeftRight
className={`h-4 w-4 ${activePathHashModeOverride != null ? 'text-status-connected' : 'text-muted-foreground'}`}
aria-hidden="true"
/>
</button>
)}
{(conversation.type === 'channel' || conversation.type === 'contact') && ( {(conversation.type === 'channel' || conversation.type === 'contact') && (
<button <button
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@@ -379,6 +408,16 @@ export function ChatHeader({
onSetOverride={(value) => onSetChannelFloodScopeOverride(conversation.id, value)} onSetOverride={(value) => onSetChannelFloodScopeOverride(conversation.id, value)}
/> />
)} )}
{showPathHashModeOverride && (
<ChannelPathHashModeOverrideModal
open={pathHashModeOverrideOpen}
onClose={() => setPathHashModeOverrideOpen(false)}
channelName={conversation.name}
currentOverride={activePathHashModeOverride}
radioDefault={config?.path_hash_mode ?? 0}
onSetOverride={(value) => onSetChannelPathHashModeOverride(conversation.id, value)}
/>
)}
</header> </header>
); );
} }
+4 -4
View File
@@ -292,7 +292,7 @@ export function ContactInfoPane({
{contact.public_key} {contact.public_key}
</span> </span>
<div className="flex items-center gap-2 mt-1.5"> <div className="flex items-center gap-2 mt-1.5">
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium"> <span className="text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
{CONTACT_TYPE_LABELS[contact.type] ?? 'Unknown'} {CONTACT_TYPE_LABELS[contact.type] ?? 'Unknown'}
</span> </span>
</div> </div>
@@ -568,7 +568,7 @@ export function ContactInfoPane({
function SectionLabel({ children }: { children: React.ReactNode }) { function SectionLabel({ children }: { children: React.ReactNode }) {
return ( return (
<h3 className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium mb-1.5"> <h3 className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium mb-1.5">
{children} {children}
</h3> </h3>
); );
@@ -729,7 +729,7 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu
</div> </div>
)} )}
<p className="text-[11px] text-muted-foreground"> <p className="text-[0.6875rem] text-muted-foreground">
Hourly lines compare the last 24 hours against 7-day and all-time averages for the same hour Hourly lines compare the last 24 hours against 7-day and all-time averages for the same hour
slots. slots.
{!analytics.includes_direct_messages && {!analytics.includes_direct_messages &&
@@ -821,7 +821,7 @@ function ActivityLineChart<T extends ContactAnalyticsHourlyBucket | ContactAnaly
{legendItems && ( {legendItems && (
<Legend <Legend
content={() => ( content={() => (
<div className="flex flex-wrap justify-center gap-x-3 gap-y-1 mt-1 text-[11px] text-muted-foreground"> <div className="flex flex-wrap justify-center gap-x-3 gap-y-1 mt-1 text-[0.6875rem] text-muted-foreground">
{legendItems.map((item) => ( {legendItems.map((item) => (
<span key={item.label} className="inline-flex items-center gap-1.5"> <span key={item.label} className="inline-flex items-center gap-1.5">
<span <span
@@ -74,12 +74,12 @@ function RouteCard({
<div className="rounded-md border border-border bg-muted/20 p-3"> <div className="rounded-md border border-border bg-muted/20 p-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<h4 className="text-sm font-semibold">{label}</h4> <h4 className="text-sm font-semibold">{label}</h4>
<span className="text-[11px] text-muted-foreground"> <span className="text-[0.6875rem] text-muted-foreground">
{formatRouteLabel(route.path_len, true)} {formatRouteLabel(route.path_len, true)}
</span> </span>
</div> </div>
<p className="mt-2 text-sm">{chain}</p> <p className="mt-2 text-sm">{chain}</p>
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-muted-foreground"> <div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[0.6875rem] text-muted-foreground">
<span>Raw: {rawPath}</span> <span>Raw: {rawPath}</span>
<span>{formatPathHashMode(route.path_hash_mode)}</span> <span>{formatPathHashMode(route.path_hash_mode)}</span>
</div> </div>
+21 -1
View File
@@ -44,6 +44,7 @@ interface ConversationPaneProps {
notificationsPermission: NotificationPermission | 'unsupported'; notificationsPermission: NotificationPermission | 'unsupported';
favorites: Favorite[]; favorites: Favorite[];
messages: Message[]; messages: Message[];
preSorted?: boolean;
messagesLoading: boolean; messagesLoading: boolean;
loadingOlder: boolean; loadingOlder: boolean;
hasOlderMessages: boolean; hasOlderMessages: boolean;
@@ -62,6 +63,10 @@ interface ConversationPaneProps {
onDeleteContact: (publicKey: string) => Promise<void>; onDeleteContact: (publicKey: string) => Promise<void>;
onDeleteChannel: (key: string) => Promise<void>; onDeleteChannel: (key: string) => Promise<void>;
onSetChannelFloodScopeOverride: (channelKey: string, floodScopeOverride: string) => Promise<void>; onSetChannelFloodScopeOverride: (channelKey: string, floodScopeOverride: string) => Promise<void>;
onSetChannelPathHashModeOverride?: (
channelKey: string,
pathHashModeOverride: number | null
) => Promise<void>;
onOpenContactInfo: (publicKey: string, fromChannel?: boolean) => void; onOpenContactInfo: (publicKey: string, fromChannel?: boolean) => void;
onOpenChannelInfo: (channelKey: string) => void; onOpenChannelInfo: (channelKey: string) => void;
onSenderClick: (sender: string) => void; onSenderClick: (sender: string) => void;
@@ -74,6 +79,8 @@ interface ConversationPaneProps {
onDismissUnreadMarker: () => void; onDismissUnreadMarker: () => void;
onSendMessage: (text: string) => Promise<void>; onSendMessage: (text: string) => Promise<void>;
onToggleNotifications: () => void; onToggleNotifications: () => void;
trackedTelemetryRepeaters: string[];
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
} }
function LoadingPane({ label }: { label: string }) { function LoadingPane({ label }: { label: string }) {
@@ -114,6 +121,7 @@ export function ConversationPane({
notificationsPermission, notificationsPermission,
favorites, favorites,
messages, messages,
preSorted,
messagesLoading, messagesLoading,
loadingOlder, loadingOlder,
hasOlderMessages, hasOlderMessages,
@@ -129,6 +137,7 @@ export function ConversationPane({
onDeleteContact, onDeleteContact,
onDeleteChannel, onDeleteChannel,
onSetChannelFloodScopeOverride, onSetChannelFloodScopeOverride,
onSetChannelPathHashModeOverride,
onOpenContactInfo, onOpenContactInfo,
onOpenChannelInfo, onOpenChannelInfo,
onSenderClick, onSenderClick,
@@ -141,6 +150,8 @@ export function ConversationPane({
onDismissUnreadMarker, onDismissUnreadMarker,
onSendMessage, onSendMessage,
onToggleNotifications, onToggleNotifications,
trackedTelemetryRepeaters,
onToggleTrackedTelemetry,
}: ConversationPaneProps) { }: ConversationPaneProps) {
const [roomAuthenticated, setRoomAuthenticated] = useState(false); const [roomAuthenticated, setRoomAuthenticated] = useState(false);
const activeContactIsRepeater = useMemo(() => { const activeContactIsRepeater = useMemo(() => {
@@ -180,7 +191,12 @@ export function ConversationPane({
</h2> </h2>
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<Suspense fallback={<LoadingPane label="Loading map..." />}> <Suspense fallback={<LoadingPane label="Loading map..." />}>
<MapView contacts={contacts} focusedKey={activeConversation.mapFocusKey} /> <MapView
contacts={contacts}
focusedKey={activeConversation.mapFocusKey}
rawPackets={rawPackets}
config={config}
/>
</Suspense> </Suspense>
</div> </div>
</> </>
@@ -234,6 +250,8 @@ export function ConversationPane({
onToggleFavorite={onToggleFavorite} onToggleFavorite={onToggleFavorite}
onDeleteContact={onDeleteContact} onDeleteContact={onDeleteContact}
onOpenContactInfo={onOpenContactInfo} onOpenContactInfo={onOpenContactInfo}
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
/> />
</Suspense> </Suspense>
); );
@@ -257,6 +275,7 @@ export function ConversationPane({
onToggleNotifications={onToggleNotifications} onToggleNotifications={onToggleNotifications}
onToggleFavorite={onToggleFavorite} onToggleFavorite={onToggleFavorite}
onSetChannelFloodScopeOverride={onSetChannelFloodScopeOverride} onSetChannelFloodScopeOverride={onSetChannelFloodScopeOverride}
onSetChannelPathHashModeOverride={onSetChannelPathHashModeOverride}
onDeleteChannel={onDeleteChannel} onDeleteChannel={onDeleteChannel}
onDeleteContact={onDeleteContact} onDeleteContact={onDeleteContact}
onOpenContactInfo={onOpenContactInfo} onOpenContactInfo={onOpenContactInfo}
@@ -275,6 +294,7 @@ export function ConversationPane({
<MessageList <MessageList
key={activeConversation.id} key={activeConversation.id}
messages={messages} messages={messages}
preSorted={preSorted}
contacts={contacts} contacts={contacts}
channels={channels} channels={channels}
loading={messagesLoading} loading={messagesLoading}
+643 -65
View File
@@ -1,16 +1,47 @@
import { Fragment, useEffect, useState, useMemo, useRef, useCallback } from 'react'; import { Fragment, useEffect, useState, useMemo, useRef, useCallback } from 'react';
import { MapContainer, TileLayer, CircleMarker, Popup, useMap } from 'react-leaflet'; import { MapContainer, TileLayer, CircleMarker, Popup, useMap, Polyline } from 'react-leaflet';
import type { LatLngBoundsExpression, CircleMarker as LeafletCircleMarker } from 'leaflet'; import type { LatLngBoundsExpression, CircleMarker as LeafletCircleMarker } from 'leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
import type { Contact } from '../types'; import type { Contact, RadioConfig, RawPacket } from '../types';
import { formatTime } from '../utils/messageParser'; import { formatTime } from '../utils/messageParser';
import { isValidLocation } from '../utils/pathUtils'; import { isValidLocation } from '../utils/pathUtils';
import { CONTACT_TYPE_REPEATER } from '../types'; import { CONTACT_TYPE_REPEATER } from '../types';
import {
parsePacket,
getPacketLabel,
PARTICLE_COLOR_MAP,
dedupeConsecutive,
} from '../utils/visualizerUtils';
import { getRawPacketObservationKey } from '../utils/rawPacketIdentity';
interface MapViewProps { interface MapViewProps {
contacts: Contact[]; contacts: Contact[];
/** Public key of contact to focus on and open popup */ /** Public key of contact to focus on and open popup */
focusedKey?: string | null; focusedKey?: string | null;
rawPackets?: RawPacket[];
config?: RadioConfig | null;
}
// --- Tile layer presets ---
const TILE_LIGHT = {
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
background: '#1a1a2e',
};
const TILE_DARK = {
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/">CARTO</a>',
background: '#0d0d0d',
};
function getSavedDarkMap(): boolean {
try {
return localStorage.getItem('remoteterm-dark-map') === 'true';
} catch {
return false;
}
} }
const MAP_RECENCY_COLORS = { const MAP_RECENCY_COLORS = {
@@ -22,7 +53,16 @@ const MAP_RECENCY_COLORS = {
const MAP_MARKER_STROKE = '#0f172a'; const MAP_MARKER_STROKE = '#0f172a';
const MAP_REPEATER_RING = '#f8fafc'; const MAP_REPEATER_RING = '#f8fafc';
// Calculate marker color based on how recently the contact was heard // --- Packet visualization constants ---
const THREE_DAYS_SEC = 3 * 24 * 60 * 60;
const PARTICLE_LIFETIME_MS = 3000;
const PARTICLE_TAIL_LENGTH = 0.25; // fraction of progress to trail behind
const PARTICLE_RADIUS = 8;
const PARTICLE_TAIL_WIDTH = 5;
const MAX_MAP_PARTICLES = 200;
// --- Helpers ---
function getMarkerColor(lastSeen: number | null | undefined): string { function getMarkerColor(lastSeen: number | null | undefined): string {
if (lastSeen == null) return MAP_RECENCY_COLORS.old; if (lastSeen == null) return MAP_RECENCY_COLORS.old;
const now = Date.now() / 1000; const now = Date.now() / 1000;
@@ -36,7 +76,85 @@ function getMarkerColor(lastSeen: number | null | undefined): string {
return MAP_RECENCY_COLORS.old; return MAP_RECENCY_COLORS.old;
} }
// Component to handle map bounds fitting /** Resolve a hop token to a single contact with GPS, or null. */
function resolveHopToGps(hopToken: string, prefixIndex: Map<string, Contact[]>): Contact | null {
const matches = prefixIndex.get(hopToken.toLowerCase());
if (!matches || matches.length !== 1) return null;
const c = matches[0];
return isValidLocation(c.lat, c.lon) ? c : null;
}
/** Resolve a contact by display name (for GroupText senders). */
function resolveNameToGps(name: string, nameIndex: Map<string, Contact>): Contact | null {
const c = nameIndex.get(name);
if (!c) return null;
return isValidLocation(c.lat, c.lon) ? c : null;
}
/** Collect public keys of all unambiguously resolved GPS-bearing contacts from a parsed packet. */
function resolvePacketContacts(
parsed: ReturnType<typeof parsePacket>,
prefixIndex: Map<string, Contact[]>,
nameIndex: Map<string, Contact>,
myLatLon: [number, number] | null,
config?: RadioConfig | null
): Set<string> {
const keys = new Set<string>();
if (!parsed) return keys;
// Source by pubkey prefix
const sourcePrefixes = parsed.advertPubkey
? [parsed.advertPubkey.slice(0, 12).toLowerCase()]
: parsed.srcHash
? [parsed.srcHash.toLowerCase()]
: [];
for (const prefix of sourcePrefixes) {
const matches = prefixIndex.get(prefix);
if (matches?.length === 1 && isValidLocation(matches[0].lat, matches[0].lon)) {
keys.add(matches[0].public_key);
}
}
// Source by name (GroupText sender)
if (parsed.groupTextSender) {
const c = resolveNameToGps(parsed.groupTextSender, nameIndex);
if (c) keys.add(c.public_key);
}
// Intermediate hops
for (const hop of parsed.pathBytes) {
if (hop.length < 4) continue;
const matches = prefixIndex.get(hop.toLowerCase());
if (matches?.length === 1 && isValidLocation(matches[0].lat, matches[0].lon)) {
keys.add(matches[0].public_key);
}
}
// Self
if (myLatLon && config?.public_key) {
keys.add(config.public_key.toLowerCase());
}
// Destination
if (parsed.dstHash) {
const matches = prefixIndex.get(parsed.dstHash.toLowerCase());
if (matches?.length === 1 && isValidLocation(matches[0].lat, matches[0].lon)) {
keys.add(matches[0].public_key);
}
}
return keys;
}
interface MapParticle {
id: number;
path: [number, number][]; // lat/lon waypoints
color: string;
startedAt: number;
}
// --- Map bounds handler ---
function MapBoundsHandler({ function MapBoundsHandler({
contacts, contacts,
focusedContact, focusedContact,
@@ -48,7 +166,6 @@ function MapBoundsHandler({
const [hasInitialized, setHasInitialized] = useState(false); const [hasInitialized, setHasInitialized] = useState(false);
useEffect(() => { useEffect(() => {
// If we have a focused contact, center on it immediately (even if already initialized)
if (focusedContact && focusedContact.lat != null && focusedContact.lon != null) { if (focusedContact && focusedContact.lat != null && focusedContact.lon != null) {
map.setView([focusedContact.lat, focusedContact.lon], 12); map.setView([focusedContact.lat, focusedContact.lon], 12);
setHasInitialized(true); setHasInitialized(true);
@@ -59,20 +176,17 @@ function MapBoundsHandler({
const fitToContacts = () => { const fitToContacts = () => {
if (contacts.length === 0) { if (contacts.length === 0) {
// No contacts with location - show world view
map.setView([20, 0], 2); map.setView([20, 0], 2);
setHasInitialized(true); setHasInitialized(true);
return; return;
} }
if (contacts.length === 1) { if (contacts.length === 1) {
// Single contact - center on it
map.setView([contacts[0].lat!, contacts[0].lon!], 10); map.setView([contacts[0].lat!, contacts[0].lon!], 10);
setHasInitialized(true); setHasInitialized(true);
return; return;
} }
// Multiple contacts - fit bounds
const bounds: LatLngBoundsExpression = contacts.map( const bounds: LatLngBoundsExpression = contacts.map(
(c) => [c.lat!, c.lon!] as [number, number] (c) => [c.lat!, c.lon!] as [number, number]
); );
@@ -80,22 +194,18 @@ function MapBoundsHandler({
setHasInitialized(true); setHasInitialized(true);
}; };
// Try geolocation first
if ('geolocation' in navigator) { if ('geolocation' in navigator) {
navigator.geolocation.getCurrentPosition( navigator.geolocation.getCurrentPosition(
(position) => { (position) => {
// Success - center on user location with reasonable zoom
map.setView([position.coords.latitude, position.coords.longitude], 8); map.setView([position.coords.latitude, position.coords.longitude], 8);
setHasInitialized(true); setHasInitialized(true);
}, },
() => { () => {
// Geolocation denied/failed - fit to contacts
fitToContacts(); fitToContacts();
}, },
{ timeout: 5000, maximumAge: 300000 } { timeout: 5000, maximumAge: 300000 }
); );
} else { } else {
// No geolocation support - fit to contacts
fitToContacts(); fitToContacts();
} }
}, [map, contacts, hasInitialized, focusedContact]); }, [map, contacts, hasInitialized, focusedContact]);
@@ -103,18 +213,404 @@ function MapBoundsHandler({
return null; return null;
} }
export function MapView({ contacts, focusedKey }: MapViewProps) { // --- Canvas particle overlay ---
const [sevenDaysAgo] = useState(() => Date.now() / 1000 - 7 * 24 * 60 * 60);
// Filter to contacts with GPS coordinates, heard within the last 7 days. function ParticleOverlay({ particles }: { particles: MapParticle[] }) {
// Always include the focused contact so "view on map" links work for older nodes. const map = useMap();
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const animRef = useRef<number>(0);
useEffect(() => {
const container = map.getContainer();
const canvas = document.createElement('canvas');
canvas.style.position = 'absolute';
canvas.style.top = '0';
canvas.style.left = '0';
canvas.style.pointerEvents = 'none';
canvas.style.zIndex = '450'; // above tiles, below popups
container.appendChild(canvas);
canvasRef.current = canvas;
const resize = () => {
const size = map.getSize();
canvas.width = size.x * window.devicePixelRatio;
canvas.height = size.y * window.devicePixelRatio;
canvas.style.width = `${size.x}px`;
canvas.style.height = `${size.y}px`;
};
resize();
map.on('resize', resize);
map.on('zoom', resize);
return () => {
cancelAnimationFrame(animRef.current);
map.off('resize', resize);
map.off('zoom', resize);
container.removeChild(canvas);
canvasRef.current = null;
};
}, [map]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const draw = () => {
const now = Date.now();
const dpr = window.devicePixelRatio;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.scale(dpr, dpr);
for (const particle of particles) {
const elapsed = now - particle.startedAt;
if (elapsed < 0 || elapsed > PARTICLE_LIFETIME_MS) continue;
const progress = elapsed / PARTICLE_LIFETIME_MS;
const path = particle.path;
if (path.length < 2) continue;
// Calculate total path length in pixels for even speed
const pixelPath = path.map((ll) => map.latLngToContainerPoint(L.latLng(ll[0], ll[1])));
const segLengths: number[] = [];
let totalLen = 0;
for (let i = 1; i < pixelPath.length; i++) {
const dx = pixelPath[i].x - pixelPath[i - 1].x;
const dy = pixelPath[i].y - pixelPath[i - 1].y;
const len = Math.sqrt(dx * dx + dy * dy);
segLengths.push(len);
totalLen += len;
}
if (totalLen === 0) continue;
// Interpolate head position
const headDist = progress * totalLen;
const tailDist = Math.max(0, headDist - PARTICLE_TAIL_LENGTH * totalLen);
const pointAtDist = (d: number): { x: number; y: number } => {
let accum = 0;
for (let i = 0; i < segLengths.length; i++) {
if (accum + segLengths[i] >= d) {
const t = segLengths[i] > 0 ? (d - accum) / segLengths[i] : 0;
return {
x: pixelPath[i].x + (pixelPath[i + 1].x - pixelPath[i].x) * t,
y: pixelPath[i].y + (pixelPath[i + 1].y - pixelPath[i].y) * t,
};
}
accum += segLengths[i];
}
const last = pixelPath[pixelPath.length - 1];
return { x: last.x, y: last.y };
};
const head = pointAtDist(headDist);
const tail = pointAtDist(tailDist);
// Draw tail as a gradient line from transparent to opaque
const grad = ctx.createLinearGradient(tail.x, tail.y, head.x, head.y);
grad.addColorStop(0, particle.color + '00');
grad.addColorStop(1, particle.color + 'cc');
ctx.beginPath();
ctx.moveTo(tail.x, tail.y);
// Sample intermediate points along the tail for curved paths
const steps = 8;
for (let s = 1; s <= steps; s++) {
const d = tailDist + ((headDist - tailDist) * s) / steps;
const pt = pointAtDist(d);
ctx.lineTo(pt.x, pt.y);
}
ctx.strokeStyle = grad;
ctx.lineWidth = PARTICLE_TAIL_WIDTH;
ctx.lineCap = 'round';
ctx.stroke();
// Draw head blob with glow
const fade = progress > 0.8 ? 1 - (progress - 0.8) / 0.2 : 1;
const alpha = Math.round(fade * 230)
.toString(16)
.padStart(2, '0');
// Outer glow
ctx.beginPath();
ctx.arc(head.x, head.y, PARTICLE_RADIUS + 4, 0, Math.PI * 2);
ctx.fillStyle =
particle.color +
Math.round(fade * 40)
.toString(16)
.padStart(2, '0');
ctx.fill();
// Core blob
ctx.beginPath();
ctx.arc(head.x, head.y, PARTICLE_RADIUS, 0, Math.PI * 2);
ctx.fillStyle = particle.color + alpha;
ctx.shadowColor = particle.color;
ctx.shadowBlur = 12 * fade;
ctx.fill();
ctx.shadowBlur = 0;
// Bright center
ctx.beginPath();
ctx.arc(head.x, head.y, PARTICLE_RADIUS * 0.4, 0, Math.PI * 2);
ctx.fillStyle = '#ffffff' + alpha;
ctx.fill();
}
ctx.restore();
animRef.current = requestAnimationFrame(draw);
};
animRef.current = requestAnimationFrame(draw);
return () => cancelAnimationFrame(animRef.current);
}, [map, particles]);
// Redraw on map move/zoom
useEffect(() => {
const redraw = () => {}; // Animation loop already redraws every frame
map.on('move', redraw);
map.on('zoom', redraw);
return () => {
map.off('move', redraw);
map.off('zoom', redraw);
};
}, [map]);
return null;
}
// --- Main component ---
export function MapView({ contacts, focusedKey, rawPackets, config }: MapViewProps) {
const [sevenDaysAgo] = useState(() => Date.now() / 1000 - 7 * 24 * 60 * 60);
const [darkMap, setDarkMap] = useState(getSavedDarkMap);
const tile = darkMap ? TILE_DARK : TILE_LIGHT;
// Sync with settings changes from other components
useEffect(() => {
const onStorage = (e: StorageEvent) => {
if (e.key === 'remoteterm-dark-map') setDarkMap(e.newValue === 'true');
};
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, []);
const [showPackets, setShowPackets] = useState(false);
const [discoveryMode, setDiscoveryMode] = useState(false);
const [discoveredKeys, setDiscoveredKeys] = useState<Set<string>>(new Set());
const [particles, setParticles] = useState<MapParticle[]>([]);
const particleIdRef = useRef(0);
const seenObservationsRef = useRef(new Set<string>());
// Build prefix index and name index for hop resolution
const { prefixIndex, nameIndex } = useMemo(() => {
const prefix = new Map<string, Contact[]>();
const name = new Map<string, Contact>();
for (const c of contacts) {
const pubkey = c.public_key.toLowerCase();
for (let len = 1; len <= 12 && len <= pubkey.length; len++) {
const p = pubkey.slice(0, len);
const arr = prefix.get(p);
if (arr) arr.push(c);
else prefix.set(p, [c]);
}
if (c.name && !name.has(c.name)) name.set(c.name, c);
}
return { prefixIndex: prefix, nameIndex: name };
}, [contacts]);
// Self GPS
const myLatLon = useMemo<[number, number] | null>(() => {
if (!config || !isValidLocation(config.lat, config.lon)) return null;
return [config.lat, config.lon];
}, [config]);
// Determine time window for packet visualization
const threeDaysAgoSec = useMemo(() => Date.now() / 1000 - THREE_DAYS_SEC, []);
// Filter contacts for map display
const mappableContacts = useMemo(() => { const mappableContacts = useMemo(() => {
if (showPackets && discoveryMode) {
// Discovery mode: only show nodes that have appeared in resolved packets
return contacts.filter(
(c) => isValidLocation(c.lat, c.lon) && discoveredKeys.has(c.public_key)
);
}
if (showPackets) {
// Packet mode: show only last 3 days
return contacts.filter(
(c) =>
isValidLocation(c.lat, c.lon) &&
(c.public_key === focusedKey || (c.last_seen != null && c.last_seen > threeDaysAgoSec))
);
}
return contacts.filter( return contacts.filter(
(c) => (c) =>
isValidLocation(c.lat, c.lon) && isValidLocation(c.lat, c.lon) &&
(c.public_key === focusedKey || (c.last_seen != null && c.last_seen > sevenDaysAgo)) (c.public_key === focusedKey || (c.last_seen != null && c.last_seen > sevenDaysAgo))
); );
}, [contacts, focusedKey, sevenDaysAgo]); }, [
contacts,
focusedKey,
sevenDaysAgo,
threeDaysAgoSec,
showPackets,
discoveryMode,
discoveredKeys,
]);
// Resolve a path of hop tokens to geographic waypoints (only unambiguous + has GPS)
const resolvePacketPath = useCallback(
(parsed: ReturnType<typeof parsePacket>): [number, number][] | null => {
if (!parsed) return null;
const waypoints: [number, number][] = [];
// Source: advertPubkey, srcHash, or groupTextSender resolved by name
let sourceContact: Contact | null = null;
if (parsed.advertPubkey) {
const prefix = parsed.advertPubkey.slice(0, 12).toLowerCase();
const matches = prefixIndex.get(prefix);
if (matches?.length === 1 && isValidLocation(matches[0].lat, matches[0].lon)) {
sourceContact = matches[0];
}
} else if (parsed.srcHash) {
sourceContact = resolveHopToGps(parsed.srcHash, prefixIndex);
} else if (parsed.groupTextSender) {
sourceContact = resolveNameToGps(parsed.groupTextSender, nameIndex);
}
if (sourceContact) {
waypoints.push([sourceContact.lat!, sourceContact.lon!]);
}
// Intermediate hops (path bytes)
for (const hop of parsed.pathBytes) {
// Only resolve 2+ byte hops (4+ hex chars) to avoid ambiguous 1-byte hops
if (hop.length < 4) continue;
const contact = resolveHopToGps(hop, prefixIndex);
if (contact) {
waypoints.push([contact.lat!, contact.lon!]);
}
}
// Destination: self (our radio), or dstHash
if (myLatLon) {
waypoints.push(myLatLon);
} else if (parsed.dstHash) {
const dest = resolveHopToGps(parsed.dstHash, prefixIndex);
if (dest) {
waypoints.push([dest.lat!, dest.lon!]);
}
}
// Dedupe consecutive identical waypoints
const deduped = dedupeConsecutive(waypoints.map((w) => `${w[0]},${w[1]}`));
if (deduped.length < 2) return null;
return deduped.map((s) => {
const [lat, lon] = s.split(',').map(Number);
return [lat, lon] as [number, number];
});
},
[prefixIndex, nameIndex, myLatLon]
);
// Process new packets into particles and track discovered contacts
useEffect(() => {
if (!showPackets || !rawPackets?.length) return;
const now = Date.now();
const newParticles: MapParticle[] = [];
const newDiscovered = new Set<string>();
for (const pkt of rawPackets) {
// Skip old packets
if (pkt.timestamp < threeDaysAgoSec) continue;
// Deduplicate by observation
const obsKey = getRawPacketObservationKey(pkt);
if (seenObservationsRef.current.has(obsKey)) continue;
const parsed = parsePacket(pkt.data);
if (!parsed) continue;
// Discover contacts from this packet regardless of whether a full path resolves
const resolvedContacts = resolvePacketContacts(
parsed,
prefixIndex,
nameIndex,
myLatLon,
config
);
const path = resolvePacketPath(parsed);
// Only mark as seen if we got something useful; otherwise a later run
// with updated contacts/config can retry this observation.
if (resolvedContacts.size === 0 && !path) continue;
seenObservationsRef.current.add(obsKey);
for (const key of resolvedContacts) newDiscovered.add(key);
if (path) {
newParticles.push({
id: particleIdRef.current++,
path,
color: PARTICLE_COLOR_MAP[getPacketLabel(parsed.payloadType)],
startedAt: now,
});
}
}
if (newDiscovered.size > 0) {
setDiscoveredKeys((prev) => {
const next = new Set(prev);
for (const k of newDiscovered) next.add(k);
return next.size !== prev.size ? next : prev;
});
}
if (newParticles.length === 0) return;
setParticles((prev) => {
const combined = [...prev, ...newParticles];
// Prune expired and cap total
const alive = combined.filter((p) => now - p.startedAt < PARTICLE_LIFETIME_MS);
return alive.slice(-MAX_MAP_PARTICLES);
});
}, [
rawPackets,
showPackets,
resolvePacketPath,
threeDaysAgoSec,
prefixIndex,
nameIndex,
myLatLon,
config,
]);
// Prune expired particles periodically
useEffect(() => {
if (!showPackets) return;
const interval = setInterval(() => {
const now = Date.now();
setParticles((prev) => prev.filter((p) => now - p.startedAt < PARTICLE_LIFETIME_MS));
}, 1000);
return () => clearInterval(interval);
}, [showPackets]);
// Reset discovered set when exiting discovery mode
useEffect(() => {
if (!discoveryMode) setDiscoveredKeys(new Set());
}, [discoveryMode]);
// Clear state when toggling off
useEffect(() => {
if (!showPackets) {
setParticles([]);
setDiscoveredKeys(new Set());
setDiscoveryMode(false);
seenObservationsRef.current.clear();
}
}, [showPackets]);
// Find the focused contact by key // Find the focused contact by key
const focusedContact = useMemo(() => { const focusedContact = useMemo(() => {
@@ -124,18 +620,17 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
const includesFocusedOutsideWindow = const includesFocusedOutsideWindow =
focusedContact != null && focusedContact != null &&
(focusedContact.last_seen == null || focusedContact.last_seen <= sevenDaysAgo); (focusedContact.last_seen == null ||
focusedContact.last_seen <= (showPackets ? threeDaysAgoSec : sevenDaysAgo));
// Track marker refs to open popup programmatically // Track marker refs to open popup programmatically
const markerRefs = useRef<Record<string, LeafletCircleMarker | null>>({}); const markerRefs = useRef<Record<string, LeafletCircleMarker | null>>({});
// Store ref for a marker
const setMarkerRef = useCallback((key: string, ref: LeafletCircleMarker | null) => { const setMarkerRef = useCallback((key: string, ref: LeafletCircleMarker | null) => {
if (ref === null) { if (ref === null) {
delete markerRefs.current[key]; delete markerRefs.current[key];
return; return;
} }
markerRefs.current[key] = ref; markerRefs.current[key] = ref;
}, []); }, []);
@@ -148,10 +643,8 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
} }
}, [mappableContacts]); }, [mappableContacts]);
// Open popup for focused contact after map is ready
useEffect(() => { useEffect(() => {
if (focusedContact && markerRefs.current[focusedContact.public_key]) { if (focusedContact && markerRefs.current[focusedContact.public_key]) {
// Small delay to ensure map has finished rendering
const timer = setTimeout(() => { const timer = setTimeout(() => {
markerRefs.current[focusedContact.public_key]?.openPopup(); markerRefs.current[focusedContact.public_key]?.openPopup();
}, 100); }, 100);
@@ -159,48 +652,104 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
} }
}, [focusedContact]); }, [focusedContact]);
// Gather unique link paths for static route lines when packet viz is on
const routeLines = useMemo(() => {
if (!showPackets) return [];
const seen = new Set<string>();
const lines: { path: [number, number][]; color: string }[] = [];
for (const p of particles) {
const key = p.path.map((w) => `${w[0]},${w[1]}`).join('|');
if (seen.has(key)) continue;
seen.add(key);
lines.push({ path: p.path, color: p.color });
}
return lines;
}, [showPackets, particles]);
const timeWindowLabel = showPackets ? '3 days' : '7 days';
const infoLabel =
showPackets && discoveryMode
? `${mappableContacts.length} node${mappableContacts.length !== 1 ? 's' : ''} discovered from live traffic`
: `Showing ${mappableContacts.length} contact${mappableContacts.length !== 1 ? 's' : ''} heard in the last ${timeWindowLabel}${includesFocusedOutsideWindow ? ' plus the focused contact' : ''}`;
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Info bar */} {/* Info bar */}
<div className="px-4 py-2 bg-muted/50 text-xs text-muted-foreground flex items-center justify-between"> <div className="px-4 py-2 bg-muted/50 text-xs text-muted-foreground flex items-center justify-between">
<span> <span>{infoLabel}</span>
Showing {mappableContacts.length} contact{mappableContacts.length !== 1 ? 's' : ''} heard
in the last 7 days
{includesFocusedOutsideWindow ? ' plus the focused contact' : ''}
</span>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="flex items-center gap-1"> {!showPackets && (
<span <>
className="w-3 h-3 rounded-full" <span className="flex items-center gap-1">
style={{ backgroundColor: MAP_RECENCY_COLORS.recent }} <span
aria-hidden="true" className="w-3 h-3 rounded-full"
/>{' '} style={{ backgroundColor: MAP_RECENCY_COLORS.recent }}
&lt;1h aria-hidden="true"
</span> />{' '}
<span className="flex items-center gap-1"> &lt;1h
<span </span>
className="w-3 h-3 rounded-full" <span className="flex items-center gap-1">
style={{ backgroundColor: MAP_RECENCY_COLORS.today }} <span
aria-hidden="true" className="w-3 h-3 rounded-full"
/>{' '} style={{ backgroundColor: MAP_RECENCY_COLORS.today }}
&lt;1d aria-hidden="true"
</span> />{' '}
<span className="flex items-center gap-1"> &lt;1d
<span </span>
className="w-3 h-3 rounded-full" <span className="flex items-center gap-1">
style={{ backgroundColor: MAP_RECENCY_COLORS.stale }} <span
aria-hidden="true" className="w-3 h-3 rounded-full"
/>{' '} style={{ backgroundColor: MAP_RECENCY_COLORS.stale }}
&lt;3d aria-hidden="true"
</span> />{' '}
<span className="flex items-center gap-1"> &lt;3d
<span </span>
className="w-3 h-3 rounded-full" <span className="flex items-center gap-1">
style={{ backgroundColor: MAP_RECENCY_COLORS.old }} <span
aria-hidden="true" className="w-3 h-3 rounded-full"
/>{' '} style={{ backgroundColor: MAP_RECENCY_COLORS.old }}
older aria-hidden="true"
</span> />{' '}
older
</span>
</>
)}
{showPackets && (
<>
<span className="flex items-center gap-1">
<span
className="w-2 h-2 rounded-full"
style={{ backgroundColor: PARTICLE_COLOR_MAP['AD'] }}
aria-hidden="true"
/>
Ad
</span>
<span className="flex items-center gap-1">
<span
className="w-2 h-2 rounded-full"
style={{ backgroundColor: PARTICLE_COLOR_MAP['GT'] }}
aria-hidden="true"
/>
Ch
</span>
<span className="flex items-center gap-1">
<span
className="w-2 h-2 rounded-full"
style={{ backgroundColor: PARTICLE_COLOR_MAP['DM'] }}
aria-hidden="true"
/>
DM
</span>
<span className="flex items-center gap-1">
<span
className="w-2 h-2 rounded-full"
style={{ backgroundColor: PARTICLE_COLOR_MAP['ACK'] }}
aria-hidden="true"
/>
ACK
</span>
</>
)}
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<span <span
className="w-3 h-3 rounded-full border-2" className="w-3 h-3 rounded-full border-2"
@@ -209,10 +758,30 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
/>{' '} />{' '}
repeater repeater
</span> </span>
<label className="flex items-center gap-1.5 cursor-pointer ml-2">
<input
type="checkbox"
checked={showPackets}
onChange={(e) => setShowPackets(e.target.checked)}
className="rounded border-border"
/>
<span className="text-[0.6875rem]">Visualize packets</span>
</label>
{showPackets && (
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={discoveryMode}
onChange={(e) => setDiscoveryMode(e.target.checked)}
className="rounded border-border"
/>
<span className="text-[0.6875rem]">Discover nodes</span>
</label>
)}
</div> </div>
</div> </div>
{/* Map - z-index constrained to stay below modals/sheets */} {/* Map */}
<div <div
className="flex-1 relative" className="flex-1 relative"
style={{ zIndex: 0 }} style={{ zIndex: 0 }}
@@ -223,14 +792,21 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
center={[20, 0]} center={[20, 0]}
zoom={2} zoom={2}
className="h-full w-full" className="h-full w-full"
style={{ background: '#1a1a2e' }} style={{ background: tile.background }}
> >
<TileLayer <TileLayer key={tile.url} attribution={tile.attribution} url={tile.url} />
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<MapBoundsHandler contacts={mappableContacts} focusedContact={focusedContact} /> <MapBoundsHandler contacts={mappableContacts} focusedContact={focusedContact} />
{/* Faint route lines for active packet paths */}
{showPackets &&
routeLines.map((line, i) => (
<Polyline
key={i}
positions={line.path}
pathOptions={{ color: line.color, weight: 1, opacity: 0.15, dashArray: '4 6' }}
/>
))}
{mappableContacts.map((contact) => { {mappableContacts.map((contact) => {
const isRepeater = contact.type === CONTACT_TYPE_REPEATER; const isRepeater = contact.type === CONTACT_TYPE_REPEATER;
const color = getMarkerColor(contact.last_seen); const color = getMarkerColor(contact.last_seen);
@@ -275,6 +851,8 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
</Fragment> </Fragment>
); );
})} })}
{showPackets && <ParticleOverlay particles={particles} />}
</MapContainer> </MapContainer>
</div> </div>
</div> </div>
+29 -8
View File
@@ -47,6 +47,7 @@ interface MessageListProps {
loadingNewer?: boolean; loadingNewer?: boolean;
onLoadNewer?: () => void; onLoadNewer?: () => void;
onJumpToBottom?: () => void; onJumpToBottom?: () => void;
preSorted?: boolean;
} }
// URL regex for linkifying plain text // URL regex for linkifying plain text
@@ -219,8 +220,8 @@ function HopCountBadge({ paths, onClick, variant }: HopCountBadgeProps) {
const className = const className =
variant === 'header' variant === 'header'
? 'font-normal text-muted-foreground ml-1 text-[11px] cursor-pointer hover:text-primary hover:underline' ? 'font-normal text-muted-foreground ml-1 text-[0.6875rem] cursor-pointer hover:text-primary hover:underline'
: 'text-[10px] text-muted-foreground ml-1 cursor-pointer hover:text-primary hover:underline'; : 'text-[0.625rem] text-muted-foreground ml-1 cursor-pointer hover:text-primary hover:underline';
return ( return (
<span <span
@@ -283,6 +284,7 @@ export function MessageList({
loadingNewer = false, loadingNewer = false,
onLoadNewer, onLoadNewer,
onJumpToBottom, onJumpToBottom,
preSorted = false,
}: MessageListProps) { }: MessageListProps) {
const listRef = useRef<HTMLDivElement>(null); const listRef = useRef<HTMLDivElement>(null);
const prevMessagesLengthRef = useRef<number>(0); const prevMessagesLengthRef = useRef<number>(0);
@@ -298,6 +300,9 @@ export function MessageList({
const [resendableIds, setResendableIds] = useState<Set<number>>(new Set()); const [resendableIds, setResendableIds] = useState<Set<number>>(new Set());
const resendTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map()); const resendTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
const packetCacheRef = useRef<Map<number, RawPacket>>(new Map()); const packetCacheRef = useRef<Map<number, RawPacket>>(new Map());
const packetSignalOverrideRef = useRef<{ rssi: number | null; snr: number | null } | undefined>(
undefined
);
const [packetInspectorSource, setPacketInspectorSource] = useState< const [packetInspectorSource, setPacketInspectorSource] = useState<
| { kind: 'packet'; packet: RawPacket } | { kind: 'packet'; packet: RawPacket }
| { kind: 'loading'; message: string } | { kind: 'loading'; message: string }
@@ -323,6 +328,13 @@ export function MessageList({
const prevConvKeyRef = useRef<string | null>(null); const prevConvKeyRef = useRef<string | null>(null);
const handleAnalyzePacket = useCallback(async (message: Message) => { const handleAnalyzePacket = useCallback(async (message: Message) => {
// Extract signal from the first path if available
const firstPath = message.paths?.[0];
packetSignalOverrideRef.current =
firstPath && (firstPath.rssi != null || firstPath.snr != null)
? { rssi: firstPath.rssi ?? null, snr: firstPath.snr ?? null }
: undefined;
if (message.packet_id == null) { if (message.packet_id == null) {
setPacketInspectorSource({ setPacketInspectorSource({
kind: 'unavailable', kind: 'unavailable',
@@ -486,8 +498,11 @@ export function MessageList({
// Note: Deduplication is handled by useConversationMessages.observeMessage() // Note: Deduplication is handled by useConversationMessages.observeMessage()
// and the database UNIQUE constraint on (type, conversation_key, text, sender_timestamp) // and the database UNIQUE constraint on (type, conversation_key, text, sender_timestamp)
const sortedMessages = useMemo( const sortedMessages = useMemo(
() => [...messages].sort((a, b) => a.received_at - b.received_at || a.id - b.id), () =>
[messages] preSorted
? messages
: [...messages].sort((a, b) => a.received_at - b.received_at || a.id - b.id),
[messages, preSorted]
); );
const unreadMarkerIndex = useMemo(() => { const unreadMarkerIndex = useMemo(() => {
if (unreadMarkerLastReadAt === undefined) { if (unreadMarkerLastReadAt === undefined) {
@@ -960,7 +975,7 @@ export function MessageList({
)} )}
> >
{showAvatar && ( {showAvatar && (
<div className="text-[13px] font-semibold text-foreground mb-0.5"> <div className="text-[0.8125rem] font-semibold text-foreground mb-0.5">
{canClickSender ? ( {canClickSender ? (
<span <span
className="cursor-pointer hover:text-primary transition-colors" className="cursor-pointer hover:text-primary transition-colors"
@@ -975,7 +990,7 @@ export function MessageList({
) : ( ) : (
displaySender displaySender
)} )}
<span className="font-normal text-muted-foreground ml-2 text-[11px]"> <span className="font-normal text-muted-foreground ml-2 text-[0.6875rem]">
{formatTime(msg.sender_timestamp || msg.received_at)} {formatTime(msg.sender_timestamp || msg.received_at)}
</span> </span>
{!msg.outgoing && msg.paths && msg.paths.length > 0 && ( {!msg.outgoing && msg.paths && msg.paths.length > 0 && (
@@ -1003,7 +1018,7 @@ export function MessageList({
))} ))}
{!showAvatar && ( {!showAvatar && (
<> <>
<span className="text-[10px] text-muted-foreground ml-2"> <span className="text-[0.625rem] text-muted-foreground ml-2">
{formatTime(msg.sender_timestamp || msg.received_at)} {formatTime(msg.sender_timestamp || msg.received_at)}
</span> </span>
{!msg.outgoing && msg.paths && msg.paths.length > 0 && ( {!msg.outgoing && msg.paths && msg.paths.length > 0 && (
@@ -1175,12 +1190,18 @@ export function MessageList({
{packetInspectorSource && ( {packetInspectorSource && (
<RawPacketInspectorDialog <RawPacketInspectorDialog
open={packetInspectorSource !== null} open={packetInspectorSource !== null}
onOpenChange={(isOpen) => !isOpen && setPacketInspectorSource(null)} onOpenChange={(isOpen) => {
if (!isOpen) {
setPacketInspectorSource(null);
packetSignalOverrideRef.current = undefined;
}
}}
channels={channels} channels={channels}
source={packetInspectorSource} source={packetInspectorSource}
title="Analyze Packet" title="Analyze Packet"
description="On-demand raw packet analysis for a message-backed archival packet." description="On-demand raw packet analysis for a message-backed archival packet."
notice={ANALYZE_PACKET_NOTICE} notice={ANALYZE_PACKET_NOTICE}
signalOverride={packetSignalOverrideRef.current}
/> />
)} )}
</div> </div>
+16 -5
View File
@@ -103,14 +103,25 @@ export function PathModal({
) : null} ) : null}
{/* Raw path summary */} {/* Raw path summary */}
<div className="text-sm"> <div className="text-sm space-y-1">
{paths.map((p, index) => { {paths.map((p, index) => {
const hops = parsePathHops(p.path, p.path_len); const hops = parsePathHops(p.path, p.path_len);
const rawPath = hops.length > 0 ? hops.join('->') : 'direct'; const rawPath = hops.length > 0 ? hops.join('->') : 'direct';
const hasSignal = p.rssi != null || p.snr != null;
return ( return (
<div key={index}> <div key={index}>
<span className="text-foreground/70 font-semibold">Path {index + 1}:</span>{' '} <div>
<span className="font-mono text-muted-foreground">{rawPath}</span> <span className="text-foreground/70 font-semibold">Path {index + 1}:</span>{' '}
<span className="font-mono text-muted-foreground">{rawPath}</span>
</div>
{hasSignal && (
<div className="text-[0.6875rem] text-muted-foreground ml-4">
Last hop (as heard by you):{' '}
{p.rssi != null && <span>{p.rssi} dBm RSSI</span>}
{p.rssi != null && p.snr != null && <span> · </span>}
{p.snr != null && <span>{p.snr.toFixed(1)} dB SNR</span>}
</div>
)}
</div> </div>
); );
})} })}
@@ -221,7 +232,7 @@ export function PathModal({
> >
<span className="flex flex-col items-center leading-tight"> <span className="flex flex-col items-center leading-tight">
<span> Resend</span> <span> Resend</span>
<span className="text-[10px] font-normal opacity-80"> <span className="text-[0.625rem] font-normal opacity-80">
Only repeated by new repeaters Only repeated by new repeaters
</span> </span>
</span> </span>
@@ -237,7 +248,7 @@ export function PathModal({
> >
<span className="flex flex-col items-center leading-tight"> <span className="flex flex-col items-center leading-tight">
<span> Resend as new</span> <span> Resend as new</span>
<span className="text-[10px] font-normal opacity-80"> <span className="text-[0.625rem] font-normal opacity-80">
Will appear as duplicate to receivers Will appear as duplicate to receivers
</span> </span>
</span> </span>
@@ -35,6 +35,11 @@ type RawPacketInspectorDialogSource =
message: string; message: string;
}; };
interface SignalOverride {
rssi: number | null;
snr: number | null;
}
interface RawPacketInspectorDialogProps { interface RawPacketInspectorDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
@@ -43,10 +48,12 @@ interface RawPacketInspectorDialogProps {
title: string; title: string;
description: string; description: string;
notice?: ReactNode; notice?: ReactNode;
signalOverride?: SignalOverride;
} }
interface RawPacketInspectionPanelProps { interface RawPacketInspectionPanelProps {
packet: RawPacket; packet: RawPacket;
signalOverride?: SignalOverride;
channels: Channel[]; channels: Channel[];
} }
@@ -125,15 +132,21 @@ function formatTimestamp(timestamp: number): string {
}); });
} }
function formatSignal(packet: RawPacket): string { function formatSignal(
const parts: string[] = []; packet: RawPacket,
if (packet.rssi !== null) { signalOverride?: SignalOverride
parts.push(`${packet.rssi} dBm RSSI`); ): { lines: string[]; label: string } {
} const rssi = signalOverride?.rssi ?? packet.rssi;
if (packet.snr !== null) { const snr = signalOverride?.snr ?? packet.snr;
parts.push(`${packet.snr.toFixed(1)} dB SNR`); const lines: string[] = [];
} if (rssi !== null) lines.push(`${rssi} dBm RSSI`);
return parts.length > 0 ? parts.join(' · ') : 'No signal sample'; if (snr !== null) lines.push(`${snr.toFixed(1)} dB SNR`);
const isOverride =
signalOverride != null && (signalOverride.rssi != null || signalOverride.snr != null);
return {
lines: lines.length > 0 ? lines : ['No signal sample'],
label: isOverride ? 'Last Hop Signal' : 'Signal',
};
} }
function formatByteRange(field: PacketByteField): string { function formatByteRange(field: PacketByteField): string {
@@ -312,7 +325,7 @@ function CompactMetaCard({
}) { }) {
return ( return (
<div className="rounded-lg border border-border/70 bg-card/70 p-2.5"> <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="text-[0.625rem] uppercase tracking-wider text-muted-foreground">{label}</div>
<div className="mt-1 text-sm font-medium leading-tight text-foreground">{primary}</div> <div className="mt-1 text-sm font-medium leading-tight text-foreground">{primary}</div>
{secondary ? ( {secondary ? (
<div className="mt-1 text-xs leading-tight text-muted-foreground">{secondary}</div> <div className="mt-1 text-xs leading-tight text-muted-foreground">{secondary}</div>
@@ -340,7 +353,7 @@ function FullPacketHex({
const byteRuns = useMemo(() => buildByteRuns(bytes, byteOwners), [byteOwners, bytes]); const byteRuns = useMemo(() => buildByteRuns(bytes, byteOwners), [byteOwners, bytes]);
return ( return (
<div className="font-mono text-[15px] leading-7 text-foreground"> <div className="font-mono text-[0.9375rem] leading-7 text-foreground">
{byteRuns.map((run, index) => { {byteRuns.map((run, index) => {
const fieldId = run.fieldId; const fieldId = run.fieldId;
const palette = fieldId ? colorMap.get(fieldId) : null; const palette = fieldId ? colorMap.get(fieldId) : null;
@@ -446,7 +459,9 @@ function FieldBox({
<div className="flex flex-col items-start gap-2 sm:flex-row sm:justify-between"> <div className="flex flex-col items-start gap-2 sm:flex-row sm:justify-between">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-base font-semibold leading-tight text-foreground">{field.name}</div> <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 className="mt-0.5 text-[0.6875rem] text-muted-foreground">
{formatByteRange(field)}
</div>
</div> </div>
<div <div
className={cn( className={cn(
@@ -464,7 +479,7 @@ function FieldBox({
{field.decryptedMessage ? ( {field.decryptedMessage ? (
<div className="mt-2 rounded border border-border/50 bg-background/40 p-2"> <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"> <div className="text-[0.625rem] uppercase tracking-wider text-muted-foreground">
{field.name === 'Ciphertext' ? 'Plaintext' : 'Decoded value'} {field.name === 'Ciphertext' ? 'Plaintext' : 'Decoded value'}
</div> </div>
<PlaintextContent text={field.decryptedMessage} /> <PlaintextContent text={field.decryptedMessage} />
@@ -486,11 +501,13 @@ function FieldBox({
<div className="text-sm font-medium leading-tight text-foreground"> <div className="text-sm font-medium leading-tight text-foreground">
{part.field} {part.field}
</div> </div>
<div className="mt-0.5 text-[11px] text-muted-foreground">Bits {part.bits}</div> <div className="mt-0.5 text-[0.6875rem] text-muted-foreground">
Bits {part.bits}
</div>
</div> </div>
<div className="text-right"> <div className="text-right">
<div className="font-mono text-sm text-foreground">{part.binary}</div> <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 className="mt-0.5 text-[0.6875rem] text-muted-foreground">{part.value}</div>
</div> </div>
</div> </div>
</div> </div>
@@ -565,7 +582,11 @@ function FieldSection({
); );
} }
export function RawPacketInspectionPanel({ packet, channels }: RawPacketInspectionPanelProps) { export function RawPacketInspectionPanel({
packet,
channels,
signalOverride,
}: RawPacketInspectionPanelProps) {
const decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]); const decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]);
const groupTextCandidates = useMemo( const groupTextCandidates = useMemo(
() => buildGroupTextResolutionCandidates(channels), () => buildGroupTextResolutionCandidates(channels),
@@ -598,7 +619,7 @@ export function RawPacketInspectionPanel({ packet, channels }: RawPacketInspecti
<section className="rounded-lg border border-border/70 bg-card/70 p-3"> <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="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground"> <div className="text-[0.625rem] uppercase tracking-wider text-muted-foreground">
Summary Summary
</div> </div>
<div className="mt-1 text-base font-semibold leading-tight text-foreground"> <div className="mt-1 text-base font-semibold leading-tight text-foreground">
@@ -611,7 +632,7 @@ export function RawPacketInspectionPanel({ packet, channels }: RawPacketInspecti
</div> </div>
{packetContext ? ( {packetContext ? (
<div className="mt-2 rounded-md border border-border/60 bg-background/35 px-2.5 py-2"> <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"> <div className="text-[0.625rem] uppercase tracking-wider text-muted-foreground">
{packetContext.title} {packetContext.title}
</div> </div>
<div className="mt-1 text-sm font-medium leading-tight text-foreground"> <div className="mt-1 text-sm font-medium leading-tight text-foreground">
@@ -637,11 +658,24 @@ export function RawPacketInspectionPanel({ packet, channels }: RawPacketInspecti
primary={`${inspection.routeTypeName} · ${inspection.payloadTypeName}`} primary={`${inspection.routeTypeName} · ${inspection.payloadTypeName}`}
secondary={`${inspection.payloadVersionName} · ${formatPathMode(inspection.decoded?.pathHashSize, inspection.pathTokens.length)}`} secondary={`${inspection.payloadVersionName} · ${formatPathMode(inspection.decoded?.pathHashSize, inspection.pathTokens.length)}`}
/> />
<CompactMetaCard {(() => {
label="Signal" const sig = formatSignal(packet, signalOverride);
primary={formatSignal(packet)} return (
secondary={packetContext ? null : undefined} <div className="rounded-lg border border-border/70 bg-card/70 p-2.5">
/> <div className="text-[0.625rem] uppercase tracking-wider text-muted-foreground">
{sig.label}
</div>
{sig.lines.map((line, i) => (
<div
key={i}
className={`${i === 0 ? 'mt-1' : 'mt-0.5'} text-sm font-medium leading-tight text-foreground`}
>
{line}
</div>
))}
</div>
);
})()}
</section> </section>
</div> </div>
@@ -711,6 +745,7 @@ export function RawPacketInspectorDialog({
title, title,
description, description,
notice, notice,
signalOverride,
}: RawPacketInspectorDialogProps) { }: RawPacketInspectorDialogProps) {
const [packetInput, setPacketInput] = useState(''); const [packetInput, setPacketInput] = useState('');
@@ -735,7 +770,13 @@ export function RawPacketInspectorDialog({
let body: ReactNode; let body: ReactNode;
if (source.kind === 'packet') { if (source.kind === 'packet') {
body = <RawPacketInspectionPanel packet={source.packet} channels={channels} />; body = (
<RawPacketInspectionPanel
packet={source.packet}
channels={channels}
signalOverride={signalOverride}
/>
);
} else if (source.kind === 'paste') { } else if (source.kind === 'paste') {
body = ( body = (
<> <>
@@ -211,7 +211,9 @@ function getCoverageMessage(
function StatTile({ label, value, detail }: { label: string; value: string; detail?: string }) { function StatTile({ label, value, detail }: { label: string; value: string; detail?: string }) {
return ( return (
<div className="break-inside-avoid rounded-lg border border-border/70 bg-card/80 p-3"> <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="text-[0.625rem] uppercase tracking-wider font-medium text-muted-foreground">
{label}
</div>
<div className="mt-1 text-xl font-semibold tabular-nums text-foreground">{value}</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} {detail ? <div className="mt-1 text-xs text-muted-foreground">{detail}</div> : null}
</div> </div>
@@ -329,7 +331,7 @@ function NeighborList({
: `Last seen ${new Date(item.lastSeen * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`} : `Last seen ${new Date(item.lastSeen * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`}
</div> </div>
{!isNeighborIdentityResolvable(item, contacts) ? ( {!isNeighborIdentityResolvable(item, contacts) ? (
<div className="text-[11px] text-warning">Identity not resolvable</div> <div className="text-[0.6875rem] text-warning">Identity not resolvable</div>
) : null} ) : null}
</div> </div>
{mode !== 'signal' ? ( {mode !== 'signal' ? (
@@ -363,7 +365,7 @@ function TimelineChart({ bins }: { bins: PacketTimelineBin[] }) {
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3"> <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"> <div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-foreground">Traffic Timeline</h3> <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"> <div className="flex flex-wrap justify-end gap-2 text-[0.6875rem] text-muted-foreground">
{typeOrder.map((type, i) => ( {typeOrder.map((type, i) => (
<span key={type} className="inline-flex items-center gap-1"> <span key={type} className="inline-flex items-center gap-1">
<span <span
@@ -513,7 +515,7 @@ export function RawPacketFeedView({
<div className="break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3"> <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 className="flex flex-wrap items-center justify-between gap-3">
<div> <div>
<div className="text-[11px] uppercase tracking-wide text-muted-foreground"> <div className="text-[0.625rem] uppercase tracking-wider font-medium text-muted-foreground">
Coverage Coverage
</div> </div>
<div <div
+8 -5
View File
@@ -101,7 +101,7 @@ export function RawPacketList({ packets, channels, onPacketClick }: RawPacketLis
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Route type badge */} {/* Route type badge */}
<span <span
className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${getRouteTypeColor(decoded.routeType)}`} className={`text-[0.625rem] font-mono px-1.5 py-0.5 rounded ${getRouteTypeColor(decoded.routeType)}`}
title={decoded.routeType} title={decoded.routeType}
> >
{getRouteTypeLabel(decoded.routeType)} {getRouteTypeLabel(decoded.routeType)}
@@ -117,26 +117,29 @@ export function RawPacketList({ packets, channels, onPacketClick }: RawPacketLis
{/* Summary */} {/* Summary */}
<span <span
className={cn('text-[13px]', packet.decrypted ? 'text-primary' : 'text-foreground')} className={cn(
'text-[0.8125rem]',
packet.decrypted ? 'text-primary' : 'text-foreground'
)}
> >
{decoded.summary} {decoded.summary}
</span> </span>
{/* Time */} {/* Time */}
<span className="text-muted-foreground ml-auto text-[12px] tabular-nums"> <span className="text-muted-foreground ml-auto text-xs tabular-nums">
{formatTime(packet.timestamp)} {formatTime(packet.timestamp)}
</span> </span>
</div> </div>
{/* Signal info */} {/* Signal info */}
{(packet.snr !== null || packet.rssi !== null) && ( {(packet.snr !== null || packet.rssi !== null) && (
<div className="text-[11px] text-muted-foreground mt-0.5 tabular-nums"> <div className="text-[0.6875rem] text-muted-foreground mt-0.5 tabular-nums">
{formatSignalInfo(packet)} {formatSignalInfo(packet)}
</div> </div>
)} )}
{/* Raw hex data (always visible) */} {/* 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"> <div className="font-mono text-[0.625rem] break-all text-muted-foreground mt-1.5 p-1.5 bg-background/60 rounded">
{packet.data.toUpperCase()} {packet.data.toUpperCase()}
</div> </div>
</> </>
+15 -5
View File
@@ -54,6 +54,8 @@ interface RepeaterDashboardProps {
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void; onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
onDeleteContact: (publicKey: string) => void; onDeleteContact: (publicKey: string) => void;
onOpenContactInfo?: (publicKey: string) => void; onOpenContactInfo?: (publicKey: string) => void;
trackedTelemetryRepeaters: string[];
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
} }
export function RepeaterDashboard({ export function RepeaterDashboard({
@@ -72,6 +74,8 @@ export function RepeaterDashboard({
onToggleFavorite, onToggleFavorite,
onDeleteContact, onDeleteContact,
onOpenContactInfo, onOpenContactInfo,
trackedTelemetryRepeaters,
onToggleTrackedTelemetry,
}: RepeaterDashboardProps) { }: RepeaterDashboardProps) {
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false); const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
const contact = contacts.find((c) => c.public_key === conversation.id) ?? null; const contact = contacts.find((c) => c.public_key === conversation.id) ?? null;
@@ -177,7 +181,7 @@ export function RepeaterDashboard({
)} )}
</h2> </h2>
<span <span
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary" className="min-w-0 flex-1 truncate font-mono text-[0.6875rem] text-muted-foreground transition-colors hover:text-primary"
role="button" role="button"
tabIndex={0} tabIndex={0}
onKeyDown={handleKeyboardActivate} onKeyDown={handleKeyboardActivate}
@@ -193,7 +197,7 @@ export function RepeaterDashboard({
</span> </span>
</span> </span>
{contact && ( {contact && (
<div className="col-span-2 row-start-2 min-w-0 text-[11px] text-muted-foreground min-[1100px]:col-span-1 min-[1100px]:col-start-2 min-[1100px]:row-start-1"> <div className="col-span-2 row-start-2 min-w-0 text-[0.6875rem] text-muted-foreground min-[1100px]:col-span-1 min-[1100px]:col-start-2 min-[1100px]:row-start-1">
<ContactStatusInfo contact={contact} ourLat={radioLat} ourLon={radioLon} /> <ContactStatusInfo contact={contact} ourLat={radioLat} ourLon={radioLon} />
</div> </div>
)} )}
@@ -204,7 +208,7 @@ export function RepeaterDashboard({
size="sm" size="sm"
onClick={loadAll} onClick={loadAll}
disabled={anyLoading} disabled={anyLoading}
className="h-7 px-2 text-[11px] leading-none border-success text-success hover:bg-success/10 hover:text-success sm:h-8 sm:px-3 sm:text-xs" className="h-7 px-2 text-[0.6875rem] leading-none border-success text-success hover:bg-success/10 hover:text-success sm:h-8 sm:px-3 sm:text-xs"
> >
{anyLoading ? 'Loading...' : 'Load All'} {anyLoading ? 'Loading...' : 'Load All'}
</Button> </Button>
@@ -250,7 +254,7 @@ export function RepeaterDashboard({
aria-hidden="true" aria-hidden="true"
/> />
{notificationsEnabled && ( {notificationsEnabled && (
<span className="hidden md:inline text-[11px] font-medium text-status-connected"> <span className="hidden md:inline text-[0.6875rem] font-medium text-status-connected">
Notifications On Notifications On
</span> </span>
)} )}
@@ -396,7 +400,13 @@ export function RepeaterDashboard({
/> />
{/* Telemetry history chart — full width, below console */} {/* Telemetry history chart — full width, below console */}
<TelemetryHistoryPane entries={telemetryHistory} /> <TelemetryHistoryPane
entries={telemetryHistory}
publicKey={conversation.id}
contacts={contacts}
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
/>
</div> </div>
)} )}
</div> </div>
+4 -4
View File
@@ -290,7 +290,7 @@ export function SearchView({
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<span <span
className={cn( className={cn(
'text-[10px] font-medium px-1.5 py-0.5 rounded', 'text-[0.625rem] font-medium px-1.5 py-0.5 rounded',
result.type === 'CHAN' result.type === 'CHAN'
? 'bg-primary/20 text-primary' ? 'bg-primary/20 text-primary'
: 'bg-secondary text-secondary-foreground' : 'bg-secondary text-secondary-foreground'
@@ -298,12 +298,12 @@ export function SearchView({
> >
{typeBadge} {typeBadge}
</span> </span>
<span className="text-[12px] font-medium text-foreground truncate">{convName}</span> <span className="text-xs font-medium text-foreground truncate">{convName}</span>
<span className="text-[11px] text-muted-foreground ml-auto flex-shrink-0"> <span className="text-[0.6875rem] text-muted-foreground ml-auto flex-shrink-0">
{formatTime(result.received_at)} {formatTime(result.received_at)}
</span> </span>
</div> </div>
<div className="text-[13px] text-foreground/80 line-clamp-2 break-words"> <div className="text-[0.8125rem] text-foreground/80 line-clamp-2 break-words">
{result.sender_name && !result.outgoing && ( {result.sender_name && !result.outgoing && (
<span className="text-muted-foreground">{result.sender_name}: </span> <span className="text-muted-foreground">{result.sender_name}: </span>
)} )}
@@ -50,6 +50,8 @@ interface SettingsModalBaseProps {
onToggleBlockedName?: (name: string) => void; onToggleBlockedName?: (name: string) => void;
contacts?: Contact[]; contacts?: Contact[];
onBulkDeleteContacts?: (deletedKeys: string[]) => void; onBulkDeleteContacts?: (deletedKeys: string[]) => void;
trackedTelemetryRepeaters?: string[];
onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>;
} }
export type SettingsModalProps = SettingsModalBaseProps & export type SettingsModalProps = SettingsModalBaseProps &
@@ -85,6 +87,8 @@ export function SettingsModal(props: SettingsModalProps) {
onToggleBlockedName, onToggleBlockedName,
contacts, contacts,
onBulkDeleteContacts, onBulkDeleteContacts,
trackedTelemetryRepeaters,
onToggleTrackedTelemetry,
} = props; } = props;
const externalSidebarNav = props.externalSidebarNav === true; const externalSidebarNav = props.externalSidebarNav === true;
const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined; const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined;
@@ -246,6 +250,8 @@ export function SettingsModal(props: SettingsModalProps) {
onToggleBlockedName={onToggleBlockedName} onToggleBlockedName={onToggleBlockedName}
contacts={contacts} contacts={contacts}
onBulkDeleteContacts={onBulkDeleteContacts} onBulkDeleteContacts={onBulkDeleteContacts}
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
className={sectionContentClass} className={sectionContentClass}
/> />
) : ( ) : (
+17 -52
View File
@@ -107,36 +107,19 @@ interface SidebarProps {
onToggleCracker: () => void; onToggleCracker: () => void;
onMarkAllRead: () => void; onMarkAllRead: () => void;
favorites: Favorite[]; favorites: Favorite[];
/** Legacy global sort order, used only to seed per-section local preferences. */
legacySortOrder?: SortOrder;
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean; isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
blockedKeys?: string[]; blockedKeys?: string[];
blockedNames?: string[]; blockedNames?: string[];
} }
type InitialSectionSortState = { function loadInitialSectionSortOrders(): SidebarSectionSortOrders {
orders: SidebarSectionSortOrders;
source: 'section' | 'legacy' | 'none';
};
function loadInitialSectionSortOrders(): InitialSectionSortState {
const storedOrders = loadLocalStorageSidebarSectionSortOrders(); const storedOrders = loadLocalStorageSidebarSectionSortOrders();
if (storedOrders) { if (storedOrders) return storedOrders;
return { orders: storedOrders, source: 'section' };
}
const legacyOrder = loadLegacyLocalStorageSortOrder(); const legacyOrder = loadLegacyLocalStorageSortOrder();
if (legacyOrder) { const orders = buildSidebarSectionSortOrders(legacyOrder ?? undefined);
return { saveLocalStorageSidebarSectionSortOrders(orders);
orders: buildSidebarSectionSortOrders(legacyOrder), return orders;
source: 'legacy',
};
}
return {
orders: buildSidebarSectionSortOrders(),
source: 'none',
};
} }
export function Sidebar({ export function Sidebar({
@@ -153,7 +136,6 @@ export function Sidebar({
onToggleCracker, onToggleCracker,
onMarkAllRead, onMarkAllRead,
favorites, favorites,
legacySortOrder,
isConversationNotificationsEnabled, isConversationNotificationsEnabled,
blockedKeys = [], blockedKeys = [],
blockedNames = [], blockedNames = [],
@@ -166,8 +148,8 @@ export function Sidebar({
); );
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const initialSectionSortState = useMemo(loadInitialSectionSortOrders, []); const initialSectionSortOrders = useMemo(loadInitialSectionSortOrders, []);
const [sectionSortOrders, setSectionSortOrders] = useState(initialSectionSortState.orders); const [sectionSortOrders, setSectionSortOrders] = useState(initialSectionSortOrders);
const initialCollapsedState = useMemo(loadCollapsedState, []); const initialCollapsedState = useMemo(loadCollapsedState, []);
const [toolsCollapsed, setToolsCollapsed] = useState(initialCollapsedState.tools); const [toolsCollapsed, setToolsCollapsed] = useState(initialCollapsedState.tools);
const [favoritesCollapsed, setFavoritesCollapsed] = useState(initialCollapsedState.favorites); const [favoritesCollapsed, setFavoritesCollapsed] = useState(initialCollapsedState.favorites);
@@ -176,29 +158,12 @@ export function Sidebar({
const [roomsCollapsed, setRoomsCollapsed] = useState(initialCollapsedState.rooms); const [roomsCollapsed, setRoomsCollapsed] = useState(initialCollapsedState.rooms);
const [repeatersCollapsed, setRepeatersCollapsed] = useState(initialCollapsedState.repeaters); const [repeatersCollapsed, setRepeatersCollapsed] = useState(initialCollapsedState.repeaters);
const collapseSnapshotRef = useRef<CollapseState | null>(null); const collapseSnapshotRef = useRef<CollapseState | null>(null);
const sectionSortSourceRef = useRef(initialSectionSortState.source);
useEffect(() => {
if (sectionSortSourceRef.current === 'legacy') {
saveLocalStorageSidebarSectionSortOrders(sectionSortOrders);
sectionSortSourceRef.current = 'section';
return;
}
if (sectionSortSourceRef.current !== 'none' || legacySortOrder === undefined) return;
const seededOrders = buildSidebarSectionSortOrders(legacySortOrder);
setSectionSortOrders(seededOrders);
saveLocalStorageSidebarSectionSortOrders(seededOrders);
sectionSortSourceRef.current = 'section';
}, [legacySortOrder, sectionSortOrders]);
const handleSortToggle = (section: SidebarSortableSection) => { const handleSortToggle = (section: SidebarSortableSection) => {
setSectionSortOrders((prev) => { setSectionSortOrders((prev) => {
const nextOrder = prev[section] === 'alpha' ? 'recent' : 'alpha'; const nextOrder = prev[section] === 'alpha' ? 'recent' : 'alpha';
const updated = { ...prev, [section]: nextOrder }; const updated = { ...prev, [section]: nextOrder };
saveLocalStorageSidebarSectionSortOrders(updated); saveLocalStorageSidebarSectionSortOrders(updated);
sectionSortSourceRef.current = 'section';
return updated; return updated;
}); });
}; };
@@ -619,7 +584,7 @@ export function Sidebar({
contactType={row.contact.type} contactType={row.contact.type}
/> />
)} )}
<span className="name flex-1 truncate text-[13px]">{row.name}</span> <span className="name flex-1 truncate text-[0.8125rem]">{row.name}</span>
<span className="ml-auto flex items-center gap-1"> <span className="ml-auto flex items-center gap-1">
{row.notificationsEnabled && ( {row.notificationsEnabled && (
<span aria-label="Notifications enabled" title="Notifications enabled"> <span aria-label="Notifications enabled" title="Notifications enabled">
@@ -629,7 +594,7 @@ export function Sidebar({
{row.unreadCount > 0 && ( {row.unreadCount > 0 && (
<span <span
className={cn( className={cn(
'text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center', 'text-[0.625rem] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
highlightUnread highlightUnread
? 'bg-badge-mention text-badge-mention-foreground' ? 'bg-badge-mention text-badge-mention-foreground'
: 'bg-badge-unread/90 text-badge-unread-foreground' : 'bg-badge-unread/90 text-badge-unread-foreground'
@@ -661,7 +626,7 @@ export function Sidebar({
key={key} key={key}
data-active={active ? 'true' : undefined} data-active={active ? 'true' : undefined}
className={cn( className={cn(
'sidebar-action-row px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 'sidebar-action-row px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[0.8125rem] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
active && 'bg-accent border-l-primary' active && 'bg-accent border-l-primary'
)} )}
role="button" role="button"
@@ -770,7 +735,7 @@ export function Sidebar({
{showCracker ? 'Hide' : 'Show'} Channel Finder {showCracker ? 'Hide' : 'Show'} Channel Finder
<span <span
className={cn( className={cn(
'ml-1 text-[11px]', 'ml-1 text-[0.6875rem]',
crackerRunning ? 'text-primary' : 'text-muted-foreground' crackerRunning ? 'text-primary' : 'text-muted-foreground'
)} )}
> >
@@ -798,7 +763,7 @@ export function Sidebar({
<div className="flex justify-between items-center px-3 py-2 pt-3.5"> <div className="flex justify-between items-center px-3 py-2 pt-3.5">
<button <button
className={cn( className={cn(
'flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded', 'flex items-center gap-1.5 text-[0.625rem] uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded',
isSearching && 'cursor-default' isSearching && 'cursor-default'
)} )}
aria-expanded={!effectiveCollapsed} aria-expanded={!effectiveCollapsed}
@@ -818,7 +783,7 @@ export function Sidebar({
<div className="ml-auto flex items-center gap-1.5"> <div className="ml-auto flex items-center gap-1.5">
{sortSection && sectionSortOrder && ( {sortSection && sectionSortOrder && (
<button <button
className="bg-transparent text-muted-foreground/60 px-1 py-0.5 text-[10px] rounded hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="bg-transparent text-muted-foreground/60 px-1 py-0.5 text-[0.625rem] rounded hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => handleSortToggle(sortSection)} onClick={() => handleSortToggle(sortSection)}
aria-label={ aria-label={
sectionSortOrder === 'alpha' sectionSortOrder === 'alpha'
@@ -837,7 +802,7 @@ export function Sidebar({
{unreadCount > 0 && ( {unreadCount > 0 && (
<span <span
className={cn( className={cn(
'text-[10px] font-medium px-1.5 py-0.5 rounded-full', 'text-[0.625rem] font-medium px-1.5 py-0.5 rounded-full',
highlightUnread highlightUnread
? 'bg-badge-mention text-badge-mention-foreground' ? 'bg-badge-mention text-badge-mention-foreground'
: 'bg-secondary text-muted-foreground' : 'bg-secondary text-muted-foreground'
@@ -866,7 +831,7 @@ export function Sidebar({
onClick={onNewMessage} onClick={onNewMessage}
title="Add channel or contact" title="Add channel or contact"
aria-label="Add channel or contact" aria-label="Add channel or contact"
className="h-8 w-full justify-start gap-2 border-primary/20 bg-primary/5 px-3 text-[13px] text-primary hover:bg-primary/10 hover:text-primary" className="h-8 w-full justify-start gap-2 border-primary/20 bg-primary/5 px-3 text-[0.8125rem] text-primary hover:bg-primary/10 hover:text-primary"
> >
<SquarePen className="h-4 w-4" /> <SquarePen className="h-4 w-4" />
<span>Add Channel/Contact</span> <span>Add Channel/Contact</span>
@@ -883,7 +848,7 @@ export function Sidebar({
aria-label="Search conversations" aria-label="Search conversations"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className={cn('h-7 text-[13px] bg-background/50', searchQuery ? 'pr-8' : 'pr-3')} className={cn('h-7 text-[0.8125rem] bg-background/50', searchQuery ? 'pr-8' : 'pr-3')}
/> />
{searchQuery && ( {searchQuery && (
<button <button
@@ -909,7 +874,7 @@ export function Sidebar({
{/* Mark All Read */} {/* Mark All Read */}
{!query && Object.values(unreadCounts).some((c) => c > 0) && ( {!query && Object.values(unreadCounts).some((c) => c > 0) && (
<div <div
className="px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[0.8125rem] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
role="button" role="button"
tabIndex={0} tabIndex={0}
onKeyDown={handleKeyboardActivate} onKeyDown={handleKeyboardActivate}
+1 -1
View File
@@ -123,7 +123,7 @@ export function StatusBar({
<div className="hidden lg:flex items-center gap-2 text-muted-foreground"> <div className="hidden lg:flex items-center gap-2 text-muted-foreground">
<span className="text-foreground font-medium">{config.name || 'Unnamed'}</span> <span className="text-foreground font-medium">{config.name || 'Unnamed'}</span>
<span <span
className="font-mono text-[11px] text-muted-foreground cursor-pointer hover:text-primary transition-colors" className="font-mono text-[0.6875rem] text-muted-foreground cursor-pointer hover:text-primary transition-colors"
role="button" role="button"
tabIndex={0} tabIndex={0}
onKeyDown={handleKeyboardActivate} onKeyDown={handleKeyboardActivate}
+7 -7
View File
@@ -118,7 +118,7 @@ function TraceNodeRow({
> >
<div <div
className={cn( className={cn(
'flex h-9 w-9 items-center justify-center rounded-full border text-[11px] font-semibold uppercase tracking-wide', 'flex h-9 w-9 items-center justify-center rounded-full border text-[0.6875rem] font-semibold uppercase tracking-wide',
fixed fixed
? 'border-primary/30 bg-primary/10 text-primary' ? 'border-primary/30 bg-primary/10 text-primary'
: 'border-border bg-muted text-muted-foreground' : 'border-border bg-muted text-muted-foreground'
@@ -129,12 +129,12 @@ function TraceNodeRow({
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{title}</div> <div className="truncate text-sm font-medium">{title}</div>
<div className="truncate text-xs text-muted-foreground">{subtitle}</div> <div className="truncate text-xs text-muted-foreground">{subtitle}</div>
{meta ? <div className="mt-1 text-[11px] text-muted-foreground">{meta}</div> : null} {meta ? <div className="mt-1 text-[0.6875rem] text-muted-foreground">{meta}</div> : null}
{note ? <div className="mt-1 text-[11px] text-muted-foreground">{note}</div> : null} {note ? <div className="mt-1 text-[0.6875rem] text-muted-foreground">{note}</div> : null}
</div> </div>
{snr ? ( {snr ? (
<div className="shrink-0 text-right"> <div className="shrink-0 text-right">
<div className="text-[11px] text-muted-foreground">SNR</div> <div className="text-[0.6875rem] text-muted-foreground">SNR</div>
<div className="font-mono text-sm">{snr}</div> <div className="font-mono text-sm">{snr}</div>
</div> </div>
) : null} ) : null}
@@ -370,7 +370,7 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
))} ))}
</div> </div>
{sortMode === 'distance' && !canSortByDistance ? ( {sortMode === 'distance' && !canSortByDistance ? (
<p className="mt-2 text-[11px] text-muted-foreground"> <p className="mt-2 text-[0.6875rem] text-muted-foreground">
Distance sorting is using known repeater coordinates, but the local radio does not Distance sorting is using known repeater coordinates, but the local radio does not
currently have a valid location. currently have a valid location.
</p> </p>
@@ -421,12 +421,12 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
{getShortKey(contact.public_key)} {getShortKey(contact.public_key)}
</div> </div>
{sortMode === 'distance' && distanceKm !== null ? ( {sortMode === 'distance' && distanceKm !== null ? (
<div className="mt-1 text-[11px] text-muted-foreground"> <div className="mt-1 text-[0.6875rem] text-muted-foreground">
{distanceKm.toFixed(1)} km away {distanceKm.toFixed(1)} km away
</div> </div>
) : null} ) : null}
{selectedCount > 0 ? ( {selectedCount > 0 ? (
<div className="mt-1 text-[11px] text-muted-foreground"> <div className="mt-1 text-[0.6875rem] text-muted-foreground">
Added {selectedCount} time{selectedCount === 1 ? '' : 's'} Added {selectedCount} time{selectedCount === 1 ? '' : 's'}
</div> </div>
) : null} ) : null}
@@ -9,7 +9,11 @@ import {
ResponsiveContainer, ResponsiveContainer,
} from 'recharts'; } from 'recharts';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { TelemetryHistoryEntry } from '../../types'; import { Button } from '../ui/button';
import { Separator } from '../ui/separator';
import type { TelemetryHistoryEntry, Contact } from '../../types';
const MAX_TRACKED = 8;
type Metric = 'battery_volts' | 'noise_floor_dbm' | 'packets' | 'uptime_seconds'; type Metric = 'battery_volts' | 'noise_floor_dbm' | 'packets' | 'uptime_seconds';
@@ -47,8 +51,26 @@ function formatUptime(seconds: number): string {
return `${(seconds / 86400).toFixed(1)}d`; return `${(seconds / 86400).toFixed(1)}d`;
} }
export function TelemetryHistoryPane({ entries }: { entries: TelemetryHistoryEntry[] }) { interface TelemetryHistoryPaneProps {
entries: TelemetryHistoryEntry[];
publicKey: string;
contacts: Contact[];
trackedTelemetryRepeaters: string[];
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
}
export function TelemetryHistoryPane({
entries,
publicKey,
contacts,
trackedTelemetryRepeaters,
onToggleTrackedTelemetry,
}: TelemetryHistoryPaneProps) {
const [metric, setMetric] = useState<Metric>('battery_volts'); const [metric, setMetric] = useState<Metric>('battery_volts');
const [toggling, setToggling] = useState(false);
const isTracked = trackedTelemetryRepeaters.includes(publicKey);
const slotsFull = trackedTelemetryRepeaters.length >= MAX_TRACKED && !isTracked;
const config = METRIC_CONFIG[metric]; const config = METRIC_CONFIG[metric];
@@ -68,13 +90,87 @@ export function TelemetryHistoryPane({ entries }: { entries: TelemetryHistoryEnt
const dataKeys = metric === 'packets' ? ['packets_received', 'packets_sent'] : [metric]; const dataKeys = metric === 'packets' ? ['packets_received', 'packets_sent'] : [metric];
const handleToggle = async () => {
setToggling(true);
try {
await onToggleTrackedTelemetry(publicKey);
} finally {
setToggling(false);
}
};
const trackedNames = useMemo(() => {
if (!slotsFull) return [];
return trackedTelemetryRepeaters.map((key) => {
const contact = contacts.find((c) => c.public_key === key);
return { key, name: contact?.name ?? key.slice(0, 12) };
});
}, [slotsFull, trackedTelemetryRepeaters, contacts]);
return ( return (
<div className="border border-border rounded-lg overflow-hidden"> <div className="border border-border rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border"> <div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border">
<h3 className="text-sm font-medium">Telemetry History</h3> <div className="flex items-center gap-2">
<span className="text-[10px] text-muted-foreground">{entries.length} samples</span> <h3 className="text-sm font-medium">Telemetry History</h3>
{entries.length > 0 && (
<span className="text-[0.625rem] text-muted-foreground">{entries.length} samples</span>
)}
</div>
</div> </div>
<div className="p-3"> <div className="p-3">
{/* Explanation + tracking toggle */}
<div className="mb-3 space-y-3">
<p className="text-xs text-muted-foreground leading-relaxed">
Any time repeater telemetry is fetched, the metrics are stored for 30 days (or 1,000
samples, whichever comes first). This telemetry is stored on normal interactive fetches
via the repeater pane, API calls to the endpoint (
<code className="text-[0.6875rem]">POST /api/contacts/&lt;key&gt;/repeater/status</code>
), or when the repeater is opted into interval telemetry polling, in which case the
repeater will be polled for metrics every 8 hours. You can see which repeaters are opted
into this flow in the{' '}
<a
href="#settings/database"
className="underline text-primary hover:text-primary/80 transition-colors"
>
Database &amp; Messaging
</a>{' '}
settings pane. A maximum of {MAX_TRACKED} repeaters may be opted into this for the sake
of keeping mesh congestion reasonable.
</p>
{isTracked ? (
<Button
variant="outline"
onClick={handleToggle}
disabled={toggling}
className="border-destructive/50 text-destructive hover:bg-destructive/10"
>
{toggling ? 'Updating...' : 'Remove Repeater from Interval Metrics Tracking'}
</Button>
) : slotsFull ? (
<div className="space-y-2">
<Button variant="outline" disabled>
Tracking Full ({trackedTelemetryRepeaters.length}/{MAX_TRACKED} slots used)
</Button>
<p className="text-xs text-muted-foreground">
Disable tracking on another repeater to free a slot:{' '}
{trackedNames.map((t) => t.name).join(', ')}
</p>
</div>
) : (
<Button
variant="outline"
onClick={handleToggle}
disabled={toggling}
className="border-green-600/50 text-green-600 hover:bg-green-600/10"
>
{toggling ? 'Updating...' : 'Opt Repeater into 8hr Interval Metrics Tracking'}
</Button>
)}
</div>
<Separator className="mb-3" />
{/* Metric selector */} {/* Metric selector */}
<div className="flex gap-1 mb-2"> <div className="flex gap-1 mb-2">
{(Object.keys(METRIC_CONFIG) as Metric[]).map((m) => ( {(Object.keys(METRIC_CONFIG) as Metric[]).map((m) => (
@@ -83,7 +179,7 @@ export function TelemetryHistoryPane({ entries }: { entries: TelemetryHistoryEnt
type="button" type="button"
onClick={() => setMetric(m)} onClick={() => setMetric(m)}
className={cn( className={cn(
'text-[11px] px-2 py-0.5 rounded transition-colors', 'text-[0.6875rem] px-2 py-0.5 rounded transition-colors',
metric === m metric === m
? 'bg-primary text-primary-foreground' ? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-accent' : 'text-muted-foreground hover:text-foreground hover:bg-accent'
@@ -149,10 +245,15 @@ export function TelemetryHistoryPane({ entries }: { entries: TelemetryHistoryEnt
fill={metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color} fill={metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color}
fillOpacity={0.15} fillOpacity={0.15}
strokeWidth={1.5} strokeWidth={1.5}
dot={false} dot={{
activeDot={{
r: 4, r: 4,
fill: metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color, fill: metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color,
strokeWidth: 1.5,
stroke: 'hsl(var(--popover))',
}}
activeDot={{
r: 6,
fill: metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color,
strokeWidth: 2, strokeWidth: 2,
stroke: 'hsl(var(--popover))', stroke: 'hsl(var(--popover))',
}} }}
@@ -141,10 +141,10 @@ export function RepeaterPane({
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border"> <div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border">
<div className="min-w-0"> <div className="min-w-0">
<h3 className="text-sm font-medium">{title}</h3> <h3 className="text-sm font-medium">{title}</h3>
{headerNote && <p className="text-[11px] text-muted-foreground">{headerNote}</p>} {headerNote && <p className="text-[0.6875rem] text-muted-foreground">{headerNote}</p>}
{fetchedAt && ( {fetchedAt && (
<p <p
className="text-[11px] text-muted-foreground" className="text-[0.6875rem] text-muted-foreground"
title={new Date(fetchedAt).toLocaleString()} title={new Date(fetchedAt).toLocaleString()}
> >
Fetched {formatFetchedTime(fetchedAt)} ({formatFetchedRelative(fetchedAt)}) Fetched {formatFetchedTime(fetchedAt)} ({formatFetchedRelative(fetchedAt)})
@@ -20,6 +20,8 @@ export function SettingsDatabaseSection({
onToggleBlockedName, onToggleBlockedName,
contacts = [], contacts = [],
onBulkDeleteContacts, onBulkDeleteContacts,
trackedTelemetryRepeaters = [],
onToggleTrackedTelemetry,
className, className,
}: { }: {
appSettings: AppSettings; appSettings: AppSettings;
@@ -32,6 +34,8 @@ export function SettingsDatabaseSection({
onToggleBlockedName?: (name: string) => void; onToggleBlockedName?: (name: string) => void;
contacts?: Contact[]; contacts?: Contact[];
onBulkDeleteContacts?: (deletedKeys: string[]) => void; onBulkDeleteContacts?: (deletedKeys: string[]) => void;
trackedTelemetryRepeaters?: string[];
onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>;
className?: string; className?: string;
}) { }) {
const [retentionDays, setRetentionDays] = useState('14'); const [retentionDays, setRetentionDays] = useState('14');
@@ -223,6 +227,50 @@ export function SettingsDatabaseSection({
</p> </p>
</div> </div>
<Separator />
{/* ── Tracked Repeater Telemetry ── */}
<div className="space-y-3">
<Label className="text-base">Tracked Repeater Telemetry</Label>
<p className="text-xs text-muted-foreground">
Repeaters opted into automatic telemetry collection are polled every 8 hours. Up to 8
repeaters may be tracked at a time ({trackedTelemetryRepeaters.length} / 8 slots used).
</p>
{trackedTelemetryRepeaters.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No repeaters are being tracked. Enable tracking from a repeater's dashboard.
</p>
) : (
<div className="space-y-1">
{trackedTelemetryRepeaters.map((key) => {
const contact = contacts.find((c) => c.public_key === key);
const displayName = contact?.name ?? key.slice(0, 12);
return (
<div key={key} className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0">
<span className="text-sm truncate block">{displayName}</span>
<span className="text-[0.625rem] text-muted-foreground font-mono">
{key.slice(0, 12)}
</span>
</div>
{onToggleTrackedTelemetry && (
<Button
variant="ghost"
size="sm"
onClick={() => onToggleTrackedTelemetry(key)}
className="h-7 text-xs flex-shrink-0 text-destructive hover:text-destructive"
>
Remove
</Button>
)}
</div>
);
})}
</div>
)}
</div>
{error && ( {error && (
<div className="text-sm text-destructive" role="alert"> <div className="text-sm text-destructive" role="alert">
{error} {error}
@@ -537,7 +537,7 @@ function CreateIntegrationDialog({
<div className="space-y-4"> <div className="space-y-4">
{sectionedOptions.map((group) => ( {sectionedOptions.map((group) => (
<div key={group.section} className="space-y-1.5"> <div key={group.section} className="space-y-1.5">
<div className="px-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground"> <div className="px-2 text-[0.6875rem] font-semibold uppercase tracking-wider text-muted-foreground">
{group.section} {group.section}
</div> </div>
{group.options.map((option) => { {group.options.map((option) => {
@@ -577,7 +577,7 @@ function CreateIntegrationDialog({
{selectedOption ? ( {selectedOption ? (
<> <>
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-muted-foreground"> <div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{selectedOption.section} {selectedOption.section}
</div> </div>
<h3 className="text-lg font-semibold">{selectedOption.label}</h3> <h3 className="text-lg font-semibold">{selectedOption.label}</h3>
@@ -643,16 +643,20 @@ function formatPrivateTopicSummary(config: Record<string, unknown>) {
return `${prefix}/dm:<pubkey>, ${prefix}/gm:<channel>, ${prefix}/raw/...`; return `${prefix}/dm:<pubkey>, ${prefix}/gm:<channel>, ${prefix}/raw/...`;
} }
function formatAppriseTargets(urls: string | undefined, maxLength = 80) { function censorAppriseUrl(url: string): string {
const protoMatch = url.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//);
if (protoMatch) return `${protoMatch[0]}********`;
return '********';
}
function formatAppriseTargets(urls: string | undefined) {
const targets = (urls || '') const targets = (urls || '')
.split('\n') .split('\n')
.map((line) => line.trim()) .map((line) => line.trim())
.filter(Boolean); .filter(Boolean);
if (targets.length === 0) return 'No targets configured'; if (targets.length === 0) return 'No targets configured';
const joined = targets.join(', '); return targets.map(censorAppriseUrl).join(', ');
if (joined.length <= maxLength) return joined;
return `${joined.slice(0, maxLength - 3)}...`;
} }
function formatSqsQueueSummary(config: Record<string, unknown>) { function formatSqsQueueSummary(config: Record<string, unknown>) {
@@ -1,8 +1,10 @@
import { useState } from 'react'; import { useState } from 'react';
import { Logs, MessageSquare } from 'lucide-react'; import { ChevronRight, Logs, MessageSquare, Send, Settings } from 'lucide-react';
import { Button } from '../ui/button';
import { Input } from '../ui/input'; import { Input } from '../ui/input';
import { Label } from '../ui/label'; import { Label } from '../ui/label';
import { Separator } from '../ui/separator'; import { Separator } from '../ui/separator';
import { cn } from '../../lib/utils';
import { ContactAvatar } from '../ContactAvatar'; import { ContactAvatar } from '../ContactAvatar';
import { import {
captureLastViewedConversationFromHash, captureLastViewedConversationFromHash,
@@ -37,6 +39,13 @@ export function SettingsLocalSection({
const [reopenLastConversation, setReopenLastConversation] = useState( const [reopenLastConversation, setReopenLastConversation] = useState(
getReopenLastConversationEnabled getReopenLastConversationEnabled
); );
const [darkMap, setDarkMap] = useState(() => {
try {
return localStorage.getItem('remoteterm-dark-map') === 'true';
} catch {
return false;
}
});
const [localLabelText, setLocalLabelText] = useState(() => getLocalLabel().text); const [localLabelText, setLocalLabelText] = useState(() => getLocalLabel().text);
const [localLabelColor, setLocalLabelColor] = useState(() => getLocalLabel().color); const [localLabelColor, setLocalLabelColor] = useState(() => getLocalLabel().color);
const [fontScale, setFontScale] = useState(getSavedFontScale); const [fontScale, setFontScale] = useState(getSavedFontScale);
@@ -233,11 +242,31 @@ export function SettingsLocalSection({
/> />
<span className="text-sm">Reopen to last viewed channel/conversation</span> <span className="text-sm">Reopen to last viewed channel/conversation</span>
</label> </label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={darkMap}
onChange={(e) => {
const v = e.target.checked;
setDarkMap(v);
try {
localStorage.setItem('remoteterm-dark-map', String(v));
} catch {
// localStorage may be disabled
}
}}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Dark mode map tiles</span>
</label>
</div> </div>
); );
} }
function ThemePreview({ className }: { className?: string }) { function ThemePreview({ className }: { className?: string }) {
const [showStyleRef, setShowStyleRef] = useState(false);
return ( return (
<div className={`rounded-lg border border-border bg-card p-3 ${className ?? ''}`}> <div className={`rounded-lg border border-border bg-card p-3 ${className ?? ''}`}>
<p className="text-xs text-muted-foreground mb-3"> <p className="text-xs text-muted-foreground mb-3">
@@ -271,7 +300,7 @@ function ThemePreview({ className }: { className?: string }) {
</div> </div>
<div className="mt-4 rounded-md border border-border bg-background p-2"> <div className="mt-4 rounded-md border border-border bg-background p-2">
<p className="mb-2 text-[11px] font-medium text-muted-foreground">Sidebar preview</p> <p className="mb-2 text-[0.6875rem] font-medium text-muted-foreground">Sidebar preview</p>
<div className="space-y-1"> <div className="space-y-1">
<PreviewSidebarRow <PreviewSidebarRow
active active
@@ -289,7 +318,7 @@ function ThemePreview({ className }: { className?: string }) {
leading={<ContactAvatar name="Alice" publicKey={'ab'.repeat(32)} size={24} />} leading={<ContactAvatar name="Alice" publicKey={'ab'.repeat(32)} size={24} />}
label="Alice" label="Alice"
badge={ badge={
<span className="rounded-full bg-badge-unread/90 px-1.5 py-0.5 text-[10px] font-semibold text-badge-unread-foreground"> <span className="rounded-full bg-badge-unread/90 px-1.5 py-0.5 text-[0.625rem] font-semibold text-badge-unread-foreground">
3 3
</span> </span>
} }
@@ -298,13 +327,267 @@ function ThemePreview({ className }: { className?: string }) {
leading={<ContactAvatar name="Mesh Ops" publicKey={'cd'.repeat(32)} size={24} />} leading={<ContactAvatar name="Mesh Ops" publicKey={'cd'.repeat(32)} size={24} />}
label="Mesh Ops" label="Mesh Ops"
badge={ badge={
<span className="rounded-full bg-badge-mention px-1.5 py-0.5 text-[10px] font-semibold text-badge-mention-foreground"> <span className="rounded-full bg-badge-mention px-1.5 py-0.5 text-[0.625rem] font-semibold text-badge-mention-foreground">
@2 @2
</span> </span>
} }
/> />
</div> </div>
</div> </div>
{/* ── Style Reference (collapsible) ── */}
<button
type="button"
onClick={() => setShowStyleRef((v) => !v)}
className="mt-4 flex w-full items-center gap-1.5 text-[0.6875rem] font-medium text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronRight
className={cn('h-3.5 w-3.5 transition-transform', showStyleRef && 'rotate-90')}
/>
Canonical style reference
</button>
{showStyleRef && (
<>
{/* ── Text Hierarchy ── */}
<PreviewSection title="Text hierarchy">
<div className="space-y-2">
<PreviewTextRow
classes="text-xl font-semibold"
label="text-xl font-semibold"
desc="Hero / large data"
/>
<PreviewTextRow
classes="text-lg font-semibold"
label="text-lg font-semibold"
desc="Sheet / dialog title"
/>
<PreviewTextRow
classes="text-base font-semibold"
label="text-base font-semibold"
desc="Section title"
/>
<PreviewTextRow classes="text-sm" label="text-sm" desc="Body text, form labels" />
<PreviewTextRow
classes="text-xs text-muted-foreground"
label="text-xs text-muted-foreground"
desc="Helper text"
/>
<PreviewTextRow
classes="text-[0.6875rem] text-muted-foreground"
label="text-[0.6875rem] text-muted-foreground"
desc="Metadata, timestamps"
/>
<div>
<p className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
Section Label
</p>
<p className="text-[0.625rem] text-muted-foreground/60 mt-0.5">
text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium
</p>
</div>
</div>
</PreviewSection>
{/* ── Mono Text ── */}
<PreviewSection title="Mono text">
<div className="space-y-1.5">
<div>
<p className="text-xs font-mono text-muted-foreground">
a1b2c3d4e5f6...7890abcdef01
</p>
<p className="text-[0.625rem] text-muted-foreground/60">
text-xs font-mono keys, identifiers
</p>
</div>
<div>
<p className="text-[0.6875rem] font-mono">1h 23m 45s uptime</p>
<p className="text-[0.625rem] text-muted-foreground/60">
text-[0.6875rem] font-mono metadata mono
</p>
</div>
<div>
<p className="text-sm font-mono">$ req_status_sync 0xA1B2...</p>
<p className="text-[0.625rem] text-muted-foreground/60">
text-sm font-mono console / code
</p>
</div>
</div>
</PreviewSection>
{/* ── Badges ── */}
<PreviewSection title="Badges and tags">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
Hashtag
</span>
<span className="text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
Repeater
</span>
<span className="text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">
On Radio
</span>
<span className="rounded-full bg-badge-unread/90 px-1.5 py-0.5 text-[0.625rem] font-semibold text-badge-unread-foreground">
3
</span>
<span className="rounded-full bg-badge-mention px-1.5 py-0.5 text-[0.625rem] font-semibold text-badge-mention-foreground">
@2
</span>
</div>
<p className="text-[0.625rem] text-muted-foreground/60 mt-1.5">
Muted: bg-muted &middot; Primary: bg-primary/10 &middot; Unread/Mention: bg-badge-*
</p>
</PreviewSection>
{/* ── Buttons ── */}
<PreviewSection title="Buttons">
<div className="space-y-3">
<div>
<p className="text-[0.625rem] text-muted-foreground/60 mb-1.5">
Standard variants (size sm)
</p>
<div className="flex flex-wrap gap-1.5">
<Button size="sm">Default</Button>
<Button size="sm" variant="outline">
Outline
</Button>
<Button size="sm" variant="secondary">
Secondary
</Button>
<Button size="sm" variant="destructive">
Destructive
</Button>
<Button size="sm" variant="ghost">
Ghost
</Button>
<Button size="icon" variant="outline">
<Settings className="h-4 w-4" />
</Button>
<Button size="icon" variant="outline">
<Send className="h-4 w-4" />
</Button>
</div>
</div>
<div>
<p className="text-[0.625rem] text-muted-foreground/60 mb-1.5">
Semantic outline variants
</p>
<div className="flex flex-wrap gap-1.5">
<Button
size="sm"
variant="outline"
className="border-destructive/50 text-destructive hover:bg-destructive/10"
>
Danger
</Button>
<Button
size="sm"
variant="outline"
className="border-warning/50 text-warning hover:bg-warning/10"
>
Warning
</Button>
<Button
size="sm"
variant="outline"
className="border-green-600/50 text-green-600 hover:bg-green-600/10"
>
Success
</Button>
</div>
</div>
<div>
<p className="text-[0.625rem] text-muted-foreground/60 mb-1.5">
Metric selector pills
</p>
<div className="flex gap-1">
{['Voltage', 'Noise Floor', 'Packets'].map((label, i) => (
<button
key={label}
type="button"
className={cn(
'text-[0.6875rem] px-2 py-0.5 rounded transition-colors',
i === 0
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
>
{label}
</button>
))}
</div>
</div>
</div>
</PreviewSection>
{/* ── Clickable Text ── */}
<PreviewSection title="Clickable text">
<div className="space-y-1.5">
<span
role="button"
tabIndex={0}
className="text-xs font-mono text-muted-foreground cursor-pointer hover:text-primary transition-colors block"
>
a1b2c3d4e5f6 (click to copy)
</span>
<span
role="button"
tabIndex={0}
className="text-sm cursor-pointer underline underline-offset-2 decoration-muted-foreground/50 hover:text-primary transition-colors"
>
Underlined navigational link
</span>
</div>
<p className="text-[0.625rem] text-muted-foreground/60 mt-1.5">
cursor-pointer hover:text-primary transition-colors use role=&quot;button&quot; +
tabIndex
</p>
</PreviewSection>
{/* ── Inline Alerts ── */}
<PreviewSection title="Inline alerts">
<div className="space-y-1.5">
<div className="rounded-md border border-info/30 bg-info/10 px-3 py-2 text-xs text-info">
Info: channel slot cache refreshed from radio.
</div>
<div className="rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-xs text-warning">
Warning: radio clock skew detected.
</div>
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
Error: post-connect setup timed out. Reboot the radio and restart.
</div>
</div>
</PreviewSection>
</>
)}
</div>
);
}
function PreviewSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mt-4 rounded-md border border-border bg-background p-2">
<p className="mb-2 text-[0.6875rem] font-medium text-muted-foreground">{title}</p>
{children}
</div>
);
}
function PreviewTextRow({
classes,
label,
desc,
}: {
classes: string;
label: string;
desc: string;
}) {
return (
<div>
<p className={classes}>Sample text at this size</p>
<p className="text-[0.625rem] text-muted-foreground/60">
{label} {desc}
</p>
</div> </div>
); );
} }
@@ -327,7 +610,7 @@ function PreviewMessage({
return ( return (
<div className={`flex ${alignRight ? 'justify-end' : 'justify-start'}`}> <div className={`flex ${alignRight ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[85%] ${alignRight ? 'items-end' : 'items-start'} flex flex-col`}> <div className={`max-w-[85%] ${alignRight ? 'items-end' : 'items-start'} flex flex-col`}>
<span className="mb-1 text-[11px] text-muted-foreground">{sender}</span> <span className="mb-1 text-[0.6875rem] text-muted-foreground">{sender}</span>
<div className={`rounded-2xl px-3 py-2 text-sm break-words ${bubbleClassName}`}>{text}</div> <div className={`rounded-2xl px-3 py-2 text-sm break-words ${bubbleClassName}`}>{text}</div>
</div> </div>
</div> </div>
@@ -348,7 +631,7 @@ function PreviewSidebarRow({
return ( return (
<div <div
data-active={active ? 'true' : undefined} data-active={active ? 'true' : undefined}
className={`sidebar-action-row flex items-center gap-2 rounded-md border-l-2 px-3 py-2 text-[13px] ${ className={`sidebar-action-row flex items-center gap-2 rounded-md border-l-2 px-3 py-2 text-[0.8125rem] ${
active ? 'border-l-primary bg-accent text-foreground' : 'border-l-transparent' active ? 'border-l-primary bg-accent text-foreground' : 'border-l-transparent'
}`} }`}
> >
@@ -702,6 +702,26 @@ export function SettingsRadioSection({
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3">
<Checkbox
id="auto-resend-channel"
checked={appSettings.auto_resend_channel}
onCheckedChange={(checked) =>
onSaveAppSettings({ auto_resend_channel: checked === true })
}
className="mt-0.5"
/>
<div className="space-y-1">
<Label htmlFor="auto-resend-channel">Auto-Resend Unheard Channel Messages</Label>
<p className="text-xs text-muted-foreground">
When enabled, outgoing channel messages that receive no echo within 2 seconds are
automatically resent once (byte-perfect, within the 30-second dedup window). Repeaters
that already heard the original will ignore the duplicate. This functionality will NOT
create double-sent/duplicate messages.
</p>
</div>
</div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
+1
View File
@@ -19,6 +19,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
'group-[.toaster]:bg-toast-error group-[.toaster]:text-toast-error-foreground group-[.toaster]:border-toast-error-border [&_[data-description]]:text-toast-error-foreground', 'group-[.toaster]:bg-toast-error group-[.toaster]:text-toast-error-foreground group-[.toaster]:border-toast-error-border [&_[data-description]]:text-toast-error-foreground',
}, },
}} }}
closeButton
{...props} {...props}
/> />
); );
@@ -95,7 +95,7 @@ export function VisualizerControls({
{PACKET_LEGEND_ITEMS.map((item) => ( {PACKET_LEGEND_ITEMS.map((item) => (
<div key={item.label} className="flex items-center gap-2"> <div key={item.label} className="flex items-center gap-2">
<div <div
className="w-5 h-5 rounded-full flex items-center justify-center text-[8px] font-bold text-white" className="w-5 h-5 rounded-full flex items-center justify-center text-[0.5rem] font-bold text-white"
style={{ backgroundColor: item.color }} style={{ backgroundColor: item.color }}
> >
{item.label} {item.label}
+56 -55
View File
@@ -2,17 +2,8 @@ import { useState, useCallback, useEffect, useRef } from 'react';
import { api } from '../api'; import { api } from '../api';
import { takePrefetchOrFetch } from '../prefetch'; import { takePrefetchOrFetch } from '../prefetch';
import { toast } from '../components/ui/sonner'; import { toast } from '../components/ui/sonner';
import { import { initLastMessageTimes } from '../utils/conversationState';
initLastMessageTimes, import { isFavorite } from '../utils/favorites';
loadLocalStorageLastMessageTimes,
loadLocalStorageSortOrder,
clearLocalStorageConversationState,
} from '../utils/conversationState';
import {
isFavorite,
loadLocalStorageFavorites,
clearLocalStorageFavorites,
} from '../utils/favorites';
import type { AppSettings, AppSettingsUpdate, Favorite } from '../types'; import type { AppSettings, AppSettingsUpdate, Favorite } from '../types';
export function useAppSettings() { export function useAppSettings() {
@@ -120,59 +111,68 @@ export function useAppSettings() {
} }
}, []); }, []);
// One-time migration of localStorage preferences to server const handleToggleTrackedTelemetry = useCallback(async (publicKey: string) => {
const key = publicKey.toLowerCase();
setAppSettings((prev) => {
if (!prev) return prev;
const current = prev.tracked_telemetry_repeaters ?? [];
const wasTracked = current.includes(key);
const optimistic = wasTracked ? current.filter((k) => k !== key) : [...current, key];
return { ...prev, tracked_telemetry_repeaters: optimistic };
});
try {
const result = await api.toggleTrackedTelemetry(publicKey);
setAppSettings((prev) =>
prev ? { ...prev, tracked_telemetry_repeaters: result.tracked_telemetry_repeaters } : prev
);
} catch (err) {
console.error('Failed to toggle tracked telemetry:', err);
try {
const settings = await api.getSettings();
setAppSettings(settings);
} catch {
// If refetch also fails, leave optimistic state
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const detail = (err as any)?.body?.detail;
if (typeof detail === 'object' && detail?.message) {
toast.error(detail.message);
} else {
toast.error('Failed to update tracked telemetry');
}
}
}, []);
// Legacy favorites migration: if pre-server-side favorites exist in
// localStorage, toggle each one via the existing API and clear the key.
useEffect(() => { useEffect(() => {
if (!appSettings || hasMigratedRef.current) return; if (!appSettings || hasMigratedRef.current) return;
if (appSettings.preferences_migrated) {
clearLocalStorageFavorites();
clearLocalStorageConversationState();
hasMigratedRef.current = true;
return;
}
const localFavorites = loadLocalStorageFavorites();
const localSortOrder = loadLocalStorageSortOrder();
const localLastMessageTimes = loadLocalStorageLastMessageTimes();
const hasLocalData =
localFavorites.length > 0 ||
localSortOrder !== 'recent' ||
Object.keys(localLastMessageTimes).length > 0;
if (!hasLocalData) {
hasMigratedRef.current = true;
return;
}
hasMigratedRef.current = true; hasMigratedRef.current = true;
const migratePreferences = async () => { const FAVORITES_KEY = 'remoteterm-favorites';
let localFavorites: Favorite[] = [];
try {
const stored = localStorage.getItem(FAVORITES_KEY);
if (stored) localFavorites = JSON.parse(stored);
} catch {
// corrupt or unavailable
}
if (localFavorites.length === 0) return;
const migrate = async () => {
try { try {
const result = await api.migratePreferences({ for (const f of localFavorites) {
favorites: localFavorites, await api.toggleFavorite(f.type, f.id);
sort_order: localSortOrder,
last_message_times: localLastMessageTimes,
});
if (result.migrated) {
toast.success('Preferences migrated', {
description: `Migrated ${localFavorites.length} favorites to server`,
});
} }
localStorage.removeItem(FAVORITES_KEY);
setAppSettings(result.settings); await fetchAppSettings();
initLastMessageTimes(result.settings.last_message_times ?? {});
clearLocalStorageFavorites();
clearLocalStorageConversationState();
} catch (err) { } catch (err) {
console.error('Failed to migrate preferences:', err); console.error('Failed to migrate legacy favorites:', err);
} }
}; };
migrate();
migratePreferences(); }, [appSettings, fetchAppSettings]);
}, [appSettings]);
return { return {
appSettings, appSettings,
@@ -182,5 +182,6 @@ export function useAppSettings() {
handleToggleFavorite, handleToggleFavorite,
handleToggleBlockedKey, handleToggleBlockedKey,
handleToggleBlockedName, handleToggleBlockedName,
handleToggleTrackedTelemetry,
}; };
} }
@@ -21,6 +21,10 @@ interface UseConversationActionsResult {
channelKey: string, channelKey: string,
floodScopeOverride: string floodScopeOverride: string
) => Promise<void>; ) => Promise<void>;
handleSetChannelPathHashModeOverride: (
channelKey: string,
pathHashModeOverride: number | null
) => Promise<void>;
handleSenderClick: (sender: string) => void; handleSenderClick: (sender: string) => void;
handleTrace: () => Promise<void>; handleTrace: () => Promise<void>;
handlePathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>; handlePathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
@@ -106,6 +110,25 @@ export function useConversationActions({
[mergeChannelIntoList] [mergeChannelIntoList]
); );
const handleSetChannelPathHashModeOverride = useCallback(
async (channelKey: string, pathHashModeOverride: number | null) => {
try {
const updated = await api.setChannelPathHashModeOverride(channelKey, pathHashModeOverride);
mergeChannelIntoList(updated);
toast.success(
updated.path_hash_mode_override != null
? 'Path hop width override saved'
: 'Path hop width override cleared'
);
} catch (err) {
toast.error('Failed to update path hop width override', {
description: err instanceof Error ? err.message : 'Unknown error',
});
}
},
[mergeChannelIntoList]
);
const handleSenderClick = useCallback( const handleSenderClick = useCallback(
(sender: string) => { (sender: string) => {
messageInputRef.current?.appendText(`@[${sender}] `); messageInputRef.current?.appendText(`@[${sender}] `);
@@ -143,6 +166,7 @@ export function useConversationActions({
handleSendMessage, handleSendMessage,
handleResendChannelMessage, handleResendChannelMessage,
handleSetChannelFloodScopeOverride, handleSetChannelFloodScopeOverride,
handleSetChannelPathHashModeOverride,
handleSenderClick, handleSenderClick,
handleTrace, handleTrace,
handlePathDiscovery, handlePathDiscovery,
+1 -3
View File
@@ -24,7 +24,6 @@ const mocks = vi.hoisted(() => ({
requestTrace: vi.fn(), requestTrace: vi.fn(),
updateRadioConfig: vi.fn(), updateRadioConfig: vi.fn(),
setPrivateKey: vi.fn(), setPrivateKey: vi.fn(),
migratePreferences: vi.fn(),
}, },
toast: { toast: {
success: vi.fn(), success: vi.fn(),
@@ -190,9 +189,8 @@ const baseSettings = {
max_radio_contacts: 200, max_radio_contacts: 200,
favorites: [] as Array<{ type: 'channel' | 'contact'; id: string }>, favorites: [] as Array<{ type: 'channel' | 'contact'; id: string }>,
auto_decrypt_dm_on_advert: false, auto_decrypt_dm_on_advert: false,
sidebar_sort_order: 'recent' as const,
last_message_times: {}, last_message_times: {},
preferences_migrated: false,
advert_interval: 0, advert_interval: 0,
last_advert_time: 0, last_advert_time: 0,
flood_scope: '', flood_scope: '',
+1 -3
View File
@@ -11,7 +11,6 @@ const mocks = vi.hoisted(() => ({
getUndecryptedPacketCount: vi.fn(), getUndecryptedPacketCount: vi.fn(),
getChannels: vi.fn(), getChannels: vi.fn(),
getContacts: vi.fn(), getContacts: vi.fn(),
migratePreferences: vi.fn(),
}, },
useConversationMessagesCalls: vi.fn(), useConversationMessagesCalls: vi.fn(),
})); }));
@@ -218,9 +217,8 @@ describe('App search jump target handling', () => {
max_radio_contacts: 200, max_radio_contacts: 200,
favorites: [], favorites: [],
auto_decrypt_dm_on_advert: false, auto_decrypt_dm_on_advert: false,
sidebar_sort_order: 'recent',
last_message_times: {}, last_message_times: {},
preferences_migrated: true,
advert_interval: 0, advert_interval: 0,
last_advert_time: 0, last_advert_time: 0,
}); });
+1 -3
View File
@@ -9,7 +9,6 @@ const mocks = vi.hoisted(() => ({
getUndecryptedPacketCount: vi.fn(), getUndecryptedPacketCount: vi.fn(),
getChannels: vi.fn(), getChannels: vi.fn(),
getContacts: vi.fn(), getContacts: vi.fn(),
migratePreferences: vi.fn(),
}, },
})); }));
@@ -169,9 +168,8 @@ describe('App startup hash resolution', () => {
max_radio_contacts: 200, max_radio_contacts: 200,
favorites: [], favorites: [],
auto_decrypt_dm_on_advert: false, auto_decrypt_dm_on_advert: false,
sidebar_sort_order: 'recent',
last_message_times: {}, last_message_times: {},
preferences_migrated: true,
advert_interval: 0, advert_interval: 0,
last_advert_time: 0, last_advert_time: 0,
}); });
@@ -25,6 +25,15 @@ function makeDetail(channel: Channel): ChannelDetail {
first_message_at: null, first_message_at: null,
unique_sender_count: 0, unique_sender_count: 0,
top_senders_24h: [], top_senders_24h: [],
path_hash_width_24h: {
total_packets: 0,
single_byte: 0,
double_byte: 0,
triple_byte: 0,
single_byte_pct: 0,
double_byte_pct: 0,
triple_byte_pct: 0,
},
}; };
} }
@@ -164,6 +164,8 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
onDismissUnreadMarker: vi.fn(), onDismissUnreadMarker: vi.fn(),
onSendMessage: vi.fn(async () => {}), onSendMessage: vi.fn(async () => {}),
onToggleNotifications: vi.fn(), onToggleNotifications: vi.fn(),
trackedTelemetryRepeaters: [],
onToggleTrackedTelemetry: vi.fn(async () => {}),
...overrides, ...overrides,
}; };
} }
+1 -1
View File
@@ -1227,7 +1227,7 @@ describe('SettingsFanoutSection', () => {
const group = await screen.findByRole('group', { name: 'Integration Apprise Feed' }); const group = await screen.findByRole('group', { name: 'Integration Apprise Feed' });
expect( expect(
within(group).getByText(/discord:\/\/abc, mailto:\/\/one@example.com/) within(group).getByText(/discord:\/\/\*{8}, mailto:\/\/\*{8}, mailto:\/\/\*{8}/)
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
-37
View File
@@ -1,37 +0,0 @@
import { describe, it, expect } from 'vitest';
import { RADIO_PRESETS } from '../utils/radioPresets';
describe('Radio Presets', () => {
describe('preset values are valid LoRa parameters', () => {
it('all frequencies are in valid ISM bands', () => {
for (const preset of RADIO_PRESETS) {
// 433 MHz: 433.05-434.79, EU 868: 863-870, US/AU/NZ/VN 900: 902-928
const valid433 = preset.freq >= 433 && preset.freq <= 435;
const validEU = preset.freq >= 863 && preset.freq <= 870;
const valid900 = preset.freq >= 902 && preset.freq <= 928;
expect(valid433 || validEU || valid900).toBe(true);
}
});
it('all spreading factors are valid (7-12)', () => {
for (const preset of RADIO_PRESETS) {
expect(preset.sf).toBeGreaterThanOrEqual(7);
expect(preset.sf).toBeLessThanOrEqual(12);
}
});
it('all coding rates are valid (5-8 for 4/5 to 4/8)', () => {
for (const preset of RADIO_PRESETS) {
expect(preset.cr).toBeGreaterThanOrEqual(5);
expect(preset.cr).toBeLessThanOrEqual(8);
}
});
it('all bandwidths are standard LoRa values', () => {
const validBandwidths = [7.8, 10.4, 15.6, 20.8, 31.25, 41.7, 62.5, 125, 250, 500];
for (const preset of RADIO_PRESETS) {
expect(validBandwidths).toContain(preset.bw);
}
});
});
});
+3 -1
View File
@@ -124,6 +124,8 @@ const defaultProps = {
onToggleNotifications: vi.fn(), onToggleNotifications: vi.fn(),
onToggleFavorite: vi.fn(), onToggleFavorite: vi.fn(),
onDeleteContact: vi.fn(), onDeleteContact: vi.fn(),
trackedTelemetryRepeaters: [] as string[],
onToggleTrackedTelemetry: vi.fn(async () => {}),
}; };
function createDeferred<T>() { function createDeferred<T>() {
@@ -677,7 +679,7 @@ describe('RepeaterDashboard', () => {
render(<RepeaterDashboard {...defaultProps} />); render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText('Telemetry History')).toBeInTheDocument(); expect(screen.getByText('Telemetry History')).toBeInTheDocument();
expect(screen.getByText('0 samples')).toBeInTheDocument(); expect(screen.getByText(/No history yet/)).toBeInTheDocument();
}); });
it('updates history from live status fetch', async () => { it('updates history from live status fetch', async () => {
+3 -2
View File
@@ -61,15 +61,16 @@ const baseSettings: AppSettings = {
max_radio_contacts: 200, max_radio_contacts: 200,
favorites: [], favorites: [],
auto_decrypt_dm_on_advert: false, auto_decrypt_dm_on_advert: false,
sidebar_sort_order: 'recent',
last_message_times: {}, last_message_times: {},
preferences_migrated: false,
advert_interval: 0, advert_interval: 0,
last_advert_time: 0, last_advert_time: 0,
flood_scope: '', flood_scope: '',
blocked_keys: [], blocked_keys: [],
blocked_names: [], blocked_names: [],
discovery_blocked_types: [], discovery_blocked_types: [],
tracked_telemetry_repeaters: [],
auto_resend_channel: false,
}; };
function renderModal(overrides?: { function renderModal(overrides?: {
-9
View File
@@ -92,7 +92,6 @@ function renderSidebar(overrides?: {
onToggleCracker={vi.fn()} onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()} onMarkAllRead={vi.fn()}
favorites={favorites} favorites={favorites}
legacySortOrder="recent"
isConversationNotificationsEnabled={overrides?.isConversationNotificationsEnabled} isConversationNotificationsEnabled={overrides?.isConversationNotificationsEnabled}
/> />
); );
@@ -140,7 +139,6 @@ describe('Sidebar section summaries', () => {
onToggleCracker={vi.fn()} onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()} onMarkAllRead={vi.fn()}
favorites={[]} favorites={[]}
legacySortOrder="recent"
/> />
); );
@@ -300,7 +298,6 @@ describe('Sidebar section summaries', () => {
onToggleCracker={vi.fn()} onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()} onMarkAllRead={vi.fn()}
favorites={[]} favorites={[]}
legacySortOrder="recent"
/> />
); );
@@ -397,7 +394,6 @@ describe('Sidebar section summaries', () => {
onToggleCracker: vi.fn(), onToggleCracker: vi.fn(),
onMarkAllRead: vi.fn(), onMarkAllRead: vi.fn(),
favorites: [], favorites: [],
legacySortOrder: 'recent' as const,
}; };
const getChannelsOrder = () => screen.getAllByText(/^#/).map((node) => node.textContent); const getChannelsOrder = () => screen.getAllByText(/^#/).map((node) => node.textContent);
@@ -469,7 +465,6 @@ describe('Sidebar section summaries', () => {
onToggleCracker={vi.fn()} onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()} onMarkAllRead={vi.fn()}
favorites={[]} favorites={[]}
legacySortOrder="recent"
/> />
); );
@@ -504,7 +499,6 @@ describe('Sidebar section summaries', () => {
onToggleCracker={vi.fn()} onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()} onMarkAllRead={vi.fn()}
favorites={[]} favorites={[]}
legacySortOrder="recent"
/> />
); );
@@ -553,7 +547,6 @@ describe('Sidebar section summaries', () => {
onToggleCracker={vi.fn()} onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()} onMarkAllRead={vi.fn()}
favorites={[]} favorites={[]}
legacySortOrder="recent"
/> />
); );
@@ -586,7 +579,6 @@ describe('Sidebar section summaries', () => {
onToggleCracker={vi.fn()} onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()} onMarkAllRead={vi.fn()}
favorites={[]} favorites={[]}
legacySortOrder="alpha"
/> />
); );
@@ -623,7 +615,6 @@ describe('Sidebar section summaries', () => {
{ type: 'contact', id: zed.public_key }, { type: 'contact', id: zed.public_key },
{ type: 'contact', id: amy.public_key }, { type: 'contact', id: amy.public_key },
] satisfies Favorite[], ] satisfies Favorite[],
legacySortOrder: 'recent' as const,
}; };
const getFavoritesOrder = () => const getFavoritesOrder = () =>
@@ -1,27 +0,0 @@
import { describe, expect, it } from 'vitest';
import { getSceneNodeLabel } from '../components/visualizer/shared';
describe('visualizer shared label helpers', () => {
it('adds an ambiguity suffix to in-graph labels for ambiguous nodes', () => {
expect(
getSceneNodeLabel({
id: '?32',
name: 'Likely Relay',
type: 'repeater',
isAmbiguous: true,
})
).toBe('Likely Relay (?)');
});
it('does not add an ambiguity suffix to unambiguous nodes', () => {
expect(
getSceneNodeLabel({
id: 'aaaaaaaaaaaa',
name: 'Alice',
type: 'client',
isAmbiguous: false,
})
).toBe('Alice');
});
});
+22 -29
View File
@@ -166,23 +166,6 @@ export interface NearestRepeater {
heard_count: number; heard_count: number;
} }
export interface ContactDetail {
contact: Contact;
name_history: ContactNameHistory[];
dm_message_count: number;
channel_message_count: number;
most_active_rooms: ContactActiveRoom[];
advert_paths: ContactAdvertPath[];
advert_frequency: number | null;
nearest_repeaters: NearestRepeater[];
}
export interface NameOnlyContactDetail {
name: string;
channel_message_count: number;
most_active_rooms: ContactActiveRoom[];
}
export interface ContactAnalyticsHourlyBucket { export interface ContactAnalyticsHourlyBucket {
bucket_start: number; bucket_start: number;
last_24h_count: number; last_24h_count: number;
@@ -218,6 +201,7 @@ export interface Channel {
is_hashtag: boolean; is_hashtag: boolean;
on_radio: boolean; on_radio: boolean;
flood_scope_override?: string | null; flood_scope_override?: string | null;
path_hash_mode_override?: number | null;
last_read_at: number | null; last_read_at: number | null;
} }
@@ -244,12 +228,23 @@ export interface BulkCreateHashtagChannelsResult {
message: string; message: string;
} }
export interface PathHashWidthStats {
total_packets: number;
single_byte: number;
double_byte: number;
triple_byte: number;
single_byte_pct: number;
double_byte_pct: number;
triple_byte_pct: number;
}
export interface ChannelDetail { export interface ChannelDetail {
channel: Channel; channel: Channel;
message_counts: ChannelMessageCounts; message_counts: ChannelMessageCounts;
first_message_at: number | null; first_message_at: number | null;
unique_sender_count: number; unique_sender_count: number;
top_senders_24h: ChannelTopSender[]; top_senders_24h: ChannelTopSender[];
path_hash_width_24h: PathHashWidthStats;
} }
/** A single path that a message took to reach us */ /** A single path that a message took to reach us */
@@ -260,6 +255,10 @@ export interface MessagePath {
received_at: number; received_at: number;
/** Hop count (number of intermediate nodes). Null for legacy data (infer as len(path)/2). */ /** Hop count (number of intermediate nodes). Null for legacy data (infer as len(path)/2). */
path_len?: number | null; path_len?: number | null;
/** Last-hop RSSI in dBm (null if not available, e.g. older data) */
rssi?: number | null;
/** Last-hop SNR in dB (null if not available, e.g. older data) */
snr?: number | null;
} }
export interface Message { export interface Message {
@@ -333,37 +332,31 @@ export interface AppSettings {
max_radio_contacts: number; max_radio_contacts: number;
favorites: Favorite[]; favorites: Favorite[];
auto_decrypt_dm_on_advert: boolean; auto_decrypt_dm_on_advert: boolean;
sidebar_sort_order: 'recent' | 'alpha';
last_message_times: Record<string, number>; last_message_times: Record<string, number>;
preferences_migrated: boolean;
advert_interval: number; advert_interval: number;
last_advert_time: number; last_advert_time: number;
flood_scope: string; flood_scope: string;
blocked_keys: string[]; blocked_keys: string[];
blocked_names: string[]; blocked_names: string[];
discovery_blocked_types: number[]; discovery_blocked_types: number[];
tracked_telemetry_repeaters: string[];
auto_resend_channel: boolean;
} }
export interface AppSettingsUpdate { export interface AppSettingsUpdate {
max_radio_contacts?: number; max_radio_contacts?: number;
auto_decrypt_dm_on_advert?: boolean; auto_decrypt_dm_on_advert?: boolean;
sidebar_sort_order?: 'recent' | 'alpha';
advert_interval?: number; advert_interval?: number;
auto_resend_channel?: boolean;
flood_scope?: string; flood_scope?: string;
blocked_keys?: string[]; blocked_keys?: string[];
blocked_names?: string[]; blocked_names?: string[];
discovery_blocked_types?: number[]; discovery_blocked_types?: number[];
} }
export interface MigratePreferencesRequest { export interface TrackedTelemetryResponse {
favorites: Favorite[]; tracked_telemetry_repeaters: string[];
sort_order: string; names: Record<string, string>;
last_message_times: Record<string, number>;
}
export interface MigratePreferencesResponse {
migrated: boolean;
settings: AppSettings;
} }
/** Contact type constants */ /** Contact type constants */
-37
View File
@@ -9,7 +9,6 @@
* across devices - see useUnreadCounts hook. * across devices - see useUnreadCounts hook.
*/ */
const LAST_MESSAGE_KEY = 'remoteterm-lastMessageTime';
const SORT_ORDER_KEY = 'remoteterm-sortOrder'; const SORT_ORDER_KEY = 'remoteterm-sortOrder';
const SIDEBAR_SECTION_SORT_ORDERS_KEY = 'remoteterm-sidebar-section-sort-orders'; const SIDEBAR_SECTION_SORT_ORDERS_KEY = 'remoteterm-sidebar-section-sort-orders';
@@ -72,30 +71,6 @@ export function getStateKey(type: 'channel' | 'contact', id: string): string {
return `${type}-${id}`; return `${type}-${id}`;
} }
/**
* Load last message times from localStorage (for migration only)
*/
export function loadLocalStorageLastMessageTimes(): ConversationTimes {
try {
const stored = localStorage.getItem(LAST_MESSAGE_KEY);
return stored ? JSON.parse(stored) : {};
} catch {
return {};
}
}
/**
* Load sort order from localStorage (for migration only)
*/
export function loadLocalStorageSortOrder(): SortOrder {
try {
const stored = localStorage.getItem(SORT_ORDER_KEY);
return stored === 'alpha' ? 'alpha' : 'recent';
} catch {
return 'recent';
}
}
/** /**
* Load the legacy single sidebar sort order from localStorage, if present. * Load the legacy single sidebar sort order from localStorage, if present.
*/ */
@@ -149,15 +124,3 @@ export function saveLocalStorageSidebarSectionSortOrders(orders: SidebarSectionS
// localStorage might be disabled // localStorage might be disabled
} }
} }
/**
* Clear conversation state from localStorage (after migration)
*/
export function clearLocalStorageConversationState(): void {
try {
localStorage.removeItem(LAST_MESSAGE_KEY);
localStorage.removeItem(SORT_ORDER_KEY);
} catch {
// localStorage might be disabled
}
}
+1 -28
View File
@@ -1,15 +1,11 @@
/** /**
* Favorites utilities. * Favorites utilities.
* *
* Favorites are now stored server-side in the database. * Favorites are stored server-side in the database.
* This file provides helper functions for checking favorites
* and loading legacy localStorage data for migration.
*/ */
import type { Favorite } from '../types'; import type { Favorite } from '../types';
const FAVORITES_KEY = 'remoteterm-favorites';
/** /**
* Check if a conversation is favorited (from provided favorites array) * Check if a conversation is favorited (from provided favorites array)
*/ */
@@ -20,26 +16,3 @@ export function isFavorite(
): boolean { ): boolean {
return favorites.some((f) => f.type === type && f.id === id); return favorites.some((f) => f.type === type && f.id === id);
} }
/**
* Load favorites from localStorage (for migration only)
*/
export function loadLocalStorageFavorites(): Favorite[] {
try {
const stored = localStorage.getItem(FAVORITES_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
/**
* Clear favorites from localStorage (after migration)
*/
export function clearLocalStorageFavorites(): void {
try {
localStorage.removeItem(FAVORITES_KEY);
} catch {
// localStorage might be disabled
}
}
+1 -4
View File
@@ -6,10 +6,7 @@ import { getRawPacketObservationKey } from './rawPacketIdentity';
export const RAW_PACKET_STATS_WINDOWS = ['1m', '5m', '10m', '30m', 'session'] as const; export const RAW_PACKET_STATS_WINDOWS = ['1m', '5m', '10m', '30m', 'session'] as const;
export type RawPacketStatsWindow = (typeof RAW_PACKET_STATS_WINDOWS)[number]; export type RawPacketStatsWindow = (typeof RAW_PACKET_STATS_WINDOWS)[number];
export const RAW_PACKET_STATS_WINDOW_SECONDS: Record< const RAW_PACKET_STATS_WINDOW_SECONDS: Record<Exclude<RawPacketStatsWindow, 'session'>, number> = {
Exclude<RawPacketStatsWindow, 'session'>,
number
> = {
'1m': 60, '1m': 60,
'5m': 5 * 60, '5m': 5 * 60,
'10m': 10 * 60, '10m': 10 * 60,
+1 -1
View File
@@ -28,7 +28,7 @@ export type ServerLoginAttemptState =
at: number; at: number;
}; };
export function getServerLoginMethodLabel( function getServerLoginMethodLabel(
method: ServerLoginMethod, method: ServerLoginMethod,
blankLabel = 'existing-access' blankLabel = 'existing-access'
): string { ): string {
+1 -1
View File
@@ -17,7 +17,7 @@ export interface VisualizerSettings {
hidePacketFeed: boolean; hidePacketFeed: boolean;
} }
export const VISUALIZER_DEFAULTS: VisualizerSettings = { const VISUALIZER_DEFAULTS: VisualizerSettings = {
showAmbiguousPaths: true, showAmbiguousPaths: true,
showAmbiguousNodes: false, showAmbiguousNodes: false,
useAdvertPathHints: true, useAdvertPathHints: true,
+1 -1
View File
@@ -116,7 +116,7 @@ export interface PathStep {
hiddenLabel?: string | null; hiddenLabel?: string | null;
} }
export function normalizeHopToken(hop: string | null | undefined): string | null { function normalizeHopToken(hop: string | null | undefined): string | null {
const normalized = hop?.trim().toLowerCase() ?? ''; const normalized = hop?.trim().toLowerCase() ?? '';
return normalized.length > 0 ? normalized : null; return normalized.length > 0 ? normalized : null;
} }
+3 -3
View File
@@ -1,9 +1,9 @@
[project] [project]
name = "remoteterm-meshcore" name = "remoteterm-meshcore"
version = "3.7.0" version = "3.8.0"
description = "RemoteTerm - Web interface for MeshCore radio mesh networks" description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.11"
dependencies = [ dependencies = [
"fastapi>=0.115.0", "fastapi>=0.115.0",
"uvicorn[standard]>=0.32.0", "uvicorn[standard]>=0.32.0",
@@ -57,7 +57,7 @@ ignore = [
known-first-party = ["app"] known-first-party = ["app"]
[tool.pyright] [tool.pyright]
pythonVersion = "3.10" pythonVersion = "3.11"
typeCheckingMode = "basic" typeCheckingMode = "basic"
include = ["app"] include = ["app"]
exclude = ["references", ".venv", "tests"] exclude = ["references", ".venv", "tests"]
+24 -7
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -e set -euo pipefail
# developer perogative ;D # developer perogative ;D
if command -v enablenvm >/dev/null 2>&1; then if command -v enablenvm >/dev/null 2>&1; then
@@ -44,12 +44,21 @@ echo -e "${YELLOW}=== Phase 2: Typecheck, Tests & Build ===${NC}"
echo -ne "${BLUE}[pyright]${NC} " echo -ne "${BLUE}[pyright]${NC} "
cd "$REPO_ROOT" cd "$REPO_ROOT"
uv run pyright app/ --outputjson 2>/dev/null | python3 -c " pyright_json="$(mktemp)"
import sys, json if uv run pyright app/ --outputjson >"$pyright_json"; then
d = json.load(sys.stdin) python3 -c "
import json, sys
with open(sys.argv[1]) as f:
d = json.load(f)
s = d.get('summary', {}) s = d.get('summary', {})
print(f\"{s.get('filesAnalyzed',0)} files, 0 errors\") print(f\"{s.get('filesAnalyzed', 0)} files, {s.get('errorCount', 0)} errors\")
" 2>/dev/null || { uv run pyright app/; exit 1; } " "$pyright_json"
else
uv run pyright app/
rm -f "$pyright_json"
exit 1
fi
rm -f "$pyright_json"
echo -e "${GREEN}Passed!${NC}" echo -e "${GREEN}Passed!${NC}"
echo -ne "${BLUE}[pytest]${NC} " echo -ne "${BLUE}[pytest]${NC} "
@@ -59,7 +68,15 @@ echo -e "${GREEN}Passed!${NC}"
echo -ne "${BLUE}[vitest]${NC} " echo -ne "${BLUE}[vitest]${NC} "
cd "$REPO_ROOT/frontend" cd "$REPO_ROOT/frontend"
npx --quiet vitest run --reporter=dot 2>&1 | tail -5 vitest_log="$(mktemp)"
if npx --quiet vitest run --reporter=dot >"$vitest_log" 2>&1; then
tail -5 "$vitest_log"
else
cat "$vitest_log"
rm -f "$vitest_log"
exit 1
fi
rm -f "$vitest_log"
echo -e "${GREEN}Passed!${NC}" echo -e "${GREEN}Passed!${NC}"
echo -ne "${BLUE}[build]${NC} " echo -ne "${BLUE}[build]${NC} "
-2
View File
@@ -222,9 +222,7 @@ export interface AppSettings {
max_radio_contacts: number; max_radio_contacts: number;
favorites: Favorite[]; favorites: Favorite[];
auto_decrypt_dm_on_advert: boolean; auto_decrypt_dm_on_advert: boolean;
sidebar_sort_order: string;
last_message_times: Record<string, number>; last_message_times: Record<string, number>;
preferences_migrated: boolean;
advert_interval: number; advert_interval: number;
} }
+14 -5
View File
@@ -25,6 +25,16 @@ test.describe('Apprise integration settings', () => {
receiver.close(); receiver.close();
}); });
test.beforeEach(async () => {
// Clean up any stale configs from previous failed runs
const configs = await getFanoutConfigs();
for (const c of configs.filter((c) => c.name === 'E2E Apprise')) {
try {
await deleteFanoutConfig(c.id);
} catch { /* ignore */ }
}
});
test.afterEach(async () => { test.afterEach(async () => {
if (createdAppriseId) { if (createdAppriseId) {
try { try {
@@ -66,16 +76,15 @@ test.describe('Apprise integration settings', () => {
await page.getByRole('button', { name: /Save as Enabled/i }).click(); await page.getByRole('button', { name: /Save as Enabled/i }).click();
await expect(page.getByText('Integration saved and enabled')).toBeVisible(); await expect(page.getByText('Integration saved and enabled')).toBeVisible();
// Should be back on list view with our apprise config visible // Capture ID for cleanup before assertions that might fail
await expect(page.getByText('E2E Apprise')).toBeVisible();
await expect(page.getByText(appriseUrl)).toBeVisible();
// Clean up via API
const configs = await getFanoutConfigs(); const configs = await getFanoutConfigs();
const apprise = configs.find((c) => c.name === 'E2E Apprise'); const apprise = configs.find((c) => c.name === 'E2E Apprise');
if (apprise) { if (apprise) {
createdAppriseId = apprise.id; createdAppriseId = apprise.id;
} }
// Should be back on list view with our apprise config visible
await expect(fanoutHeader(page, 'E2E Apprise')).toBeVisible();
}); });
test('create apprise via API, verify options persist after edit', async ({ page }) => { test('create apprise via API, verify options persist after edit', async ({ page }) => {
+4 -4
View File
@@ -83,7 +83,7 @@ class TestDMAckTrackingWiring:
await _insert_contact(pub_key) await _insert_contact(pub_key)
with ( with (
patch("app.routers.messages.require_connected", return_value=mc), patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc), patch.object(radio_manager, "_meshcore", mc),
patch("app.routers.messages.track_pending_ack") as mock_track, patch("app.routers.messages.track_pending_ack") as mock_track,
patch("app.routers.messages.broadcast_event"), patch("app.routers.messages.broadcast_event"),
@@ -115,7 +115,7 @@ class TestDMAckTrackingWiring:
await _insert_contact(pub_key) await _insert_contact(pub_key)
with ( with (
patch("app.routers.messages.require_connected", return_value=mc), patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc), patch.object(radio_manager, "_meshcore", mc),
patch("app.routers.messages.track_pending_ack") as mock_track, patch("app.routers.messages.track_pending_ack") as mock_track,
patch("app.routers.messages.broadcast_event"), patch("app.routers.messages.broadcast_event"),
@@ -144,7 +144,7 @@ class TestDMAckTrackingWiring:
await _insert_contact(pub_key) await _insert_contact(pub_key)
with ( with (
patch("app.routers.messages.require_connected", return_value=mc), patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc), patch.object(radio_manager, "_meshcore", mc),
patch("app.routers.messages.track_pending_ack") as mock_track, patch("app.routers.messages.track_pending_ack") as mock_track,
patch("app.routers.messages.broadcast_event"), patch("app.routers.messages.broadcast_event"),
@@ -172,7 +172,7 @@ class TestDMAckTrackingWiring:
await _insert_contact(pub_key) await _insert_contact(pub_key)
with ( with (
patch("app.routers.messages.require_connected", return_value=mc), patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc), patch.object(radio_manager, "_meshcore", mc),
patch("app.routers.messages.track_pending_ack") as mock_track, patch("app.routers.messages.track_pending_ack") as mock_track,
patch("app.routers.messages.broadcast_event"), patch("app.routers.messages.broadcast_event"),
+2 -3
View File
@@ -49,10 +49,10 @@ def _disable_background_dm_retries(monkeypatch):
def _patch_require_connected(mc=None, *, detail="Radio not connected"): def _patch_require_connected(mc=None, *, detail="Radio not connected"):
if mc is None: if mc is None:
return patch( return patch(
"app.dependencies.radio_manager.require_connected", "app.services.radio_runtime.radio_runtime.require_connected",
side_effect=HTTPException(status_code=503, detail=detail), side_effect=HTTPException(status_code=503, detail=detail),
) )
return patch("app.dependencies.radio_manager.require_connected", return_value=mc) return patch("app.services.radio_runtime.radio_runtime.require_connected", return_value=mc)
async def _insert_contact(public_key, name="Alice", **overrides): async def _insert_contact(public_key, name="Alice", **overrides):
@@ -293,7 +293,6 @@ class TestDebugEndpoint:
json={ json={
"max_radio_contacts": 321, "max_radio_contacts": 321,
"auto_decrypt_dm_on_advert": True, "auto_decrypt_dm_on_advert": True,
"sidebar_sort_order": "alpha",
"advert_interval": 7200, "advert_interval": 7200,
"flood_scope": "US-CA", "flood_scope": "US-CA",
"blocked_keys": [pub_key], "blocked_keys": [pub_key],
+44 -2
View File
@@ -286,7 +286,7 @@ class TestPathDiscovery:
) )
with ( with (
patch("app.routers.contacts.require_connected", return_value=mc), patch("app.routers.contacts.radio_manager.require_connected", return_value=mc),
patch("app.routers.contacts.radio_manager") as mock_rm, patch("app.routers.contacts.radio_manager") as mock_rm,
patch("app.websocket.broadcast_event") as mock_broadcast, patch("app.websocket.broadcast_event") as mock_broadcast,
): ):
@@ -324,7 +324,7 @@ class TestPathDiscovery:
mc.wait_for_event = AsyncMock(return_value=None) mc.wait_for_event = AsyncMock(return_value=None)
with ( with (
patch("app.routers.contacts.require_connected", return_value=mc), patch("app.routers.contacts.radio_manager.require_connected", return_value=mc),
patch("app.routers.contacts.radio_manager") as mock_rm, patch("app.routers.contacts.radio_manager") as mock_rm,
): ):
mock_rm.radio_operation = _noop_radio_operation(mc) mock_rm.radio_operation = _noop_radio_operation(mc)
@@ -363,6 +363,48 @@ class TestDeleteContactCascade:
assert len(await ContactNameHistoryRepository.get_history(KEY_A)) == 0 assert len(await ContactNameHistoryRepository.get_history(KEY_A)) == 0
assert len(await ContactAdvertPathRepository.get_recent_for_contact(KEY_A)) == 0 assert len(await ContactAdvertPathRepository.get_recent_for_contact(KEY_A)) == 0
@pytest.mark.asyncio
async def test_delete_preserves_dms_and_readd_resurfaces_them(self, test_db, client):
await _insert_contact(KEY_A, "Alice")
# Create an incoming DM for this contact
await MessageRepository.create(
msg_type="PRIV",
conversation_key=KEY_A,
text="hello",
sender_timestamp=1000,
received_at=1000,
)
# Unread count should include the DM
unreads = await MessageRepository.get_unread_counts()
assert unreads["counts"].get(f"contact-{KEY_A.lower()}", 0) == 1
with patch("app.routers.contacts.radio_manager") as mock_rm:
mock_rm.is_connected = False
mock_rm.meshcore = None
mock_rm.radio_operation = _noop_radio_operation()
response = await client.delete(f"/api/contacts/{KEY_A}")
assert response.status_code == 200
# DMs are preserved in the database
msgs = await MessageRepository.get_all(msg_type="PRIV", conversation_key=KEY_A)
assert len(msgs) == 1
# Orphaned DMs still appear in unread counts (LEFT JOIN)
unreads = await MessageRepository.get_unread_counts()
assert unreads["counts"].get(f"contact-{KEY_A.lower()}", 0) == 1
# Re-add the contact
await _insert_contact(KEY_A, "Alice Returns")
# Messages re-surface with the re-added contact
msgs = await MessageRepository.get_all(msg_type="PRIV", conversation_key=KEY_A)
assert len(msgs) == 1
unreads = await MessageRepository.get_unread_counts()
assert unreads["counts"].get(f"contact-{KEY_A.lower()}", 0) == 1
class TestMarkRead: class TestMarkRead:
"""Test POST /api/contacts/{public_key}/mark-read.""" """Test POST /api/contacts/{public_key}/mark-read."""
-69
View File
@@ -13,7 +13,6 @@ from app.decoder import (
DecryptedDirectMessage, DecryptedDirectMessage,
PayloadType, PayloadType,
RouteType, RouteType,
_clamp_scalar,
decrypt_direct_message, decrypt_direct_message,
decrypt_group_text, decrypt_group_text,
decrypt_path_payload, decrypt_path_payload,
@@ -27,17 +26,6 @@ from app.decoder import (
) )
class TestChannelKeyDerivation:
"""Test channel key derivation from hashtag names."""
def test_hashtag_key_derivation(self):
"""Hashtag channel keys are derived as SHA256(name)[:16]."""
channel_name = "#test"
expected_key = hashlib.sha256(channel_name.encode("utf-8")).digest()[:16]
assert len(expected_key) == 16
class TestPacketParsing: class TestPacketParsing:
"""Test raw packet header parsing.""" """Test raw packet header parsing."""
@@ -687,49 +675,6 @@ class TestAdvertisementParsing:
assert result is None assert result is None
class TestScalarClamping:
"""Test X25519 scalar clamping for ECDH."""
def test_clamp_scalar_modifies_first_byte(self):
"""Clamping clears the lower 3 bits of the first byte."""
# Input with all bits set in first byte
scalar = bytes([0xFF]) + bytes(31)
result = _clamp_scalar(scalar)
# First byte should have lower 3 bits cleared: 0xFF & 248 = 0xF8
assert result[0] == 0xF8
def test_clamp_scalar_modifies_last_byte(self):
"""Clamping modifies the last byte for correct group operations."""
# Input with all bits set in last byte
scalar = bytes(31) + bytes([0xFF])
result = _clamp_scalar(scalar)
# Last byte: (0xFF & 63) | 64 = 0x7F
assert result[31] == 0x7F
def test_clamp_scalar_preserves_middle_bytes(self):
"""Clamping preserves the middle bytes unchanged."""
# Known middle bytes
scalar = bytes([0xAB]) + bytes([0x12, 0x34, 0x56] * 10)[:30] + bytes([0xCD])
result = _clamp_scalar(scalar)
# Middle bytes should be unchanged
assert result[1:31] == scalar[1:31]
def test_clamp_scalar_truncates_to_32_bytes(self):
"""Clamping uses only first 32 bytes of input."""
# 64-byte input (typical Ed25519 private key)
scalar = bytes(64)
result = _clamp_scalar(scalar)
assert len(result) == 32
class TestPublicKeyDerivation: class TestPublicKeyDerivation:
"""Test deriving Ed25519 public key from MeshCore private key.""" """Test deriving Ed25519 public key from MeshCore private key."""
@@ -766,13 +711,6 @@ class TestPublicKeyDerivation:
assert len(result) == 32 assert len(result) == 32
assert result == self.FACE12_PUB_EXPECTED assert result == self.FACE12_PUB_EXPECTED
def test_derive_public_key_deterministic(self):
"""Same private key always produces same public key."""
result1 = derive_public_key(self.FACE12_PRIV)
result2 = derive_public_key(self.FACE12_PRIV)
assert result1 == result2
class TestSharedSecretDerivation: class TestSharedSecretDerivation:
"""Test ECDH shared secret derivation from Ed25519 keys.""" """Test ECDH shared secret derivation from Ed25519 keys."""
@@ -793,13 +731,6 @@ class TestSharedSecretDerivation:
assert len(result) == 32 assert len(result) == 32
def test_derive_shared_secret_deterministic(self):
"""Same inputs always produce same shared secret."""
result1 = derive_shared_secret(self.FACE12_PRIV, self.A1B2C3_PUB)
result2 = derive_shared_secret(self.FACE12_PRIV, self.A1B2C3_PUB)
assert result1 == result2
def test_derive_shared_secret_different_keys_different_result(self): def test_derive_shared_secret_different_keys_different_result(self):
"""Different key pairs produce different shared secrets.""" """Different key pairs produce different shared secrets."""
# Use the real FACE12 public key as a second peer key (valid curve point) # Use the real FACE12 public key as a second peer key (valid curve point)
-13
View File
@@ -10,24 +10,11 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from fastapi import HTTPException from fastapi import HTTPException
from app.config import Settings
from app.repository.fanout import FanoutConfigRepository from app.repository.fanout import FanoutConfigRepository
from app.routers.fanout import FanoutConfigCreate, create_fanout_config from app.routers.fanout import FanoutConfigCreate, create_fanout_config
from app.routers.health import build_health_data from app.routers.health import build_health_data
class TestDisableBotsConfig:
"""Test the disable_bots configuration field."""
def test_disable_bots_defaults_to_false(self):
s = Settings(serial_port="", tcp_host="", ble_address="")
assert s.disable_bots is False
def test_disable_bots_can_be_set_true(self):
s = Settings(serial_port="", tcp_host="", ble_address="", disable_bots=True)
assert s.disable_bots is True
class TestDisableBotsFanoutEndpoint: class TestDisableBotsFanoutEndpoint:
"""Test that bot creation via fanout router is rejected when bots are disabled.""" """Test that bot creation via fanout router is rejected when bots are disabled."""
+1 -1
View File
@@ -883,7 +883,7 @@ class TestDirectMessageDirectionDetection:
message_broadcasts = [b for b in broadcasts if b["type"] == "message"] message_broadcasts = [b for b in broadcasts if b["type"] == "message"]
assert len(message_broadcasts) == 1 assert len(message_broadcasts) == 1
assert message_broadcasts[0]["data"]["paths"] == [ assert message_broadcasts[0]["data"]["paths"] == [
{"path": "", "received_at": SENDER_TIMESTAMP, "path_len": 0} {"path": "", "received_at": SENDER_TIMESTAMP, "path_len": 0, "rssi": None, "snr": None}
] ]
@pytest.mark.asyncio @pytest.mark.asyncio
-13
View File
@@ -89,19 +89,6 @@ class TestSetPrivateKey:
assert pub1 != pub2 assert pub1 != pub2
class TestGettersWhenEmpty:
"""Test getter behavior when no key is stored."""
def test_get_private_key_returns_none(self):
assert get_private_key() is None
def test_get_public_key_returns_none(self):
assert get_public_key() is None
def test_has_private_key_false(self):
assert has_private_key() is False
class TestClearKeys: class TestClearKeys:
"""Test clearing in-memory key material.""" """Test clearing in-memory key material."""
+16 -41
View File
@@ -8,31 +8,6 @@ import pytest
from app.migrations import get_version, run_migrations, set_version from app.migrations import get_version, run_migrations, set_version
class TestMigrationSystem:
"""Test the migration version tracking system."""
@pytest.mark.asyncio
async def test_get_version_returns_zero_for_new_db(self):
"""New database has user_version=0."""
conn = await aiosqlite.connect(":memory:")
try:
version = await get_version(conn)
assert version == 0
finally:
await conn.close()
@pytest.mark.asyncio
async def test_set_version_updates_pragma(self):
"""Setting version updates the user_version pragma."""
conn = await aiosqlite.connect(":memory:")
try:
await set_version(conn, 5)
version = await get_version(conn)
assert version == 5
finally:
await conn.close()
class TestMigration001: class TestMigration001:
"""Test migration 001: add last_read_at columns.""" """Test migration 001: add last_read_at columns."""
@@ -1249,8 +1224,8 @@ class TestMigration039:
applied = await run_migrations(conn) applied = await run_migrations(conn)
assert applied == 12 assert applied == 16
assert await get_version(conn) == 50 assert await get_version(conn) == 54
cursor = await conn.execute( cursor = await conn.execute(
""" """
@@ -1321,8 +1296,8 @@ class TestMigration039:
applied = await run_migrations(conn) applied = await run_migrations(conn)
assert applied == 12 assert applied == 16
assert await get_version(conn) == 50 assert await get_version(conn) == 54
cursor = await conn.execute( cursor = await conn.execute(
""" """
@@ -1388,8 +1363,8 @@ class TestMigration039:
applied = await run_migrations(conn) applied = await run_migrations(conn)
assert applied == 6 assert applied == 10
assert await get_version(conn) == 50 assert await get_version(conn) == 54
cursor = await conn.execute( cursor = await conn.execute(
""" """
@@ -1441,8 +1416,8 @@ class TestMigration040:
applied = await run_migrations(conn) applied = await run_migrations(conn)
assert applied == 11 assert applied == 15
assert await get_version(conn) == 50 assert await get_version(conn) == 54
await conn.execute( await conn.execute(
""" """
@@ -1503,8 +1478,8 @@ class TestMigration041:
applied = await run_migrations(conn) applied = await run_migrations(conn)
assert applied == 10 assert applied == 14
assert await get_version(conn) == 50 assert await get_version(conn) == 54
await conn.execute( await conn.execute(
""" """
@@ -1556,8 +1531,8 @@ class TestMigration042:
applied = await run_migrations(conn) applied = await run_migrations(conn)
assert applied == 9 assert applied == 13
assert await get_version(conn) == 50 assert await get_version(conn) == 54
await conn.execute( await conn.execute(
""" """
@@ -1696,8 +1671,8 @@ class TestMigration046:
applied = await run_migrations(conn) applied = await run_migrations(conn)
assert applied == 5 assert applied == 9
assert await get_version(conn) == 50 assert await get_version(conn) == 54
cursor = await conn.execute( cursor = await conn.execute(
""" """
@@ -1790,8 +1765,8 @@ class TestMigration047:
applied = await run_migrations(conn) applied = await run_migrations(conn)
assert applied == 4 assert applied == 8
assert await get_version(conn) == 50 assert await get_version(conn) == 54
cursor = await conn.execute( cursor = await conn.execute(
""" """
+11 -11
View File
@@ -180,7 +180,7 @@ class TestChannelMessagePipeline:
assert result is not None assert result is not None
# Raw packet should be stored # Raw packet should be stored
raw_packets = await RawPacketRepository.get_all_undecrypted() raw_packets = [p async for p in RawPacketRepository.stream_all_undecrypted()]
assert len(raw_packets) >= 1 assert len(raw_packets) >= 1
# No message broadcast (only raw_packet broadcast) # No message broadcast (only raw_packet broadcast)
@@ -900,7 +900,7 @@ class TestCreateMessageFromDecrypted:
) )
# Verify packet is marked decrypted (has message_id set) # Verify packet is marked decrypted (has message_id set)
undecrypted = await RawPacketRepository.get_all_undecrypted() undecrypted = [p async for p in RawPacketRepository.stream_all_undecrypted()]
packet_ids = [p[0] for p in undecrypted] packet_ids = [p[0] for p in undecrypted]
assert packet_id not in packet_ids # Should be marked as decrypted assert packet_id not in packet_ids # Should be marked as decrypted
@@ -1206,7 +1206,7 @@ class TestCreateDMMessageFromDecrypted:
) )
# Verify packet is marked decrypted # Verify packet is marked decrypted
undecrypted = await RawPacketRepository.get_all_undecrypted() undecrypted = [p async for p in RawPacketRepository.stream_all_undecrypted()]
packet_ids = [p[0] for p in undecrypted] packet_ids = [p[0] for p in undecrypted]
assert packet_id not in packet_ids assert packet_id not in packet_ids
@@ -1314,7 +1314,7 @@ class TestDMDecryptionFunction:
assert messages[0].outgoing is False assert messages[0].outgoing is False
# Verify raw packet is linked # Verify raw packet is linked
undecrypted = await RawPacketRepository.get_all_undecrypted() undecrypted = [p async for p in RawPacketRepository.stream_all_undecrypted()]
assert packet_id not in [p[0] for p in undecrypted] assert packet_id not in [p[0] for p in undecrypted]
@@ -2080,7 +2080,7 @@ class TestProcessRawPacketIntegration:
result = await process_raw_packet(raw, timestamp=7000) result = await process_raw_packet(raw, timestamp=7000)
# Verify packet is in undecrypted list # Verify packet is in undecrypted list
undecrypted = await RawPacketRepository.get_all_undecrypted() undecrypted = [p async for p in RawPacketRepository.stream_all_undecrypted()]
packet_ids = [p[0] for p in undecrypted] packet_ids = [p[0] for p in undecrypted]
assert result["packet_id"] in packet_ids assert result["packet_id"] in packet_ids
@@ -2590,7 +2590,7 @@ class TestHistoricalChannelDecryptIntegration:
assert len(message_broadcasts) == 0 assert len(message_broadcasts) == 0
# Raw packet is in the undecrypted pool # Raw packet is in the undecrypted pool
undecrypted = await RawPacketRepository.get_all_undecrypted() undecrypted = [p async for p in RawPacketRepository.stream_all_undecrypted()]
assert len(undecrypted) == 1 assert len(undecrypted) == 1
packet_id = undecrypted[0][0] packet_id = undecrypted[0][0]
@@ -2615,7 +2615,7 @@ class TestHistoricalChannelDecryptIntegration:
assert msg.conversation_key == channel_key_hex assert msg.conversation_key == channel_key_hex
# --- Verify: raw packet is now marked as decrypted --- # --- Verify: raw packet is now marked as decrypted ---
undecrypted_after = await RawPacketRepository.get_all_undecrypted() undecrypted_after = [p async for p in RawPacketRepository.stream_all_undecrypted()]
remaining_ids = [p[0] for p in undecrypted_after] remaining_ids = [p[0] for p in undecrypted_after]
assert packet_id not in remaining_ids assert packet_id not in remaining_ids
@@ -2639,7 +2639,7 @@ class TestHistoricalChannelDecryptIntegration:
await process_raw_packet(raw_packet, timestamp=1700000000) await process_raw_packet(raw_packet, timestamp=1700000000)
# Packet stored undecrypted # Packet stored undecrypted
assert len(await RawPacketRepository.get_all_undecrypted()) == 1 assert len([p async for p in RawPacketRepository.stream_all_undecrypted()]) == 1
# Run historical decrypt with the wrong key # Run historical decrypt with the wrong key
with patch("app.websocket.ws_manager") as mock_ws: with patch("app.websocket.ws_manager") as mock_ws:
@@ -2653,7 +2653,7 @@ class TestHistoricalChannelDecryptIntegration:
assert len(messages) == 0 assert len(messages) == 0
# Packet still undecrypted # Packet still undecrypted
assert len(await RawPacketRepository.get_all_undecrypted()) == 1 assert len([p async for p in RawPacketRepository.stream_all_undecrypted()]) == 1
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_historical_decrypt_multiple_packets(self, test_db, captured_broadcasts): async def test_historical_decrypt_multiple_packets(self, test_db, captured_broadcasts):
@@ -2680,7 +2680,7 @@ class TestHistoricalChannelDecryptIntegration:
for pkt in packets: for pkt in packets:
await process_raw_packet(pkt, timestamp=1700000000) await process_raw_packet(pkt, timestamp=1700000000)
assert len(await RawPacketRepository.get_all_undecrypted()) == 3 assert len([p async for p in RawPacketRepository.stream_all_undecrypted()]) == 3
# Add channel, run historical decrypt # Add channel, run historical decrypt
await ChannelRepository.upsert(key=channel_key_hex, name=channel_name, is_hashtag=True) await ChannelRepository.upsert(key=channel_key_hex, name=channel_name, is_hashtag=True)
@@ -2697,4 +2697,4 @@ class TestHistoricalChannelDecryptIntegration:
assert texts == ["Alice: First message", "Bob: Second message", "Carol: Third message"] assert texts == ["Alice: First message", "Bob: Second message", "Carol: Third message"]
# All packets now decrypted # All packets now decrypted
assert len(await RawPacketRepository.get_all_undecrypted()) == 0 assert len([p async for p in RawPacketRepository.stream_all_undecrypted()]) == 0
+11 -11
View File
@@ -295,12 +295,12 @@ class TestContactToRadioDictHashMode:
class TestContactFromRadioDictHashMode: class TestContactFromRadioDictHashMode:
"""Test that Contact.from_radio_dict() preserves explicit path hash mode.""" """Test that ContactUpsert.from_radio_dict() preserves explicit path hash mode."""
def test_preserves_mode_from_radio_payload(self): def test_preserves_mode_from_radio_payload(self):
from app.models import Contact from app.models import ContactUpsert
d = Contact.from_radio_dict( upsert = ContactUpsert.from_radio_dict(
"aa" * 32, "aa" * 32,
{ {
"adv_name": "Alice", "adv_name": "Alice",
@@ -309,14 +309,14 @@ class TestContactFromRadioDictHashMode:
"out_path_hash_mode": 1, "out_path_hash_mode": 1,
}, },
) )
assert d["direct_path"] == "aa00bb00" assert upsert.direct_path == "aa00bb00"
assert d["direct_path_len"] == 2 assert upsert.direct_path_len == 2
assert d["direct_path_hash_mode"] == 1 assert upsert.direct_path_hash_mode == 1
def test_flood_falls_back_to_minus_one(self): def test_flood_falls_back_to_minus_one(self):
from app.models import Contact from app.models import ContactUpsert
d = Contact.from_radio_dict( upsert = ContactUpsert.from_radio_dict(
"bb" * 32, "bb" * 32,
{ {
"adv_name": "Bob", "adv_name": "Bob",
@@ -324,6 +324,6 @@ class TestContactFromRadioDictHashMode:
"out_path_len": -1, "out_path_len": -1,
}, },
) )
assert d["direct_path"] == "" assert upsert.direct_path == ""
assert d["direct_path_len"] == -1 assert upsert.direct_path_len == -1
assert d["direct_path_hash_mode"] == -1 assert upsert.direct_path_hash_mode == -1

Some files were not shown because too many files have changed in this diff Show More