mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-12 12:26:21 +02:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c33eb469ac | |||
| 0fe6584e7a | |||
| 557d79d437 | |||
| daff3dcb4a | |||
| 77db7287d6 | |||
| 67873e8dd9 | |||
| e2ddf5f79f | |||
| 4a93641f04 | |||
| d5922a214b | |||
| 7ad1ee26a4 | |||
| 08238aa464 | |||
| 1046baf741 | |||
| 42e1b7b5d9 | |||
| 3ca4f7edf7 | |||
| 55081d4a2d | |||
| be2b2604df | |||
| 35981d8f8b | |||
| 8e998c03ba | |||
| d802dd4212 | |||
| 7557eb1fa6 | |||
| 6a4af5e602 | |||
| 1895e6a919 | |||
| 975bf7f03f | |||
| c7d5d3887d | |||
| 5c93d8487e | |||
| 5d2834a9fb | |||
| cfe485bf29 | |||
| e7f6bd0397 | |||
| 1e7dc6af46 | |||
| af40cc3c8e | |||
| 2561b70fed | |||
| 44f145b646 | |||
| 55e2dc478d | |||
| 0932800e1f |
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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")
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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."""
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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,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",
|
||||||
|
|||||||
@@ -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
@@ -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: {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 (<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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: '© <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:
|
||||||
|
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <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 }}
|
||||||
<1h
|
aria-hidden="true"
|
||||||
</span>
|
/>{' '}
|
||||||
<span className="flex items-center gap-1">
|
<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 }}
|
||||||
<1d
|
aria-hidden="true"
|
||||||
</span>
|
/>{' '}
|
||||||
<span className="flex items-center gap-1">
|
<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 }}
|
||||||
<3d
|
aria-hidden="true"
|
||||||
</span>
|
/>{' '}
|
||||||
<span className="flex items-center gap-1">
|
<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='© <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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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/<key>/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 & 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 · Primary: bg-primary/10 · 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="button" +
|
||||||
|
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">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: '',
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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?: {
|
||||||
|
|||||||
@@ -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
@@ -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 */
|
||||||
|
|||||||
@@ -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,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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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"]
|
||||||
|
|||||||
@@ -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} "
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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
@@ -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],
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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
@@ -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
Reference in New Issue
Block a user