mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-13 04:46:05 +02:00
Compare commits
67 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 | |||
| c333eb25e3 | |||
| 580aa1cefd | |||
| 30de09f71b | |||
| 93d31adecd | |||
| 5f969017f7 | |||
| 967dd05fad | |||
| c808f0930b | |||
| 87df4b4aa1 | |||
| 0511d6f69b | |||
| 78b5598f67 | |||
| 5e1bdb2cc1 | |||
| 4420d44838 | |||
| ead1774cd3 | |||
| 0d45cbd849 | |||
| 456f739f51 | |||
| 80c6cc44e5 | |||
| 35265d8ae8 | |||
| 4a2d7ed100 | |||
| 47c4f038fe | |||
| 630ba67ef0 | |||
| fd1188abcd | |||
| 94513d7177 | |||
| fbff9821be | |||
| 1fd281121b | |||
| 5653a43941 | |||
| 7f07aedb8a | |||
| e437ce74c6 | |||
| 4ff6d2018a | |||
| 1c634da687 | |||
| 738c21dd66 | |||
| 7d72448ebf | |||
| b4f3d1f14c | |||
| 416166b07c |
@@ -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/repeaters/advert-paths` | List recent unique advert paths for all contacts |
|
||||
| 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 |
|
||||
| POST | `/api/contacts/{public_key}/mark-read` | Mark contact conversation as read |
|
||||
| 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/lpp-telemetry` | Fetch room-server CayenneLPP sensor data |
|
||||
| POST | `/api/contacts/{public_key}/room/acl` | Fetch room-server ACL entries |
|
||||
|
||||
| GET | `/api/channels` | List channels |
|
||||
| GET | `/api/channels/{key}/detail` | Comprehensive channel profile (message stats, top senders) |
|
||||
| POST | `/api/channels` | Create channel |
|
||||
| POST | `/api/channels/bulk-hashtag` | Create multiple hashtag channels |
|
||||
| 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}/path-hash-mode-override` | Set or clear a per-channel path hash mode override |
|
||||
| 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/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
|
||||
- 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 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
|
||||
|
||||
@@ -463,7 +466,7 @@ mc.subscribe(EventType.ACK, handler)
|
||||
|----------|---------|-------------|
|
||||
| `MESHCORE_SERIAL_PORT` | auto-detect | Serial port for radio |
|
||||
| `MESHCORE_TCP_HOST` | *(none)* | TCP host for radio (mutually exclusive with serial/BLE) |
|
||||
| `MESHCORE_TCP_PORT` | `4000` | TCP port (used with `MESHCORE_TCP_HOST`) |
|
||||
| `MESHCORE_TCP_PORT` | `5000` | TCP port (used with `MESHCORE_TCP_HOST`) |
|
||||
| `MESHCORE_BLE_ADDRESS` | *(none)* | BLE device address (mutually exclusive with serial/TCP) |
|
||||
| `MESHCORE_BLE_PIN` | *(required with BLE)* | BLE PIN code |
|
||||
| `MESHCORE_SERIAL_BAUDRATE` | `115200` | Serial baud rate |
|
||||
@@ -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_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.
|
||||
|
||||
|
||||
@@ -1,3 +1,38 @@
|
||||
## [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
|
||||
|
||||
* Feature: Repeater battery tracking
|
||||
* Feature: Repeater info pane just like contacts
|
||||
* Feature: Make repeaters blockable
|
||||
* Feature: Add new-node advert blocking
|
||||
* Feature: Add bulk deletion interface
|
||||
* Feature: Bulk room add on alt+click of new channel button
|
||||
* Feature: More info in debug endpoint
|
||||
* Bugfix: Be more conservative around radio load limits and don't exceed radio-reported capacity
|
||||
* Misc: Default auto-DM decrypt to true
|
||||
* Misc: Reorganize some settings panes
|
||||
* Misc: Enable FK pragma
|
||||
* Misc: Various performance and correctness fixes
|
||||
* Misc: Correct TCP default port
|
||||
|
||||
## [3.6.7] - 2026-03-31
|
||||
|
||||
* Misc: Remove armv7 (for now)
|
||||
|
||||
@@ -177,7 +177,7 @@ Only one transport may be active at a time. If multiple are set, the server will
|
||||
| `MESHCORE_SERIAL_PORT` | (auto-detect) | Serial port path |
|
||||
| `MESHCORE_SERIAL_BAUDRATE` | 115200 | Serial baud rate |
|
||||
| `MESHCORE_TCP_HOST` | | TCP host (mutually exclusive with serial/BLE) |
|
||||
| `MESHCORE_TCP_PORT` | 4000 | TCP port |
|
||||
| `MESHCORE_TCP_PORT` | 5000 | TCP port |
|
||||
| `MESHCORE_BLE_ADDRESS` | | BLE device address (mutually exclusive with serial/TCP) |
|
||||
| `MESHCORE_BLE_PIN` | | BLE PIN (required when BLE address is set) |
|
||||
| `MESHCORE_LOG_LEVEL` | INFO | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
|
||||
@@ -193,7 +193,7 @@ Common launch patterns:
|
||||
MESHCORE_SERIAL_PORT=/dev/ttyUSB0 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
|
||||
# TCP
|
||||
MESHCORE_TCP_HOST=192.168.1.100 MESHCORE_TCP_PORT=4000 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
MESHCORE_TCP_HOST=192.168.1.100 MESHCORE_TCP_PORT=5000 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
|
||||
# BLE
|
||||
MESHCORE_BLE_ADDRESS=AA:BB:CC:DD:EE:FF MESHCORE_BLE_PIN=123456 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
|
||||
+5
-5
@@ -190,6 +190,7 @@ app/
|
||||
- `GET /contacts/analytics` — unified keyed-or-name analytics payload
|
||||
- `GET /contacts/repeaters/advert-paths` — recent advert paths for all contacts
|
||||
- `POST /contacts`
|
||||
- `POST /contacts/bulk-delete`
|
||||
- `DELETE /contacts/{public_key}`
|
||||
- `POST /contacts/{public_key}/mark-read`
|
||||
- `POST /contacts/{public_key}/command`
|
||||
@@ -214,8 +215,10 @@ app/
|
||||
- `GET /channels`
|
||||
- `GET /channels/{key}/detail`
|
||||
- `POST /channels`
|
||||
- `POST /channels/bulk-hashtag`
|
||||
- `DELETE /channels/{key}`
|
||||
- `POST /channels/{key}/flood-scope-override`
|
||||
- `POST /channels/{key}/path-hash-mode-override`
|
||||
- `POST /channels/{key}/mark-read`
|
||||
|
||||
### Messages
|
||||
@@ -278,7 +281,7 @@ Client sends `"ping"` text; server replies `{"type":"pong"}`.
|
||||
Main tables:
|
||||
- `contacts` (includes `first_seen` for contact age tracking and `direct_path_hash_mode` / `route_override_*` for DM routing)
|
||||
- `channels`
|
||||
Includes optional `flood_scope_override` for channel-specific regional sends.
|
||||
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)
|
||||
- `raw_packets`
|
||||
- `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`
|
||||
- `favorites`
|
||||
- `auto_decrypt_dm_on_advert`
|
||||
- `sidebar_sort_order`
|
||||
- `last_message_times`
|
||||
- `preferences_migrated`
|
||||
- `advert_interval`
|
||||
- `last_advert_time`
|
||||
- `flood_scope`
|
||||
- `blocked_keys`, `blocked_names`
|
||||
|
||||
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.
|
||||
- `blocked_keys`, `blocked_names`, `discovery_blocked_types`
|
||||
|
||||
Note: MQTT, community MQTT, and bot configs were migrated to the `fanout_configs` table (migrations 36-38).
|
||||
|
||||
|
||||
+2
-1
@@ -14,7 +14,7 @@ class Settings(BaseSettings):
|
||||
serial_port: str = "" # Empty string triggers auto-detection
|
||||
serial_baudrate: int = 115200
|
||||
tcp_host: str = ""
|
||||
tcp_port: int = 4000
|
||||
tcp_port: int = 5000
|
||||
ble_address: str = ""
|
||||
ble_pin: str = ""
|
||||
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
|
||||
@@ -26,6 +26,7 @@ class Settings(BaseSettings):
|
||||
default=False,
|
||||
validation_alias="__CLOWNTOWN_DO_CLOCK_WRAPAROUND",
|
||||
)
|
||||
skip_post_connect_sync: bool = False
|
||||
basic_auth_username: str = ""
|
||||
basic_auth_password: str = ""
|
||||
|
||||
|
||||
+62
-7
@@ -36,6 +36,7 @@ CREATE TABLE IF NOT EXISTS channels (
|
||||
is_hashtag INTEGER DEFAULT 0,
|
||||
on_radio INTEGER DEFAULT 0,
|
||||
flood_scope_override TEXT,
|
||||
path_hash_mode_override INTEGER,
|
||||
last_read_at INTEGER
|
||||
);
|
||||
|
||||
@@ -46,7 +47,7 @@ CREATE TABLE IF NOT EXISTS messages (
|
||||
text TEXT NOT NULL,
|
||||
sender_timestamp INTEGER,
|
||||
received_at INTEGER NOT NULL,
|
||||
path TEXT,
|
||||
paths TEXT,
|
||||
txt_type INTEGER DEFAULT 0,
|
||||
signature TEXT,
|
||||
outgoing INTEGER DEFAULT 0,
|
||||
@@ -66,7 +67,7 @@ CREATE TABLE IF NOT EXISTS raw_packets (
|
||||
data BLOB NOT NULL,
|
||||
message_id INTEGER,
|
||||
payload_hash BLOB,
|
||||
FOREIGN KEY (message_id) REFERENCES messages(id)
|
||||
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS contact_advert_paths (
|
||||
@@ -78,7 +79,7 @@ CREATE TABLE IF NOT EXISTS contact_advert_paths (
|
||||
last_seen INTEGER NOT NULL,
|
||||
heard_count INTEGER NOT NULL DEFAULT 1,
|
||||
UNIQUE(public_key, path_hex, path_len),
|
||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key)
|
||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS contact_name_history (
|
||||
@@ -88,26 +89,70 @@ CREATE TABLE IF NOT EXISTS contact_name_history (
|
||||
first_seen INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
UNIQUE(public_key, name),
|
||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key)
|
||||
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 UNIQUE INDEX IF NOT EXISTS idx_messages_dedup_null_safe
|
||||
ON messages(type, conversation_key, text, COALESCE(sender_timestamp, 0))
|
||||
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_timestamp ON raw_packets(timestamp);
|
||||
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_messages_type_received_conversation
|
||||
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
|
||||
ON contact_advert_paths(public_key, last_seen DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_contact_name_history_key
|
||||
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);
|
||||
"""
|
||||
|
||||
|
||||
@@ -132,6 +177,12 @@ class Database:
|
||||
# migration 20 handles the one-time VACUUM to restructure the file.
|
||||
await self._connection.execute("PRAGMA auto_vacuum = INCREMENTAL")
|
||||
|
||||
# Foreign key enforcement: must be set per-connection (not persisted).
|
||||
# Disabled during schema init and migrations to avoid issues with
|
||||
# historical table-rebuild migrations that may temporarily violate
|
||||
# constraints, then re-enabled for all subsequent application queries.
|
||||
await self._connection.execute("PRAGMA foreign_keys = OFF")
|
||||
|
||||
await self._connection.executescript(SCHEMA)
|
||||
await self._connection.commit()
|
||||
logger.debug("Database schema initialized")
|
||||
@@ -141,6 +192,10 @@ class Database:
|
||||
|
||||
await run_migrations(self._connection)
|
||||
|
||||
# Enable FK enforcement for all application queries from this point on.
|
||||
await self._connection.execute("PRAGMA foreign_keys = ON")
|
||||
logger.debug("Foreign key enforcement enabled")
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
if self._connection:
|
||||
await self._connection.close()
|
||||
|
||||
@@ -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.
|
||||
normalized_path_hash_mode = -1 if normalized_path_len == -1 else 0
|
||||
else:
|
||||
normalized_path_hash_mode = None
|
||||
try:
|
||||
normalized_path_hash_mode = int(path_hash_mode)
|
||||
except (TypeError, ValueError):
|
||||
|
||||
@@ -52,19 +52,6 @@ class ToastPayload(TypedDict):
|
||||
details: NotRequired[str]
|
||||
|
||||
|
||||
WsEventPayload = (
|
||||
HealthResponse
|
||||
| Message
|
||||
| Contact
|
||||
| ContactResolvedPayload
|
||||
| Channel
|
||||
| ContactDeletedPayload
|
||||
| ChannelDeletedPayload
|
||||
| RawPacketBroadcast
|
||||
| MessageAckedPayload
|
||||
| ToastPayload
|
||||
)
|
||||
|
||||
_PAYLOAD_ADAPTERS: dict[WsEventType, TypeAdapter[Any]] = {
|
||||
"health": TypeAdapter(HealthResponse),
|
||||
"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:
|
||||
"""Serialize a WebSocket event envelope with validation for known event types."""
|
||||
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,
|
||||
)
|
||||
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:
|
||||
return
|
||||
|
||||
# TODO: advert Ed25519 signature verification is skipped here.
|
||||
# The radio has already validated the packet before passing it 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.
|
||||
# Advert Ed25519 signature verification is intentionally skipped.
|
||||
# The radio validates packets before passing them to RT.
|
||||
|
||||
# Only process repeaters (2) and rooms (3) — any other role is rejected
|
||||
if advert.device_role not in _ALLOWED_DEVICE_ROLES:
|
||||
|
||||
@@ -21,6 +21,7 @@ from app.radio_sync import (
|
||||
stop_message_polling,
|
||||
stop_periodic_advert,
|
||||
stop_periodic_sync,
|
||||
stop_telemetry_collect,
|
||||
)
|
||||
from app.routers import (
|
||||
channels,
|
||||
@@ -103,6 +104,7 @@ async def lifespan(app: FastAPI):
|
||||
await stop_noise_floor_sampling()
|
||||
await stop_periodic_advert()
|
||||
await stop_periodic_sync()
|
||||
await stop_telemetry_collect()
|
||||
if radio_manager.meshcore:
|
||||
await radio_manager.meshcore.stop_auto_message_fetching()
|
||||
await radio_manager.disconnect()
|
||||
|
||||
+312
-8
@@ -367,6 +367,52 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
|
||||
await set_version(conn, 47)
|
||||
applied += 1
|
||||
|
||||
# Migration 48: Add discovery_blocked_types column to app_settings
|
||||
if version < 48:
|
||||
logger.info("Applying migration 48: add discovery_blocked_types to app_settings")
|
||||
await _migrate_048_discovery_blocked_types(conn)
|
||||
await set_version(conn, 48)
|
||||
applied += 1
|
||||
|
||||
# Migration 49: Enable foreign key enforcement — rebuild tables with
|
||||
# CASCADE / SET NULL and clean up any orphaned rows first.
|
||||
if version < 49:
|
||||
logger.info("Applying migration 49: add foreign key cascade/set-null and clean orphans")
|
||||
await _migrate_049_foreign_key_cascade(conn)
|
||||
await set_version(conn, 49)
|
||||
applied += 1
|
||||
|
||||
# Migration 50: Repeater telemetry history table + tracking opt-in column
|
||||
if version < 50:
|
||||
logger.info("Applying migration 50: repeater telemetry history")
|
||||
await _migrate_050_repeater_telemetry_history(conn)
|
||||
await set_version(conn, 50)
|
||||
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:
|
||||
logger.info(
|
||||
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
|
||||
@@ -829,7 +875,7 @@ async def _migrate_009_create_app_settings_table(conn: aiosqlite.Connection) ->
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
max_radio_contacts INTEGER DEFAULT 200,
|
||||
favorites TEXT DEFAULT '[]',
|
||||
auto_decrypt_dm_on_advert INTEGER DEFAULT 0,
|
||||
auto_decrypt_dm_on_advert INTEGER DEFAULT 1,
|
||||
sidebar_sort_order TEXT DEFAULT 'recent',
|
||||
last_message_times TEXT DEFAULT '{}',
|
||||
preferences_migrated INTEGER DEFAULT 0
|
||||
@@ -837,13 +883,9 @@ async def _migrate_009_create_app_settings_table(conn: aiosqlite.Connection) ->
|
||||
"""
|
||||
)
|
||||
|
||||
# Initialize with default row
|
||||
await conn.execute(
|
||||
"""
|
||||
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, '[]', 0, 'recent', '{}', 0)
|
||||
"""
|
||||
)
|
||||
# Initialize with default row (use only the id column so this works
|
||||
# regardless of which columns exist — defaults fill the rest).
|
||||
await conn.execute("INSERT OR IGNORE INTO app_settings (id) VALUES (1)")
|
||||
|
||||
await conn.commit()
|
||||
logger.debug("Created app_settings table with default values")
|
||||
@@ -2909,3 +2951,265 @@ async def _migrate_047_add_statistics_indexes(conn: aiosqlite.Connection) -> Non
|
||||
"""
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def _migrate_048_discovery_blocked_types(conn: aiosqlite.Connection) -> None:
|
||||
"""Add discovery_blocked_types column to app_settings.
|
||||
|
||||
Stores a JSON array of integer contact type codes (1=Client, 2=Repeater,
|
||||
3=Room, 4=Sensor) whose advertisements should not create new contacts.
|
||||
Empty list means all types are accepted.
|
||||
"""
|
||||
try:
|
||||
await conn.execute(
|
||||
"ALTER TABLE app_settings ADD COLUMN discovery_blocked_types TEXT DEFAULT '[]'"
|
||||
)
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
if "duplicate column" in error_msg:
|
||||
logger.debug("discovery_blocked_types column already exists, skipping")
|
||||
elif "no such table" in error_msg:
|
||||
logger.debug("app_settings table not ready, skipping discovery_blocked_types migration")
|
||||
else:
|
||||
raise
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def _migrate_049_foreign_key_cascade(conn: aiosqlite.Connection) -> None:
|
||||
"""Rebuild FK tables with CASCADE/SET NULL and clean orphaned rows.
|
||||
|
||||
SQLite cannot ALTER existing FK constraints, so each table is rebuilt.
|
||||
Orphaned child rows are cleaned up before the rebuild to ensure the
|
||||
INSERT...SELECT into the new table (which has enforced FKs) succeeds.
|
||||
"""
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
# Back up the database before table rebuilds (skip for in-memory DBs).
|
||||
cursor = await conn.execute("PRAGMA database_list")
|
||||
db_row = await cursor.fetchone()
|
||||
db_path = db_row[2] if db_row else ""
|
||||
if db_path and db_path != ":memory:" and Path(db_path).exists():
|
||||
backup_path = db_path + ".pre-fk-migration.bak"
|
||||
for suffix in ("", "-wal", "-shm"):
|
||||
src = Path(db_path + suffix)
|
||||
if src.exists():
|
||||
shutil.copy2(str(src), backup_path + suffix)
|
||||
logger.info("Database backed up to %s before FK migration", backup_path)
|
||||
|
||||
# --- Phase 1: clean orphans (guard each table's existence) ---
|
||||
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
existing_tables = {row[0] for row in await tables_cursor.fetchall()}
|
||||
|
||||
if "contact_advert_paths" in existing_tables and "contacts" in existing_tables:
|
||||
await conn.execute(
|
||||
"DELETE FROM contact_advert_paths "
|
||||
"WHERE public_key NOT IN (SELECT public_key FROM contacts)"
|
||||
)
|
||||
if "contact_name_history" in existing_tables and "contacts" in existing_tables:
|
||||
await conn.execute(
|
||||
"DELETE FROM contact_name_history "
|
||||
"WHERE public_key NOT IN (SELECT public_key FROM contacts)"
|
||||
)
|
||||
if "raw_packets" in existing_tables and "messages" in existing_tables:
|
||||
# Guard: message_id column may not exist on very old schemas
|
||||
col_cursor = await conn.execute("PRAGMA table_info(raw_packets)")
|
||||
raw_cols = {row[1] for row in await col_cursor.fetchall()}
|
||||
if "message_id" in raw_cols:
|
||||
await conn.execute(
|
||||
"UPDATE raw_packets SET message_id = NULL WHERE message_id IS NOT NULL "
|
||||
"AND message_id NOT IN (SELECT id FROM messages)"
|
||||
)
|
||||
await conn.commit()
|
||||
logger.debug("Cleaned orphaned child rows before FK rebuild")
|
||||
|
||||
# --- Phase 2: rebuild raw_packets with ON DELETE SET NULL ---
|
||||
# Skip if raw_packets doesn't have message_id (pre-migration-18 schema)
|
||||
raw_has_message_id = False
|
||||
if "raw_packets" in existing_tables:
|
||||
col_cursor2 = await conn.execute("PRAGMA table_info(raw_packets)")
|
||||
raw_has_message_id = "message_id" in {row[1] for row in await col_cursor2.fetchall()}
|
||||
|
||||
if raw_has_message_id:
|
||||
# Dynamically build column list based on what the old table actually has,
|
||||
# since very old schemas may lack payload_hash (added in migration 28).
|
||||
col_cursor3 = await conn.execute("PRAGMA table_info(raw_packets)")
|
||||
old_cols = [row[1] for row in await col_cursor3.fetchall()]
|
||||
|
||||
new_col_defs = [
|
||||
"id INTEGER PRIMARY KEY AUTOINCREMENT",
|
||||
"timestamp INTEGER NOT NULL",
|
||||
"data BLOB NOT NULL",
|
||||
"message_id INTEGER",
|
||||
]
|
||||
copy_cols = ["id", "timestamp", "data", "message_id"]
|
||||
if "payload_hash" in old_cols:
|
||||
new_col_defs.append("payload_hash BLOB")
|
||||
copy_cols.append("payload_hash")
|
||||
new_col_defs.append("FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE SET NULL")
|
||||
|
||||
cols_sql = ", ".join(new_col_defs)
|
||||
copy_sql = ", ".join(copy_cols)
|
||||
await conn.execute(f"CREATE TABLE raw_packets_fk ({cols_sql})")
|
||||
await conn.execute(
|
||||
f"INSERT INTO raw_packets_fk ({copy_sql}) SELECT {copy_sql} FROM raw_packets"
|
||||
)
|
||||
await conn.execute("DROP TABLE raw_packets")
|
||||
await conn.execute("ALTER TABLE raw_packets_fk RENAME TO raw_packets")
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_raw_packets_message_id ON raw_packets(message_id)"
|
||||
)
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_raw_packets_timestamp ON raw_packets(timestamp)"
|
||||
)
|
||||
if "payload_hash" in old_cols:
|
||||
await conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_packets_payload_hash ON raw_packets(payload_hash)"
|
||||
)
|
||||
await conn.commit()
|
||||
logger.debug("Rebuilt raw_packets with ON DELETE SET NULL")
|
||||
|
||||
# --- Phase 3: rebuild contact_advert_paths with ON DELETE CASCADE ---
|
||||
if "contact_advert_paths" in existing_tables:
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE contact_advert_paths_fk (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
public_key TEXT NOT NULL,
|
||||
path_hex TEXT NOT NULL,
|
||||
path_len INTEGER NOT NULL,
|
||||
first_seen INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
heard_count INTEGER NOT NULL DEFAULT 1,
|
||||
UNIQUE(public_key, path_hex, path_len),
|
||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
await conn.execute(
|
||||
"INSERT INTO contact_advert_paths_fk (id, public_key, path_hex, path_len, first_seen, last_seen, heard_count) "
|
||||
"SELECT id, public_key, path_hex, path_len, first_seen, last_seen, heard_count FROM contact_advert_paths"
|
||||
)
|
||||
await conn.execute("DROP TABLE contact_advert_paths")
|
||||
await conn.execute("ALTER TABLE contact_advert_paths_fk RENAME TO contact_advert_paths")
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_contact_advert_paths_recent "
|
||||
"ON contact_advert_paths(public_key, last_seen DESC)"
|
||||
)
|
||||
await conn.commit()
|
||||
logger.debug("Rebuilt contact_advert_paths with ON DELETE CASCADE")
|
||||
|
||||
# --- Phase 4: rebuild contact_name_history with ON DELETE CASCADE ---
|
||||
if "contact_name_history" in existing_tables:
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE contact_name_history_fk (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
public_key TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
first_seen INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
UNIQUE(public_key, name),
|
||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
await conn.execute(
|
||||
"INSERT INTO contact_name_history_fk (id, public_key, name, first_seen, last_seen) "
|
||||
"SELECT id, public_key, name, first_seen, last_seen FROM contact_name_history"
|
||||
)
|
||||
await conn.execute("DROP TABLE contact_name_history")
|
||||
await conn.execute("ALTER TABLE contact_name_history_fk RENAME TO contact_name_history")
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_contact_name_history_key "
|
||||
"ON contact_name_history(public_key, last_seen DESC)"
|
||||
)
|
||||
await conn.commit()
|
||||
logger.debug("Rebuilt contact_name_history with ON DELETE CASCADE")
|
||||
|
||||
|
||||
async def _migrate_050_repeater_telemetry_history(conn: aiosqlite.Connection) -> None:
|
||||
"""Create repeater_telemetry_history table for JSON-blob telemetry snapshots."""
|
||||
await conn.execute(
|
||||
"""
|
||||
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
|
||||
)
|
||||
"""
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_repeater_telemetry_pk_ts
|
||||
ON repeater_telemetry_history (public_key, timestamp)
|
||||
"""
|
||||
)
|
||||
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()
|
||||
|
||||
+46
-65
@@ -196,15 +196,6 @@ class Contact(BaseModel):
|
||||
"""Convert the stored contact to the repository's write contract."""
|
||||
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):
|
||||
"""Request to create a new contact."""
|
||||
@@ -283,30 +274,6 @@ class NearestRepeater(BaseModel):
|
||||
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):
|
||||
"""A single hourly activity bucket for contact analytics."""
|
||||
|
||||
@@ -354,6 +321,10 @@ class Channel(BaseModel):
|
||||
default=None,
|
||||
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
|
||||
|
||||
|
||||
@@ -375,6 +346,18 @@ class ChannelTopSender(BaseModel):
|
||||
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):
|
||||
"""Comprehensive channel profile data."""
|
||||
|
||||
@@ -383,6 +366,7 @@ class ChannelDetail(BaseModel):
|
||||
first_message_at: int | None = None
|
||||
unique_sender_count: int = 0
|
||||
top_senders_24h: list[ChannelTopSender] = Field(default_factory=list)
|
||||
path_hash_width_24h: PathHashWidthStats = Field(default_factory=PathHashWidthStats)
|
||||
|
||||
|
||||
class MessagePath(BaseModel):
|
||||
@@ -394,6 +378,8 @@ class MessagePath(BaseModel):
|
||||
default=None,
|
||||
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):
|
||||
@@ -530,6 +516,9 @@ class RepeaterStatusResponse(BaseModel):
|
||||
flood_dups: int = Field(description="Duplicate flood packets")
|
||||
direct_dups: int = Field(description="Duplicate direct packets")
|
||||
full_events: int = Field(description="Full event queue count")
|
||||
telemetry_history: list["TelemetryHistoryEntry"] = Field(
|
||||
default_factory=list, description="Recent telemetry history snapshots"
|
||||
)
|
||||
|
||||
|
||||
class RepeaterNodeInfoResponse(BaseModel):
|
||||
@@ -805,21 +794,13 @@ class AppSettings(BaseModel):
|
||||
default_factory=list, description="List of favorited conversations"
|
||||
)
|
||||
auto_decrypt_dm_on_advert: bool = Field(
|
||||
default=False,
|
||||
default=True,
|
||||
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(
|
||||
default_factory=dict,
|
||||
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(
|
||||
default=0,
|
||||
description="Periodic advertisement interval in seconds (0 = disabled)",
|
||||
@@ -840,19 +821,24 @@ class AppSettings(BaseModel):
|
||||
default_factory=list,
|
||||
description="Display names whose messages are hidden from the UI",
|
||||
)
|
||||
|
||||
|
||||
class FanoutConfig(BaseModel):
|
||||
"""Configuration for a single fanout integration."""
|
||||
|
||||
id: str
|
||||
type: str # 'mqtt_private' | 'mqtt_community' | 'bot' | 'webhook' | 'apprise' | 'sqs'
|
||||
name: str
|
||||
enabled: bool
|
||||
config: dict
|
||||
scope: dict
|
||||
sort_order: int = 0
|
||||
created_at: int = 0
|
||||
discovery_blocked_types: list[int] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"Contact type codes (1=Client, 2=Repeater, 3=Room, 4=Sensor) whose "
|
||||
"advertisements should not create new contacts; existing contacts are still updated"
|
||||
),
|
||||
)
|
||||
tracked_telemetry_repeaters: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Public keys of repeaters opted into periodic telemetry collection (max 8)",
|
||||
)
|
||||
auto_resend_channel: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"When enabled, outgoing channel messages that receive no echo within 2 seconds "
|
||||
"are automatically byte-perfect resent once (within the 30-second dedup window)"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class BusyChannel(BaseModel):
|
||||
@@ -867,16 +853,6 @@ class ContactActivityCounts(BaseModel):
|
||||
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):
|
||||
timestamp: int = Field(description="Unix timestamp of the sampled reading")
|
||||
noise_floor_dbm: int = Field(description="Noise floor in dBm")
|
||||
@@ -914,3 +890,8 @@ class StatisticsResponse(BaseModel):
|
||||
known_channels_active: ContactActivityCounts
|
||||
path_hash_width_24h: PathHashWidthStats
|
||||
noise_floor_24h: NoiseFloorHistoryStats
|
||||
|
||||
|
||||
class TelemetryHistoryEntry(BaseModel):
|
||||
timestamp: int
|
||||
data: dict
|
||||
|
||||
+46
-10
@@ -68,6 +68,8 @@ async def create_message_from_decrypted(
|
||||
received_at: int | None = None,
|
||||
path: str | None = None,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
channel_name: str | None = None,
|
||||
realtime: bool = True,
|
||||
) -> int | None:
|
||||
@@ -81,6 +83,8 @@ async def create_message_from_decrypted(
|
||||
received_at=received_at,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
channel_name=channel_name,
|
||||
realtime=realtime,
|
||||
broadcast_fn=broadcast_event,
|
||||
@@ -95,6 +99,8 @@ async def create_dm_message_from_decrypted(
|
||||
received_at: int | None = None,
|
||||
path: str | None = None,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
outgoing: bool = False,
|
||||
realtime: bool = True,
|
||||
) -> int | None:
|
||||
@@ -107,6 +113,8 @@ async def create_dm_message_from_decrypted(
|
||||
received_at=received_at,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
outgoing=outgoing,
|
||||
realtime=realtime,
|
||||
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.
|
||||
# This is more reliable than trying to look up the message via raw packet linking.
|
||||
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:
|
||||
result.update(decrypt_result)
|
||||
|
||||
@@ -330,7 +340,9 @@ async def process_raw_packet(
|
||||
|
||||
elif payload_type == PayloadType.TEXT_MESSAGE:
|
||||
# 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:
|
||||
result.update(decrypt_result)
|
||||
|
||||
@@ -367,6 +379,8 @@ async def _process_group_text(
|
||||
packet_id: int,
|
||||
timestamp: int,
|
||||
packet_info: PacketInfo | None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
) -> dict | None:
|
||||
"""
|
||||
Process a GroupText (channel message) packet.
|
||||
@@ -403,6 +417,8 @@ async def _process_group_text(
|
||||
received_at=timestamp,
|
||||
path=packet_info.path.hex() if packet_info else None,
|
||||
path_len=packet_info.path_length if packet_info else None,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -462,14 +478,19 @@ async def _process_advertisement(
|
||||
advert.device_role if advert.device_role > 0 else (existing.type if existing else 0)
|
||||
)
|
||||
|
||||
# Keep recent unique advert paths for all contacts.
|
||||
await ContactAdvertPathRepository.record_observation(
|
||||
public_key=advert.public_key.lower(),
|
||||
path_hex=new_path_hex,
|
||||
timestamp=timestamp,
|
||||
max_paths=10,
|
||||
hop_count=new_path_len,
|
||||
)
|
||||
# Check discovery_blocked_types: skip new contacts whose type is blocked.
|
||||
# Existing contacts are always updated (location, name, last_seen, etc.).
|
||||
if existing is None and contact_type > 0:
|
||||
from app.repository import AppSettingsRepository
|
||||
|
||||
settings = await AppSettingsRepository.get()
|
||||
if contact_type in settings.discovery_blocked_types:
|
||||
logger.debug(
|
||||
"Skipping new contact %s: type %d is in discovery_blocked_types",
|
||||
advert.public_key[:12],
|
||||
contact_type,
|
||||
)
|
||||
return
|
||||
|
||||
contact_upsert = ContactUpsert(
|
||||
public_key=advert.public_key.lower(),
|
||||
@@ -482,7 +503,18 @@ async def _process_advertisement(
|
||||
first_seen=timestamp, # COALESCE in upsert preserves existing value
|
||||
)
|
||||
|
||||
# Upsert the contact BEFORE recording advert paths so the parent row
|
||||
# exists when foreign key enforcement is enabled.
|
||||
await ContactRepository.upsert(contact_upsert)
|
||||
|
||||
# Keep recent unique advert paths for all contacts.
|
||||
await ContactAdvertPathRepository.record_observation(
|
||||
public_key=advert.public_key.lower(),
|
||||
path_hex=new_path_hex,
|
||||
timestamp=timestamp,
|
||||
max_paths=10,
|
||||
hop_count=new_path_len,
|
||||
)
|
||||
promoted_keys = await promote_prefix_contacts_for_contact(
|
||||
public_key=advert.public_key,
|
||||
log=logger,
|
||||
@@ -528,6 +560,8 @@ async def _process_direct_message(
|
||||
packet_id: int,
|
||||
timestamp: int,
|
||||
packet_info: PacketInfo | None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
) -> dict | None:
|
||||
"""
|
||||
Process a TEXT_MESSAGE (direct message) packet.
|
||||
@@ -628,6 +662,8 @@ async def _process_direct_message(
|
||||
received_at=timestamp,
|
||||
path=packet_info.path.hex() if packet_info else None,
|
||||
path_len=packet_info.path_length if packet_info else None,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
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")
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
+200
-68
@@ -28,8 +28,12 @@ from app.repository import (
|
||||
AppSettingsRepository,
|
||||
ChannelRepository,
|
||||
ContactRepository,
|
||||
RepeaterTelemetryRepository,
|
||||
)
|
||||
from app.services.contact_reconciliation import (
|
||||
promote_prefix_contacts_for_contact,
|
||||
reconcile_contact_messages,
|
||||
)
|
||||
from app.services.contact_reconciliation import reconcile_contact_messages
|
||||
from app.services.messages import create_fallback_channel_message
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
from app.websocket import broadcast_error, broadcast_event
|
||||
@@ -63,13 +67,25 @@ async def _reconcile_contact_messages_background(
|
||||
public_key: str,
|
||||
contact_name: str | None,
|
||||
) -> None:
|
||||
"""Run contact/message reconciliation outside the radio critical path."""
|
||||
"""Run prefix promotion and contact/message reconciliation outside the radio critical path."""
|
||||
try:
|
||||
promoted_keys = await promote_prefix_contacts_for_contact(
|
||||
public_key=public_key,
|
||||
log=logger,
|
||||
)
|
||||
await reconcile_contact_messages(
|
||||
public_key=public_key,
|
||||
contact_name=contact_name,
|
||||
log=logger,
|
||||
)
|
||||
if promoted_keys:
|
||||
contact = await ContactRepository.get_by_key(public_key.lower())
|
||||
if contact is not None:
|
||||
for old_key in promoted_keys:
|
||||
broadcast_event(
|
||||
"contact_resolved",
|
||||
{"previous_public_key": old_key, "contact": contact.model_dump()},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Background contact reconciliation failed for %s: %s",
|
||||
@@ -140,6 +156,15 @@ ADVERT_CHECK_INTERVAL = 60
|
||||
# more frequently than this.
|
||||
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)
|
||||
_polling_pause_count: int = 0
|
||||
|
||||
@@ -179,6 +204,22 @@ RADIO_CONTACT_REFILL_RATIO = 0.80
|
||||
RADIO_CONTACT_FULL_SYNC_RATIO = 0.95
|
||||
|
||||
|
||||
def _effective_radio_capacity(configured: int) -> int:
|
||||
"""Return the effective radio contact capacity.
|
||||
|
||||
Uses the lower of the user-configured ``max_radio_contacts`` and the
|
||||
hardware limit reported by the radio at connect time. The existing
|
||||
80% refill ratio already reserves headroom for the radio to
|
||||
organically add contacts it hears via adverts, so no additional
|
||||
reduction is applied here.
|
||||
"""
|
||||
capacity = max(1, configured)
|
||||
hw_limit = radio_manager.max_contacts
|
||||
if hw_limit is not None:
|
||||
capacity = min(capacity, hw_limit)
|
||||
return max(1, capacity)
|
||||
|
||||
|
||||
def _compute_radio_contact_limits(max_contacts: int) -> tuple[int, int]:
|
||||
"""Return (refill_target, full_sync_trigger) for the configured capacity."""
|
||||
capacity = max(1, max_contacts)
|
||||
@@ -193,7 +234,7 @@ def _compute_radio_contact_limits(max_contacts: int) -> tuple[int, int]:
|
||||
async def should_run_full_periodic_sync(mc: MeshCore) -> bool:
|
||||
"""Check current radio occupancy and decide whether to offload/reload."""
|
||||
app_settings = await AppSettingsRepository.get()
|
||||
capacity = app_settings.max_radio_contacts
|
||||
capacity = _effective_radio_capacity(app_settings.max_radio_contacts)
|
||||
refill_target, full_sync_trigger = _compute_radio_contact_limits(capacity)
|
||||
|
||||
result = await mc.commands.get_contacts()
|
||||
@@ -222,70 +263,6 @@ async def should_run_full_periodic_sync(mc: MeshCore) -> bool:
|
||||
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:
|
||||
"""
|
||||
Sync channels from radio to database, then clear them from radio.
|
||||
@@ -1301,7 +1278,7 @@ async def stop_background_contact_reconciliation() -> None:
|
||||
async def get_contacts_selected_for_radio_sync() -> list[Contact]:
|
||||
"""Return the contacts that would be loaded onto the radio right now."""
|
||||
app_settings = await AppSettingsRepository.get()
|
||||
max_contacts = app_settings.max_radio_contacts
|
||||
max_contacts = _effective_radio_capacity(app_settings.max_radio_contacts)
|
||||
refill_target, _full_sync_trigger = _compute_radio_contact_limits(max_contacts)
|
||||
selected_contacts: list[Contact] = []
|
||||
selected_keys: set[str] = set()
|
||||
@@ -1557,3 +1534,158 @@ async def sync_recent_contacts_to_radio(force: bool = False, mc: MeshCore | None
|
||||
except Exception as e:
|
||||
logger.error("Error syncing contacts to radio: %s", e, exc_info=True)
|
||||
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")
|
||||
|
||||
@@ -8,6 +8,7 @@ from app.repository.contacts import (
|
||||
from app.repository.fanout import FanoutConfigRepository
|
||||
from app.repository.messages import MessageRepository
|
||||
from app.repository.raw_packets import RawPacketRepository
|
||||
from app.repository.repeater_telemetry import RepeaterTelemetryRepository
|
||||
from app.repository.settings import AppSettingsRepository, StatisticsRepository
|
||||
|
||||
__all__ = [
|
||||
@@ -20,5 +21,6 @@ __all__ = [
|
||||
"FanoutConfigRepository",
|
||||
"MessageRepository",
|
||||
"RawPacketRepository",
|
||||
"RepeaterTelemetryRepository",
|
||||
"StatisticsRepository",
|
||||
]
|
||||
|
||||
+14
-26
@@ -26,7 +26,7 @@ class ChannelRepository:
|
||||
"""Get a channel by its key (32-char hex string)."""
|
||||
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
|
||||
WHERE key = ?
|
||||
""",
|
||||
@@ -40,6 +40,7 @@ class ChannelRepository:
|
||||
is_hashtag=bool(row["is_hashtag"]),
|
||||
on_radio=bool(row["on_radio"]),
|
||||
flood_scope_override=row["flood_scope_override"],
|
||||
path_hash_mode_override=row["path_hash_mode_override"],
|
||||
last_read_at=row["last_read_at"],
|
||||
)
|
||||
return None
|
||||
@@ -48,7 +49,7 @@ class ChannelRepository:
|
||||
async def get_all() -> list[Channel]:
|
||||
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
|
||||
ORDER BY name
|
||||
"""
|
||||
@@ -61,30 +62,7 @@ class ChannelRepository:
|
||||
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"],
|
||||
)
|
||||
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"],
|
||||
path_hash_mode_override=row["path_hash_mode_override"],
|
||||
last_read_at=row["last_read_at"],
|
||||
)
|
||||
for row in rows
|
||||
@@ -123,6 +101,16 @@ class ChannelRepository:
|
||||
await db.conn.commit()
|
||||
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
|
||||
async def mark_all_read(timestamp: int) -> None:
|
||||
"""Mark all channels as read at the given timestamp."""
|
||||
|
||||
+66
-57
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
@@ -12,6 +13,8 @@ from app.models import (
|
||||
)
|
||||
from app.path_utils import first_hop_hex, normalize_contact_route, normalize_route_override
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AmbiguousPublicKeyPrefixError(ValueError):
|
||||
"""Raised when a public key prefix matches multiple contacts."""
|
||||
@@ -392,12 +395,9 @@ class ContactRepository:
|
||||
@staticmethod
|
||||
async def delete(public_key: str) -> None:
|
||||
normalized = public_key.lower()
|
||||
await db.conn.execute(
|
||||
"DELETE FROM contact_name_history WHERE public_key = ?", (normalized,)
|
||||
)
|
||||
await db.conn.execute(
|
||||
"DELETE FROM contact_advert_paths WHERE public_key = ?", (normalized,)
|
||||
)
|
||||
# contact_name_history and contact_advert_paths cascade via FK.
|
||||
# Messages are intentionally preserved so history re-surfaces
|
||||
# if the contact is re-added later.
|
||||
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (normalized,))
|
||||
await db.conn.commit()
|
||||
|
||||
@@ -484,7 +484,6 @@ class ContactRepository:
|
||||
return []
|
||||
|
||||
promoted_keys: list[str] = []
|
||||
full_exists = await ContactRepository.get_by_key(normalized_full_key) is not None
|
||||
|
||||
for row in rows:
|
||||
old_key = row["public_key"]
|
||||
@@ -501,60 +500,70 @@ class ContactRepository:
|
||||
(old_key,),
|
||||
)
|
||||
match_row = await match_cursor.fetchone()
|
||||
if (match_row["match_count"] if match_row is not None else 0) != 1:
|
||||
match_count = match_row["match_count"] if match_row is not None else 0
|
||||
if match_count != 1:
|
||||
logger.warning(
|
||||
"Skipping prefix promotion for %s: %d full-key contacts match (expected 1)",
|
||||
old_key,
|
||||
match_count,
|
||||
)
|
||||
continue
|
||||
|
||||
await migrate_child_rows(old_key, normalized_full_key)
|
||||
|
||||
if full_exists:
|
||||
await db.conn.execute(
|
||||
"""
|
||||
UPDATE contacts
|
||||
SET last_seen = CASE
|
||||
WHEN contacts.last_seen IS NULL THEN ?
|
||||
WHEN ? IS NULL THEN contacts.last_seen
|
||||
WHEN ? > contacts.last_seen THEN ?
|
||||
ELSE contacts.last_seen
|
||||
END,
|
||||
last_contacted = CASE
|
||||
WHEN contacts.last_contacted IS NULL THEN ?
|
||||
WHEN ? IS NULL THEN contacts.last_contacted
|
||||
WHEN ? > contacts.last_contacted THEN ?
|
||||
ELSE contacts.last_contacted
|
||||
END,
|
||||
first_seen = CASE
|
||||
WHEN contacts.first_seen IS NULL THEN ?
|
||||
WHEN ? IS NULL THEN contacts.first_seen
|
||||
WHEN ? < contacts.first_seen THEN ?
|
||||
ELSE contacts.first_seen
|
||||
END,
|
||||
last_read_at = COALESCE(contacts.last_read_at, ?)
|
||||
WHERE public_key = ?
|
||||
""",
|
||||
(
|
||||
row["last_seen"],
|
||||
row["last_seen"],
|
||||
row["last_seen"],
|
||||
row["last_seen"],
|
||||
row["last_contacted"],
|
||||
row["last_contacted"],
|
||||
row["last_contacted"],
|
||||
row["last_contacted"],
|
||||
row["first_seen"],
|
||||
row["first_seen"],
|
||||
row["first_seen"],
|
||||
row["first_seen"],
|
||||
row["last_read_at"],
|
||||
normalized_full_key,
|
||||
),
|
||||
)
|
||||
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (old_key,))
|
||||
else:
|
||||
await db.conn.execute(
|
||||
"UPDATE contacts SET public_key = ? WHERE public_key = ?",
|
||||
(normalized_full_key, old_key),
|
||||
)
|
||||
full_exists = True
|
||||
# Merge timestamp metadata from the old prefix contact into the
|
||||
# full-key contact (which all callers guarantee already exists),
|
||||
# then delete the prefix placeholder.
|
||||
await db.conn.execute(
|
||||
"""
|
||||
UPDATE contacts
|
||||
SET last_seen = CASE
|
||||
WHEN contacts.last_seen IS NULL THEN ?
|
||||
WHEN ? IS NULL THEN contacts.last_seen
|
||||
WHEN ? > contacts.last_seen THEN ?
|
||||
ELSE contacts.last_seen
|
||||
END,
|
||||
last_contacted = CASE
|
||||
WHEN contacts.last_contacted IS NULL THEN ?
|
||||
WHEN ? IS NULL THEN contacts.last_contacted
|
||||
WHEN ? > contacts.last_contacted THEN ?
|
||||
ELSE contacts.last_contacted
|
||||
END,
|
||||
first_seen = CASE
|
||||
WHEN contacts.first_seen IS NULL THEN ?
|
||||
WHEN ? IS NULL THEN contacts.first_seen
|
||||
WHEN ? < contacts.first_seen THEN ?
|
||||
ELSE contacts.first_seen
|
||||
END,
|
||||
last_read_at = CASE
|
||||
WHEN contacts.last_read_at IS NULL THEN ?
|
||||
WHEN ? IS NULL THEN contacts.last_read_at
|
||||
WHEN ? > contacts.last_read_at THEN ?
|
||||
ELSE contacts.last_read_at
|
||||
END
|
||||
WHERE public_key = ?
|
||||
""",
|
||||
(
|
||||
row["last_seen"],
|
||||
row["last_seen"],
|
||||
row["last_seen"],
|
||||
row["last_seen"],
|
||||
row["last_contacted"],
|
||||
row["last_contacted"],
|
||||
row["last_contacted"],
|
||||
row["last_contacted"],
|
||||
row["first_seen"],
|
||||
row["first_seen"],
|
||||
row["first_seen"],
|
||||
row["first_seen"],
|
||||
row["last_read_at"],
|
||||
row["last_read_at"],
|
||||
row["last_read_at"],
|
||||
row["last_read_at"],
|
||||
normalized_full_key,
|
||||
),
|
||||
)
|
||||
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (old_key,))
|
||||
|
||||
promoted_keys.append(old_key)
|
||||
|
||||
|
||||
+54
-18
@@ -29,8 +29,7 @@ class MessageRepository:
|
||||
def _contact_activity_filter(public_key: str) -> tuple[str, list[Any]]:
|
||||
lower_key = public_key.lower()
|
||||
return (
|
||||
"((type = 'PRIV' AND LOWER(conversation_key) = ?)"
|
||||
" OR (type = 'CHAN' AND LOWER(sender_key) = ?))",
|
||||
"((type = 'PRIV' AND conversation_key = ?) OR (type = 'CHAN' AND sender_key = ?))",
|
||||
[lower_key, lower_key],
|
||||
)
|
||||
|
||||
@@ -58,6 +57,8 @@ class MessageRepository:
|
||||
sender_timestamp: int | None = None,
|
||||
path: str | None = None,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
txt_type: int = 0,
|
||||
signature: str | None = None,
|
||||
outgoing: bool = False,
|
||||
@@ -79,8 +80,15 @@ class MessageRepository:
|
||||
entry: dict = {"path": path, "received_at": received_at}
|
||||
if path_len is not None:
|
||||
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])
|
||||
|
||||
# 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(
|
||||
"""
|
||||
INSERT OR IGNORE INTO messages (type, conversation_key, text, sender_timestamp,
|
||||
@@ -99,7 +107,7 @@ class MessageRepository:
|
||||
signature,
|
||||
outgoing,
|
||||
sender_name,
|
||||
sender_key,
|
||||
normalized_sender_key,
|
||||
),
|
||||
)
|
||||
await db.conn.commit()
|
||||
@@ -114,6 +122,8 @@ class MessageRepository:
|
||||
path: str,
|
||||
received_at: int | None = None,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
) -> list[MessagePath]:
|
||||
"""Add a new path to an existing message.
|
||||
|
||||
@@ -127,6 +137,10 @@ class MessageRepository:
|
||||
entry: dict = {"path": path, "received_at": ts}
|
||||
if path_len is not None:
|
||||
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)
|
||||
await db.conn.execute(
|
||||
"""UPDATE messages SET paths = json_insert(
|
||||
@@ -158,7 +172,11 @@ class MessageRepository:
|
||||
"""
|
||||
lower_key = full_key.lower()
|
||||
cursor = await db.conn.execute(
|
||||
"""UPDATE messages SET conversation_key = ?
|
||||
"""UPDATE messages SET conversation_key = ?,
|
||||
sender_key = CASE
|
||||
WHEN sender_key IS NOT NULL AND length(sender_key) < 64
|
||||
AND ? LIKE sender_key || '%'
|
||||
THEN ? ELSE sender_key END
|
||||
WHERE type = 'PRIV' AND length(conversation_key) < 64
|
||||
AND ? LIKE conversation_key || '%'
|
||||
AND (
|
||||
@@ -166,7 +184,7 @@ class MessageRepository:
|
||||
WHERE length(public_key) = 64
|
||||
AND public_key LIKE messages.conversation_key || '%'
|
||||
) = 1""",
|
||||
(lower_key, lower_key),
|
||||
(lower_key, lower_key, lower_key, lower_key),
|
||||
)
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount
|
||||
@@ -255,10 +273,10 @@ class MessageRepository:
|
||||
|
||||
if MessageRepository._looks_like_hex_prefix(value):
|
||||
if len(value) == 32:
|
||||
clause += " OR UPPER(messages.conversation_key) = ?"
|
||||
clause += " OR messages.conversation_key = ?"
|
||||
params.append(value.upper())
|
||||
else:
|
||||
clause += " OR UPPER(messages.conversation_key) LIKE ? ESCAPE '\\'"
|
||||
clause += " OR messages.conversation_key LIKE ? ESCAPE '\\'"
|
||||
params.append(f"{MessageRepository._escape_like(value.upper())}%")
|
||||
|
||||
clause += "))"
|
||||
@@ -277,13 +295,13 @@ class MessageRepository:
|
||||
priv_key_clause: str
|
||||
chan_key_clause: str
|
||||
if len(value) == 64:
|
||||
priv_key_clause = "LOWER(messages.conversation_key) = ?"
|
||||
chan_key_clause = "LOWER(sender_key) = ?"
|
||||
priv_key_clause = "messages.conversation_key = ?"
|
||||
chan_key_clause = "sender_key = ?"
|
||||
params.extend([lower_value, lower_value])
|
||||
else:
|
||||
escaped_prefix = f"{MessageRepository._escape_like(lower_value)}%"
|
||||
priv_key_clause = "LOWER(messages.conversation_key) LIKE ? ESCAPE '\\'"
|
||||
chan_key_clause = "LOWER(sender_key) LIKE ? ESCAPE '\\'"
|
||||
priv_key_clause = "messages.conversation_key LIKE ? ESCAPE '\\'"
|
||||
chan_key_clause = "sender_key LIKE ? ESCAPE '\\'"
|
||||
params.extend([escaped_prefix, escaped_prefix])
|
||||
|
||||
clause += (
|
||||
@@ -307,12 +325,12 @@ class MessageRepository:
|
||||
if blocked_keys:
|
||||
placeholders = ",".join("?" for _ in blocked_keys)
|
||||
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)
|
||||
blocked_matchers.append(
|
||||
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)
|
||||
|
||||
@@ -379,9 +397,9 @@ class MessageRepository:
|
||||
query = (
|
||||
f"SELECT {MessageRepository._message_select('messages')} FROM messages "
|
||||
"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' "
|
||||
"AND UPPER(messages.conversation_key) = UPPER(channels.key) "
|
||||
"AND messages.conversation_key = channels.key "
|
||||
"WHERE 1=1"
|
||||
)
|
||||
params: list[Any] = []
|
||||
@@ -572,6 +590,9 @@ class MessageRepository:
|
||||
@staticmethod
|
||||
async def delete_by_id(message_id: int) -> None:
|
||||
"""Delete a message row by ID."""
|
||||
await db.conn.execute(
|
||||
"UPDATE raw_packets SET message_id = NULL WHERE message_id = ?", (message_id,)
|
||||
)
|
||||
await db.conn.execute("DELETE FROM messages WHERE id = ?", (message_id,))
|
||||
await db.conn.commit()
|
||||
|
||||
@@ -666,7 +687,7 @@ class MessageRepository:
|
||||
ELSE 0
|
||||
END) > 0 as has_mention
|
||||
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
|
||||
AND m.received_at > COALESCE(ct.last_read_at, 0)
|
||||
{blocked_sql}
|
||||
@@ -777,12 +798,14 @@ class MessageRepository:
|
||||
|
||||
@staticmethod
|
||||
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
|
||||
|
||||
from app.path_utils import bucket_path_hash_widths
|
||||
|
||||
now = int(_time.time())
|
||||
t_1h = now - 3600
|
||||
t_24h = now - 86400
|
||||
@@ -834,11 +857,24 @@ class MessageRepository:
|
||||
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 {
|
||||
"message_counts": message_counts,
|
||||
"first_message_at": row["first_message_at"],
|
||||
"unique_sender_count": row["unique_sender_count"] or 0,
|
||||
"top_senders_24h": top_senders,
|
||||
"path_hash_width_24h": path_hash_width_24h,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import logging
|
||||
import sqlite3
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
from hashlib import sha256
|
||||
@@ -35,46 +34,23 @@ class RawPacketRepository:
|
||||
# For malformed packets, hash the full data
|
||||
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(
|
||||
"SELECT id FROM raw_packets WHERE payload_hash = ?", (payload_hash,)
|
||||
)
|
||||
existing = await cursor.fetchone()
|
||||
|
||||
if existing:
|
||||
# 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
|
||||
assert existing is not None
|
||||
return (existing["id"], False)
|
||||
|
||||
@staticmethod
|
||||
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
|
||||
|
||||
@staticmethod
|
||||
async def get_all_undecrypted() -> list[tuple[int, bytes, int]]:
|
||||
"""Get all undecrypted packets as (id, data, timestamp) tuples."""
|
||||
async def stream_all_undecrypted(
|
||||
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(
|
||||
"SELECT id, data, timestamp FROM raw_packets WHERE message_id IS NULL ORDER BY timestamp ASC"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [(row["id"], bytes(row["data"]), row["timestamp"]) for row in rows]
|
||||
try:
|
||||
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
|
||||
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")
|
||||
await db.conn.commit()
|
||||
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()]
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
from app.database import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Maximum age for telemetry history entries (30 days)
|
||||
_MAX_AGE_SECONDS = 30 * 86400
|
||||
|
||||
# Maximum entries to keep per repeater (sanity cap)
|
||||
_MAX_ENTRIES_PER_REPEATER = 1000
|
||||
|
||||
|
||||
class RepeaterTelemetryRepository:
|
||||
@staticmethod
|
||||
async def record(
|
||||
public_key: str,
|
||||
timestamp: int,
|
||||
data: dict,
|
||||
) -> None:
|
||||
"""Insert a telemetry history row and prune stale entries."""
|
||||
await db.conn.execute(
|
||||
"""
|
||||
INSERT INTO repeater_telemetry_history
|
||||
(public_key, timestamp, data)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(public_key, timestamp, json.dumps(data)),
|
||||
)
|
||||
|
||||
# Prune entries older than 30 days
|
||||
cutoff = int(time.time()) - _MAX_AGE_SECONDS
|
||||
await db.conn.execute(
|
||||
"DELETE FROM repeater_telemetry_history WHERE public_key = ? AND timestamp < ?",
|
||||
(public_key, cutoff),
|
||||
)
|
||||
|
||||
# Cap at _MAX_ENTRIES_PER_REPEATER (keep newest)
|
||||
await db.conn.execute(
|
||||
"""
|
||||
DELETE FROM repeater_telemetry_history
|
||||
WHERE public_key = ? AND id NOT IN (
|
||||
SELECT id FROM repeater_telemetry_history
|
||||
WHERE public_key = ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
)
|
||||
""",
|
||||
(public_key, public_key, _MAX_ENTRIES_PER_REPEATER),
|
||||
)
|
||||
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def get_history(public_key: str, since_timestamp: int) -> list[dict]:
|
||||
"""Return telemetry rows for a repeater since a given timestamp, ordered ASC."""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT timestamp, data
|
||||
FROM repeater_telemetry_history
|
||||
WHERE public_key = ? AND timestamp >= ?
|
||||
ORDER BY timestamp ASC
|
||||
""",
|
||||
(public_key, since_timestamp),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [
|
||||
{
|
||||
"timestamp": row["timestamp"],
|
||||
"data": json.loads(row["data"]),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
+45
-94
@@ -5,7 +5,7 @@ from typing import Any, Literal
|
||||
|
||||
from app.database import db
|
||||
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__)
|
||||
|
||||
@@ -27,9 +27,10 @@ class AppSettingsRepository:
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
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,
|
||||
blocked_keys, blocked_names
|
||||
blocked_keys, blocked_names, discovery_blocked_types,
|
||||
tracked_telemetry_repeaters, auto_resend_channel
|
||||
FROM app_settings WHERE id = 1
|
||||
"""
|
||||
)
|
||||
@@ -81,23 +82,42 @@ class AppSettingsRepository:
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
blocked_names = []
|
||||
|
||||
# Validate sidebar_sort_order (fallback to "recent" if invalid)
|
||||
sort_order = row["sidebar_sort_order"]
|
||||
if sort_order not in ("recent", "alpha"):
|
||||
sort_order = "recent"
|
||||
# Parse discovery_blocked_types JSON
|
||||
discovery_blocked_types: list[int] = []
|
||||
if row["discovery_blocked_types"]:
|
||||
try:
|
||||
discovery_blocked_types = json.loads(row["discovery_blocked_types"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
discovery_blocked_types = []
|
||||
|
||||
# Parse tracked_telemetry_repeaters JSON
|
||||
tracked_telemetry_repeaters: list[str] = []
|
||||
try:
|
||||
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(
|
||||
max_radio_contacts=row["max_radio_contacts"],
|
||||
favorites=favorites,
|
||||
auto_decrypt_dm_on_advert=bool(row["auto_decrypt_dm_on_advert"]),
|
||||
sidebar_sort_order=sort_order,
|
||||
last_message_times=last_message_times,
|
||||
preferences_migrated=bool(row["preferences_migrated"]),
|
||||
advert_interval=row["advert_interval"] or 0,
|
||||
last_advert_time=row["last_advert_time"] or 0,
|
||||
flood_scope=row["flood_scope"] or "",
|
||||
blocked_keys=blocked_keys,
|
||||
blocked_names=blocked_names,
|
||||
discovery_blocked_types=discovery_blocked_types,
|
||||
tracked_telemetry_repeaters=tracked_telemetry_repeaters,
|
||||
auto_resend_channel=auto_resend_channel,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -105,14 +125,15 @@ class AppSettingsRepository:
|
||||
max_radio_contacts: int | None = None,
|
||||
favorites: list[Favorite] | None = None,
|
||||
auto_decrypt_dm_on_advert: bool | None = None,
|
||||
sidebar_sort_order: str | None = None,
|
||||
last_message_times: dict[str, int] | None = None,
|
||||
preferences_migrated: bool | None = None,
|
||||
advert_interval: int | None = None,
|
||||
last_advert_time: int | None = None,
|
||||
flood_scope: str | None = None,
|
||||
blocked_keys: list[str] | None = None,
|
||||
blocked_names: list[str] | None = None,
|
||||
discovery_blocked_types: list[int] | None = None,
|
||||
tracked_telemetry_repeaters: list[str] | None = None,
|
||||
auto_resend_channel: bool | None = None,
|
||||
) -> AppSettings:
|
||||
"""Update app settings. Only provided fields are updated."""
|
||||
updates = []
|
||||
@@ -131,18 +152,10 @@ class AppSettingsRepository:
|
||||
updates.append("auto_decrypt_dm_on_advert = ?")
|
||||
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:
|
||||
updates.append("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:
|
||||
updates.append("advert_interval = ?")
|
||||
params.append(advert_interval)
|
||||
@@ -163,6 +176,18 @@ class AppSettingsRepository:
|
||||
updates.append("blocked_names = ?")
|
||||
params.append(json.dumps(blocked_names))
|
||||
|
||||
if discovery_blocked_types is not None:
|
||||
updates.append("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:
|
||||
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
|
||||
await db.conn.execute(query, params)
|
||||
@@ -212,39 +237,6 @@ class AppSettingsRepository:
|
||||
new_names = settings.blocked_names + [name]
|
||||
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:
|
||||
@staticmethod
|
||||
@@ -332,48 +324,7 @@ class StatisticsRepository:
|
||||
"SELECT data FROM raw_packets WHERE timestamp >= ?",
|
||||
(now - SECONDS_24H,),
|
||||
)
|
||||
|
||||
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,
|
||||
}
|
||||
return await bucket_path_hash_widths(cursor, batch_size=RAW_PACKET_STATS_BATCH_SIZE)
|
||||
|
||||
@staticmethod
|
||||
async def get_all() -> dict:
|
||||
|
||||
+260
-47
@@ -1,7 +1,8 @@
|
||||
import logging
|
||||
import re
|
||||
from hashlib import sha256
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Response, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.channel_constants import (
|
||||
@@ -10,10 +11,12 @@ from app.channel_constants import (
|
||||
is_public_channel_key,
|
||||
is_public_channel_name,
|
||||
)
|
||||
from app.decoder import parse_packet, try_decrypt_packet_with_channel_key
|
||||
from app.models import Channel, ChannelDetail, ChannelMessageCounts, ChannelTopSender
|
||||
from app.packet_processor import create_message_from_decrypted
|
||||
from app.region_scope import normalize_region_scope
|
||||
from app.repository import ChannelRepository, MessageRepository
|
||||
from app.websocket import broadcast_event
|
||||
from app.repository import ChannelRepository, MessageRepository, RawPacketRepository
|
||||
from app.websocket import broadcast_event, broadcast_success
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/channels", tags=["channels"])
|
||||
@@ -31,12 +34,166 @@ class CreateChannelRequest(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class BulkCreateHashtagChannelsRequest(BaseModel):
|
||||
channel_names: list[str] = Field(
|
||||
min_length=1,
|
||||
description="List of hashtag room names. Leading # is optional per entry.",
|
||||
)
|
||||
try_historical: bool = Field(
|
||||
default=False,
|
||||
description="Attempt one background historical decrypt sweep for the newly added rooms.",
|
||||
)
|
||||
|
||||
|
||||
class BulkCreateHashtagChannelsResponse(BaseModel):
|
||||
created_channels: list[Channel]
|
||||
existing_count: int
|
||||
invalid_names: list[str]
|
||||
decrypt_started: bool = False
|
||||
decrypt_total_packets: int = 0
|
||||
message: str
|
||||
|
||||
|
||||
class ChannelFloodScopeOverrideRequest(BaseModel):
|
||||
flood_scope_override: str = Field(
|
||||
description="Blank clears the override; non-empty values temporarily override flood scope"
|
||||
)
|
||||
|
||||
|
||||
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(
|
||||
requested_name: str,
|
||||
request_key: str | None = None,
|
||||
) -> tuple[str, str, bool]:
|
||||
is_hashtag = requested_name.startswith("#")
|
||||
|
||||
if is_public_channel_name(requested_name):
|
||||
if request_key:
|
||||
try:
|
||||
key_bytes = bytes.fromhex(request_key)
|
||||
if len(key_bytes) != 16:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Channel key must be exactly 16 bytes (32 hex chars)",
|
||||
)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
|
||||
if key_bytes.hex().upper() != PUBLIC_CHANNEL_KEY:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f'"{PUBLIC_CHANNEL_NAME}" must use the canonical Public key',
|
||||
)
|
||||
return PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME, False
|
||||
|
||||
if request_key and not is_hashtag:
|
||||
try:
|
||||
key_bytes = bytes.fromhex(request_key)
|
||||
if len(key_bytes) != 16:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Channel key must be exactly 16 bytes (32 hex chars)"
|
||||
)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
|
||||
key_hex = key_bytes.hex().upper()
|
||||
if is_public_channel_key(key_hex):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f'The canonical Public key may only be used for "{PUBLIC_CHANNEL_NAME}"',
|
||||
)
|
||||
return key_hex, requested_name, False
|
||||
|
||||
key_bytes = sha256(requested_name.encode("utf-8")).digest()[:16]
|
||||
return key_bytes.hex().upper(), requested_name, is_hashtag
|
||||
|
||||
|
||||
def _normalize_bulk_hashtag_name(name: str) -> str | None:
|
||||
trimmed = name.strip()
|
||||
if not trimmed:
|
||||
return None
|
||||
normalized = trimmed.lstrip("#").strip()
|
||||
if not normalized:
|
||||
return None
|
||||
if len(normalized) > 31:
|
||||
return None
|
||||
if not re.fullmatch(r"[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*", normalized):
|
||||
return None
|
||||
return f"#{normalized}"
|
||||
|
||||
|
||||
async def _run_historical_channel_decryption_for_channels(
|
||||
channels: list[tuple[bytes, str, str]],
|
||||
) -> None:
|
||||
total = await RawPacketRepository.get_undecrypted_count()
|
||||
decrypted_count = 0
|
||||
matched_channel_names: set[str] = set()
|
||||
|
||||
if total == 0:
|
||||
logger.info("No undecrypted packets to process for bulk channel decrypt")
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Starting bulk historical channel decryption of %d packets across %d channels",
|
||||
total,
|
||||
len(channels),
|
||||
)
|
||||
|
||||
async for (
|
||||
packet_id,
|
||||
packet_data,
|
||||
packet_timestamp,
|
||||
) in RawPacketRepository.stream_all_undecrypted():
|
||||
packet_info = parse_packet(packet_data)
|
||||
path_hex = packet_info.path.hex() if packet_info else None
|
||||
path_len = packet_info.path_length if packet_info else None
|
||||
|
||||
for channel_key_bytes, channel_key_hex, channel_name in channels:
|
||||
result = try_decrypt_packet_with_channel_key(packet_data, channel_key_bytes)
|
||||
if result is None:
|
||||
continue
|
||||
|
||||
msg_id = await create_message_from_decrypted(
|
||||
packet_id=packet_id,
|
||||
channel_key=channel_key_hex,
|
||||
channel_name=channel_name,
|
||||
sender=result.sender,
|
||||
message_text=result.message,
|
||||
timestamp=result.timestamp,
|
||||
received_at=packet_timestamp,
|
||||
path=path_hex,
|
||||
path_len=path_len,
|
||||
realtime=False,
|
||||
)
|
||||
if msg_id is not None:
|
||||
decrypted_count += 1
|
||||
matched_channel_names.add(channel_name)
|
||||
break
|
||||
|
||||
logger.info(
|
||||
"Bulk historical channel decryption complete: %d/%d packets decrypted across %d channels",
|
||||
decrypted_count,
|
||||
total,
|
||||
len(matched_channel_names),
|
||||
)
|
||||
|
||||
if decrypted_count > 0:
|
||||
broadcast_success(
|
||||
"Bulk historical decrypt complete",
|
||||
(
|
||||
f"Decrypted {decrypted_count} message{'s' if decrypted_count != 1 else ''} "
|
||||
f"across {len(matched_channel_names)} room"
|
||||
f"{'s' if len(matched_channel_names) != 1 else ''}"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[Channel])
|
||||
async def list_channels() -> list[Channel]:
|
||||
"""List all channels from the database."""
|
||||
@@ -58,6 +215,7 @@ async def get_channel_detail(key: str) -> ChannelDetail:
|
||||
first_message_at=stats["first_message_at"],
|
||||
unique_sender_count=stats["unique_sender_count"],
|
||||
top_senders_24h=[ChannelTopSender(**s) for s in stats["top_senders_24h"]],
|
||||
path_hash_width_24h=stats["path_hash_width_24h"],
|
||||
)
|
||||
|
||||
|
||||
@@ -69,50 +227,7 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
|
||||
automatically when sending a message (see messages.py send_channel_message).
|
||||
"""
|
||||
requested_name = request.name
|
||||
is_hashtag = requested_name.startswith("#")
|
||||
|
||||
# Reserve the canonical Public channel so it cannot drift to another key,
|
||||
# and the well-known Public key cannot be renamed to something else.
|
||||
if is_public_channel_name(requested_name):
|
||||
if request.key:
|
||||
try:
|
||||
key_bytes = bytes.fromhex(request.key)
|
||||
if len(key_bytes) != 16:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Channel key must be exactly 16 bytes (32 hex chars)",
|
||||
)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
|
||||
if key_bytes.hex().upper() != PUBLIC_CHANNEL_KEY:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f'"{PUBLIC_CHANNEL_NAME}" must use the canonical Public key',
|
||||
)
|
||||
key_hex = PUBLIC_CHANNEL_KEY
|
||||
channel_name = PUBLIC_CHANNEL_NAME
|
||||
is_hashtag = False
|
||||
elif request.key and not is_hashtag:
|
||||
try:
|
||||
key_bytes = bytes.fromhex(request.key)
|
||||
if len(key_bytes) != 16:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Channel key must be exactly 16 bytes (32 hex chars)"
|
||||
)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
|
||||
key_hex = key_bytes.hex().upper()
|
||||
if is_public_channel_key(key_hex):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f'The canonical Public key may only be used for "{PUBLIC_CHANNEL_NAME}"',
|
||||
)
|
||||
channel_name = requested_name
|
||||
else:
|
||||
# Derive key from name hash (same as meshcore library does)
|
||||
key_bytes = sha256(requested_name.encode("utf-8")).digest()[:16]
|
||||
key_hex = key_bytes.hex().upper()
|
||||
channel_name = requested_name
|
||||
key_hex, channel_name, is_hashtag = _derive_channel_identity(requested_name, request.key)
|
||||
|
||||
logger.info("Creating channel %s: %s (hashtag=%s)", key_hex, channel_name, is_hashtag)
|
||||
|
||||
@@ -132,6 +247,81 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
|
||||
return stored
|
||||
|
||||
|
||||
@router.post("/bulk-hashtag", response_model=BulkCreateHashtagChannelsResponse)
|
||||
async def bulk_create_hashtag_channels(
|
||||
request: BulkCreateHashtagChannelsRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
response: Response,
|
||||
) -> BulkCreateHashtagChannelsResponse:
|
||||
created_channels: list[Channel] = []
|
||||
existing_count = 0
|
||||
invalid_names: list[str] = []
|
||||
decrypt_started = False
|
||||
decrypt_total_packets = 0
|
||||
decrypt_targets: list[tuple[bytes, str, str]] = []
|
||||
|
||||
for raw_name in request.channel_names:
|
||||
normalized_name = _normalize_bulk_hashtag_name(raw_name)
|
||||
if normalized_name is None:
|
||||
invalid_names.append(raw_name)
|
||||
continue
|
||||
|
||||
key_hex, channel_name, is_hashtag = _derive_channel_identity(normalized_name)
|
||||
existing = await ChannelRepository.get_by_key(key_hex)
|
||||
if existing is not None:
|
||||
existing_count += 1
|
||||
continue
|
||||
|
||||
await ChannelRepository.upsert(
|
||||
key=key_hex,
|
||||
name=channel_name,
|
||||
is_hashtag=is_hashtag,
|
||||
on_radio=False,
|
||||
)
|
||||
stored = await ChannelRepository.get_by_key(key_hex)
|
||||
if stored is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Channel was created but could not be reloaded",
|
||||
)
|
||||
|
||||
created_channels.append(stored)
|
||||
decrypt_targets.append((bytes.fromhex(stored.key), stored.key, stored.name))
|
||||
_broadcast_channel_update(stored)
|
||||
|
||||
if request.try_historical and decrypt_targets:
|
||||
decrypt_total_packets = await RawPacketRepository.get_undecrypted_count()
|
||||
if decrypt_total_packets > 0:
|
||||
background_tasks.add_task(
|
||||
_run_historical_channel_decryption_for_channels, decrypt_targets
|
||||
)
|
||||
decrypt_started = True
|
||||
response.status_code = status.HTTP_202_ACCEPTED
|
||||
|
||||
message = (
|
||||
f"Created {len(created_channels)} room{'s' if len(created_channels) != 1 else ''}"
|
||||
if created_channels
|
||||
else "No new rooms were added"
|
||||
)
|
||||
if request.try_historical and decrypt_targets:
|
||||
if decrypt_started:
|
||||
message += (
|
||||
f" and started background decrypt of {decrypt_total_packets} packet"
|
||||
f"{'s' if decrypt_total_packets != 1 else ''}"
|
||||
)
|
||||
else:
|
||||
message += "; no undecrypted packets were available"
|
||||
|
||||
return BulkCreateHashtagChannelsResponse(
|
||||
created_channels=created_channels,
|
||||
existing_count=existing_count,
|
||||
invalid_names=invalid_names,
|
||||
decrypt_started=decrypt_started,
|
||||
decrypt_total_packets=decrypt_total_packets,
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{key}/mark-read")
|
||||
async def mark_channel_read(key: str) -> dict:
|
||||
"""Mark a channel as read (update last_read_at timestamp)."""
|
||||
@@ -168,6 +358,29 @@ async def set_channel_flood_scope_override(
|
||||
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}")
|
||||
async def delete_channel(key: str) -> dict:
|
||||
"""Delete a channel from the database by key.
|
||||
|
||||
+52
-6
@@ -1,12 +1,13 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from contextlib import suppress
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
|
||||
from meshcore import EventType
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.models import (
|
||||
Contact,
|
||||
ContactActiveRoom,
|
||||
@@ -31,7 +32,7 @@ from app.repository import (
|
||||
)
|
||||
from app.services.contact_reconciliation import (
|
||||
promote_prefix_contacts_for_contact,
|
||||
reconcile_contact_messages,
|
||||
record_contact_name_and_reconcile,
|
||||
)
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
@@ -277,12 +278,18 @@ async def create_contact(
|
||||
# Check if contact already exists
|
||||
existing = await ContactRepository.get_by_key(request.public_key)
|
||||
if existing:
|
||||
# Update name if provided
|
||||
# Update name if provided and record name history
|
||||
if request.name:
|
||||
await ContactRepository.upsert(existing.to_upsert(name=request.name))
|
||||
refreshed = await ContactRepository.get_by_key(request.public_key)
|
||||
if refreshed is not None:
|
||||
existing = refreshed
|
||||
await record_contact_name_and_reconcile(
|
||||
public_key=request.public_key,
|
||||
contact_name=request.name,
|
||||
timestamp=int(time.time()),
|
||||
log=logger,
|
||||
)
|
||||
|
||||
promoted_keys = await promote_prefix_contacts_for_contact(
|
||||
public_key=request.public_key,
|
||||
@@ -317,9 +324,10 @@ async def create_contact(
|
||||
log=logger,
|
||||
)
|
||||
|
||||
await reconcile_contact_messages(
|
||||
await record_contact_name_and_reconcile(
|
||||
public_key=lower_key,
|
||||
contact_name=request.name,
|
||||
timestamp=int(time.time()),
|
||||
log=logger,
|
||||
)
|
||||
|
||||
@@ -347,6 +355,44 @@ async def mark_contact_read(public_key: str) -> dict:
|
||||
return {"status": "ok", "public_key": contact.public_key}
|
||||
|
||||
|
||||
class BulkDeleteRequest(BaseModel):
|
||||
public_keys: list[str] = Field(description="Public keys to delete")
|
||||
|
||||
|
||||
@router.post("/bulk-delete")
|
||||
async def bulk_delete_contacts(request: BulkDeleteRequest) -> dict:
|
||||
"""Delete multiple contacts from the database (and radio if present)."""
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
# Resolve all contacts first
|
||||
contacts_to_delete: list[Contact] = []
|
||||
for key in request.public_keys:
|
||||
contact = await ContactRepository.get_by_key(key.lower())
|
||||
if contact:
|
||||
contacts_to_delete.append(contact)
|
||||
|
||||
# Remove from radio in a single locked operation (blocks until radio is free)
|
||||
if radio_manager.is_connected and contacts_to_delete:
|
||||
try:
|
||||
async with radio_manager.radio_operation("bulk_delete_contacts_from_radio") as mc:
|
||||
for contact in contacts_to_delete:
|
||||
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
|
||||
if radio_contact:
|
||||
await mc.commands.remove_contact(radio_contact)
|
||||
except Exception as e:
|
||||
logger.warning("Radio removal during bulk delete failed: %s", e)
|
||||
|
||||
# Delete from database and broadcast events
|
||||
deleted = 0
|
||||
for contact in contacts_to_delete:
|
||||
await ContactRepository.delete(contact.public_key)
|
||||
broadcast_event("contact_deleted", {"public_key": contact.public_key})
|
||||
deleted += 1
|
||||
|
||||
logger.info("Bulk deleted %d/%d contacts", deleted, len(request.public_keys))
|
||||
return {"deleted": deleted}
|
||||
|
||||
|
||||
@router.delete("/{public_key}")
|
||||
async def delete_contact(public_key: str) -> dict:
|
||||
"""Delete a contact from the database (and radio if present)."""
|
||||
@@ -381,7 +427,7 @@ async def request_trace(public_key: str) -> TraceResponse:
|
||||
(no intermediate repeaters). This uses TRACE's dedicated width flags rather
|
||||
than the radio's normal path_hash_mode setting.
|
||||
"""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
|
||||
@@ -440,7 +486,7 @@ async def request_trace(public_key: str) -> TraceResponse:
|
||||
@router.post("/{public_key}/path-discovery", response_model=PathDiscoveryResponse)
|
||||
async def request_path_discovery(public_key: str) -> PathDiscoveryResponse:
|
||||
"""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)
|
||||
pubkey_prefix = contact.public_key[:12]
|
||||
|
||||
+138
-19
@@ -1,17 +1,21 @@
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import struct
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter
|
||||
from meshcore import EventType
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.config import get_recent_log_lines, settings
|
||||
from app.models import AppSettings
|
||||
from app.radio_sync import get_contacts_selected_for_radio_sync, get_radio_channel_limit
|
||||
from app.repository import MessageRepository, StatisticsRepository
|
||||
from app.routers.health import HealthResponse, build_health_data
|
||||
from app.repository import AppSettingsRepository, MessageRepository, StatisticsRepository
|
||||
from app.routers.health import FanoutStatusResponse, build_health_data
|
||||
from app.services.radio_runtime import radio_runtime
|
||||
from app.version_info import get_app_build_info, git_output
|
||||
|
||||
@@ -34,6 +38,13 @@ LOG_COPY_BOUNDARY_PREFIX = [
|
||||
]
|
||||
|
||||
|
||||
class DebugSystemInfo(BaseModel):
|
||||
os: str
|
||||
arch: str
|
||||
arch_bits: int
|
||||
total_ram_mb: int
|
||||
|
||||
|
||||
class DebugApplicationInfo(BaseModel):
|
||||
version: str
|
||||
version_source: str
|
||||
@@ -50,8 +61,6 @@ class DebugRuntimeInfo(BaseModel):
|
||||
setup_in_progress: bool
|
||||
setup_complete: bool
|
||||
channels_with_incoming_messages: int
|
||||
max_channels: int
|
||||
path_hash_mode: int
|
||||
path_hash_mode_supported: bool
|
||||
channel_slot_reuse_enabled: bool
|
||||
channel_send_cache_capacity: int
|
||||
@@ -78,7 +87,6 @@ class DebugChannelAudit(BaseModel):
|
||||
class DebugRadioProbe(BaseModel):
|
||||
performed: bool
|
||||
errors: list[str] = Field(default_factory=list)
|
||||
multi_acks_enabled: bool | None = None
|
||||
self_info: dict[str, Any] | None = None
|
||||
device_info: dict[str, Any] | None = None
|
||||
stats_core: dict[str, Any] | None = None
|
||||
@@ -93,16 +101,53 @@ class DebugDatabaseInfo(BaseModel):
|
||||
total_outgoing: int
|
||||
|
||||
|
||||
class DebugHealthSummary(BaseModel):
|
||||
radio_state: str
|
||||
database_size_mb: float
|
||||
oldest_undecrypted_timestamp: int | None
|
||||
fanouts_with_errors: dict[str, FanoutStatusResponse] = Field(default_factory=dict)
|
||||
bots_disabled_source: Literal["env", "until_restart"] | None = None
|
||||
basic_auth_enabled: bool = False
|
||||
|
||||
|
||||
class DebugAppSettings(BaseModel):
|
||||
max_radio_contacts: int
|
||||
auto_decrypt_dm_on_advert: bool
|
||||
advert_interval: int
|
||||
flood_scope: str
|
||||
blocked_keys_count: int
|
||||
blocked_names_count: int
|
||||
|
||||
|
||||
class DebugSnapshotResponse(BaseModel):
|
||||
captured_at: str
|
||||
system: DebugSystemInfo
|
||||
application: DebugApplicationInfo
|
||||
health: HealthResponse
|
||||
health: DebugHealthSummary
|
||||
settings: DebugAppSettings
|
||||
runtime: DebugRuntimeInfo
|
||||
database: DebugDatabaseInfo
|
||||
radio_probe: DebugRadioProbe
|
||||
logs: list[str]
|
||||
|
||||
|
||||
def _build_system_info() -> DebugSystemInfo:
|
||||
try:
|
||||
# os.sysconf is available on Linux/macOS
|
||||
page_size = os.sysconf("SC_PAGE_SIZE")
|
||||
page_count = os.sysconf("SC_PHYS_PAGES")
|
||||
total_ram_mb = (page_size * page_count) // (1024 * 1024)
|
||||
except (AttributeError, ValueError, OSError):
|
||||
total_ram_mb = 0
|
||||
|
||||
return DebugSystemInfo(
|
||||
os=f"{platform.system()} {platform.release()}",
|
||||
arch=platform.machine(),
|
||||
arch_bits=struct.calcsize("P") * 8,
|
||||
total_ram_mb=total_ram_mb,
|
||||
)
|
||||
|
||||
|
||||
def _build_application_info() -> DebugApplicationInfo:
|
||||
build_info = get_app_build_info()
|
||||
dirty_output = git_output("status", "--porcelain")
|
||||
@@ -158,6 +203,68 @@ def _coerce_live_max_channels(device_info: dict[str, Any] | None) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
def _build_debug_app_settings(app_settings: AppSettings) -> DebugAppSettings:
|
||||
return DebugAppSettings(
|
||||
max_radio_contacts=app_settings.max_radio_contacts,
|
||||
auto_decrypt_dm_on_advert=app_settings.auto_decrypt_dm_on_advert,
|
||||
advert_interval=app_settings.advert_interval,
|
||||
flood_scope=app_settings.flood_scope,
|
||||
blocked_keys_count=len(app_settings.blocked_keys),
|
||||
blocked_names_count=len(app_settings.blocked_names),
|
||||
)
|
||||
|
||||
|
||||
def _derive_debug_radio_state(
|
||||
*,
|
||||
radio_connected: bool,
|
||||
connection_desired: bool,
|
||||
setup_in_progress: bool,
|
||||
setup_complete: bool,
|
||||
is_reconnecting: bool,
|
||||
) -> str:
|
||||
if not connection_desired:
|
||||
return "paused"
|
||||
if radio_connected and (setup_in_progress or not setup_complete):
|
||||
return "initializing"
|
||||
if radio_connected:
|
||||
return "connected"
|
||||
if is_reconnecting:
|
||||
return "connecting"
|
||||
return "disconnected"
|
||||
|
||||
|
||||
def _build_debug_health_summary(
|
||||
health_data: dict[str, Any], *, radio_state: str
|
||||
) -> DebugHealthSummary:
|
||||
def _fanout_last_error(status: Any) -> str | None:
|
||||
if isinstance(status, dict):
|
||||
value = status.get("last_error")
|
||||
else:
|
||||
value = getattr(status, "last_error", None)
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
fanouts_with_errors = {
|
||||
config_id: status
|
||||
for config_id, status in health_data["fanout_statuses"].items()
|
||||
if _fanout_last_error(status)
|
||||
}
|
||||
return DebugHealthSummary(
|
||||
radio_state=radio_state,
|
||||
database_size_mb=health_data["database_size_mb"],
|
||||
oldest_undecrypted_timestamp=health_data["oldest_undecrypted_timestamp"],
|
||||
fanouts_with_errors=fanouts_with_errors,
|
||||
bots_disabled_source=health_data["bots_disabled_source"],
|
||||
basic_auth_enabled=health_data["basic_auth_enabled"],
|
||||
)
|
||||
|
||||
|
||||
def _sanitize_radio_probe_self_info(self_info: dict[str, Any] | None) -> dict[str, Any]:
|
||||
sanitized = dict(self_info or {})
|
||||
sanitized.pop("adv_lat", None)
|
||||
sanitized.pop("adv_lon", None)
|
||||
return sanitized
|
||||
|
||||
|
||||
async def _build_contact_audit(
|
||||
observed_contacts_payload: dict[str, dict[str, Any]],
|
||||
) -> DebugContactAudit:
|
||||
@@ -242,10 +349,7 @@ async def _probe_radio() -> DebugRadioProbe:
|
||||
return DebugRadioProbe(
|
||||
performed=True,
|
||||
errors=errors,
|
||||
multi_acks_enabled=bool(mc.self_info.get("multi_acks", 0))
|
||||
if mc.self_info is not None
|
||||
else None,
|
||||
self_info=dict(mc.self_info or {}),
|
||||
self_info=_sanitize_radio_probe_self_info(mc.self_info),
|
||||
device_info=device_info,
|
||||
stats_core=stats_core,
|
||||
stats_radio=stats_radio,
|
||||
@@ -264,24 +368,39 @@ async def _probe_radio() -> DebugRadioProbe:
|
||||
@router.get("/debug", response_model=DebugSnapshotResponse)
|
||||
async def debug_support_snapshot() -> DebugSnapshotResponse:
|
||||
"""Return a support/debug snapshot with recent logs and live radio state."""
|
||||
health_data = await build_health_data(radio_runtime.is_connected, radio_runtime.connection_info)
|
||||
connection_info = radio_runtime.connection_info
|
||||
connection_desired = radio_runtime.connection_desired
|
||||
setup_in_progress = radio_runtime.is_setup_in_progress
|
||||
setup_complete = radio_runtime.is_setup_complete
|
||||
radio_connected = radio_runtime.is_connected
|
||||
is_reconnecting = getattr(radio_runtime, "is_reconnecting", False)
|
||||
|
||||
health_data = await build_health_data(radio_connected, connection_info)
|
||||
app_settings = await AppSettingsRepository.get()
|
||||
message_totals = await StatisticsRepository.get_database_message_totals()
|
||||
radio_probe = await _probe_radio()
|
||||
channels_with_incoming_messages = (
|
||||
await MessageRepository.count_channels_with_incoming_messages()
|
||||
)
|
||||
radio_state = _derive_debug_radio_state(
|
||||
radio_connected=radio_connected,
|
||||
connection_desired=connection_desired,
|
||||
setup_in_progress=setup_in_progress,
|
||||
setup_complete=setup_complete,
|
||||
is_reconnecting=is_reconnecting,
|
||||
)
|
||||
return DebugSnapshotResponse(
|
||||
captured_at=datetime.now(timezone.utc).isoformat(),
|
||||
system=_build_system_info(),
|
||||
application=_build_application_info(),
|
||||
health=HealthResponse(**health_data),
|
||||
health=_build_debug_health_summary(health_data, radio_state=radio_state),
|
||||
settings=_build_debug_app_settings(app_settings),
|
||||
runtime=DebugRuntimeInfo(
|
||||
connection_info=radio_runtime.connection_info,
|
||||
connection_desired=radio_runtime.connection_desired,
|
||||
setup_in_progress=radio_runtime.is_setup_in_progress,
|
||||
setup_complete=radio_runtime.is_setup_complete,
|
||||
connection_info=connection_info,
|
||||
connection_desired=connection_desired,
|
||||
setup_in_progress=setup_in_progress,
|
||||
setup_complete=setup_complete,
|
||||
channels_with_incoming_messages=channels_with_incoming_messages,
|
||||
max_channels=radio_runtime.max_channels,
|
||||
path_hash_mode=radio_runtime.path_hash_mode,
|
||||
path_hash_mode_supported=radio_runtime.path_hash_mode_supported,
|
||||
channel_slot_reuse_enabled=radio_runtime.channel_slot_reuse_enabled(),
|
||||
channel_send_cache_capacity=radio_runtime.get_channel_send_cache_capacity(),
|
||||
|
||||
@@ -3,7 +3,6 @@ import time
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.event_handlers import track_pending_ack
|
||||
from app.models import (
|
||||
Message,
|
||||
@@ -89,7 +88,7 @@ async def list_messages(
|
||||
@router.post("/direct", response_model=Message)
|
||||
async def send_direct_message(request: SendDirectMessageRequest) -> Message:
|
||||
"""Send a direct message to a contact."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
|
||||
# First check our database for the contact
|
||||
from app.repository import ContactRepository
|
||||
@@ -136,7 +135,7 @@ TEMP_RADIO_SLOT = 0
|
||||
@router.post("/channel", response_model=Message)
|
||||
async def send_channel_message(request: SendChannelMessageRequest) -> Message:
|
||||
"""Send a message to a channel."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
|
||||
# Get channel info from our database
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
@@ -49,8 +49,7 @@ async def _run_historical_channel_decryption(
|
||||
channel_key_bytes: bytes, channel_key_hex: str, display_name: str | None = None
|
||||
) -> None:
|
||||
"""Background task to decrypt historical packets with a channel key."""
|
||||
packets = await RawPacketRepository.get_all_undecrypted()
|
||||
total = len(packets)
|
||||
total = await RawPacketRepository.get_undecrypted_count()
|
||||
decrypted_count = 0
|
||||
|
||||
if total == 0:
|
||||
@@ -59,7 +58,11 @@ async def _run_historical_channel_decryption(
|
||||
|
||||
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)
|
||||
|
||||
if result is not None:
|
||||
|
||||
+21
-14
@@ -9,7 +9,6 @@ from fastapi import APIRouter, HTTPException
|
||||
from meshcore import EventType
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.models import (
|
||||
CONTACT_TYPE_REPEATER,
|
||||
ContactUpsert,
|
||||
@@ -24,7 +23,11 @@ from app.models import (
|
||||
from app.radio_sync import send_advertisement as do_send_advertisement
|
||||
from app.radio_sync import sync_radio_time
|
||||
from app.repository import ContactRepository
|
||||
from app.services.contact_reconciliation import promote_prefix_contacts_for_contact
|
||||
from app.routers.server_control import _monotonic
|
||||
from app.services.contact_reconciliation import (
|
||||
promote_prefix_contacts_for_contact,
|
||||
reconcile_contact_messages,
|
||||
)
|
||||
from app.services.radio_commands import (
|
||||
KeystoreRefreshError,
|
||||
PathHashModeUnsupportedError,
|
||||
@@ -133,10 +136,6 @@ class RadioAdvertiseRequest(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
def _monotonic() -> float:
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
def _better_signal(first: float | None, second: float | None) -> float | None:
|
||||
if first is None:
|
||||
return second
|
||||
@@ -214,11 +213,19 @@ async def _persist_new_discovery_contacts(results: list[RadioDiscoveryResult]) -
|
||||
public_key=result.public_key,
|
||||
log=logger,
|
||||
)
|
||||
await reconcile_contact_messages(
|
||||
public_key=result.public_key,
|
||||
contact_name=result.name,
|
||||
log=logger,
|
||||
)
|
||||
created = await ContactRepository.get_by_key(result.public_key)
|
||||
if created is not None:
|
||||
broadcast_event("contact", created.model_dump())
|
||||
for old_key in promoted_keys:
|
||||
broadcast_event("contact_deleted", {"public_key": old_key})
|
||||
for old_key in promoted_keys:
|
||||
broadcast_event(
|
||||
"contact_resolved",
|
||||
{"previous_public_key": old_key, "contact": created.model_dump()},
|
||||
)
|
||||
|
||||
|
||||
async def _attach_known_names(results: list[RadioDiscoveryResult]) -> None:
|
||||
@@ -327,7 +334,7 @@ async def _resolve_trace_hops(
|
||||
@router.get("/config", response_model=RadioConfigResponse)
|
||||
async def get_radio_config() -> RadioConfigResponse:
|
||||
"""Get the current radio configuration."""
|
||||
mc = require_connected()
|
||||
mc = radio_manager.require_connected()
|
||||
|
||||
info = mc.self_info
|
||||
if not info:
|
||||
@@ -359,7 +366,7 @@ async def get_radio_config() -> RadioConfigResponse:
|
||||
@router.patch("/config", response_model=RadioConfigResponse)
|
||||
async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
|
||||
"""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:
|
||||
try:
|
||||
@@ -381,7 +388,7 @@ async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
|
||||
@router.put("/private-key")
|
||||
async def set_private_key(update: PrivateKeyUpdate) -> dict:
|
||||
"""Set the radio's private key. This is write-only."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
|
||||
try:
|
||||
key_bytes = bytes.fromhex(update.private_key)
|
||||
@@ -415,7 +422,7 @@ async def send_advertisement(request: RadioAdvertiseRequest | None = None) -> di
|
||||
Returns:
|
||||
status: "ok" if sent successfully
|
||||
"""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
mode: RadioAdvertMode = request.mode if request is not None else "flood"
|
||||
|
||||
logger.info("Sending %s advertisement", mode.replace("_", "-"))
|
||||
@@ -431,7 +438,7 @@ async def send_advertisement(request: RadioAdvertiseRequest | None = None) -> di
|
||||
@router.post("/discover", response_model=RadioDiscoveryResponse)
|
||||
async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryResponse:
|
||||
"""Run a short node-discovery sweep from the local radio."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
|
||||
target_bits = _DISCOVERY_TARGET_BITS[request.target]
|
||||
tag = random.randint(1, 0xFFFFFFFF)
|
||||
@@ -498,7 +505,7 @@ async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryRespons
|
||||
@router.post("/trace", response_model=RadioTraceResponse)
|
||||
async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse:
|
||||
"""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)
|
||||
|
||||
tag = random.randint(1, 0xFFFFFFFF)
|
||||
|
||||
+50
-18
@@ -1,8 +1,8 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.models import (
|
||||
CONTACT_TYPE_REPEATER,
|
||||
AclEntry,
|
||||
@@ -21,12 +21,12 @@ from app.models import (
|
||||
RepeaterOwnerInfoResponse,
|
||||
RepeaterRadioSettingsResponse,
|
||||
RepeaterStatusResponse,
|
||||
TelemetryHistoryEntry,
|
||||
)
|
||||
from app.repository import ContactRepository
|
||||
from app.repository import ContactRepository, RepeaterTelemetryRepository
|
||||
from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404
|
||||
from app.routers.server_control import (
|
||||
batch_cli_fetch,
|
||||
extract_response_text,
|
||||
prepare_authenticated_contact_connection,
|
||||
require_server_capable_contact,
|
||||
send_contact_cli_command,
|
||||
@@ -46,10 +46,6 @@ router = APIRouter(prefix="/contacts", tags=["repeaters"])
|
||||
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:
|
||||
return await prepare_authenticated_contact_connection(
|
||||
mc,
|
||||
@@ -78,7 +74,7 @@ def _require_repeater(contact: Contact) -> None:
|
||||
@router.post("/{public_key}/repeater/login", response_model=RepeaterLoginResponse)
|
||||
async def repeater_login(public_key: str, request: RepeaterLoginRequest) -> RepeaterLoginResponse:
|
||||
"""Attempt repeater login and report whether auth was confirmed."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
@@ -93,7 +89,7 @@ async def repeater_login(public_key: str, request: RepeaterLoginRequest) -> Repe
|
||||
@router.post("/{public_key}/repeater/status", response_model=RepeaterStatusResponse)
|
||||
async def repeater_status(public_key: str) -> RepeaterStatusResponse:
|
||||
"""Fetch status telemetry from a repeater (single attempt, 10s timeout)."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
@@ -108,7 +104,7 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
|
||||
if status is None:
|
||||
raise HTTPException(status_code=504, detail="No status response from repeater")
|
||||
|
||||
return RepeaterStatusResponse(
|
||||
response = RepeaterStatusResponse(
|
||||
battery_volts=status.get("bat", 0) / 1000.0,
|
||||
tx_queue_len=status.get("tx_queue_len", 0),
|
||||
noise_floor_dbm=status.get("noise_floor", 0),
|
||||
@@ -128,11 +124,47 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
|
||||
full_events=status.get("full_evts", 0),
|
||||
)
|
||||
|
||||
# Record to telemetry history as a JSON blob (best-effort)
|
||||
now = int(time.time())
|
||||
status_dict = response.model_dump(exclude={"telemetry_history"})
|
||||
try:
|
||||
await RepeaterTelemetryRepository.record(
|
||||
public_key=contact.public_key,
|
||||
timestamp=now,
|
||||
data=status_dict,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to record telemetry history: %s", e)
|
||||
|
||||
# Fetch recent history and embed in response
|
||||
try:
|
||||
since = now - 30 * 86400 # last 30 days
|
||||
rows = await RepeaterTelemetryRepository.get_history(contact.public_key, since)
|
||||
response.telemetry_history = [TelemetryHistoryEntry(**row) for row in rows]
|
||||
except Exception as e:
|
||||
logger.warning("Failed to fetch telemetry history: %s", e)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{public_key}/repeater/telemetry-history",
|
||||
response_model=list[TelemetryHistoryEntry],
|
||||
)
|
||||
async def repeater_telemetry_history(public_key: str) -> list[TelemetryHistoryEntry]:
|
||||
"""Return stored telemetry history for a repeater (read-only, no radio access)."""
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
since = int(time.time()) - 30 * 86400
|
||||
rows = await RepeaterTelemetryRepository.get_history(contact.public_key, since)
|
||||
return [TelemetryHistoryEntry(**row) for row in rows]
|
||||
|
||||
|
||||
@router.post("/{public_key}/repeater/lpp-telemetry", response_model=RepeaterLppTelemetryResponse)
|
||||
async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
|
||||
"""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)
|
||||
_require_repeater(contact)
|
||||
|
||||
@@ -161,7 +193,7 @@ async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryRespons
|
||||
@router.post("/{public_key}/repeater/neighbors", response_model=RepeaterNeighborsResponse)
|
||||
async def repeater_neighbors(public_key: str) -> RepeaterNeighborsResponse:
|
||||
"""Fetch neighbors from a repeater (single attempt, 10s timeout)."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
@@ -195,7 +227,7 @@ async def repeater_neighbors(public_key: str) -> RepeaterNeighborsResponse:
|
||||
@router.post("/{public_key}/repeater/acl", response_model=RepeaterAclResponse)
|
||||
async def repeater_acl(public_key: str) -> RepeaterAclResponse:
|
||||
"""Fetch ACL from a repeater (single attempt, 10s timeout)."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
@@ -236,7 +268,7 @@ async def _batch_cli_fetch(
|
||||
@router.post("/{public_key}/repeater/node-info", response_model=RepeaterNodeInfoResponse)
|
||||
async def repeater_node_info(public_key: str) -> RepeaterNodeInfoResponse:
|
||||
"""Fetch repeater identity/location info via a small CLI batch."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
@@ -256,7 +288,7 @@ async def repeater_node_info(public_key: str) -> RepeaterNodeInfoResponse:
|
||||
@router.post("/{public_key}/repeater/radio-settings", response_model=RepeaterRadioSettingsResponse)
|
||||
async def repeater_radio_settings(public_key: str) -> RepeaterRadioSettingsResponse:
|
||||
"""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)
|
||||
_require_repeater(contact)
|
||||
|
||||
@@ -280,7 +312,7 @@ async def repeater_radio_settings(public_key: str) -> RepeaterRadioSettingsRespo
|
||||
)
|
||||
async def repeater_advert_intervals(public_key: str) -> RepeaterAdvertIntervalsResponse:
|
||||
"""Fetch advertisement intervals from a repeater via CLI commands."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
@@ -298,7 +330,7 @@ async def repeater_advert_intervals(public_key: str) -> RepeaterAdvertIntervalsR
|
||||
@router.post("/{public_key}/repeater/owner-info", response_model=RepeaterOwnerInfoResponse)
|
||||
async def repeater_owner_info(public_key: str) -> RepeaterOwnerInfoResponse:
|
||||
"""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)
|
||||
_require_repeater(contact)
|
||||
|
||||
@@ -316,7 +348,7 @@ async def repeater_owner_info(public_key: str) -> RepeaterOwnerInfoResponse:
|
||||
@router.post("/{public_key}/command", response_model=CommandResponse)
|
||||
async def send_repeater_command(public_key: str, request: CommandRequest) -> CommandResponse:
|
||||
"""Send a CLI command to a repeater or room server."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
require_server_capable_contact(contact)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.models import (
|
||||
CONTACT_TYPE_ROOM,
|
||||
AclEntry,
|
||||
@@ -28,7 +27,7 @@ def _require_room(contact) -> None:
|
||||
@router.post("/{public_key}/room/login", response_model=RepeaterLoginResponse)
|
||||
async def room_login(public_key: str, request: RepeaterLoginRequest) -> RepeaterLoginResponse:
|
||||
"""Attempt room-server login and report whether auth was confirmed."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_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)
|
||||
async def room_status(public_key: str) -> RepeaterStatusResponse:
|
||||
"""Fetch status telemetry from a room server."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_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)
|
||||
async def room_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
|
||||
"""Fetch CayenneLPP telemetry from a room server."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_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)
|
||||
async def room_acl(public_key: str) -> RepeaterAclResponse:
|
||||
"""Fetch ACL entries from a room server."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_room(contact)
|
||||
|
||||
|
||||
@@ -230,20 +230,27 @@ async def batch_cli_fetch(
|
||||
operation_name: str,
|
||||
commands: list[tuple[str, str]],
|
||||
) -> dict[str, str | None]:
|
||||
"""Send a batch of CLI commands to a 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}
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
operation_name,
|
||||
pause_polling=True,
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
await _ensure_on_radio(mc, contact)
|
||||
await asyncio.sleep(1.0)
|
||||
for index, (cmd, field) in enumerate(commands):
|
||||
if index > 0:
|
||||
# Yield briefly so queued operations can acquire the lock.
|
||||
await asyncio.sleep(0.25)
|
||||
|
||||
for index, (cmd, field) in enumerate(commands):
|
||||
if index > 0:
|
||||
await asyncio.sleep(1.0)
|
||||
async with radio_manager.radio_operation(
|
||||
operation_name,
|
||||
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)
|
||||
if send_result.type == EventType.ERROR:
|
||||
|
||||
+81
-58
@@ -2,16 +2,18 @@ import asyncio
|
||||
import logging
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, HTTPException
|
||||
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.repository import AppSettingsRepository
|
||||
from app.repository import AppSettingsRepository, ContactRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
MAX_TRACKED_TELEMETRY_REPEATERS = 8
|
||||
|
||||
|
||||
class AppSettingsUpdate(BaseModel):
|
||||
max_radio_contacts: int | None = Field(
|
||||
@@ -27,10 +29,6 @@ class AppSettingsUpdate(BaseModel):
|
||||
default=None,
|
||||
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(
|
||||
default=None,
|
||||
ge=0,
|
||||
@@ -48,6 +46,17 @@ class AppSettingsUpdate(BaseModel):
|
||||
default=None,
|
||||
description="Display names whose messages are hidden from the UI",
|
||||
)
|
||||
discovery_blocked_types: list[int] | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Contact type codes (1=Client, 2=Repeater, 3=Room, 4=Sensor) whose "
|
||||
"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):
|
||||
@@ -63,24 +72,17 @@ class FavoriteRequest(BaseModel):
|
||||
id: str = Field(description="Channel key or contact public key")
|
||||
|
||||
|
||||
class MigratePreferencesRequest(BaseModel):
|
||||
favorites: list[FavoriteRequest] = Field(
|
||||
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 TrackedTelemetryRequest(BaseModel):
|
||||
public_key: str = Field(description="Public key of the repeater to toggle tracking")
|
||||
|
||||
|
||||
class MigratePreferencesResponse(BaseModel):
|
||||
migrated: bool = Field(description="Whether migration occurred (false if already migrated)")
|
||||
settings: AppSettings = Field(description="Current settings after migration attempt")
|
||||
class TrackedTelemetryResponse(BaseModel):
|
||||
tracked_telemetry_repeaters: list[str] = Field(
|
||||
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)
|
||||
@@ -104,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)
|
||||
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:
|
||||
# Enforce minimum 1-hour interval; 0 means disabled
|
||||
interval = update.advert_interval
|
||||
@@ -122,6 +120,16 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
|
||||
if update.blocked_names is not None:
|
||||
kwargs["blocked_names"] = update.blocked_names
|
||||
|
||||
# Discovery blocked types
|
||||
if update.discovery_blocked_types is not None:
|
||||
# Only allow valid contact type codes (1-4)
|
||||
valid = [t for t in update.discovery_blocked_types if t in (1, 2, 3, 4)]
|
||||
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_changed = False
|
||||
if update.flood_scope is not None:
|
||||
@@ -186,41 +194,56 @@ async def toggle_blocked_name(request: BlockNameRequest) -> AppSettings:
|
||||
return await AppSettingsRepository.toggle_blocked_name(request.name)
|
||||
|
||||
|
||||
@router.post("/migrate", response_model=MigratePreferencesResponse)
|
||||
async def migrate_preferences(request: MigratePreferencesRequest) -> MigratePreferencesResponse:
|
||||
"""Migrate all preferences from frontend localStorage to database.
|
||||
@router.post("/tracked-telemetry/toggle", response_model=TrackedTelemetryResponse)
|
||||
async def toggle_tracked_telemetry(request: TrackedTelemetryRequest) -> TrackedTelemetryResponse:
|
||||
"""Toggle periodic telemetry collection for a repeater.
|
||||
|
||||
This is a one-time migration. If preferences have already been migrated,
|
||||
this endpoint will not overwrite them and will return migrated=false.
|
||||
|
||||
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)
|
||||
Max 8 repeaters may be tracked. Returns 409 if the limit is reached and
|
||||
the requested repeater is not already tracked.
|
||||
"""
|
||||
# Convert to dict format for the repository method
|
||||
frontend_favorites = [{"type": f.type, "id": f.id} for f in request.favorites]
|
||||
key = request.public_key.lower()
|
||||
settings = await AppSettingsRepository.get()
|
||||
current = settings.tracked_telemetry_repeaters
|
||||
|
||||
settings, did_migrate = await AppSettingsRepository.migrate_preferences_from_frontend(
|
||||
favorites=frontend_favorites,
|
||||
sort_order=request.sort_order,
|
||||
last_message_times=request.last_message_times,
|
||||
)
|
||||
async def _resolve_names(keys: list[str]) -> dict[str, str]:
|
||||
names: dict[str, str] = {}
|
||||
for k in keys:
|
||||
contact = await ContactRepository.get_by_key(k)
|
||||
names[k] = contact.name if contact and contact.name else k[:12]
|
||||
return names
|
||||
|
||||
if did_migrate:
|
||||
logger.info(
|
||||
"Migrated preferences from frontend: %d favorites, sort_order=%s, %d message times",
|
||||
len(frontend_favorites),
|
||||
request.sort_order,
|
||||
len(request.last_message_times),
|
||||
if key in current:
|
||||
# Remove
|
||||
new_list = [k for k in current if k != key]
|
||||
logger.info("Removing repeater %s from 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),
|
||||
)
|
||||
else:
|
||||
logger.debug("Preferences already migrated, skipping")
|
||||
|
||||
return MigratePreferencesResponse(
|
||||
migrated=did_migrate,
|
||||
settings=settings,
|
||||
# Validate it's a repeater
|
||||
contact = await ContactRepository.get_by_key(key)
|
||||
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."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from app.services import dm_ack_tracker
|
||||
from app.services.messages import increment_ack_and_broadcast
|
||||
|
||||
BroadcastFn = Callable[..., Any]
|
||||
from app.services.messages import BroadcastFn, increment_ack_and_broadcast
|
||||
|
||||
|
||||
async def apply_dm_ack_code(ack_code: str, *, broadcast_fn: BroadcastFn) -> bool:
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
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.repository import (
|
||||
@@ -14,6 +13,7 @@ from app.repository import (
|
||||
)
|
||||
from app.services.contact_reconciliation import claim_prefix_messages_for_contact
|
||||
from app.services.messages import (
|
||||
BroadcastFn,
|
||||
broadcast_message,
|
||||
build_message_model,
|
||||
build_message_paths,
|
||||
@@ -27,8 +27,6 @@ if TYPE_CHECKING:
|
||||
from app.decoder import DecryptedDirectMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BroadcastFn = Callable[..., Any]
|
||||
_decrypted_dm_store_lock = asyncio.Lock()
|
||||
|
||||
|
||||
@@ -144,6 +142,8 @@ async def _store_direct_message(
|
||||
received_at: int,
|
||||
path: str | None,
|
||||
path_len: int | None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
outgoing: bool,
|
||||
txt_type: int,
|
||||
signature: str | None,
|
||||
@@ -170,6 +170,8 @@ async def _store_direct_message(
|
||||
path=path,
|
||||
received_at=received_at,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
return None
|
||||
@@ -189,6 +191,8 @@ async def _store_direct_message(
|
||||
path=path,
|
||||
received_at=received_at,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
return None
|
||||
@@ -201,6 +205,8 @@ async def _store_direct_message(
|
||||
received_at=received_at,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
txt_type=txt_type,
|
||||
signature=signature,
|
||||
outgoing=outgoing,
|
||||
@@ -218,6 +224,8 @@ async def _store_direct_message(
|
||||
path=path,
|
||||
received_at=received_at,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
return None
|
||||
@@ -232,7 +240,7 @@ async def _store_direct_message(
|
||||
text=text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
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,
|
||||
signature=signature,
|
||||
sender_key=sender_key,
|
||||
@@ -261,6 +269,8 @@ async def ingest_decrypted_direct_message(
|
||||
received_at: int | None = None,
|
||||
path: str | None = None,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
outgoing: bool = False,
|
||||
realtime: bool = True,
|
||||
broadcast_fn: BroadcastFn,
|
||||
@@ -311,6 +321,8 @@ async def ingest_decrypted_direct_message(
|
||||
received_at=received,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
outgoing=outgoing,
|
||||
txt_type=decrypted.txt_type,
|
||||
signature=signature,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time as _time
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
@@ -9,10 +10,17 @@ from fastapi import HTTPException
|
||||
from meshcore import EventType
|
||||
|
||||
from app.models import ResendChannelMessageResponse
|
||||
from app.radio import RadioOperationBusyError
|
||||
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.messages import (
|
||||
BroadcastFn,
|
||||
broadcast_message,
|
||||
build_stored_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. "
|
||||
"The message may or may not have sent successfully."
|
||||
)
|
||||
|
||||
BroadcastFn = Callable[..., Any]
|
||||
TrackAckFn = Callable[[str, int, int], bool]
|
||||
NowFn = Callable[[], float]
|
||||
OutgoingReservationKey = tuple[str, str, str]
|
||||
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]] = {}
|
||||
_outgoing_timestamp_reservations_lock = asyncio.Lock()
|
||||
|
||||
@@ -122,7 +137,7 @@ async def send_channel_message_with_effective_scope(
|
||||
error_broadcast_fn: BroadcastFn,
|
||||
app_settings_repository=AppSettingsRepository,
|
||||
) -> 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)
|
||||
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:
|
||||
channel_slot, needs_configure, evicted_channel_key = radio_manager.plan_channel_send_slot(
|
||||
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:
|
||||
if result is None or result.type == EventType.ERROR:
|
||||
@@ -550,6 +635,85 @@ async def send_direct_message_to_contact(
|
||||
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(
|
||||
*,
|
||||
channel,
|
||||
@@ -658,6 +822,22 @@ async def send_channel_message_to_channel(
|
||||
message_repository=message_repository,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -37,10 +37,16 @@ def build_message_paths(
|
||||
path: str | None,
|
||||
received_at: int,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
) -> list[MessagePath] | None:
|
||||
"""Build the single-path list used by message payloads."""
|
||||
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
|
||||
else None
|
||||
)
|
||||
@@ -166,6 +172,8 @@ async def reconcile_duplicate_message(
|
||||
path: str | None,
|
||||
received_at: int,
|
||||
path_len: int | None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
broadcast_fn: BroadcastFn,
|
||||
) -> None:
|
||||
logger.debug(
|
||||
@@ -177,7 +185,9 @@ async def reconcile_duplicate_message(
|
||||
)
|
||||
|
||||
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:
|
||||
paths = existing_msg.paths or []
|
||||
|
||||
@@ -214,6 +224,8 @@ async def handle_duplicate_message(
|
||||
path: str | None,
|
||||
received_at: int,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
broadcast_fn: BroadcastFn,
|
||||
) -> None:
|
||||
"""Handle a duplicate message by updating paths/acks on the existing record."""
|
||||
@@ -239,6 +251,8 @@ async def handle_duplicate_message(
|
||||
path=path,
|
||||
received_at=received_at,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
|
||||
@@ -253,6 +267,8 @@ async def create_message_from_decrypted(
|
||||
received_at: int | None = None,
|
||||
path: str | None = None,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
channel_name: str | None = None,
|
||||
realtime: bool = True,
|
||||
broadcast_fn: BroadcastFn,
|
||||
@@ -276,6 +292,8 @@ async def create_message_from_decrypted(
|
||||
received_at=received,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
sender_name=sender,
|
||||
sender_key=resolved_sender_key,
|
||||
)
|
||||
@@ -291,6 +309,8 @@ async def create_message_from_decrypted(
|
||||
path=path,
|
||||
received_at=received,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
return None
|
||||
@@ -312,7 +332,7 @@ async def create_message_from_decrypted(
|
||||
text=text,
|
||||
sender_timestamp=timestamp,
|
||||
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_key=resolved_sender_key,
|
||||
channel_name=channel_name,
|
||||
@@ -334,6 +354,8 @@ async def create_dm_message_from_decrypted(
|
||||
received_at: int | None = None,
|
||||
path: str | None = None,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
outgoing: bool = False,
|
||||
realtime: bool = True,
|
||||
broadcast_fn: BroadcastFn,
|
||||
@@ -348,6 +370,8 @@ async def create_dm_message_from_decrypted(
|
||||
received_at=received_at,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
outgoing=outgoing,
|
||||
realtime=realtime,
|
||||
broadcast_fn=broadcast_fn,
|
||||
|
||||
@@ -33,6 +33,7 @@ async def run_post_connect_setup(radio_manager) -> None:
|
||||
start_message_polling,
|
||||
start_periodic_advert,
|
||||
start_periodic_sync,
|
||||
start_telemetry_collect,
|
||||
sync_and_offload_all,
|
||||
sync_radio_time,
|
||||
)
|
||||
@@ -204,35 +205,44 @@ async def run_post_connect_setup(radio_manager) -> None:
|
||||
finally:
|
||||
reader.handle_rx = _original_handle_rx
|
||||
|
||||
# Sync contacts/channels from radio to DB and clear radio
|
||||
logger.info("Syncing and offloading radio data...")
|
||||
result = await sync_and_offload_all(mc)
|
||||
logger.info("Sync complete: %s", result)
|
||||
from app.config import settings as app_settings_config
|
||||
|
||||
# Send advertisement to announce our presence (if enabled and not throttled)
|
||||
if await send_advertisement(mc):
|
||||
logger.info("Advertisement sent")
|
||||
if app_settings_config.skip_post_connect_sync:
|
||||
logger.info(
|
||||
"Skipping sync/offload/advert/drain (MESHCORE_SKIP_POST_CONNECT_SYNC)"
|
||||
)
|
||||
else:
|
||||
logger.debug("Advertisement skipped (disabled or throttled)")
|
||||
# Sync contacts/channels from radio to DB and clear radio
|
||||
logger.info("Syncing and offloading radio data...")
|
||||
result = await sync_and_offload_all(mc)
|
||||
logger.info("Sync complete: %s", result)
|
||||
|
||||
# Drain any messages that were queued before we connected.
|
||||
# This must happen BEFORE starting auto-fetch, otherwise both
|
||||
# compete on get_msg() with interleaved radio I/O.
|
||||
drained = await drain_pending_messages(mc)
|
||||
if drained > 0:
|
||||
logger.info("Drained %d pending message(s)", drained)
|
||||
radio_manager.clear_pending_message_channel_slots()
|
||||
# Send advertisement to announce our presence (if enabled and not throttled)
|
||||
if await send_advertisement(mc):
|
||||
logger.info("Advertisement sent")
|
||||
else:
|
||||
logger.debug("Advertisement skipped (disabled or throttled)")
|
||||
|
||||
# Drain any messages that were queued before we connected.
|
||||
# This must happen BEFORE starting auto-fetch, otherwise both
|
||||
# compete on get_msg() with interleaved radio I/O.
|
||||
drained = await drain_pending_messages(mc)
|
||||
if drained > 0:
|
||||
logger.info("Drained %d pending message(s)", drained)
|
||||
radio_manager.clear_pending_message_channel_slots()
|
||||
|
||||
await mc.start_auto_message_fetching()
|
||||
logger.info("Auto message fetching started")
|
||||
finally:
|
||||
radio_manager._release_operation_lock("post_connect_setup")
|
||||
|
||||
# Start background tasks AFTER releasing the operation lock.
|
||||
# These tasks acquire their own locks when they need radio access.
|
||||
start_periodic_sync()
|
||||
start_periodic_advert()
|
||||
start_message_polling()
|
||||
if not app_settings_config.skip_post_connect_sync:
|
||||
# Start background tasks AFTER releasing the operation lock.
|
||||
# These tasks acquire their own locks when they need radio access.
|
||||
start_periodic_sync()
|
||||
start_periodic_advert()
|
||||
start_message_polling()
|
||||
start_telemetry_collect()
|
||||
|
||||
radio_manager._setup_complete = True
|
||||
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`
|
||||
- `favorites`
|
||||
- `auto_decrypt_dm_on_advert`
|
||||
- `sidebar_sort_order`
|
||||
- `last_message_times`
|
||||
- `preferences_migrated`
|
||||
- `advert_interval`
|
||||
- `last_advert_time`
|
||||
- `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`.
|
||||
|
||||
@@ -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`.
|
||||
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)
|
||||
|
||||
- No authentication UI.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"version": "3.6.2",
|
||||
"version": "3.6.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"version": "3.6.2",
|
||||
"version": "3.6.3",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"private": true,
|
||||
"version": "3.6.7",
|
||||
"version": "3.8.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+50
-7
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useCallback, useRef, useState, useMemo } from 'react';
|
||||
import { useEffect, useCallback, useRef, useState, useMemo, type MouseEvent } from 'react';
|
||||
import { api } from './api';
|
||||
import { takePrefetchOrFetch } from './prefetch';
|
||||
import { useWebSocket } from './useWebSocket';
|
||||
@@ -23,7 +23,7 @@ import type { MessageInputHandle } from './components/MessageInput';
|
||||
import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
|
||||
import { messageContainsMention } from './utils/messageParser';
|
||||
import { getStateKey } from './utils/conversationState';
|
||||
import type { Conversation, Message, RawPacket } from './types';
|
||||
import type { BulkCreateHashtagChannelsResult, Conversation, Message, RawPacket } from './types';
|
||||
import { CONTACT_TYPE_ROOM } from './types';
|
||||
|
||||
interface ChannelUnreadMarker {
|
||||
@@ -85,6 +85,8 @@ export function App() {
|
||||
const [channelUnreadMarker, setChannelUnreadMarker] = useState<ChannelUnreadMarker | null>(null);
|
||||
const [newMessagePrefillRequest, setNewMessagePrefillRequest] =
|
||||
useState<NewMessagePrefillRequest | null>(null);
|
||||
const [showBulkAddChannelTab, setShowBulkAddChannelTab] = useState(false);
|
||||
const [bulkAddResult, setBulkAddResult] = useState<BulkCreateHashtagChannelsResult | null>(null);
|
||||
const [visibilityVersion, setVisibilityVersion] = useState(0);
|
||||
const lastUnreadBackfillAttemptRef = useRef<string | null>(null);
|
||||
const {
|
||||
@@ -154,6 +156,7 @@ export function App() {
|
||||
handleToggleFavorite,
|
||||
handleToggleBlockedKey,
|
||||
handleToggleBlockedName,
|
||||
handleToggleTrackedTelemetry,
|
||||
} = useAppSettings();
|
||||
|
||||
// Keep user's name in ref for mention detection in WebSocket callback
|
||||
@@ -190,6 +193,7 @@ export function App() {
|
||||
handleCreateContact,
|
||||
handleCreateChannel,
|
||||
handleCreateHashtagChannel,
|
||||
handleBulkCreateHashtagChannels,
|
||||
handleDeleteChannel,
|
||||
handleDeleteContact,
|
||||
} = useContactsAndChannels({
|
||||
@@ -394,6 +398,7 @@ export function App() {
|
||||
handleSendMessage,
|
||||
handleResendChannelMessage,
|
||||
handleSetChannelFloodScopeOverride,
|
||||
handleSetChannelPathHashModeOverride,
|
||||
handleSenderClick,
|
||||
handleTrace,
|
||||
handlePathDiscovery,
|
||||
@@ -421,16 +426,25 @@ export function App() {
|
||||
[fetchUndecryptedCount, setChannels]
|
||||
);
|
||||
|
||||
const handleOpenNewMessage = useCallback(() => {
|
||||
setNewMessagePrefillRequest(null);
|
||||
openNewMessageModal();
|
||||
}, [openNewMessageModal]);
|
||||
const handleOpenNewMessage = useCallback(
|
||||
(event?: MouseEvent<HTMLButtonElement>) => {
|
||||
setNewMessagePrefillRequest(null);
|
||||
setShowBulkAddChannelTab(event?.altKey === true);
|
||||
openNewMessageModal();
|
||||
},
|
||||
[openNewMessageModal]
|
||||
);
|
||||
|
||||
const handleCloseNewMessage = useCallback(() => {
|
||||
setNewMessagePrefillRequest(null);
|
||||
setShowBulkAddChannelTab(false);
|
||||
closeNewMessageModal();
|
||||
}, [closeNewMessageModal]);
|
||||
|
||||
const handleCloseBulkAddResults = useCallback(() => {
|
||||
setBulkAddResult(null);
|
||||
}, []);
|
||||
|
||||
const handleChannelReferenceClick = useCallback(
|
||||
(channelName: string) => {
|
||||
const existingChannel = channels.find((channel) => channel.name === channelName);
|
||||
@@ -444,11 +458,20 @@ export function App() {
|
||||
hashtagName: channelName.slice(1),
|
||||
nonce: (previous?.nonce ?? 0) + 1,
|
||||
}));
|
||||
setShowBulkAddChannelTab(false);
|
||||
openNewMessageModal();
|
||||
},
|
||||
[channels, handleNavigateToChannel, openNewMessageModal]
|
||||
);
|
||||
|
||||
const handleBulkAddChannels = useCallback(
|
||||
async (channelNames: string[], tryHistorical: boolean) => {
|
||||
const result = await handleBulkCreateHashtagChannels(channelNames, tryHistorical);
|
||||
setBulkAddResult(result);
|
||||
},
|
||||
[handleBulkCreateHashtagChannels]
|
||||
);
|
||||
|
||||
const statusProps = {
|
||||
health,
|
||||
config,
|
||||
@@ -469,8 +492,12 @@ export function App() {
|
||||
void markAllRead();
|
||||
},
|
||||
favorites,
|
||||
legacySortOrder: appSettings?.sidebar_sort_order,
|
||||
isConversationNotificationsEnabled,
|
||||
blockedKeys: appSettings?.blocked_keys ?? [],
|
||||
blockedNames: appSettings?.blocked_names ?? [],
|
||||
};
|
||||
const bulkAddChannelResultModalProps = {
|
||||
result: bulkAddResult,
|
||||
};
|
||||
const conversationPaneProps = {
|
||||
activeConversation,
|
||||
@@ -482,6 +509,7 @@ export function App() {
|
||||
health,
|
||||
favorites,
|
||||
messages: sortedMessages,
|
||||
preSorted: activeContactIsRoom,
|
||||
messagesLoading,
|
||||
loadingOlder,
|
||||
hasOlderMessages,
|
||||
@@ -501,6 +529,7 @@ export function App() {
|
||||
onDeleteContact: handleDeleteContact,
|
||||
onDeleteChannel: handleDeleteChannel,
|
||||
onSetChannelFloodScopeOverride: handleSetChannelFloodScopeOverride,
|
||||
onSetChannelPathHashModeOverride: handleSetChannelPathHashModeOverride,
|
||||
onOpenContactInfo: handleOpenContactInfo,
|
||||
onOpenChannelInfo: handleOpenChannelInfo,
|
||||
onSenderClick: handleSenderClick,
|
||||
@@ -527,6 +556,8 @@ export function App() {
|
||||
);
|
||||
}
|
||||
},
|
||||
trackedTelemetryRepeaters: appSettings?.tracked_telemetry_repeaters ?? [],
|
||||
onToggleTrackedTelemetry: handleToggleTrackedTelemetry,
|
||||
};
|
||||
const searchProps = {
|
||||
contacts,
|
||||
@@ -555,6 +586,13 @@ export function App() {
|
||||
blockedNames: appSettings?.blocked_names,
|
||||
onToggleBlockedKey: handleBlockKey,
|
||||
onToggleBlockedName: handleBlockName,
|
||||
contacts,
|
||||
onBulkDeleteContacts: (deletedKeys: string[]) => {
|
||||
const keySet = new Set(deletedKeys.map((k) => k.toLowerCase()));
|
||||
setContacts((prev) => prev.filter((c) => !keySet.has(c.public_key.toLowerCase())));
|
||||
},
|
||||
trackedTelemetryRepeaters: appSettings?.tracked_telemetry_repeaters ?? [],
|
||||
onToggleTrackedTelemetry: handleToggleTrackedTelemetry,
|
||||
};
|
||||
const crackerProps = {
|
||||
packets: rawPackets,
|
||||
@@ -563,10 +601,12 @@ export function App() {
|
||||
};
|
||||
const newMessageModalProps = {
|
||||
undecryptedCount,
|
||||
showBulkAddChannelTab,
|
||||
prefillRequest: newMessagePrefillRequest,
|
||||
onCreateContact: handleCreateContact,
|
||||
onCreateChannel: handleCreateChannel,
|
||||
onCreateHashtagChannel: handleCreateHashtagChannel,
|
||||
onBulkAddHashtagChannels: handleBulkAddChannels,
|
||||
};
|
||||
const contactInfoPaneProps = {
|
||||
contactKey: infoPaneContactKey,
|
||||
@@ -630,6 +670,7 @@ export function App() {
|
||||
<AppShell
|
||||
localLabel={localLabel}
|
||||
showNewMessage={showNewMessage}
|
||||
showBulkAddResults={bulkAddResult !== null}
|
||||
showSettings={showSettings}
|
||||
settingsSection={settingsSection}
|
||||
sidebarOpen={sidebarOpen}
|
||||
@@ -640,6 +681,7 @@ export function App() {
|
||||
onToggleSettingsView={handleToggleSettingsView}
|
||||
onCloseSettingsView={handleCloseSettingsView}
|
||||
onCloseNewMessage={handleCloseNewMessage}
|
||||
onCloseBulkAddResults={handleCloseBulkAddResults}
|
||||
onLocalLabelChange={setLocalLabel}
|
||||
statusProps={statusProps}
|
||||
sidebarProps={sidebarProps}
|
||||
@@ -648,6 +690,7 @@ export function App() {
|
||||
settingsProps={settingsProps}
|
||||
crackerProps={crackerProps}
|
||||
newMessageModalProps={newMessageModalProps}
|
||||
bulkAddChannelResultModalProps={bulkAddChannelResultModalProps}
|
||||
contactInfoPaneProps={contactInfoPaneProps}
|
||||
channelInfoPaneProps={channelInfoPaneProps}
|
||||
/>
|
||||
|
||||
+29
-9
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
AppSettings,
|
||||
AppSettingsUpdate,
|
||||
BulkCreateHashtagChannelsResult,
|
||||
Channel,
|
||||
ChannelDetail,
|
||||
CommandResponse,
|
||||
@@ -13,8 +14,6 @@ import type {
|
||||
MaintenanceResult,
|
||||
Message,
|
||||
MessagesAroundResponse,
|
||||
MigratePreferencesRequest,
|
||||
MigratePreferencesResponse,
|
||||
RawPacket,
|
||||
RadioAdvertMode,
|
||||
RadioConfig,
|
||||
@@ -34,6 +33,8 @@ import type {
|
||||
RepeaterOwnerInfoResponse,
|
||||
RepeaterRadioSettingsResponse,
|
||||
RepeaterStatusResponse,
|
||||
TelemetryHistoryEntry,
|
||||
TrackedTelemetryResponse,
|
||||
StatisticsResponse,
|
||||
TraceResponse,
|
||||
UnreadCounts,
|
||||
@@ -149,6 +150,12 @@ export const api = {
|
||||
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
bulkDeleteContacts: (publicKeys: string[]) =>
|
||||
fetchJson<{ deleted: number }>('/contacts/bulk-delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ public_keys: publicKeys }),
|
||||
}),
|
||||
createContact: (publicKey: string, name?: string, tryHistorical?: boolean) =>
|
||||
fetchJson<Contact>('/contacts', {
|
||||
method: 'POST',
|
||||
@@ -184,6 +191,11 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, key }),
|
||||
}),
|
||||
bulkCreateHashtagChannels: (channelNames: string[], tryHistorical?: boolean) =>
|
||||
fetchJson<BulkCreateHashtagChannelsResult>('/channels/bulk-hashtag', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ channel_names: channelNames, try_historical: tryHistorical }),
|
||||
}),
|
||||
deleteChannel: (key: string) =>
|
||||
fetchJson<{ status: string }>(`/channels/${key}`, { method: 'DELETE' }),
|
||||
getChannelDetail: (key: string) => fetchJson<ChannelDetail>(`/channels/${key}/detail`),
|
||||
@@ -197,6 +209,12 @@ export const api = {
|
||||
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
|
||||
getMessages: (
|
||||
params?: {
|
||||
@@ -308,6 +326,13 @@ export const api = {
|
||||
body: JSON.stringify({ name }),
|
||||
}),
|
||||
|
||||
// Tracked telemetry
|
||||
toggleTrackedTelemetry: (publicKey: string) =>
|
||||
fetchJson<TrackedTelemetryResponse>('/settings/tracked-telemetry/toggle', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ public_key: publicKey }),
|
||||
}),
|
||||
|
||||
// Favorites
|
||||
toggleFavorite: (type: Favorite['type'], id: string) =>
|
||||
fetchJson<AppSettings>('/settings/favorites/toggle', {
|
||||
@@ -315,13 +340,6 @@ export const api = {
|
||||
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
|
||||
getFanoutConfigs: () => fetchJson<FanoutConfig[]>('/fanout'),
|
||||
createFanoutConfig: (config: {
|
||||
@@ -402,6 +420,8 @@ export const api = {
|
||||
fetchJson<RepeaterLppTelemetryResponse>(`/contacts/${publicKey}/repeater/lpp-telemetry`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
repeaterTelemetryHistory: (publicKey: string) =>
|
||||
fetchJson<TelemetryHistoryEntry[]>(`/contacts/${publicKey}/repeater/telemetry-history`),
|
||||
roomLogin: (publicKey: string, password: string) =>
|
||||
fetchJson<RepeaterLoginResponse>(`/contacts/${publicKey}/room/login`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -5,6 +5,7 @@ import { StatusBar } from './StatusBar';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { ConversationPane } from './ConversationPane';
|
||||
import { NewMessageModal } from './NewMessageModal';
|
||||
import { BulkAddChannelResultModal } from './BulkAddChannelResultModal';
|
||||
import { ContactInfoPane } from './ContactInfoPane';
|
||||
import { ChannelInfoPane } from './ChannelInfoPane';
|
||||
import { SecurityWarningModal } from './SecurityWarningModal';
|
||||
@@ -33,12 +34,17 @@ const SearchView = lazy(() => import('./SearchView').then((m) => ({ default: m.S
|
||||
type SidebarProps = ComponentProps<typeof Sidebar>;
|
||||
type ConversationPaneProps = ComponentProps<typeof ConversationPane>;
|
||||
type NewMessageModalProps = Omit<ComponentProps<typeof NewMessageModal>, 'open' | 'onClose'>;
|
||||
type BulkAddChannelResultModalProps = Omit<
|
||||
ComponentProps<typeof BulkAddChannelResultModal>,
|
||||
'open' | 'onClose'
|
||||
>;
|
||||
type ContactInfoPaneProps = ComponentProps<typeof ContactInfoPane>;
|
||||
type ChannelInfoPaneProps = ComponentProps<typeof ChannelInfoPane>;
|
||||
|
||||
interface AppShellProps {
|
||||
localLabel: LocalLabel;
|
||||
showNewMessage: boolean;
|
||||
showBulkAddResults: boolean;
|
||||
showSettings: boolean;
|
||||
settingsSection: SettingsSection;
|
||||
sidebarOpen: boolean;
|
||||
@@ -50,6 +56,7 @@ interface AppShellProps {
|
||||
onToggleSettingsView: () => void;
|
||||
onCloseSettingsView: () => void;
|
||||
onCloseNewMessage: () => void;
|
||||
onCloseBulkAddResults: () => void;
|
||||
onLocalLabelChange: (label: LocalLabel) => void;
|
||||
statusProps: Pick<ComponentProps<typeof StatusBar>, 'health' | 'config'>;
|
||||
sidebarProps: SidebarProps;
|
||||
@@ -61,6 +68,7 @@ interface AppShellProps {
|
||||
>;
|
||||
crackerProps: Omit<CrackerPanelProps, 'visible' | 'onRunningChange'>;
|
||||
newMessageModalProps: NewMessageModalProps;
|
||||
bulkAddChannelResultModalProps: BulkAddChannelResultModalProps;
|
||||
contactInfoPaneProps: ContactInfoPaneProps;
|
||||
channelInfoPaneProps: ChannelInfoPaneProps;
|
||||
}
|
||||
@@ -68,6 +76,7 @@ interface AppShellProps {
|
||||
export function AppShell({
|
||||
localLabel,
|
||||
showNewMessage,
|
||||
showBulkAddResults,
|
||||
showSettings,
|
||||
settingsSection,
|
||||
sidebarOpen,
|
||||
@@ -79,6 +88,7 @@ export function AppShell({
|
||||
onToggleSettingsView,
|
||||
onCloseSettingsView,
|
||||
onCloseNewMessage,
|
||||
onCloseBulkAddResults,
|
||||
onLocalLabelChange,
|
||||
statusProps,
|
||||
sidebarProps,
|
||||
@@ -87,6 +97,7 @@ export function AppShell({
|
||||
settingsProps,
|
||||
crackerProps,
|
||||
newMessageModalProps,
|
||||
bulkAddChannelResultModalProps,
|
||||
contactInfoPaneProps,
|
||||
channelInfoPaneProps,
|
||||
}: AppShellProps) {
|
||||
@@ -124,7 +135,7 @@ export function AppShell({
|
||||
aria-label="Settings"
|
||||
>
|
||||
<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
|
||||
</h2>
|
||||
<button
|
||||
@@ -147,7 +158,7 @@ export function AppShell({
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
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',
|
||||
settingsSection === section && !disabled && 'bg-accent border-l-primary'
|
||||
)}
|
||||
@@ -306,6 +317,11 @@ export function AppShell({
|
||||
open={showNewMessage}
|
||||
onClose={onCloseNewMessage}
|
||||
/>
|
||||
<BulkAddChannelResultModal
|
||||
{...bulkAddChannelResultModalProps}
|
||||
open={showBulkAddResults}
|
||||
onClose={onCloseBulkAddResults}
|
||||
/>
|
||||
|
||||
<SecurityWarningModal health={statusProps.health} />
|
||||
<ContactInfoPane {...contactInfoPaneProps} />
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { BulkCreateHashtagChannelsResult, Channel } from '../types';
|
||||
import { getConversationHash } from '../utils/urlHash';
|
||||
import { Button } from './ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
|
||||
interface BulkAddChannelResultModalProps {
|
||||
open: boolean;
|
||||
result: BulkCreateHashtagChannelsResult | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function getChannelHref(channel: Channel): string {
|
||||
const hash = getConversationHash({
|
||||
type: 'channel',
|
||||
id: channel.key,
|
||||
name: channel.name,
|
||||
});
|
||||
if (typeof window === 'undefined') {
|
||||
return hash;
|
||||
}
|
||||
return `${window.location.origin}${window.location.pathname}${hash}`;
|
||||
}
|
||||
|
||||
export function BulkAddChannelResultModal({
|
||||
open,
|
||||
result,
|
||||
onClose,
|
||||
}: BulkAddChannelResultModalProps) {
|
||||
const createdChannels = result?.created_channels ?? [];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="sm:max-w-[520px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Bulk Add Complete</DialogTitle>
|
||||
<DialogDescription>
|
||||
{result?.message ?? 'Review the newly added rooms below.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{result && (
|
||||
<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="text-[0.625rem] uppercase tracking-wider font-medium text-muted-foreground">
|
||||
Created
|
||||
</div>
|
||||
<div className="mt-1 font-medium">{createdChannels.length}</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2">
|
||||
<div className="text-[0.625rem] uppercase tracking-wider font-medium text-muted-foreground">
|
||||
Already Present
|
||||
</div>
|
||||
<div className="mt-1 font-medium">{result.existing_count}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{createdChannels.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ctrl+click any room to open it in a new tab.
|
||||
</p>
|
||||
<div className="max-h-64 overflow-y-auto rounded-md border border-border/70">
|
||||
<ul className="divide-y divide-border/70">
|
||||
{createdChannels.map((channel) => (
|
||||
<li key={channel.key}>
|
||||
<a
|
||||
href={getChannelHref(channel)}
|
||||
className="block px-3 py-2 text-sm text-primary hover:bg-accent hover:text-primary"
|
||||
>
|
||||
{channel.name}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No new rooms were added.</p>
|
||||
)}
|
||||
|
||||
{result && result.invalid_names.length > 0 && (
|
||||
<div className="rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
|
||||
Ignored invalid room names: {result.invalid_names.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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 { api } from '../api';
|
||||
import { formatTime } from '../utils/messageParser';
|
||||
@@ -6,7 +7,7 @@ import { isFavorite } from '../utils/favorites';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
|
||||
import { toast } from './ui/sonner';
|
||||
import type { Channel, ChannelDetail, Favorite } from '../types';
|
||||
import type { Channel, ChannelDetail, Favorite, PathHashWidthStats } from '../types';
|
||||
|
||||
interface ChannelInfoPaneProps {
|
||||
channelKey: string | null;
|
||||
@@ -106,11 +107,11 @@ export function ChannelInfoPane({
|
||||
</span>
|
||||
)}
|
||||
<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'}
|
||||
</span>
|
||||
{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
|
||||
</span>
|
||||
)}
|
||||
@@ -179,6 +180,14 @@ export function ChannelInfoPane({
|
||||
</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 */}
|
||||
{detail && detail.top_senders_24h.length > 0 && (
|
||||
<div className="px-5 py-3">
|
||||
@@ -212,7 +221,7 @@ export function ChannelInfoPane({
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
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}
|
||||
</h3>
|
||||
);
|
||||
@@ -226,3 +235,82 @@ function InfoItem({ label, value }: { label: string; value: string }) {
|
||||
</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 { 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 { DirectTraceIcon } from './DirectTraceIcon';
|
||||
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
|
||||
import { ChannelFloodScopeOverrideModal } from './ChannelFloodScopeOverrideModal';
|
||||
import { ChannelPathHashModeOverrideModal } from './ChannelPathHashModeOverrideModal';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { isPublicChannelKey } from '../utils/publicChannel';
|
||||
@@ -36,6 +37,7 @@ interface ChatHeaderProps {
|
||||
onToggleNotifications: () => void;
|
||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
||||
onSetChannelFloodScopeOverride?: (key: string, floodScopeOverride: string) => void;
|
||||
onSetChannelPathHashModeOverride?: (key: string, pathHashModeOverride: number | null) => void;
|
||||
onDeleteChannel: (key: string) => void;
|
||||
onDeleteContact: (publicKey: string) => void;
|
||||
onOpenContactInfo?: (publicKey: string) => void;
|
||||
@@ -56,6 +58,7 @@ export function ChatHeader({
|
||||
onToggleNotifications,
|
||||
onToggleFavorite,
|
||||
onSetChannelFloodScopeOverride,
|
||||
onSetChannelPathHashModeOverride,
|
||||
onDeleteChannel,
|
||||
onDeleteContact,
|
||||
onOpenContactInfo,
|
||||
@@ -64,11 +67,13 @@ export function ChatHeader({
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
|
||||
const [channelOverrideOpen, setChannelOverrideOpen] = useState(false);
|
||||
const [pathHashModeOverrideOpen, setPathHashModeOverrideOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setShowKey(false);
|
||||
setPathDiscoveryOpen(false);
|
||||
setChannelOverrideOpen(false);
|
||||
setPathHashModeOverrideOpen(false);
|
||||
}, [conversation.id]);
|
||||
|
||||
const activeChannel =
|
||||
@@ -81,6 +86,12 @@ export function ChatHeader({
|
||||
? stripRegionScopePrefix(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 activeContact =
|
||||
conversation.type === 'contact'
|
||||
@@ -108,6 +119,11 @@ export function ChatHeader({
|
||||
setChannelOverrideOpen(true);
|
||||
};
|
||||
|
||||
const handleEditPathHashModeOverride = () => {
|
||||
if (conversation.type !== 'channel' || !onSetChannelPathHashModeOverride) return;
|
||||
setPathHashModeOverrideOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenConversationInfo = () => {
|
||||
if (conversation.type === 'contact' && onOpenContactInfo) {
|
||||
onOpenContactInfo(conversation.id);
|
||||
@@ -182,7 +198,7 @@ export function ChatHeader({
|
||||
</h2>
|
||||
{isPrivateChannel && !showKey ? (
|
||||
<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) => {
|
||||
e.stopPropagation();
|
||||
setShowKey(true);
|
||||
@@ -193,7 +209,7 @@ export function ChatHeader({
|
||||
</button>
|
||||
) : (
|
||||
<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"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
@@ -228,7 +244,7 @@ export function ChatHeader({
|
||||
className="h-3.5 w-3.5 flex-shrink-0 text-[hsl(var(--region-override))]"
|
||||
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}
|
||||
</span>
|
||||
</button>
|
||||
@@ -237,7 +253,7 @@ export function ChatHeader({
|
||||
</span>
|
||||
</span>
|
||||
{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
|
||||
contact={activeContact}
|
||||
ourLat={config?.lat ?? null}
|
||||
@@ -299,7 +315,7 @@ export function ChatHeader({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{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
|
||||
</span>
|
||||
)}
|
||||
@@ -317,12 +333,25 @@ export function ChatHeader({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
</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') && (
|
||||
<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"
|
||||
@@ -379,6 +408,16 @@ export function ChatHeader({
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
import { type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
import { Ban, Search, Star } from 'lucide-react';
|
||||
import {
|
||||
LineChart,
|
||||
@@ -35,6 +35,7 @@ import { ContactAvatar } from './ContactAvatar';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
|
||||
import { toast } from './ui/sonner';
|
||||
import { useDistanceUnit } from '../contexts/DistanceUnitContext';
|
||||
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||
import type {
|
||||
Contact,
|
||||
ContactActiveRoom,
|
||||
@@ -158,6 +159,7 @@ export function ContactInfoPane({
|
||||
contact !== null &&
|
||||
!isPrefixOnlyResolvedContact &&
|
||||
isUnknownFullKeyContact(contact.public_key, contact.last_advert);
|
||||
const isRepeater = contact?.type === CONTACT_TYPE_REPEATER;
|
||||
|
||||
return (
|
||||
<Sheet open={contactKey !== null} onOpenChange={(open) => !open && onClose()}>
|
||||
@@ -290,7 +292,7 @@ export function ContactInfoPane({
|
||||
{contact.public_key}
|
||||
</span>
|
||||
<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'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -440,7 +442,7 @@ export function ContactInfoPane({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onSearchMessagesByKey && (
|
||||
{!isRepeater && onSearchMessagesByKey && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<button
|
||||
type="button"
|
||||
@@ -453,40 +455,60 @@ export function ContactInfoPane({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Nearest Repeaters */}
|
||||
{analytics && analytics.nearest_repeaters.length > 0 && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Nearest Repeaters</SectionLabel>
|
||||
<div className="space-y-1">
|
||||
{analytics.nearest_repeaters.map((r) => (
|
||||
<div key={r.public_key} className="flex justify-between items-center text-sm">
|
||||
<span className="truncate">{r.name || r.public_key.slice(0, 12)}</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||
{r.path_len === 0
|
||||
? 'direct'
|
||||
: `${r.path_len} hop${r.path_len > 1 ? 's' : ''}`}{' '}
|
||||
· {r.heard_count}x
|
||||
</span>
|
||||
{/* Nearest Repeaters (Hops) — last 7 days only */}
|
||||
{analytics &&
|
||||
(() => {
|
||||
const sevenDaysAgo = Math.floor(Date.now() / 1000) - 7 * 86400;
|
||||
const recent = analytics.nearest_repeaters.filter(
|
||||
(r) => r.last_seen >= sevenDaysAgo
|
||||
);
|
||||
if (recent.length === 0) return null;
|
||||
return (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Nearest Repeaters — Hops (last 7 days)</SectionLabel>
|
||||
<div className="space-y-1">
|
||||
{recent.map((r) => (
|
||||
<div
|
||||
key={r.public_key}
|
||||
className="flex justify-between items-center text-sm"
|
||||
>
|
||||
<span className="truncate">{r.name || r.public_key.slice(0, 12)}</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||
{r.path_len === 0
|
||||
? 'direct'
|
||||
: `${r.path_len} hop${r.path_len > 1 ? 's' : ''}`}{' '}
|
||||
· {r.heard_count}x
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Geographically nearest repeaters (repeaters only) */}
|
||||
{isRepeater && contact && isValidLocation(contact.lat, contact.lon) && (
|
||||
<NearbyRepeatersSection
|
||||
contact={contact}
|
||||
contacts={contacts}
|
||||
distanceUnit={distanceUnit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Advert Paths */}
|
||||
{analytics && analytics.advert_paths.length > 0 && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Recent Advert Paths</SectionLabel>
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-1.5">
|
||||
{analytics.advert_paths.map((p) => (
|
||||
<div
|
||||
key={p.path + p.first_seen}
|
||||
className="flex justify-between items-center text-sm"
|
||||
className="flex justify-between items-start gap-2 text-sm"
|
||||
>
|
||||
<span className="font-mono text-xs truncate">
|
||||
<span className="font-mono text-xs break-all">
|
||||
{p.path ? parsePathHops(p.path, p.path_len).join(' → ') : '(direct)'}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0">
|
||||
{p.heard_count}x · {formatTime(p.last_seen)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -518,17 +540,21 @@ export function ContactInfoPane({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MessageStatsSection
|
||||
dmMessageCount={analytics?.dm_message_count ?? 0}
|
||||
channelMessageCount={analytics?.channel_message_count ?? 0}
|
||||
/>
|
||||
{!isRepeater && (
|
||||
<>
|
||||
<MessageStatsSection
|
||||
dmMessageCount={analytics?.dm_message_count ?? 0}
|
||||
channelMessageCount={analytics?.channel_message_count ?? 0}
|
||||
/>
|
||||
|
||||
<ActivityChartsSection analytics={analytics} />
|
||||
<ActivityChartsSection analytics={analytics} />
|
||||
|
||||
<MostActiveChannelsSection
|
||||
channels={analytics?.most_active_rooms ?? []}
|
||||
onNavigateToChannel={onNavigateToChannel}
|
||||
/>
|
||||
<MostActiveChannelsSection
|
||||
channels={analytics?.most_active_rooms ?? []}
|
||||
onNavigateToChannel={onNavigateToChannel}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
@@ -542,7 +568,7 @@ export function ContactInfoPane({
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
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}
|
||||
</h3>
|
||||
);
|
||||
@@ -703,7 +729,7 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu
|
||||
</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
|
||||
slots.
|
||||
{!analytics.includes_direct_messages &&
|
||||
@@ -795,7 +821,7 @@ function ActivityLineChart<T extends ContactAnalyticsHourlyBucket | ContactAnaly
|
||||
{legendItems && (
|
||||
<Legend
|
||||
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) => (
|
||||
<span key={item.label} className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
@@ -826,6 +852,60 @@ function ActivityLineChart<T extends ContactAnalyticsHourlyBucket | ContactAnaly
|
||||
);
|
||||
}
|
||||
|
||||
function NearbyRepeatersSection({
|
||||
contact,
|
||||
contacts,
|
||||
distanceUnit,
|
||||
}: {
|
||||
contact: Contact;
|
||||
contacts: Contact[];
|
||||
distanceUnit: import('../utils/distanceUnits').DistanceUnit;
|
||||
}) {
|
||||
const nearby = useMemo(() => {
|
||||
const sevenDaysAgo = Math.floor(Date.now() / 1000) - 7 * 86400;
|
||||
const results: Array<{ name: string; publicKey: string; distance: number }> = [];
|
||||
for (const other of contacts) {
|
||||
const heardAt = Math.max(other.last_seen ?? 0, other.last_advert ?? 0);
|
||||
if (
|
||||
other.public_key === contact.public_key ||
|
||||
other.type !== CONTACT_TYPE_REPEATER ||
|
||||
!isValidLocation(other.lat, other.lon) ||
|
||||
heardAt < sevenDaysAgo
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const dist = calculateDistance(contact.lat, contact.lon, other.lat, other.lon);
|
||||
if (dist !== null) {
|
||||
results.push({
|
||||
name: getContactDisplayName(other.name, other.public_key, other.last_advert),
|
||||
publicKey: other.public_key,
|
||||
distance: dist,
|
||||
});
|
||||
}
|
||||
}
|
||||
results.sort((a, b) => a.distance - b.distance);
|
||||
return results.slice(0, 5);
|
||||
}, [contact.public_key, contact.lat, contact.lon, contacts]);
|
||||
|
||||
if (nearby.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Nearest Repeaters — Geo (last 7 days)</SectionLabel>
|
||||
<div className="space-y-1">
|
||||
{nearby.map((r) => (
|
||||
<div key={r.publicKey} className="flex justify-between items-center text-sm">
|
||||
<span className="truncate">{r.name}</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||
{formatDistance(r.distance, distanceUnit)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoItem({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -74,12 +74,12 @@ function RouteCard({
|
||||
<div className="rounded-md border border-border bg-muted/20 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<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)}
|
||||
</span>
|
||||
</div>
|
||||
<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>{formatPathHashMode(route.path_hash_mode)}</span>
|
||||
</div>
|
||||
|
||||
@@ -44,6 +44,7 @@ interface ConversationPaneProps {
|
||||
notificationsPermission: NotificationPermission | 'unsupported';
|
||||
favorites: Favorite[];
|
||||
messages: Message[];
|
||||
preSorted?: boolean;
|
||||
messagesLoading: boolean;
|
||||
loadingOlder: boolean;
|
||||
hasOlderMessages: boolean;
|
||||
@@ -62,6 +63,10 @@ interface ConversationPaneProps {
|
||||
onDeleteContact: (publicKey: string) => Promise<void>;
|
||||
onDeleteChannel: (key: string) => Promise<void>;
|
||||
onSetChannelFloodScopeOverride: (channelKey: string, floodScopeOverride: string) => Promise<void>;
|
||||
onSetChannelPathHashModeOverride?: (
|
||||
channelKey: string,
|
||||
pathHashModeOverride: number | null
|
||||
) => Promise<void>;
|
||||
onOpenContactInfo: (publicKey: string, fromChannel?: boolean) => void;
|
||||
onOpenChannelInfo: (channelKey: string) => void;
|
||||
onSenderClick: (sender: string) => void;
|
||||
@@ -74,6 +79,8 @@ interface ConversationPaneProps {
|
||||
onDismissUnreadMarker: () => void;
|
||||
onSendMessage: (text: string) => Promise<void>;
|
||||
onToggleNotifications: () => void;
|
||||
trackedTelemetryRepeaters: string[];
|
||||
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function LoadingPane({ label }: { label: string }) {
|
||||
@@ -114,6 +121,7 @@ export function ConversationPane({
|
||||
notificationsPermission,
|
||||
favorites,
|
||||
messages,
|
||||
preSorted,
|
||||
messagesLoading,
|
||||
loadingOlder,
|
||||
hasOlderMessages,
|
||||
@@ -129,6 +137,7 @@ export function ConversationPane({
|
||||
onDeleteContact,
|
||||
onDeleteChannel,
|
||||
onSetChannelFloodScopeOverride,
|
||||
onSetChannelPathHashModeOverride,
|
||||
onOpenContactInfo,
|
||||
onOpenChannelInfo,
|
||||
onSenderClick,
|
||||
@@ -141,6 +150,8 @@ export function ConversationPane({
|
||||
onDismissUnreadMarker,
|
||||
onSendMessage,
|
||||
onToggleNotifications,
|
||||
trackedTelemetryRepeaters,
|
||||
onToggleTrackedTelemetry,
|
||||
}: ConversationPaneProps) {
|
||||
const [roomAuthenticated, setRoomAuthenticated] = useState(false);
|
||||
const activeContactIsRepeater = useMemo(() => {
|
||||
@@ -180,7 +191,12 @@ export function ConversationPane({
|
||||
</h2>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Suspense fallback={<LoadingPane label="Loading map..." />}>
|
||||
<MapView contacts={contacts} focusedKey={activeConversation.mapFocusKey} />
|
||||
<MapView
|
||||
contacts={contacts}
|
||||
focusedKey={activeConversation.mapFocusKey}
|
||||
rawPackets={rawPackets}
|
||||
config={config}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</>
|
||||
@@ -233,6 +249,9 @@ export function ConversationPane({
|
||||
onToggleNotifications={onToggleNotifications}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onDeleteContact={onDeleteContact}
|
||||
onOpenContactInfo={onOpenContactInfo}
|
||||
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
|
||||
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
@@ -256,6 +275,7 @@ export function ConversationPane({
|
||||
onToggleNotifications={onToggleNotifications}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onSetChannelFloodScopeOverride={onSetChannelFloodScopeOverride}
|
||||
onSetChannelPathHashModeOverride={onSetChannelPathHashModeOverride}
|
||||
onDeleteChannel={onDeleteChannel}
|
||||
onDeleteContact={onDeleteContact}
|
||||
onOpenContactInfo={onOpenContactInfo}
|
||||
@@ -274,6 +294,7 @@ export function ConversationPane({
|
||||
<MessageList
|
||||
key={activeConversation.id}
|
||||
messages={messages}
|
||||
preSorted={preSorted}
|
||||
contacts={contacts}
|
||||
channels={channels}
|
||||
loading={messagesLoading}
|
||||
|
||||
@@ -1,16 +1,47 @@
|
||||
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 L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import type { Contact } from '../types';
|
||||
import type { Contact, RadioConfig, RawPacket } from '../types';
|
||||
import { formatTime } from '../utils/messageParser';
|
||||
import { isValidLocation } from '../utils/pathUtils';
|
||||
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||
import {
|
||||
parsePacket,
|
||||
getPacketLabel,
|
||||
PARTICLE_COLOR_MAP,
|
||||
dedupeConsecutive,
|
||||
} from '../utils/visualizerUtils';
|
||||
import { getRawPacketObservationKey } from '../utils/rawPacketIdentity';
|
||||
|
||||
interface MapViewProps {
|
||||
contacts: Contact[];
|
||||
/** Public key of contact to focus on and open popup */
|
||||
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 = {
|
||||
@@ -22,7 +53,16 @@ const MAP_RECENCY_COLORS = {
|
||||
const MAP_MARKER_STROKE = '#0f172a';
|
||||
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 {
|
||||
if (lastSeen == null) return MAP_RECENCY_COLORS.old;
|
||||
const now = Date.now() / 1000;
|
||||
@@ -36,7 +76,85 @@ function getMarkerColor(lastSeen: number | null | undefined): string {
|
||||
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({
|
||||
contacts,
|
||||
focusedContact,
|
||||
@@ -48,7 +166,6 @@ function MapBoundsHandler({
|
||||
const [hasInitialized, setHasInitialized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// If we have a focused contact, center on it immediately (even if already initialized)
|
||||
if (focusedContact && focusedContact.lat != null && focusedContact.lon != null) {
|
||||
map.setView([focusedContact.lat, focusedContact.lon], 12);
|
||||
setHasInitialized(true);
|
||||
@@ -59,20 +176,17 @@ function MapBoundsHandler({
|
||||
|
||||
const fitToContacts = () => {
|
||||
if (contacts.length === 0) {
|
||||
// No contacts with location - show world view
|
||||
map.setView([20, 0], 2);
|
||||
setHasInitialized(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (contacts.length === 1) {
|
||||
// Single contact - center on it
|
||||
map.setView([contacts[0].lat!, contacts[0].lon!], 10);
|
||||
setHasInitialized(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Multiple contacts - fit bounds
|
||||
const bounds: LatLngBoundsExpression = contacts.map(
|
||||
(c) => [c.lat!, c.lon!] as [number, number]
|
||||
);
|
||||
@@ -80,22 +194,18 @@ function MapBoundsHandler({
|
||||
setHasInitialized(true);
|
||||
};
|
||||
|
||||
// Try geolocation first
|
||||
if ('geolocation' in navigator) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
// Success - center on user location with reasonable zoom
|
||||
map.setView([position.coords.latitude, position.coords.longitude], 8);
|
||||
setHasInitialized(true);
|
||||
},
|
||||
() => {
|
||||
// Geolocation denied/failed - fit to contacts
|
||||
fitToContacts();
|
||||
},
|
||||
{ timeout: 5000, maximumAge: 300000 }
|
||||
);
|
||||
} else {
|
||||
// No geolocation support - fit to contacts
|
||||
fitToContacts();
|
||||
}
|
||||
}, [map, contacts, hasInitialized, focusedContact]);
|
||||
@@ -103,18 +213,404 @@ function MapBoundsHandler({
|
||||
return null;
|
||||
}
|
||||
|
||||
export function MapView({ contacts, focusedKey }: MapViewProps) {
|
||||
const [sevenDaysAgo] = useState(() => Date.now() / 1000 - 7 * 24 * 60 * 60);
|
||||
// --- Canvas particle overlay ---
|
||||
|
||||
// Filter to contacts with GPS coordinates, heard within the last 7 days.
|
||||
// Always include the focused contact so "view on map" links work for older nodes.
|
||||
function ParticleOverlay({ particles }: { particles: MapParticle[] }) {
|
||||
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(() => {
|
||||
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(
|
||||
(c) =>
|
||||
isValidLocation(c.lat, c.lon) &&
|
||||
(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
|
||||
const focusedContact = useMemo(() => {
|
||||
@@ -124,18 +620,17 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
||||
|
||||
const includesFocusedOutsideWindow =
|
||||
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
|
||||
const markerRefs = useRef<Record<string, LeafletCircleMarker | null>>({});
|
||||
|
||||
// Store ref for a marker
|
||||
const setMarkerRef = useCallback((key: string, ref: LeafletCircleMarker | null) => {
|
||||
if (ref === null) {
|
||||
delete markerRefs.current[key];
|
||||
return;
|
||||
}
|
||||
|
||||
markerRefs.current[key] = ref;
|
||||
}, []);
|
||||
|
||||
@@ -148,10 +643,8 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
||||
}
|
||||
}, [mappableContacts]);
|
||||
|
||||
// Open popup for focused contact after map is ready
|
||||
useEffect(() => {
|
||||
if (focusedContact && markerRefs.current[focusedContact.public_key]) {
|
||||
// Small delay to ensure map has finished rendering
|
||||
const timer = setTimeout(() => {
|
||||
markerRefs.current[focusedContact.public_key]?.openPopup();
|
||||
}, 100);
|
||||
@@ -159,48 +652,104 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
||||
}
|
||||
}, [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 (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Info bar */}
|
||||
<div className="px-4 py-2 bg-muted/50 text-xs text-muted-foreground flex items-center justify-between">
|
||||
<span>
|
||||
Showing {mappableContacts.length} contact{mappableContacts.length !== 1 ? 's' : ''} heard
|
||||
in the last 7 days
|
||||
{includesFocusedOutsideWindow ? ' plus the focused contact' : ''}
|
||||
</span>
|
||||
<span>{infoLabel}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: MAP_RECENCY_COLORS.recent }}
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
<1h
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: MAP_RECENCY_COLORS.today }}
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
<1d
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: MAP_RECENCY_COLORS.stale }}
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
<3d
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: MAP_RECENCY_COLORS.old }}
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
older
|
||||
</span>
|
||||
{!showPackets && (
|
||||
<>
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: MAP_RECENCY_COLORS.recent }}
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
<1h
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: MAP_RECENCY_COLORS.today }}
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
<1d
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: MAP_RECENCY_COLORS.stale }}
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
<3d
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: MAP_RECENCY_COLORS.old }}
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
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="w-3 h-3 rounded-full border-2"
|
||||
@@ -209,10 +758,30 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
||||
/>{' '}
|
||||
repeater
|
||||
</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>
|
||||
|
||||
{/* Map - z-index constrained to stay below modals/sheets */}
|
||||
{/* Map */}
|
||||
<div
|
||||
className="flex-1 relative"
|
||||
style={{ zIndex: 0 }}
|
||||
@@ -223,14 +792,21 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
||||
center={[20, 0]}
|
||||
zoom={2}
|
||||
className="h-full w-full"
|
||||
style={{ background: '#1a1a2e' }}
|
||||
style={{ background: tile.background }}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<TileLayer key={tile.url} attribution={tile.attribution} url={tile.url} />
|
||||
<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) => {
|
||||
const isRepeater = contact.type === CONTACT_TYPE_REPEATER;
|
||||
const color = getMarkerColor(contact.last_seen);
|
||||
@@ -275,6 +851,8 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
{showPackets && <ParticleOverlay particles={particles} />}
|
||||
</MapContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,6 +47,7 @@ interface MessageListProps {
|
||||
loadingNewer?: boolean;
|
||||
onLoadNewer?: () => void;
|
||||
onJumpToBottom?: () => void;
|
||||
preSorted?: boolean;
|
||||
}
|
||||
|
||||
// URL regex for linkifying plain text
|
||||
@@ -219,8 +220,8 @@ function HopCountBadge({ paths, onClick, variant }: HopCountBadgeProps) {
|
||||
|
||||
const className =
|
||||
variant === 'header'
|
||||
? 'font-normal text-muted-foreground ml-1 text-[11px] cursor-pointer hover:text-primary hover:underline'
|
||||
: 'text-[10px] text-muted-foreground ml-1 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-[0.625rem] text-muted-foreground ml-1 cursor-pointer hover:text-primary hover:underline';
|
||||
|
||||
return (
|
||||
<span
|
||||
@@ -283,6 +284,7 @@ export function MessageList({
|
||||
loadingNewer = false,
|
||||
onLoadNewer,
|
||||
onJumpToBottom,
|
||||
preSorted = false,
|
||||
}: MessageListProps) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const prevMessagesLengthRef = useRef<number>(0);
|
||||
@@ -298,6 +300,9 @@ export function MessageList({
|
||||
const [resendableIds, setResendableIds] = useState<Set<number>>(new Set());
|
||||
const resendTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(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<
|
||||
| { kind: 'packet'; packet: RawPacket }
|
||||
| { kind: 'loading'; message: string }
|
||||
@@ -323,6 +328,13 @@ export function MessageList({
|
||||
const prevConvKeyRef = useRef<string | null>(null);
|
||||
|
||||
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) {
|
||||
setPacketInspectorSource({
|
||||
kind: 'unavailable',
|
||||
@@ -486,8 +498,11 @@ export function MessageList({
|
||||
// Note: Deduplication is handled by useConversationMessages.observeMessage()
|
||||
// and the database UNIQUE constraint on (type, conversation_key, text, sender_timestamp)
|
||||
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(() => {
|
||||
if (unreadMarkerLastReadAt === undefined) {
|
||||
@@ -960,7 +975,7 @@ export function MessageList({
|
||||
)}
|
||||
>
|
||||
{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 ? (
|
||||
<span
|
||||
className="cursor-pointer hover:text-primary transition-colors"
|
||||
@@ -975,7 +990,7 @@ export function MessageList({
|
||||
) : (
|
||||
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)}
|
||||
</span>
|
||||
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
|
||||
@@ -1003,7 +1018,7 @@ export function MessageList({
|
||||
))}
|
||||
{!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)}
|
||||
</span>
|
||||
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
|
||||
@@ -1175,12 +1190,18 @@ export function MessageList({
|
||||
{packetInspectorSource && (
|
||||
<RawPacketInspectorDialog
|
||||
open={packetInspectorSource !== null}
|
||||
onOpenChange={(isOpen) => !isOpen && setPacketInspectorSource(null)}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
setPacketInspectorSource(null);
|
||||
packetSignalOverrideRef.current = undefined;
|
||||
}
|
||||
}}
|
||||
channels={channels}
|
||||
source={packetInspectorSource}
|
||||
title="Analyze Packet"
|
||||
description="On-demand raw packet analysis for a message-backed archival packet."
|
||||
notice={ANALYZE_PACKET_NOTICE}
|
||||
signalOverride={packetSignalOverrideRef.current}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,23 +3,29 @@ import { Dice5 } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Checkbox } from './ui/checkbox';
|
||||
import { Button } from './ui/button';
|
||||
import { toast } from './ui/sonner';
|
||||
|
||||
type Tab = 'new-contact' | 'new-channel' | 'hashtag';
|
||||
type Tab = 'new-contact' | 'new-channel' | 'hashtag' | 'bulk-hashtag';
|
||||
|
||||
interface BulkParseResult {
|
||||
channelNames: string[];
|
||||
invalidNames: string[];
|
||||
}
|
||||
|
||||
interface NewMessageModalProps {
|
||||
open: boolean;
|
||||
undecryptedCount: number;
|
||||
showBulkAddChannelTab?: boolean;
|
||||
prefillRequest?: {
|
||||
tab: 'hashtag';
|
||||
hashtagName: string;
|
||||
@@ -29,53 +35,121 @@ interface NewMessageModalProps {
|
||||
onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>;
|
||||
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
|
||||
onCreateHashtagChannel: (name: string, tryHistorical: boolean) => Promise<void>;
|
||||
onBulkAddHashtagChannels: (channelNames: string[], tryHistorical: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
function validateHashtagName(channelName: string): string | null {
|
||||
if (!channelName) {
|
||||
return 'Channel name is required';
|
||||
}
|
||||
if (!/^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$/.test(channelName)) {
|
||||
return 'Use letters, numbers, and single dashes (no leading/trailing dashes)';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseBulkHashtagNames(rawText: string, permitCapitals: boolean): BulkParseResult {
|
||||
const tokens = rawText
|
||||
.split(/[\s,]+/)
|
||||
.map((token) => token.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const invalidNames: string[] = [];
|
||||
const channelNames: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const token of tokens) {
|
||||
const stripped = token.replace(/^#+/, '');
|
||||
const validationError = validateHashtagName(stripped);
|
||||
if (validationError) {
|
||||
invalidNames.push(token);
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalized = permitCapitals ? stripped : stripped.toLowerCase();
|
||||
const channelName = `#${normalized}`;
|
||||
if (seen.has(channelName)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(channelName);
|
||||
channelNames.push(channelName);
|
||||
}
|
||||
|
||||
return { channelNames, invalidNames };
|
||||
}
|
||||
|
||||
export function NewMessageModal({
|
||||
open,
|
||||
undecryptedCount,
|
||||
showBulkAddChannelTab = false,
|
||||
prefillRequest = null,
|
||||
onClose,
|
||||
onCreateContact,
|
||||
onCreateChannel,
|
||||
onCreateHashtagChannel,
|
||||
onBulkAddHashtagChannels,
|
||||
}: NewMessageModalProps) {
|
||||
const [tab, setTab] = useState<Tab>('new-contact');
|
||||
const [name, setName] = useState('');
|
||||
const [contactKey, setContactKey] = useState('');
|
||||
const [channelKey, setChannelKey] = useState('');
|
||||
const [bulkChannelText, setBulkChannelText] = useState('');
|
||||
const [tryHistorical, setTryHistorical] = useState(false);
|
||||
const [permitCapitals, setPermitCapitals] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const hashtagInputRef = useRef<HTMLInputElement>(null);
|
||||
const bulkTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const resetForm = () => {
|
||||
setName('');
|
||||
setContactKey('');
|
||||
setChannelKey('');
|
||||
setBulkChannelText('');
|
||||
setTryHistorical(false);
|
||||
setPermitCapitals(false);
|
||||
setError('');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !prefillRequest) {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTab(prefillRequest.tab);
|
||||
setName(prefillRequest.hashtagName);
|
||||
setContactKey('');
|
||||
setChannelKey('');
|
||||
setTryHistorical(false);
|
||||
setPermitCapitals(false);
|
||||
setError('');
|
||||
setLoading(false);
|
||||
requestAnimationFrame(() => {
|
||||
hashtagInputRef.current?.focus();
|
||||
});
|
||||
}, [open, prefillRequest]);
|
||||
if (prefillRequest) {
|
||||
setTab(prefillRequest.tab);
|
||||
setName(prefillRequest.hashtagName);
|
||||
setContactKey('');
|
||||
setChannelKey('');
|
||||
setBulkChannelText('');
|
||||
setTryHistorical(false);
|
||||
setPermitCapitals(false);
|
||||
setError('');
|
||||
setLoading(false);
|
||||
requestAnimationFrame(() => {
|
||||
hashtagInputRef.current?.focus();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (showBulkAddChannelTab) {
|
||||
setTab('bulk-hashtag');
|
||||
setName('');
|
||||
setContactKey('');
|
||||
setChannelKey('');
|
||||
setBulkChannelText('');
|
||||
setTryHistorical(false);
|
||||
setPermitCapitals(false);
|
||||
setError('');
|
||||
setLoading(false);
|
||||
requestAnimationFrame(() => {
|
||||
bulkTextareaRef.current?.focus();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setTab('new-contact');
|
||||
}, [open, prefillRequest, showBulkAddChannelTab]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
setError('');
|
||||
@@ -87,7 +161,6 @@ export function NewMessageModal({
|
||||
setError('Name and public key are required');
|
||||
return;
|
||||
}
|
||||
// handleCreateContact sets activeConversation with the backend-normalized key
|
||||
await onCreateContact(name.trim(), contactKey.trim(), tryHistorical);
|
||||
} else if (tab === 'new-channel') {
|
||||
if (!name.trim() || !channelKey.trim()) {
|
||||
@@ -102,10 +175,24 @@ export function NewMessageModal({
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
// Normalize to lowercase unless user explicitly permits capitals
|
||||
const normalizedName = permitCapitals ? channelName : channelName.toLowerCase();
|
||||
await onCreateHashtagChannel(`#${normalizedName}`, tryHistorical);
|
||||
} else {
|
||||
const { channelNames, invalidNames } = parseBulkHashtagNames(
|
||||
bulkChannelText,
|
||||
permitCapitals
|
||||
);
|
||||
if (channelNames.length === 0) {
|
||||
setError('Enter at least one valid room name');
|
||||
return;
|
||||
}
|
||||
if (invalidNames.length > 0) {
|
||||
setError(`Invalid room names: ${invalidNames.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
await onBulkAddHashtagChannels(channelNames, tryHistorical);
|
||||
}
|
||||
|
||||
resetForm();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
@@ -118,16 +205,6 @@ export function NewMessageModal({
|
||||
}
|
||||
};
|
||||
|
||||
const validateHashtagName = (channelName: string): string | null => {
|
||||
if (!channelName) {
|
||||
return 'Channel name is required';
|
||||
}
|
||||
if (!/^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$/.test(channelName)) {
|
||||
return 'Use letters, numbers, and single dashes (no leading/trailing dashes)';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleCreateAndAddAnother = async () => {
|
||||
setError('');
|
||||
const channelName = name.trim();
|
||||
@@ -139,7 +216,6 @@ export function NewMessageModal({
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Normalize to lowercase unless user explicitly permits capitals
|
||||
const normalizedName = permitCapitals ? channelName : channelName.toLowerCase();
|
||||
await onCreateHashtagChannel(`#${normalizedName}`, tryHistorical);
|
||||
setName('');
|
||||
@@ -166,28 +242,36 @@ export function NewMessageModal({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogContent className="sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Conversation</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{tab === 'new-contact' && 'Add a new contact by entering their name and public key'}
|
||||
{tab === 'new-channel' && 'Create a private channel with a shared encryption key'}
|
||||
{tab === 'hashtag' && 'Join a public hashtag channel'}
|
||||
{tab === 'bulk-hashtag' && 'Paste multiple hashtag rooms to add them in one batch'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(v) => {
|
||||
setTab(v as Tab);
|
||||
onValueChange={(value) => {
|
||||
setTab(value as Tab);
|
||||
resetForm();
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsList
|
||||
className={
|
||||
showBulkAddChannelTab ? 'grid w-full grid-cols-4' : 'grid w-full grid-cols-3'
|
||||
}
|
||||
>
|
||||
<TabsTrigger value="new-contact">Contact</TabsTrigger>
|
||||
<TabsTrigger value="new-channel">Private Channel</TabsTrigger>
|
||||
<TabsTrigger value="hashtag">Hashtag Channel</TabsTrigger>
|
||||
{showBulkAddChannelTab && (
|
||||
<TabsTrigger value="bulk-hashtag">Bulk Add Channel</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="new-contact" className="mt-4 space-y-4">
|
||||
@@ -239,7 +323,7 @@ export function NewMessageModal({
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
const hex = Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
setChannelKey(hex);
|
||||
}}
|
||||
@@ -268,20 +352,55 @@ export function NewMessageModal({
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 space-y-1">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<label className="flex cursor-pointer items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={permitCapitals}
|
||||
onChange={(e) => setPermitCapitals(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-input accent-primary"
|
||||
className="h-4 w-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">Permit capitals in channel key derivation</span>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground pl-7">
|
||||
<p className="pl-7 text-xs text-muted-foreground">
|
||||
Not recommended; most companions normalize to lowercase
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{showBulkAddChannelTab && (
|
||||
<TabsContent value="bulk-hashtag" className="mt-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bulk-hashtag-names">Bulk Add Channel</Label>
|
||||
<textarea
|
||||
ref={bulkTextareaRef}
|
||||
id="bulk-hashtag-names"
|
||||
aria-label="Bulk channel names"
|
||||
value={bulkChannelText}
|
||||
onChange={(e) => setBulkChannelText(e.target.value)}
|
||||
placeholder={'#ops\nmesh-room\nanother-room'}
|
||||
className="min-h-48 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Paste room names separated by lines, spaces, or commas. Leading # marks are
|
||||
stripped automatically.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="flex cursor-pointer items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={permitCapitals}
|
||||
onChange={(e) => setPermitCapitals(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">Permit capitals in channel key derivation</span>
|
||||
</label>
|
||||
<p className="pl-7 text-xs text-muted-foreground">
|
||||
Not recommended; most companions normalize to lowercase
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
{showHistoricalOption && (
|
||||
@@ -289,7 +408,7 @@ export function NewMessageModal({
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<Label
|
||||
htmlFor="try-historical"
|
||||
className="text-sm text-muted-foreground cursor-pointer"
|
||||
className="cursor-pointer text-sm text-muted-foreground"
|
||||
>
|
||||
Try decrypting {undecryptedCount.toLocaleString()} stored packet
|
||||
{undecryptedCount !== 1 ? 's' : ''}
|
||||
@@ -301,7 +420,7 @@ export function NewMessageModal({
|
||||
/>
|
||||
</div>
|
||||
{tryHistorical && (
|
||||
<p className="text-xs text-muted-foreground text-right">
|
||||
<p className="text-right text-xs text-muted-foreground">
|
||||
Messages will stream in as they decrypt in the background
|
||||
</p>
|
||||
)}
|
||||
@@ -330,7 +449,13 @@ export function NewMessageModal({
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleCreate} disabled={loading}>
|
||||
{loading ? 'Creating...' : 'Create'}
|
||||
{loading
|
||||
? tab === 'bulk-hashtag'
|
||||
? 'Adding...'
|
||||
: 'Creating...'
|
||||
: tab === 'bulk-hashtag'
|
||||
? 'Add Channels'
|
||||
: 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -103,14 +103,25 @@ export function PathModal({
|
||||
) : null}
|
||||
|
||||
{/* Raw path summary */}
|
||||
<div className="text-sm">
|
||||
<div className="text-sm space-y-1">
|
||||
{paths.map((p, index) => {
|
||||
const hops = parsePathHops(p.path, p.path_len);
|
||||
const rawPath = hops.length > 0 ? hops.join('->') : 'direct';
|
||||
const hasSignal = p.rssi != null || p.snr != null;
|
||||
return (
|
||||
<div key={index}>
|
||||
<span className="text-foreground/70 font-semibold">Path {index + 1}:</span>{' '}
|
||||
<span className="font-mono text-muted-foreground">{rawPath}</span>
|
||||
<div>
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
@@ -221,7 +232,7 @@ export function PathModal({
|
||||
>
|
||||
<span className="flex flex-col items-center leading-tight">
|
||||
<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
|
||||
</span>
|
||||
</span>
|
||||
@@ -237,7 +248,7 @@ export function PathModal({
|
||||
>
|
||||
<span className="flex flex-col items-center leading-tight">
|
||||
<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
|
||||
</span>
|
||||
</span>
|
||||
@@ -406,9 +417,12 @@ interface HopNodeProps {
|
||||
distanceUnit: DistanceUnit;
|
||||
}
|
||||
|
||||
const AMBIGUOUS_MATCH_PREVIEW_LIMIT = 3;
|
||||
|
||||
function HopNode({ hop, hopNumber, prevLocation, distanceUnit }: HopNodeProps) {
|
||||
const isAmbiguous = hop.matches.length > 1;
|
||||
const isUnknown = hop.matches.length === 0;
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// Calculate distance from previous location for a contact
|
||||
// Returns null if prev location unknown/ambiguous or contact has no valid location
|
||||
@@ -447,27 +461,38 @@ function HopNode({ hop, hopNumber, prevLocation, distanceUnit }: HopNodeProps) {
|
||||
<div className="font-medium text-muted-foreground"><UNKNOWN></div>
|
||||
) : isAmbiguous ? (
|
||||
<div>
|
||||
{hop.matches.map((contact) => {
|
||||
const dist = getDistanceForContact(contact);
|
||||
const hasLocation = isValidLocation(contact.lat, contact.lon);
|
||||
return (
|
||||
<div key={contact.public_key} className="font-medium truncate">
|
||||
{contact.name || contact.public_key.slice(0, 12)}
|
||||
{dist !== null && (
|
||||
<span className="text-xs text-muted-foreground ml-1">
|
||||
- {formatDistance(dist, distanceUnit)}
|
||||
</span>
|
||||
)}
|
||||
{hasLocation && (
|
||||
<CoordinateLink
|
||||
lat={contact.lat!}
|
||||
lon={contact.lon!}
|
||||
publicKey={contact.public_key}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(expanded ? hop.matches : hop.matches.slice(0, AMBIGUOUS_MATCH_PREVIEW_LIMIT)).map(
|
||||
(contact) => {
|
||||
const dist = getDistanceForContact(contact);
|
||||
const hasLocation = isValidLocation(contact.lat, contact.lon);
|
||||
return (
|
||||
<div key={contact.public_key} className="font-medium truncate">
|
||||
{contact.name || contact.public_key.slice(0, 12)}
|
||||
{dist !== null && (
|
||||
<span className="text-xs text-muted-foreground ml-1">
|
||||
- {formatDistance(dist, distanceUnit)}
|
||||
</span>
|
||||
)}
|
||||
{hasLocation && (
|
||||
<CoordinateLink
|
||||
lat={contact.lat!}
|
||||
lon={contact.lon!}
|
||||
publicKey={contact.public_key}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)}
|
||||
{!expanded && hop.matches.length > AMBIGUOUS_MATCH_PREVIEW_LIMIT && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-primary hover:underline cursor-pointer"
|
||||
onClick={() => setExpanded(true)}
|
||||
>
|
||||
(and {hop.matches.length - AMBIGUOUS_MATCH_PREVIEW_LIMIT} more)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="font-medium truncate">
|
||||
|
||||
@@ -35,6 +35,11 @@ type RawPacketInspectorDialogSource =
|
||||
message: string;
|
||||
};
|
||||
|
||||
interface SignalOverride {
|
||||
rssi: number | null;
|
||||
snr: number | null;
|
||||
}
|
||||
|
||||
interface RawPacketInspectorDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -43,10 +48,12 @@ interface RawPacketInspectorDialogProps {
|
||||
title: string;
|
||||
description: string;
|
||||
notice?: ReactNode;
|
||||
signalOverride?: SignalOverride;
|
||||
}
|
||||
|
||||
interface RawPacketInspectionPanelProps {
|
||||
packet: RawPacket;
|
||||
signalOverride?: SignalOverride;
|
||||
channels: Channel[];
|
||||
}
|
||||
|
||||
@@ -125,15 +132,21 @@ function formatTimestamp(timestamp: number): string {
|
||||
});
|
||||
}
|
||||
|
||||
function formatSignal(packet: RawPacket): string {
|
||||
const parts: string[] = [];
|
||||
if (packet.rssi !== null) {
|
||||
parts.push(`${packet.rssi} dBm RSSI`);
|
||||
}
|
||||
if (packet.snr !== null) {
|
||||
parts.push(`${packet.snr.toFixed(1)} dB SNR`);
|
||||
}
|
||||
return parts.length > 0 ? parts.join(' · ') : 'No signal sample';
|
||||
function formatSignal(
|
||||
packet: RawPacket,
|
||||
signalOverride?: SignalOverride
|
||||
): { lines: string[]; label: string } {
|
||||
const rssi = signalOverride?.rssi ?? packet.rssi;
|
||||
const snr = signalOverride?.snr ?? packet.snr;
|
||||
const lines: string[] = [];
|
||||
if (rssi !== null) lines.push(`${rssi} dBm RSSI`);
|
||||
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 {
|
||||
@@ -312,7 +325,7 @@ function CompactMetaCard({
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border/70 bg-card/70 p-2.5">
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">{label}</div>
|
||||
<div className="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>
|
||||
{secondary ? (
|
||||
<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]);
|
||||
|
||||
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) => {
|
||||
const fieldId = run.fieldId;
|
||||
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="min-w-0">
|
||||
<div className="text-base font-semibold leading-tight text-foreground">{field.name}</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted-foreground">{formatByteRange(field)}</div>
|
||||
<div className="mt-0.5 text-[0.6875rem] text-muted-foreground">
|
||||
{formatByteRange(field)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -464,7 +479,7 @@ function FieldBox({
|
||||
|
||||
{field.decryptedMessage ? (
|
||||
<div className="mt-2 rounded border border-border/50 bg-background/40 p-2">
|
||||
<div className="text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
|
||||
<div className="text-[0.625rem] uppercase tracking-wider text-muted-foreground">
|
||||
{field.name === 'Ciphertext' ? 'Plaintext' : 'Decoded value'}
|
||||
</div>
|
||||
<PlaintextContent text={field.decryptedMessage} />
|
||||
@@ -486,11 +501,13 @@ function FieldBox({
|
||||
<div className="text-sm font-medium leading-tight text-foreground">
|
||||
{part.field}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted-foreground">Bits {part.bits}</div>
|
||||
<div className="mt-0.5 text-[0.6875rem] text-muted-foreground">
|
||||
Bits {part.bits}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-mono text-sm text-foreground">{part.binary}</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted-foreground">{part.value}</div>
|
||||
<div className="mt-0.5 text-[0.6875rem] text-muted-foreground">{part.value}</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 groupTextCandidates = useMemo(
|
||||
() => 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">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
<div className="text-[0.625rem] uppercase tracking-wider text-muted-foreground">
|
||||
Summary
|
||||
</div>
|
||||
<div className="mt-1 text-base font-semibold leading-tight text-foreground">
|
||||
@@ -611,7 +632,7 @@ export function RawPacketInspectionPanel({ packet, channels }: RawPacketInspecti
|
||||
</div>
|
||||
{packetContext ? (
|
||||
<div className="mt-2 rounded-md border border-border/60 bg-background/35 px-2.5 py-2">
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
<div className="text-[0.625rem] uppercase tracking-wider text-muted-foreground">
|
||||
{packetContext.title}
|
||||
</div>
|
||||
<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}`}
|
||||
secondary={`${inspection.payloadVersionName} · ${formatPathMode(inspection.decoded?.pathHashSize, inspection.pathTokens.length)}`}
|
||||
/>
|
||||
<CompactMetaCard
|
||||
label="Signal"
|
||||
primary={formatSignal(packet)}
|
||||
secondary={packetContext ? null : undefined}
|
||||
/>
|
||||
{(() => {
|
||||
const sig = formatSignal(packet, signalOverride);
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -711,6 +745,7 @@ export function RawPacketInspectorDialog({
|
||||
title,
|
||||
description,
|
||||
notice,
|
||||
signalOverride,
|
||||
}: RawPacketInspectorDialogProps) {
|
||||
const [packetInput, setPacketInput] = useState('');
|
||||
|
||||
@@ -735,7 +770,13 @@ export function RawPacketInspectorDialog({
|
||||
|
||||
let body: ReactNode;
|
||||
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') {
|
||||
body = (
|
||||
<>
|
||||
|
||||
@@ -211,7 +211,9 @@ function getCoverageMessage(
|
||||
function StatTile({ label, value, detail }: { label: string; value: string; detail?: string }) {
|
||||
return (
|
||||
<div className="break-inside-avoid rounded-lg border border-border/70 bg-card/80 p-3">
|
||||
<div className="text-[11px] uppercase tracking-wide text-muted-foreground">{label}</div>
|
||||
<div className="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>
|
||||
{detail ? <div className="mt-1 text-xs text-muted-foreground">{detail}</div> : null}
|
||||
</div>
|
||||
@@ -329,7 +331,7 @@ function NeighborList({
|
||||
: `Last seen ${new Date(item.lastSeen * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`}
|
||||
</div>
|
||||
{!isNeighborIdentityResolvable(item, contacts) ? (
|
||||
<div className="text-[11px] text-warning">Identity not resolvable</div>
|
||||
<div className="text-[0.6875rem] text-warning">Identity not resolvable</div>
|
||||
) : null}
|
||||
</div>
|
||||
{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">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold text-foreground">Traffic Timeline</h3>
|
||||
<div className="flex flex-wrap justify-end gap-2 text-[11px] text-muted-foreground">
|
||||
<div className="flex flex-wrap justify-end gap-2 text-[0.6875rem] text-muted-foreground">
|
||||
{typeOrder.map((type, i) => (
|
||||
<span key={type} className="inline-flex items-center gap-1">
|
||||
<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="flex flex-wrap items-center justify-between gap-3">
|
||||
<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
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -101,7 +101,7 @@ export function RawPacketList({ packets, channels, onPacketClick }: RawPacketLis
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Route type badge */}
|
||||
<span
|
||||
className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${getRouteTypeColor(decoded.routeType)}`}
|
||||
className={`text-[0.625rem] font-mono px-1.5 py-0.5 rounded ${getRouteTypeColor(decoded.routeType)}`}
|
||||
title={decoded.routeType}
|
||||
>
|
||||
{getRouteTypeLabel(decoded.routeType)}
|
||||
@@ -117,26 +117,29 @@ export function RawPacketList({ packets, channels, onPacketClick }: RawPacketLis
|
||||
|
||||
{/* Summary */}
|
||||
<span
|
||||
className={cn('text-[13px]', packet.decrypted ? 'text-primary' : 'text-foreground')}
|
||||
className={cn(
|
||||
'text-[0.8125rem]',
|
||||
packet.decrypted ? 'text-primary' : 'text-foreground'
|
||||
)}
|
||||
>
|
||||
{decoded.summary}
|
||||
</span>
|
||||
|
||||
{/* 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)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Signal info */}
|
||||
{(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)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Raw hex data (always visible) */}
|
||||
<div className="font-mono text-[10px] break-all text-muted-foreground mt-1.5 p-1.5 bg-background/60 rounded">
|
||||
<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()}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { api } from '../api';
|
||||
import { toast } from './ui/sonner';
|
||||
import { Button } from './ui/button';
|
||||
import { Bell, Route, Star, Trash2 } from 'lucide-react';
|
||||
import { Bell, Info, Route, Star, Trash2 } from 'lucide-react';
|
||||
import { DirectTraceIcon } from './DirectTraceIcon';
|
||||
import { RepeaterLogin } from './RepeaterLogin';
|
||||
import { ServerLoginStatusBanner } from './ServerLoginStatusBanner';
|
||||
@@ -12,7 +13,13 @@ import { isFavorite } from '../utils/favorites';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { isValidLocation } from '../utils/pathUtils';
|
||||
import { ContactStatusInfo } from './ContactStatusInfo';
|
||||
import type { Contact, Conversation, Favorite, PathDiscoveryResponse } from '../types';
|
||||
import type {
|
||||
Contact,
|
||||
Conversation,
|
||||
Favorite,
|
||||
PathDiscoveryResponse,
|
||||
TelemetryHistoryEntry,
|
||||
} from '../types';
|
||||
import { cn } from '../lib/utils';
|
||||
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
|
||||
import { NeighborsPane } from './repeater/RepeaterNeighborsPane';
|
||||
@@ -23,6 +30,7 @@ import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane';
|
||||
import { OwnerInfoPane } from './repeater/RepeaterOwnerInfoPane';
|
||||
import { ActionsPane } from './repeater/RepeaterActionsPane';
|
||||
import { ConsolePane } from './repeater/RepeaterConsolePane';
|
||||
import { TelemetryHistoryPane } from './repeater/RepeaterTelemetryHistoryPane';
|
||||
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
|
||||
|
||||
// Re-export for backwards compatibility (used by repeaterFormatters.test.ts)
|
||||
@@ -45,6 +53,9 @@ interface RepeaterDashboardProps {
|
||||
onToggleNotifications: () => void;
|
||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
||||
onDeleteContact: (publicKey: string) => void;
|
||||
onOpenContactInfo?: (publicKey: string) => void;
|
||||
trackedTelemetryRepeaters: string[];
|
||||
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function RepeaterDashboard({
|
||||
@@ -62,6 +73,9 @@ export function RepeaterDashboard({
|
||||
onToggleNotifications,
|
||||
onToggleFavorite,
|
||||
onDeleteContact,
|
||||
onOpenContactInfo,
|
||||
trackedTelemetryRepeaters,
|
||||
onToggleTrackedTelemetry,
|
||||
}: RepeaterDashboardProps) {
|
||||
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
|
||||
const contact = contacts.find((c) => c.public_key === conversation.id) ?? null;
|
||||
@@ -88,7 +102,40 @@ export function RepeaterDashboard({
|
||||
const { password, setPassword, rememberPassword, setRememberPassword, persistAfterLogin } =
|
||||
useRememberedServerPassword('repeater', conversation.id);
|
||||
|
||||
// Telemetry history: preload from stored data, refresh from live status
|
||||
const [telemetryHistory, setTelemetryHistory] = useState<TelemetryHistoryEntry[]>([]);
|
||||
const telemetryHistorySourceRef = useRef<'none' | 'preload' | 'live'>('none');
|
||||
const telemetryHistoryRequestRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
telemetryHistoryRequestRef.current += 1;
|
||||
telemetryHistorySourceRef.current = 'none';
|
||||
setTelemetryHistory([]);
|
||||
|
||||
if (!loggedIn) return;
|
||||
|
||||
const requestId = telemetryHistoryRequestRef.current;
|
||||
api
|
||||
.repeaterTelemetryHistory(conversation.id)
|
||||
.then((history) => {
|
||||
if (telemetryHistoryRequestRef.current !== requestId) return;
|
||||
if (telemetryHistorySourceRef.current === 'live') return;
|
||||
telemetryHistorySourceRef.current = 'preload';
|
||||
setTelemetryHistory(history);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [loggedIn, conversation.id]);
|
||||
|
||||
// When a live status fetch returns embedded telemetry_history, replace local state
|
||||
useEffect(() => {
|
||||
const liveHistory = paneData.status?.telemetry_history;
|
||||
if (!liveHistory) return;
|
||||
telemetryHistorySourceRef.current = 'live';
|
||||
setTelemetryHistory(liveHistory);
|
||||
}, [paneData.status?.telemetry_history]);
|
||||
|
||||
const isFav = isFavorite(favorites, 'contact', conversation.id);
|
||||
|
||||
const handleRepeaterLogin = async (nextPassword: string) => {
|
||||
await login(nextPassword);
|
||||
persistAfterLogin(nextPassword);
|
||||
@@ -115,11 +162,26 @@ export function RepeaterDashboard({
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<span className="flex min-w-0 flex-1 items-baseline gap-2">
|
||||
<span className="min-w-0 flex-shrink truncate font-semibold text-base">
|
||||
{conversation.name}
|
||||
</span>
|
||||
<h2 className="min-w-0 flex-shrink font-semibold text-base">
|
||||
{onOpenContactInfo ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex max-w-full min-w-0 items-center gap-1.5 overflow-hidden rounded-sm text-left transition-colors hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={`View info for ${conversation.name}`}
|
||||
onClick={() => onOpenContactInfo(conversation.id)}
|
||||
>
|
||||
<span className="truncate">{conversation.name}</span>
|
||||
<Info
|
||||
className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span className="truncate">{conversation.name}</span>
|
||||
)}
|
||||
</h2>
|
||||
<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"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
@@ -135,7 +197,7 @@ export function RepeaterDashboard({
|
||||
</span>
|
||||
</span>
|
||||
{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} />
|
||||
</div>
|
||||
)}
|
||||
@@ -146,7 +208,7 @@ export function RepeaterDashboard({
|
||||
size="sm"
|
||||
onClick={loadAll}
|
||||
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'}
|
||||
</Button>
|
||||
@@ -192,7 +254,7 @@ export function RepeaterDashboard({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{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
|
||||
</span>
|
||||
)}
|
||||
@@ -336,6 +398,15 @@ export function RepeaterDashboard({
|
||||
loading={consoleLoading}
|
||||
onSend={sendConsoleCommand}
|
||||
/>
|
||||
|
||||
{/* Telemetry history chart — full width, below console */}
|
||||
<TelemetryHistoryPane
|
||||
entries={telemetryHistory}
|
||||
publicKey={conversation.id}
|
||||
contacts={contacts}
|
||||
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
|
||||
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -290,7 +290,7 @@ export function SearchView({
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span
|
||||
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'
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-secondary text-secondary-foreground'
|
||||
@@ -298,12 +298,12 @@ export function SearchView({
|
||||
>
|
||||
{typeBadge}
|
||||
</span>
|
||||
<span className="text-[12px] font-medium text-foreground truncate">{convName}</span>
|
||||
<span className="text-[11px] text-muted-foreground ml-auto flex-shrink-0">
|
||||
<span className="text-xs font-medium text-foreground truncate">{convName}</span>
|
||||
<span className="text-[0.6875rem] text-muted-foreground ml-auto flex-shrink-0">
|
||||
{formatTime(result.received_at)}
|
||||
</span>
|
||||
</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 && (
|
||||
<span className="text-muted-foreground">{result.sender_name}: </span>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, type ReactNode } from 'react';
|
||||
import type {
|
||||
AppSettings,
|
||||
AppSettingsUpdate,
|
||||
Contact,
|
||||
HealthStatus,
|
||||
RadioAdvertMode,
|
||||
RadioConfig,
|
||||
@@ -47,6 +48,10 @@ interface SettingsModalBaseProps {
|
||||
blockedNames?: string[];
|
||||
onToggleBlockedKey?: (key: string) => void;
|
||||
onToggleBlockedName?: (name: string) => void;
|
||||
contacts?: Contact[];
|
||||
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
|
||||
trackedTelemetryRepeaters?: string[];
|
||||
onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export type SettingsModalProps = SettingsModalBaseProps &
|
||||
@@ -80,6 +85,10 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
blockedNames,
|
||||
onToggleBlockedKey,
|
||||
onToggleBlockedName,
|
||||
contacts,
|
||||
onBulkDeleteContacts,
|
||||
trackedTelemetryRepeaters,
|
||||
onToggleTrackedTelemetry,
|
||||
} = props;
|
||||
const externalSidebarNav = props.externalSidebarNav === true;
|
||||
const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined;
|
||||
@@ -239,6 +248,10 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
blockedNames={blockedNames}
|
||||
onToggleBlockedKey={onToggleBlockedKey}
|
||||
onToggleBlockedName={onToggleBlockedName}
|
||||
contacts={contacts}
|
||||
onBulkDeleteContacts={onBulkDeleteContacts}
|
||||
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
|
||||
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
|
||||
className={sectionContentClass}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -97,7 +97,7 @@ interface SidebarProps {
|
||||
channels: Channel[];
|
||||
activeConversation: Conversation | null;
|
||||
onSelectConversation: (conversation: Conversation) => void;
|
||||
onNewMessage: () => void;
|
||||
onNewMessage: (event?: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
lastMessageTimes: ConversationTimes;
|
||||
unreadCounts: Record<string, number>;
|
||||
/** Tracks which conversations have unread messages that mention the user */
|
||||
@@ -107,34 +107,19 @@ interface SidebarProps {
|
||||
onToggleCracker: () => void;
|
||||
onMarkAllRead: () => void;
|
||||
favorites: Favorite[];
|
||||
/** Legacy global sort order, used only to seed per-section local preferences. */
|
||||
legacySortOrder?: SortOrder;
|
||||
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
|
||||
blockedKeys?: string[];
|
||||
blockedNames?: string[];
|
||||
}
|
||||
|
||||
type InitialSectionSortState = {
|
||||
orders: SidebarSectionSortOrders;
|
||||
source: 'section' | 'legacy' | 'none';
|
||||
};
|
||||
|
||||
function loadInitialSectionSortOrders(): InitialSectionSortState {
|
||||
function loadInitialSectionSortOrders(): SidebarSectionSortOrders {
|
||||
const storedOrders = loadLocalStorageSidebarSectionSortOrders();
|
||||
if (storedOrders) {
|
||||
return { orders: storedOrders, source: 'section' };
|
||||
}
|
||||
if (storedOrders) return storedOrders;
|
||||
|
||||
const legacyOrder = loadLegacyLocalStorageSortOrder();
|
||||
if (legacyOrder) {
|
||||
return {
|
||||
orders: buildSidebarSectionSortOrders(legacyOrder),
|
||||
source: 'legacy',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
orders: buildSidebarSectionSortOrders(),
|
||||
source: 'none',
|
||||
};
|
||||
const orders = buildSidebarSectionSortOrders(legacyOrder ?? undefined);
|
||||
saveLocalStorageSidebarSectionSortOrders(orders);
|
||||
return orders;
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
@@ -151,12 +136,20 @@ export function Sidebar({
|
||||
onToggleCracker,
|
||||
onMarkAllRead,
|
||||
favorites,
|
||||
legacySortOrder,
|
||||
isConversationNotificationsEnabled,
|
||||
blockedKeys = [],
|
||||
blockedNames = [],
|
||||
}: SidebarProps) {
|
||||
const isContactBlocked = useCallback(
|
||||
(c: Contact) =>
|
||||
blockedKeys.includes(c.public_key.toLowerCase()) ||
|
||||
(c.name != null && blockedNames.includes(c.name)),
|
||||
[blockedKeys, blockedNames]
|
||||
);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const initialSectionSortState = useMemo(loadInitialSectionSortOrders, []);
|
||||
const [sectionSortOrders, setSectionSortOrders] = useState(initialSectionSortState.orders);
|
||||
const initialSectionSortOrders = useMemo(loadInitialSectionSortOrders, []);
|
||||
const [sectionSortOrders, setSectionSortOrders] = useState(initialSectionSortOrders);
|
||||
const initialCollapsedState = useMemo(loadCollapsedState, []);
|
||||
const [toolsCollapsed, setToolsCollapsed] = useState(initialCollapsedState.tools);
|
||||
const [favoritesCollapsed, setFavoritesCollapsed] = useState(initialCollapsedState.favorites);
|
||||
@@ -165,29 +158,12 @@ export function Sidebar({
|
||||
const [roomsCollapsed, setRoomsCollapsed] = useState(initialCollapsedState.rooms);
|
||||
const [repeatersCollapsed, setRepeatersCollapsed] = useState(initialCollapsedState.repeaters);
|
||||
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) => {
|
||||
setSectionSortOrders((prev) => {
|
||||
const nextOrder = prev[section] === 'alpha' ? 'recent' : 'alpha';
|
||||
const updated = { ...prev, [section]: nextOrder };
|
||||
saveLocalStorageSidebarSectionSortOrders(updated);
|
||||
sectionSortSourceRef.current = 'section';
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
@@ -398,38 +374,32 @@ export function Sidebar({
|
||||
[sortedChannels, query]
|
||||
);
|
||||
|
||||
const filteredNonRepeaterContacts = useMemo(
|
||||
() =>
|
||||
query
|
||||
? sortedNonRepeaterContacts.filter(
|
||||
(c) =>
|
||||
c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
||||
)
|
||||
: sortedNonRepeaterContacts,
|
||||
[sortedNonRepeaterContacts, query]
|
||||
);
|
||||
const filteredNonRepeaterContacts = useMemo(() => {
|
||||
const visible = sortedNonRepeaterContacts.filter((c) => !isContactBlocked(c));
|
||||
return query
|
||||
? visible.filter(
|
||||
(c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
||||
)
|
||||
: visible;
|
||||
}, [sortedNonRepeaterContacts, query, isContactBlocked]);
|
||||
|
||||
const filteredRooms = useMemo(
|
||||
() =>
|
||||
query
|
||||
? sortedRooms.filter(
|
||||
(c) =>
|
||||
c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
||||
)
|
||||
: sortedRooms,
|
||||
[sortedRooms, query]
|
||||
);
|
||||
const filteredRooms = useMemo(() => {
|
||||
const visible = sortedRooms.filter((c) => !isContactBlocked(c));
|
||||
return query
|
||||
? visible.filter(
|
||||
(c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
||||
)
|
||||
: visible;
|
||||
}, [sortedRooms, query, isContactBlocked]);
|
||||
|
||||
const filteredRepeaters = useMemo(
|
||||
() =>
|
||||
query
|
||||
? sortedRepeaters.filter(
|
||||
(c) =>
|
||||
c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
||||
)
|
||||
: sortedRepeaters,
|
||||
[sortedRepeaters, query]
|
||||
);
|
||||
const filteredRepeaters = useMemo(() => {
|
||||
const visible = sortedRepeaters.filter((c) => !isContactBlocked(c));
|
||||
return query
|
||||
? visible.filter(
|
||||
(c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
||||
)
|
||||
: visible;
|
||||
}, [sortedRepeaters, query, isContactBlocked]);
|
||||
|
||||
// Expand sections while searching; restore prior collapse state when search ends.
|
||||
useEffect(() => {
|
||||
@@ -614,7 +584,7 @@ export function Sidebar({
|
||||
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">
|
||||
{row.notificationsEnabled && (
|
||||
<span aria-label="Notifications enabled" title="Notifications enabled">
|
||||
@@ -624,7 +594,7 @@ export function Sidebar({
|
||||
{row.unreadCount > 0 && (
|
||||
<span
|
||||
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
|
||||
? 'bg-badge-mention text-badge-mention-foreground'
|
||||
: 'bg-badge-unread/90 text-badge-unread-foreground'
|
||||
@@ -654,8 +624,9 @@ export function Sidebar({
|
||||
}) => (
|
||||
<div
|
||||
key={key}
|
||||
data-active={active ? 'true' : undefined}
|
||||
className={cn(
|
||||
'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'
|
||||
)}
|
||||
role="button"
|
||||
@@ -664,10 +635,10 @@ export function Sidebar({
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="sidebar-tool-icon text-muted-foreground" aria-hidden="true">
|
||||
<span className="sidebar-tool-icon" aria-hidden="true">
|
||||
{icon}
|
||||
</span>
|
||||
<span className="sidebar-tool-label flex-1 truncate text-muted-foreground">{label}</span>
|
||||
<span className="sidebar-tool-label flex-1 truncate">{label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -764,7 +735,7 @@ export function Sidebar({
|
||||
{showCracker ? 'Hide' : 'Show'} Channel Finder
|
||||
<span
|
||||
className={cn(
|
||||
'ml-1 text-[11px]',
|
||||
'ml-1 text-[0.6875rem]',
|
||||
crackerRunning ? 'text-primary' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
@@ -792,7 +763,7 @@ export function Sidebar({
|
||||
<div className="flex justify-between items-center px-3 py-2 pt-3.5">
|
||||
<button
|
||||
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'
|
||||
)}
|
||||
aria-expanded={!effectiveCollapsed}
|
||||
@@ -812,7 +783,7 @@ export function Sidebar({
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
{sortSection && sectionSortOrder && (
|
||||
<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)}
|
||||
aria-label={
|
||||
sectionSortOrder === 'alpha'
|
||||
@@ -831,7 +802,7 @@ export function Sidebar({
|
||||
{unreadCount > 0 && (
|
||||
<span
|
||||
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
|
||||
? 'bg-badge-mention text-badge-mention-foreground'
|
||||
: 'bg-secondary text-muted-foreground'
|
||||
@@ -860,7 +831,7 @@ export function Sidebar({
|
||||
onClick={onNewMessage}
|
||||
title="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" />
|
||||
<span>Add Channel/Contact</span>
|
||||
@@ -877,7 +848,7 @@ export function Sidebar({
|
||||
aria-label="Search conversations"
|
||||
value={searchQuery}
|
||||
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 && (
|
||||
<button
|
||||
@@ -903,7 +874,7 @@ export function Sidebar({
|
||||
{/* Mark All Read */}
|
||||
{!query && Object.values(unreadCounts).some((c) => c > 0) && (
|
||||
<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"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
|
||||
@@ -123,7 +123,7 @@ export function StatusBar({
|
||||
<div className="hidden lg:flex items-center gap-2 text-muted-foreground">
|
||||
<span className="text-foreground font-medium">{config.name || 'Unnamed'}</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"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
|
||||
@@ -118,7 +118,7 @@ function TraceNodeRow({
|
||||
>
|
||||
<div
|
||||
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
|
||||
? 'border-primary/30 bg-primary/10 text-primary'
|
||||
: 'border-border bg-muted text-muted-foreground'
|
||||
@@ -129,12 +129,12 @@ function TraceNodeRow({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{title}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{subtitle}</div>
|
||||
{meta ? <div className="mt-1 text-[11px] text-muted-foreground">{meta}</div> : null}
|
||||
{note ? <div className="mt-1 text-[11px] text-muted-foreground">{note}</div> : null}
|
||||
{meta ? <div className="mt-1 text-[0.6875rem] text-muted-foreground">{meta}</div> : null}
|
||||
{note ? <div className="mt-1 text-[0.6875rem] text-muted-foreground">{note}</div> : null}
|
||||
</div>
|
||||
{snr ? (
|
||||
<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>
|
||||
) : null}
|
||||
@@ -370,7 +370,7 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
|
||||
))}
|
||||
</div>
|
||||
{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
|
||||
currently have a valid location.
|
||||
</p>
|
||||
@@ -421,12 +421,12 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
|
||||
{getShortKey(contact.public_key)}
|
||||
</div>
|
||||
{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
|
||||
</div>
|
||||
) : null}
|
||||
{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'}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip as RechartsTooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { cn } from '@/lib/utils';
|
||||
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';
|
||||
|
||||
const METRIC_CONFIG: Record<Metric, { label: string; unit: string; color: string }> = {
|
||||
battery_volts: { label: 'Voltage', unit: 'V', color: '#22c55e' },
|
||||
noise_floor_dbm: { label: 'Noise Floor', unit: 'dBm', color: '#8b5cf6' },
|
||||
packets: { label: 'Packets', unit: '', color: '#0ea5e9' },
|
||||
uptime_seconds: { label: 'Uptime', unit: 's', color: '#f59e0b' },
|
||||
};
|
||||
|
||||
const TOOLTIP_STYLE = {
|
||||
contentStyle: {
|
||||
backgroundColor: 'hsl(var(--popover))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
fontSize: '11px',
|
||||
color: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
itemStyle: { color: 'hsl(var(--popover-foreground))' },
|
||||
labelStyle: { color: 'hsl(var(--muted-foreground))' },
|
||||
} as const;
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
return new Date(ts * 1000).toLocaleString([], {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
|
||||
if (seconds < 86400) return `${(seconds / 3600).toFixed(1)}h`;
|
||||
return `${(seconds / 86400).toFixed(1)}d`;
|
||||
}
|
||||
|
||||
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 [toggling, setToggling] = useState(false);
|
||||
|
||||
const isTracked = trackedTelemetryRepeaters.includes(publicKey);
|
||||
const slotsFull = trackedTelemetryRepeaters.length >= MAX_TRACKED && !isTracked;
|
||||
|
||||
const config = METRIC_CONFIG[metric];
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
return entries.map((e) => {
|
||||
const d = e.data;
|
||||
return {
|
||||
timestamp: e.timestamp,
|
||||
battery_volts: d.battery_volts,
|
||||
noise_floor_dbm: d.noise_floor_dbm,
|
||||
packets_received: d.packets_received,
|
||||
packets_sent: d.packets_sent,
|
||||
uptime_seconds: d.uptime_seconds,
|
||||
};
|
||||
});
|
||||
}, [entries]);
|
||||
|
||||
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 (
|
||||
<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 gap-2">
|
||||
<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 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 */}
|
||||
<div className="flex gap-1 mb-2">
|
||||
{(Object.keys(METRIC_CONFIG) as Metric[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setMetric(m)}
|
||||
className={cn(
|
||||
'text-[0.6875rem] px-2 py-0.5 rounded transition-colors',
|
||||
metric === m
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
{METRIC_CONFIG[m].label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No history yet. Fetch status above to record data points.
|
||||
</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<AreaChart data={chartData} margin={{ top: 4, right: 4, bottom: 0, left: -8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
type="number"
|
||||
domain={['dataMin', 'dataMax']}
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={formatTime}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(v) => (metric === 'uptime_seconds' ? formatUptime(v) : `${v}`)}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
{...TOOLTIP_STYLE}
|
||||
cursor={{
|
||||
stroke: 'hsl(var(--muted-foreground))',
|
||||
strokeWidth: 1,
|
||||
strokeDasharray: '3 3',
|
||||
}}
|
||||
labelFormatter={(ts) => formatTime(Number(ts))}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
formatter={(value: any, name: any) => {
|
||||
const numVal = typeof value === 'number' ? value : Number(value);
|
||||
const display = metric === 'uptime_seconds' ? formatUptime(numVal) : `${value}`;
|
||||
const suffix =
|
||||
metric === 'uptime_seconds' ? '' : config.unit ? ` ${config.unit}` : '';
|
||||
const label =
|
||||
metric === 'packets'
|
||||
? name === 'packets_received'
|
||||
? 'Received'
|
||||
: 'Sent'
|
||||
: config.label;
|
||||
return [`${display}${suffix}`, label];
|
||||
}}
|
||||
/>
|
||||
{dataKeys.map((key, i) => (
|
||||
<Area
|
||||
key={key}
|
||||
type="linear"
|
||||
dataKey={key}
|
||||
stroke={metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color}
|
||||
fill={metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color}
|
||||
fillOpacity={0.15}
|
||||
strokeWidth={1.5}
|
||||
dot={{
|
||||
r: 4,
|
||||
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,
|
||||
stroke: 'hsl(var(--popover))',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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="min-w-0">
|
||||
<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 && (
|
||||
<p
|
||||
className="text-[11px] text-muted-foreground"
|
||||
className="text-[0.6875rem] text-muted-foreground"
|
||||
title={new Date(fetchedAt).toLocaleString()}
|
||||
>
|
||||
Fetched {formatFetchedTime(fetchedAt)} ({formatFetchedRelative(fetchedAt)})
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { api } from '../../api';
|
||||
import { getContactDisplayName } from '../../utils/pubkey';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '../ui/dialog';
|
||||
import { toast } from '../ui/sonner';
|
||||
import type { Contact } from '../../types';
|
||||
|
||||
const CONTACT_TYPE_LABELS: Record<number, string> = {
|
||||
0: 'Unknown',
|
||||
1: 'Client',
|
||||
2: 'Repeater',
|
||||
3: 'Room',
|
||||
4: 'Sensor',
|
||||
};
|
||||
|
||||
function formatDate(ts: number): string {
|
||||
return new Date(ts * 1000).toLocaleDateString([], {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatDateISO(ts: number): string {
|
||||
return new Date(ts * 1000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function datetimeToUnix(datetimeStr: string): number {
|
||||
const d = new Date(datetimeStr);
|
||||
return Math.floor(d.getTime() / 1000);
|
||||
}
|
||||
|
||||
interface BulkDeleteContactsModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
contacts: Contact[];
|
||||
onDeleted: (deletedKeys: string[]) => void;
|
||||
}
|
||||
|
||||
export function BulkDeleteContactsModal({
|
||||
open,
|
||||
onClose,
|
||||
contacts,
|
||||
onDeleted,
|
||||
}: BulkDeleteContactsModalProps) {
|
||||
const [step, setStep] = useState<'select' | 'confirm'>('select');
|
||||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState<number | 'all'>('all');
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const lastClickedKeyRef = useRef<string | null>(null);
|
||||
|
||||
const resetAndClose = useCallback(() => {
|
||||
setStep('select');
|
||||
setSelectedKeys(new Set());
|
||||
setStartDate('');
|
||||
setEndDate('');
|
||||
setTypeFilter('all');
|
||||
lastClickedKeyRef.current = null;
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const filteredContacts = useMemo(() => {
|
||||
let list = [...contacts].sort((a, b) => (b.first_seen ?? 0) - (a.first_seen ?? 0));
|
||||
if (typeFilter !== 'all') {
|
||||
list = list.filter((c) => c.type === typeFilter);
|
||||
}
|
||||
if (startDate) {
|
||||
const start = datetimeToUnix(startDate);
|
||||
list = list.filter((c) => (c.first_seen ?? 0) >= start);
|
||||
}
|
||||
if (endDate) {
|
||||
const end = datetimeToUnix(endDate);
|
||||
list = list.filter((c) => (c.first_seen ?? 0) <= end);
|
||||
}
|
||||
return list;
|
||||
}, [contacts, typeFilter, startDate, endDate]);
|
||||
|
||||
const handleToggle = (key: string, shiftKey: boolean) => {
|
||||
if (shiftKey && lastClickedKeyRef.current && lastClickedKeyRef.current !== key) {
|
||||
const keys = filteredContacts.map((c) => c.public_key);
|
||||
const lastIdx = keys.indexOf(lastClickedKeyRef.current);
|
||||
const curIdx = keys.indexOf(key);
|
||||
if (lastIdx >= 0 && curIdx >= 0) {
|
||||
const from = Math.min(lastIdx, curIdx);
|
||||
const to = Math.max(lastIdx, curIdx);
|
||||
const rangeKeys = keys.slice(from, to + 1);
|
||||
setSelectedKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const k of rangeKeys) next.add(k);
|
||||
return next;
|
||||
});
|
||||
lastClickedKeyRef.current = key;
|
||||
return;
|
||||
}
|
||||
}
|
||||
setSelectedKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
lastClickedKeyRef.current = key;
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
setSelectedKeys(new Set(filteredContacts.map((c) => c.public_key)));
|
||||
};
|
||||
|
||||
const handleSelectNone = () => {
|
||||
setSelectedKeys(new Set());
|
||||
};
|
||||
|
||||
const selectedContacts = useMemo(
|
||||
() => contacts.filter((c) => selectedKeys.has(c.public_key)),
|
||||
[contacts, selectedKeys]
|
||||
);
|
||||
|
||||
const contactCount = selectedContacts.filter((c) => c.type === 1 || c.type === 0).length;
|
||||
const repeaterCount = selectedContacts.filter((c) => c.type === 2).length;
|
||||
const roomCount = selectedContacts.filter((c) => c.type === 3).length;
|
||||
const sensorCount = selectedContacts.filter((c) => c.type === 4).length;
|
||||
|
||||
const firstSeenDates = selectedContacts.map((c) => c.first_seen ?? 0).filter((t) => t > 0);
|
||||
const minDate =
|
||||
firstSeenDates.length > 0 ? formatDateISO(Math.min(...firstSeenDates)) : 'unknown';
|
||||
const maxDate =
|
||||
firstSeenDates.length > 0 ? formatDateISO(Math.max(...firstSeenDates)) : 'unknown';
|
||||
|
||||
const handleDelete = async () => {
|
||||
setDeleting(true);
|
||||
try {
|
||||
const keysToDelete = [...selectedKeys];
|
||||
const result = await api.bulkDeleteContacts(keysToDelete);
|
||||
toast.success(`Deleted ${result.deleted} contact${result.deleted === 1 ? '' : 's'}`);
|
||||
onDeleted(keysToDelete);
|
||||
resetAndClose();
|
||||
} catch (err) {
|
||||
console.error('Bulk delete failed:', err);
|
||||
toast.error('Bulk delete failed', {
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && resetAndClose()}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[85dvh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{step === 'select' ? 'Bulk Delete Contacts' : 'Confirm Deletion'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === 'select'
|
||||
? 'Select contacts to delete. Message history will be preserved and accessible if a contact is re-added, but will no longer appear in the sidebar.'
|
||||
: 'Review the contacts that will be permanently deleted.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{step === 'select' && (
|
||||
<>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-muted-foreground">Show</label>
|
||||
<select
|
||||
value={typeFilter === 'all' ? 'all' : String(typeFilter)}
|
||||
onChange={(e) =>
|
||||
setTypeFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))
|
||||
}
|
||||
className="block h-8 rounded-md border border-input bg-background px-2 text-sm"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="1">Clients</option>
|
||||
<option value="2">Repeaters</option>
|
||||
<option value="3">Room Servers</option>
|
||||
<option value="4">Sensors</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-muted-foreground">Created after</label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="w-48 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-muted-foreground">Created before</label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="w-48 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleSelectAll}>
|
||||
Select all
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleSelectNone}>
|
||||
Select none
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{filteredContacts.length} contact{filteredContacts.length === 1 ? '' : 's'} shown
|
||||
{(startDate || endDate) && ' (filtered)'}
|
||||
{' · '}
|
||||
{selectedKeys.size} selected
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0 border border-border rounded-md">
|
||||
{filteredContacts.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
No contacts match the selected date range.
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-muted/90 backdrop-blur-sm">
|
||||
<tr className="text-left text-xs text-muted-foreground">
|
||||
<th className="px-3 py-1.5 w-8" />
|
||||
<th className="px-3 py-1.5">Name</th>
|
||||
<th className="px-3 py-1.5 hidden sm:table-cell">Type</th>
|
||||
<th className="px-3 py-1.5">Key</th>
|
||||
<th className="px-3 py-1.5 hidden sm:table-cell">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredContacts.map((c) => (
|
||||
<tr
|
||||
key={c.public_key}
|
||||
className="border-t border-border hover:bg-accent/50 cursor-pointer"
|
||||
onClick={(e) => handleToggle(c.public_key, e.shiftKey)}
|
||||
>
|
||||
<td className="px-3 py-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedKeys.has(c.public_key)}
|
||||
onChange={(e) =>
|
||||
handleToggle(
|
||||
c.public_key,
|
||||
e.nativeEvent instanceof MouseEvent && e.nativeEvent.shiftKey
|
||||
)
|
||||
}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 truncate max-w-[10rem]">
|
||||
{getContactDisplayName(c.name, c.public_key, c.last_advert)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
|
||||
{CONTACT_TYPE_LABELS[c.type] ?? 'Unknown'}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 font-mono text-xs text-muted-foreground truncate max-w-[8rem]">
|
||||
{c.public_key.slice(0, 12)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
|
||||
{c.first_seen ? formatDate(c.first_seen) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="secondary" onClick={resetAndClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-warning text-warning hover:bg-warning/10 hover:text-warning"
|
||||
disabled={selectedKeys.size === 0}
|
||||
onClick={() => setStep('confirm')}
|
||||
>
|
||||
Proceed to confirmation ({selectedKeys.size})
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'confirm' && (
|
||||
<>
|
||||
<div className="flex-1 overflow-y-auto min-h-0 border border-border rounded-md">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-muted/90 backdrop-blur-sm">
|
||||
<tr className="text-left text-xs text-muted-foreground">
|
||||
<th className="px-3 py-1.5">Name</th>
|
||||
<th className="px-3 py-1.5">Type</th>
|
||||
<th className="px-3 py-1.5">Key</th>
|
||||
<th className="px-3 py-1.5 hidden sm:table-cell">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{selectedContacts.map((c) => (
|
||||
<tr key={c.public_key} className="border-t border-border">
|
||||
<td className="px-3 py-1.5 truncate max-w-[12rem]">
|
||||
{getContactDisplayName(c.name, c.public_key, c.last_advert)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-xs text-muted-foreground">
|
||||
{CONTACT_TYPE_LABELS[c.type] ?? 'Unknown'}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 font-mono text-xs text-muted-foreground truncate max-w-[8rem]">
|
||||
{c.public_key.slice(0, 12)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
|
||||
{c.first_seen ? formatDate(c.first_seen) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 pt-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full h-auto py-3 text-wrap"
|
||||
disabled={deleting}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{deleting
|
||||
? 'Deleting...'
|
||||
: `I confirm permanent, irrevocable deletion of all listed nodes above, totalling ${[
|
||||
contactCount > 0 && `${contactCount} contact${contactCount === 1 ? '' : 's'}`,
|
||||
repeaterCount > 0 &&
|
||||
`${repeaterCount} repeater${repeaterCount === 1 ? '' : 's'}`,
|
||||
roomCount > 0 && `${roomCount} room${roomCount === 1 ? '' : 's'}`,
|
||||
sensorCount > 0 && `${sensorCount} sensor${sensorCount === 1 ? '' : 's'}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ')}, spanning creation dates from ${minDate} to ${maxDate}`}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setStep('select')} disabled={deleting}>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,8 @@ import { Separator } from '../ui/separator';
|
||||
import { toast } from '../ui/sonner';
|
||||
import { api } from '../../api';
|
||||
import { formatTime } from '../../utils/messageParser';
|
||||
import type { AppSettings, AppSettingsUpdate, HealthStatus } from '../../types';
|
||||
import { BulkDeleteContactsModal } from './BulkDeleteContactsModal';
|
||||
import type { AppSettings, AppSettingsUpdate, Contact, HealthStatus } from '../../types';
|
||||
|
||||
export function SettingsDatabaseSection({
|
||||
appSettings,
|
||||
@@ -17,6 +18,10 @@ export function SettingsDatabaseSection({
|
||||
blockedNames = [],
|
||||
onToggleBlockedKey,
|
||||
onToggleBlockedName,
|
||||
contacts = [],
|
||||
onBulkDeleteContacts,
|
||||
trackedTelemetryRepeaters = [],
|
||||
onToggleTrackedTelemetry,
|
||||
className,
|
||||
}: {
|
||||
appSettings: AppSettings;
|
||||
@@ -27,18 +32,25 @@ export function SettingsDatabaseSection({
|
||||
blockedNames?: string[];
|
||||
onToggleBlockedKey?: (key: string) => void;
|
||||
onToggleBlockedName?: (name: string) => void;
|
||||
contacts?: Contact[];
|
||||
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
|
||||
trackedTelemetryRepeaters?: string[];
|
||||
onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>;
|
||||
className?: string;
|
||||
}) {
|
||||
const [retentionDays, setRetentionDays] = useState('14');
|
||||
const [cleaning, setCleaning] = useState(false);
|
||||
const [purgingDecryptedRaw, setPurgingDecryptedRaw] = useState(false);
|
||||
const [autoDecryptOnAdvert, setAutoDecryptOnAdvert] = useState(false);
|
||||
const [discoveryBlockedTypes, setDiscoveryBlockedTypes] = useState<number[]>([]);
|
||||
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
|
||||
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert);
|
||||
setDiscoveryBlockedTypes(appSettings.discovery_blocked_types ?? []);
|
||||
}, [appSettings]);
|
||||
|
||||
const handleCleanup = async () => {
|
||||
@@ -92,7 +104,15 @@ export function SettingsDatabaseSection({
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await onSaveAppSettings({ auto_decrypt_dm_on_advert: autoDecryptOnAdvert });
|
||||
const update: AppSettingsUpdate = { auto_decrypt_dm_on_advert: autoDecryptOnAdvert };
|
||||
const currentBlocked = appSettings.discovery_blocked_types ?? [];
|
||||
if (
|
||||
discoveryBlockedTypes.length !== currentBlocked.length ||
|
||||
discoveryBlockedTypes.some((t) => !currentBlocked.includes(t))
|
||||
) {
|
||||
update.discovery_blocked_types = discoveryBlockedTypes;
|
||||
}
|
||||
await onSaveAppSettings(update);
|
||||
toast.success('Database settings saved');
|
||||
} catch (err) {
|
||||
console.error('Failed to save database settings:', err);
|
||||
@@ -105,93 +125,93 @@ export function SettingsDatabaseSection({
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* ── Database Overview ── */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Database size</span>
|
||||
<span className="font-medium">{health?.database_size_mb ?? '?'} MB</span>
|
||||
</div>
|
||||
|
||||
{health?.oldest_undecrypted_timestamp ? (
|
||||
<Label className="text-base">Database Overview</Label>
|
||||
<div className="rounded-md border border-border bg-muted/30 p-3 space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Oldest undecrypted packet</span>
|
||||
<span className="font-medium">
|
||||
{formatTime(health.oldest_undecrypted_timestamp)}
|
||||
<span className="text-muted-foreground ml-1">
|
||||
({Math.floor((Date.now() / 1000 - health.oldest_undecrypted_timestamp) / 86400)}{' '}
|
||||
days old)
|
||||
<span className="text-sm">Database size</span>
|
||||
<span className="text-sm font-semibold">{health?.database_size_mb ?? '?'} MB</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm">Oldest undecrypted packet</span>
|
||||
{health?.oldest_undecrypted_timestamp ? (
|
||||
<span className="text-sm font-semibold">
|
||||
{formatTime(health.oldest_undecrypted_timestamp)}
|
||||
<span className="font-normal text-muted-foreground ml-1">
|
||||
({Math.floor((Date.now() / 1000 - health.oldest_undecrypted_timestamp) / 86400)}{' '}
|
||||
days)
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">None</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Oldest undecrypted packet</span>
|
||||
<span className="text-muted-foreground">None</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>Delete Undecrypted Packets</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Permanently deletes stored raw packets containing DMs and channel messages that have not
|
||||
yet been decrypted. These packets are retained in case you later obtain the correct key —
|
||||
once deleted, these messages can never be recovered or decrypted.
|
||||
</p>
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="retention-days" className="text-xs">
|
||||
Older than (days)
|
||||
</Label>
|
||||
<Input
|
||||
id="retention-days"
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
value={retentionDays}
|
||||
onChange={(e) => setRetentionDays(e.target.value)}
|
||||
className="w-24"
|
||||
/>
|
||||
{/* ── Storage Cleanup ── */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base">Storage Cleanup</Label>
|
||||
|
||||
<div className="rounded-md border border-border p-3 space-y-2">
|
||||
<Label className="text-sm">Delete Undecrypted Packets</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Permanently deletes stored raw packets that have not yet been decrypted. These are
|
||||
retained in case you later obtain the correct key — once deleted, these messages can
|
||||
never be recovered.
|
||||
</p>
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="retention-days" className="text-xs text-muted-foreground">
|
||||
Older than (days)
|
||||
</Label>
|
||||
<Input
|
||||
id="retention-days"
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
value={retentionDays}
|
||||
onChange={(e) => setRetentionDays(e.target.value)}
|
||||
className="w-24"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCleanup}
|
||||
disabled={cleaning}
|
||||
className="border-destructive/50 text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
{cleaning ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-border p-3 space-y-2">
|
||||
<Label className="text-sm">Purge Archival Raw Packets</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Deletes the raw packet bytes behind messages that are already decrypted and visible in
|
||||
chat. This frees space but removes packet-analysis availability for those messages. It
|
||||
does not affect displayed messages or future decryption.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCleanup}
|
||||
disabled={cleaning}
|
||||
className="border-destructive/50 text-destructive hover:bg-destructive/10"
|
||||
onClick={handlePurgeDecryptedRawPackets}
|
||||
disabled={purgingDecryptedRaw}
|
||||
className="w-full border-warning/50 text-warning hover:bg-warning/10"
|
||||
>
|
||||
{cleaning ? 'Deleting...' : 'Permanently Delete'}
|
||||
{purgingDecryptedRaw ? 'Purging...' : 'Purge Archival Packets'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── DM Decryption ── */}
|
||||
<div className="space-y-3">
|
||||
<Label>Purge Archival Raw Packets</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Deletes archival copies of raw packet bytes for messages that are already decrypted and
|
||||
visible in your chat history.{' '}
|
||||
<em className="text-muted-foreground/80">
|
||||
This will not affect any displayed messages or your ability to do historical decryption,
|
||||
but it will remove packet-analysis availability for those historical messages.
|
||||
</em>{' '}
|
||||
The raw bytes are only useful for manual packet analysis.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handlePurgeDecryptedRawPackets}
|
||||
disabled={purgingDecryptedRaw}
|
||||
className="w-full border-warning/50 text-warning hover:bg-warning/10"
|
||||
>
|
||||
{purgingDecryptedRaw ? 'Purging Archival Raw Packets...' : 'Purge Archival Raw Packets'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>DM Decryption</Label>
|
||||
<Label className="text-base">DM Decryption</Label>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -209,15 +229,129 @@ export function SettingsDatabaseSection({
|
||||
|
||||
<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 && (
|
||||
<div className="text-sm text-destructive" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={handleSave} disabled={busy} className="w-full">
|
||||
{busy ? 'Saving...' : 'Save Settings'}
|
||||
</Button>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Contact Management ── */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-base">Contact Management</Label>
|
||||
</div>
|
||||
|
||||
{/* Block discovery of new node types */}
|
||||
<div className="space-y-3">
|
||||
<Label>Block Discovery of New Node Types</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Checked types will be ignored when heard via advertisement. Existing contacts of these
|
||||
types are still updated. This does not affect contacts added manually or via DM.
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{(
|
||||
[
|
||||
[1, 'Block clients'],
|
||||
[2, 'Block repeaters'],
|
||||
[3, 'Block room servers'],
|
||||
[4, 'Block sensors'],
|
||||
] as const
|
||||
).map(([typeCode, label]) => {
|
||||
const checked = discoveryBlockedTypes.includes(typeCode);
|
||||
return (
|
||||
<label key={typeCode} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() =>
|
||||
setDiscoveryBlockedTypes((prev) =>
|
||||
checked ? prev.filter((t) => t !== typeCode) : [...prev, typeCode]
|
||||
)
|
||||
}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{discoveryBlockedTypes.length > 0 && (
|
||||
<p className="text-xs text-warning">
|
||||
New{' '}
|
||||
{discoveryBlockedTypes
|
||||
.map((t) =>
|
||||
t === 1 ? 'clients' : t === 2 ? 'repeaters' : t === 3 ? 'room servers' : 'sensors'
|
||||
)
|
||||
.join(', ')}{' '}
|
||||
heard via advertisement will not be added to your contact list.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Blocked contacts list */}
|
||||
<div className="space-y-3">
|
||||
<Label>Blocked Contacts</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Blocking only hides messages from the UI. MQTT forwarding and bot responses are not
|
||||
affected. Messages are still stored and will reappear if unblocked.
|
||||
Blocked contacts are hidden from the sidebar. Blocking only hides messages from the UI —
|
||||
MQTT forwarding and bot responses are not affected. Messages are still stored and will
|
||||
reappear if unblocked.
|
||||
</p>
|
||||
|
||||
{blockedKeys.length === 0 && blockedNames.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">No blocked contacts</p>
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No blocked contacts. Block contacts from their info pane, viewed by clicking their
|
||||
avatar in any channel, or their name within the top status bar with the conversation
|
||||
open.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{blockedKeys.length > 0 && (
|
||||
@@ -268,15 +402,25 @@ export function SettingsDatabaseSection({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-destructive" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<Separator />
|
||||
|
||||
<Button onClick={handleSave} disabled={busy} className="w-full">
|
||||
{busy ? 'Saving...' : 'Save Settings'}
|
||||
</Button>
|
||||
{/* Bulk delete */}
|
||||
<div className="space-y-3">
|
||||
<Label>Bulk Delete Contacts</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Remove multiple contacts or repeaters at once. Useful for cleaning up spam or unwanted
|
||||
nodes. Message history will be preserved.
|
||||
</p>
|
||||
<Button variant="outline" className="w-full" onClick={() => setBulkDeleteOpen(true)}>
|
||||
Open Bulk Delete
|
||||
</Button>
|
||||
<BulkDeleteContactsModal
|
||||
open={bulkDeleteOpen}
|
||||
onClose={() => setBulkDeleteOpen(false)}
|
||||
contacts={contacts}
|
||||
onDeleted={(keys) => onBulkDeleteContacts?.(keys)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -537,7 +537,7 @@ function CreateIntegrationDialog({
|
||||
<div className="space-y-4">
|
||||
{sectionedOptions.map((group) => (
|
||||
<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}
|
||||
</div>
|
||||
{group.options.map((option) => {
|
||||
@@ -577,7 +577,7 @@ function CreateIntegrationDialog({
|
||||
{selectedOption ? (
|
||||
<>
|
||||
<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}
|
||||
</div>
|
||||
<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/...`;
|
||||
}
|
||||
|
||||
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 || '')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
if (targets.length === 0) return 'No targets configured';
|
||||
|
||||
const joined = targets.join(', ');
|
||||
if (joined.length <= maxLength) return joined;
|
||||
return `${joined.slice(0, maxLength - 3)}...`;
|
||||
return targets.map(censorAppriseUrl).join(', ');
|
||||
}
|
||||
|
||||
function formatSqsQueueSummary(config: Record<string, unknown>) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
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 { Label } from '../ui/label';
|
||||
import { Separator } from '../ui/separator';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { ContactAvatar } from '../ContactAvatar';
|
||||
import {
|
||||
captureLastViewedConversationFromHash,
|
||||
@@ -37,6 +39,13 @@ export function SettingsLocalSection({
|
||||
const [reopenLastConversation, setReopenLastConversation] = useState(
|
||||
getReopenLastConversationEnabled
|
||||
);
|
||||
const [darkMap, setDarkMap] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem('remoteterm-dark-map') === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const [localLabelText, setLocalLabelText] = useState(() => getLocalLabel().text);
|
||||
const [localLabelColor, setLocalLabelColor] = useState(() => getLocalLabel().color);
|
||||
const [fontScale, setFontScale] = useState(getSavedFontScale);
|
||||
@@ -233,11 +242,31 @@ export function SettingsLocalSection({
|
||||
/>
|
||||
<span className="text-sm">Reopen to last viewed channel/conversation</span>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemePreview({ className }: { className?: string }) {
|
||||
const [showStyleRef, setShowStyleRef] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border border-border bg-card p-3 ${className ?? ''}`}>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
@@ -271,7 +300,7 @@ function ThemePreview({ className }: { className?: string }) {
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<PreviewSidebarRow
|
||||
active
|
||||
@@ -289,7 +318,7 @@ function ThemePreview({ className }: { className?: string }) {
|
||||
leading={<ContactAvatar name="Alice" publicKey={'ab'.repeat(32)} size={24} />}
|
||||
label="Alice"
|
||||
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
|
||||
</span>
|
||||
}
|
||||
@@ -298,13 +327,267 @@ function ThemePreview({ className }: { className?: string }) {
|
||||
leading={<ContactAvatar name="Mesh Ops" publicKey={'cd'.repeat(32)} size={24} />}
|
||||
label="Mesh Ops"
|
||||
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
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -327,7 +610,7 @@ function PreviewMessage({
|
||||
return (
|
||||
<div className={`flex ${alignRight ? 'justify-end' : 'justify-start'}`}>
|
||||
<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>
|
||||
</div>
|
||||
@@ -347,17 +630,20 @@ function PreviewSidebarRow({
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-md border-l-2 px-3 py-2 text-[13px] ${
|
||||
data-active={active ? 'true' : undefined}
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
{leading}
|
||||
<span className={`min-w-0 flex-1 truncate ${active ? 'font-medium' : 'text-foreground'}`}>
|
||||
<span className="sidebar-tool-icon" aria-hidden="true">
|
||||
{leading}
|
||||
</span>
|
||||
<span className={`sidebar-tool-label min-w-0 flex-1 truncate ${active ? 'font-medium' : ''}`}>
|
||||
{label}
|
||||
</span>
|
||||
{badge}
|
||||
{!badge && (
|
||||
<span className="text-muted-foreground" aria-hidden="true">
|
||||
<span className="sidebar-tool-icon" aria-hidden="true">
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -390,9 +390,9 @@ export function SettingsRadioSection({
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Connection display */}
|
||||
{/* ── Connection ── */}
|
||||
<div className="space-y-3">
|
||||
<Label>Connection</Label>
|
||||
<Label className="text-base">Connection</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
@@ -428,15 +428,58 @@ export function SettingsRadioSection({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Radio Name */}
|
||||
<Separator />
|
||||
|
||||
{/* ── Identity ── */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-base">Identity</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Radio Name</Label>
|
||||
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="public-key">Public Key</Label>
|
||||
<Input id="public-key" value={config.public_key} disabled className="font-mono text-xs" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="private-key">Set Private Key (write-only)</Label>
|
||||
<Input
|
||||
id="private-key"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={privateKey}
|
||||
onChange={(e) => setPrivateKey(e.target.value)}
|
||||
placeholder="64-character hex private key"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSetPrivateKey}
|
||||
disabled={identityBusy || identityRebooting || !privateKey.trim()}
|
||||
className="w-full border-destructive/50 text-destructive hover:bg-destructive/10"
|
||||
variant="outline"
|
||||
>
|
||||
{identityBusy || identityRebooting
|
||||
? 'Setting & Rebooting...'
|
||||
: 'Set Private Key & Reboot'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{identityError && (
|
||||
<div className="text-sm text-destructive" role="alert">
|
||||
{identityError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Radio Config */}
|
||||
{/* ── Radio Parameters ── */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-base">Radio Parameters</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="preset">Preset</Label>
|
||||
<select
|
||||
@@ -518,11 +561,36 @@ export function SettingsRadioSection({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.path_hash_mode_supported && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="path-hash-mode">Path Hash Mode</Label>
|
||||
<select
|
||||
id="path-hash-mode"
|
||||
value={pathHashMode}
|
||||
onChange={(e) => setPathHashMode(e.target.value)}
|
||||
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
>
|
||||
<option value="0">1 byte (default)</option>
|
||||
<option value="1">2 bytes</option>
|
||||
<option value="2">3 bytes</option>
|
||||
</select>
|
||||
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-xs text-warning">
|
||||
<p className="font-semibold mb-1">Compatibility Warning</p>
|
||||
<p>
|
||||
ALL nodes along a message's route — your radio, every repeater, and the
|
||||
recipient — must be running firmware that supports the selected mode. Messages
|
||||
sent with 2-byte or 3-byte hops will be dropped by any node on older firmware.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Location ── */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Location</Label>
|
||||
<Label className="text-base">Location</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -585,53 +653,8 @@ export function SettingsRadioSection({
|
||||
library.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3">
|
||||
<Checkbox
|
||||
id="multi-acks-enabled"
|
||||
checked={multiAcksEnabled}
|
||||
onCheckedChange={(checked) => setMultiAcksEnabled(checked === true)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="multi-acks-enabled">Extra Direct ACK Transmission</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When enabled, the radio sends one extra direct ACK transmission before the normal
|
||||
ACK for received direct messages. This is a firmware-level receive behavior, not a
|
||||
RemoteTerm retry setting.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.path_hash_mode_supported && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="path-hash-mode">Path Hash Mode</Label>
|
||||
<select
|
||||
id="path-hash-mode"
|
||||
value={pathHashMode}
|
||||
onChange={(e) => setPathHashMode(e.target.value)}
|
||||
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
>
|
||||
<option value="0">1 byte (default)</option>
|
||||
<option value="1">2 bytes</option>
|
||||
<option value="2">3 bytes</option>
|
||||
</select>
|
||||
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-xs text-warning">
|
||||
<p className="font-semibold mb-1">Compatibility Warning</p>
|
||||
<p>
|
||||
ALL nodes along a message's route — your radio, every repeater, and the
|
||||
recipient — must be running firmware that supports the selected mode. Messages
|
||||
sent with 2-byte or 3-byte hops will be dropped by any node on older firmware.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-destructive" role="alert">
|
||||
{error}
|
||||
@@ -657,64 +680,48 @@ export function SettingsRadioSection({
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Keys */}
|
||||
{/* ── Messaging ── */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="public-key">Public Key</Label>
|
||||
<Input id="public-key" value={config.public_key} disabled className="font-mono text-xs" />
|
||||
<Label className="text-base">Messaging</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="private-key">Set Private Key (write-only)</Label>
|
||||
<Input
|
||||
id="private-key"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={privateKey}
|
||||
onChange={(e) => setPrivateKey(e.target.value)}
|
||||
placeholder="64-character hex private key"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSetPrivateKey}
|
||||
disabled={identityBusy || identityRebooting || !privateKey.trim()}
|
||||
className="w-full border-destructive/50 text-destructive hover:bg-destructive/10"
|
||||
variant="outline"
|
||||
>
|
||||
{identityBusy || identityRebooting
|
||||
? 'Setting & Rebooting...'
|
||||
: 'Set Private Key & Reboot'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{identityError && (
|
||||
<div className="text-sm text-destructive" role="alert">
|
||||
{identityError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Flood & Advert Control */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-base">Flood & Advert Control</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="advert-interval">Periodic Advertising Interval</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="advert-interval"
|
||||
type="number"
|
||||
min="0"
|
||||
value={advertIntervalHours}
|
||||
onChange={(e) => setAdvertIntervalHours(e.target.value)}
|
||||
className="w-28"
|
||||
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3">
|
||||
<Checkbox
|
||||
id="multi-acks-enabled"
|
||||
checked={multiAcksEnabled}
|
||||
onCheckedChange={(checked) => setMultiAcksEnabled(checked === true)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">hours (0 = off)</span>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="multi-acks-enabled">Extra Direct ACK Transmission</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When enabled, the radio sends one extra direct ACK transmission before the normal ACK
|
||||
for received direct messages. This is a firmware-level receive behavior, not a
|
||||
RemoteTerm retry setting.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div 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>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour.
|
||||
Recommended: 24 hours or higher.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -746,6 +753,13 @@ export function SettingsRadioSection({
|
||||
Configured radio contact capacity. Favorites reload first, then background maintenance
|
||||
refills to about 80% of this value and offloads once occupancy reaches about 95%.
|
||||
</p>
|
||||
{health?.radio_device_info?.max_contacts != null &&
|
||||
Number(maxRadioContacts) > health.radio_device_info.max_contacts && (
|
||||
<p className="text-xs text-warning">
|
||||
Your radio reports a hardware limit of {health.radio_device_info.max_contacts}{' '}
|
||||
contacts. The effective cap will be limited to what the radio supports.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{floodError && (
|
||||
@@ -760,8 +774,28 @@ export function SettingsRadioSection({
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Advertising & Discovery ── */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-base">Hear & Be Heard</Label>
|
||||
<Label className="text-base">Advertising & Discovery</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="advert-interval">Periodic Advertising Interval</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="advert-interval"
|
||||
type="number"
|
||||
min="0"
|
||||
value={advertIntervalHours}
|
||||
onChange={(e) => setAdvertIntervalHours(e.target.value)}
|
||||
className="w-28"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">hours (0 = off)</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour.
|
||||
Recommended: 24 hours or higher.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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',
|
||||
},
|
||||
}}
|
||||
closeButton
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -95,7 +95,7 @@ export function VisualizerControls({
|
||||
{PACKET_LEGEND_ITEMS.map((item) => (
|
||||
<div key={item.label} className="flex items-center gap-2">
|
||||
<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 }}
|
||||
>
|
||||
{item.label}
|
||||
|
||||
@@ -2,17 +2,8 @@ import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { api } from '../api';
|
||||
import { takePrefetchOrFetch } from '../prefetch';
|
||||
import { toast } from '../components/ui/sonner';
|
||||
import {
|
||||
initLastMessageTimes,
|
||||
loadLocalStorageLastMessageTimes,
|
||||
loadLocalStorageSortOrder,
|
||||
clearLocalStorageConversationState,
|
||||
} from '../utils/conversationState';
|
||||
import {
|
||||
isFavorite,
|
||||
loadLocalStorageFavorites,
|
||||
clearLocalStorageFavorites,
|
||||
} from '../utils/favorites';
|
||||
import { initLastMessageTimes } from '../utils/conversationState';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
import type { AppSettings, AppSettingsUpdate, Favorite } from '../types';
|
||||
|
||||
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(() => {
|
||||
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;
|
||||
|
||||
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 {
|
||||
const result = await api.migratePreferences({
|
||||
favorites: localFavorites,
|
||||
sort_order: localSortOrder,
|
||||
last_message_times: localLastMessageTimes,
|
||||
});
|
||||
|
||||
if (result.migrated) {
|
||||
toast.success('Preferences migrated', {
|
||||
description: `Migrated ${localFavorites.length} favorites to server`,
|
||||
});
|
||||
for (const f of localFavorites) {
|
||||
await api.toggleFavorite(f.type, f.id);
|
||||
}
|
||||
|
||||
setAppSettings(result.settings);
|
||||
initLastMessageTimes(result.settings.last_message_times ?? {});
|
||||
|
||||
clearLocalStorageFavorites();
|
||||
clearLocalStorageConversationState();
|
||||
localStorage.removeItem(FAVORITES_KEY);
|
||||
await fetchAppSettings();
|
||||
} catch (err) {
|
||||
console.error('Failed to migrate preferences:', err);
|
||||
console.error('Failed to migrate legacy favorites:', err);
|
||||
}
|
||||
};
|
||||
|
||||
migratePreferences();
|
||||
}, [appSettings]);
|
||||
migrate();
|
||||
}, [appSettings, fetchAppSettings]);
|
||||
|
||||
return {
|
||||
appSettings,
|
||||
@@ -182,5 +182,6 @@ export function useAppSettings() {
|
||||
handleToggleFavorite,
|
||||
handleToggleBlockedKey,
|
||||
handleToggleBlockedName,
|
||||
handleToggleTrackedTelemetry,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { takePrefetchOrFetch } from '../prefetch';
|
||||
import { toast } from '../components/ui/sonner';
|
||||
import { getContactDisplayName } from '../utils/pubkey';
|
||||
import { findPublicChannel, PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME } from '../utils/publicChannel';
|
||||
import type { Channel, Contact, Conversation } from '../types';
|
||||
import type { BulkCreateHashtagChannelsResult, Channel, Contact, Conversation } from '../types';
|
||||
|
||||
interface UseContactsAndChannelsArgs {
|
||||
setActiveConversation: (conv: Conversation | null) => void;
|
||||
@@ -112,6 +112,24 @@ export function useContactsAndChannels({
|
||||
[fetchUndecryptedCountInternal, setActiveConversation]
|
||||
);
|
||||
|
||||
const handleBulkCreateHashtagChannels = useCallback(
|
||||
async (
|
||||
channelNames: string[],
|
||||
tryHistorical: boolean
|
||||
): Promise<BulkCreateHashtagChannelsResult> => {
|
||||
const result = await api.bulkCreateHashtagChannels(channelNames, tryHistorical);
|
||||
const data = await api.getChannels();
|
||||
setChannels(data);
|
||||
|
||||
if (tryHistorical && result.decrypt_started) {
|
||||
fetchUndecryptedCountInternal();
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
[fetchUndecryptedCountInternal]
|
||||
);
|
||||
|
||||
const handleDeleteChannel = useCallback(
|
||||
async (key: string) => {
|
||||
if (!confirm('Delete this channel? Message history will be preserved.')) return;
|
||||
@@ -190,6 +208,7 @@ export function useContactsAndChannels({
|
||||
handleCreateContact,
|
||||
handleCreateChannel,
|
||||
handleCreateHashtagChannel,
|
||||
handleBulkCreateHashtagChannels,
|
||||
handleDeleteChannel,
|
||||
handleDeleteContact,
|
||||
};
|
||||
|
||||
@@ -21,6 +21,10 @@ interface UseConversationActionsResult {
|
||||
channelKey: string,
|
||||
floodScopeOverride: string
|
||||
) => Promise<void>;
|
||||
handleSetChannelPathHashModeOverride: (
|
||||
channelKey: string,
|
||||
pathHashModeOverride: number | null
|
||||
) => Promise<void>;
|
||||
handleSenderClick: (sender: string) => void;
|
||||
handleTrace: () => Promise<void>;
|
||||
handlePathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
|
||||
@@ -106,6 +110,25 @@ export function useConversationActions({
|
||||
[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(
|
||||
(sender: string) => {
|
||||
messageInputRef.current?.appendText(`@[${sender}] `);
|
||||
@@ -143,6 +166,7 @@ export function useConversationActions({
|
||||
handleSendMessage,
|
||||
handleResendChannelMessage,
|
||||
handleSetChannelFloodScopeOverride,
|
||||
handleSetChannelPathHashModeOverride,
|
||||
handleSenderClick,
|
||||
handleTrace,
|
||||
handlePathDiscovery,
|
||||
|
||||
@@ -56,6 +56,14 @@
|
||||
--badge-mention: var(--destructive);
|
||||
--badge-mention-foreground: var(--destructive-foreground);
|
||||
|
||||
/* Sidebar navigation accents */
|
||||
--sidebar-icon-color: hsl(var(--foreground));
|
||||
--sidebar-icon-hover-color: hsl(var(--foreground));
|
||||
--sidebar-icon-active-color: hsl(var(--foreground));
|
||||
--sidebar-label-color: hsl(var(--muted-foreground));
|
||||
--sidebar-label-hover-color: hsl(var(--foreground));
|
||||
--sidebar-label-active-color: hsl(var(--foreground));
|
||||
|
||||
/* Error toast */
|
||||
--toast-error: 0 30% 14%;
|
||||
--toast-error-foreground: 0 56% 77%;
|
||||
@@ -126,6 +134,50 @@
|
||||
animation: message-highlight 2s ease-out forwards;
|
||||
}
|
||||
|
||||
.sidebar-tool-icon {
|
||||
display: inline-flex;
|
||||
height: 1.5rem;
|
||||
width: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.45rem;
|
||||
color: var(--sidebar-icon-color);
|
||||
opacity: 1;
|
||||
transition:
|
||||
color 150ms ease,
|
||||
opacity 150ms ease;
|
||||
}
|
||||
|
||||
.sidebar-tool-icon svg {
|
||||
stroke-width: 2.35;
|
||||
}
|
||||
|
||||
.sidebar-tool-label {
|
||||
color: var(--sidebar-label-color);
|
||||
transition: color 150ms ease;
|
||||
}
|
||||
|
||||
.sidebar-action-row:hover .sidebar-tool-icon,
|
||||
.sidebar-action-row:focus-visible .sidebar-tool-icon {
|
||||
color: var(--sidebar-icon-hover-color);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar-action-row:hover .sidebar-tool-label,
|
||||
.sidebar-action-row:focus-visible .sidebar-tool-label {
|
||||
color: var(--sidebar-label-hover-color);
|
||||
}
|
||||
|
||||
.sidebar-action-row[data-active='true'] .sidebar-tool-icon {
|
||||
color: var(--sidebar-icon-active-color);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar-action-row[data-active='true'] .sidebar-tool-label {
|
||||
color: var(--sidebar-label-active-color);
|
||||
}
|
||||
|
||||
/* Constrain CodeMirror editor width */
|
||||
.cm-editor {
|
||||
max-width: 100% !important;
|
||||
|
||||
@@ -24,7 +24,6 @@ const mocks = vi.hoisted(() => ({
|
||||
requestTrace: vi.fn(),
|
||||
updateRadioConfig: vi.fn(),
|
||||
setPrivateKey: vi.fn(),
|
||||
migratePreferences: vi.fn(),
|
||||
},
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
@@ -190,9 +189,8 @@ const baseSettings = {
|
||||
max_radio_contacts: 200,
|
||||
favorites: [] as Array<{ type: 'channel' | 'contact'; id: string }>,
|
||||
auto_decrypt_dm_on_advert: false,
|
||||
sidebar_sort_order: 'recent' as const,
|
||||
last_message_times: {},
|
||||
preferences_migrated: false,
|
||||
|
||||
advert_interval: 0,
|
||||
last_advert_time: 0,
|
||||
flood_scope: '',
|
||||
|
||||
@@ -11,7 +11,6 @@ const mocks = vi.hoisted(() => ({
|
||||
getUndecryptedPacketCount: vi.fn(),
|
||||
getChannels: vi.fn(),
|
||||
getContacts: vi.fn(),
|
||||
migratePreferences: vi.fn(),
|
||||
},
|
||||
useConversationMessagesCalls: vi.fn(),
|
||||
}));
|
||||
@@ -218,9 +217,8 @@ describe('App search jump target handling', () => {
|
||||
max_radio_contacts: 200,
|
||||
favorites: [],
|
||||
auto_decrypt_dm_on_advert: false,
|
||||
sidebar_sort_order: 'recent',
|
||||
last_message_times: {},
|
||||
preferences_migrated: true,
|
||||
|
||||
advert_interval: 0,
|
||||
last_advert_time: 0,
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ const mocks = vi.hoisted(() => ({
|
||||
getUndecryptedPacketCount: vi.fn(),
|
||||
getChannels: vi.fn(),
|
||||
getContacts: vi.fn(),
|
||||
migratePreferences: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -169,9 +168,8 @@ describe('App startup hash resolution', () => {
|
||||
max_radio_contacts: 200,
|
||||
favorites: [],
|
||||
auto_decrypt_dm_on_advert: false,
|
||||
sidebar_sort_order: 'recent',
|
||||
last_message_times: {},
|
||||
preferences_migrated: true,
|
||||
|
||||
advert_interval: 0,
|
||||
last_advert_time: 0,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { BulkAddChannelResultModal } from '../components/BulkAddChannelResultModal';
|
||||
|
||||
describe('BulkAddChannelResultModal', () => {
|
||||
it('renders links only for newly created rooms', () => {
|
||||
render(
|
||||
<BulkAddChannelResultModal
|
||||
open
|
||||
onClose={() => {}}
|
||||
result={{
|
||||
created_channels: [
|
||||
{
|
||||
key: 'AA'.repeat(16),
|
||||
name: '#ops',
|
||||
is_hashtag: true,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
},
|
||||
{
|
||||
key: 'BB'.repeat(16),
|
||||
name: '#mesh-room',
|
||||
is_hashtag: true,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
},
|
||||
],
|
||||
existing_count: 3,
|
||||
invalid_names: ['bad_room'],
|
||||
decrypt_started: true,
|
||||
decrypt_total_packets: 8,
|
||||
message: 'Created 2 rooms',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const opsLink = screen.getByRole('link', { name: '#ops' });
|
||||
const meshLink = screen.getByRole('link', { name: '#mesh-room' });
|
||||
|
||||
expect(opsLink.getAttribute('href')).toContain('#channel/');
|
||||
expect(meshLink.getAttribute('href')).toContain('#channel/');
|
||||
expect(screen.queryByRole('link', { name: /bad_room/i })).toBeNull();
|
||||
expect(screen.getByText(/Ignored invalid room names: bad_room/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,15 @@ function makeDetail(channel: Channel): ChannelDetail {
|
||||
first_message_at: null,
|
||||
unique_sender_count: 0,
|
||||
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(),
|
||||
onSendMessage: vi.fn(async () => {}),
|
||||
onToggleNotifications: vi.fn(),
|
||||
trackedTelemetryRepeaters: [],
|
||||
onToggleTrackedTelemetry: vi.fn(async () => {}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1227,7 +1227,7 @@ describe('SettingsFanoutSection', () => {
|
||||
|
||||
const group = await screen.findByRole('group', { name: 'Integration Apprise Feed' });
|
||||
expect(
|
||||
within(group).getByText(/discord:\/\/abc, mailto:\/\/one@example.com/)
|
||||
within(group).getByText(/discord:\/\/\*{8}, mailto:\/\/\*{8}, mailto:\/\/\*{8}/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -169,6 +169,32 @@ describe('MessageList channel sender rendering', () => {
|
||||
expect(onChannelReferenceClick).toHaveBeenCalledWith('#mesh-room');
|
||||
});
|
||||
|
||||
it('links valid channel references when followed by clause punctuation', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChannelReferenceClick = vi.fn();
|
||||
|
||||
render(
|
||||
<MessageList
|
||||
messages={[
|
||||
createMessage({
|
||||
text: 'Alice: Check #mesh-room, then #ops-room; then #alpha-room.',
|
||||
}),
|
||||
]}
|
||||
contacts={[]}
|
||||
loading={false}
|
||||
onChannelReferenceClick={onChannelReferenceClick}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '#mesh-room' }));
|
||||
await user.click(screen.getByRole('button', { name: '#ops-room' }));
|
||||
await user.click(screen.getByRole('button', { name: '#alpha-room' }));
|
||||
|
||||
expect(onChannelReferenceClick).toHaveBeenNthCalledWith(1, '#mesh-room');
|
||||
expect(onChannelReferenceClick).toHaveBeenNthCalledWith(2, '#ops-room');
|
||||
expect(onChannelReferenceClick).toHaveBeenNthCalledWith(3, '#alpha-room');
|
||||
});
|
||||
|
||||
it('links valid channel references in direct messages too', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChannelReferenceClick = vi.fn();
|
||||
|
||||
@@ -122,11 +122,21 @@ describe('linked channel references', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores invalid or embedded channel-like text', () => {
|
||||
it('finds linked channel references terminated by clause punctuation', () => {
|
||||
expect(
|
||||
findLinkedChannelReferences(
|
||||
'skip #Bad #bad--name abc#ops #ops- #opsRoom #ops_room #good-room,'
|
||||
)
|
||||
).toEqual([]);
|
||||
findLinkedChannelReferences('Join #mesh-room, then #ops2; finally #alpha-room.')
|
||||
).toEqual([
|
||||
{ label: '#mesh-room', start: 5, end: 15 },
|
||||
{ label: '#ops2', start: 22, end: 27 },
|
||||
{ label: '#alpha-room', start: 37, end: 48 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores invalid or embedded channel-like text', () => {
|
||||
const references = findLinkedChannelReferences(
|
||||
'skip #Bad #bad--name abc#ops #ops- #opsRoom #ops_room #good-room,'
|
||||
);
|
||||
|
||||
expect(references.map((reference) => reference.label)).toEqual(['#good-room']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ describe('NewMessageModal form reset', () => {
|
||||
const onCreateContact = vi.fn().mockResolvedValue(undefined);
|
||||
const onCreateChannel = vi.fn().mockResolvedValue(undefined);
|
||||
const onCreateHashtagChannel = vi.fn().mockResolvedValue(undefined);
|
||||
const onBulkAddHashtagChannels = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -44,6 +45,7 @@ describe('NewMessageModal form reset', () => {
|
||||
onCreateContact={onCreateContact}
|
||||
onCreateChannel={onCreateChannel}
|
||||
onCreateHashtagChannel={onCreateHashtagChannel}
|
||||
onBulkAddHashtagChannels={onBulkAddHashtagChannels}
|
||||
{...overrides}
|
||||
/>
|
||||
);
|
||||
@@ -111,6 +113,53 @@ describe('NewMessageModal form reset', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulk hashtag tab', () => {
|
||||
it('is only visible when enabled', () => {
|
||||
renderModal();
|
||||
expect(screen.queryByRole('tab', { name: 'Bulk Add Channel' })).toBeNull();
|
||||
});
|
||||
|
||||
it('opens on the bulk tab when enabled and submits normalized room names', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal(true, { showBulkAddChannelTab: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('tab', { name: 'Bulk Add Channel' })).toHaveAttribute(
|
||||
'data-state',
|
||||
'active'
|
||||
);
|
||||
});
|
||||
|
||||
await user.type(
|
||||
screen.getByRole('textbox', { name: 'Bulk channel names' }),
|
||||
'#Ops{enter}mesh-room another-room #Ops'
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: 'Add Channels' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onBulkAddHashtagChannels).toHaveBeenCalledWith(
|
||||
['#ops', '#mesh-room', '#another-room'],
|
||||
false
|
||||
);
|
||||
});
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows invalid bulk room names before submitting', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal(true, { showBulkAddChannelTab: true });
|
||||
|
||||
await user.type(
|
||||
screen.getByRole('textbox', { name: 'Bulk channel names' }),
|
||||
'good-room bad_room'
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: 'Add Channels' }));
|
||||
|
||||
expect(onBulkAddHashtagChannels).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('Invalid room names: bad_room')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('new-contact tab', () => {
|
||||
it('clears name and key after successful Create', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,14 @@ vi.mock('../hooks/useRepeaterDashboard', () => ({
|
||||
useRepeaterDashboard: () => mockHook,
|
||||
}));
|
||||
|
||||
// Mock api module (TelemetryHistoryPane fetches on mount)
|
||||
vi.mock('../api', () => ({
|
||||
api: {
|
||||
repeaterTelemetryHistory: vi.fn().mockResolvedValue([]),
|
||||
setContactRoutingOverride: vi.fn().mockResolvedValue({ status: 'ok' }),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock sonner toast
|
||||
vi.mock('../components/ui/sonner', () => ({
|
||||
toast: {
|
||||
@@ -116,8 +124,20 @@ const defaultProps = {
|
||||
onToggleNotifications: vi.fn(),
|
||||
onToggleFavorite: vi.fn(),
|
||||
onDeleteContact: vi.fn(),
|
||||
trackedTelemetryRepeaters: [] as string[],
|
||||
onToggleTrackedTelemetry: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe('RepeaterDashboard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -418,6 +438,7 @@ describe('RepeaterDashboard', () => {
|
||||
flood_dups: 1,
|
||||
direct_dups: 0,
|
||||
full_events: 0,
|
||||
telemetry_history: [],
|
||||
};
|
||||
|
||||
render(<RepeaterDashboard {...defaultProps} />);
|
||||
@@ -634,4 +655,106 @@ describe('RepeaterDashboard', () => {
|
||||
overrideSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('telemetry history', () => {
|
||||
beforeEach(async () => {
|
||||
const { api } = await import('../api');
|
||||
vi.mocked(api.repeaterTelemetryHistory).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('loads telemetry history on mount when logged in', async () => {
|
||||
const { api } = await import('../api');
|
||||
mockHook.loggedIn = true;
|
||||
|
||||
render(<RepeaterDashboard {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.repeaterTelemetryHistory).toHaveBeenCalledWith(REPEATER_KEY);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows telemetry history pane in logged-in view even before status fetch', () => {
|
||||
mockHook.loggedIn = true;
|
||||
|
||||
render(<RepeaterDashboard {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Telemetry History')).toBeInTheDocument();
|
||||
expect(screen.getByText(/No history yet/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates history from live status fetch', async () => {
|
||||
const { api } = await import('../api');
|
||||
const historySpy = vi.mocked(api.repeaterTelemetryHistory);
|
||||
const liveEntry = { timestamp: 1700000000, data: { battery_volts: 4.2 } };
|
||||
historySpy.mockResolvedValue([]);
|
||||
|
||||
mockHook.loggedIn = true;
|
||||
mockHook.paneData.status = {
|
||||
battery_volts: 4.2,
|
||||
tx_queue_len: 0,
|
||||
noise_floor_dbm: -120,
|
||||
last_rssi_dbm: -85,
|
||||
last_snr_db: 7.5,
|
||||
packets_received: 100,
|
||||
packets_sent: 50,
|
||||
airtime_seconds: 600,
|
||||
rx_airtime_seconds: 1200,
|
||||
uptime_seconds: 86400,
|
||||
sent_flood: 10,
|
||||
sent_direct: 40,
|
||||
recv_flood: 30,
|
||||
recv_direct: 70,
|
||||
flood_dups: 1,
|
||||
direct_dups: 0,
|
||||
full_events: 0,
|
||||
telemetry_history: [liveEntry],
|
||||
};
|
||||
|
||||
render(<RepeaterDashboard {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('1 samples')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not let an older preload overwrite newer live status history', async () => {
|
||||
const { api } = await import('../api');
|
||||
const historySpy = vi.mocked(api.repeaterTelemetryHistory);
|
||||
const deferred = createDeferred<{ timestamp: number; data: { battery_volts: number } }[]>();
|
||||
historySpy.mockReturnValue(deferred.promise);
|
||||
|
||||
mockHook.loggedIn = true;
|
||||
mockHook.paneData.status = {
|
||||
battery_volts: 4.2,
|
||||
tx_queue_len: 0,
|
||||
noise_floor_dbm: -120,
|
||||
last_rssi_dbm: -85,
|
||||
last_snr_db: 7.5,
|
||||
packets_received: 100,
|
||||
packets_sent: 50,
|
||||
airtime_seconds: 600,
|
||||
rx_airtime_seconds: 1200,
|
||||
uptime_seconds: 86400,
|
||||
sent_flood: 10,
|
||||
sent_direct: 40,
|
||||
recv_flood: 30,
|
||||
recv_direct: 70,
|
||||
flood_dups: 1,
|
||||
direct_dups: 0,
|
||||
full_events: 0,
|
||||
telemetry_history: [{ timestamp: 1700000000, data: { battery_volts: 4.2 } }],
|
||||
};
|
||||
|
||||
render(<RepeaterDashboard {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('1 samples')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
deferred.resolve([{ timestamp: 1690000000, data: { battery_volts: 3.9 } }]);
|
||||
await deferred.promise;
|
||||
|
||||
expect(screen.getByText('1 samples')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,14 +61,16 @@ const baseSettings: AppSettings = {
|
||||
max_radio_contacts: 200,
|
||||
favorites: [],
|
||||
auto_decrypt_dm_on_advert: false,
|
||||
sidebar_sort_order: 'recent',
|
||||
last_message_times: {},
|
||||
preferences_migrated: false,
|
||||
|
||||
advert_interval: 0,
|
||||
last_advert_time: 0,
|
||||
flood_scope: '',
|
||||
blocked_keys: [],
|
||||
blocked_names: [],
|
||||
discovery_blocked_types: [],
|
||||
tracked_telemetry_repeaters: [],
|
||||
auto_resend_channel: false,
|
||||
};
|
||||
|
||||
function renderModal(overrides?: {
|
||||
@@ -615,10 +617,10 @@ describe('SettingsModal', () => {
|
||||
openDatabaseSection();
|
||||
|
||||
expect(
|
||||
screen.getByText(/remove packet-analysis availability for those historical messages/i)
|
||||
screen.getByText(/removes packet-analysis availability for those messages/i)
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Purge Archival Raw Packets' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Purge Archival Packets' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(runMaintenanceSpy).toHaveBeenCalledWith({ purgeLinkedRawPackets: true });
|
||||
|
||||
@@ -7,3 +7,19 @@ class ResizeObserver {
|
||||
}
|
||||
|
||||
globalThis.ResizeObserver = ResizeObserver;
|
||||
|
||||
// Several components call matchMedia at import time for responsive detection
|
||||
if (typeof globalThis.matchMedia === 'undefined') {
|
||||
Object.defineProperty(globalThis, 'matchMedia', {
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -92,7 +92,6 @@ function renderSidebar(overrides?: {
|
||||
onToggleCracker={vi.fn()}
|
||||
onMarkAllRead={vi.fn()}
|
||||
favorites={favorites}
|
||||
legacySortOrder="recent"
|
||||
isConversationNotificationsEnabled={overrides?.isConversationNotificationsEnabled}
|
||||
/>
|
||||
);
|
||||
@@ -140,7 +139,6 @@ describe('Sidebar section summaries', () => {
|
||||
onToggleCracker={vi.fn()}
|
||||
onMarkAllRead={vi.fn()}
|
||||
favorites={[]}
|
||||
legacySortOrder="recent"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -300,7 +298,6 @@ describe('Sidebar section summaries', () => {
|
||||
onToggleCracker={vi.fn()}
|
||||
onMarkAllRead={vi.fn()}
|
||||
favorites={[]}
|
||||
legacySortOrder="recent"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -397,7 +394,6 @@ describe('Sidebar section summaries', () => {
|
||||
onToggleCracker: vi.fn(),
|
||||
onMarkAllRead: vi.fn(),
|
||||
favorites: [],
|
||||
legacySortOrder: 'recent' as const,
|
||||
};
|
||||
|
||||
const getChannelsOrder = () => screen.getAllByText(/^#/).map((node) => node.textContent);
|
||||
@@ -469,7 +465,6 @@ describe('Sidebar section summaries', () => {
|
||||
onToggleCracker={vi.fn()}
|
||||
onMarkAllRead={vi.fn()}
|
||||
favorites={[]}
|
||||
legacySortOrder="recent"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -504,7 +499,6 @@ describe('Sidebar section summaries', () => {
|
||||
onToggleCracker={vi.fn()}
|
||||
onMarkAllRead={vi.fn()}
|
||||
favorites={[]}
|
||||
legacySortOrder="recent"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -553,7 +547,6 @@ describe('Sidebar section summaries', () => {
|
||||
onToggleCracker={vi.fn()}
|
||||
onMarkAllRead={vi.fn()}
|
||||
favorites={[]}
|
||||
legacySortOrder="recent"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -586,7 +579,6 @@ describe('Sidebar section summaries', () => {
|
||||
onToggleCracker={vi.fn()}
|
||||
onMarkAllRead={vi.fn()}
|
||||
favorites={[]}
|
||||
legacySortOrder="alpha"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -623,7 +615,6 @@ describe('Sidebar section summaries', () => {
|
||||
{ type: 'contact', id: zed.public_key },
|
||||
{ type: 'contact', id: amy.public_key },
|
||||
] satisfies Favorite[],
|
||||
legacySortOrder: 'recent' as const,
|
||||
};
|
||||
|
||||
const getFavoritesOrder = () =>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { act, renderHook } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { useContactsAndChannels } from '../hooks/useContactsAndChannels';
|
||||
import type { Contact } from '../types';
|
||||
import type { BulkCreateHashtagChannelsResult, Contact } from '../types';
|
||||
|
||||
// Mock api module
|
||||
vi.mock('../api', () => ({
|
||||
@@ -18,6 +18,7 @@ vi.mock('../api', () => ({
|
||||
getChannels: vi.fn(),
|
||||
createContact: vi.fn(),
|
||||
createChannel: vi.fn(),
|
||||
bulkCreateHashtagChannels: vi.fn(),
|
||||
deleteContact: vi.fn(),
|
||||
deleteChannel: vi.fn(),
|
||||
decryptHistoricalPackets: vi.fn(),
|
||||
@@ -171,4 +172,41 @@ describe('useContactsAndChannels', () => {
|
||||
expect(api.getContacts).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulk hashtag creation', () => {
|
||||
it('refreshes channels and returns the backend result', async () => {
|
||||
const { api } = await import('../api');
|
||||
const resultPayload: BulkCreateHashtagChannelsResult = {
|
||||
created_channels: [
|
||||
{
|
||||
key: 'AA'.repeat(16),
|
||||
name: '#ops',
|
||||
is_hashtag: true,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
},
|
||||
],
|
||||
existing_count: 1,
|
||||
invalid_names: [],
|
||||
decrypt_started: true,
|
||||
decrypt_total_packets: 12,
|
||||
message: 'Created 1 room',
|
||||
};
|
||||
vi.mocked(api.bulkCreateHashtagChannels).mockResolvedValueOnce(resultPayload);
|
||||
vi.mocked(api.getChannels).mockResolvedValueOnce(resultPayload.created_channels);
|
||||
vi.mocked(api.getUndecryptedPacketCount).mockResolvedValueOnce({ count: 9 });
|
||||
|
||||
const { result } = renderUseContactsAndChannels();
|
||||
|
||||
let response: BulkCreateHashtagChannelsResult | null = null;
|
||||
await act(async () => {
|
||||
response = await result.current.handleBulkCreateHashtagChannels(['#ops'], true);
|
||||
});
|
||||
|
||||
expect(api.bulkCreateHashtagChannels).toHaveBeenCalledWith(['#ops'], true);
|
||||
expect(api.getChannels).toHaveBeenCalled();
|
||||
expect(api.getUndecryptedPacketCount).toHaveBeenCalled();
|
||||
expect(response).toEqual(resultPayload);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -49,10 +49,6 @@
|
||||
--overlay: 220 20% 10%;
|
||||
}
|
||||
|
||||
:root[data-theme='light'] .sidebar-tool-label {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
/* ── Windows 95 ───────────────────────────────────────────── */
|
||||
:root[data-theme='windows-95'] {
|
||||
--background: 180 100% 25%;
|
||||
|
||||
+39
-29
@@ -166,23 +166,6 @@ export interface NearestRepeater {
|
||||
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 {
|
||||
bucket_start: number;
|
||||
last_24h_count: number;
|
||||
@@ -218,6 +201,7 @@ export interface Channel {
|
||||
is_hashtag: boolean;
|
||||
on_radio: boolean;
|
||||
flood_scope_override?: string | null;
|
||||
path_hash_mode_override?: number | null;
|
||||
last_read_at: number | null;
|
||||
}
|
||||
|
||||
@@ -235,12 +219,32 @@ export interface ChannelTopSender {
|
||||
message_count: number;
|
||||
}
|
||||
|
||||
export interface BulkCreateHashtagChannelsResult {
|
||||
created_channels: Channel[];
|
||||
existing_count: number;
|
||||
invalid_names: string[];
|
||||
decrypt_started: boolean;
|
||||
decrypt_total_packets: number;
|
||||
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 {
|
||||
channel: Channel;
|
||||
message_counts: ChannelMessageCounts;
|
||||
first_message_at: number | null;
|
||||
unique_sender_count: number;
|
||||
top_senders_24h: ChannelTopSender[];
|
||||
path_hash_width_24h: PathHashWidthStats;
|
||||
}
|
||||
|
||||
/** A single path that a message took to reach us */
|
||||
@@ -251,6 +255,10 @@ export interface MessagePath {
|
||||
received_at: number;
|
||||
/** Hop count (number of intermediate nodes). Null for legacy data (infer as len(path)/2). */
|
||||
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 {
|
||||
@@ -324,35 +332,31 @@ export interface AppSettings {
|
||||
max_radio_contacts: number;
|
||||
favorites: Favorite[];
|
||||
auto_decrypt_dm_on_advert: boolean;
|
||||
sidebar_sort_order: 'recent' | 'alpha';
|
||||
last_message_times: Record<string, number>;
|
||||
preferences_migrated: boolean;
|
||||
advert_interval: number;
|
||||
last_advert_time: number;
|
||||
flood_scope: string;
|
||||
blocked_keys: string[];
|
||||
blocked_names: string[];
|
||||
discovery_blocked_types: number[];
|
||||
tracked_telemetry_repeaters: string[];
|
||||
auto_resend_channel: boolean;
|
||||
}
|
||||
|
||||
export interface AppSettingsUpdate {
|
||||
max_radio_contacts?: number;
|
||||
auto_decrypt_dm_on_advert?: boolean;
|
||||
sidebar_sort_order?: 'recent' | 'alpha';
|
||||
advert_interval?: number;
|
||||
auto_resend_channel?: boolean;
|
||||
flood_scope?: string;
|
||||
blocked_keys?: string[];
|
||||
blocked_names?: string[];
|
||||
discovery_blocked_types?: number[];
|
||||
}
|
||||
|
||||
export interface MigratePreferencesRequest {
|
||||
favorites: Favorite[];
|
||||
sort_order: string;
|
||||
last_message_times: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface MigratePreferencesResponse {
|
||||
migrated: boolean;
|
||||
settings: AppSettings;
|
||||
export interface TrackedTelemetryResponse {
|
||||
tracked_telemetry_repeaters: string[];
|
||||
names: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Contact type constants */
|
||||
@@ -405,6 +409,7 @@ export interface RepeaterStatusResponse {
|
||||
flood_dups: number;
|
||||
direct_dups: number;
|
||||
full_events: number;
|
||||
telemetry_history: TelemetryHistoryEntry[];
|
||||
}
|
||||
|
||||
export interface RepeaterNeighborsResponse {
|
||||
@@ -468,6 +473,11 @@ export interface PaneState {
|
||||
fetched_at?: number | null;
|
||||
}
|
||||
|
||||
export interface TelemetryHistoryEntry {
|
||||
timestamp: number;
|
||||
data: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface TraceResponse {
|
||||
remote_snr: number | null;
|
||||
local_snr: number | null;
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
* across devices - see useUnreadCounts hook.
|
||||
*/
|
||||
|
||||
const LAST_MESSAGE_KEY = 'remoteterm-lastMessageTime';
|
||||
const SORT_ORDER_KEY = 'remoteterm-sortOrder';
|
||||
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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@@ -149,15 +124,3 @@ export function saveLocalStorageSidebarSectionSortOrders(orders: SidebarSectionS
|
||||
// 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 are now stored server-side in the database.
|
||||
* This file provides helper functions for checking favorites
|
||||
* and loading legacy localStorage data for migration.
|
||||
* Favorites are stored server-side in the database.
|
||||
*/
|
||||
|
||||
import type { Favorite } from '../types';
|
||||
|
||||
const FAVORITES_KEY = 'remoteterm-favorites';
|
||||
|
||||
/**
|
||||
* Check if a conversation is favorited (from provided favorites array)
|
||||
*/
|
||||
@@ -20,26 +16,3 @@ export function isFavorite(
|
||||
): boolean {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Channel messages have format "sender: message".
|
||||
*/
|
||||
const HASHTAG_CHANNEL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
const HASHTAG_CHANNEL_REFERENCE_PATTERN = /(^|\s)(#[a-z0-9]+(?:-[a-z0-9]+)*)(?=$|\s)/g;
|
||||
const HASHTAG_CHANNEL_REFERENCE_PATTERN = /(^|\s)(#[a-z0-9]+(?:-[a-z0-9]+)*)(?=$|[\s.,;:])/g;
|
||||
|
||||
export function parseSenderFromText(text: string): { sender: string | null; content: string } {
|
||||
const colonIndex = text.indexOf(': ');
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user