mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-16 06:16:07 +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/analytics` | Unified keyed-or-name contact analytics payload |
|
||||||
| GET | `/api/contacts/repeaters/advert-paths` | List recent unique advert paths for all contacts |
|
| GET | `/api/contacts/repeaters/advert-paths` | List recent unique advert paths for all contacts |
|
||||||
| POST | `/api/contacts` | Create contact (optionally trigger historical DM decrypt) |
|
| POST | `/api/contacts` | Create contact (optionally trigger historical DM decrypt) |
|
||||||
|
| POST | `/api/contacts/bulk-delete` | Delete multiple contacts |
|
||||||
| DELETE | `/api/contacts/{public_key}` | Delete contact |
|
| DELETE | `/api/contacts/{public_key}` | Delete contact |
|
||||||
| POST | `/api/contacts/{public_key}/mark-read` | Mark contact conversation as read |
|
| POST | `/api/contacts/{public_key}/mark-read` | Mark contact conversation as read |
|
||||||
| POST | `/api/contacts/{public_key}/command` | Send CLI command to repeater |
|
| POST | `/api/contacts/{public_key}/command` | Send CLI command to repeater |
|
||||||
@@ -346,12 +347,13 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
|||||||
| POST | `/api/contacts/{public_key}/room/status` | Fetch room-server status telemetry |
|
| POST | `/api/contacts/{public_key}/room/status` | Fetch room-server status telemetry |
|
||||||
| POST | `/api/contacts/{public_key}/room/lpp-telemetry` | Fetch room-server CayenneLPP sensor data |
|
| POST | `/api/contacts/{public_key}/room/lpp-telemetry` | Fetch room-server CayenneLPP sensor data |
|
||||||
| POST | `/api/contacts/{public_key}/room/acl` | Fetch room-server ACL entries |
|
| POST | `/api/contacts/{public_key}/room/acl` | Fetch room-server ACL entries |
|
||||||
|
|
||||||
| GET | `/api/channels` | List channels |
|
| GET | `/api/channels` | List channels |
|
||||||
| GET | `/api/channels/{key}/detail` | Comprehensive channel profile (message stats, top senders) |
|
| GET | `/api/channels/{key}/detail` | Comprehensive channel profile (message stats, top senders) |
|
||||||
| POST | `/api/channels` | Create channel |
|
| POST | `/api/channels` | Create channel |
|
||||||
|
| POST | `/api/channels/bulk-hashtag` | Create multiple hashtag channels |
|
||||||
| DELETE | `/api/channels/{key}` | Delete channel |
|
| DELETE | `/api/channels/{key}` | Delete channel |
|
||||||
| POST | `/api/channels/{key}/flood-scope-override` | Set or clear a per-channel regional flood-scope override |
|
| POST | `/api/channels/{key}/flood-scope-override` | Set or clear a per-channel regional flood-scope override |
|
||||||
|
| POST | `/api/channels/{key}/path-hash-mode-override` | Set or clear a per-channel path hash mode override |
|
||||||
| POST | `/api/channels/{key}/mark-read` | Mark channel as read |
|
| POST | `/api/channels/{key}/mark-read` | Mark channel as read |
|
||||||
| GET | `/api/messages` | List with filters (`q`, `after`/`after_id` for forward pagination) |
|
| GET | `/api/messages` | List with filters (`q`, `after`/`after_id` for forward pagination) |
|
||||||
| GET | `/api/messages/around/{id}` | Get messages around a specific message (for jump-to-message) |
|
| GET | `/api/messages/around/{id}` | Get messages around a specific message (for jump-to-message) |
|
||||||
@@ -400,6 +402,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
|||||||
- Hashtag channels: `SHA256("#name")[:16]` converted to hex
|
- Hashtag channels: `SHA256("#name")[:16]` converted to hex
|
||||||
- Custom channels: User-provided or generated
|
- Custom channels: User-provided or generated
|
||||||
- Channels may also persist `flood_scope_override`; when set, channel sends temporarily switch the radio flood scope to that value for the duration of the send, then restore the global app setting.
|
- Channels may also persist `flood_scope_override`; when set, channel sends temporarily switch the radio flood scope to that value for the duration of the send, then restore the global app setting.
|
||||||
|
- Channels may persist `path_hash_mode_override` (0/1/2); when set, channel sends temporarily switch the radio path hash mode for the duration of the send, then restore the radio default.
|
||||||
|
|
||||||
### Message Types
|
### Message Types
|
||||||
|
|
||||||
@@ -463,7 +466,7 @@ mc.subscribe(EventType.ACK, handler)
|
|||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `MESHCORE_SERIAL_PORT` | auto-detect | Serial port for radio |
|
| `MESHCORE_SERIAL_PORT` | auto-detect | Serial port for radio |
|
||||||
| `MESHCORE_TCP_HOST` | *(none)* | TCP host for radio (mutually exclusive with serial/BLE) |
|
| `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_ADDRESS` | *(none)* | BLE device address (mutually exclusive with serial/TCP) |
|
||||||
| `MESHCORE_BLE_PIN` | *(required with BLE)* | BLE PIN code |
|
| `MESHCORE_BLE_PIN` | *(required with BLE)* | BLE PIN code |
|
||||||
| `MESHCORE_SERIAL_BAUDRATE` | `115200` | Serial baud rate |
|
| `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_ENABLE_MESSAGE_POLL_FALLBACK` | `false` | Switch the always-on radio audit task from hourly checks to aggressive 10-second polling; the audit checks both missed message drift and channel-slot cache drift |
|
||||||
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | `false` | Disable channel-slot reuse and force `set_channel(...)` before every channel send, even on serial/BLE |
|
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | `false` | Disable channel-slot reuse and force `set_channel(...)` before every channel send, even on serial/BLE |
|
||||||
|
|
||||||
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `flood_scope`, `blocked_keys`, and `blocked_names`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. The backend still carries `sidebar_sort_order` for compatibility and migration, but the current frontend sidebar stores sort order per section (`Channels`, `Contacts`, `Repeaters`) in localStorage rather than treating it as one shared server-backed preference. MQTT, bot, webhook, Apprise, and SQS configs are stored in the `fanout_configs` table, managed via `/api/fanout`. If the radio's channel slots appear unstable or another client is mutating them underneath this app, operators can force the old always-reconfigure send path with `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true`.
|
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `flood_scope`, `blocked_keys`, `blocked_names`, and `discovery_blocked_types`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, Apprise, and SQS configs are stored in the `fanout_configs` table, managed via `/api/fanout`. If the radio's channel slots appear unstable or another client is mutating them underneath this app, operators can force the old always-reconfigure send path with `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true`.
|
||||||
|
|
||||||
Byte-perfect channel retries are user-triggered via `POST /api/messages/channel/{message_id}/resend` and are allowed for 30 seconds after the original send.
|
Byte-perfect channel retries are user-triggered via `POST /api/messages/channel/{message_id}/resend` and are allowed for 30 seconds after the original send.
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,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
|
## [3.6.7] - 2026-03-31
|
||||||
|
|
||||||
* Misc: Remove armv7 (for now)
|
* 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_PORT` | (auto-detect) | Serial port path |
|
||||||
| `MESHCORE_SERIAL_BAUDRATE` | 115200 | Serial baud rate |
|
| `MESHCORE_SERIAL_BAUDRATE` | 115200 | Serial baud rate |
|
||||||
| `MESHCORE_TCP_HOST` | | TCP host (mutually exclusive with serial/BLE) |
|
| `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_ADDRESS` | | BLE device address (mutually exclusive with serial/TCP) |
|
||||||
| `MESHCORE_BLE_PIN` | | BLE PIN (required when BLE address is set) |
|
| `MESHCORE_BLE_PIN` | | BLE PIN (required when BLE address is set) |
|
||||||
| `MESHCORE_LOG_LEVEL` | INFO | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
|
| `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
|
MESHCORE_SERIAL_PORT=/dev/ttyUSB0 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
|
|
||||||
# TCP
|
# 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
|
# 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
|
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/analytics` — unified keyed-or-name analytics payload
|
||||||
- `GET /contacts/repeaters/advert-paths` — recent advert paths for all contacts
|
- `GET /contacts/repeaters/advert-paths` — recent advert paths for all contacts
|
||||||
- `POST /contacts`
|
- `POST /contacts`
|
||||||
|
- `POST /contacts/bulk-delete`
|
||||||
- `DELETE /contacts/{public_key}`
|
- `DELETE /contacts/{public_key}`
|
||||||
- `POST /contacts/{public_key}/mark-read`
|
- `POST /contacts/{public_key}/mark-read`
|
||||||
- `POST /contacts/{public_key}/command`
|
- `POST /contacts/{public_key}/command`
|
||||||
@@ -214,8 +215,10 @@ app/
|
|||||||
- `GET /channels`
|
- `GET /channels`
|
||||||
- `GET /channels/{key}/detail`
|
- `GET /channels/{key}/detail`
|
||||||
- `POST /channels`
|
- `POST /channels`
|
||||||
|
- `POST /channels/bulk-hashtag`
|
||||||
- `DELETE /channels/{key}`
|
- `DELETE /channels/{key}`
|
||||||
- `POST /channels/{key}/flood-scope-override`
|
- `POST /channels/{key}/flood-scope-override`
|
||||||
|
- `POST /channels/{key}/path-hash-mode-override`
|
||||||
- `POST /channels/{key}/mark-read`
|
- `POST /channels/{key}/mark-read`
|
||||||
|
|
||||||
### Messages
|
### Messages
|
||||||
@@ -278,7 +281,7 @@ Client sends `"ping"` text; server replies `{"type":"pong"}`.
|
|||||||
Main tables:
|
Main tables:
|
||||||
- `contacts` (includes `first_seen` for contact age tracking and `direct_path_hash_mode` / `route_override_*` for DM routing)
|
- `contacts` (includes `first_seen` for contact age tracking and `direct_path_hash_mode` / `route_override_*` for DM routing)
|
||||||
- `channels`
|
- `channels`
|
||||||
Includes optional `flood_scope_override` for channel-specific regional sends.
|
Includes optional `flood_scope_override` for channel-specific regional sends and optional `path_hash_mode_override` for per-channel path hop width.
|
||||||
- `messages` (includes `sender_name`, `sender_key` for per-contact channel message attribution)
|
- `messages` (includes `sender_name`, `sender_key` for per-contact channel message attribution)
|
||||||
- `raw_packets`
|
- `raw_packets`
|
||||||
- `contact_advert_paths` (recent unique advertisement paths per contact, keyed by contact + path bytes + hop count)
|
- `contact_advert_paths` (recent unique advertisement paths per contact, keyed by contact + path bytes + hop count)
|
||||||
@@ -300,15 +303,12 @@ Repository writes should prefer typed models such as `ContactUpsert` over ad hoc
|
|||||||
- `max_radio_contacts`
|
- `max_radio_contacts`
|
||||||
- `favorites`
|
- `favorites`
|
||||||
- `auto_decrypt_dm_on_advert`
|
- `auto_decrypt_dm_on_advert`
|
||||||
- `sidebar_sort_order`
|
|
||||||
- `last_message_times`
|
- `last_message_times`
|
||||||
- `preferences_migrated`
|
- `preferences_migrated`
|
||||||
- `advert_interval`
|
- `advert_interval`
|
||||||
- `last_advert_time`
|
- `last_advert_time`
|
||||||
- `flood_scope`
|
- `flood_scope`
|
||||||
- `blocked_keys`, `blocked_names`
|
- `blocked_keys`, `blocked_names`, `discovery_blocked_types`
|
||||||
|
|
||||||
Note: `sidebar_sort_order` remains in the backend model for compatibility and migration, but the current frontend sidebar uses per-section localStorage sort preferences instead of a single shared server-backed sort mode.
|
|
||||||
|
|
||||||
Note: MQTT, community MQTT, and bot configs were migrated to the `fanout_configs` table (migrations 36-38).
|
Note: MQTT, community MQTT, and bot configs were migrated to the `fanout_configs` table (migrations 36-38).
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -14,7 +14,7 @@ class Settings(BaseSettings):
|
|||||||
serial_port: str = "" # Empty string triggers auto-detection
|
serial_port: str = "" # Empty string triggers auto-detection
|
||||||
serial_baudrate: int = 115200
|
serial_baudrate: int = 115200
|
||||||
tcp_host: str = ""
|
tcp_host: str = ""
|
||||||
tcp_port: int = 4000
|
tcp_port: int = 5000
|
||||||
ble_address: str = ""
|
ble_address: str = ""
|
||||||
ble_pin: str = ""
|
ble_pin: str = ""
|
||||||
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
|
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
|
||||||
@@ -26,6 +26,7 @@ class Settings(BaseSettings):
|
|||||||
default=False,
|
default=False,
|
||||||
validation_alias="__CLOWNTOWN_DO_CLOCK_WRAPAROUND",
|
validation_alias="__CLOWNTOWN_DO_CLOCK_WRAPAROUND",
|
||||||
)
|
)
|
||||||
|
skip_post_connect_sync: bool = False
|
||||||
basic_auth_username: str = ""
|
basic_auth_username: str = ""
|
||||||
basic_auth_password: str = ""
|
basic_auth_password: str = ""
|
||||||
|
|
||||||
|
|||||||
+62
-7
@@ -36,6 +36,7 @@ CREATE TABLE IF NOT EXISTS channels (
|
|||||||
is_hashtag INTEGER DEFAULT 0,
|
is_hashtag INTEGER DEFAULT 0,
|
||||||
on_radio INTEGER DEFAULT 0,
|
on_radio INTEGER DEFAULT 0,
|
||||||
flood_scope_override TEXT,
|
flood_scope_override TEXT,
|
||||||
|
path_hash_mode_override INTEGER,
|
||||||
last_read_at INTEGER
|
last_read_at INTEGER
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -46,7 +47,7 @@ CREATE TABLE IF NOT EXISTS messages (
|
|||||||
text TEXT NOT NULL,
|
text TEXT NOT NULL,
|
||||||
sender_timestamp INTEGER,
|
sender_timestamp INTEGER,
|
||||||
received_at INTEGER NOT NULL,
|
received_at INTEGER NOT NULL,
|
||||||
path TEXT,
|
paths TEXT,
|
||||||
txt_type INTEGER DEFAULT 0,
|
txt_type INTEGER DEFAULT 0,
|
||||||
signature TEXT,
|
signature TEXT,
|
||||||
outgoing INTEGER DEFAULT 0,
|
outgoing INTEGER DEFAULT 0,
|
||||||
@@ -66,7 +67,7 @@ CREATE TABLE IF NOT EXISTS raw_packets (
|
|||||||
data BLOB NOT NULL,
|
data BLOB NOT NULL,
|
||||||
message_id INTEGER,
|
message_id INTEGER,
|
||||||
payload_hash BLOB,
|
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 (
|
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,
|
last_seen INTEGER NOT NULL,
|
||||||
heard_count INTEGER NOT NULL DEFAULT 1,
|
heard_count INTEGER NOT NULL DEFAULT 1,
|
||||||
UNIQUE(public_key, path_hex, path_len),
|
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 (
|
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,
|
first_seen INTEGER NOT NULL,
|
||||||
last_seen INTEGER NOT NULL,
|
last_seen INTEGER NOT NULL,
|
||||||
UNIQUE(public_key, name),
|
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 INDEX IF NOT EXISTS idx_messages_received ON messages(received_at);
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_dedup_null_safe
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_dedup_null_safe
|
||||||
ON messages(type, conversation_key, text, COALESCE(sender_timestamp, 0))
|
ON messages(type, conversation_key, text, COALESCE(sender_timestamp, 0))
|
||||||
WHERE type = 'CHAN';
|
WHERE type = 'CHAN';
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_incoming_priv_dedup
|
||||||
|
ON messages(type, conversation_key, text, COALESCE(sender_timestamp, 0))
|
||||||
|
WHERE type = 'PRIV' AND outgoing = 0;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_sender_key ON messages(sender_key);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_pagination
|
||||||
|
ON messages(type, conversation_key, received_at DESC, id DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_unread_covering
|
||||||
|
ON messages(type, conversation_key, outgoing, received_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_raw_packets_message_id ON raw_packets(message_id);
|
CREATE INDEX IF NOT EXISTS idx_raw_packets_message_id ON raw_packets(message_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_raw_packets_timestamp ON raw_packets(timestamp);
|
CREATE INDEX IF NOT EXISTS idx_raw_packets_timestamp ON raw_packets(timestamp);
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_packets_payload_hash ON raw_packets(payload_hash);
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_packets_payload_hash ON raw_packets(payload_hash);
|
||||||
CREATE INDEX IF NOT EXISTS idx_contacts_on_radio ON contacts(on_radio);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_contacts_type_last_seen ON contacts(type, last_seen);
|
CREATE INDEX IF NOT EXISTS idx_contacts_type_last_seen ON contacts(type, last_seen);
|
||||||
CREATE INDEX IF NOT EXISTS idx_messages_type_received_conversation
|
CREATE INDEX IF NOT EXISTS idx_messages_type_received_conversation
|
||||||
ON messages(type, received_at, conversation_key);
|
ON messages(type, received_at, conversation_key);
|
||||||
-- idx_messages_sender_key is created by migration 25 (after adding the sender_key column)
|
|
||||||
-- idx_messages_incoming_priv_dedup is created by migration 44 after legacy rows are reconciled
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_contact_advert_paths_recent
|
CREATE INDEX IF NOT EXISTS idx_contact_advert_paths_recent
|
||||||
ON contact_advert_paths(public_key, last_seen DESC);
|
ON contact_advert_paths(public_key, last_seen DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_contact_name_history_key
|
CREATE INDEX IF NOT EXISTS idx_contact_name_history_key
|
||||||
ON contact_name_history(public_key, last_seen DESC);
|
ON contact_name_history(public_key, last_seen DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_repeater_telemetry_pk_ts
|
||||||
|
ON repeater_telemetry_history(public_key, timestamp);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -132,6 +177,12 @@ class Database:
|
|||||||
# migration 20 handles the one-time VACUUM to restructure the file.
|
# migration 20 handles the one-time VACUUM to restructure the file.
|
||||||
await self._connection.execute("PRAGMA auto_vacuum = INCREMENTAL")
|
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.executescript(SCHEMA)
|
||||||
await self._connection.commit()
|
await self._connection.commit()
|
||||||
logger.debug("Database schema initialized")
|
logger.debug("Database schema initialized")
|
||||||
@@ -141,6 +192,10 @@ class Database:
|
|||||||
|
|
||||||
await run_migrations(self._connection)
|
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:
|
async def disconnect(self) -> None:
|
||||||
if self._connection:
|
if self._connection:
|
||||||
await self._connection.close()
|
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.
|
# Legacy firmware/library payloads only support 1-byte hop hashes.
|
||||||
normalized_path_hash_mode = -1 if normalized_path_len == -1 else 0
|
normalized_path_hash_mode = -1 if normalized_path_len == -1 else 0
|
||||||
else:
|
else:
|
||||||
normalized_path_hash_mode = None
|
|
||||||
try:
|
try:
|
||||||
normalized_path_hash_mode = int(path_hash_mode)
|
normalized_path_hash_mode = int(path_hash_mode)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
|
|||||||
@@ -52,19 +52,6 @@ class ToastPayload(TypedDict):
|
|||||||
details: NotRequired[str]
|
details: NotRequired[str]
|
||||||
|
|
||||||
|
|
||||||
WsEventPayload = (
|
|
||||||
HealthResponse
|
|
||||||
| Message
|
|
||||||
| Contact
|
|
||||||
| ContactResolvedPayload
|
|
||||||
| Channel
|
|
||||||
| ContactDeletedPayload
|
|
||||||
| ChannelDeletedPayload
|
|
||||||
| RawPacketBroadcast
|
|
||||||
| MessageAckedPayload
|
|
||||||
| ToastPayload
|
|
||||||
)
|
|
||||||
|
|
||||||
_PAYLOAD_ADAPTERS: dict[WsEventType, TypeAdapter[Any]] = {
|
_PAYLOAD_ADAPTERS: dict[WsEventType, TypeAdapter[Any]] = {
|
||||||
"health": TypeAdapter(HealthResponse),
|
"health": TypeAdapter(HealthResponse),
|
||||||
"message": TypeAdapter(Message),
|
"message": TypeAdapter(Message),
|
||||||
@@ -80,14 +67,6 @@ _PAYLOAD_ADAPTERS: dict[WsEventType, TypeAdapter[Any]] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def validate_ws_event_payload(event_type: str, data: Any) -> WsEventPayload | Any:
|
|
||||||
"""Validate known WebSocket payloads; pass unknown events through unchanged."""
|
|
||||||
adapter = _PAYLOAD_ADAPTERS.get(event_type) # type: ignore[arg-type]
|
|
||||||
if adapter is None:
|
|
||||||
return data
|
|
||||||
return adapter.validate_python(data)
|
|
||||||
|
|
||||||
|
|
||||||
def dump_ws_event(event_type: str, data: Any) -> str:
|
def dump_ws_event(event_type: str, data: Any) -> str:
|
||||||
"""Serialize a WebSocket event envelope with validation for known event types."""
|
"""Serialize a WebSocket event envelope with validation for known event types."""
|
||||||
adapter = _PAYLOAD_ADAPTERS.get(event_type) # type: ignore[arg-type]
|
adapter = _PAYLOAD_ADAPTERS.get(event_type) # type: ignore[arg-type]
|
||||||
@@ -104,13 +83,3 @@ def dump_ws_event(event_type: str, data: Any) -> str:
|
|||||||
event_type,
|
event_type,
|
||||||
)
|
)
|
||||||
return json.dumps({"type": event_type, "data": data})
|
return json.dumps({"type": event_type, "data": data})
|
||||||
|
|
||||||
|
|
||||||
def dump_ws_event_payload(event_type: str, data: Any) -> Any:
|
|
||||||
"""Return the JSON-serializable payload for a WebSocket event."""
|
|
||||||
adapter = _PAYLOAD_ADAPTERS.get(event_type) # type: ignore[arg-type]
|
|
||||||
if adapter is None:
|
|
||||||
return data
|
|
||||||
|
|
||||||
validated = adapter.validate_python(data)
|
|
||||||
return adapter.dump_python(validated, mode="json")
|
|
||||||
|
|||||||
@@ -144,11 +144,8 @@ class MapUploadModule(FanoutModule):
|
|||||||
if advert is None:
|
if advert is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
# TODO: advert Ed25519 signature verification is skipped here.
|
# Advert Ed25519 signature verification is intentionally skipped.
|
||||||
# The radio has already validated the packet before passing it to RT,
|
# The radio validates packets before passing them to RT.
|
||||||
# so re-verification is redundant in practice. If added, verify that
|
|
||||||
# nacl.bindings.crypto_sign_open(sig + (pubkey_bytes || timestamp_bytes),
|
|
||||||
# advert.public_key_bytes) succeeds before proceeding.
|
|
||||||
|
|
||||||
# Only process repeaters (2) and rooms (3) — any other role is rejected
|
# Only process repeaters (2) and rooms (3) — any other role is rejected
|
||||||
if advert.device_role not in _ALLOWED_DEVICE_ROLES:
|
if advert.device_role not in _ALLOWED_DEVICE_ROLES:
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from app.radio_sync import (
|
|||||||
stop_message_polling,
|
stop_message_polling,
|
||||||
stop_periodic_advert,
|
stop_periodic_advert,
|
||||||
stop_periodic_sync,
|
stop_periodic_sync,
|
||||||
|
stop_telemetry_collect,
|
||||||
)
|
)
|
||||||
from app.routers import (
|
from app.routers import (
|
||||||
channels,
|
channels,
|
||||||
@@ -103,6 +104,7 @@ async def lifespan(app: FastAPI):
|
|||||||
await stop_noise_floor_sampling()
|
await stop_noise_floor_sampling()
|
||||||
await stop_periodic_advert()
|
await stop_periodic_advert()
|
||||||
await stop_periodic_sync()
|
await stop_periodic_sync()
|
||||||
|
await stop_telemetry_collect()
|
||||||
if radio_manager.meshcore:
|
if radio_manager.meshcore:
|
||||||
await radio_manager.meshcore.stop_auto_message_fetching()
|
await radio_manager.meshcore.stop_auto_message_fetching()
|
||||||
await radio_manager.disconnect()
|
await radio_manager.disconnect()
|
||||||
|
|||||||
+312
-8
@@ -367,6 +367,52 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
|
|||||||
await set_version(conn, 47)
|
await set_version(conn, 47)
|
||||||
applied += 1
|
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:
|
if applied > 0:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
|
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
|
||||||
@@ -829,7 +875,7 @@ async def _migrate_009_create_app_settings_table(conn: aiosqlite.Connection) ->
|
|||||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
max_radio_contacts INTEGER DEFAULT 200,
|
max_radio_contacts INTEGER DEFAULT 200,
|
||||||
favorites TEXT DEFAULT '[]',
|
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',
|
sidebar_sort_order TEXT DEFAULT 'recent',
|
||||||
last_message_times TEXT DEFAULT '{}',
|
last_message_times TEXT DEFAULT '{}',
|
||||||
preferences_migrated INTEGER DEFAULT 0
|
preferences_migrated INTEGER DEFAULT 0
|
||||||
@@ -837,13 +883,9 @@ async def _migrate_009_create_app_settings_table(conn: aiosqlite.Connection) ->
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
# Initialize with default row
|
# Initialize with default row (use only the id column so this works
|
||||||
await conn.execute(
|
# regardless of which columns exist — defaults fill the rest).
|
||||||
"""
|
await conn.execute("INSERT OR IGNORE INTO app_settings (id) VALUES (1)")
|
||||||
INSERT OR IGNORE INTO app_settings (id, max_radio_contacts, favorites, auto_decrypt_dm_on_advert, sidebar_sort_order, last_message_times, preferences_migrated)
|
|
||||||
VALUES (1, 200, '[]', 0, 'recent', '{}', 0)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
await conn.commit()
|
await conn.commit()
|
||||||
logger.debug("Created app_settings table with default values")
|
logger.debug("Created app_settings table with default values")
|
||||||
@@ -2909,3 +2951,265 @@ async def _migrate_047_add_statistics_indexes(conn: aiosqlite.Connection) -> Non
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
await conn.commit()
|
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."""
|
"""Convert the stored contact to the repository's write contract."""
|
||||||
return ContactUpsert.from_contact(self, **changes)
|
return ContactUpsert.from_contact(self, **changes)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def from_radio_dict(public_key: str, radio_data: dict, on_radio: bool = False) -> dict:
|
|
||||||
"""Backward-compatible dict wrapper over ContactUpsert.from_radio_dict()."""
|
|
||||||
return ContactUpsert.from_radio_dict(
|
|
||||||
public_key,
|
|
||||||
radio_data,
|
|
||||||
on_radio=on_radio,
|
|
||||||
).model_dump()
|
|
||||||
|
|
||||||
|
|
||||||
class CreateContactRequest(BaseModel):
|
class CreateContactRequest(BaseModel):
|
||||||
"""Request to create a new contact."""
|
"""Request to create a new contact."""
|
||||||
@@ -283,30 +274,6 @@ class NearestRepeater(BaseModel):
|
|||||||
heard_count: int
|
heard_count: int
|
||||||
|
|
||||||
|
|
||||||
class ContactDetail(BaseModel):
|
|
||||||
"""Comprehensive contact profile data."""
|
|
||||||
|
|
||||||
contact: Contact
|
|
||||||
name_history: list[ContactNameHistory] = Field(default_factory=list)
|
|
||||||
dm_message_count: int = 0
|
|
||||||
channel_message_count: int = 0
|
|
||||||
most_active_rooms: list[ContactActiveRoom] = Field(default_factory=list)
|
|
||||||
advert_paths: list[ContactAdvertPath] = Field(default_factory=list)
|
|
||||||
advert_frequency: float | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Advert observations per hour (includes multi-path arrivals of same advert)",
|
|
||||||
)
|
|
||||||
nearest_repeaters: list[NearestRepeater] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class NameOnlyContactDetail(BaseModel):
|
|
||||||
"""Channel activity summary for a sender name that is not tied to a known key."""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
channel_message_count: int = 0
|
|
||||||
most_active_rooms: list[ContactActiveRoom] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class ContactAnalyticsHourlyBucket(BaseModel):
|
class ContactAnalyticsHourlyBucket(BaseModel):
|
||||||
"""A single hourly activity bucket for contact analytics."""
|
"""A single hourly activity bucket for contact analytics."""
|
||||||
|
|
||||||
@@ -354,6 +321,10 @@ class Channel(BaseModel):
|
|||||||
default=None,
|
default=None,
|
||||||
description="Per-channel outbound flood scope override (null = use global app setting)",
|
description="Per-channel outbound flood scope override (null = use global app setting)",
|
||||||
)
|
)
|
||||||
|
path_hash_mode_override: int | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Per-channel path hash mode override (0=1-byte, 1=2-byte, 2=3-byte, null = use radio default)",
|
||||||
|
)
|
||||||
last_read_at: int | None = None # Server-side read state tracking
|
last_read_at: int | None = None # Server-side read state tracking
|
||||||
|
|
||||||
|
|
||||||
@@ -375,6 +346,18 @@ class ChannelTopSender(BaseModel):
|
|||||||
message_count: int
|
message_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class PathHashWidthStats(BaseModel):
|
||||||
|
"""Hop byte width distribution for parsed raw packets."""
|
||||||
|
|
||||||
|
total_packets: int = 0
|
||||||
|
single_byte: int = 0
|
||||||
|
double_byte: int = 0
|
||||||
|
triple_byte: int = 0
|
||||||
|
single_byte_pct: float = 0.0
|
||||||
|
double_byte_pct: float = 0.0
|
||||||
|
triple_byte_pct: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
class ChannelDetail(BaseModel):
|
class ChannelDetail(BaseModel):
|
||||||
"""Comprehensive channel profile data."""
|
"""Comprehensive channel profile data."""
|
||||||
|
|
||||||
@@ -383,6 +366,7 @@ class ChannelDetail(BaseModel):
|
|||||||
first_message_at: int | None = None
|
first_message_at: int | None = None
|
||||||
unique_sender_count: int = 0
|
unique_sender_count: int = 0
|
||||||
top_senders_24h: list[ChannelTopSender] = Field(default_factory=list)
|
top_senders_24h: list[ChannelTopSender] = Field(default_factory=list)
|
||||||
|
path_hash_width_24h: PathHashWidthStats = Field(default_factory=PathHashWidthStats)
|
||||||
|
|
||||||
|
|
||||||
class MessagePath(BaseModel):
|
class MessagePath(BaseModel):
|
||||||
@@ -394,6 +378,8 @@ class MessagePath(BaseModel):
|
|||||||
default=None,
|
default=None,
|
||||||
description="Hop count. None = legacy (infer as len(path)//2, i.e. 1-byte hops)",
|
description="Hop count. None = legacy (infer as len(path)//2, i.e. 1-byte hops)",
|
||||||
)
|
)
|
||||||
|
rssi: int | None = Field(default=None, description="Last-hop RSSI in dBm")
|
||||||
|
snr: float | None = Field(default=None, description="Last-hop SNR in dB")
|
||||||
|
|
||||||
|
|
||||||
class Message(BaseModel):
|
class Message(BaseModel):
|
||||||
@@ -530,6 +516,9 @@ class RepeaterStatusResponse(BaseModel):
|
|||||||
flood_dups: int = Field(description="Duplicate flood packets")
|
flood_dups: int = Field(description="Duplicate flood packets")
|
||||||
direct_dups: int = Field(description="Duplicate direct packets")
|
direct_dups: int = Field(description="Duplicate direct packets")
|
||||||
full_events: int = Field(description="Full event queue count")
|
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):
|
class RepeaterNodeInfoResponse(BaseModel):
|
||||||
@@ -805,21 +794,13 @@ class AppSettings(BaseModel):
|
|||||||
default_factory=list, description="List of favorited conversations"
|
default_factory=list, description="List of favorited conversations"
|
||||||
)
|
)
|
||||||
auto_decrypt_dm_on_advert: bool = Field(
|
auto_decrypt_dm_on_advert: bool = Field(
|
||||||
default=False,
|
default=True,
|
||||||
description="Whether to attempt historical DM decryption on new contact advertisement",
|
description="Whether to attempt historical DM decryption on new contact advertisement",
|
||||||
)
|
)
|
||||||
sidebar_sort_order: Literal["recent", "alpha"] = Field(
|
|
||||||
default="recent",
|
|
||||||
description="Sidebar sort order: 'recent' or 'alpha'",
|
|
||||||
)
|
|
||||||
last_message_times: dict[str, int] = Field(
|
last_message_times: dict[str, int] = Field(
|
||||||
default_factory=dict,
|
default_factory=dict,
|
||||||
description="Map of conversation state keys to last message timestamps",
|
description="Map of conversation state keys to last message timestamps",
|
||||||
)
|
)
|
||||||
preferences_migrated: bool = Field(
|
|
||||||
default=False,
|
|
||||||
description="Whether preferences have been migrated from localStorage",
|
|
||||||
)
|
|
||||||
advert_interval: int = Field(
|
advert_interval: int = Field(
|
||||||
default=0,
|
default=0,
|
||||||
description="Periodic advertisement interval in seconds (0 = disabled)",
|
description="Periodic advertisement interval in seconds (0 = disabled)",
|
||||||
@@ -840,19 +821,24 @@ class AppSettings(BaseModel):
|
|||||||
default_factory=list,
|
default_factory=list,
|
||||||
description="Display names whose messages are hidden from the UI",
|
description="Display names whose messages are hidden from the UI",
|
||||||
)
|
)
|
||||||
|
discovery_blocked_types: list[int] = Field(
|
||||||
|
default_factory=list,
|
||||||
class FanoutConfig(BaseModel):
|
description=(
|
||||||
"""Configuration for a single fanout integration."""
|
"Contact type codes (1=Client, 2=Repeater, 3=Room, 4=Sensor) whose "
|
||||||
|
"advertisements should not create new contacts; existing contacts are still updated"
|
||||||
id: str
|
),
|
||||||
type: str # 'mqtt_private' | 'mqtt_community' | 'bot' | 'webhook' | 'apprise' | 'sqs'
|
)
|
||||||
name: str
|
tracked_telemetry_repeaters: list[str] = Field(
|
||||||
enabled: bool
|
default_factory=list,
|
||||||
config: dict
|
description="Public keys of repeaters opted into periodic telemetry collection (max 8)",
|
||||||
scope: dict
|
)
|
||||||
sort_order: int = 0
|
auto_resend_channel: bool = Field(
|
||||||
created_at: int = 0
|
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):
|
class BusyChannel(BaseModel):
|
||||||
@@ -867,16 +853,6 @@ class ContactActivityCounts(BaseModel):
|
|||||||
last_week: int
|
last_week: int
|
||||||
|
|
||||||
|
|
||||||
class PathHashWidthStats(BaseModel):
|
|
||||||
total_packets: int
|
|
||||||
single_byte: int
|
|
||||||
double_byte: int
|
|
||||||
triple_byte: int
|
|
||||||
single_byte_pct: float
|
|
||||||
double_byte_pct: float
|
|
||||||
triple_byte_pct: float
|
|
||||||
|
|
||||||
|
|
||||||
class NoiseFloorSample(BaseModel):
|
class NoiseFloorSample(BaseModel):
|
||||||
timestamp: int = Field(description="Unix timestamp of the sampled reading")
|
timestamp: int = Field(description="Unix timestamp of the sampled reading")
|
||||||
noise_floor_dbm: int = Field(description="Noise floor in dBm")
|
noise_floor_dbm: int = Field(description="Noise floor in dBm")
|
||||||
@@ -914,3 +890,8 @@ class StatisticsResponse(BaseModel):
|
|||||||
known_channels_active: ContactActivityCounts
|
known_channels_active: ContactActivityCounts
|
||||||
path_hash_width_24h: PathHashWidthStats
|
path_hash_width_24h: PathHashWidthStats
|
||||||
noise_floor_24h: NoiseFloorHistoryStats
|
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,
|
received_at: int | None = None,
|
||||||
path: str | None = None,
|
path: str | None = None,
|
||||||
path_len: int | None = None,
|
path_len: int | None = None,
|
||||||
|
rssi: int | None = None,
|
||||||
|
snr: float | None = None,
|
||||||
channel_name: str | None = None,
|
channel_name: str | None = None,
|
||||||
realtime: bool = True,
|
realtime: bool = True,
|
||||||
) -> int | None:
|
) -> int | None:
|
||||||
@@ -81,6 +83,8 @@ async def create_message_from_decrypted(
|
|||||||
received_at=received_at,
|
received_at=received_at,
|
||||||
path=path,
|
path=path,
|
||||||
path_len=path_len,
|
path_len=path_len,
|
||||||
|
rssi=rssi,
|
||||||
|
snr=snr,
|
||||||
channel_name=channel_name,
|
channel_name=channel_name,
|
||||||
realtime=realtime,
|
realtime=realtime,
|
||||||
broadcast_fn=broadcast_event,
|
broadcast_fn=broadcast_event,
|
||||||
@@ -95,6 +99,8 @@ async def create_dm_message_from_decrypted(
|
|||||||
received_at: int | None = None,
|
received_at: int | None = None,
|
||||||
path: str | None = None,
|
path: str | None = None,
|
||||||
path_len: int | None = None,
|
path_len: int | None = None,
|
||||||
|
rssi: int | None = None,
|
||||||
|
snr: float | None = None,
|
||||||
outgoing: bool = False,
|
outgoing: bool = False,
|
||||||
realtime: bool = True,
|
realtime: bool = True,
|
||||||
) -> int | None:
|
) -> int | None:
|
||||||
@@ -107,6 +113,8 @@ async def create_dm_message_from_decrypted(
|
|||||||
received_at=received_at,
|
received_at=received_at,
|
||||||
path=path,
|
path=path,
|
||||||
path_len=path_len,
|
path_len=path_len,
|
||||||
|
rssi=rssi,
|
||||||
|
snr=snr,
|
||||||
outgoing=outgoing,
|
outgoing=outgoing,
|
||||||
realtime=realtime,
|
realtime=realtime,
|
||||||
broadcast_fn=broadcast_event,
|
broadcast_fn=broadcast_event,
|
||||||
@@ -319,7 +327,9 @@ async def process_raw_packet(
|
|||||||
# deduplication in create_message_from_decrypted handles adding paths to existing messages.
|
# deduplication in create_message_from_decrypted handles adding paths to existing messages.
|
||||||
# This is more reliable than trying to look up the message via raw packet linking.
|
# This is more reliable than trying to look up the message via raw packet linking.
|
||||||
if payload_type == PayloadType.GROUP_TEXT:
|
if payload_type == PayloadType.GROUP_TEXT:
|
||||||
decrypt_result = await _process_group_text(raw_bytes, packet_id, ts, packet_info)
|
decrypt_result = await _process_group_text(
|
||||||
|
raw_bytes, packet_id, ts, packet_info, rssi=rssi, snr=snr
|
||||||
|
)
|
||||||
if decrypt_result:
|
if decrypt_result:
|
||||||
result.update(decrypt_result)
|
result.update(decrypt_result)
|
||||||
|
|
||||||
@@ -330,7 +340,9 @@ async def process_raw_packet(
|
|||||||
|
|
||||||
elif payload_type == PayloadType.TEXT_MESSAGE:
|
elif payload_type == PayloadType.TEXT_MESSAGE:
|
||||||
# Try to decrypt direct messages using stored private key and known contacts
|
# Try to decrypt direct messages using stored private key and known contacts
|
||||||
decrypt_result = await _process_direct_message(raw_bytes, packet_id, ts, packet_info)
|
decrypt_result = await _process_direct_message(
|
||||||
|
raw_bytes, packet_id, ts, packet_info, rssi=rssi, snr=snr
|
||||||
|
)
|
||||||
if decrypt_result:
|
if decrypt_result:
|
||||||
result.update(decrypt_result)
|
result.update(decrypt_result)
|
||||||
|
|
||||||
@@ -367,6 +379,8 @@ async def _process_group_text(
|
|||||||
packet_id: int,
|
packet_id: int,
|
||||||
timestamp: int,
|
timestamp: int,
|
||||||
packet_info: PacketInfo | None,
|
packet_info: PacketInfo | None,
|
||||||
|
rssi: int | None = None,
|
||||||
|
snr: float | None = None,
|
||||||
) -> dict | None:
|
) -> dict | None:
|
||||||
"""
|
"""
|
||||||
Process a GroupText (channel message) packet.
|
Process a GroupText (channel message) packet.
|
||||||
@@ -403,6 +417,8 @@ async def _process_group_text(
|
|||||||
received_at=timestamp,
|
received_at=timestamp,
|
||||||
path=packet_info.path.hex() if packet_info else None,
|
path=packet_info.path.hex() if packet_info else None,
|
||||||
path_len=packet_info.path_length if packet_info else None,
|
path_len=packet_info.path_length if packet_info else None,
|
||||||
|
rssi=rssi,
|
||||||
|
snr=snr,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -462,14 +478,19 @@ async def _process_advertisement(
|
|||||||
advert.device_role if advert.device_role > 0 else (existing.type if existing else 0)
|
advert.device_role if advert.device_role > 0 else (existing.type if existing else 0)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Keep recent unique advert paths for all contacts.
|
# Check discovery_blocked_types: skip new contacts whose type is blocked.
|
||||||
await ContactAdvertPathRepository.record_observation(
|
# Existing contacts are always updated (location, name, last_seen, etc.).
|
||||||
public_key=advert.public_key.lower(),
|
if existing is None and contact_type > 0:
|
||||||
path_hex=new_path_hex,
|
from app.repository import AppSettingsRepository
|
||||||
timestamp=timestamp,
|
|
||||||
max_paths=10,
|
settings = await AppSettingsRepository.get()
|
||||||
hop_count=new_path_len,
|
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(
|
contact_upsert = ContactUpsert(
|
||||||
public_key=advert.public_key.lower(),
|
public_key=advert.public_key.lower(),
|
||||||
@@ -482,7 +503,18 @@ async def _process_advertisement(
|
|||||||
first_seen=timestamp, # COALESCE in upsert preserves existing value
|
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)
|
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(
|
promoted_keys = await promote_prefix_contacts_for_contact(
|
||||||
public_key=advert.public_key,
|
public_key=advert.public_key,
|
||||||
log=logger,
|
log=logger,
|
||||||
@@ -528,6 +560,8 @@ async def _process_direct_message(
|
|||||||
packet_id: int,
|
packet_id: int,
|
||||||
timestamp: int,
|
timestamp: int,
|
||||||
packet_info: PacketInfo | None,
|
packet_info: PacketInfo | None,
|
||||||
|
rssi: int | None = None,
|
||||||
|
snr: float | None = None,
|
||||||
) -> dict | None:
|
) -> dict | None:
|
||||||
"""
|
"""
|
||||||
Process a TEXT_MESSAGE (direct message) packet.
|
Process a TEXT_MESSAGE (direct message) packet.
|
||||||
@@ -628,6 +662,8 @@ async def _process_direct_message(
|
|||||||
received_at=timestamp,
|
received_at=timestamp,
|
||||||
path=packet_info.path.hex() if packet_info else None,
|
path=packet_info.path.hex() if packet_info else None,
|
||||||
path_len=packet_info.path_length if packet_info else None,
|
path_len=packet_info.path_length if packet_info else None,
|
||||||
|
rssi=rssi,
|
||||||
|
snr=snr,
|
||||||
outgoing=is_outgoing,
|
outgoing=is_outgoing,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -244,3 +244,51 @@ def parse_explicit_hop_route(route_text: str) -> tuple[str, int, int]:
|
|||||||
raise ValueError(f"Explicit path exceeds MAX_PATH_SIZE={MAX_PATH_SIZE} bytes")
|
raise ValueError(f"Explicit path exceeds MAX_PATH_SIZE={MAX_PATH_SIZE} bytes")
|
||||||
|
|
||||||
return "".join(hops), len(hops), hash_size - 1
|
return "".join(hops), len(hops), hash_size - 1
|
||||||
|
|
||||||
|
|
||||||
|
async def bucket_path_hash_widths(cursor, *, batch_size: int = 500) -> dict[str, int | float]:
|
||||||
|
"""Bucket raw packet rows by hop hash width and return counts + percentages.
|
||||||
|
|
||||||
|
*cursor* must be an already-executed async cursor whose rows have a ``data``
|
||||||
|
column containing raw packet bytes.
|
||||||
|
"""
|
||||||
|
single_byte = 0
|
||||||
|
double_byte = 0
|
||||||
|
triple_byte = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
rows = await cursor.fetchmany(batch_size)
|
||||||
|
if not rows:
|
||||||
|
break
|
||||||
|
for row in rows:
|
||||||
|
envelope = parse_packet_envelope(bytes(row["data"]))
|
||||||
|
if envelope is None:
|
||||||
|
continue
|
||||||
|
if envelope.hash_size == 1:
|
||||||
|
single_byte += 1
|
||||||
|
elif envelope.hash_size == 2:
|
||||||
|
double_byte += 1
|
||||||
|
elif envelope.hash_size == 3:
|
||||||
|
triple_byte += 1
|
||||||
|
|
||||||
|
total = single_byte + double_byte + triple_byte
|
||||||
|
if total == 0:
|
||||||
|
return {
|
||||||
|
"total_packets": 0,
|
||||||
|
"single_byte": 0,
|
||||||
|
"double_byte": 0,
|
||||||
|
"triple_byte": 0,
|
||||||
|
"single_byte_pct": 0.0,
|
||||||
|
"double_byte_pct": 0.0,
|
||||||
|
"triple_byte_pct": 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_packets": total,
|
||||||
|
"single_byte": single_byte,
|
||||||
|
"double_byte": double_byte,
|
||||||
|
"triple_byte": triple_byte,
|
||||||
|
"single_byte_pct": (single_byte / total) * 100,
|
||||||
|
"double_byte_pct": (double_byte / total) * 100,
|
||||||
|
"triple_byte_pct": (triple_byte / total) * 100,
|
||||||
|
}
|
||||||
|
|||||||
+200
-68
@@ -28,8 +28,12 @@ from app.repository import (
|
|||||||
AppSettingsRepository,
|
AppSettingsRepository,
|
||||||
ChannelRepository,
|
ChannelRepository,
|
||||||
ContactRepository,
|
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.messages import create_fallback_channel_message
|
||||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||||
from app.websocket import broadcast_error, broadcast_event
|
from app.websocket import broadcast_error, broadcast_event
|
||||||
@@ -63,13 +67,25 @@ async def _reconcile_contact_messages_background(
|
|||||||
public_key: str,
|
public_key: str,
|
||||||
contact_name: str | None,
|
contact_name: str | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Run contact/message reconciliation outside the radio critical path."""
|
"""Run prefix promotion and contact/message reconciliation outside the radio critical path."""
|
||||||
try:
|
try:
|
||||||
|
promoted_keys = await promote_prefix_contacts_for_contact(
|
||||||
|
public_key=public_key,
|
||||||
|
log=logger,
|
||||||
|
)
|
||||||
await reconcile_contact_messages(
|
await reconcile_contact_messages(
|
||||||
public_key=public_key,
|
public_key=public_key,
|
||||||
contact_name=contact_name,
|
contact_name=contact_name,
|
||||||
log=logger,
|
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:
|
except Exception as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Background contact reconciliation failed for %s: %s",
|
"Background contact reconciliation failed for %s: %s",
|
||||||
@@ -140,6 +156,15 @@ ADVERT_CHECK_INTERVAL = 60
|
|||||||
# more frequently than this.
|
# more frequently than this.
|
||||||
MIN_ADVERT_INTERVAL = 3600
|
MIN_ADVERT_INTERVAL = 3600
|
||||||
|
|
||||||
|
# Periodic telemetry collection task handle
|
||||||
|
_telemetry_collect_task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
# Telemetry collection interval (8 hours)
|
||||||
|
TELEMETRY_COLLECT_INTERVAL = 8 * 3600
|
||||||
|
|
||||||
|
# Initial delay before the first telemetry collection cycle (let radio settle)
|
||||||
|
TELEMETRY_COLLECT_INITIAL_DELAY = 60
|
||||||
|
|
||||||
# Counter to pause polling during repeater operations (supports nested pauses)
|
# Counter to pause polling during repeater operations (supports nested pauses)
|
||||||
_polling_pause_count: int = 0
|
_polling_pause_count: int = 0
|
||||||
|
|
||||||
@@ -179,6 +204,22 @@ RADIO_CONTACT_REFILL_RATIO = 0.80
|
|||||||
RADIO_CONTACT_FULL_SYNC_RATIO = 0.95
|
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]:
|
def _compute_radio_contact_limits(max_contacts: int) -> tuple[int, int]:
|
||||||
"""Return (refill_target, full_sync_trigger) for the configured capacity."""
|
"""Return (refill_target, full_sync_trigger) for the configured capacity."""
|
||||||
capacity = max(1, max_contacts)
|
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:
|
async def should_run_full_periodic_sync(mc: MeshCore) -> bool:
|
||||||
"""Check current radio occupancy and decide whether to offload/reload."""
|
"""Check current radio occupancy and decide whether to offload/reload."""
|
||||||
app_settings = await AppSettingsRepository.get()
|
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)
|
refill_target, full_sync_trigger = _compute_radio_contact_limits(capacity)
|
||||||
|
|
||||||
result = await mc.commands.get_contacts()
|
result = await mc.commands.get_contacts()
|
||||||
@@ -222,70 +263,6 @@ async def should_run_full_periodic_sync(mc: MeshCore) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def sync_and_offload_contacts(mc: MeshCore) -> dict:
|
|
||||||
"""
|
|
||||||
Sync contacts from radio to database, then remove them from radio.
|
|
||||||
Returns counts of synced and removed contacts.
|
|
||||||
"""
|
|
||||||
synced = 0
|
|
||||||
removed = 0
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Get all contacts from radio
|
|
||||||
result = await mc.commands.get_contacts()
|
|
||||||
|
|
||||||
if result is None or result.type == EventType.ERROR:
|
|
||||||
logger.error(
|
|
||||||
"Failed to get contacts from radio: %s. "
|
|
||||||
"If you see this repeatedly, the radio may be visible on the "
|
|
||||||
"serial/TCP/BLE port but not responding to commands. Check for "
|
|
||||||
"another process with the serial port open (other RemoteTerm "
|
|
||||||
"instances, serial monitors, etc.), verify the firmware is "
|
|
||||||
"up-to-date and in client mode (not repeater), or try a "
|
|
||||||
"power cycle.",
|
|
||||||
result,
|
|
||||||
)
|
|
||||||
return {"synced": 0, "removed": 0, "error": str(result)}
|
|
||||||
|
|
||||||
contacts = result.payload or {}
|
|
||||||
logger.info("Found %d contacts on radio", len(contacts))
|
|
||||||
|
|
||||||
# Sync each contact to database, then remove from radio
|
|
||||||
for public_key, contact_data in contacts.items():
|
|
||||||
# Save to database
|
|
||||||
await ContactRepository.upsert(
|
|
||||||
ContactUpsert.from_radio_dict(public_key, contact_data, on_radio=False)
|
|
||||||
)
|
|
||||||
asyncio.create_task(
|
|
||||||
_reconcile_contact_messages_background(
|
|
||||||
public_key,
|
|
||||||
contact_data.get("adv_name"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
synced += 1
|
|
||||||
|
|
||||||
# Remove from radio
|
|
||||||
try:
|
|
||||||
remove_result = await mc.commands.remove_contact(contact_data)
|
|
||||||
if remove_result.type == EventType.OK:
|
|
||||||
removed += 1
|
|
||||||
_evict_removed_contact_from_library_cache(mc, public_key)
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
"Failed to remove contact %s: %s", public_key[:12], remove_result.payload
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Error removing contact %s: %s", public_key[:12], e)
|
|
||||||
|
|
||||||
logger.info("Synced %d contacts, removed %d from radio", synced, removed)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Error during contact sync: %s", e)
|
|
||||||
return {"synced": synced, "removed": removed, "error": str(e)}
|
|
||||||
|
|
||||||
return {"synced": synced, "removed": removed}
|
|
||||||
|
|
||||||
|
|
||||||
async def sync_and_offload_channels(mc: MeshCore, max_channels: int | None = None) -> dict:
|
async def sync_and_offload_channels(mc: MeshCore, max_channels: int | None = None) -> dict:
|
||||||
"""
|
"""
|
||||||
Sync channels from radio to database, then clear them from radio.
|
Sync channels from radio to database, then clear them from radio.
|
||||||
@@ -1301,7 +1278,7 @@ async def stop_background_contact_reconciliation() -> None:
|
|||||||
async def get_contacts_selected_for_radio_sync() -> list[Contact]:
|
async def get_contacts_selected_for_radio_sync() -> list[Contact]:
|
||||||
"""Return the contacts that would be loaded onto the radio right now."""
|
"""Return the contacts that would be loaded onto the radio right now."""
|
||||||
app_settings = await AppSettingsRepository.get()
|
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)
|
refill_target, _full_sync_trigger = _compute_radio_contact_limits(max_contacts)
|
||||||
selected_contacts: list[Contact] = []
|
selected_contacts: list[Contact] = []
|
||||||
selected_keys: set[str] = set()
|
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:
|
except Exception as e:
|
||||||
logger.error("Error syncing contacts to radio: %s", e, exc_info=True)
|
logger.error("Error syncing contacts to radio: %s", e, exc_info=True)
|
||||||
return {"loaded": 0, "error": str(e)}
|
return {"loaded": 0, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Periodic repeater telemetry collection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def _collect_repeater_telemetry(mc: MeshCore, contact: Contact) -> bool:
|
||||||
|
"""Fetch status telemetry from a single repeater and record it.
|
||||||
|
|
||||||
|
Returns True on success, False on failure (logged, not raised).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await mc.commands.add_contact(contact.to_radio_dict())
|
||||||
|
status = await mc.commands.req_status_sync(contact.public_key, timeout=10, min_timeout=5)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(
|
||||||
|
"Telemetry collect: radio command failed for %s: %s",
|
||||||
|
contact.public_key[:12],
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if status is None:
|
||||||
|
logger.debug("Telemetry collect: no response from %s", contact.public_key[:12])
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Map to the same field names as the manual repeater status endpoint
|
||||||
|
data = {
|
||||||
|
"battery_volts": status.get("bat", 0) / 1000.0,
|
||||||
|
"tx_queue_len": status.get("tx_queue_len", 0),
|
||||||
|
"noise_floor_dbm": status.get("noise_floor", 0),
|
||||||
|
"last_rssi_dbm": status.get("last_rssi", 0),
|
||||||
|
"last_snr_db": status.get("last_snr", 0.0),
|
||||||
|
"packets_received": status.get("nb_recv", 0),
|
||||||
|
"packets_sent": status.get("nb_sent", 0),
|
||||||
|
"airtime_seconds": status.get("airtime", 0),
|
||||||
|
"rx_airtime_seconds": status.get("rx_airtime", 0),
|
||||||
|
"uptime_seconds": status.get("uptime", 0),
|
||||||
|
"sent_flood": status.get("sent_flood", 0),
|
||||||
|
"sent_direct": status.get("sent_direct", 0),
|
||||||
|
"recv_flood": status.get("recv_flood", 0),
|
||||||
|
"recv_direct": status.get("recv_direct", 0),
|
||||||
|
"flood_dups": status.get("flood_dups", 0),
|
||||||
|
"direct_dups": status.get("direct_dups", 0),
|
||||||
|
"full_events": status.get("full_evts", 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
await RepeaterTelemetryRepository.record(
|
||||||
|
public_key=contact.public_key,
|
||||||
|
timestamp=int(time.time()),
|
||||||
|
data=data,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Telemetry collect: recorded snapshot for %s (%s)",
|
||||||
|
contact.name or contact.public_key[:12],
|
||||||
|
contact.public_key[:12],
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Telemetry collect: failed to record for %s: %s",
|
||||||
|
contact.public_key[:12],
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _telemetry_collect_loop() -> None:
|
||||||
|
"""Background task that collects telemetry from tracked repeaters every 8 hours.
|
||||||
|
|
||||||
|
Runs a first cycle after a short initial delay (so newly tracked repeaters
|
||||||
|
get a sample promptly), then sleeps the full interval between subsequent cycles.
|
||||||
|
|
||||||
|
Acquires the radio lock per-repeater (non-blocking) so manual operations can
|
||||||
|
interleave. Failures are logged and skipped.
|
||||||
|
"""
|
||||||
|
first_run = True
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
delay = TELEMETRY_COLLECT_INITIAL_DELAY if first_run else TELEMETRY_COLLECT_INTERVAL
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
first_run = False
|
||||||
|
|
||||||
|
if not radio_manager.is_connected:
|
||||||
|
logger.debug("Telemetry collect: radio not connected, skipping cycle")
|
||||||
|
continue
|
||||||
|
|
||||||
|
app_settings = await AppSettingsRepository.get()
|
||||||
|
tracked = app_settings.tracked_telemetry_repeaters
|
||||||
|
if not tracked:
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info("Telemetry collect: starting cycle for %d repeater(s)", len(tracked))
|
||||||
|
collected = 0
|
||||||
|
|
||||||
|
for pub_key in tracked:
|
||||||
|
contact = await ContactRepository.get_by_key(pub_key)
|
||||||
|
if not contact or contact.type != 2:
|
||||||
|
logger.debug(
|
||||||
|
"Telemetry collect: skipping %s (not found or not repeater)",
|
||||||
|
pub_key[:12],
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with radio_manager.radio_operation(
|
||||||
|
"telemetry_collect",
|
||||||
|
blocking=False,
|
||||||
|
suspend_auto_fetch=True,
|
||||||
|
) as mc:
|
||||||
|
if await _collect_repeater_telemetry(mc, contact):
|
||||||
|
collected += 1
|
||||||
|
except RadioOperationBusyError:
|
||||||
|
logger.debug(
|
||||||
|
"Telemetry collect: radio busy, skipping %s",
|
||||||
|
pub_key[:12],
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Telemetry collect: cycle complete, %d/%d successful",
|
||||||
|
collected,
|
||||||
|
len(tracked),
|
||||||
|
)
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("Telemetry collect task cancelled")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error in telemetry collect loop: %s", e, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def start_telemetry_collect() -> None:
|
||||||
|
"""Start the periodic telemetry collection background task."""
|
||||||
|
global _telemetry_collect_task
|
||||||
|
if _telemetry_collect_task is None or _telemetry_collect_task.done():
|
||||||
|
_telemetry_collect_task = asyncio.create_task(_telemetry_collect_loop())
|
||||||
|
logger.info(
|
||||||
|
"Started periodic telemetry collection (interval: %ds)",
|
||||||
|
TELEMETRY_COLLECT_INTERVAL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def stop_telemetry_collect() -> None:
|
||||||
|
"""Stop the periodic telemetry collection background task."""
|
||||||
|
global _telemetry_collect_task
|
||||||
|
if _telemetry_collect_task and not _telemetry_collect_task.done():
|
||||||
|
_telemetry_collect_task.cancel()
|
||||||
|
try:
|
||||||
|
await _telemetry_collect_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
_telemetry_collect_task = None
|
||||||
|
logger.info("Stopped periodic telemetry collection")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from app.repository.contacts import (
|
|||||||
from app.repository.fanout import FanoutConfigRepository
|
from app.repository.fanout import FanoutConfigRepository
|
||||||
from app.repository.messages import MessageRepository
|
from app.repository.messages import MessageRepository
|
||||||
from app.repository.raw_packets import RawPacketRepository
|
from app.repository.raw_packets import RawPacketRepository
|
||||||
|
from app.repository.repeater_telemetry import RepeaterTelemetryRepository
|
||||||
from app.repository.settings import AppSettingsRepository, StatisticsRepository
|
from app.repository.settings import AppSettingsRepository, StatisticsRepository
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -20,5 +21,6 @@ __all__ = [
|
|||||||
"FanoutConfigRepository",
|
"FanoutConfigRepository",
|
||||||
"MessageRepository",
|
"MessageRepository",
|
||||||
"RawPacketRepository",
|
"RawPacketRepository",
|
||||||
|
"RepeaterTelemetryRepository",
|
||||||
"StatisticsRepository",
|
"StatisticsRepository",
|
||||||
]
|
]
|
||||||
|
|||||||
+14
-26
@@ -26,7 +26,7 @@ class ChannelRepository:
|
|||||||
"""Get a channel by its key (32-char hex string)."""
|
"""Get a channel by its key (32-char hex string)."""
|
||||||
cursor = await db.conn.execute(
|
cursor = await db.conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT key, name, is_hashtag, on_radio, flood_scope_override, last_read_at
|
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at
|
||||||
FROM channels
|
FROM channels
|
||||||
WHERE key = ?
|
WHERE key = ?
|
||||||
""",
|
""",
|
||||||
@@ -40,6 +40,7 @@ class ChannelRepository:
|
|||||||
is_hashtag=bool(row["is_hashtag"]),
|
is_hashtag=bool(row["is_hashtag"]),
|
||||||
on_radio=bool(row["on_radio"]),
|
on_radio=bool(row["on_radio"]),
|
||||||
flood_scope_override=row["flood_scope_override"],
|
flood_scope_override=row["flood_scope_override"],
|
||||||
|
path_hash_mode_override=row["path_hash_mode_override"],
|
||||||
last_read_at=row["last_read_at"],
|
last_read_at=row["last_read_at"],
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
@@ -48,7 +49,7 @@ class ChannelRepository:
|
|||||||
async def get_all() -> list[Channel]:
|
async def get_all() -> list[Channel]:
|
||||||
cursor = await db.conn.execute(
|
cursor = await db.conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT key, name, is_hashtag, on_radio, flood_scope_override, last_read_at
|
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at
|
||||||
FROM channels
|
FROM channels
|
||||||
ORDER BY name
|
ORDER BY name
|
||||||
"""
|
"""
|
||||||
@@ -61,30 +62,7 @@ class ChannelRepository:
|
|||||||
is_hashtag=bool(row["is_hashtag"]),
|
is_hashtag=bool(row["is_hashtag"]),
|
||||||
on_radio=bool(row["on_radio"]),
|
on_radio=bool(row["on_radio"]),
|
||||||
flood_scope_override=row["flood_scope_override"],
|
flood_scope_override=row["flood_scope_override"],
|
||||||
last_read_at=row["last_read_at"],
|
path_hash_mode_override=row["path_hash_mode_override"],
|
||||||
)
|
|
||||||
for row in rows
|
|
||||||
]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def get_on_radio() -> list[Channel]:
|
|
||||||
"""Return channels currently marked as resident on the radio in the database."""
|
|
||||||
cursor = await db.conn.execute(
|
|
||||||
"""
|
|
||||||
SELECT key, name, is_hashtag, on_radio, flood_scope_override, last_read_at
|
|
||||||
FROM channels
|
|
||||||
WHERE on_radio = 1
|
|
||||||
ORDER BY name
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
rows = await cursor.fetchall()
|
|
||||||
return [
|
|
||||||
Channel(
|
|
||||||
key=row["key"],
|
|
||||||
name=row["name"],
|
|
||||||
is_hashtag=bool(row["is_hashtag"]),
|
|
||||||
on_radio=bool(row["on_radio"]),
|
|
||||||
flood_scope_override=row["flood_scope_override"],
|
|
||||||
last_read_at=row["last_read_at"],
|
last_read_at=row["last_read_at"],
|
||||||
)
|
)
|
||||||
for row in rows
|
for row in rows
|
||||||
@@ -123,6 +101,16 @@ class ChannelRepository:
|
|||||||
await db.conn.commit()
|
await db.conn.commit()
|
||||||
return cursor.rowcount > 0
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def update_path_hash_mode_override(key: str, path_hash_mode_override: int | None) -> bool:
|
||||||
|
"""Set or clear a channel's path hash mode override."""
|
||||||
|
cursor = await db.conn.execute(
|
||||||
|
"UPDATE channels SET path_hash_mode_override = ? WHERE key = ?",
|
||||||
|
(path_hash_mode_override, key.upper()),
|
||||||
|
)
|
||||||
|
await db.conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def mark_all_read(timestamp: int) -> None:
|
async def mark_all_read(timestamp: int) -> None:
|
||||||
"""Mark all channels as read at the given timestamp."""
|
"""Mark all channels as read at the given timestamp."""
|
||||||
|
|||||||
+66
-57
@@ -1,3 +1,4 @@
|
|||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from typing import Any
|
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
|
from app.path_utils import first_hop_hex, normalize_contact_route, normalize_route_override
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class AmbiguousPublicKeyPrefixError(ValueError):
|
class AmbiguousPublicKeyPrefixError(ValueError):
|
||||||
"""Raised when a public key prefix matches multiple contacts."""
|
"""Raised when a public key prefix matches multiple contacts."""
|
||||||
@@ -392,12 +395,9 @@ class ContactRepository:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
async def delete(public_key: str) -> None:
|
async def delete(public_key: str) -> None:
|
||||||
normalized = public_key.lower()
|
normalized = public_key.lower()
|
||||||
await db.conn.execute(
|
# contact_name_history and contact_advert_paths cascade via FK.
|
||||||
"DELETE FROM contact_name_history WHERE public_key = ?", (normalized,)
|
# Messages are intentionally preserved so history re-surfaces
|
||||||
)
|
# if the contact is re-added later.
|
||||||
await db.conn.execute(
|
|
||||||
"DELETE FROM contact_advert_paths WHERE public_key = ?", (normalized,)
|
|
||||||
)
|
|
||||||
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (normalized,))
|
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (normalized,))
|
||||||
await db.conn.commit()
|
await db.conn.commit()
|
||||||
|
|
||||||
@@ -484,7 +484,6 @@ class ContactRepository:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
promoted_keys: list[str] = []
|
promoted_keys: list[str] = []
|
||||||
full_exists = await ContactRepository.get_by_key(normalized_full_key) is not None
|
|
||||||
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
old_key = row["public_key"]
|
old_key = row["public_key"]
|
||||||
@@ -501,60 +500,70 @@ class ContactRepository:
|
|||||||
(old_key,),
|
(old_key,),
|
||||||
)
|
)
|
||||||
match_row = await match_cursor.fetchone()
|
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
|
continue
|
||||||
|
|
||||||
await migrate_child_rows(old_key, normalized_full_key)
|
await migrate_child_rows(old_key, normalized_full_key)
|
||||||
|
|
||||||
if full_exists:
|
# Merge timestamp metadata from the old prefix contact into the
|
||||||
await db.conn.execute(
|
# full-key contact (which all callers guarantee already exists),
|
||||||
"""
|
# then delete the prefix placeholder.
|
||||||
UPDATE contacts
|
await db.conn.execute(
|
||||||
SET last_seen = CASE
|
"""
|
||||||
WHEN contacts.last_seen IS NULL THEN ?
|
UPDATE contacts
|
||||||
WHEN ? IS NULL THEN contacts.last_seen
|
SET last_seen = CASE
|
||||||
WHEN ? > contacts.last_seen THEN ?
|
WHEN contacts.last_seen IS NULL THEN ?
|
||||||
ELSE contacts.last_seen
|
WHEN ? IS NULL THEN contacts.last_seen
|
||||||
END,
|
WHEN ? > contacts.last_seen THEN ?
|
||||||
last_contacted = CASE
|
ELSE contacts.last_seen
|
||||||
WHEN contacts.last_contacted IS NULL THEN ?
|
END,
|
||||||
WHEN ? IS NULL THEN contacts.last_contacted
|
last_contacted = CASE
|
||||||
WHEN ? > contacts.last_contacted THEN ?
|
WHEN contacts.last_contacted IS NULL THEN ?
|
||||||
ELSE contacts.last_contacted
|
WHEN ? IS NULL THEN contacts.last_contacted
|
||||||
END,
|
WHEN ? > contacts.last_contacted THEN ?
|
||||||
first_seen = CASE
|
ELSE contacts.last_contacted
|
||||||
WHEN contacts.first_seen IS NULL THEN ?
|
END,
|
||||||
WHEN ? IS NULL THEN contacts.first_seen
|
first_seen = CASE
|
||||||
WHEN ? < contacts.first_seen THEN ?
|
WHEN contacts.first_seen IS NULL THEN ?
|
||||||
ELSE contacts.first_seen
|
WHEN ? IS NULL THEN contacts.first_seen
|
||||||
END,
|
WHEN ? < contacts.first_seen THEN ?
|
||||||
last_read_at = COALESCE(contacts.last_read_at, ?)
|
ELSE contacts.first_seen
|
||||||
WHERE public_key = ?
|
END,
|
||||||
""",
|
last_read_at = CASE
|
||||||
(
|
WHEN contacts.last_read_at IS NULL THEN ?
|
||||||
row["last_seen"],
|
WHEN ? IS NULL THEN contacts.last_read_at
|
||||||
row["last_seen"],
|
WHEN ? > contacts.last_read_at THEN ?
|
||||||
row["last_seen"],
|
ELSE contacts.last_read_at
|
||||||
row["last_seen"],
|
END
|
||||||
row["last_contacted"],
|
WHERE public_key = ?
|
||||||
row["last_contacted"],
|
""",
|
||||||
row["last_contacted"],
|
(
|
||||||
row["last_contacted"],
|
row["last_seen"],
|
||||||
row["first_seen"],
|
row["last_seen"],
|
||||||
row["first_seen"],
|
row["last_seen"],
|
||||||
row["first_seen"],
|
row["last_seen"],
|
||||||
row["first_seen"],
|
row["last_contacted"],
|
||||||
row["last_read_at"],
|
row["last_contacted"],
|
||||||
normalized_full_key,
|
row["last_contacted"],
|
||||||
),
|
row["last_contacted"],
|
||||||
)
|
row["first_seen"],
|
||||||
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (old_key,))
|
row["first_seen"],
|
||||||
else:
|
row["first_seen"],
|
||||||
await db.conn.execute(
|
row["first_seen"],
|
||||||
"UPDATE contacts SET public_key = ? WHERE public_key = ?",
|
row["last_read_at"],
|
||||||
(normalized_full_key, old_key),
|
row["last_read_at"],
|
||||||
)
|
row["last_read_at"],
|
||||||
full_exists = True
|
row["last_read_at"],
|
||||||
|
normalized_full_key,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (old_key,))
|
||||||
|
|
||||||
promoted_keys.append(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]]:
|
def _contact_activity_filter(public_key: str) -> tuple[str, list[Any]]:
|
||||||
lower_key = public_key.lower()
|
lower_key = public_key.lower()
|
||||||
return (
|
return (
|
||||||
"((type = 'PRIV' AND LOWER(conversation_key) = ?)"
|
"((type = 'PRIV' AND conversation_key = ?) OR (type = 'CHAN' AND sender_key = ?))",
|
||||||
" OR (type = 'CHAN' AND LOWER(sender_key) = ?))",
|
|
||||||
[lower_key, lower_key],
|
[lower_key, lower_key],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -58,6 +57,8 @@ class MessageRepository:
|
|||||||
sender_timestamp: int | None = None,
|
sender_timestamp: int | None = None,
|
||||||
path: str | None = None,
|
path: str | None = None,
|
||||||
path_len: int | None = None,
|
path_len: int | None = None,
|
||||||
|
rssi: int | None = None,
|
||||||
|
snr: float | None = None,
|
||||||
txt_type: int = 0,
|
txt_type: int = 0,
|
||||||
signature: str | None = None,
|
signature: str | None = None,
|
||||||
outgoing: bool = False,
|
outgoing: bool = False,
|
||||||
@@ -79,8 +80,15 @@ class MessageRepository:
|
|||||||
entry: dict = {"path": path, "received_at": received_at}
|
entry: dict = {"path": path, "received_at": received_at}
|
||||||
if path_len is not None:
|
if path_len is not None:
|
||||||
entry["path_len"] = path_len
|
entry["path_len"] = path_len
|
||||||
|
if rssi is not None:
|
||||||
|
entry["rssi"] = rssi
|
||||||
|
if snr is not None:
|
||||||
|
entry["snr"] = snr
|
||||||
paths_json = json.dumps([entry])
|
paths_json = json.dumps([entry])
|
||||||
|
|
||||||
|
# Normalize sender_key to lowercase so queries can match without LOWER().
|
||||||
|
normalized_sender_key = sender_key.lower() if sender_key else sender_key
|
||||||
|
|
||||||
cursor = await db.conn.execute(
|
cursor = await db.conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT OR IGNORE INTO messages (type, conversation_key, text, sender_timestamp,
|
INSERT OR IGNORE INTO messages (type, conversation_key, text, sender_timestamp,
|
||||||
@@ -99,7 +107,7 @@ class MessageRepository:
|
|||||||
signature,
|
signature,
|
||||||
outgoing,
|
outgoing,
|
||||||
sender_name,
|
sender_name,
|
||||||
sender_key,
|
normalized_sender_key,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
await db.conn.commit()
|
await db.conn.commit()
|
||||||
@@ -114,6 +122,8 @@ class MessageRepository:
|
|||||||
path: str,
|
path: str,
|
||||||
received_at: int | None = None,
|
received_at: int | None = None,
|
||||||
path_len: int | None = None,
|
path_len: int | None = None,
|
||||||
|
rssi: int | None = None,
|
||||||
|
snr: float | None = None,
|
||||||
) -> list[MessagePath]:
|
) -> list[MessagePath]:
|
||||||
"""Add a new path to an existing message.
|
"""Add a new path to an existing message.
|
||||||
|
|
||||||
@@ -127,6 +137,10 @@ class MessageRepository:
|
|||||||
entry: dict = {"path": path, "received_at": ts}
|
entry: dict = {"path": path, "received_at": ts}
|
||||||
if path_len is not None:
|
if path_len is not None:
|
||||||
entry["path_len"] = path_len
|
entry["path_len"] = path_len
|
||||||
|
if rssi is not None:
|
||||||
|
entry["rssi"] = rssi
|
||||||
|
if snr is not None:
|
||||||
|
entry["snr"] = snr
|
||||||
new_entry = json.dumps(entry)
|
new_entry = json.dumps(entry)
|
||||||
await db.conn.execute(
|
await db.conn.execute(
|
||||||
"""UPDATE messages SET paths = json_insert(
|
"""UPDATE messages SET paths = json_insert(
|
||||||
@@ -158,7 +172,11 @@ class MessageRepository:
|
|||||||
"""
|
"""
|
||||||
lower_key = full_key.lower()
|
lower_key = full_key.lower()
|
||||||
cursor = await db.conn.execute(
|
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
|
WHERE type = 'PRIV' AND length(conversation_key) < 64
|
||||||
AND ? LIKE conversation_key || '%'
|
AND ? LIKE conversation_key || '%'
|
||||||
AND (
|
AND (
|
||||||
@@ -166,7 +184,7 @@ class MessageRepository:
|
|||||||
WHERE length(public_key) = 64
|
WHERE length(public_key) = 64
|
||||||
AND public_key LIKE messages.conversation_key || '%'
|
AND public_key LIKE messages.conversation_key || '%'
|
||||||
) = 1""",
|
) = 1""",
|
||||||
(lower_key, lower_key),
|
(lower_key, lower_key, lower_key, lower_key),
|
||||||
)
|
)
|
||||||
await db.conn.commit()
|
await db.conn.commit()
|
||||||
return cursor.rowcount
|
return cursor.rowcount
|
||||||
@@ -255,10 +273,10 @@ class MessageRepository:
|
|||||||
|
|
||||||
if MessageRepository._looks_like_hex_prefix(value):
|
if MessageRepository._looks_like_hex_prefix(value):
|
||||||
if len(value) == 32:
|
if len(value) == 32:
|
||||||
clause += " OR UPPER(messages.conversation_key) = ?"
|
clause += " OR messages.conversation_key = ?"
|
||||||
params.append(value.upper())
|
params.append(value.upper())
|
||||||
else:
|
else:
|
||||||
clause += " OR UPPER(messages.conversation_key) LIKE ? ESCAPE '\\'"
|
clause += " OR messages.conversation_key LIKE ? ESCAPE '\\'"
|
||||||
params.append(f"{MessageRepository._escape_like(value.upper())}%")
|
params.append(f"{MessageRepository._escape_like(value.upper())}%")
|
||||||
|
|
||||||
clause += "))"
|
clause += "))"
|
||||||
@@ -277,13 +295,13 @@ class MessageRepository:
|
|||||||
priv_key_clause: str
|
priv_key_clause: str
|
||||||
chan_key_clause: str
|
chan_key_clause: str
|
||||||
if len(value) == 64:
|
if len(value) == 64:
|
||||||
priv_key_clause = "LOWER(messages.conversation_key) = ?"
|
priv_key_clause = "messages.conversation_key = ?"
|
||||||
chan_key_clause = "LOWER(sender_key) = ?"
|
chan_key_clause = "sender_key = ?"
|
||||||
params.extend([lower_value, lower_value])
|
params.extend([lower_value, lower_value])
|
||||||
else:
|
else:
|
||||||
escaped_prefix = f"{MessageRepository._escape_like(lower_value)}%"
|
escaped_prefix = f"{MessageRepository._escape_like(lower_value)}%"
|
||||||
priv_key_clause = "LOWER(messages.conversation_key) LIKE ? ESCAPE '\\'"
|
priv_key_clause = "messages.conversation_key LIKE ? ESCAPE '\\'"
|
||||||
chan_key_clause = "LOWER(sender_key) LIKE ? ESCAPE '\\'"
|
chan_key_clause = "sender_key LIKE ? ESCAPE '\\'"
|
||||||
params.extend([escaped_prefix, escaped_prefix])
|
params.extend([escaped_prefix, escaped_prefix])
|
||||||
|
|
||||||
clause += (
|
clause += (
|
||||||
@@ -307,12 +325,12 @@ class MessageRepository:
|
|||||||
if blocked_keys:
|
if blocked_keys:
|
||||||
placeholders = ",".join("?" for _ in blocked_keys)
|
placeholders = ",".join("?" for _ in blocked_keys)
|
||||||
blocked_matchers.append(
|
blocked_matchers.append(
|
||||||
f"({prefix}type = 'PRIV' AND LOWER({prefix}conversation_key) IN ({placeholders}))"
|
f"({prefix}type = 'PRIV' AND {prefix}conversation_key IN ({placeholders}))"
|
||||||
)
|
)
|
||||||
params.extend(blocked_keys)
|
params.extend(blocked_keys)
|
||||||
blocked_matchers.append(
|
blocked_matchers.append(
|
||||||
f"({prefix}type = 'CHAN' AND {prefix}sender_key IS NOT NULL"
|
f"({prefix}type = 'CHAN' AND {prefix}sender_key IS NOT NULL"
|
||||||
f" AND LOWER({prefix}sender_key) IN ({placeholders}))"
|
f" AND {prefix}sender_key IN ({placeholders}))"
|
||||||
)
|
)
|
||||||
params.extend(blocked_keys)
|
params.extend(blocked_keys)
|
||||||
|
|
||||||
@@ -379,9 +397,9 @@ class MessageRepository:
|
|||||||
query = (
|
query = (
|
||||||
f"SELECT {MessageRepository._message_select('messages')} FROM messages "
|
f"SELECT {MessageRepository._message_select('messages')} FROM messages "
|
||||||
"LEFT JOIN contacts ON messages.type = 'PRIV' "
|
"LEFT JOIN contacts ON messages.type = 'PRIV' "
|
||||||
"AND LOWER(messages.conversation_key) = LOWER(contacts.public_key) "
|
"AND messages.conversation_key = contacts.public_key "
|
||||||
"LEFT JOIN channels ON messages.type = 'CHAN' "
|
"LEFT JOIN channels ON messages.type = 'CHAN' "
|
||||||
"AND UPPER(messages.conversation_key) = UPPER(channels.key) "
|
"AND messages.conversation_key = channels.key "
|
||||||
"WHERE 1=1"
|
"WHERE 1=1"
|
||||||
)
|
)
|
||||||
params: list[Any] = []
|
params: list[Any] = []
|
||||||
@@ -572,6 +590,9 @@ class MessageRepository:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
async def delete_by_id(message_id: int) -> None:
|
async def delete_by_id(message_id: int) -> None:
|
||||||
"""Delete a message row by ID."""
|
"""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.execute("DELETE FROM messages WHERE id = ?", (message_id,))
|
||||||
await db.conn.commit()
|
await db.conn.commit()
|
||||||
|
|
||||||
@@ -666,7 +687,7 @@ class MessageRepository:
|
|||||||
ELSE 0
|
ELSE 0
|
||||||
END) > 0 as has_mention
|
END) > 0 as has_mention
|
||||||
FROM messages m
|
FROM messages m
|
||||||
JOIN contacts ct ON m.conversation_key = ct.public_key
|
LEFT JOIN contacts ct ON m.conversation_key = ct.public_key
|
||||||
WHERE m.type = 'PRIV' AND m.outgoing = 0
|
WHERE m.type = 'PRIV' AND m.outgoing = 0
|
||||||
AND m.received_at > COALESCE(ct.last_read_at, 0)
|
AND m.received_at > COALESCE(ct.last_read_at, 0)
|
||||||
{blocked_sql}
|
{blocked_sql}
|
||||||
@@ -777,12 +798,14 @@ class MessageRepository:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_channel_stats(conversation_key: str) -> dict:
|
async def get_channel_stats(conversation_key: str) -> dict:
|
||||||
"""Get channel message statistics: time-windowed counts, first message, unique senders, top senders.
|
"""Get channel message statistics: time-windowed counts, first message, unique senders, top senders, path hash widths.
|
||||||
|
|
||||||
Returns a dict with message_counts, first_message_at, unique_sender_count, top_senders_24h.
|
Returns a dict with message_counts, first_message_at, unique_sender_count, top_senders_24h, path_hash_width_24h.
|
||||||
"""
|
"""
|
||||||
import time as _time
|
import time as _time
|
||||||
|
|
||||||
|
from app.path_utils import bucket_path_hash_widths
|
||||||
|
|
||||||
now = int(_time.time())
|
now = int(_time.time())
|
||||||
t_1h = now - 3600
|
t_1h = now - 3600
|
||||||
t_24h = now - 86400
|
t_24h = now - 86400
|
||||||
@@ -834,11 +857,24 @@ class MessageRepository:
|
|||||||
for r in top_rows
|
for r in top_rows
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Path hash width distribution for last 24h (in-Python parse of raw packet envelopes)
|
||||||
|
cursor3 = await db.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT rp.data FROM raw_packets rp
|
||||||
|
JOIN messages m ON rp.message_id = m.id
|
||||||
|
WHERE m.type = 'CHAN' AND m.conversation_key = ?
|
||||||
|
AND rp.timestamp >= ?
|
||||||
|
""",
|
||||||
|
(conversation_key, t_24h),
|
||||||
|
)
|
||||||
|
path_hash_width_24h = await bucket_path_hash_widths(cursor3)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"message_counts": message_counts,
|
"message_counts": message_counts,
|
||||||
"first_message_at": row["first_message_at"],
|
"first_message_at": row["first_message_at"],
|
||||||
"unique_sender_count": row["unique_sender_count"] or 0,
|
"unique_sender_count": row["unique_sender_count"] or 0,
|
||||||
"top_senders_24h": top_senders,
|
"top_senders_24h": top_senders,
|
||||||
|
"path_hash_width_24h": path_hash_width_24h,
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import logging
|
import logging
|
||||||
import sqlite3
|
|
||||||
import time
|
import time
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
@@ -35,46 +34,23 @@ class RawPacketRepository:
|
|||||||
# For malformed packets, hash the full data
|
# For malformed packets, hash the full data
|
||||||
payload_hash = sha256(data).digest()
|
payload_hash = sha256(data).digest()
|
||||||
|
|
||||||
# Check if this payload already exists
|
cursor = await db.conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO raw_packets (timestamp, data, payload_hash) VALUES (?, ?, ?)",
|
||||||
|
(ts, data, payload_hash),
|
||||||
|
)
|
||||||
|
await db.conn.commit()
|
||||||
|
|
||||||
|
if cursor.rowcount > 0:
|
||||||
|
assert cursor.lastrowid is not None
|
||||||
|
return (cursor.lastrowid, True)
|
||||||
|
|
||||||
|
# Duplicate payload — look up the existing row.
|
||||||
cursor = await db.conn.execute(
|
cursor = await db.conn.execute(
|
||||||
"SELECT id FROM raw_packets WHERE payload_hash = ?", (payload_hash,)
|
"SELECT id FROM raw_packets WHERE payload_hash = ?", (payload_hash,)
|
||||||
)
|
)
|
||||||
existing = await cursor.fetchone()
|
existing = await cursor.fetchone()
|
||||||
|
assert existing is not None
|
||||||
if existing:
|
return (existing["id"], False)
|
||||||
# Duplicate - return existing packet ID
|
|
||||||
logger.debug(
|
|
||||||
"Duplicate payload detected (hash=%s..., existing_id=%d)",
|
|
||||||
payload_hash.hex()[:12],
|
|
||||||
existing["id"],
|
|
||||||
)
|
|
||||||
return (existing["id"], False)
|
|
||||||
|
|
||||||
# New packet - insert with hash
|
|
||||||
try:
|
|
||||||
cursor = await db.conn.execute(
|
|
||||||
"INSERT INTO raw_packets (timestamp, data, payload_hash) VALUES (?, ?, ?)",
|
|
||||||
(ts, data, payload_hash),
|
|
||||||
)
|
|
||||||
await db.conn.commit()
|
|
||||||
assert cursor.lastrowid is not None # INSERT always returns a row ID
|
|
||||||
return (cursor.lastrowid, True)
|
|
||||||
except sqlite3.IntegrityError:
|
|
||||||
# Race condition: another insert with same payload_hash happened between
|
|
||||||
# our SELECT and INSERT. This is expected for duplicate packets arriving
|
|
||||||
# close together. Query again to get the existing ID.
|
|
||||||
logger.debug(
|
|
||||||
"Duplicate packet detected via race condition (payload_hash=%s), dropping",
|
|
||||||
payload_hash.hex()[:16],
|
|
||||||
)
|
|
||||||
cursor = await db.conn.execute(
|
|
||||||
"SELECT id FROM raw_packets WHERE payload_hash = ?", (payload_hash,)
|
|
||||||
)
|
|
||||||
existing = await cursor.fetchone()
|
|
||||||
if existing:
|
|
||||||
return (existing["id"], False)
|
|
||||||
# This shouldn't happen, but if it does, re-raise
|
|
||||||
raise
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_undecrypted_count() -> int:
|
async def get_undecrypted_count() -> int:
|
||||||
@@ -95,13 +71,22 @@ class RawPacketRepository:
|
|||||||
return row["oldest"] if row and row["oldest"] is not None else None
|
return row["oldest"] if row and row["oldest"] is not None else None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_all_undecrypted() -> list[tuple[int, bytes, int]]:
|
async def stream_all_undecrypted(
|
||||||
"""Get all undecrypted packets as (id, data, timestamp) tuples."""
|
batch_size: int = UNDECRYPTED_PACKET_BATCH_SIZE,
|
||||||
|
) -> AsyncIterator[tuple[int, bytes, int]]:
|
||||||
|
"""Yield all undecrypted packets as (id, data, timestamp) in bounded batches."""
|
||||||
cursor = await db.conn.execute(
|
cursor = await db.conn.execute(
|
||||||
"SELECT id, data, timestamp FROM raw_packets WHERE message_id IS NULL ORDER BY timestamp ASC"
|
"SELECT id, data, timestamp FROM raw_packets WHERE message_id IS NULL ORDER BY timestamp ASC"
|
||||||
)
|
)
|
||||||
rows = await cursor.fetchall()
|
try:
|
||||||
return [(row["id"], bytes(row["data"]), row["timestamp"]) for row in rows]
|
while True:
|
||||||
|
rows = await cursor.fetchmany(batch_size)
|
||||||
|
if not rows:
|
||||||
|
break
|
||||||
|
for row in rows:
|
||||||
|
yield (row["id"], bytes(row["data"]), row["timestamp"])
|
||||||
|
finally:
|
||||||
|
await cursor.close()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def stream_undecrypted_text_messages(
|
async def stream_undecrypted_text_messages(
|
||||||
@@ -187,12 +172,3 @@ class RawPacketRepository:
|
|||||||
cursor = await db.conn.execute("DELETE FROM raw_packets WHERE message_id IS NOT NULL")
|
cursor = await db.conn.execute("DELETE FROM raw_packets WHERE message_id IS NOT NULL")
|
||||||
await db.conn.commit()
|
await db.conn.commit()
|
||||||
return cursor.rowcount
|
return cursor.rowcount
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def get_undecrypted_text_messages() -> list[tuple[int, bytes, int]]:
|
|
||||||
"""Get all undecrypted TEXT_MESSAGE packets as (id, data, timestamp) tuples.
|
|
||||||
|
|
||||||
Filters raw packets to only include those with PayloadType.TEXT_MESSAGE (0x02).
|
|
||||||
These are direct messages that can be decrypted with contact ECDH keys.
|
|
||||||
"""
|
|
||||||
return [packet async for packet in RawPacketRepository.stream_undecrypted_text_messages()]
|
|
||||||
|
|||||||
@@ -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.database import db
|
||||||
from app.models import AppSettings, Favorite
|
from app.models import AppSettings, Favorite
|
||||||
from app.path_utils import parse_packet_envelope
|
from app.path_utils import bucket_path_hash_widths
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -27,9 +27,10 @@ class AppSettingsRepository:
|
|||||||
cursor = await db.conn.execute(
|
cursor = await db.conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert,
|
SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert,
|
||||||
sidebar_sort_order, last_message_times, preferences_migrated,
|
last_message_times,
|
||||||
advert_interval, last_advert_time, flood_scope,
|
advert_interval, last_advert_time, flood_scope,
|
||||||
blocked_keys, blocked_names
|
blocked_keys, blocked_names, discovery_blocked_types,
|
||||||
|
tracked_telemetry_repeaters, auto_resend_channel
|
||||||
FROM app_settings WHERE id = 1
|
FROM app_settings WHERE id = 1
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
@@ -81,23 +82,42 @@ class AppSettingsRepository:
|
|||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
blocked_names = []
|
blocked_names = []
|
||||||
|
|
||||||
# Validate sidebar_sort_order (fallback to "recent" if invalid)
|
# Parse discovery_blocked_types JSON
|
||||||
sort_order = row["sidebar_sort_order"]
|
discovery_blocked_types: list[int] = []
|
||||||
if sort_order not in ("recent", "alpha"):
|
if row["discovery_blocked_types"]:
|
||||||
sort_order = "recent"
|
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(
|
return AppSettings(
|
||||||
max_radio_contacts=row["max_radio_contacts"],
|
max_radio_contacts=row["max_radio_contacts"],
|
||||||
favorites=favorites,
|
favorites=favorites,
|
||||||
auto_decrypt_dm_on_advert=bool(row["auto_decrypt_dm_on_advert"]),
|
auto_decrypt_dm_on_advert=bool(row["auto_decrypt_dm_on_advert"]),
|
||||||
sidebar_sort_order=sort_order,
|
|
||||||
last_message_times=last_message_times,
|
last_message_times=last_message_times,
|
||||||
preferences_migrated=bool(row["preferences_migrated"]),
|
|
||||||
advert_interval=row["advert_interval"] or 0,
|
advert_interval=row["advert_interval"] or 0,
|
||||||
last_advert_time=row["last_advert_time"] or 0,
|
last_advert_time=row["last_advert_time"] or 0,
|
||||||
flood_scope=row["flood_scope"] or "",
|
flood_scope=row["flood_scope"] or "",
|
||||||
blocked_keys=blocked_keys,
|
blocked_keys=blocked_keys,
|
||||||
blocked_names=blocked_names,
|
blocked_names=blocked_names,
|
||||||
|
discovery_blocked_types=discovery_blocked_types,
|
||||||
|
tracked_telemetry_repeaters=tracked_telemetry_repeaters,
|
||||||
|
auto_resend_channel=auto_resend_channel,
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -105,14 +125,15 @@ class AppSettingsRepository:
|
|||||||
max_radio_contacts: int | None = None,
|
max_radio_contacts: int | None = None,
|
||||||
favorites: list[Favorite] | None = None,
|
favorites: list[Favorite] | None = None,
|
||||||
auto_decrypt_dm_on_advert: bool | None = None,
|
auto_decrypt_dm_on_advert: bool | None = None,
|
||||||
sidebar_sort_order: str | None = None,
|
|
||||||
last_message_times: dict[str, int] | None = None,
|
last_message_times: dict[str, int] | None = None,
|
||||||
preferences_migrated: bool | None = None,
|
|
||||||
advert_interval: int | None = None,
|
advert_interval: int | None = None,
|
||||||
last_advert_time: int | None = None,
|
last_advert_time: int | None = None,
|
||||||
flood_scope: str | None = None,
|
flood_scope: str | None = None,
|
||||||
blocked_keys: list[str] | None = None,
|
blocked_keys: list[str] | None = None,
|
||||||
blocked_names: list[str] | None = None,
|
blocked_names: list[str] | None = None,
|
||||||
|
discovery_blocked_types: list[int] | None = None,
|
||||||
|
tracked_telemetry_repeaters: list[str] | None = None,
|
||||||
|
auto_resend_channel: bool | None = None,
|
||||||
) -> AppSettings:
|
) -> AppSettings:
|
||||||
"""Update app settings. Only provided fields are updated."""
|
"""Update app settings. Only provided fields are updated."""
|
||||||
updates = []
|
updates = []
|
||||||
@@ -131,18 +152,10 @@ class AppSettingsRepository:
|
|||||||
updates.append("auto_decrypt_dm_on_advert = ?")
|
updates.append("auto_decrypt_dm_on_advert = ?")
|
||||||
params.append(1 if auto_decrypt_dm_on_advert else 0)
|
params.append(1 if auto_decrypt_dm_on_advert else 0)
|
||||||
|
|
||||||
if sidebar_sort_order is not None:
|
|
||||||
updates.append("sidebar_sort_order = ?")
|
|
||||||
params.append(sidebar_sort_order)
|
|
||||||
|
|
||||||
if last_message_times is not None:
|
if last_message_times is not None:
|
||||||
updates.append("last_message_times = ?")
|
updates.append("last_message_times = ?")
|
||||||
params.append(json.dumps(last_message_times))
|
params.append(json.dumps(last_message_times))
|
||||||
|
|
||||||
if preferences_migrated is not None:
|
|
||||||
updates.append("preferences_migrated = ?")
|
|
||||||
params.append(1 if preferences_migrated else 0)
|
|
||||||
|
|
||||||
if advert_interval is not None:
|
if advert_interval is not None:
|
||||||
updates.append("advert_interval = ?")
|
updates.append("advert_interval = ?")
|
||||||
params.append(advert_interval)
|
params.append(advert_interval)
|
||||||
@@ -163,6 +176,18 @@ class AppSettingsRepository:
|
|||||||
updates.append("blocked_names = ?")
|
updates.append("blocked_names = ?")
|
||||||
params.append(json.dumps(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:
|
if updates:
|
||||||
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
|
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
|
||||||
await db.conn.execute(query, params)
|
await db.conn.execute(query, params)
|
||||||
@@ -212,39 +237,6 @@ class AppSettingsRepository:
|
|||||||
new_names = settings.blocked_names + [name]
|
new_names = settings.blocked_names + [name]
|
||||||
return await AppSettingsRepository.update(blocked_names=new_names)
|
return await AppSettingsRepository.update(blocked_names=new_names)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def migrate_preferences_from_frontend(
|
|
||||||
favorites: list[dict],
|
|
||||||
sort_order: str,
|
|
||||||
last_message_times: dict[str, int],
|
|
||||||
) -> tuple[AppSettings, bool]:
|
|
||||||
"""Migrate all preferences from frontend localStorage.
|
|
||||||
|
|
||||||
This is a one-time migration. If already migrated, returns current settings
|
|
||||||
without overwriting. Returns (settings, did_migrate) tuple.
|
|
||||||
"""
|
|
||||||
settings = await AppSettingsRepository.get()
|
|
||||||
|
|
||||||
if settings.preferences_migrated:
|
|
||||||
# Already migrated, don't overwrite
|
|
||||||
return settings, False
|
|
||||||
|
|
||||||
# Convert frontend favorites format to Favorite objects
|
|
||||||
new_favorites = []
|
|
||||||
for f in favorites:
|
|
||||||
if f.get("type") in ("channel", "contact") and f.get("id"):
|
|
||||||
new_favorites.append(Favorite(type=f["type"], id=f["id"]))
|
|
||||||
|
|
||||||
# Update with migrated preferences and mark as migrated
|
|
||||||
settings = await AppSettingsRepository.update(
|
|
||||||
favorites=new_favorites,
|
|
||||||
sidebar_sort_order=sort_order if sort_order in ("recent", "alpha") else "recent",
|
|
||||||
last_message_times=last_message_times,
|
|
||||||
preferences_migrated=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
return settings, True
|
|
||||||
|
|
||||||
|
|
||||||
class StatisticsRepository:
|
class StatisticsRepository:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -332,48 +324,7 @@ class StatisticsRepository:
|
|||||||
"SELECT data FROM raw_packets WHERE timestamp >= ?",
|
"SELECT data FROM raw_packets WHERE timestamp >= ?",
|
||||||
(now - SECONDS_24H,),
|
(now - SECONDS_24H,),
|
||||||
)
|
)
|
||||||
|
return await bucket_path_hash_widths(cursor, batch_size=RAW_PACKET_STATS_BATCH_SIZE)
|
||||||
single_byte = 0
|
|
||||||
double_byte = 0
|
|
||||||
triple_byte = 0
|
|
||||||
|
|
||||||
while True:
|
|
||||||
rows = await cursor.fetchmany(RAW_PACKET_STATS_BATCH_SIZE)
|
|
||||||
if not rows:
|
|
||||||
break
|
|
||||||
|
|
||||||
for row in rows:
|
|
||||||
envelope = parse_packet_envelope(bytes(row["data"]))
|
|
||||||
if envelope is None:
|
|
||||||
continue
|
|
||||||
if envelope.hash_size == 1:
|
|
||||||
single_byte += 1
|
|
||||||
elif envelope.hash_size == 2:
|
|
||||||
double_byte += 1
|
|
||||||
elif envelope.hash_size == 3:
|
|
||||||
triple_byte += 1
|
|
||||||
|
|
||||||
total_packets = single_byte + double_byte + triple_byte
|
|
||||||
if total_packets == 0:
|
|
||||||
return {
|
|
||||||
"total_packets": 0,
|
|
||||||
"single_byte": 0,
|
|
||||||
"double_byte": 0,
|
|
||||||
"triple_byte": 0,
|
|
||||||
"single_byte_pct": 0.0,
|
|
||||||
"double_byte_pct": 0.0,
|
|
||||||
"triple_byte_pct": 0.0,
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"total_packets": total_packets,
|
|
||||||
"single_byte": single_byte,
|
|
||||||
"double_byte": double_byte,
|
|
||||||
"triple_byte": triple_byte,
|
|
||||||
"single_byte_pct": (single_byte / total_packets) * 100,
|
|
||||||
"double_byte_pct": (double_byte / total_packets) * 100,
|
|
||||||
"triple_byte_pct": (triple_byte / total_packets) * 100,
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_all() -> dict:
|
async def get_all() -> dict:
|
||||||
|
|||||||
+260
-47
@@ -1,7 +1,8 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, BackgroundTasks, HTTPException, Response, status
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.channel_constants import (
|
from app.channel_constants import (
|
||||||
@@ -10,10 +11,12 @@ from app.channel_constants import (
|
|||||||
is_public_channel_key,
|
is_public_channel_key,
|
||||||
is_public_channel_name,
|
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.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.region_scope import normalize_region_scope
|
||||||
from app.repository import ChannelRepository, MessageRepository
|
from app.repository import ChannelRepository, MessageRepository, RawPacketRepository
|
||||||
from app.websocket import broadcast_event
|
from app.websocket import broadcast_event, broadcast_success
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/channels", tags=["channels"])
|
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):
|
class ChannelFloodScopeOverrideRequest(BaseModel):
|
||||||
flood_scope_override: str = Field(
|
flood_scope_override: str = Field(
|
||||||
description="Blank clears the override; non-empty values temporarily override flood scope"
|
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])
|
@router.get("", response_model=list[Channel])
|
||||||
async def list_channels() -> list[Channel]:
|
async def list_channels() -> list[Channel]:
|
||||||
"""List all channels from the database."""
|
"""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"],
|
first_message_at=stats["first_message_at"],
|
||||||
unique_sender_count=stats["unique_sender_count"],
|
unique_sender_count=stats["unique_sender_count"],
|
||||||
top_senders_24h=[ChannelTopSender(**s) for s in stats["top_senders_24h"]],
|
top_senders_24h=[ChannelTopSender(**s) for s in stats["top_senders_24h"]],
|
||||||
|
path_hash_width_24h=stats["path_hash_width_24h"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -69,50 +227,7 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
|
|||||||
automatically when sending a message (see messages.py send_channel_message).
|
automatically when sending a message (see messages.py send_channel_message).
|
||||||
"""
|
"""
|
||||||
requested_name = request.name
|
requested_name = request.name
|
||||||
is_hashtag = requested_name.startswith("#")
|
key_hex, channel_name, is_hashtag = _derive_channel_identity(requested_name, request.key)
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
logger.info("Creating channel %s: %s (hashtag=%s)", key_hex, channel_name, is_hashtag)
|
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
|
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")
|
@router.post("/{key}/mark-read")
|
||||||
async def mark_channel_read(key: str) -> dict:
|
async def mark_channel_read(key: str) -> dict:
|
||||||
"""Mark a channel as read (update last_read_at timestamp)."""
|
"""Mark a channel as read (update last_read_at timestamp)."""
|
||||||
@@ -168,6 +358,29 @@ async def set_channel_flood_scope_override(
|
|||||||
return refreshed
|
return refreshed
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{key}/path-hash-mode-override", response_model=Channel)
|
||||||
|
async def set_channel_path_hash_mode_override(
|
||||||
|
key: str, request: ChannelPathHashModeOverrideRequest
|
||||||
|
) -> Channel:
|
||||||
|
"""Set or clear a per-channel path hash mode override."""
|
||||||
|
channel = await ChannelRepository.get_by_key(key)
|
||||||
|
if not channel:
|
||||||
|
raise HTTPException(status_code=404, detail="Channel not found")
|
||||||
|
|
||||||
|
updated = await ChannelRepository.update_path_hash_mode_override(
|
||||||
|
channel.key, request.path_hash_mode_override
|
||||||
|
)
|
||||||
|
if not updated:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to update path-hash-mode override")
|
||||||
|
|
||||||
|
refreshed = await ChannelRepository.get_by_key(channel.key)
|
||||||
|
if refreshed is None:
|
||||||
|
raise HTTPException(status_code=500, detail="Channel disappeared after update")
|
||||||
|
|
||||||
|
broadcast_event("channel", refreshed.model_dump())
|
||||||
|
return refreshed
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{key}")
|
@router.delete("/{key}")
|
||||||
async def delete_channel(key: str) -> dict:
|
async def delete_channel(key: str) -> dict:
|
||||||
"""Delete a channel from the database by key.
|
"""Delete a channel from the database by key.
|
||||||
|
|||||||
+52
-6
@@ -1,12 +1,13 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
|
import time
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
|
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
|
||||||
from meshcore import EventType
|
from meshcore import EventType
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.dependencies import require_connected
|
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Contact,
|
Contact,
|
||||||
ContactActiveRoom,
|
ContactActiveRoom,
|
||||||
@@ -31,7 +32,7 @@ from app.repository import (
|
|||||||
)
|
)
|
||||||
from app.services.contact_reconciliation import (
|
from app.services.contact_reconciliation import (
|
||||||
promote_prefix_contacts_for_contact,
|
promote_prefix_contacts_for_contact,
|
||||||
reconcile_contact_messages,
|
record_contact_name_and_reconcile,
|
||||||
)
|
)
|
||||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||||
|
|
||||||
@@ -277,12 +278,18 @@ async def create_contact(
|
|||||||
# Check if contact already exists
|
# Check if contact already exists
|
||||||
existing = await ContactRepository.get_by_key(request.public_key)
|
existing = await ContactRepository.get_by_key(request.public_key)
|
||||||
if existing:
|
if existing:
|
||||||
# Update name if provided
|
# Update name if provided and record name history
|
||||||
if request.name:
|
if request.name:
|
||||||
await ContactRepository.upsert(existing.to_upsert(name=request.name))
|
await ContactRepository.upsert(existing.to_upsert(name=request.name))
|
||||||
refreshed = await ContactRepository.get_by_key(request.public_key)
|
refreshed = await ContactRepository.get_by_key(request.public_key)
|
||||||
if refreshed is not None:
|
if refreshed is not None:
|
||||||
existing = refreshed
|
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(
|
promoted_keys = await promote_prefix_contacts_for_contact(
|
||||||
public_key=request.public_key,
|
public_key=request.public_key,
|
||||||
@@ -317,9 +324,10 @@ async def create_contact(
|
|||||||
log=logger,
|
log=logger,
|
||||||
)
|
)
|
||||||
|
|
||||||
await reconcile_contact_messages(
|
await record_contact_name_and_reconcile(
|
||||||
public_key=lower_key,
|
public_key=lower_key,
|
||||||
contact_name=request.name,
|
contact_name=request.name,
|
||||||
|
timestamp=int(time.time()),
|
||||||
log=logger,
|
log=logger,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -347,6 +355,44 @@ async def mark_contact_read(public_key: str) -> dict:
|
|||||||
return {"status": "ok", "public_key": contact.public_key}
|
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}")
|
@router.delete("/{public_key}")
|
||||||
async def delete_contact(public_key: str) -> dict:
|
async def delete_contact(public_key: str) -> dict:
|
||||||
"""Delete a contact from the database (and radio if present)."""
|
"""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
|
(no intermediate repeaters). This uses TRACE's dedicated width flags rather
|
||||||
than the radio's normal path_hash_mode setting.
|
than the radio's normal path_hash_mode setting.
|
||||||
"""
|
"""
|
||||||
require_connected()
|
radio_manager.require_connected()
|
||||||
|
|
||||||
contact = await _resolve_contact_or_404(public_key)
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
|
|
||||||
@@ -440,7 +486,7 @@ async def request_trace(public_key: str) -> TraceResponse:
|
|||||||
@router.post("/{public_key}/path-discovery", response_model=PathDiscoveryResponse)
|
@router.post("/{public_key}/path-discovery", response_model=PathDiscoveryResponse)
|
||||||
async def request_path_discovery(public_key: str) -> PathDiscoveryResponse:
|
async def request_path_discovery(public_key: str) -> PathDiscoveryResponse:
|
||||||
"""Discover the current forward and return paths to a known contact."""
|
"""Discover the current forward and return paths to a known contact."""
|
||||||
require_connected()
|
radio_manager.require_connected()
|
||||||
|
|
||||||
contact = await _resolve_contact_or_404(public_key)
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
pubkey_prefix = contact.public_key[:12]
|
pubkey_prefix = contact.public_key[:12]
|
||||||
|
|||||||
+138
-19
@@ -1,17 +1,21 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import struct
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any, Literal
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from meshcore import EventType
|
from meshcore import EventType
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.config import get_recent_log_lines, settings
|
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.radio_sync import get_contacts_selected_for_radio_sync, get_radio_channel_limit
|
||||||
from app.repository import MessageRepository, StatisticsRepository
|
from app.repository import AppSettingsRepository, MessageRepository, StatisticsRepository
|
||||||
from app.routers.health import HealthResponse, build_health_data
|
from app.routers.health import FanoutStatusResponse, build_health_data
|
||||||
from app.services.radio_runtime import radio_runtime
|
from app.services.radio_runtime import radio_runtime
|
||||||
from app.version_info import get_app_build_info, git_output
|
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):
|
class DebugApplicationInfo(BaseModel):
|
||||||
version: str
|
version: str
|
||||||
version_source: str
|
version_source: str
|
||||||
@@ -50,8 +61,6 @@ class DebugRuntimeInfo(BaseModel):
|
|||||||
setup_in_progress: bool
|
setup_in_progress: bool
|
||||||
setup_complete: bool
|
setup_complete: bool
|
||||||
channels_with_incoming_messages: int
|
channels_with_incoming_messages: int
|
||||||
max_channels: int
|
|
||||||
path_hash_mode: int
|
|
||||||
path_hash_mode_supported: bool
|
path_hash_mode_supported: bool
|
||||||
channel_slot_reuse_enabled: bool
|
channel_slot_reuse_enabled: bool
|
||||||
channel_send_cache_capacity: int
|
channel_send_cache_capacity: int
|
||||||
@@ -78,7 +87,6 @@ class DebugChannelAudit(BaseModel):
|
|||||||
class DebugRadioProbe(BaseModel):
|
class DebugRadioProbe(BaseModel):
|
||||||
performed: bool
|
performed: bool
|
||||||
errors: list[str] = Field(default_factory=list)
|
errors: list[str] = Field(default_factory=list)
|
||||||
multi_acks_enabled: bool | None = None
|
|
||||||
self_info: dict[str, Any] | None = None
|
self_info: dict[str, Any] | None = None
|
||||||
device_info: dict[str, Any] | None = None
|
device_info: dict[str, Any] | None = None
|
||||||
stats_core: dict[str, Any] | None = None
|
stats_core: dict[str, Any] | None = None
|
||||||
@@ -93,16 +101,53 @@ class DebugDatabaseInfo(BaseModel):
|
|||||||
total_outgoing: int
|
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):
|
class DebugSnapshotResponse(BaseModel):
|
||||||
captured_at: str
|
captured_at: str
|
||||||
|
system: DebugSystemInfo
|
||||||
application: DebugApplicationInfo
|
application: DebugApplicationInfo
|
||||||
health: HealthResponse
|
health: DebugHealthSummary
|
||||||
|
settings: DebugAppSettings
|
||||||
runtime: DebugRuntimeInfo
|
runtime: DebugRuntimeInfo
|
||||||
database: DebugDatabaseInfo
|
database: DebugDatabaseInfo
|
||||||
radio_probe: DebugRadioProbe
|
radio_probe: DebugRadioProbe
|
||||||
logs: list[str]
|
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:
|
def _build_application_info() -> DebugApplicationInfo:
|
||||||
build_info = get_app_build_info()
|
build_info = get_app_build_info()
|
||||||
dirty_output = git_output("status", "--porcelain")
|
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
|
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(
|
async def _build_contact_audit(
|
||||||
observed_contacts_payload: dict[str, dict[str, Any]],
|
observed_contacts_payload: dict[str, dict[str, Any]],
|
||||||
) -> DebugContactAudit:
|
) -> DebugContactAudit:
|
||||||
@@ -242,10 +349,7 @@ async def _probe_radio() -> DebugRadioProbe:
|
|||||||
return DebugRadioProbe(
|
return DebugRadioProbe(
|
||||||
performed=True,
|
performed=True,
|
||||||
errors=errors,
|
errors=errors,
|
||||||
multi_acks_enabled=bool(mc.self_info.get("multi_acks", 0))
|
self_info=_sanitize_radio_probe_self_info(mc.self_info),
|
||||||
if mc.self_info is not None
|
|
||||||
else None,
|
|
||||||
self_info=dict(mc.self_info or {}),
|
|
||||||
device_info=device_info,
|
device_info=device_info,
|
||||||
stats_core=stats_core,
|
stats_core=stats_core,
|
||||||
stats_radio=stats_radio,
|
stats_radio=stats_radio,
|
||||||
@@ -264,24 +368,39 @@ async def _probe_radio() -> DebugRadioProbe:
|
|||||||
@router.get("/debug", response_model=DebugSnapshotResponse)
|
@router.get("/debug", response_model=DebugSnapshotResponse)
|
||||||
async def debug_support_snapshot() -> DebugSnapshotResponse:
|
async def debug_support_snapshot() -> DebugSnapshotResponse:
|
||||||
"""Return a support/debug snapshot with recent logs and live radio state."""
|
"""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()
|
message_totals = await StatisticsRepository.get_database_message_totals()
|
||||||
radio_probe = await _probe_radio()
|
radio_probe = await _probe_radio()
|
||||||
channels_with_incoming_messages = (
|
channels_with_incoming_messages = (
|
||||||
await MessageRepository.count_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(
|
return DebugSnapshotResponse(
|
||||||
captured_at=datetime.now(timezone.utc).isoformat(),
|
captured_at=datetime.now(timezone.utc).isoformat(),
|
||||||
|
system=_build_system_info(),
|
||||||
application=_build_application_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(
|
runtime=DebugRuntimeInfo(
|
||||||
connection_info=radio_runtime.connection_info,
|
connection_info=connection_info,
|
||||||
connection_desired=radio_runtime.connection_desired,
|
connection_desired=connection_desired,
|
||||||
setup_in_progress=radio_runtime.is_setup_in_progress,
|
setup_in_progress=setup_in_progress,
|
||||||
setup_complete=radio_runtime.is_setup_complete,
|
setup_complete=setup_complete,
|
||||||
channels_with_incoming_messages=channels_with_incoming_messages,
|
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,
|
path_hash_mode_supported=radio_runtime.path_hash_mode_supported,
|
||||||
channel_slot_reuse_enabled=radio_runtime.channel_slot_reuse_enabled(),
|
channel_slot_reuse_enabled=radio_runtime.channel_slot_reuse_enabled(),
|
||||||
channel_send_cache_capacity=radio_runtime.get_channel_send_cache_capacity(),
|
channel_send_cache_capacity=radio_runtime.get_channel_send_cache_capacity(),
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import time
|
|||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
|
||||||
from app.dependencies import require_connected
|
|
||||||
from app.event_handlers import track_pending_ack
|
from app.event_handlers import track_pending_ack
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Message,
|
Message,
|
||||||
@@ -89,7 +88,7 @@ async def list_messages(
|
|||||||
@router.post("/direct", response_model=Message)
|
@router.post("/direct", response_model=Message)
|
||||||
async def send_direct_message(request: SendDirectMessageRequest) -> Message:
|
async def send_direct_message(request: SendDirectMessageRequest) -> Message:
|
||||||
"""Send a direct message to a contact."""
|
"""Send a direct message to a contact."""
|
||||||
require_connected()
|
radio_manager.require_connected()
|
||||||
|
|
||||||
# First check our database for the contact
|
# First check our database for the contact
|
||||||
from app.repository import ContactRepository
|
from app.repository import ContactRepository
|
||||||
@@ -136,7 +135,7 @@ TEMP_RADIO_SLOT = 0
|
|||||||
@router.post("/channel", response_model=Message)
|
@router.post("/channel", response_model=Message)
|
||||||
async def send_channel_message(request: SendChannelMessageRequest) -> Message:
|
async def send_channel_message(request: SendChannelMessageRequest) -> Message:
|
||||||
"""Send a message to a channel."""
|
"""Send a message to a channel."""
|
||||||
require_connected()
|
radio_manager.require_connected()
|
||||||
|
|
||||||
# Get channel info from our database
|
# Get channel info from our database
|
||||||
from app.repository import ChannelRepository
|
from app.repository import ChannelRepository
|
||||||
@@ -189,7 +188,7 @@ async def resend_channel_message(
|
|||||||
When new_timestamp=True: resend with a fresh timestamp so repeaters treat it as a
|
When new_timestamp=True: resend with a fresh timestamp so repeaters treat it as a
|
||||||
new packet. Creates a new message row in the database. No time window restriction.
|
new packet. Creates a new message row in the database. No time window restriction.
|
||||||
"""
|
"""
|
||||||
require_connected()
|
radio_manager.require_connected()
|
||||||
|
|
||||||
from app.repository import ChannelRepository
|
from app.repository import ChannelRepository
|
||||||
|
|
||||||
|
|||||||
@@ -49,8 +49,7 @@ async def _run_historical_channel_decryption(
|
|||||||
channel_key_bytes: bytes, channel_key_hex: str, display_name: str | None = None
|
channel_key_bytes: bytes, channel_key_hex: str, display_name: str | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Background task to decrypt historical packets with a channel key."""
|
"""Background task to decrypt historical packets with a channel key."""
|
||||||
packets = await RawPacketRepository.get_all_undecrypted()
|
total = await RawPacketRepository.get_undecrypted_count()
|
||||||
total = len(packets)
|
|
||||||
decrypted_count = 0
|
decrypted_count = 0
|
||||||
|
|
||||||
if total == 0:
|
if total == 0:
|
||||||
@@ -59,7 +58,11 @@ async def _run_historical_channel_decryption(
|
|||||||
|
|
||||||
logger.info("Starting historical channel decryption of %d packets", total)
|
logger.info("Starting historical channel decryption of %d packets", total)
|
||||||
|
|
||||||
for packet_id, packet_data, packet_timestamp in packets:
|
async for (
|
||||||
|
packet_id,
|
||||||
|
packet_data,
|
||||||
|
packet_timestamp,
|
||||||
|
) in RawPacketRepository.stream_all_undecrypted():
|
||||||
result = try_decrypt_packet_with_channel_key(packet_data, channel_key_bytes)
|
result = try_decrypt_packet_with_channel_key(packet_data, channel_key_bytes)
|
||||||
|
|
||||||
if result is not None:
|
if result is not None:
|
||||||
|
|||||||
+21
-14
@@ -9,7 +9,6 @@ from fastapi import APIRouter, HTTPException
|
|||||||
from meshcore import EventType
|
from meshcore import EventType
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.dependencies import require_connected
|
|
||||||
from app.models import (
|
from app.models import (
|
||||||
CONTACT_TYPE_REPEATER,
|
CONTACT_TYPE_REPEATER,
|
||||||
ContactUpsert,
|
ContactUpsert,
|
||||||
@@ -24,7 +23,11 @@ from app.models import (
|
|||||||
from app.radio_sync import send_advertisement as do_send_advertisement
|
from app.radio_sync import send_advertisement as do_send_advertisement
|
||||||
from app.radio_sync import sync_radio_time
|
from app.radio_sync import sync_radio_time
|
||||||
from app.repository import ContactRepository
|
from app.repository import ContactRepository
|
||||||
from app.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 (
|
from app.services.radio_commands import (
|
||||||
KeystoreRefreshError,
|
KeystoreRefreshError,
|
||||||
PathHashModeUnsupportedError,
|
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:
|
def _better_signal(first: float | None, second: float | None) -> float | None:
|
||||||
if first is None:
|
if first is None:
|
||||||
return second
|
return second
|
||||||
@@ -214,11 +213,19 @@ async def _persist_new_discovery_contacts(results: list[RadioDiscoveryResult]) -
|
|||||||
public_key=result.public_key,
|
public_key=result.public_key,
|
||||||
log=logger,
|
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)
|
created = await ContactRepository.get_by_key(result.public_key)
|
||||||
if created is not None:
|
if created is not None:
|
||||||
broadcast_event("contact", created.model_dump())
|
broadcast_event("contact", created.model_dump())
|
||||||
for old_key in promoted_keys:
|
for old_key in promoted_keys:
|
||||||
broadcast_event("contact_deleted", {"public_key": old_key})
|
broadcast_event(
|
||||||
|
"contact_resolved",
|
||||||
|
{"previous_public_key": old_key, "contact": created.model_dump()},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _attach_known_names(results: list[RadioDiscoveryResult]) -> None:
|
async def _attach_known_names(results: list[RadioDiscoveryResult]) -> None:
|
||||||
@@ -327,7 +334,7 @@ async def _resolve_trace_hops(
|
|||||||
@router.get("/config", response_model=RadioConfigResponse)
|
@router.get("/config", response_model=RadioConfigResponse)
|
||||||
async def get_radio_config() -> RadioConfigResponse:
|
async def get_radio_config() -> RadioConfigResponse:
|
||||||
"""Get the current radio configuration."""
|
"""Get the current radio configuration."""
|
||||||
mc = require_connected()
|
mc = radio_manager.require_connected()
|
||||||
|
|
||||||
info = mc.self_info
|
info = mc.self_info
|
||||||
if not info:
|
if not info:
|
||||||
@@ -359,7 +366,7 @@ async def get_radio_config() -> RadioConfigResponse:
|
|||||||
@router.patch("/config", response_model=RadioConfigResponse)
|
@router.patch("/config", response_model=RadioConfigResponse)
|
||||||
async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
|
async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
|
||||||
"""Update radio configuration. Only provided fields will be updated."""
|
"""Update radio configuration. Only provided fields will be updated."""
|
||||||
require_connected()
|
radio_manager.require_connected()
|
||||||
|
|
||||||
async with radio_manager.radio_operation("update_radio_config") as mc:
|
async with radio_manager.radio_operation("update_radio_config") as mc:
|
||||||
try:
|
try:
|
||||||
@@ -381,7 +388,7 @@ async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
|
|||||||
@router.put("/private-key")
|
@router.put("/private-key")
|
||||||
async def set_private_key(update: PrivateKeyUpdate) -> dict:
|
async def set_private_key(update: PrivateKeyUpdate) -> dict:
|
||||||
"""Set the radio's private key. This is write-only."""
|
"""Set the radio's private key. This is write-only."""
|
||||||
require_connected()
|
radio_manager.require_connected()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
key_bytes = bytes.fromhex(update.private_key)
|
key_bytes = bytes.fromhex(update.private_key)
|
||||||
@@ -415,7 +422,7 @@ async def send_advertisement(request: RadioAdvertiseRequest | None = None) -> di
|
|||||||
Returns:
|
Returns:
|
||||||
status: "ok" if sent successfully
|
status: "ok" if sent successfully
|
||||||
"""
|
"""
|
||||||
require_connected()
|
radio_manager.require_connected()
|
||||||
mode: RadioAdvertMode = request.mode if request is not None else "flood"
|
mode: RadioAdvertMode = request.mode if request is not None else "flood"
|
||||||
|
|
||||||
logger.info("Sending %s advertisement", mode.replace("_", "-"))
|
logger.info("Sending %s advertisement", mode.replace("_", "-"))
|
||||||
@@ -431,7 +438,7 @@ async def send_advertisement(request: RadioAdvertiseRequest | None = None) -> di
|
|||||||
@router.post("/discover", response_model=RadioDiscoveryResponse)
|
@router.post("/discover", response_model=RadioDiscoveryResponse)
|
||||||
async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryResponse:
|
async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryResponse:
|
||||||
"""Run a short node-discovery sweep from the local radio."""
|
"""Run a short node-discovery sweep from the local radio."""
|
||||||
require_connected()
|
radio_manager.require_connected()
|
||||||
|
|
||||||
target_bits = _DISCOVERY_TARGET_BITS[request.target]
|
target_bits = _DISCOVERY_TARGET_BITS[request.target]
|
||||||
tag = random.randint(1, 0xFFFFFFFF)
|
tag = random.randint(1, 0xFFFFFFFF)
|
||||||
@@ -498,7 +505,7 @@ async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryRespons
|
|||||||
@router.post("/trace", response_model=RadioTraceResponse)
|
@router.post("/trace", response_model=RadioTraceResponse)
|
||||||
async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse:
|
async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse:
|
||||||
"""Send a multi-hop trace loop through known repeaters and back to the local radio."""
|
"""Send a multi-hop trace loop through known repeaters and back to the local radio."""
|
||||||
require_connected()
|
radio_manager.require_connected()
|
||||||
trace_nodes, requested_hashes = await _resolve_trace_hops(request.hops, request.hop_hash_bytes)
|
trace_nodes, requested_hashes = await _resolve_trace_hops(request.hops, request.hop_hash_bytes)
|
||||||
|
|
||||||
tag = random.randint(1, 0xFFFFFFFF)
|
tag = random.randint(1, 0xFFFFFFFF)
|
||||||
|
|||||||
+50
-18
@@ -1,8 +1,8 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
from app.dependencies import require_connected
|
|
||||||
from app.models import (
|
from app.models import (
|
||||||
CONTACT_TYPE_REPEATER,
|
CONTACT_TYPE_REPEATER,
|
||||||
AclEntry,
|
AclEntry,
|
||||||
@@ -21,12 +21,12 @@ from app.models import (
|
|||||||
RepeaterOwnerInfoResponse,
|
RepeaterOwnerInfoResponse,
|
||||||
RepeaterRadioSettingsResponse,
|
RepeaterRadioSettingsResponse,
|
||||||
RepeaterStatusResponse,
|
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.contacts import _ensure_on_radio, _resolve_contact_or_404
|
||||||
from app.routers.server_control import (
|
from app.routers.server_control import (
|
||||||
batch_cli_fetch,
|
batch_cli_fetch,
|
||||||
extract_response_text,
|
|
||||||
prepare_authenticated_contact_connection,
|
prepare_authenticated_contact_connection,
|
||||||
require_server_capable_contact,
|
require_server_capable_contact,
|
||||||
send_contact_cli_command,
|
send_contact_cli_command,
|
||||||
@@ -46,10 +46,6 @@ router = APIRouter(prefix="/contacts", tags=["repeaters"])
|
|||||||
REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS = 5.0
|
REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS = 5.0
|
||||||
|
|
||||||
|
|
||||||
def _extract_response_text(event) -> str:
|
|
||||||
return extract_response_text(event)
|
|
||||||
|
|
||||||
|
|
||||||
async def prepare_repeater_connection(mc, contact: Contact, password: str) -> RepeaterLoginResponse:
|
async def prepare_repeater_connection(mc, contact: Contact, password: str) -> RepeaterLoginResponse:
|
||||||
return await prepare_authenticated_contact_connection(
|
return await prepare_authenticated_contact_connection(
|
||||||
mc,
|
mc,
|
||||||
@@ -78,7 +74,7 @@ def _require_repeater(contact: Contact) -> None:
|
|||||||
@router.post("/{public_key}/repeater/login", response_model=RepeaterLoginResponse)
|
@router.post("/{public_key}/repeater/login", response_model=RepeaterLoginResponse)
|
||||||
async def repeater_login(public_key: str, request: RepeaterLoginRequest) -> RepeaterLoginResponse:
|
async def repeater_login(public_key: str, request: RepeaterLoginRequest) -> RepeaterLoginResponse:
|
||||||
"""Attempt repeater login and report whether auth was confirmed."""
|
"""Attempt repeater login and report whether auth was confirmed."""
|
||||||
require_connected()
|
radio_manager.require_connected()
|
||||||
contact = await _resolve_contact_or_404(public_key)
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
_require_repeater(contact)
|
_require_repeater(contact)
|
||||||
|
|
||||||
@@ -93,7 +89,7 @@ async def repeater_login(public_key: str, request: RepeaterLoginRequest) -> Repe
|
|||||||
@router.post("/{public_key}/repeater/status", response_model=RepeaterStatusResponse)
|
@router.post("/{public_key}/repeater/status", response_model=RepeaterStatusResponse)
|
||||||
async def repeater_status(public_key: str) -> RepeaterStatusResponse:
|
async def repeater_status(public_key: str) -> RepeaterStatusResponse:
|
||||||
"""Fetch status telemetry from a repeater (single attempt, 10s timeout)."""
|
"""Fetch status telemetry from a repeater (single attempt, 10s timeout)."""
|
||||||
require_connected()
|
radio_manager.require_connected()
|
||||||
contact = await _resolve_contact_or_404(public_key)
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
_require_repeater(contact)
|
_require_repeater(contact)
|
||||||
|
|
||||||
@@ -108,7 +104,7 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
|
|||||||
if status is None:
|
if status is None:
|
||||||
raise HTTPException(status_code=504, detail="No status response from repeater")
|
raise HTTPException(status_code=504, detail="No status response from repeater")
|
||||||
|
|
||||||
return RepeaterStatusResponse(
|
response = RepeaterStatusResponse(
|
||||||
battery_volts=status.get("bat", 0) / 1000.0,
|
battery_volts=status.get("bat", 0) / 1000.0,
|
||||||
tx_queue_len=status.get("tx_queue_len", 0),
|
tx_queue_len=status.get("tx_queue_len", 0),
|
||||||
noise_floor_dbm=status.get("noise_floor", 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),
|
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)
|
@router.post("/{public_key}/repeater/lpp-telemetry", response_model=RepeaterLppTelemetryResponse)
|
||||||
async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
|
async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
|
||||||
"""Fetch CayenneLPP sensor telemetry from a repeater (single attempt, 10s timeout)."""
|
"""Fetch CayenneLPP sensor telemetry from a repeater (single attempt, 10s timeout)."""
|
||||||
require_connected()
|
radio_manager.require_connected()
|
||||||
contact = await _resolve_contact_or_404(public_key)
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
_require_repeater(contact)
|
_require_repeater(contact)
|
||||||
|
|
||||||
@@ -161,7 +193,7 @@ async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryRespons
|
|||||||
@router.post("/{public_key}/repeater/neighbors", response_model=RepeaterNeighborsResponse)
|
@router.post("/{public_key}/repeater/neighbors", response_model=RepeaterNeighborsResponse)
|
||||||
async def repeater_neighbors(public_key: str) -> RepeaterNeighborsResponse:
|
async def repeater_neighbors(public_key: str) -> RepeaterNeighborsResponse:
|
||||||
"""Fetch neighbors from a repeater (single attempt, 10s timeout)."""
|
"""Fetch neighbors from a repeater (single attempt, 10s timeout)."""
|
||||||
require_connected()
|
radio_manager.require_connected()
|
||||||
contact = await _resolve_contact_or_404(public_key)
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
_require_repeater(contact)
|
_require_repeater(contact)
|
||||||
|
|
||||||
@@ -195,7 +227,7 @@ async def repeater_neighbors(public_key: str) -> RepeaterNeighborsResponse:
|
|||||||
@router.post("/{public_key}/repeater/acl", response_model=RepeaterAclResponse)
|
@router.post("/{public_key}/repeater/acl", response_model=RepeaterAclResponse)
|
||||||
async def repeater_acl(public_key: str) -> RepeaterAclResponse:
|
async def repeater_acl(public_key: str) -> RepeaterAclResponse:
|
||||||
"""Fetch ACL from a repeater (single attempt, 10s timeout)."""
|
"""Fetch ACL from a repeater (single attempt, 10s timeout)."""
|
||||||
require_connected()
|
radio_manager.require_connected()
|
||||||
contact = await _resolve_contact_or_404(public_key)
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
_require_repeater(contact)
|
_require_repeater(contact)
|
||||||
|
|
||||||
@@ -236,7 +268,7 @@ async def _batch_cli_fetch(
|
|||||||
@router.post("/{public_key}/repeater/node-info", response_model=RepeaterNodeInfoResponse)
|
@router.post("/{public_key}/repeater/node-info", response_model=RepeaterNodeInfoResponse)
|
||||||
async def repeater_node_info(public_key: str) -> RepeaterNodeInfoResponse:
|
async def repeater_node_info(public_key: str) -> RepeaterNodeInfoResponse:
|
||||||
"""Fetch repeater identity/location info via a small CLI batch."""
|
"""Fetch repeater identity/location info via a small CLI batch."""
|
||||||
require_connected()
|
radio_manager.require_connected()
|
||||||
contact = await _resolve_contact_or_404(public_key)
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
_require_repeater(contact)
|
_require_repeater(contact)
|
||||||
|
|
||||||
@@ -256,7 +288,7 @@ async def repeater_node_info(public_key: str) -> RepeaterNodeInfoResponse:
|
|||||||
@router.post("/{public_key}/repeater/radio-settings", response_model=RepeaterRadioSettingsResponse)
|
@router.post("/{public_key}/repeater/radio-settings", response_model=RepeaterRadioSettingsResponse)
|
||||||
async def repeater_radio_settings(public_key: str) -> RepeaterRadioSettingsResponse:
|
async def repeater_radio_settings(public_key: str) -> RepeaterRadioSettingsResponse:
|
||||||
"""Fetch radio settings from a repeater via radio/config CLI commands."""
|
"""Fetch radio settings from a repeater via radio/config CLI commands."""
|
||||||
require_connected()
|
radio_manager.require_connected()
|
||||||
contact = await _resolve_contact_or_404(public_key)
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
_require_repeater(contact)
|
_require_repeater(contact)
|
||||||
|
|
||||||
@@ -280,7 +312,7 @@ async def repeater_radio_settings(public_key: str) -> RepeaterRadioSettingsRespo
|
|||||||
)
|
)
|
||||||
async def repeater_advert_intervals(public_key: str) -> RepeaterAdvertIntervalsResponse:
|
async def repeater_advert_intervals(public_key: str) -> RepeaterAdvertIntervalsResponse:
|
||||||
"""Fetch advertisement intervals from a repeater via CLI commands."""
|
"""Fetch advertisement intervals from a repeater via CLI commands."""
|
||||||
require_connected()
|
radio_manager.require_connected()
|
||||||
contact = await _resolve_contact_or_404(public_key)
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
_require_repeater(contact)
|
_require_repeater(contact)
|
||||||
|
|
||||||
@@ -298,7 +330,7 @@ async def repeater_advert_intervals(public_key: str) -> RepeaterAdvertIntervalsR
|
|||||||
@router.post("/{public_key}/repeater/owner-info", response_model=RepeaterOwnerInfoResponse)
|
@router.post("/{public_key}/repeater/owner-info", response_model=RepeaterOwnerInfoResponse)
|
||||||
async def repeater_owner_info(public_key: str) -> RepeaterOwnerInfoResponse:
|
async def repeater_owner_info(public_key: str) -> RepeaterOwnerInfoResponse:
|
||||||
"""Fetch owner info and guest password from a repeater via CLI commands."""
|
"""Fetch owner info and guest password from a repeater via CLI commands."""
|
||||||
require_connected()
|
radio_manager.require_connected()
|
||||||
contact = await _resolve_contact_or_404(public_key)
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
_require_repeater(contact)
|
_require_repeater(contact)
|
||||||
|
|
||||||
@@ -316,7 +348,7 @@ async def repeater_owner_info(public_key: str) -> RepeaterOwnerInfoResponse:
|
|||||||
@router.post("/{public_key}/command", response_model=CommandResponse)
|
@router.post("/{public_key}/command", response_model=CommandResponse)
|
||||||
async def send_repeater_command(public_key: str, request: CommandRequest) -> CommandResponse:
|
async def send_repeater_command(public_key: str, request: CommandRequest) -> CommandResponse:
|
||||||
"""Send a CLI command to a repeater or room server."""
|
"""Send a CLI command to a repeater or room server."""
|
||||||
require_connected()
|
radio_manager.require_connected()
|
||||||
|
|
||||||
contact = await _resolve_contact_or_404(public_key)
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
require_server_capable_contact(contact)
|
require_server_capable_contact(contact)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
from app.dependencies import require_connected
|
|
||||||
from app.models import (
|
from app.models import (
|
||||||
CONTACT_TYPE_ROOM,
|
CONTACT_TYPE_ROOM,
|
||||||
AclEntry,
|
AclEntry,
|
||||||
@@ -28,7 +27,7 @@ def _require_room(contact) -> None:
|
|||||||
@router.post("/{public_key}/room/login", response_model=RepeaterLoginResponse)
|
@router.post("/{public_key}/room/login", response_model=RepeaterLoginResponse)
|
||||||
async def room_login(public_key: str, request: RepeaterLoginRequest) -> RepeaterLoginResponse:
|
async def room_login(public_key: str, request: RepeaterLoginRequest) -> RepeaterLoginResponse:
|
||||||
"""Attempt room-server login and report whether auth was confirmed."""
|
"""Attempt room-server login and report whether auth was confirmed."""
|
||||||
require_connected()
|
radio_manager.require_connected()
|
||||||
contact = await _resolve_contact_or_404(public_key)
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
_require_room(contact)
|
_require_room(contact)
|
||||||
|
|
||||||
@@ -48,7 +47,7 @@ async def room_login(public_key: str, request: RepeaterLoginRequest) -> Repeater
|
|||||||
@router.post("/{public_key}/room/status", response_model=RepeaterStatusResponse)
|
@router.post("/{public_key}/room/status", response_model=RepeaterStatusResponse)
|
||||||
async def room_status(public_key: str) -> RepeaterStatusResponse:
|
async def room_status(public_key: str) -> RepeaterStatusResponse:
|
||||||
"""Fetch status telemetry from a room server."""
|
"""Fetch status telemetry from a room server."""
|
||||||
require_connected()
|
radio_manager.require_connected()
|
||||||
contact = await _resolve_contact_or_404(public_key)
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
_require_room(contact)
|
_require_room(contact)
|
||||||
|
|
||||||
@@ -85,7 +84,7 @@ async def room_status(public_key: str) -> RepeaterStatusResponse:
|
|||||||
@router.post("/{public_key}/room/lpp-telemetry", response_model=RepeaterLppTelemetryResponse)
|
@router.post("/{public_key}/room/lpp-telemetry", response_model=RepeaterLppTelemetryResponse)
|
||||||
async def room_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
|
async def room_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
|
||||||
"""Fetch CayenneLPP telemetry from a room server."""
|
"""Fetch CayenneLPP telemetry from a room server."""
|
||||||
require_connected()
|
radio_manager.require_connected()
|
||||||
contact = await _resolve_contact_or_404(public_key)
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
_require_room(contact)
|
_require_room(contact)
|
||||||
|
|
||||||
@@ -114,7 +113,7 @@ async def room_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
|
|||||||
@router.post("/{public_key}/room/acl", response_model=RepeaterAclResponse)
|
@router.post("/{public_key}/room/acl", response_model=RepeaterAclResponse)
|
||||||
async def room_acl(public_key: str) -> RepeaterAclResponse:
|
async def room_acl(public_key: str) -> RepeaterAclResponse:
|
||||||
"""Fetch ACL entries from a room server."""
|
"""Fetch ACL entries from a room server."""
|
||||||
require_connected()
|
radio_manager.require_connected()
|
||||||
contact = await _resolve_contact_or_404(public_key)
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
_require_room(contact)
|
_require_room(contact)
|
||||||
|
|
||||||
|
|||||||
@@ -230,20 +230,27 @@ async def batch_cli_fetch(
|
|||||||
operation_name: str,
|
operation_name: str,
|
||||||
commands: list[tuple[str, str]],
|
commands: list[tuple[str, str]],
|
||||||
) -> dict[str, str | None]:
|
) -> dict[str, str | None]:
|
||||||
"""Send a batch of CLI commands to a server-capable contact and collect responses."""
|
"""Send a batch of CLI commands to a server-capable contact and collect responses.
|
||||||
|
|
||||||
|
Each command acquires and releases the radio lock independently so that
|
||||||
|
other operations (sends, syncs) can slip in between commands.
|
||||||
|
"""
|
||||||
results: dict[str, str | None] = {field: None for _, field in commands}
|
results: dict[str, str | None] = {field: None for _, field in commands}
|
||||||
|
|
||||||
async with radio_manager.radio_operation(
|
for index, (cmd, field) in enumerate(commands):
|
||||||
operation_name,
|
if index > 0:
|
||||||
pause_polling=True,
|
# Yield briefly so queued operations can acquire the lock.
|
||||||
suspend_auto_fetch=True,
|
await asyncio.sleep(0.25)
|
||||||
) as mc:
|
|
||||||
await _ensure_on_radio(mc, contact)
|
|
||||||
await asyncio.sleep(1.0)
|
|
||||||
|
|
||||||
for index, (cmd, field) in enumerate(commands):
|
async with radio_manager.radio_operation(
|
||||||
if index > 0:
|
operation_name,
|
||||||
await asyncio.sleep(1.0)
|
pause_polling=True,
|
||||||
|
suspend_auto_fetch=True,
|
||||||
|
) as mc:
|
||||||
|
# Re-ensure contact is loaded each iteration; another operation
|
||||||
|
# may have evicted it while we didn't hold the lock.
|
||||||
|
await _ensure_on_radio(mc, contact)
|
||||||
|
await asyncio.sleep(1.0) # settle after add_contact
|
||||||
|
|
||||||
send_result = await mc.commands.send_cmd(contact.public_key, cmd)
|
send_result = await mc.commands.send_cmd(contact.public_key, cmd)
|
||||||
if send_result.type == EventType.ERROR:
|
if send_result.type == EventType.ERROR:
|
||||||
|
|||||||
+81
-58
@@ -2,16 +2,18 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.models import AppSettings
|
from app.models import CONTACT_TYPE_REPEATER, AppSettings
|
||||||
from app.region_scope import normalize_region_scope
|
from app.region_scope import normalize_region_scope
|
||||||
from app.repository import AppSettingsRepository
|
from app.repository import AppSettingsRepository, ContactRepository
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||||
|
|
||||||
|
MAX_TRACKED_TELEMETRY_REPEATERS = 8
|
||||||
|
|
||||||
|
|
||||||
class AppSettingsUpdate(BaseModel):
|
class AppSettingsUpdate(BaseModel):
|
||||||
max_radio_contacts: int | None = Field(
|
max_radio_contacts: int | None = Field(
|
||||||
@@ -27,10 +29,6 @@ class AppSettingsUpdate(BaseModel):
|
|||||||
default=None,
|
default=None,
|
||||||
description="Whether to attempt historical DM decryption on new contact advertisement",
|
description="Whether to attempt historical DM decryption on new contact advertisement",
|
||||||
)
|
)
|
||||||
sidebar_sort_order: Literal["recent", "alpha"] | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Sidebar sort order: 'recent' or 'alpha'",
|
|
||||||
)
|
|
||||||
advert_interval: int | None = Field(
|
advert_interval: int | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
ge=0,
|
ge=0,
|
||||||
@@ -48,6 +46,17 @@ class AppSettingsUpdate(BaseModel):
|
|||||||
default=None,
|
default=None,
|
||||||
description="Display names whose messages are hidden from the UI",
|
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):
|
class BlockKeyRequest(BaseModel):
|
||||||
@@ -63,24 +72,17 @@ class FavoriteRequest(BaseModel):
|
|||||||
id: str = Field(description="Channel key or contact public key")
|
id: str = Field(description="Channel key or contact public key")
|
||||||
|
|
||||||
|
|
||||||
class MigratePreferencesRequest(BaseModel):
|
class TrackedTelemetryRequest(BaseModel):
|
||||||
favorites: list[FavoriteRequest] = Field(
|
public_key: str = Field(description="Public key of the repeater to toggle tracking")
|
||||||
default_factory=list,
|
|
||||||
description="List of favorites from localStorage",
|
|
||||||
)
|
|
||||||
sort_order: str = Field(
|
|
||||||
default="recent",
|
|
||||||
description="Sort order preference from localStorage",
|
|
||||||
)
|
|
||||||
last_message_times: dict[str, int] = Field(
|
|
||||||
default_factory=dict,
|
|
||||||
description="Map of conversation state keys to timestamps from localStorage",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class MigratePreferencesResponse(BaseModel):
|
class TrackedTelemetryResponse(BaseModel):
|
||||||
migrated: bool = Field(description="Whether migration occurred (false if already migrated)")
|
tracked_telemetry_repeaters: list[str] = Field(
|
||||||
settings: AppSettings = Field(description="Current settings after migration attempt")
|
description="Current list of tracked repeater public keys"
|
||||||
|
)
|
||||||
|
names: dict[str, str] = Field(
|
||||||
|
description="Map of public key to display name for tracked repeaters"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=AppSettings)
|
@router.get("", response_model=AppSettings)
|
||||||
@@ -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)
|
logger.info("Updating auto_decrypt_dm_on_advert to %s", update.auto_decrypt_dm_on_advert)
|
||||||
kwargs["auto_decrypt_dm_on_advert"] = update.auto_decrypt_dm_on_advert
|
kwargs["auto_decrypt_dm_on_advert"] = update.auto_decrypt_dm_on_advert
|
||||||
|
|
||||||
if update.sidebar_sort_order is not None:
|
|
||||||
logger.info("Updating sidebar_sort_order to %s", update.sidebar_sort_order)
|
|
||||||
kwargs["sidebar_sort_order"] = update.sidebar_sort_order
|
|
||||||
|
|
||||||
if update.advert_interval is not None:
|
if update.advert_interval is not None:
|
||||||
# Enforce minimum 1-hour interval; 0 means disabled
|
# Enforce minimum 1-hour interval; 0 means disabled
|
||||||
interval = update.advert_interval
|
interval = update.advert_interval
|
||||||
@@ -122,6 +120,16 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
|
|||||||
if update.blocked_names is not None:
|
if update.blocked_names is not None:
|
||||||
kwargs["blocked_names"] = update.blocked_names
|
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
|
||||||
flood_scope_changed = False
|
flood_scope_changed = False
|
||||||
if update.flood_scope is not None:
|
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)
|
return await AppSettingsRepository.toggle_blocked_name(request.name)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/migrate", response_model=MigratePreferencesResponse)
|
@router.post("/tracked-telemetry/toggle", response_model=TrackedTelemetryResponse)
|
||||||
async def migrate_preferences(request: MigratePreferencesRequest) -> MigratePreferencesResponse:
|
async def toggle_tracked_telemetry(request: TrackedTelemetryRequest) -> TrackedTelemetryResponse:
|
||||||
"""Migrate all preferences from frontend localStorage to database.
|
"""Toggle periodic telemetry collection for a repeater.
|
||||||
|
|
||||||
This is a one-time migration. If preferences have already been migrated,
|
Max 8 repeaters may be tracked. Returns 409 if the limit is reached and
|
||||||
this endpoint will not overwrite them and will return migrated=false.
|
the requested repeater is not already tracked.
|
||||||
|
|
||||||
Call this on frontend startup to ensure preferences are moved to the database.
|
|
||||||
After successful migration, the frontend should clear localStorage preferences.
|
|
||||||
|
|
||||||
Migrates:
|
|
||||||
- favorites (remoteterm-favorites)
|
|
||||||
- sort_order (remoteterm-sortOrder)
|
|
||||||
- last_message_times (remoteterm-lastMessageTime)
|
|
||||||
"""
|
"""
|
||||||
# Convert to dict format for the repository method
|
key = request.public_key.lower()
|
||||||
frontend_favorites = [{"type": f.type, "id": f.id} for f in request.favorites]
|
settings = await AppSettingsRepository.get()
|
||||||
|
current = settings.tracked_telemetry_repeaters
|
||||||
|
|
||||||
settings, did_migrate = await AppSettingsRepository.migrate_preferences_from_frontend(
|
async def _resolve_names(keys: list[str]) -> dict[str, str]:
|
||||||
favorites=frontend_favorites,
|
names: dict[str, str] = {}
|
||||||
sort_order=request.sort_order,
|
for k in keys:
|
||||||
last_message_times=request.last_message_times,
|
contact = await ContactRepository.get_by_key(k)
|
||||||
)
|
names[k] = contact.name if contact and contact.name else k[:12]
|
||||||
|
return names
|
||||||
|
|
||||||
if did_migrate:
|
if key in current:
|
||||||
logger.info(
|
# Remove
|
||||||
"Migrated preferences from frontend: %d favorites, sort_order=%s, %d message times",
|
new_list = [k for k in current if k != key]
|
||||||
len(frontend_favorites),
|
logger.info("Removing repeater %s from tracked telemetry", key[:12])
|
||||||
request.sort_order,
|
await AppSettingsRepository.update(tracked_telemetry_repeaters=new_list)
|
||||||
len(request.last_message_times),
|
return TrackedTelemetryResponse(
|
||||||
|
tracked_telemetry_repeaters=new_list,
|
||||||
|
names=await _resolve_names(new_list),
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
logger.debug("Preferences already migrated, skipping")
|
|
||||||
|
|
||||||
return MigratePreferencesResponse(
|
# Validate it's a repeater
|
||||||
migrated=did_migrate,
|
contact = await ContactRepository.get_by_key(key)
|
||||||
settings=settings,
|
if not contact:
|
||||||
|
raise HTTPException(status_code=404, detail="Contact not found")
|
||||||
|
if contact.type != CONTACT_TYPE_REPEATER:
|
||||||
|
raise HTTPException(status_code=400, detail="Contact is not a repeater")
|
||||||
|
|
||||||
|
if len(current) >= MAX_TRACKED_TELEMETRY_REPEATERS:
|
||||||
|
names = await _resolve_names(current)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail={
|
||||||
|
"message": f"Limit of {MAX_TRACKED_TELEMETRY_REPEATERS} tracked repeaters reached",
|
||||||
|
"tracked_telemetry_repeaters": current,
|
||||||
|
"names": names,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
new_list = current + [key]
|
||||||
|
logger.info("Adding repeater %s to tracked telemetry", key[:12])
|
||||||
|
await AppSettingsRepository.update(tracked_telemetry_repeaters=new_list)
|
||||||
|
return TrackedTelemetryResponse(
|
||||||
|
tracked_telemetry_repeaters=new_list,
|
||||||
|
names=await _resolve_names(new_list),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
"""Shared direct-message ACK application logic."""
|
"""Shared direct-message ACK application logic."""
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from app.services import dm_ack_tracker
|
from app.services import dm_ack_tracker
|
||||||
from app.services.messages import increment_ack_and_broadcast
|
from app.services.messages import BroadcastFn, increment_ack_and_broadcast
|
||||||
|
|
||||||
BroadcastFn = Callable[..., Any]
|
|
||||||
|
|
||||||
|
|
||||||
async def apply_dm_ack_code(ack_code: str, *, broadcast_fn: BroadcastFn) -> bool:
|
async def apply_dm_ack_code(ack_code: str, *, broadcast_fn: BroadcastFn) -> bool:
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from collections.abc import Callable
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from app.models import CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM, Contact, ContactUpsert, Message
|
from app.models import CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM, Contact, ContactUpsert, Message
|
||||||
from app.repository import (
|
from app.repository import (
|
||||||
@@ -14,6 +13,7 @@ from app.repository import (
|
|||||||
)
|
)
|
||||||
from app.services.contact_reconciliation import claim_prefix_messages_for_contact
|
from app.services.contact_reconciliation import claim_prefix_messages_for_contact
|
||||||
from app.services.messages import (
|
from app.services.messages import (
|
||||||
|
BroadcastFn,
|
||||||
broadcast_message,
|
broadcast_message,
|
||||||
build_message_model,
|
build_message_model,
|
||||||
build_message_paths,
|
build_message_paths,
|
||||||
@@ -27,8 +27,6 @@ if TYPE_CHECKING:
|
|||||||
from app.decoder import DecryptedDirectMessage
|
from app.decoder import DecryptedDirectMessage
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
BroadcastFn = Callable[..., Any]
|
|
||||||
_decrypted_dm_store_lock = asyncio.Lock()
|
_decrypted_dm_store_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
@@ -144,6 +142,8 @@ async def _store_direct_message(
|
|||||||
received_at: int,
|
received_at: int,
|
||||||
path: str | None,
|
path: str | None,
|
||||||
path_len: int | None,
|
path_len: int | None,
|
||||||
|
rssi: int | None = None,
|
||||||
|
snr: float | None = None,
|
||||||
outgoing: bool,
|
outgoing: bool,
|
||||||
txt_type: int,
|
txt_type: int,
|
||||||
signature: str | None,
|
signature: str | None,
|
||||||
@@ -170,6 +170,8 @@ async def _store_direct_message(
|
|||||||
path=path,
|
path=path,
|
||||||
received_at=received_at,
|
received_at=received_at,
|
||||||
path_len=path_len,
|
path_len=path_len,
|
||||||
|
rssi=rssi,
|
||||||
|
snr=snr,
|
||||||
broadcast_fn=broadcast_fn,
|
broadcast_fn=broadcast_fn,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
@@ -189,6 +191,8 @@ async def _store_direct_message(
|
|||||||
path=path,
|
path=path,
|
||||||
received_at=received_at,
|
received_at=received_at,
|
||||||
path_len=path_len,
|
path_len=path_len,
|
||||||
|
rssi=rssi,
|
||||||
|
snr=snr,
|
||||||
broadcast_fn=broadcast_fn,
|
broadcast_fn=broadcast_fn,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
@@ -201,6 +205,8 @@ async def _store_direct_message(
|
|||||||
received_at=received_at,
|
received_at=received_at,
|
||||||
path=path,
|
path=path,
|
||||||
path_len=path_len,
|
path_len=path_len,
|
||||||
|
rssi=rssi,
|
||||||
|
snr=snr,
|
||||||
txt_type=txt_type,
|
txt_type=txt_type,
|
||||||
signature=signature,
|
signature=signature,
|
||||||
outgoing=outgoing,
|
outgoing=outgoing,
|
||||||
@@ -218,6 +224,8 @@ async def _store_direct_message(
|
|||||||
path=path,
|
path=path,
|
||||||
received_at=received_at,
|
received_at=received_at,
|
||||||
path_len=path_len,
|
path_len=path_len,
|
||||||
|
rssi=rssi,
|
||||||
|
snr=snr,
|
||||||
broadcast_fn=broadcast_fn,
|
broadcast_fn=broadcast_fn,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
@@ -232,7 +240,7 @@ async def _store_direct_message(
|
|||||||
text=text,
|
text=text,
|
||||||
sender_timestamp=sender_timestamp,
|
sender_timestamp=sender_timestamp,
|
||||||
received_at=received_at,
|
received_at=received_at,
|
||||||
paths=build_message_paths(path, received_at, path_len),
|
paths=build_message_paths(path, received_at, path_len, rssi=rssi, snr=snr),
|
||||||
txt_type=txt_type,
|
txt_type=txt_type,
|
||||||
signature=signature,
|
signature=signature,
|
||||||
sender_key=sender_key,
|
sender_key=sender_key,
|
||||||
@@ -261,6 +269,8 @@ async def ingest_decrypted_direct_message(
|
|||||||
received_at: int | None = None,
|
received_at: int | None = None,
|
||||||
path: str | None = None,
|
path: str | None = None,
|
||||||
path_len: int | None = None,
|
path_len: int | None = None,
|
||||||
|
rssi: int | None = None,
|
||||||
|
snr: float | None = None,
|
||||||
outgoing: bool = False,
|
outgoing: bool = False,
|
||||||
realtime: bool = True,
|
realtime: bool = True,
|
||||||
broadcast_fn: BroadcastFn,
|
broadcast_fn: BroadcastFn,
|
||||||
@@ -311,6 +321,8 @@ async def ingest_decrypted_direct_message(
|
|||||||
received_at=received,
|
received_at=received,
|
||||||
path=path,
|
path=path,
|
||||||
path_len=path_len,
|
path_len=path_len,
|
||||||
|
rssi=rssi,
|
||||||
|
snr=snr,
|
||||||
outgoing=outgoing,
|
outgoing=outgoing,
|
||||||
txt_type=decrypted.txt_type,
|
txt_type=decrypted.txt_type,
|
||||||
signature=signature,
|
signature=signature,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import time as _time
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -9,10 +10,17 @@ from fastapi import HTTPException
|
|||||||
from meshcore import EventType
|
from meshcore import EventType
|
||||||
|
|
||||||
from app.models import ResendChannelMessageResponse
|
from app.models import ResendChannelMessageResponse
|
||||||
|
from app.radio import RadioOperationBusyError
|
||||||
from app.region_scope import normalize_region_scope
|
from app.region_scope import normalize_region_scope
|
||||||
from app.repository import AppSettingsRepository, ContactRepository, MessageRepository
|
from app.repository import (
|
||||||
|
AppSettingsRepository,
|
||||||
|
ChannelRepository,
|
||||||
|
ContactRepository,
|
||||||
|
MessageRepository,
|
||||||
|
)
|
||||||
from app.services import dm_ack_tracker
|
from app.services import dm_ack_tracker
|
||||||
from app.services.messages import (
|
from app.services.messages import (
|
||||||
|
BroadcastFn,
|
||||||
broadcast_message,
|
broadcast_message,
|
||||||
build_stored_outgoing_channel_message,
|
build_stored_outgoing_channel_message,
|
||||||
create_outgoing_channel_message,
|
create_outgoing_channel_message,
|
||||||
@@ -26,13 +34,20 @@ NO_RADIO_RESPONSE_AFTER_SEND_DETAIL = (
|
|||||||
"Send command was issued to the radio, but no response was heard back. "
|
"Send command was issued to the radio, but no response was heard back. "
|
||||||
"The message may or may not have sent successfully."
|
"The message may or may not have sent successfully."
|
||||||
)
|
)
|
||||||
|
|
||||||
BroadcastFn = Callable[..., Any]
|
|
||||||
TrackAckFn = Callable[[str, int, int], bool]
|
TrackAckFn = Callable[[str, int, int], bool]
|
||||||
NowFn = Callable[[], float]
|
NowFn = Callable[[], float]
|
||||||
OutgoingReservationKey = tuple[str, str, str]
|
OutgoingReservationKey = tuple[str, str, str]
|
||||||
RetryTaskScheduler = Callable[[Any], Any]
|
RetryTaskScheduler = Callable[[Any], Any]
|
||||||
|
|
||||||
|
# Channel echo watchdog: delay before checking for echoes
|
||||||
|
ECHO_WATCHDOG_DELAY_SECONDS = 2.0
|
||||||
|
|
||||||
|
# Byte-perfect resend window (must match router's RESEND_WINDOW_SECONDS)
|
||||||
|
RESEND_WINDOW_SECONDS = 30
|
||||||
|
|
||||||
|
# Temp radio slot used by the router for channel sends
|
||||||
|
WATCHDOG_TEMP_RADIO_SLOT = 0
|
||||||
|
|
||||||
_pending_outgoing_timestamp_reservations: dict[OutgoingReservationKey, set[int]] = {}
|
_pending_outgoing_timestamp_reservations: dict[OutgoingReservationKey, set[int]] = {}
|
||||||
_outgoing_timestamp_reservations_lock = asyncio.Lock()
|
_outgoing_timestamp_reservations_lock = asyncio.Lock()
|
||||||
|
|
||||||
@@ -122,7 +137,7 @@ async def send_channel_message_with_effective_scope(
|
|||||||
error_broadcast_fn: BroadcastFn,
|
error_broadcast_fn: BroadcastFn,
|
||||||
app_settings_repository=AppSettingsRepository,
|
app_settings_repository=AppSettingsRepository,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Send a channel message, temporarily overriding flood scope when configured."""
|
"""Send a channel message, temporarily overriding flood scope and/or path hash mode."""
|
||||||
override_scope = normalize_region_scope(channel.flood_scope_override)
|
override_scope = normalize_region_scope(channel.flood_scope_override)
|
||||||
baseline_scope = ""
|
baseline_scope = ""
|
||||||
|
|
||||||
@@ -151,6 +166,36 @@ async def send_channel_message_with_effective_scope(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Path hash mode per-channel override
|
||||||
|
override_phm = channel.path_hash_mode_override
|
||||||
|
baseline_phm = radio_manager.path_hash_mode
|
||||||
|
apply_phm = (
|
||||||
|
override_phm is not None
|
||||||
|
and radio_manager.path_hash_mode_supported
|
||||||
|
and override_phm != baseline_phm
|
||||||
|
)
|
||||||
|
|
||||||
|
if apply_phm:
|
||||||
|
logger.info(
|
||||||
|
"Temporarily applying channel path_hash_mode override for %s: %d",
|
||||||
|
channel.name,
|
||||||
|
override_phm,
|
||||||
|
)
|
||||||
|
phm_result = await mc.commands.set_path_hash_mode(override_phm)
|
||||||
|
if phm_result is not None and phm_result.type == EventType.ERROR:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to apply channel path_hash_mode override for %s: %s",
|
||||||
|
channel.name,
|
||||||
|
phm_result.payload,
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=(
|
||||||
|
f"Failed to apply path hash mode override before {action_label}: "
|
||||||
|
f"{phm_result.payload}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
channel_slot, needs_configure, evicted_channel_key = radio_manager.plan_channel_send_slot(
|
channel_slot, needs_configure, evicted_channel_key = radio_manager.plan_channel_send_slot(
|
||||||
channel_key,
|
channel_key,
|
||||||
@@ -254,6 +299,46 @@ async def send_channel_message_with_effective_scope(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if apply_phm:
|
||||||
|
restored = False
|
||||||
|
for attempt in range(3):
|
||||||
|
try:
|
||||||
|
restore_phm = await mc.commands.set_path_hash_mode(baseline_phm)
|
||||||
|
if restore_phm is not None and restore_phm.type == EventType.ERROR:
|
||||||
|
logger.warning(
|
||||||
|
"Attempt %d/3: failed to restore path_hash_mode after sending to %s: %s",
|
||||||
|
attempt + 1,
|
||||||
|
channel.name,
|
||||||
|
restore_phm.payload,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
radio_manager.path_hash_mode = baseline_phm
|
||||||
|
logger.debug(
|
||||||
|
"Restored baseline path_hash_mode after channel send: %d",
|
||||||
|
baseline_phm,
|
||||||
|
)
|
||||||
|
restored = True
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Attempt %d/3: exception restoring path_hash_mode after sending to %s",
|
||||||
|
attempt + 1,
|
||||||
|
channel.name,
|
||||||
|
)
|
||||||
|
if not restored:
|
||||||
|
logger.error(
|
||||||
|
"All 3 attempts to restore path_hash_mode failed for %s",
|
||||||
|
channel.name,
|
||||||
|
)
|
||||||
|
error_broadcast_fn(
|
||||||
|
"Path hash mode restore failed",
|
||||||
|
(
|
||||||
|
f"Sent to {channel.name}, but restoring path hash mode failed "
|
||||||
|
f"after 3 attempts. The radio is still using a non-default hop "
|
||||||
|
f"width. Set it back manually in Radio settings."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _extract_expected_ack_code(result: Any) -> str | None:
|
def _extract_expected_ack_code(result: Any) -> str | None:
|
||||||
if result is None or result.type == EventType.ERROR:
|
if result is None or result.type == EventType.ERROR:
|
||||||
@@ -550,6 +635,85 @@ async def send_direct_message_to_contact(
|
|||||||
return message
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
async def _channel_echo_watchdog(
|
||||||
|
message_id: int,
|
||||||
|
radio_manager,
|
||||||
|
broadcast_fn: BroadcastFn,
|
||||||
|
error_broadcast_fn: BroadcastFn,
|
||||||
|
) -> None:
|
||||||
|
"""One-shot watchdog: if no echo heard after delay, attempt one byte-perfect resend.
|
||||||
|
|
||||||
|
Spawned as a fire-and-forget task after a channel send when auto_resend_channel is enabled.
|
||||||
|
Uses non-blocking radio lock so it never stalls user actions.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(ECHO_WATCHDOG_DELAY_SECONDS)
|
||||||
|
|
||||||
|
msg = await MessageRepository.get_by_id(message_id)
|
||||||
|
if not msg:
|
||||||
|
return
|
||||||
|
if msg.acked > 0:
|
||||||
|
logger.debug(
|
||||||
|
"Echo watchdog: message %d already has %d echo(s), skipping", message_id, msg.acked
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if msg.sender_timestamp is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
elapsed = int(_time.time()) - msg.sender_timestamp
|
||||||
|
if elapsed > RESEND_WINDOW_SECONDS:
|
||||||
|
logger.debug(
|
||||||
|
"Echo watchdog: message %d outside resend window (%ds)", message_id, elapsed
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
channel = await ChannelRepository.get_by_key(msg.conversation_key)
|
||||||
|
if not channel:
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Echo watchdog: no echo for message %d after %.0fs, attempting byte-perfect resend",
|
||||||
|
message_id,
|
||||||
|
ECHO_WATCHDOG_DELAY_SECONDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
key_bytes = bytes.fromhex(msg.conversation_key)
|
||||||
|
except ValueError:
|
||||||
|
return
|
||||||
|
|
||||||
|
timestamp_bytes = msg.sender_timestamp.to_bytes(4, "little")
|
||||||
|
|
||||||
|
# Strip sender name prefix to get the raw text for the radio
|
||||||
|
async with radio_manager.radio_operation("echo_watchdog_resend", blocking=False) as mc:
|
||||||
|
radio_name = mc.self_info.get("name", "") if mc.self_info else ""
|
||||||
|
text_to_send = msg.text
|
||||||
|
if radio_name and text_to_send.startswith(f"{radio_name}: "):
|
||||||
|
text_to_send = text_to_send[len(f"{radio_name}: ") :]
|
||||||
|
|
||||||
|
result = await send_channel_message_with_effective_scope(
|
||||||
|
mc=mc,
|
||||||
|
channel=channel,
|
||||||
|
channel_key=msg.conversation_key,
|
||||||
|
key_bytes=key_bytes,
|
||||||
|
text=text_to_send,
|
||||||
|
timestamp_bytes=timestamp_bytes,
|
||||||
|
action_label="echo watchdog resend",
|
||||||
|
radio_manager=radio_manager,
|
||||||
|
temp_radio_slot=WATCHDOG_TEMP_RADIO_SLOT,
|
||||||
|
error_broadcast_fn=error_broadcast_fn,
|
||||||
|
)
|
||||||
|
if result is not None and result.type != EventType.ERROR:
|
||||||
|
logger.info("Echo watchdog: resent message %d successfully", message_id)
|
||||||
|
else:
|
||||||
|
logger.debug("Echo watchdog: resend got no/error result for message %d", message_id)
|
||||||
|
|
||||||
|
except RadioOperationBusyError:
|
||||||
|
logger.debug("Echo watchdog: radio busy, skipping resend for message %d", message_id)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Echo watchdog: resend failed for message %d", message_id, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
async def send_channel_message_to_channel(
|
async def send_channel_message_to_channel(
|
||||||
*,
|
*,
|
||||||
channel,
|
channel,
|
||||||
@@ -658,6 +822,22 @@ async def send_channel_message_to_channel(
|
|||||||
message_repository=message_repository,
|
message_repository=message_repository,
|
||||||
)
|
)
|
||||||
broadcast_message(message=outgoing_message, broadcast_fn=broadcast_fn)
|
broadcast_message(message=outgoing_message, broadcast_fn=broadcast_fn)
|
||||||
|
|
||||||
|
# Spawn echo watchdog if auto-resend is enabled
|
||||||
|
try:
|
||||||
|
settings = await AppSettingsRepository.get()
|
||||||
|
if settings.auto_resend_channel:
|
||||||
|
asyncio.create_task(
|
||||||
|
_channel_echo_watchdog(
|
||||||
|
message_id=outgoing_message.id,
|
||||||
|
radio_manager=radio_manager,
|
||||||
|
broadcast_fn=broadcast_fn,
|
||||||
|
error_broadcast_fn=error_broadcast_fn,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # Never let watchdog setup failure break the send
|
||||||
|
|
||||||
return outgoing_message
|
return outgoing_message
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -37,10 +37,16 @@ def build_message_paths(
|
|||||||
path: str | None,
|
path: str | None,
|
||||||
received_at: int,
|
received_at: int,
|
||||||
path_len: int | None = None,
|
path_len: int | None = None,
|
||||||
|
rssi: int | None = None,
|
||||||
|
snr: float | None = None,
|
||||||
) -> list[MessagePath] | None:
|
) -> list[MessagePath] | None:
|
||||||
"""Build the single-path list used by message payloads."""
|
"""Build the single-path list used by message payloads."""
|
||||||
return (
|
return (
|
||||||
[MessagePath(path=path or "", received_at=received_at, path_len=path_len)]
|
[
|
||||||
|
MessagePath(
|
||||||
|
path=path or "", received_at=received_at, path_len=path_len, rssi=rssi, snr=snr
|
||||||
|
)
|
||||||
|
]
|
||||||
if path is not None
|
if path is not None
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
@@ -166,6 +172,8 @@ async def reconcile_duplicate_message(
|
|||||||
path: str | None,
|
path: str | None,
|
||||||
received_at: int,
|
received_at: int,
|
||||||
path_len: int | None,
|
path_len: int | None,
|
||||||
|
rssi: int | None = None,
|
||||||
|
snr: float | None = None,
|
||||||
broadcast_fn: BroadcastFn,
|
broadcast_fn: BroadcastFn,
|
||||||
) -> None:
|
) -> None:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -177,7 +185,9 @@ async def reconcile_duplicate_message(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if path is not None:
|
if path is not None:
|
||||||
paths = await MessageRepository.add_path(existing_msg.id, path, received_at, path_len)
|
paths = await MessageRepository.add_path(
|
||||||
|
existing_msg.id, path, received_at, path_len, rssi=rssi, snr=snr
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
paths = existing_msg.paths or []
|
paths = existing_msg.paths or []
|
||||||
|
|
||||||
@@ -214,6 +224,8 @@ async def handle_duplicate_message(
|
|||||||
path: str | None,
|
path: str | None,
|
||||||
received_at: int,
|
received_at: int,
|
||||||
path_len: int | None = None,
|
path_len: int | None = None,
|
||||||
|
rssi: int | None = None,
|
||||||
|
snr: float | None = None,
|
||||||
broadcast_fn: BroadcastFn,
|
broadcast_fn: BroadcastFn,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Handle a duplicate message by updating paths/acks on the existing record."""
|
"""Handle a duplicate message by updating paths/acks on the existing record."""
|
||||||
@@ -239,6 +251,8 @@ async def handle_duplicate_message(
|
|||||||
path=path,
|
path=path,
|
||||||
received_at=received_at,
|
received_at=received_at,
|
||||||
path_len=path_len,
|
path_len=path_len,
|
||||||
|
rssi=rssi,
|
||||||
|
snr=snr,
|
||||||
broadcast_fn=broadcast_fn,
|
broadcast_fn=broadcast_fn,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -253,6 +267,8 @@ async def create_message_from_decrypted(
|
|||||||
received_at: int | None = None,
|
received_at: int | None = None,
|
||||||
path: str | None = None,
|
path: str | None = None,
|
||||||
path_len: int | None = None,
|
path_len: int | None = None,
|
||||||
|
rssi: int | None = None,
|
||||||
|
snr: float | None = None,
|
||||||
channel_name: str | None = None,
|
channel_name: str | None = None,
|
||||||
realtime: bool = True,
|
realtime: bool = True,
|
||||||
broadcast_fn: BroadcastFn,
|
broadcast_fn: BroadcastFn,
|
||||||
@@ -276,6 +292,8 @@ async def create_message_from_decrypted(
|
|||||||
received_at=received,
|
received_at=received,
|
||||||
path=path,
|
path=path,
|
||||||
path_len=path_len,
|
path_len=path_len,
|
||||||
|
rssi=rssi,
|
||||||
|
snr=snr,
|
||||||
sender_name=sender,
|
sender_name=sender,
|
||||||
sender_key=resolved_sender_key,
|
sender_key=resolved_sender_key,
|
||||||
)
|
)
|
||||||
@@ -291,6 +309,8 @@ async def create_message_from_decrypted(
|
|||||||
path=path,
|
path=path,
|
||||||
received_at=received,
|
received_at=received,
|
||||||
path_len=path_len,
|
path_len=path_len,
|
||||||
|
rssi=rssi,
|
||||||
|
snr=snr,
|
||||||
broadcast_fn=broadcast_fn,
|
broadcast_fn=broadcast_fn,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
@@ -312,7 +332,7 @@ async def create_message_from_decrypted(
|
|||||||
text=text,
|
text=text,
|
||||||
sender_timestamp=timestamp,
|
sender_timestamp=timestamp,
|
||||||
received_at=received,
|
received_at=received,
|
||||||
paths=build_message_paths(path, received, path_len),
|
paths=build_message_paths(path, received, path_len, rssi=rssi, snr=snr),
|
||||||
sender_name=sender,
|
sender_name=sender,
|
||||||
sender_key=resolved_sender_key,
|
sender_key=resolved_sender_key,
|
||||||
channel_name=channel_name,
|
channel_name=channel_name,
|
||||||
@@ -334,6 +354,8 @@ async def create_dm_message_from_decrypted(
|
|||||||
received_at: int | None = None,
|
received_at: int | None = None,
|
||||||
path: str | None = None,
|
path: str | None = None,
|
||||||
path_len: int | None = None,
|
path_len: int | None = None,
|
||||||
|
rssi: int | None = None,
|
||||||
|
snr: float | None = None,
|
||||||
outgoing: bool = False,
|
outgoing: bool = False,
|
||||||
realtime: bool = True,
|
realtime: bool = True,
|
||||||
broadcast_fn: BroadcastFn,
|
broadcast_fn: BroadcastFn,
|
||||||
@@ -348,6 +370,8 @@ async def create_dm_message_from_decrypted(
|
|||||||
received_at=received_at,
|
received_at=received_at,
|
||||||
path=path,
|
path=path,
|
||||||
path_len=path_len,
|
path_len=path_len,
|
||||||
|
rssi=rssi,
|
||||||
|
snr=snr,
|
||||||
outgoing=outgoing,
|
outgoing=outgoing,
|
||||||
realtime=realtime,
|
realtime=realtime,
|
||||||
broadcast_fn=broadcast_fn,
|
broadcast_fn=broadcast_fn,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ async def run_post_connect_setup(radio_manager) -> None:
|
|||||||
start_message_polling,
|
start_message_polling,
|
||||||
start_periodic_advert,
|
start_periodic_advert,
|
||||||
start_periodic_sync,
|
start_periodic_sync,
|
||||||
|
start_telemetry_collect,
|
||||||
sync_and_offload_all,
|
sync_and_offload_all,
|
||||||
sync_radio_time,
|
sync_radio_time,
|
||||||
)
|
)
|
||||||
@@ -204,35 +205,44 @@ async def run_post_connect_setup(radio_manager) -> None:
|
|||||||
finally:
|
finally:
|
||||||
reader.handle_rx = _original_handle_rx
|
reader.handle_rx = _original_handle_rx
|
||||||
|
|
||||||
# Sync contacts/channels from radio to DB and clear radio
|
from app.config import settings as app_settings_config
|
||||||
logger.info("Syncing and offloading radio data...")
|
|
||||||
result = await sync_and_offload_all(mc)
|
|
||||||
logger.info("Sync complete: %s", result)
|
|
||||||
|
|
||||||
# Send advertisement to announce our presence (if enabled and not throttled)
|
if app_settings_config.skip_post_connect_sync:
|
||||||
if await send_advertisement(mc):
|
logger.info(
|
||||||
logger.info("Advertisement sent")
|
"Skipping sync/offload/advert/drain (MESHCORE_SKIP_POST_CONNECT_SYNC)"
|
||||||
|
)
|
||||||
else:
|
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.
|
# Send advertisement to announce our presence (if enabled and not throttled)
|
||||||
# This must happen BEFORE starting auto-fetch, otherwise both
|
if await send_advertisement(mc):
|
||||||
# compete on get_msg() with interleaved radio I/O.
|
logger.info("Advertisement sent")
|
||||||
drained = await drain_pending_messages(mc)
|
else:
|
||||||
if drained > 0:
|
logger.debug("Advertisement skipped (disabled or throttled)")
|
||||||
logger.info("Drained %d pending message(s)", drained)
|
|
||||||
radio_manager.clear_pending_message_channel_slots()
|
# 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()
|
await mc.start_auto_message_fetching()
|
||||||
logger.info("Auto message fetching started")
|
logger.info("Auto message fetching started")
|
||||||
finally:
|
finally:
|
||||||
radio_manager._release_operation_lock("post_connect_setup")
|
radio_manager._release_operation_lock("post_connect_setup")
|
||||||
|
|
||||||
# Start background tasks AFTER releasing the operation lock.
|
if not app_settings_config.skip_post_connect_sync:
|
||||||
# These tasks acquire their own locks when they need radio access.
|
# Start background tasks AFTER releasing the operation lock.
|
||||||
start_periodic_sync()
|
# These tasks acquire their own locks when they need radio access.
|
||||||
start_periodic_advert()
|
start_periodic_sync()
|
||||||
start_message_polling()
|
start_periodic_advert()
|
||||||
|
start_message_polling()
|
||||||
|
start_telemetry_collect()
|
||||||
|
|
||||||
radio_manager._setup_complete = True
|
radio_manager._setup_complete = True
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 109 KiB |
+13
-3
@@ -350,15 +350,13 @@ LocalStorage migration helpers for favorites; canonical favorites are server-sid
|
|||||||
- `max_radio_contacts`
|
- `max_radio_contacts`
|
||||||
- `favorites`
|
- `favorites`
|
||||||
- `auto_decrypt_dm_on_advert`
|
- `auto_decrypt_dm_on_advert`
|
||||||
- `sidebar_sort_order`
|
|
||||||
- `last_message_times`
|
- `last_message_times`
|
||||||
- `preferences_migrated`
|
- `preferences_migrated`
|
||||||
- `advert_interval`
|
- `advert_interval`
|
||||||
- `last_advert_time`
|
- `last_advert_time`
|
||||||
- `flood_scope`
|
- `flood_scope`
|
||||||
- `blocked_keys`, `blocked_names`
|
- `blocked_keys`, `blocked_names`, `discovery_blocked_types`
|
||||||
|
|
||||||
The backend still carries `sidebar_sort_order` for compatibility and old preference migration, but the current sidebar UI stores sort order per section (`Channels`, `Contacts`, `Repeaters`) in frontend localStorage rather than treating it as one global server-backed setting.
|
|
||||||
|
|
||||||
Note: MQTT, bot, and community MQTT settings were migrated to the `fanout_configs` table (managed via `/api/fanout`). They are no longer part of `AppSettings`.
|
Note: MQTT, bot, and community MQTT settings were migrated to the `fanout_configs` table (managed via `/api/fanout`). They are no longer part of `AppSettings`.
|
||||||
|
|
||||||
@@ -436,6 +434,18 @@ The `SearchView` component (`components/SearchView.tsx`) provides full-text sear
|
|||||||
UI styling is mostly utility-class driven (Tailwind-style classes in JSX) plus shared globals in `index.css` and `styles.css`.
|
UI styling is mostly utility-class driven (Tailwind-style classes in JSX) plus shared globals in `index.css` and `styles.css`.
|
||||||
Do not rely on old class-only layout assumptions.
|
Do not rely on old class-only layout assumptions.
|
||||||
|
|
||||||
|
### Canonical style reference
|
||||||
|
|
||||||
|
`SettingsLocalSection.tsx` contains a **ThemePreview** component with a collapsible "Canonical style reference" section. This is the authoritative catalog of text sizes, button variants, badge patterns, and interactive elements used throughout the app. **When adding or modifying UI, match the patterns shown there rather than inventing new ones.**
|
||||||
|
|
||||||
|
Key conventions documented in the reference:
|
||||||
|
|
||||||
|
- **Text sizes** use `rem`-based Tailwind values so they scale with the user's font-size slider. Do not use hard-locked `px` values (e.g., `text-[10px]`). The canonical sizes are `text-[0.625rem]` (10px), `text-[0.6875rem]` (11px), `text-[0.8125rem]` (13px), plus standard Tailwind `text-xs`/`text-sm`/`text-base`/`text-lg`/`text-xl`.
|
||||||
|
- **Section labels** use `text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium`.
|
||||||
|
- **Buttons** use the shadcn `<Button>` component. Semantic color overrides (danger, warning, success) use `variant="outline"` with `className="border-{color}/50 text-{color} hover:bg-{color}/10"`.
|
||||||
|
- **Badges/tags** use `text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded` with `bg-muted` (neutral) or `bg-primary/10` (active).
|
||||||
|
- **Clickable text** (copy-to-clipboard, navigational links) uses `role="button" tabIndex={0}` with `cursor-pointer hover:text-primary transition-colors`.
|
||||||
|
|
||||||
## Security Posture (intentional)
|
## Security Posture (intentional)
|
||||||
|
|
||||||
- No authentication UI.
|
- No authentication UI.
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "remoteterm-meshcore-frontend",
|
"name": "remoteterm-meshcore-frontend",
|
||||||
"version": "3.6.2",
|
"version": "3.6.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "remoteterm-meshcore-frontend",
|
"name": "remoteterm-meshcore-frontend",
|
||||||
"version": "3.6.2",
|
"version": "3.6.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-python": "^6.2.1",
|
"@codemirror/lang-python": "^6.2.1",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "remoteterm-meshcore-frontend",
|
"name": "remoteterm-meshcore-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "3.6.7",
|
"version": "3.8.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"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 { api } from './api';
|
||||||
import { takePrefetchOrFetch } from './prefetch';
|
import { takePrefetchOrFetch } from './prefetch';
|
||||||
import { useWebSocket } from './useWebSocket';
|
import { useWebSocket } from './useWebSocket';
|
||||||
@@ -23,7 +23,7 @@ import type { MessageInputHandle } from './components/MessageInput';
|
|||||||
import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
|
import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
|
||||||
import { messageContainsMention } from './utils/messageParser';
|
import { messageContainsMention } from './utils/messageParser';
|
||||||
import { getStateKey } from './utils/conversationState';
|
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';
|
import { CONTACT_TYPE_ROOM } from './types';
|
||||||
|
|
||||||
interface ChannelUnreadMarker {
|
interface ChannelUnreadMarker {
|
||||||
@@ -85,6 +85,8 @@ export function App() {
|
|||||||
const [channelUnreadMarker, setChannelUnreadMarker] = useState<ChannelUnreadMarker | null>(null);
|
const [channelUnreadMarker, setChannelUnreadMarker] = useState<ChannelUnreadMarker | null>(null);
|
||||||
const [newMessagePrefillRequest, setNewMessagePrefillRequest] =
|
const [newMessagePrefillRequest, setNewMessagePrefillRequest] =
|
||||||
useState<NewMessagePrefillRequest | null>(null);
|
useState<NewMessagePrefillRequest | null>(null);
|
||||||
|
const [showBulkAddChannelTab, setShowBulkAddChannelTab] = useState(false);
|
||||||
|
const [bulkAddResult, setBulkAddResult] = useState<BulkCreateHashtagChannelsResult | null>(null);
|
||||||
const [visibilityVersion, setVisibilityVersion] = useState(0);
|
const [visibilityVersion, setVisibilityVersion] = useState(0);
|
||||||
const lastUnreadBackfillAttemptRef = useRef<string | null>(null);
|
const lastUnreadBackfillAttemptRef = useRef<string | null>(null);
|
||||||
const {
|
const {
|
||||||
@@ -154,6 +156,7 @@ export function App() {
|
|||||||
handleToggleFavorite,
|
handleToggleFavorite,
|
||||||
handleToggleBlockedKey,
|
handleToggleBlockedKey,
|
||||||
handleToggleBlockedName,
|
handleToggleBlockedName,
|
||||||
|
handleToggleTrackedTelemetry,
|
||||||
} = useAppSettings();
|
} = useAppSettings();
|
||||||
|
|
||||||
// Keep user's name in ref for mention detection in WebSocket callback
|
// Keep user's name in ref for mention detection in WebSocket callback
|
||||||
@@ -190,6 +193,7 @@ export function App() {
|
|||||||
handleCreateContact,
|
handleCreateContact,
|
||||||
handleCreateChannel,
|
handleCreateChannel,
|
||||||
handleCreateHashtagChannel,
|
handleCreateHashtagChannel,
|
||||||
|
handleBulkCreateHashtagChannels,
|
||||||
handleDeleteChannel,
|
handleDeleteChannel,
|
||||||
handleDeleteContact,
|
handleDeleteContact,
|
||||||
} = useContactsAndChannels({
|
} = useContactsAndChannels({
|
||||||
@@ -394,6 +398,7 @@ export function App() {
|
|||||||
handleSendMessage,
|
handleSendMessage,
|
||||||
handleResendChannelMessage,
|
handleResendChannelMessage,
|
||||||
handleSetChannelFloodScopeOverride,
|
handleSetChannelFloodScopeOverride,
|
||||||
|
handleSetChannelPathHashModeOverride,
|
||||||
handleSenderClick,
|
handleSenderClick,
|
||||||
handleTrace,
|
handleTrace,
|
||||||
handlePathDiscovery,
|
handlePathDiscovery,
|
||||||
@@ -421,16 +426,25 @@ export function App() {
|
|||||||
[fetchUndecryptedCount, setChannels]
|
[fetchUndecryptedCount, setChannels]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenNewMessage = useCallback(() => {
|
const handleOpenNewMessage = useCallback(
|
||||||
setNewMessagePrefillRequest(null);
|
(event?: MouseEvent<HTMLButtonElement>) => {
|
||||||
openNewMessageModal();
|
setNewMessagePrefillRequest(null);
|
||||||
}, [openNewMessageModal]);
|
setShowBulkAddChannelTab(event?.altKey === true);
|
||||||
|
openNewMessageModal();
|
||||||
|
},
|
||||||
|
[openNewMessageModal]
|
||||||
|
);
|
||||||
|
|
||||||
const handleCloseNewMessage = useCallback(() => {
|
const handleCloseNewMessage = useCallback(() => {
|
||||||
setNewMessagePrefillRequest(null);
|
setNewMessagePrefillRequest(null);
|
||||||
|
setShowBulkAddChannelTab(false);
|
||||||
closeNewMessageModal();
|
closeNewMessageModal();
|
||||||
}, [closeNewMessageModal]);
|
}, [closeNewMessageModal]);
|
||||||
|
|
||||||
|
const handleCloseBulkAddResults = useCallback(() => {
|
||||||
|
setBulkAddResult(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleChannelReferenceClick = useCallback(
|
const handleChannelReferenceClick = useCallback(
|
||||||
(channelName: string) => {
|
(channelName: string) => {
|
||||||
const existingChannel = channels.find((channel) => channel.name === channelName);
|
const existingChannel = channels.find((channel) => channel.name === channelName);
|
||||||
@@ -444,11 +458,20 @@ export function App() {
|
|||||||
hashtagName: channelName.slice(1),
|
hashtagName: channelName.slice(1),
|
||||||
nonce: (previous?.nonce ?? 0) + 1,
|
nonce: (previous?.nonce ?? 0) + 1,
|
||||||
}));
|
}));
|
||||||
|
setShowBulkAddChannelTab(false);
|
||||||
openNewMessageModal();
|
openNewMessageModal();
|
||||||
},
|
},
|
||||||
[channels, handleNavigateToChannel, openNewMessageModal]
|
[channels, handleNavigateToChannel, openNewMessageModal]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleBulkAddChannels = useCallback(
|
||||||
|
async (channelNames: string[], tryHistorical: boolean) => {
|
||||||
|
const result = await handleBulkCreateHashtagChannels(channelNames, tryHistorical);
|
||||||
|
setBulkAddResult(result);
|
||||||
|
},
|
||||||
|
[handleBulkCreateHashtagChannels]
|
||||||
|
);
|
||||||
|
|
||||||
const statusProps = {
|
const statusProps = {
|
||||||
health,
|
health,
|
||||||
config,
|
config,
|
||||||
@@ -469,8 +492,12 @@ export function App() {
|
|||||||
void markAllRead();
|
void markAllRead();
|
||||||
},
|
},
|
||||||
favorites,
|
favorites,
|
||||||
legacySortOrder: appSettings?.sidebar_sort_order,
|
|
||||||
isConversationNotificationsEnabled,
|
isConversationNotificationsEnabled,
|
||||||
|
blockedKeys: appSettings?.blocked_keys ?? [],
|
||||||
|
blockedNames: appSettings?.blocked_names ?? [],
|
||||||
|
};
|
||||||
|
const bulkAddChannelResultModalProps = {
|
||||||
|
result: bulkAddResult,
|
||||||
};
|
};
|
||||||
const conversationPaneProps = {
|
const conversationPaneProps = {
|
||||||
activeConversation,
|
activeConversation,
|
||||||
@@ -482,6 +509,7 @@ export function App() {
|
|||||||
health,
|
health,
|
||||||
favorites,
|
favorites,
|
||||||
messages: sortedMessages,
|
messages: sortedMessages,
|
||||||
|
preSorted: activeContactIsRoom,
|
||||||
messagesLoading,
|
messagesLoading,
|
||||||
loadingOlder,
|
loadingOlder,
|
||||||
hasOlderMessages,
|
hasOlderMessages,
|
||||||
@@ -501,6 +529,7 @@ export function App() {
|
|||||||
onDeleteContact: handleDeleteContact,
|
onDeleteContact: handleDeleteContact,
|
||||||
onDeleteChannel: handleDeleteChannel,
|
onDeleteChannel: handleDeleteChannel,
|
||||||
onSetChannelFloodScopeOverride: handleSetChannelFloodScopeOverride,
|
onSetChannelFloodScopeOverride: handleSetChannelFloodScopeOverride,
|
||||||
|
onSetChannelPathHashModeOverride: handleSetChannelPathHashModeOverride,
|
||||||
onOpenContactInfo: handleOpenContactInfo,
|
onOpenContactInfo: handleOpenContactInfo,
|
||||||
onOpenChannelInfo: handleOpenChannelInfo,
|
onOpenChannelInfo: handleOpenChannelInfo,
|
||||||
onSenderClick: handleSenderClick,
|
onSenderClick: handleSenderClick,
|
||||||
@@ -527,6 +556,8 @@ export function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
trackedTelemetryRepeaters: appSettings?.tracked_telemetry_repeaters ?? [],
|
||||||
|
onToggleTrackedTelemetry: handleToggleTrackedTelemetry,
|
||||||
};
|
};
|
||||||
const searchProps = {
|
const searchProps = {
|
||||||
contacts,
|
contacts,
|
||||||
@@ -555,6 +586,13 @@ export function App() {
|
|||||||
blockedNames: appSettings?.blocked_names,
|
blockedNames: appSettings?.blocked_names,
|
||||||
onToggleBlockedKey: handleBlockKey,
|
onToggleBlockedKey: handleBlockKey,
|
||||||
onToggleBlockedName: handleBlockName,
|
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 = {
|
const crackerProps = {
|
||||||
packets: rawPackets,
|
packets: rawPackets,
|
||||||
@@ -563,10 +601,12 @@ export function App() {
|
|||||||
};
|
};
|
||||||
const newMessageModalProps = {
|
const newMessageModalProps = {
|
||||||
undecryptedCount,
|
undecryptedCount,
|
||||||
|
showBulkAddChannelTab,
|
||||||
prefillRequest: newMessagePrefillRequest,
|
prefillRequest: newMessagePrefillRequest,
|
||||||
onCreateContact: handleCreateContact,
|
onCreateContact: handleCreateContact,
|
||||||
onCreateChannel: handleCreateChannel,
|
onCreateChannel: handleCreateChannel,
|
||||||
onCreateHashtagChannel: handleCreateHashtagChannel,
|
onCreateHashtagChannel: handleCreateHashtagChannel,
|
||||||
|
onBulkAddHashtagChannels: handleBulkAddChannels,
|
||||||
};
|
};
|
||||||
const contactInfoPaneProps = {
|
const contactInfoPaneProps = {
|
||||||
contactKey: infoPaneContactKey,
|
contactKey: infoPaneContactKey,
|
||||||
@@ -630,6 +670,7 @@ export function App() {
|
|||||||
<AppShell
|
<AppShell
|
||||||
localLabel={localLabel}
|
localLabel={localLabel}
|
||||||
showNewMessage={showNewMessage}
|
showNewMessage={showNewMessage}
|
||||||
|
showBulkAddResults={bulkAddResult !== null}
|
||||||
showSettings={showSettings}
|
showSettings={showSettings}
|
||||||
settingsSection={settingsSection}
|
settingsSection={settingsSection}
|
||||||
sidebarOpen={sidebarOpen}
|
sidebarOpen={sidebarOpen}
|
||||||
@@ -640,6 +681,7 @@ export function App() {
|
|||||||
onToggleSettingsView={handleToggleSettingsView}
|
onToggleSettingsView={handleToggleSettingsView}
|
||||||
onCloseSettingsView={handleCloseSettingsView}
|
onCloseSettingsView={handleCloseSettingsView}
|
||||||
onCloseNewMessage={handleCloseNewMessage}
|
onCloseNewMessage={handleCloseNewMessage}
|
||||||
|
onCloseBulkAddResults={handleCloseBulkAddResults}
|
||||||
onLocalLabelChange={setLocalLabel}
|
onLocalLabelChange={setLocalLabel}
|
||||||
statusProps={statusProps}
|
statusProps={statusProps}
|
||||||
sidebarProps={sidebarProps}
|
sidebarProps={sidebarProps}
|
||||||
@@ -648,6 +690,7 @@ export function App() {
|
|||||||
settingsProps={settingsProps}
|
settingsProps={settingsProps}
|
||||||
crackerProps={crackerProps}
|
crackerProps={crackerProps}
|
||||||
newMessageModalProps={newMessageModalProps}
|
newMessageModalProps={newMessageModalProps}
|
||||||
|
bulkAddChannelResultModalProps={bulkAddChannelResultModalProps}
|
||||||
contactInfoPaneProps={contactInfoPaneProps}
|
contactInfoPaneProps={contactInfoPaneProps}
|
||||||
channelInfoPaneProps={channelInfoPaneProps}
|
channelInfoPaneProps={channelInfoPaneProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+29
-9
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
AppSettings,
|
AppSettings,
|
||||||
AppSettingsUpdate,
|
AppSettingsUpdate,
|
||||||
|
BulkCreateHashtagChannelsResult,
|
||||||
Channel,
|
Channel,
|
||||||
ChannelDetail,
|
ChannelDetail,
|
||||||
CommandResponse,
|
CommandResponse,
|
||||||
@@ -13,8 +14,6 @@ import type {
|
|||||||
MaintenanceResult,
|
MaintenanceResult,
|
||||||
Message,
|
Message,
|
||||||
MessagesAroundResponse,
|
MessagesAroundResponse,
|
||||||
MigratePreferencesRequest,
|
|
||||||
MigratePreferencesResponse,
|
|
||||||
RawPacket,
|
RawPacket,
|
||||||
RadioAdvertMode,
|
RadioAdvertMode,
|
||||||
RadioConfig,
|
RadioConfig,
|
||||||
@@ -34,6 +33,8 @@ import type {
|
|||||||
RepeaterOwnerInfoResponse,
|
RepeaterOwnerInfoResponse,
|
||||||
RepeaterRadioSettingsResponse,
|
RepeaterRadioSettingsResponse,
|
||||||
RepeaterStatusResponse,
|
RepeaterStatusResponse,
|
||||||
|
TelemetryHistoryEntry,
|
||||||
|
TrackedTelemetryResponse,
|
||||||
StatisticsResponse,
|
StatisticsResponse,
|
||||||
TraceResponse,
|
TraceResponse,
|
||||||
UnreadCounts,
|
UnreadCounts,
|
||||||
@@ -149,6 +150,12 @@ export const api = {
|
|||||||
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
|
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
|
||||||
method: 'DELETE',
|
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) =>
|
createContact: (publicKey: string, name?: string, tryHistorical?: boolean) =>
|
||||||
fetchJson<Contact>('/contacts', {
|
fetchJson<Contact>('/contacts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -184,6 +191,11 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ name, key }),
|
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) =>
|
deleteChannel: (key: string) =>
|
||||||
fetchJson<{ status: string }>(`/channels/${key}`, { method: 'DELETE' }),
|
fetchJson<{ status: string }>(`/channels/${key}`, { method: 'DELETE' }),
|
||||||
getChannelDetail: (key: string) => fetchJson<ChannelDetail>(`/channels/${key}/detail`),
|
getChannelDetail: (key: string) => fetchJson<ChannelDetail>(`/channels/${key}/detail`),
|
||||||
@@ -197,6 +209,12 @@ export const api = {
|
|||||||
body: JSON.stringify({ flood_scope_override: floodScopeOverride }),
|
body: JSON.stringify({ flood_scope_override: floodScopeOverride }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
setChannelPathHashModeOverride: (key: string, pathHashModeOverride: number | null) =>
|
||||||
|
fetchJson<Channel>(`/channels/${key}/path-hash-mode-override`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ path_hash_mode_override: pathHashModeOverride }),
|
||||||
|
}),
|
||||||
|
|
||||||
// Messages
|
// Messages
|
||||||
getMessages: (
|
getMessages: (
|
||||||
params?: {
|
params?: {
|
||||||
@@ -308,6 +326,13 @@ export const api = {
|
|||||||
body: JSON.stringify({ name }),
|
body: JSON.stringify({ name }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Tracked telemetry
|
||||||
|
toggleTrackedTelemetry: (publicKey: string) =>
|
||||||
|
fetchJson<TrackedTelemetryResponse>('/settings/tracked-telemetry/toggle', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ public_key: publicKey }),
|
||||||
|
}),
|
||||||
|
|
||||||
// Favorites
|
// Favorites
|
||||||
toggleFavorite: (type: Favorite['type'], id: string) =>
|
toggleFavorite: (type: Favorite['type'], id: string) =>
|
||||||
fetchJson<AppSettings>('/settings/favorites/toggle', {
|
fetchJson<AppSettings>('/settings/favorites/toggle', {
|
||||||
@@ -315,13 +340,6 @@ export const api = {
|
|||||||
body: JSON.stringify({ type, id }),
|
body: JSON.stringify({ type, id }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Preferences migration (one-time, from localStorage to database)
|
|
||||||
migratePreferences: (request: MigratePreferencesRequest) =>
|
|
||||||
fetchJson<MigratePreferencesResponse>('/settings/migrate', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(request),
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Fanout
|
// Fanout
|
||||||
getFanoutConfigs: () => fetchJson<FanoutConfig[]>('/fanout'),
|
getFanoutConfigs: () => fetchJson<FanoutConfig[]>('/fanout'),
|
||||||
createFanoutConfig: (config: {
|
createFanoutConfig: (config: {
|
||||||
@@ -402,6 +420,8 @@ export const api = {
|
|||||||
fetchJson<RepeaterLppTelemetryResponse>(`/contacts/${publicKey}/repeater/lpp-telemetry`, {
|
fetchJson<RepeaterLppTelemetryResponse>(`/contacts/${publicKey}/repeater/lpp-telemetry`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
}),
|
}),
|
||||||
|
repeaterTelemetryHistory: (publicKey: string) =>
|
||||||
|
fetchJson<TelemetryHistoryEntry[]>(`/contacts/${publicKey}/repeater/telemetry-history`),
|
||||||
roomLogin: (publicKey: string, password: string) =>
|
roomLogin: (publicKey: string, password: string) =>
|
||||||
fetchJson<RepeaterLoginResponse>(`/contacts/${publicKey}/room/login`, {
|
fetchJson<RepeaterLoginResponse>(`/contacts/${publicKey}/room/login`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { StatusBar } from './StatusBar';
|
|||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
import { ConversationPane } from './ConversationPane';
|
import { ConversationPane } from './ConversationPane';
|
||||||
import { NewMessageModal } from './NewMessageModal';
|
import { NewMessageModal } from './NewMessageModal';
|
||||||
|
import { BulkAddChannelResultModal } from './BulkAddChannelResultModal';
|
||||||
import { ContactInfoPane } from './ContactInfoPane';
|
import { ContactInfoPane } from './ContactInfoPane';
|
||||||
import { ChannelInfoPane } from './ChannelInfoPane';
|
import { ChannelInfoPane } from './ChannelInfoPane';
|
||||||
import { SecurityWarningModal } from './SecurityWarningModal';
|
import { SecurityWarningModal } from './SecurityWarningModal';
|
||||||
@@ -33,12 +34,17 @@ const SearchView = lazy(() => import('./SearchView').then((m) => ({ default: m.S
|
|||||||
type SidebarProps = ComponentProps<typeof Sidebar>;
|
type SidebarProps = ComponentProps<typeof Sidebar>;
|
||||||
type ConversationPaneProps = ComponentProps<typeof ConversationPane>;
|
type ConversationPaneProps = ComponentProps<typeof ConversationPane>;
|
||||||
type NewMessageModalProps = Omit<ComponentProps<typeof NewMessageModal>, 'open' | 'onClose'>;
|
type NewMessageModalProps = Omit<ComponentProps<typeof NewMessageModal>, 'open' | 'onClose'>;
|
||||||
|
type BulkAddChannelResultModalProps = Omit<
|
||||||
|
ComponentProps<typeof BulkAddChannelResultModal>,
|
||||||
|
'open' | 'onClose'
|
||||||
|
>;
|
||||||
type ContactInfoPaneProps = ComponentProps<typeof ContactInfoPane>;
|
type ContactInfoPaneProps = ComponentProps<typeof ContactInfoPane>;
|
||||||
type ChannelInfoPaneProps = ComponentProps<typeof ChannelInfoPane>;
|
type ChannelInfoPaneProps = ComponentProps<typeof ChannelInfoPane>;
|
||||||
|
|
||||||
interface AppShellProps {
|
interface AppShellProps {
|
||||||
localLabel: LocalLabel;
|
localLabel: LocalLabel;
|
||||||
showNewMessage: boolean;
|
showNewMessage: boolean;
|
||||||
|
showBulkAddResults: boolean;
|
||||||
showSettings: boolean;
|
showSettings: boolean;
|
||||||
settingsSection: SettingsSection;
|
settingsSection: SettingsSection;
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
@@ -50,6 +56,7 @@ interface AppShellProps {
|
|||||||
onToggleSettingsView: () => void;
|
onToggleSettingsView: () => void;
|
||||||
onCloseSettingsView: () => void;
|
onCloseSettingsView: () => void;
|
||||||
onCloseNewMessage: () => void;
|
onCloseNewMessage: () => void;
|
||||||
|
onCloseBulkAddResults: () => void;
|
||||||
onLocalLabelChange: (label: LocalLabel) => void;
|
onLocalLabelChange: (label: LocalLabel) => void;
|
||||||
statusProps: Pick<ComponentProps<typeof StatusBar>, 'health' | 'config'>;
|
statusProps: Pick<ComponentProps<typeof StatusBar>, 'health' | 'config'>;
|
||||||
sidebarProps: SidebarProps;
|
sidebarProps: SidebarProps;
|
||||||
@@ -61,6 +68,7 @@ interface AppShellProps {
|
|||||||
>;
|
>;
|
||||||
crackerProps: Omit<CrackerPanelProps, 'visible' | 'onRunningChange'>;
|
crackerProps: Omit<CrackerPanelProps, 'visible' | 'onRunningChange'>;
|
||||||
newMessageModalProps: NewMessageModalProps;
|
newMessageModalProps: NewMessageModalProps;
|
||||||
|
bulkAddChannelResultModalProps: BulkAddChannelResultModalProps;
|
||||||
contactInfoPaneProps: ContactInfoPaneProps;
|
contactInfoPaneProps: ContactInfoPaneProps;
|
||||||
channelInfoPaneProps: ChannelInfoPaneProps;
|
channelInfoPaneProps: ChannelInfoPaneProps;
|
||||||
}
|
}
|
||||||
@@ -68,6 +76,7 @@ interface AppShellProps {
|
|||||||
export function AppShell({
|
export function AppShell({
|
||||||
localLabel,
|
localLabel,
|
||||||
showNewMessage,
|
showNewMessage,
|
||||||
|
showBulkAddResults,
|
||||||
showSettings,
|
showSettings,
|
||||||
settingsSection,
|
settingsSection,
|
||||||
sidebarOpen,
|
sidebarOpen,
|
||||||
@@ -79,6 +88,7 @@ export function AppShell({
|
|||||||
onToggleSettingsView,
|
onToggleSettingsView,
|
||||||
onCloseSettingsView,
|
onCloseSettingsView,
|
||||||
onCloseNewMessage,
|
onCloseNewMessage,
|
||||||
|
onCloseBulkAddResults,
|
||||||
onLocalLabelChange,
|
onLocalLabelChange,
|
||||||
statusProps,
|
statusProps,
|
||||||
sidebarProps,
|
sidebarProps,
|
||||||
@@ -87,6 +97,7 @@ export function AppShell({
|
|||||||
settingsProps,
|
settingsProps,
|
||||||
crackerProps,
|
crackerProps,
|
||||||
newMessageModalProps,
|
newMessageModalProps,
|
||||||
|
bulkAddChannelResultModalProps,
|
||||||
contactInfoPaneProps,
|
contactInfoPaneProps,
|
||||||
channelInfoPaneProps,
|
channelInfoPaneProps,
|
||||||
}: AppShellProps) {
|
}: AppShellProps) {
|
||||||
@@ -124,7 +135,7 @@ export function AppShell({
|
|||||||
aria-label="Settings"
|
aria-label="Settings"
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-center px-3 py-2.5 border-b border-border">
|
<div className="flex justify-between items-center px-3 py-2.5 border-b border-border">
|
||||||
<h2 className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">
|
<h2 className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
|
||||||
Settings
|
Settings
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
@@ -147,7 +158,7 @@ export function AppShell({
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full px-3 py-2 text-left text-[13px] border-l-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset disabled:cursor-not-allowed disabled:opacity-50',
|
'w-full px-3 py-2 text-left text-[0.8125rem] border-l-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
!disabled && 'hover:bg-accent',
|
!disabled && 'hover:bg-accent',
|
||||||
settingsSection === section && !disabled && 'bg-accent border-l-primary'
|
settingsSection === section && !disabled && 'bg-accent border-l-primary'
|
||||||
)}
|
)}
|
||||||
@@ -306,6 +317,11 @@ export function AppShell({
|
|||||||
open={showNewMessage}
|
open={showNewMessage}
|
||||||
onClose={onCloseNewMessage}
|
onClose={onCloseNewMessage}
|
||||||
/>
|
/>
|
||||||
|
<BulkAddChannelResultModal
|
||||||
|
{...bulkAddChannelResultModalProps}
|
||||||
|
open={showBulkAddResults}
|
||||||
|
onClose={onCloseBulkAddResults}
|
||||||
|
/>
|
||||||
|
|
||||||
<SecurityWarningModal health={statusProps.health} />
|
<SecurityWarningModal health={statusProps.health} />
|
||||||
<ContactInfoPane {...contactInfoPaneProps} />
|
<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 { Star } from 'lucide-react';
|
||||||
import { api } from '../api';
|
import { api } from '../api';
|
||||||
import { formatTime } from '../utils/messageParser';
|
import { formatTime } from '../utils/messageParser';
|
||||||
@@ -6,7 +7,7 @@ import { isFavorite } from '../utils/favorites';
|
|||||||
import { handleKeyboardActivate } from '../utils/a11y';
|
import { handleKeyboardActivate } from '../utils/a11y';
|
||||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
|
||||||
import { toast } from './ui/sonner';
|
import { toast } from './ui/sonner';
|
||||||
import type { Channel, ChannelDetail, Favorite } from '../types';
|
import type { Channel, ChannelDetail, Favorite, PathHashWidthStats } from '../types';
|
||||||
|
|
||||||
interface ChannelInfoPaneProps {
|
interface ChannelInfoPaneProps {
|
||||||
channelKey: string | null;
|
channelKey: string | null;
|
||||||
@@ -106,11 +107,11 @@ export function ChannelInfoPane({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2 mt-1.5">
|
<div className="flex items-center gap-2 mt-1.5">
|
||||||
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
|
<span className="text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
|
||||||
{channel.is_hashtag ? 'Hashtag' : 'Private Key'}
|
{channel.is_hashtag ? 'Hashtag' : 'Private Key'}
|
||||||
</span>
|
</span>
|
||||||
{channel.on_radio && (
|
{channel.on_radio && (
|
||||||
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">
|
<span className="text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">
|
||||||
On Radio
|
On Radio
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -179,6 +180,14 @@ export function ChannelInfoPane({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Hop Byte Widths (24h) */}
|
||||||
|
{detail && detail.path_hash_width_24h.total_packets > 0 && (
|
||||||
|
<div className="px-5 py-3 border-b border-border">
|
||||||
|
<SectionLabel>Hop Byte Widths (24h)</SectionLabel>
|
||||||
|
<HopWidthChart stats={detail.path_hash_width_24h} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Top Senders 24h */}
|
{/* Top Senders 24h */}
|
||||||
{detail && detail.top_senders_24h.length > 0 && (
|
{detail && detail.top_senders_24h.length > 0 && (
|
||||||
<div className="px-5 py-3">
|
<div className="px-5 py-3">
|
||||||
@@ -212,7 +221,7 @@ export function ChannelInfoPane({
|
|||||||
|
|
||||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<h3 className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium mb-1.5">
|
<h3 className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium mb-1.5">
|
||||||
{children}
|
{children}
|
||||||
</h3>
|
</h3>
|
||||||
);
|
);
|
||||||
@@ -226,3 +235,82 @@ function InfoItem({ label, value }: { label: string; value: string }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HOP_WIDTH_SEGMENTS = [
|
||||||
|
{ key: 'single_byte', label: '1-byte', color: '#22c55e' },
|
||||||
|
{ key: 'double_byte', label: '2-byte', color: '#0ea5e9' },
|
||||||
|
{ key: 'triple_byte', label: '3-byte', color: '#8b5cf6' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const TOOLTIP_STYLE = {
|
||||||
|
contentStyle: {
|
||||||
|
backgroundColor: 'hsl(var(--popover))',
|
||||||
|
border: '1px solid hsl(var(--border))',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: 'hsl(var(--popover-foreground))',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function HopWidthChart({ stats }: { stats: PathHashWidthStats }) {
|
||||||
|
const data = useMemo(
|
||||||
|
() =>
|
||||||
|
HOP_WIDTH_SEGMENTS.map(({ key, label, color }) => ({
|
||||||
|
name: label,
|
||||||
|
value: stats[key] as number,
|
||||||
|
color,
|
||||||
|
})).filter((d) => d.value > 0),
|
||||||
|
[stats]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-shrink-0" style={{ width: 90, height: 90 }}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={data}
|
||||||
|
dataKey="value"
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={22}
|
||||||
|
outerRadius={40}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="hsl(var(--background))"
|
||||||
|
>
|
||||||
|
{data.map((d) => (
|
||||||
|
<Cell key={d.name} fill={d.color} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<RechartsTooltip
|
||||||
|
{...TOOLTIP_STYLE}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
formatter={(value: any, name: any) => {
|
||||||
|
const v = typeof value === 'number' ? value : Number(value);
|
||||||
|
return [`${v.toLocaleString()} pkt${v !== 1 ? 's' : ''}`, name];
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
{data.map((d) => (
|
||||||
|
<div key={d.name} className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: d.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-[0.6875rem] text-muted-foreground flex-1">{d.name}</span>
|
||||||
|
<span className="text-[0.6875rem] font-medium tabular-nums">
|
||||||
|
{d.value.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<p className="text-[0.625rem] text-muted-foreground pt-0.5">
|
||||||
|
{stats.total_packets.toLocaleString()} total
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from './ui/dialog';
|
||||||
|
import { Label } from './ui/label';
|
||||||
|
|
||||||
|
const PATH_HASH_MODE_LABELS: Record<number, string> = {
|
||||||
|
0: '1-byte',
|
||||||
|
1: '2-byte',
|
||||||
|
2: '3-byte',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ChannelPathHashModeOverrideModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
channelName: string;
|
||||||
|
currentOverride: number | null;
|
||||||
|
radioDefault: number;
|
||||||
|
onSetOverride: (value: number | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChannelPathHashModeOverrideModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
channelName,
|
||||||
|
currentOverride,
|
||||||
|
radioDefault,
|
||||||
|
onSetOverride,
|
||||||
|
}: ChannelPathHashModeOverrideModalProps) {
|
||||||
|
const [selected, setSelected] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setSelected(currentOverride);
|
||||||
|
}
|
||||||
|
}, [currentOverride, open]);
|
||||||
|
|
||||||
|
const radioDefaultLabel = PATH_HASH_MODE_LABELS[radioDefault] ?? `${radioDefault}`;
|
||||||
|
|
||||||
|
const options: { value: number | null; label: string; description: string }[] = [
|
||||||
|
{
|
||||||
|
value: null,
|
||||||
|
label: `Radio default (${radioDefaultLabel})`,
|
||||||
|
description: 'Use the radio-wide path hash mode setting',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 0,
|
||||||
|
label: '1-byte hop identifiers',
|
||||||
|
description: 'Shortest paths, least repeater disambiguation',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 1,
|
||||||
|
label: '2-byte hop identifiers',
|
||||||
|
description: 'Better repeater disambiguation',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 2,
|
||||||
|
label: '3-byte hop identifiers',
|
||||||
|
description: 'Best repeater disambiguation, longest paths',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||||
|
<DialogContent className="sm:max-w-[520px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Path Hop Width Override</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Override the path hash mode for this channel. Wider hop identifiers improve repeater
|
||||||
|
disambiguation but extend send time and will prevent users on old (<1.14) firmware
|
||||||
|
from receiving the message.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-md border border-border bg-muted/20 p-3 text-sm">
|
||||||
|
<div className="font-medium">{channelName}</div>
|
||||||
|
<div className="mt-1 text-muted-foreground">
|
||||||
|
Current override:{' '}
|
||||||
|
{currentOverride != null
|
||||||
|
? (PATH_HASH_MODE_LABELS[currentOverride] ?? `mode ${currentOverride}`)
|
||||||
|
: `none (using radio default: ${radioDefaultLabel})`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Hop width for this channel</Label>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{options.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={String(opt.value)}
|
||||||
|
type="button"
|
||||||
|
className={`w-full rounded-md border px-3 py-2 text-left text-sm transition-colors ${
|
||||||
|
selected === opt.value
|
||||||
|
? 'border-primary bg-primary/10 text-foreground'
|
||||||
|
: 'border-border hover:bg-accent'
|
||||||
|
}`}
|
||||||
|
onClick={() => setSelected(opt.value)}
|
||||||
|
>
|
||||||
|
<div className="font-medium">{opt.label}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{opt.description}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:block sm:space-x-0">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => {
|
||||||
|
onSetOverride(selected);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selected == null
|
||||||
|
? `Use radio default for ${channelName}`
|
||||||
|
: `Use ${PATH_HASH_MODE_LABELS[selected]} hops for ${channelName}`}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Bell, Globe2, Info, Route, Star, Trash2 } from 'lucide-react';
|
import { Bell, ChevronsLeftRight, Globe2, Info, Route, Star, Trash2 } from 'lucide-react';
|
||||||
import { toast } from './ui/sonner';
|
import { toast } from './ui/sonner';
|
||||||
import { DirectTraceIcon } from './DirectTraceIcon';
|
import { DirectTraceIcon } from './DirectTraceIcon';
|
||||||
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
|
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
|
||||||
import { ChannelFloodScopeOverrideModal } from './ChannelFloodScopeOverrideModal';
|
import { ChannelFloodScopeOverrideModal } from './ChannelFloodScopeOverrideModal';
|
||||||
|
import { ChannelPathHashModeOverrideModal } from './ChannelPathHashModeOverrideModal';
|
||||||
import { isFavorite } from '../utils/favorites';
|
import { isFavorite } from '../utils/favorites';
|
||||||
import { handleKeyboardActivate } from '../utils/a11y';
|
import { handleKeyboardActivate } from '../utils/a11y';
|
||||||
import { isPublicChannelKey } from '../utils/publicChannel';
|
import { isPublicChannelKey } from '../utils/publicChannel';
|
||||||
@@ -36,6 +37,7 @@ interface ChatHeaderProps {
|
|||||||
onToggleNotifications: () => void;
|
onToggleNotifications: () => void;
|
||||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
||||||
onSetChannelFloodScopeOverride?: (key: string, floodScopeOverride: string) => void;
|
onSetChannelFloodScopeOverride?: (key: string, floodScopeOverride: string) => void;
|
||||||
|
onSetChannelPathHashModeOverride?: (key: string, pathHashModeOverride: number | null) => void;
|
||||||
onDeleteChannel: (key: string) => void;
|
onDeleteChannel: (key: string) => void;
|
||||||
onDeleteContact: (publicKey: string) => void;
|
onDeleteContact: (publicKey: string) => void;
|
||||||
onOpenContactInfo?: (publicKey: string) => void;
|
onOpenContactInfo?: (publicKey: string) => void;
|
||||||
@@ -56,6 +58,7 @@ export function ChatHeader({
|
|||||||
onToggleNotifications,
|
onToggleNotifications,
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
onSetChannelFloodScopeOverride,
|
onSetChannelFloodScopeOverride,
|
||||||
|
onSetChannelPathHashModeOverride,
|
||||||
onDeleteChannel,
|
onDeleteChannel,
|
||||||
onDeleteContact,
|
onDeleteContact,
|
||||||
onOpenContactInfo,
|
onOpenContactInfo,
|
||||||
@@ -64,11 +67,13 @@ export function ChatHeader({
|
|||||||
const [showKey, setShowKey] = useState(false);
|
const [showKey, setShowKey] = useState(false);
|
||||||
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
|
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
|
||||||
const [channelOverrideOpen, setChannelOverrideOpen] = useState(false);
|
const [channelOverrideOpen, setChannelOverrideOpen] = useState(false);
|
||||||
|
const [pathHashModeOverrideOpen, setPathHashModeOverrideOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setShowKey(false);
|
setShowKey(false);
|
||||||
setPathDiscoveryOpen(false);
|
setPathDiscoveryOpen(false);
|
||||||
setChannelOverrideOpen(false);
|
setChannelOverrideOpen(false);
|
||||||
|
setPathHashModeOverrideOpen(false);
|
||||||
}, [conversation.id]);
|
}, [conversation.id]);
|
||||||
|
|
||||||
const activeChannel =
|
const activeChannel =
|
||||||
@@ -81,6 +86,12 @@ export function ChatHeader({
|
|||||||
? stripRegionScopePrefix(activeFloodScopeOverride)
|
? stripRegionScopePrefix(activeFloodScopeOverride)
|
||||||
: null;
|
: null;
|
||||||
const activeFloodScopeDisplay = activeFloodScopeOverride ? activeFloodScopeOverride : null;
|
const activeFloodScopeDisplay = activeFloodScopeOverride ? activeFloodScopeOverride : null;
|
||||||
|
const activePathHashModeOverride =
|
||||||
|
conversation.type === 'channel' ? (activeChannel?.path_hash_mode_override ?? null) : null;
|
||||||
|
const showPathHashModeOverride =
|
||||||
|
conversation.type === 'channel' &&
|
||||||
|
onSetChannelPathHashModeOverride &&
|
||||||
|
config?.path_hash_mode_supported;
|
||||||
const isPrivateChannel = conversation.type === 'channel' && !activeChannel?.is_hashtag;
|
const isPrivateChannel = conversation.type === 'channel' && !activeChannel?.is_hashtag;
|
||||||
const activeContact =
|
const activeContact =
|
||||||
conversation.type === 'contact'
|
conversation.type === 'contact'
|
||||||
@@ -108,6 +119,11 @@ export function ChatHeader({
|
|||||||
setChannelOverrideOpen(true);
|
setChannelOverrideOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEditPathHashModeOverride = () => {
|
||||||
|
if (conversation.type !== 'channel' || !onSetChannelPathHashModeOverride) return;
|
||||||
|
setPathHashModeOverrideOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const handleOpenConversationInfo = () => {
|
const handleOpenConversationInfo = () => {
|
||||||
if (conversation.type === 'contact' && onOpenContactInfo) {
|
if (conversation.type === 'contact' && onOpenContactInfo) {
|
||||||
onOpenContactInfo(conversation.id);
|
onOpenContactInfo(conversation.id);
|
||||||
@@ -182,7 +198,7 @@ export function ChatHeader({
|
|||||||
</h2>
|
</h2>
|
||||||
{isPrivateChannel && !showKey ? (
|
{isPrivateChannel && !showKey ? (
|
||||||
<button
|
<button
|
||||||
className="min-w-0 flex-shrink text-[11px] font-mono text-muted-foreground transition-colors hover:text-primary"
|
className="min-w-0 flex-shrink text-[0.6875rem] font-mono text-muted-foreground transition-colors hover:text-primary"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setShowKey(true);
|
setShowKey(true);
|
||||||
@@ -193,7 +209,7 @@ export function ChatHeader({
|
|||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<span
|
<span
|
||||||
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary"
|
className="min-w-0 flex-1 truncate font-mono text-[0.6875rem] text-muted-foreground transition-colors hover:text-primary"
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={handleKeyboardActivate}
|
onKeyDown={handleKeyboardActivate}
|
||||||
@@ -228,7 +244,7 @@ export function ChatHeader({
|
|||||||
className="h-3.5 w-3.5 flex-shrink-0 text-[hsl(var(--region-override))]"
|
className="h-3.5 w-3.5 flex-shrink-0 text-[hsl(var(--region-override))]"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<span className="min-w-0 truncate text-[11px] font-medium text-[hsl(var(--region-override))]">
|
<span className="min-w-0 truncate text-[0.6875rem] font-medium text-[hsl(var(--region-override))]">
|
||||||
{activeFloodScopeDisplay}
|
{activeFloodScopeDisplay}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -237,7 +253,7 @@ export function ChatHeader({
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{conversation.type === 'contact' && activeContact && (
|
{conversation.type === 'contact' && activeContact && (
|
||||||
<div className="col-span-2 row-start-2 min-w-0 text-[11px] text-muted-foreground min-[1100px]:col-span-1 min-[1100px]:col-start-2 min-[1100px]:row-start-1">
|
<div className="col-span-2 row-start-2 min-w-0 text-[0.6875rem] text-muted-foreground min-[1100px]:col-span-1 min-[1100px]:col-start-2 min-[1100px]:row-start-1">
|
||||||
<ContactStatusInfo
|
<ContactStatusInfo
|
||||||
contact={activeContact}
|
contact={activeContact}
|
||||||
ourLat={config?.lat ?? null}
|
ourLat={config?.lat ?? null}
|
||||||
@@ -299,7 +315,7 @@ export function ChatHeader({
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
{notificationsEnabled && (
|
{notificationsEnabled && (
|
||||||
<span className="hidden md:inline text-[11px] font-medium text-status-connected">
|
<span className="hidden md:inline text-[0.6875rem] font-medium text-status-connected">
|
||||||
Notifications On
|
Notifications On
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -317,12 +333,25 @@ export function ChatHeader({
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
{activeFloodScopeDisplay && (
|
{activeFloodScopeDisplay && (
|
||||||
<span className="hidden text-[11px] font-medium text-[hsl(var(--region-override))] sm:inline">
|
<span className="hidden text-[0.6875rem] font-medium text-[hsl(var(--region-override))] sm:inline">
|
||||||
{activeFloodScopeDisplay}
|
{activeFloodScopeDisplay}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{showPathHashModeOverride && (
|
||||||
|
<button
|
||||||
|
className="flex shrink-0 items-center gap-1 rounded px-1 py-1 text-lg leading-none transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
onClick={handleEditPathHashModeOverride}
|
||||||
|
title="Set path hop width override"
|
||||||
|
aria-label="Set path hop width override"
|
||||||
|
>
|
||||||
|
<ChevronsLeftRight
|
||||||
|
className={`h-4 w-4 ${activePathHashModeOverride != null ? 'text-status-connected' : 'text-muted-foreground'}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{(conversation.type === 'channel' || conversation.type === 'contact') && (
|
{(conversation.type === 'channel' || conversation.type === 'contact') && (
|
||||||
<button
|
<button
|
||||||
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
@@ -379,6 +408,16 @@ export function ChatHeader({
|
|||||||
onSetOverride={(value) => onSetChannelFloodScopeOverride(conversation.id, value)}
|
onSetOverride={(value) => onSetChannelFloodScopeOverride(conversation.id, value)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{showPathHashModeOverride && (
|
||||||
|
<ChannelPathHashModeOverrideModal
|
||||||
|
open={pathHashModeOverrideOpen}
|
||||||
|
onClose={() => setPathHashModeOverrideOpen(false)}
|
||||||
|
channelName={conversation.name}
|
||||||
|
currentOverride={activePathHashModeOverride}
|
||||||
|
radioDefault={config?.path_hash_mode ?? 0}
|
||||||
|
onSetOverride={(value) => onSetChannelPathHashModeOverride(conversation.id, value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { Ban, Search, Star } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
LineChart,
|
LineChart,
|
||||||
@@ -35,6 +35,7 @@ import { ContactAvatar } from './ContactAvatar';
|
|||||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
|
||||||
import { toast } from './ui/sonner';
|
import { toast } from './ui/sonner';
|
||||||
import { useDistanceUnit } from '../contexts/DistanceUnitContext';
|
import { useDistanceUnit } from '../contexts/DistanceUnitContext';
|
||||||
|
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||||
import type {
|
import type {
|
||||||
Contact,
|
Contact,
|
||||||
ContactActiveRoom,
|
ContactActiveRoom,
|
||||||
@@ -158,6 +159,7 @@ export function ContactInfoPane({
|
|||||||
contact !== null &&
|
contact !== null &&
|
||||||
!isPrefixOnlyResolvedContact &&
|
!isPrefixOnlyResolvedContact &&
|
||||||
isUnknownFullKeyContact(contact.public_key, contact.last_advert);
|
isUnknownFullKeyContact(contact.public_key, contact.last_advert);
|
||||||
|
const isRepeater = contact?.type === CONTACT_TYPE_REPEATER;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={contactKey !== null} onOpenChange={(open) => !open && onClose()}>
|
<Sheet open={contactKey !== null} onOpenChange={(open) => !open && onClose()}>
|
||||||
@@ -290,7 +292,7 @@ export function ContactInfoPane({
|
|||||||
{contact.public_key}
|
{contact.public_key}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2 mt-1.5">
|
<div className="flex items-center gap-2 mt-1.5">
|
||||||
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
|
<span className="text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
|
||||||
{CONTACT_TYPE_LABELS[contact.type] ?? 'Unknown'}
|
{CONTACT_TYPE_LABELS[contact.type] ?? 'Unknown'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -440,7 +442,7 @@ export function ContactInfoPane({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{onSearchMessagesByKey && (
|
{!isRepeater && onSearchMessagesByKey && (
|
||||||
<div className="px-5 py-3 border-b border-border">
|
<div className="px-5 py-3 border-b border-border">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -453,40 +455,60 @@ export function ContactInfoPane({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Nearest Repeaters */}
|
{/* Nearest Repeaters (Hops) — last 7 days only */}
|
||||||
{analytics && analytics.nearest_repeaters.length > 0 && (
|
{analytics &&
|
||||||
<div className="px-5 py-3 border-b border-border">
|
(() => {
|
||||||
<SectionLabel>Nearest Repeaters</SectionLabel>
|
const sevenDaysAgo = Math.floor(Date.now() / 1000) - 7 * 86400;
|
||||||
<div className="space-y-1">
|
const recent = analytics.nearest_repeaters.filter(
|
||||||
{analytics.nearest_repeaters.map((r) => (
|
(r) => r.last_seen >= sevenDaysAgo
|
||||||
<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>
|
if (recent.length === 0) return null;
|
||||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
return (
|
||||||
{r.path_len === 0
|
<div className="px-5 py-3 border-b border-border">
|
||||||
? 'direct'
|
<SectionLabel>Nearest Repeaters — Hops (last 7 days)</SectionLabel>
|
||||||
: `${r.path_len} hop${r.path_len > 1 ? 's' : ''}`}{' '}
|
<div className="space-y-1">
|
||||||
· {r.heard_count}x
|
{recent.map((r) => (
|
||||||
</span>
|
<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>
|
);
|
||||||
</div>
|
})()}
|
||||||
|
|
||||||
|
{/* Geographically nearest repeaters (repeaters only) */}
|
||||||
|
{isRepeater && contact && isValidLocation(contact.lat, contact.lon) && (
|
||||||
|
<NearbyRepeatersSection
|
||||||
|
contact={contact}
|
||||||
|
contacts={contacts}
|
||||||
|
distanceUnit={distanceUnit}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Advert Paths */}
|
{/* Advert Paths */}
|
||||||
{analytics && analytics.advert_paths.length > 0 && (
|
{analytics && analytics.advert_paths.length > 0 && (
|
||||||
<div className="px-5 py-3 border-b border-border">
|
<div className="px-5 py-3 border-b border-border">
|
||||||
<SectionLabel>Recent Advert Paths</SectionLabel>
|
<SectionLabel>Recent Advert Paths</SectionLabel>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1.5">
|
||||||
{analytics.advert_paths.map((p) => (
|
{analytics.advert_paths.map((p) => (
|
||||||
<div
|
<div
|
||||||
key={p.path + p.first_seen}
|
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)'}
|
{p.path ? parsePathHops(p.path, p.path_len).join(' → ') : '(direct)'}
|
||||||
</span>
|
</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)}
|
{p.heard_count}x · {formatTime(p.last_seen)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -518,17 +540,21 @@ export function ContactInfoPane({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<MessageStatsSection
|
{!isRepeater && (
|
||||||
dmMessageCount={analytics?.dm_message_count ?? 0}
|
<>
|
||||||
channelMessageCount={analytics?.channel_message_count ?? 0}
|
<MessageStatsSection
|
||||||
/>
|
dmMessageCount={analytics?.dm_message_count ?? 0}
|
||||||
|
channelMessageCount={analytics?.channel_message_count ?? 0}
|
||||||
|
/>
|
||||||
|
|
||||||
<ActivityChartsSection analytics={analytics} />
|
<ActivityChartsSection analytics={analytics} />
|
||||||
|
|
||||||
<MostActiveChannelsSection
|
<MostActiveChannelsSection
|
||||||
channels={analytics?.most_active_rooms ?? []}
|
channels={analytics?.most_active_rooms ?? []}
|
||||||
onNavigateToChannel={onNavigateToChannel}
|
onNavigateToChannel={onNavigateToChannel}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
<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 }) {
|
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<h3 className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium mb-1.5">
|
<h3 className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium mb-1.5">
|
||||||
{children}
|
{children}
|
||||||
</h3>
|
</h3>
|
||||||
);
|
);
|
||||||
@@ -703,7 +729,7 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[0.6875rem] text-muted-foreground">
|
||||||
Hourly lines compare the last 24 hours against 7-day and all-time averages for the same hour
|
Hourly lines compare the last 24 hours against 7-day and all-time averages for the same hour
|
||||||
slots.
|
slots.
|
||||||
{!analytics.includes_direct_messages &&
|
{!analytics.includes_direct_messages &&
|
||||||
@@ -795,7 +821,7 @@ function ActivityLineChart<T extends ContactAnalyticsHourlyBucket | ContactAnaly
|
|||||||
{legendItems && (
|
{legendItems && (
|
||||||
<Legend
|
<Legend
|
||||||
content={() => (
|
content={() => (
|
||||||
<div className="flex flex-wrap justify-center gap-x-3 gap-y-1 mt-1 text-[11px] text-muted-foreground">
|
<div className="flex flex-wrap justify-center gap-x-3 gap-y-1 mt-1 text-[0.6875rem] text-muted-foreground">
|
||||||
{legendItems.map((item) => (
|
{legendItems.map((item) => (
|
||||||
<span key={item.label} className="inline-flex items-center gap-1.5">
|
<span key={item.label} className="inline-flex items-center gap-1.5">
|
||||||
<span
|
<span
|
||||||
@@ -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 }) {
|
function InfoItem({ label, value }: { label: string; value: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -74,12 +74,12 @@ function RouteCard({
|
|||||||
<div className="rounded-md border border-border bg-muted/20 p-3">
|
<div className="rounded-md border border-border bg-muted/20 p-3">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<h4 className="text-sm font-semibold">{label}</h4>
|
<h4 className="text-sm font-semibold">{label}</h4>
|
||||||
<span className="text-[11px] text-muted-foreground">
|
<span className="text-[0.6875rem] text-muted-foreground">
|
||||||
{formatRouteLabel(route.path_len, true)}
|
{formatRouteLabel(route.path_len, true)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-sm">{chain}</p>
|
<p className="mt-2 text-sm">{chain}</p>
|
||||||
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-muted-foreground">
|
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[0.6875rem] text-muted-foreground">
|
||||||
<span>Raw: {rawPath}</span>
|
<span>Raw: {rawPath}</span>
|
||||||
<span>{formatPathHashMode(route.path_hash_mode)}</span>
|
<span>{formatPathHashMode(route.path_hash_mode)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ interface ConversationPaneProps {
|
|||||||
notificationsPermission: NotificationPermission | 'unsupported';
|
notificationsPermission: NotificationPermission | 'unsupported';
|
||||||
favorites: Favorite[];
|
favorites: Favorite[];
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
|
preSorted?: boolean;
|
||||||
messagesLoading: boolean;
|
messagesLoading: boolean;
|
||||||
loadingOlder: boolean;
|
loadingOlder: boolean;
|
||||||
hasOlderMessages: boolean;
|
hasOlderMessages: boolean;
|
||||||
@@ -62,6 +63,10 @@ interface ConversationPaneProps {
|
|||||||
onDeleteContact: (publicKey: string) => Promise<void>;
|
onDeleteContact: (publicKey: string) => Promise<void>;
|
||||||
onDeleteChannel: (key: string) => Promise<void>;
|
onDeleteChannel: (key: string) => Promise<void>;
|
||||||
onSetChannelFloodScopeOverride: (channelKey: string, floodScopeOverride: string) => Promise<void>;
|
onSetChannelFloodScopeOverride: (channelKey: string, floodScopeOverride: string) => Promise<void>;
|
||||||
|
onSetChannelPathHashModeOverride?: (
|
||||||
|
channelKey: string,
|
||||||
|
pathHashModeOverride: number | null
|
||||||
|
) => Promise<void>;
|
||||||
onOpenContactInfo: (publicKey: string, fromChannel?: boolean) => void;
|
onOpenContactInfo: (publicKey: string, fromChannel?: boolean) => void;
|
||||||
onOpenChannelInfo: (channelKey: string) => void;
|
onOpenChannelInfo: (channelKey: string) => void;
|
||||||
onSenderClick: (sender: string) => void;
|
onSenderClick: (sender: string) => void;
|
||||||
@@ -74,6 +79,8 @@ interface ConversationPaneProps {
|
|||||||
onDismissUnreadMarker: () => void;
|
onDismissUnreadMarker: () => void;
|
||||||
onSendMessage: (text: string) => Promise<void>;
|
onSendMessage: (text: string) => Promise<void>;
|
||||||
onToggleNotifications: () => void;
|
onToggleNotifications: () => void;
|
||||||
|
trackedTelemetryRepeaters: string[];
|
||||||
|
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function LoadingPane({ label }: { label: string }) {
|
function LoadingPane({ label }: { label: string }) {
|
||||||
@@ -114,6 +121,7 @@ export function ConversationPane({
|
|||||||
notificationsPermission,
|
notificationsPermission,
|
||||||
favorites,
|
favorites,
|
||||||
messages,
|
messages,
|
||||||
|
preSorted,
|
||||||
messagesLoading,
|
messagesLoading,
|
||||||
loadingOlder,
|
loadingOlder,
|
||||||
hasOlderMessages,
|
hasOlderMessages,
|
||||||
@@ -129,6 +137,7 @@ export function ConversationPane({
|
|||||||
onDeleteContact,
|
onDeleteContact,
|
||||||
onDeleteChannel,
|
onDeleteChannel,
|
||||||
onSetChannelFloodScopeOverride,
|
onSetChannelFloodScopeOverride,
|
||||||
|
onSetChannelPathHashModeOverride,
|
||||||
onOpenContactInfo,
|
onOpenContactInfo,
|
||||||
onOpenChannelInfo,
|
onOpenChannelInfo,
|
||||||
onSenderClick,
|
onSenderClick,
|
||||||
@@ -141,6 +150,8 @@ export function ConversationPane({
|
|||||||
onDismissUnreadMarker,
|
onDismissUnreadMarker,
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
onToggleNotifications,
|
onToggleNotifications,
|
||||||
|
trackedTelemetryRepeaters,
|
||||||
|
onToggleTrackedTelemetry,
|
||||||
}: ConversationPaneProps) {
|
}: ConversationPaneProps) {
|
||||||
const [roomAuthenticated, setRoomAuthenticated] = useState(false);
|
const [roomAuthenticated, setRoomAuthenticated] = useState(false);
|
||||||
const activeContactIsRepeater = useMemo(() => {
|
const activeContactIsRepeater = useMemo(() => {
|
||||||
@@ -180,7 +191,12 @@ export function ConversationPane({
|
|||||||
</h2>
|
</h2>
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<Suspense fallback={<LoadingPane label="Loading map..." />}>
|
<Suspense fallback={<LoadingPane label="Loading map..." />}>
|
||||||
<MapView contacts={contacts} focusedKey={activeConversation.mapFocusKey} />
|
<MapView
|
||||||
|
contacts={contacts}
|
||||||
|
focusedKey={activeConversation.mapFocusKey}
|
||||||
|
rawPackets={rawPackets}
|
||||||
|
config={config}
|
||||||
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -233,6 +249,9 @@ export function ConversationPane({
|
|||||||
onToggleNotifications={onToggleNotifications}
|
onToggleNotifications={onToggleNotifications}
|
||||||
onToggleFavorite={onToggleFavorite}
|
onToggleFavorite={onToggleFavorite}
|
||||||
onDeleteContact={onDeleteContact}
|
onDeleteContact={onDeleteContact}
|
||||||
|
onOpenContactInfo={onOpenContactInfo}
|
||||||
|
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
|
||||||
|
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
@@ -256,6 +275,7 @@ export function ConversationPane({
|
|||||||
onToggleNotifications={onToggleNotifications}
|
onToggleNotifications={onToggleNotifications}
|
||||||
onToggleFavorite={onToggleFavorite}
|
onToggleFavorite={onToggleFavorite}
|
||||||
onSetChannelFloodScopeOverride={onSetChannelFloodScopeOverride}
|
onSetChannelFloodScopeOverride={onSetChannelFloodScopeOverride}
|
||||||
|
onSetChannelPathHashModeOverride={onSetChannelPathHashModeOverride}
|
||||||
onDeleteChannel={onDeleteChannel}
|
onDeleteChannel={onDeleteChannel}
|
||||||
onDeleteContact={onDeleteContact}
|
onDeleteContact={onDeleteContact}
|
||||||
onOpenContactInfo={onOpenContactInfo}
|
onOpenContactInfo={onOpenContactInfo}
|
||||||
@@ -274,6 +294,7 @@ export function ConversationPane({
|
|||||||
<MessageList
|
<MessageList
|
||||||
key={activeConversation.id}
|
key={activeConversation.id}
|
||||||
messages={messages}
|
messages={messages}
|
||||||
|
preSorted={preSorted}
|
||||||
contacts={contacts}
|
contacts={contacts}
|
||||||
channels={channels}
|
channels={channels}
|
||||||
loading={messagesLoading}
|
loading={messagesLoading}
|
||||||
|
|||||||
@@ -1,16 +1,47 @@
|
|||||||
import { Fragment, useEffect, useState, useMemo, useRef, useCallback } from 'react';
|
import { Fragment, useEffect, useState, useMemo, useRef, useCallback } from 'react';
|
||||||
import { MapContainer, TileLayer, CircleMarker, Popup, useMap } from 'react-leaflet';
|
import { MapContainer, TileLayer, CircleMarker, Popup, useMap, Polyline } from 'react-leaflet';
|
||||||
import type { LatLngBoundsExpression, CircleMarker as LeafletCircleMarker } from 'leaflet';
|
import type { LatLngBoundsExpression, CircleMarker as LeafletCircleMarker } from 'leaflet';
|
||||||
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import type { Contact } from '../types';
|
import type { Contact, RadioConfig, RawPacket } from '../types';
|
||||||
import { formatTime } from '../utils/messageParser';
|
import { formatTime } from '../utils/messageParser';
|
||||||
import { isValidLocation } from '../utils/pathUtils';
|
import { isValidLocation } from '../utils/pathUtils';
|
||||||
import { CONTACT_TYPE_REPEATER } from '../types';
|
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||||
|
import {
|
||||||
|
parsePacket,
|
||||||
|
getPacketLabel,
|
||||||
|
PARTICLE_COLOR_MAP,
|
||||||
|
dedupeConsecutive,
|
||||||
|
} from '../utils/visualizerUtils';
|
||||||
|
import { getRawPacketObservationKey } from '../utils/rawPacketIdentity';
|
||||||
|
|
||||||
interface MapViewProps {
|
interface MapViewProps {
|
||||||
contacts: Contact[];
|
contacts: Contact[];
|
||||||
/** Public key of contact to focus on and open popup */
|
/** Public key of contact to focus on and open popup */
|
||||||
focusedKey?: string | null;
|
focusedKey?: string | null;
|
||||||
|
rawPackets?: RawPacket[];
|
||||||
|
config?: RadioConfig | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tile layer presets ---
|
||||||
|
const TILE_LIGHT = {
|
||||||
|
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||||
|
background: '#1a1a2e',
|
||||||
|
};
|
||||||
|
const TILE_DARK = {
|
||||||
|
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
|
||||||
|
attribution:
|
||||||
|
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/">CARTO</a>',
|
||||||
|
background: '#0d0d0d',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getSavedDarkMap(): boolean {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem('remoteterm-dark-map') === 'true';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAP_RECENCY_COLORS = {
|
const MAP_RECENCY_COLORS = {
|
||||||
@@ -22,7 +53,16 @@ const MAP_RECENCY_COLORS = {
|
|||||||
const MAP_MARKER_STROKE = '#0f172a';
|
const MAP_MARKER_STROKE = '#0f172a';
|
||||||
const MAP_REPEATER_RING = '#f8fafc';
|
const MAP_REPEATER_RING = '#f8fafc';
|
||||||
|
|
||||||
// Calculate marker color based on how recently the contact was heard
|
// --- Packet visualization constants ---
|
||||||
|
const THREE_DAYS_SEC = 3 * 24 * 60 * 60;
|
||||||
|
const PARTICLE_LIFETIME_MS = 3000;
|
||||||
|
const PARTICLE_TAIL_LENGTH = 0.25; // fraction of progress to trail behind
|
||||||
|
const PARTICLE_RADIUS = 8;
|
||||||
|
const PARTICLE_TAIL_WIDTH = 5;
|
||||||
|
const MAX_MAP_PARTICLES = 200;
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
function getMarkerColor(lastSeen: number | null | undefined): string {
|
function getMarkerColor(lastSeen: number | null | undefined): string {
|
||||||
if (lastSeen == null) return MAP_RECENCY_COLORS.old;
|
if (lastSeen == null) return MAP_RECENCY_COLORS.old;
|
||||||
const now = Date.now() / 1000;
|
const now = Date.now() / 1000;
|
||||||
@@ -36,7 +76,85 @@ function getMarkerColor(lastSeen: number | null | undefined): string {
|
|||||||
return MAP_RECENCY_COLORS.old;
|
return MAP_RECENCY_COLORS.old;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Component to handle map bounds fitting
|
/** Resolve a hop token to a single contact with GPS, or null. */
|
||||||
|
function resolveHopToGps(hopToken: string, prefixIndex: Map<string, Contact[]>): Contact | null {
|
||||||
|
const matches = prefixIndex.get(hopToken.toLowerCase());
|
||||||
|
if (!matches || matches.length !== 1) return null;
|
||||||
|
const c = matches[0];
|
||||||
|
return isValidLocation(c.lat, c.lon) ? c : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a contact by display name (for GroupText senders). */
|
||||||
|
function resolveNameToGps(name: string, nameIndex: Map<string, Contact>): Contact | null {
|
||||||
|
const c = nameIndex.get(name);
|
||||||
|
if (!c) return null;
|
||||||
|
return isValidLocation(c.lat, c.lon) ? c : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collect public keys of all unambiguously resolved GPS-bearing contacts from a parsed packet. */
|
||||||
|
function resolvePacketContacts(
|
||||||
|
parsed: ReturnType<typeof parsePacket>,
|
||||||
|
prefixIndex: Map<string, Contact[]>,
|
||||||
|
nameIndex: Map<string, Contact>,
|
||||||
|
myLatLon: [number, number] | null,
|
||||||
|
config?: RadioConfig | null
|
||||||
|
): Set<string> {
|
||||||
|
const keys = new Set<string>();
|
||||||
|
if (!parsed) return keys;
|
||||||
|
|
||||||
|
// Source by pubkey prefix
|
||||||
|
const sourcePrefixes = parsed.advertPubkey
|
||||||
|
? [parsed.advertPubkey.slice(0, 12).toLowerCase()]
|
||||||
|
: parsed.srcHash
|
||||||
|
? [parsed.srcHash.toLowerCase()]
|
||||||
|
: [];
|
||||||
|
for (const prefix of sourcePrefixes) {
|
||||||
|
const matches = prefixIndex.get(prefix);
|
||||||
|
if (matches?.length === 1 && isValidLocation(matches[0].lat, matches[0].lon)) {
|
||||||
|
keys.add(matches[0].public_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source by name (GroupText sender)
|
||||||
|
if (parsed.groupTextSender) {
|
||||||
|
const c = resolveNameToGps(parsed.groupTextSender, nameIndex);
|
||||||
|
if (c) keys.add(c.public_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intermediate hops
|
||||||
|
for (const hop of parsed.pathBytes) {
|
||||||
|
if (hop.length < 4) continue;
|
||||||
|
const matches = prefixIndex.get(hop.toLowerCase());
|
||||||
|
if (matches?.length === 1 && isValidLocation(matches[0].lat, matches[0].lon)) {
|
||||||
|
keys.add(matches[0].public_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self
|
||||||
|
if (myLatLon && config?.public_key) {
|
||||||
|
keys.add(config.public_key.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destination
|
||||||
|
if (parsed.dstHash) {
|
||||||
|
const matches = prefixIndex.get(parsed.dstHash.toLowerCase());
|
||||||
|
if (matches?.length === 1 && isValidLocation(matches[0].lat, matches[0].lon)) {
|
||||||
|
keys.add(matches[0].public_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MapParticle {
|
||||||
|
id: number;
|
||||||
|
path: [number, number][]; // lat/lon waypoints
|
||||||
|
color: string;
|
||||||
|
startedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Map bounds handler ---
|
||||||
|
|
||||||
function MapBoundsHandler({
|
function MapBoundsHandler({
|
||||||
contacts,
|
contacts,
|
||||||
focusedContact,
|
focusedContact,
|
||||||
@@ -48,7 +166,6 @@ function MapBoundsHandler({
|
|||||||
const [hasInitialized, setHasInitialized] = useState(false);
|
const [hasInitialized, setHasInitialized] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// If we have a focused contact, center on it immediately (even if already initialized)
|
|
||||||
if (focusedContact && focusedContact.lat != null && focusedContact.lon != null) {
|
if (focusedContact && focusedContact.lat != null && focusedContact.lon != null) {
|
||||||
map.setView([focusedContact.lat, focusedContact.lon], 12);
|
map.setView([focusedContact.lat, focusedContact.lon], 12);
|
||||||
setHasInitialized(true);
|
setHasInitialized(true);
|
||||||
@@ -59,20 +176,17 @@ function MapBoundsHandler({
|
|||||||
|
|
||||||
const fitToContacts = () => {
|
const fitToContacts = () => {
|
||||||
if (contacts.length === 0) {
|
if (contacts.length === 0) {
|
||||||
// No contacts with location - show world view
|
|
||||||
map.setView([20, 0], 2);
|
map.setView([20, 0], 2);
|
||||||
setHasInitialized(true);
|
setHasInitialized(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contacts.length === 1) {
|
if (contacts.length === 1) {
|
||||||
// Single contact - center on it
|
|
||||||
map.setView([contacts[0].lat!, contacts[0].lon!], 10);
|
map.setView([contacts[0].lat!, contacts[0].lon!], 10);
|
||||||
setHasInitialized(true);
|
setHasInitialized(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multiple contacts - fit bounds
|
|
||||||
const bounds: LatLngBoundsExpression = contacts.map(
|
const bounds: LatLngBoundsExpression = contacts.map(
|
||||||
(c) => [c.lat!, c.lon!] as [number, number]
|
(c) => [c.lat!, c.lon!] as [number, number]
|
||||||
);
|
);
|
||||||
@@ -80,22 +194,18 @@ function MapBoundsHandler({
|
|||||||
setHasInitialized(true);
|
setHasInitialized(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Try geolocation first
|
|
||||||
if ('geolocation' in navigator) {
|
if ('geolocation' in navigator) {
|
||||||
navigator.geolocation.getCurrentPosition(
|
navigator.geolocation.getCurrentPosition(
|
||||||
(position) => {
|
(position) => {
|
||||||
// Success - center on user location with reasonable zoom
|
|
||||||
map.setView([position.coords.latitude, position.coords.longitude], 8);
|
map.setView([position.coords.latitude, position.coords.longitude], 8);
|
||||||
setHasInitialized(true);
|
setHasInitialized(true);
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
// Geolocation denied/failed - fit to contacts
|
|
||||||
fitToContacts();
|
fitToContacts();
|
||||||
},
|
},
|
||||||
{ timeout: 5000, maximumAge: 300000 }
|
{ timeout: 5000, maximumAge: 300000 }
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// No geolocation support - fit to contacts
|
|
||||||
fitToContacts();
|
fitToContacts();
|
||||||
}
|
}
|
||||||
}, [map, contacts, hasInitialized, focusedContact]);
|
}, [map, contacts, hasInitialized, focusedContact]);
|
||||||
@@ -103,18 +213,404 @@ function MapBoundsHandler({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MapView({ contacts, focusedKey }: MapViewProps) {
|
// --- Canvas particle overlay ---
|
||||||
const [sevenDaysAgo] = useState(() => Date.now() / 1000 - 7 * 24 * 60 * 60);
|
|
||||||
|
|
||||||
// Filter to contacts with GPS coordinates, heard within the last 7 days.
|
function ParticleOverlay({ particles }: { particles: MapParticle[] }) {
|
||||||
// Always include the focused contact so "view on map" links work for older nodes.
|
const map = useMap();
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
const animRef = useRef<number>(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = map.getContainer();
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.style.position = 'absolute';
|
||||||
|
canvas.style.top = '0';
|
||||||
|
canvas.style.left = '0';
|
||||||
|
canvas.style.pointerEvents = 'none';
|
||||||
|
canvas.style.zIndex = '450'; // above tiles, below popups
|
||||||
|
container.appendChild(canvas);
|
||||||
|
canvasRef.current = canvas;
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
const size = map.getSize();
|
||||||
|
canvas.width = size.x * window.devicePixelRatio;
|
||||||
|
canvas.height = size.y * window.devicePixelRatio;
|
||||||
|
canvas.style.width = `${size.x}px`;
|
||||||
|
canvas.style.height = `${size.y}px`;
|
||||||
|
};
|
||||||
|
resize();
|
||||||
|
map.on('resize', resize);
|
||||||
|
map.on('zoom', resize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(animRef.current);
|
||||||
|
map.off('resize', resize);
|
||||||
|
map.off('zoom', resize);
|
||||||
|
container.removeChild(canvas);
|
||||||
|
canvasRef.current = null;
|
||||||
|
};
|
||||||
|
}, [map]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const draw = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const dpr = window.devicePixelRatio;
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.save();
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
|
for (const particle of particles) {
|
||||||
|
const elapsed = now - particle.startedAt;
|
||||||
|
if (elapsed < 0 || elapsed > PARTICLE_LIFETIME_MS) continue;
|
||||||
|
const progress = elapsed / PARTICLE_LIFETIME_MS;
|
||||||
|
const path = particle.path;
|
||||||
|
if (path.length < 2) continue;
|
||||||
|
|
||||||
|
// Calculate total path length in pixels for even speed
|
||||||
|
const pixelPath = path.map((ll) => map.latLngToContainerPoint(L.latLng(ll[0], ll[1])));
|
||||||
|
const segLengths: number[] = [];
|
||||||
|
let totalLen = 0;
|
||||||
|
for (let i = 1; i < pixelPath.length; i++) {
|
||||||
|
const dx = pixelPath[i].x - pixelPath[i - 1].x;
|
||||||
|
const dy = pixelPath[i].y - pixelPath[i - 1].y;
|
||||||
|
const len = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
segLengths.push(len);
|
||||||
|
totalLen += len;
|
||||||
|
}
|
||||||
|
if (totalLen === 0) continue;
|
||||||
|
|
||||||
|
// Interpolate head position
|
||||||
|
const headDist = progress * totalLen;
|
||||||
|
const tailDist = Math.max(0, headDist - PARTICLE_TAIL_LENGTH * totalLen);
|
||||||
|
|
||||||
|
const pointAtDist = (d: number): { x: number; y: number } => {
|
||||||
|
let accum = 0;
|
||||||
|
for (let i = 0; i < segLengths.length; i++) {
|
||||||
|
if (accum + segLengths[i] >= d) {
|
||||||
|
const t = segLengths[i] > 0 ? (d - accum) / segLengths[i] : 0;
|
||||||
|
return {
|
||||||
|
x: pixelPath[i].x + (pixelPath[i + 1].x - pixelPath[i].x) * t,
|
||||||
|
y: pixelPath[i].y + (pixelPath[i + 1].y - pixelPath[i].y) * t,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
accum += segLengths[i];
|
||||||
|
}
|
||||||
|
const last = pixelPath[pixelPath.length - 1];
|
||||||
|
return { x: last.x, y: last.y };
|
||||||
|
};
|
||||||
|
|
||||||
|
const head = pointAtDist(headDist);
|
||||||
|
const tail = pointAtDist(tailDist);
|
||||||
|
|
||||||
|
// Draw tail as a gradient line from transparent to opaque
|
||||||
|
const grad = ctx.createLinearGradient(tail.x, tail.y, head.x, head.y);
|
||||||
|
grad.addColorStop(0, particle.color + '00');
|
||||||
|
grad.addColorStop(1, particle.color + 'cc');
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(tail.x, tail.y);
|
||||||
|
|
||||||
|
// Sample intermediate points along the tail for curved paths
|
||||||
|
const steps = 8;
|
||||||
|
for (let s = 1; s <= steps; s++) {
|
||||||
|
const d = tailDist + ((headDist - tailDist) * s) / steps;
|
||||||
|
const pt = pointAtDist(d);
|
||||||
|
ctx.lineTo(pt.x, pt.y);
|
||||||
|
}
|
||||||
|
ctx.strokeStyle = grad;
|
||||||
|
ctx.lineWidth = PARTICLE_TAIL_WIDTH;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Draw head blob with glow
|
||||||
|
const fade = progress > 0.8 ? 1 - (progress - 0.8) / 0.2 : 1;
|
||||||
|
const alpha = Math.round(fade * 230)
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, '0');
|
||||||
|
// Outer glow
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(head.x, head.y, PARTICLE_RADIUS + 4, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle =
|
||||||
|
particle.color +
|
||||||
|
Math.round(fade * 40)
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, '0');
|
||||||
|
ctx.fill();
|
||||||
|
// Core blob
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(head.x, head.y, PARTICLE_RADIUS, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = particle.color + alpha;
|
||||||
|
ctx.shadowColor = particle.color;
|
||||||
|
ctx.shadowBlur = 12 * fade;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
// Bright center
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(head.x, head.y, PARTICLE_RADIUS * 0.4, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = '#ffffff' + alpha;
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
animRef.current = requestAnimationFrame(draw);
|
||||||
|
};
|
||||||
|
|
||||||
|
animRef.current = requestAnimationFrame(draw);
|
||||||
|
return () => cancelAnimationFrame(animRef.current);
|
||||||
|
}, [map, particles]);
|
||||||
|
|
||||||
|
// Redraw on map move/zoom
|
||||||
|
useEffect(() => {
|
||||||
|
const redraw = () => {}; // Animation loop already redraws every frame
|
||||||
|
map.on('move', redraw);
|
||||||
|
map.on('zoom', redraw);
|
||||||
|
return () => {
|
||||||
|
map.off('move', redraw);
|
||||||
|
map.off('zoom', redraw);
|
||||||
|
};
|
||||||
|
}, [map]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main component ---
|
||||||
|
|
||||||
|
export function MapView({ contacts, focusedKey, rawPackets, config }: MapViewProps) {
|
||||||
|
const [sevenDaysAgo] = useState(() => Date.now() / 1000 - 7 * 24 * 60 * 60);
|
||||||
|
const [darkMap, setDarkMap] = useState(getSavedDarkMap);
|
||||||
|
const tile = darkMap ? TILE_DARK : TILE_LIGHT;
|
||||||
|
|
||||||
|
// Sync with settings changes from other components
|
||||||
|
useEffect(() => {
|
||||||
|
const onStorage = (e: StorageEvent) => {
|
||||||
|
if (e.key === 'remoteterm-dark-map') setDarkMap(e.newValue === 'true');
|
||||||
|
};
|
||||||
|
window.addEventListener('storage', onStorage);
|
||||||
|
return () => window.removeEventListener('storage', onStorage);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [showPackets, setShowPackets] = useState(false);
|
||||||
|
const [discoveryMode, setDiscoveryMode] = useState(false);
|
||||||
|
const [discoveredKeys, setDiscoveredKeys] = useState<Set<string>>(new Set());
|
||||||
|
const [particles, setParticles] = useState<MapParticle[]>([]);
|
||||||
|
const particleIdRef = useRef(0);
|
||||||
|
const seenObservationsRef = useRef(new Set<string>());
|
||||||
|
|
||||||
|
// Build prefix index and name index for hop resolution
|
||||||
|
const { prefixIndex, nameIndex } = useMemo(() => {
|
||||||
|
const prefix = new Map<string, Contact[]>();
|
||||||
|
const name = new Map<string, Contact>();
|
||||||
|
for (const c of contacts) {
|
||||||
|
const pubkey = c.public_key.toLowerCase();
|
||||||
|
for (let len = 1; len <= 12 && len <= pubkey.length; len++) {
|
||||||
|
const p = pubkey.slice(0, len);
|
||||||
|
const arr = prefix.get(p);
|
||||||
|
if (arr) arr.push(c);
|
||||||
|
else prefix.set(p, [c]);
|
||||||
|
}
|
||||||
|
if (c.name && !name.has(c.name)) name.set(c.name, c);
|
||||||
|
}
|
||||||
|
return { prefixIndex: prefix, nameIndex: name };
|
||||||
|
}, [contacts]);
|
||||||
|
|
||||||
|
// Self GPS
|
||||||
|
const myLatLon = useMemo<[number, number] | null>(() => {
|
||||||
|
if (!config || !isValidLocation(config.lat, config.lon)) return null;
|
||||||
|
return [config.lat, config.lon];
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
// Determine time window for packet visualization
|
||||||
|
const threeDaysAgoSec = useMemo(() => Date.now() / 1000 - THREE_DAYS_SEC, []);
|
||||||
|
|
||||||
|
// Filter contacts for map display
|
||||||
const mappableContacts = useMemo(() => {
|
const mappableContacts = useMemo(() => {
|
||||||
|
if (showPackets && discoveryMode) {
|
||||||
|
// Discovery mode: only show nodes that have appeared in resolved packets
|
||||||
|
return contacts.filter(
|
||||||
|
(c) => isValidLocation(c.lat, c.lon) && discoveredKeys.has(c.public_key)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (showPackets) {
|
||||||
|
// Packet mode: show only last 3 days
|
||||||
|
return contacts.filter(
|
||||||
|
(c) =>
|
||||||
|
isValidLocation(c.lat, c.lon) &&
|
||||||
|
(c.public_key === focusedKey || (c.last_seen != null && c.last_seen > threeDaysAgoSec))
|
||||||
|
);
|
||||||
|
}
|
||||||
return contacts.filter(
|
return contacts.filter(
|
||||||
(c) =>
|
(c) =>
|
||||||
isValidLocation(c.lat, c.lon) &&
|
isValidLocation(c.lat, c.lon) &&
|
||||||
(c.public_key === focusedKey || (c.last_seen != null && c.last_seen > sevenDaysAgo))
|
(c.public_key === focusedKey || (c.last_seen != null && c.last_seen > sevenDaysAgo))
|
||||||
);
|
);
|
||||||
}, [contacts, focusedKey, sevenDaysAgo]);
|
}, [
|
||||||
|
contacts,
|
||||||
|
focusedKey,
|
||||||
|
sevenDaysAgo,
|
||||||
|
threeDaysAgoSec,
|
||||||
|
showPackets,
|
||||||
|
discoveryMode,
|
||||||
|
discoveredKeys,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Resolve a path of hop tokens to geographic waypoints (only unambiguous + has GPS)
|
||||||
|
const resolvePacketPath = useCallback(
|
||||||
|
(parsed: ReturnType<typeof parsePacket>): [number, number][] | null => {
|
||||||
|
if (!parsed) return null;
|
||||||
|
|
||||||
|
const waypoints: [number, number][] = [];
|
||||||
|
|
||||||
|
// Source: advertPubkey, srcHash, or groupTextSender resolved by name
|
||||||
|
let sourceContact: Contact | null = null;
|
||||||
|
if (parsed.advertPubkey) {
|
||||||
|
const prefix = parsed.advertPubkey.slice(0, 12).toLowerCase();
|
||||||
|
const matches = prefixIndex.get(prefix);
|
||||||
|
if (matches?.length === 1 && isValidLocation(matches[0].lat, matches[0].lon)) {
|
||||||
|
sourceContact = matches[0];
|
||||||
|
}
|
||||||
|
} else if (parsed.srcHash) {
|
||||||
|
sourceContact = resolveHopToGps(parsed.srcHash, prefixIndex);
|
||||||
|
} else if (parsed.groupTextSender) {
|
||||||
|
sourceContact = resolveNameToGps(parsed.groupTextSender, nameIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceContact) {
|
||||||
|
waypoints.push([sourceContact.lat!, sourceContact.lon!]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intermediate hops (path bytes)
|
||||||
|
for (const hop of parsed.pathBytes) {
|
||||||
|
// Only resolve 2+ byte hops (4+ hex chars) to avoid ambiguous 1-byte hops
|
||||||
|
if (hop.length < 4) continue;
|
||||||
|
const contact = resolveHopToGps(hop, prefixIndex);
|
||||||
|
if (contact) {
|
||||||
|
waypoints.push([contact.lat!, contact.lon!]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destination: self (our radio), or dstHash
|
||||||
|
if (myLatLon) {
|
||||||
|
waypoints.push(myLatLon);
|
||||||
|
} else if (parsed.dstHash) {
|
||||||
|
const dest = resolveHopToGps(parsed.dstHash, prefixIndex);
|
||||||
|
if (dest) {
|
||||||
|
waypoints.push([dest.lat!, dest.lon!]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedupe consecutive identical waypoints
|
||||||
|
const deduped = dedupeConsecutive(waypoints.map((w) => `${w[0]},${w[1]}`));
|
||||||
|
if (deduped.length < 2) return null;
|
||||||
|
|
||||||
|
return deduped.map((s) => {
|
||||||
|
const [lat, lon] = s.split(',').map(Number);
|
||||||
|
return [lat, lon] as [number, number];
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[prefixIndex, nameIndex, myLatLon]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Process new packets into particles and track discovered contacts
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showPackets || !rawPackets?.length) return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const newParticles: MapParticle[] = [];
|
||||||
|
const newDiscovered = new Set<string>();
|
||||||
|
|
||||||
|
for (const pkt of rawPackets) {
|
||||||
|
// Skip old packets
|
||||||
|
if (pkt.timestamp < threeDaysAgoSec) continue;
|
||||||
|
|
||||||
|
// Deduplicate by observation
|
||||||
|
const obsKey = getRawPacketObservationKey(pkt);
|
||||||
|
if (seenObservationsRef.current.has(obsKey)) continue;
|
||||||
|
|
||||||
|
const parsed = parsePacket(pkt.data);
|
||||||
|
if (!parsed) continue;
|
||||||
|
|
||||||
|
// Discover contacts from this packet regardless of whether a full path resolves
|
||||||
|
const resolvedContacts = resolvePacketContacts(
|
||||||
|
parsed,
|
||||||
|
prefixIndex,
|
||||||
|
nameIndex,
|
||||||
|
myLatLon,
|
||||||
|
config
|
||||||
|
);
|
||||||
|
const path = resolvePacketPath(parsed);
|
||||||
|
|
||||||
|
// Only mark as seen if we got something useful; otherwise a later run
|
||||||
|
// with updated contacts/config can retry this observation.
|
||||||
|
if (resolvedContacts.size === 0 && !path) continue;
|
||||||
|
seenObservationsRef.current.add(obsKey);
|
||||||
|
|
||||||
|
for (const key of resolvedContacts) newDiscovered.add(key);
|
||||||
|
|
||||||
|
if (path) {
|
||||||
|
newParticles.push({
|
||||||
|
id: particleIdRef.current++,
|
||||||
|
path,
|
||||||
|
color: PARTICLE_COLOR_MAP[getPacketLabel(parsed.payloadType)],
|
||||||
|
startedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newDiscovered.size > 0) {
|
||||||
|
setDiscoveredKeys((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const k of newDiscovered) next.add(k);
|
||||||
|
return next.size !== prev.size ? next : prev;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newParticles.length === 0) return;
|
||||||
|
|
||||||
|
setParticles((prev) => {
|
||||||
|
const combined = [...prev, ...newParticles];
|
||||||
|
// Prune expired and cap total
|
||||||
|
const alive = combined.filter((p) => now - p.startedAt < PARTICLE_LIFETIME_MS);
|
||||||
|
return alive.slice(-MAX_MAP_PARTICLES);
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
rawPackets,
|
||||||
|
showPackets,
|
||||||
|
resolvePacketPath,
|
||||||
|
threeDaysAgoSec,
|
||||||
|
prefixIndex,
|
||||||
|
nameIndex,
|
||||||
|
myLatLon,
|
||||||
|
config,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Prune expired particles periodically
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showPackets) return;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
setParticles((prev) => prev.filter((p) => now - p.startedAt < PARTICLE_LIFETIME_MS));
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [showPackets]);
|
||||||
|
|
||||||
|
// Reset discovered set when exiting discovery mode
|
||||||
|
useEffect(() => {
|
||||||
|
if (!discoveryMode) setDiscoveredKeys(new Set());
|
||||||
|
}, [discoveryMode]);
|
||||||
|
|
||||||
|
// Clear state when toggling off
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showPackets) {
|
||||||
|
setParticles([]);
|
||||||
|
setDiscoveredKeys(new Set());
|
||||||
|
setDiscoveryMode(false);
|
||||||
|
seenObservationsRef.current.clear();
|
||||||
|
}
|
||||||
|
}, [showPackets]);
|
||||||
|
|
||||||
// Find the focused contact by key
|
// Find the focused contact by key
|
||||||
const focusedContact = useMemo(() => {
|
const focusedContact = useMemo(() => {
|
||||||
@@ -124,18 +620,17 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
|||||||
|
|
||||||
const includesFocusedOutsideWindow =
|
const includesFocusedOutsideWindow =
|
||||||
focusedContact != null &&
|
focusedContact != null &&
|
||||||
(focusedContact.last_seen == null || focusedContact.last_seen <= sevenDaysAgo);
|
(focusedContact.last_seen == null ||
|
||||||
|
focusedContact.last_seen <= (showPackets ? threeDaysAgoSec : sevenDaysAgo));
|
||||||
|
|
||||||
// Track marker refs to open popup programmatically
|
// Track marker refs to open popup programmatically
|
||||||
const markerRefs = useRef<Record<string, LeafletCircleMarker | null>>({});
|
const markerRefs = useRef<Record<string, LeafletCircleMarker | null>>({});
|
||||||
|
|
||||||
// Store ref for a marker
|
|
||||||
const setMarkerRef = useCallback((key: string, ref: LeafletCircleMarker | null) => {
|
const setMarkerRef = useCallback((key: string, ref: LeafletCircleMarker | null) => {
|
||||||
if (ref === null) {
|
if (ref === null) {
|
||||||
delete markerRefs.current[key];
|
delete markerRefs.current[key];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
markerRefs.current[key] = ref;
|
markerRefs.current[key] = ref;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -148,10 +643,8 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
|||||||
}
|
}
|
||||||
}, [mappableContacts]);
|
}, [mappableContacts]);
|
||||||
|
|
||||||
// Open popup for focused contact after map is ready
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (focusedContact && markerRefs.current[focusedContact.public_key]) {
|
if (focusedContact && markerRefs.current[focusedContact.public_key]) {
|
||||||
// Small delay to ensure map has finished rendering
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
markerRefs.current[focusedContact.public_key]?.openPopup();
|
markerRefs.current[focusedContact.public_key]?.openPopup();
|
||||||
}, 100);
|
}, 100);
|
||||||
@@ -159,48 +652,104 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
|||||||
}
|
}
|
||||||
}, [focusedContact]);
|
}, [focusedContact]);
|
||||||
|
|
||||||
|
// Gather unique link paths for static route lines when packet viz is on
|
||||||
|
const routeLines = useMemo(() => {
|
||||||
|
if (!showPackets) return [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const lines: { path: [number, number][]; color: string }[] = [];
|
||||||
|
for (const p of particles) {
|
||||||
|
const key = p.path.map((w) => `${w[0]},${w[1]}`).join('|');
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
lines.push({ path: p.path, color: p.color });
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}, [showPackets, particles]);
|
||||||
|
|
||||||
|
const timeWindowLabel = showPackets ? '3 days' : '7 days';
|
||||||
|
const infoLabel =
|
||||||
|
showPackets && discoveryMode
|
||||||
|
? `${mappableContacts.length} node${mappableContacts.length !== 1 ? 's' : ''} discovered from live traffic`
|
||||||
|
: `Showing ${mappableContacts.length} contact${mappableContacts.length !== 1 ? 's' : ''} heard in the last ${timeWindowLabel}${includesFocusedOutsideWindow ? ' plus the focused contact' : ''}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Info bar */}
|
{/* Info bar */}
|
||||||
<div className="px-4 py-2 bg-muted/50 text-xs text-muted-foreground flex items-center justify-between">
|
<div className="px-4 py-2 bg-muted/50 text-xs text-muted-foreground flex items-center justify-between">
|
||||||
<span>
|
<span>{infoLabel}</span>
|
||||||
Showing {mappableContacts.length} contact{mappableContacts.length !== 1 ? 's' : ''} heard
|
|
||||||
in the last 7 days
|
|
||||||
{includesFocusedOutsideWindow ? ' plus the focused contact' : ''}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="flex items-center gap-1">
|
{!showPackets && (
|
||||||
<span
|
<>
|
||||||
className="w-3 h-3 rounded-full"
|
<span className="flex items-center gap-1">
|
||||||
style={{ backgroundColor: MAP_RECENCY_COLORS.recent }}
|
<span
|
||||||
aria-hidden="true"
|
className="w-3 h-3 rounded-full"
|
||||||
/>{' '}
|
style={{ backgroundColor: MAP_RECENCY_COLORS.recent }}
|
||||||
<1h
|
aria-hidden="true"
|
||||||
</span>
|
/>{' '}
|
||||||
<span className="flex items-center gap-1">
|
<1h
|
||||||
<span
|
</span>
|
||||||
className="w-3 h-3 rounded-full"
|
<span className="flex items-center gap-1">
|
||||||
style={{ backgroundColor: MAP_RECENCY_COLORS.today }}
|
<span
|
||||||
aria-hidden="true"
|
className="w-3 h-3 rounded-full"
|
||||||
/>{' '}
|
style={{ backgroundColor: MAP_RECENCY_COLORS.today }}
|
||||||
<1d
|
aria-hidden="true"
|
||||||
</span>
|
/>{' '}
|
||||||
<span className="flex items-center gap-1">
|
<1d
|
||||||
<span
|
</span>
|
||||||
className="w-3 h-3 rounded-full"
|
<span className="flex items-center gap-1">
|
||||||
style={{ backgroundColor: MAP_RECENCY_COLORS.stale }}
|
<span
|
||||||
aria-hidden="true"
|
className="w-3 h-3 rounded-full"
|
||||||
/>{' '}
|
style={{ backgroundColor: MAP_RECENCY_COLORS.stale }}
|
||||||
<3d
|
aria-hidden="true"
|
||||||
</span>
|
/>{' '}
|
||||||
<span className="flex items-center gap-1">
|
<3d
|
||||||
<span
|
</span>
|
||||||
className="w-3 h-3 rounded-full"
|
<span className="flex items-center gap-1">
|
||||||
style={{ backgroundColor: MAP_RECENCY_COLORS.old }}
|
<span
|
||||||
aria-hidden="true"
|
className="w-3 h-3 rounded-full"
|
||||||
/>{' '}
|
style={{ backgroundColor: MAP_RECENCY_COLORS.old }}
|
||||||
older
|
aria-hidden="true"
|
||||||
</span>
|
/>{' '}
|
||||||
|
older
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{showPackets && (
|
||||||
|
<>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full"
|
||||||
|
style={{ backgroundColor: PARTICLE_COLOR_MAP['AD'] }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
Ad
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full"
|
||||||
|
style={{ backgroundColor: PARTICLE_COLOR_MAP['GT'] }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
Ch
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full"
|
||||||
|
style={{ backgroundColor: PARTICLE_COLOR_MAP['DM'] }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
DM
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full"
|
||||||
|
style={{ backgroundColor: PARTICLE_COLOR_MAP['ACK'] }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
ACK
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<span
|
<span
|
||||||
className="w-3 h-3 rounded-full border-2"
|
className="w-3 h-3 rounded-full border-2"
|
||||||
@@ -209,10 +758,30 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
|||||||
/>{' '}
|
/>{' '}
|
||||||
repeater
|
repeater
|
||||||
</span>
|
</span>
|
||||||
|
<label className="flex items-center gap-1.5 cursor-pointer ml-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showPackets}
|
||||||
|
onChange={(e) => setShowPackets(e.target.checked)}
|
||||||
|
className="rounded border-border"
|
||||||
|
/>
|
||||||
|
<span className="text-[0.6875rem]">Visualize packets</span>
|
||||||
|
</label>
|
||||||
|
{showPackets && (
|
||||||
|
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={discoveryMode}
|
||||||
|
onChange={(e) => setDiscoveryMode(e.target.checked)}
|
||||||
|
className="rounded border-border"
|
||||||
|
/>
|
||||||
|
<span className="text-[0.6875rem]">Discover nodes</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Map - z-index constrained to stay below modals/sheets */}
|
{/* Map */}
|
||||||
<div
|
<div
|
||||||
className="flex-1 relative"
|
className="flex-1 relative"
|
||||||
style={{ zIndex: 0 }}
|
style={{ zIndex: 0 }}
|
||||||
@@ -223,14 +792,21 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
|||||||
center={[20, 0]}
|
center={[20, 0]}
|
||||||
zoom={2}
|
zoom={2}
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
style={{ background: '#1a1a2e' }}
|
style={{ background: tile.background }}
|
||||||
>
|
>
|
||||||
<TileLayer
|
<TileLayer key={tile.url} attribution={tile.attribution} url={tile.url} />
|
||||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
||||||
/>
|
|
||||||
<MapBoundsHandler contacts={mappableContacts} focusedContact={focusedContact} />
|
<MapBoundsHandler contacts={mappableContacts} focusedContact={focusedContact} />
|
||||||
|
|
||||||
|
{/* Faint route lines for active packet paths */}
|
||||||
|
{showPackets &&
|
||||||
|
routeLines.map((line, i) => (
|
||||||
|
<Polyline
|
||||||
|
key={i}
|
||||||
|
positions={line.path}
|
||||||
|
pathOptions={{ color: line.color, weight: 1, opacity: 0.15, dashArray: '4 6' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
{mappableContacts.map((contact) => {
|
{mappableContacts.map((contact) => {
|
||||||
const isRepeater = contact.type === CONTACT_TYPE_REPEATER;
|
const isRepeater = contact.type === CONTACT_TYPE_REPEATER;
|
||||||
const color = getMarkerColor(contact.last_seen);
|
const color = getMarkerColor(contact.last_seen);
|
||||||
@@ -275,6 +851,8 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
|||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{showPackets && <ParticleOverlay particles={particles} />}
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ interface MessageListProps {
|
|||||||
loadingNewer?: boolean;
|
loadingNewer?: boolean;
|
||||||
onLoadNewer?: () => void;
|
onLoadNewer?: () => void;
|
||||||
onJumpToBottom?: () => void;
|
onJumpToBottom?: () => void;
|
||||||
|
preSorted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// URL regex for linkifying plain text
|
// URL regex for linkifying plain text
|
||||||
@@ -219,8 +220,8 @@ function HopCountBadge({ paths, onClick, variant }: HopCountBadgeProps) {
|
|||||||
|
|
||||||
const className =
|
const className =
|
||||||
variant === 'header'
|
variant === 'header'
|
||||||
? 'font-normal text-muted-foreground ml-1 text-[11px] cursor-pointer hover:text-primary hover:underline'
|
? 'font-normal text-muted-foreground ml-1 text-[0.6875rem] cursor-pointer hover:text-primary hover:underline'
|
||||||
: 'text-[10px] text-muted-foreground ml-1 cursor-pointer hover:text-primary hover:underline';
|
: 'text-[0.625rem] text-muted-foreground ml-1 cursor-pointer hover:text-primary hover:underline';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
@@ -283,6 +284,7 @@ export function MessageList({
|
|||||||
loadingNewer = false,
|
loadingNewer = false,
|
||||||
onLoadNewer,
|
onLoadNewer,
|
||||||
onJumpToBottom,
|
onJumpToBottom,
|
||||||
|
preSorted = false,
|
||||||
}: MessageListProps) {
|
}: MessageListProps) {
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
const prevMessagesLengthRef = useRef<number>(0);
|
const prevMessagesLengthRef = useRef<number>(0);
|
||||||
@@ -298,6 +300,9 @@ export function MessageList({
|
|||||||
const [resendableIds, setResendableIds] = useState<Set<number>>(new Set());
|
const [resendableIds, setResendableIds] = useState<Set<number>>(new Set());
|
||||||
const resendTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
|
const resendTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
|
||||||
const packetCacheRef = useRef<Map<number, RawPacket>>(new Map());
|
const packetCacheRef = useRef<Map<number, RawPacket>>(new Map());
|
||||||
|
const packetSignalOverrideRef = useRef<{ rssi: number | null; snr: number | null } | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
const [packetInspectorSource, setPacketInspectorSource] = useState<
|
const [packetInspectorSource, setPacketInspectorSource] = useState<
|
||||||
| { kind: 'packet'; packet: RawPacket }
|
| { kind: 'packet'; packet: RawPacket }
|
||||||
| { kind: 'loading'; message: string }
|
| { kind: 'loading'; message: string }
|
||||||
@@ -323,6 +328,13 @@ export function MessageList({
|
|||||||
const prevConvKeyRef = useRef<string | null>(null);
|
const prevConvKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const handleAnalyzePacket = useCallback(async (message: Message) => {
|
const handleAnalyzePacket = useCallback(async (message: Message) => {
|
||||||
|
// Extract signal from the first path if available
|
||||||
|
const firstPath = message.paths?.[0];
|
||||||
|
packetSignalOverrideRef.current =
|
||||||
|
firstPath && (firstPath.rssi != null || firstPath.snr != null)
|
||||||
|
? { rssi: firstPath.rssi ?? null, snr: firstPath.snr ?? null }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
if (message.packet_id == null) {
|
if (message.packet_id == null) {
|
||||||
setPacketInspectorSource({
|
setPacketInspectorSource({
|
||||||
kind: 'unavailable',
|
kind: 'unavailable',
|
||||||
@@ -486,8 +498,11 @@ export function MessageList({
|
|||||||
// Note: Deduplication is handled by useConversationMessages.observeMessage()
|
// Note: Deduplication is handled by useConversationMessages.observeMessage()
|
||||||
// and the database UNIQUE constraint on (type, conversation_key, text, sender_timestamp)
|
// and the database UNIQUE constraint on (type, conversation_key, text, sender_timestamp)
|
||||||
const sortedMessages = useMemo(
|
const sortedMessages = useMemo(
|
||||||
() => [...messages].sort((a, b) => a.received_at - b.received_at || a.id - b.id),
|
() =>
|
||||||
[messages]
|
preSorted
|
||||||
|
? messages
|
||||||
|
: [...messages].sort((a, b) => a.received_at - b.received_at || a.id - b.id),
|
||||||
|
[messages, preSorted]
|
||||||
);
|
);
|
||||||
const unreadMarkerIndex = useMemo(() => {
|
const unreadMarkerIndex = useMemo(() => {
|
||||||
if (unreadMarkerLastReadAt === undefined) {
|
if (unreadMarkerLastReadAt === undefined) {
|
||||||
@@ -960,7 +975,7 @@ export function MessageList({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{showAvatar && (
|
{showAvatar && (
|
||||||
<div className="text-[13px] font-semibold text-foreground mb-0.5">
|
<div className="text-[0.8125rem] font-semibold text-foreground mb-0.5">
|
||||||
{canClickSender ? (
|
{canClickSender ? (
|
||||||
<span
|
<span
|
||||||
className="cursor-pointer hover:text-primary transition-colors"
|
className="cursor-pointer hover:text-primary transition-colors"
|
||||||
@@ -975,7 +990,7 @@ export function MessageList({
|
|||||||
) : (
|
) : (
|
||||||
displaySender
|
displaySender
|
||||||
)}
|
)}
|
||||||
<span className="font-normal text-muted-foreground ml-2 text-[11px]">
|
<span className="font-normal text-muted-foreground ml-2 text-[0.6875rem]">
|
||||||
{formatTime(msg.sender_timestamp || msg.received_at)}
|
{formatTime(msg.sender_timestamp || msg.received_at)}
|
||||||
</span>
|
</span>
|
||||||
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
|
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
|
||||||
@@ -1003,7 +1018,7 @@ export function MessageList({
|
|||||||
))}
|
))}
|
||||||
{!showAvatar && (
|
{!showAvatar && (
|
||||||
<>
|
<>
|
||||||
<span className="text-[10px] text-muted-foreground ml-2">
|
<span className="text-[0.625rem] text-muted-foreground ml-2">
|
||||||
{formatTime(msg.sender_timestamp || msg.received_at)}
|
{formatTime(msg.sender_timestamp || msg.received_at)}
|
||||||
</span>
|
</span>
|
||||||
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
|
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
|
||||||
@@ -1175,12 +1190,18 @@ export function MessageList({
|
|||||||
{packetInspectorSource && (
|
{packetInspectorSource && (
|
||||||
<RawPacketInspectorDialog
|
<RawPacketInspectorDialog
|
||||||
open={packetInspectorSource !== null}
|
open={packetInspectorSource !== null}
|
||||||
onOpenChange={(isOpen) => !isOpen && setPacketInspectorSource(null)}
|
onOpenChange={(isOpen) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setPacketInspectorSource(null);
|
||||||
|
packetSignalOverrideRef.current = undefined;
|
||||||
|
}
|
||||||
|
}}
|
||||||
channels={channels}
|
channels={channels}
|
||||||
source={packetInspectorSource}
|
source={packetInspectorSource}
|
||||||
title="Analyze Packet"
|
title="Analyze Packet"
|
||||||
description="On-demand raw packet analysis for a message-backed archival packet."
|
description="On-demand raw packet analysis for a message-backed archival packet."
|
||||||
notice={ANALYZE_PACKET_NOTICE}
|
notice={ANALYZE_PACKET_NOTICE}
|
||||||
|
signalOverride={packetSignalOverrideRef.current}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,23 +3,29 @@ import { Dice5 } from 'lucide-react';
|
|||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
} from './ui/dialog';
|
} 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 { Input } from './ui/input';
|
||||||
import { Label } from './ui/label';
|
import { Label } from './ui/label';
|
||||||
import { Checkbox } from './ui/checkbox';
|
import { Checkbox } from './ui/checkbox';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { toast } from './ui/sonner';
|
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 {
|
interface NewMessageModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
undecryptedCount: number;
|
undecryptedCount: number;
|
||||||
|
showBulkAddChannelTab?: boolean;
|
||||||
prefillRequest?: {
|
prefillRequest?: {
|
||||||
tab: 'hashtag';
|
tab: 'hashtag';
|
||||||
hashtagName: string;
|
hashtagName: string;
|
||||||
@@ -29,53 +35,121 @@ interface NewMessageModalProps {
|
|||||||
onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>;
|
onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>;
|
||||||
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
|
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
|
||||||
onCreateHashtagChannel: (name: 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({
|
export function NewMessageModal({
|
||||||
open,
|
open,
|
||||||
undecryptedCount,
|
undecryptedCount,
|
||||||
|
showBulkAddChannelTab = false,
|
||||||
prefillRequest = null,
|
prefillRequest = null,
|
||||||
onClose,
|
onClose,
|
||||||
onCreateContact,
|
onCreateContact,
|
||||||
onCreateChannel,
|
onCreateChannel,
|
||||||
onCreateHashtagChannel,
|
onCreateHashtagChannel,
|
||||||
|
onBulkAddHashtagChannels,
|
||||||
}: NewMessageModalProps) {
|
}: NewMessageModalProps) {
|
||||||
const [tab, setTab] = useState<Tab>('new-contact');
|
const [tab, setTab] = useState<Tab>('new-contact');
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [contactKey, setContactKey] = useState('');
|
const [contactKey, setContactKey] = useState('');
|
||||||
const [channelKey, setChannelKey] = useState('');
|
const [channelKey, setChannelKey] = useState('');
|
||||||
|
const [bulkChannelText, setBulkChannelText] = useState('');
|
||||||
const [tryHistorical, setTryHistorical] = useState(false);
|
const [tryHistorical, setTryHistorical] = useState(false);
|
||||||
const [permitCapitals, setPermitCapitals] = useState(false);
|
const [permitCapitals, setPermitCapitals] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const hashtagInputRef = useRef<HTMLInputElement>(null);
|
const hashtagInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const bulkTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setName('');
|
setName('');
|
||||||
setContactKey('');
|
setContactKey('');
|
||||||
setChannelKey('');
|
setChannelKey('');
|
||||||
|
setBulkChannelText('');
|
||||||
setTryHistorical(false);
|
setTryHistorical(false);
|
||||||
setPermitCapitals(false);
|
setPermitCapitals(false);
|
||||||
setError('');
|
setError('');
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !prefillRequest) {
|
if (!open) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setTab(prefillRequest.tab);
|
if (prefillRequest) {
|
||||||
setName(prefillRequest.hashtagName);
|
setTab(prefillRequest.tab);
|
||||||
setContactKey('');
|
setName(prefillRequest.hashtagName);
|
||||||
setChannelKey('');
|
setContactKey('');
|
||||||
setTryHistorical(false);
|
setChannelKey('');
|
||||||
setPermitCapitals(false);
|
setBulkChannelText('');
|
||||||
setError('');
|
setTryHistorical(false);
|
||||||
setLoading(false);
|
setPermitCapitals(false);
|
||||||
requestAnimationFrame(() => {
|
setError('');
|
||||||
hashtagInputRef.current?.focus();
|
setLoading(false);
|
||||||
});
|
requestAnimationFrame(() => {
|
||||||
}, [open, prefillRequest]);
|
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 () => {
|
const handleCreate = async () => {
|
||||||
setError('');
|
setError('');
|
||||||
@@ -87,7 +161,6 @@ export function NewMessageModal({
|
|||||||
setError('Name and public key are required');
|
setError('Name and public key are required');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// handleCreateContact sets activeConversation with the backend-normalized key
|
|
||||||
await onCreateContact(name.trim(), contactKey.trim(), tryHistorical);
|
await onCreateContact(name.trim(), contactKey.trim(), tryHistorical);
|
||||||
} else if (tab === 'new-channel') {
|
} else if (tab === 'new-channel') {
|
||||||
if (!name.trim() || !channelKey.trim()) {
|
if (!name.trim() || !channelKey.trim()) {
|
||||||
@@ -102,10 +175,24 @@ export function NewMessageModal({
|
|||||||
setError(validationError);
|
setError(validationError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Normalize to lowercase unless user explicitly permits capitals
|
|
||||||
const normalizedName = permitCapitals ? channelName : channelName.toLowerCase();
|
const normalizedName = permitCapitals ? channelName : channelName.toLowerCase();
|
||||||
await onCreateHashtagChannel(`#${normalizedName}`, tryHistorical);
|
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();
|
resetForm();
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} 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 () => {
|
const handleCreateAndAddAnother = async () => {
|
||||||
setError('');
|
setError('');
|
||||||
const channelName = name.trim();
|
const channelName = name.trim();
|
||||||
@@ -139,7 +216,6 @@ export function NewMessageModal({
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// Normalize to lowercase unless user explicitly permits capitals
|
|
||||||
const normalizedName = permitCapitals ? channelName : channelName.toLowerCase();
|
const normalizedName = permitCapitals ? channelName : channelName.toLowerCase();
|
||||||
await onCreateHashtagChannel(`#${normalizedName}`, tryHistorical);
|
await onCreateHashtagChannel(`#${normalizedName}`, tryHistorical);
|
||||||
setName('');
|
setName('');
|
||||||
@@ -166,28 +242,36 @@ export function NewMessageModal({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className="sm:max-w-[500px]">
|
<DialogContent className="sm:max-w-[560px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>New Conversation</DialogTitle>
|
<DialogTitle>New Conversation</DialogTitle>
|
||||||
<DialogDescription className="sr-only">
|
<DialogDescription className="sr-only">
|
||||||
{tab === 'new-contact' && 'Add a new contact by entering their name and public key'}
|
{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 === 'new-channel' && 'Create a private channel with a shared encryption key'}
|
||||||
{tab === 'hashtag' && 'Join a public hashtag channel'}
|
{tab === 'hashtag' && 'Join a public hashtag channel'}
|
||||||
|
{tab === 'bulk-hashtag' && 'Paste multiple hashtag rooms to add them in one batch'}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
value={tab}
|
value={tab}
|
||||||
onValueChange={(v) => {
|
onValueChange={(value) => {
|
||||||
setTab(v as Tab);
|
setTab(value as Tab);
|
||||||
resetForm();
|
resetForm();
|
||||||
}}
|
}}
|
||||||
className="w-full"
|
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-contact">Contact</TabsTrigger>
|
||||||
<TabsTrigger value="new-channel">Private Channel</TabsTrigger>
|
<TabsTrigger value="new-channel">Private Channel</TabsTrigger>
|
||||||
<TabsTrigger value="hashtag">Hashtag Channel</TabsTrigger>
|
<TabsTrigger value="hashtag">Hashtag Channel</TabsTrigger>
|
||||||
|
{showBulkAddChannelTab && (
|
||||||
|
<TabsTrigger value="bulk-hashtag">Bulk Add Channel</TabsTrigger>
|
||||||
|
)}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="new-contact" className="mt-4 space-y-4">
|
<TabsContent value="new-contact" className="mt-4 space-y-4">
|
||||||
@@ -239,7 +323,7 @@ export function NewMessageModal({
|
|||||||
const bytes = new Uint8Array(16);
|
const bytes = new Uint8Array(16);
|
||||||
crypto.getRandomValues(bytes);
|
crypto.getRandomValues(bytes);
|
||||||
const hex = Array.from(bytes)
|
const hex = Array.from(bytes)
|
||||||
.map((b) => b.toString(16).padStart(2, '0'))
|
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||||
.join('');
|
.join('');
|
||||||
setChannelKey(hex);
|
setChannelKey(hex);
|
||||||
}}
|
}}
|
||||||
@@ -268,20 +352,55 @@ export function NewMessageModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 space-y-1">
|
<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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={permitCapitals}
|
checked={permitCapitals}
|
||||||
onChange={(e) => setPermitCapitals(e.target.checked)}
|
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>
|
<span className="text-sm">Permit capitals in channel key derivation</span>
|
||||||
</label>
|
</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
|
Not recommended; most companions normalize to lowercase
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</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>
|
</Tabs>
|
||||||
|
|
||||||
{showHistoricalOption && (
|
{showHistoricalOption && (
|
||||||
@@ -289,7 +408,7 @@ export function NewMessageModal({
|
|||||||
<div className="flex items-center justify-end space-x-2">
|
<div className="flex items-center justify-end space-x-2">
|
||||||
<Label
|
<Label
|
||||||
htmlFor="try-historical"
|
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
|
Try decrypting {undecryptedCount.toLocaleString()} stored packet
|
||||||
{undecryptedCount !== 1 ? 's' : ''}
|
{undecryptedCount !== 1 ? 's' : ''}
|
||||||
@@ -301,7 +420,7 @@ export function NewMessageModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{tryHistorical && (
|
{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
|
Messages will stream in as they decrypt in the background
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -330,7 +449,13 @@ export function NewMessageModal({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button onClick={handleCreate} disabled={loading}>
|
<Button onClick={handleCreate} disabled={loading}>
|
||||||
{loading ? 'Creating...' : 'Create'}
|
{loading
|
||||||
|
? tab === 'bulk-hashtag'
|
||||||
|
? 'Adding...'
|
||||||
|
: 'Creating...'
|
||||||
|
: tab === 'bulk-hashtag'
|
||||||
|
? 'Add Channels'
|
||||||
|
: 'Create'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -103,14 +103,25 @@ export function PathModal({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Raw path summary */}
|
{/* Raw path summary */}
|
||||||
<div className="text-sm">
|
<div className="text-sm space-y-1">
|
||||||
{paths.map((p, index) => {
|
{paths.map((p, index) => {
|
||||||
const hops = parsePathHops(p.path, p.path_len);
|
const hops = parsePathHops(p.path, p.path_len);
|
||||||
const rawPath = hops.length > 0 ? hops.join('->') : 'direct';
|
const rawPath = hops.length > 0 ? hops.join('->') : 'direct';
|
||||||
|
const hasSignal = p.rssi != null || p.snr != null;
|
||||||
return (
|
return (
|
||||||
<div key={index}>
|
<div key={index}>
|
||||||
<span className="text-foreground/70 font-semibold">Path {index + 1}:</span>{' '}
|
<div>
|
||||||
<span className="font-mono text-muted-foreground">{rawPath}</span>
|
<span className="text-foreground/70 font-semibold">Path {index + 1}:</span>{' '}
|
||||||
|
<span className="font-mono text-muted-foreground">{rawPath}</span>
|
||||||
|
</div>
|
||||||
|
{hasSignal && (
|
||||||
|
<div className="text-[0.6875rem] text-muted-foreground ml-4">
|
||||||
|
Last hop (as heard by you):{' '}
|
||||||
|
{p.rssi != null && <span>{p.rssi} dBm RSSI</span>}
|
||||||
|
{p.rssi != null && p.snr != null && <span> · </span>}
|
||||||
|
{p.snr != null && <span>{p.snr.toFixed(1)} dB SNR</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -221,7 +232,7 @@ export function PathModal({
|
|||||||
>
|
>
|
||||||
<span className="flex flex-col items-center leading-tight">
|
<span className="flex flex-col items-center leading-tight">
|
||||||
<span>↻ Resend</span>
|
<span>↻ Resend</span>
|
||||||
<span className="text-[10px] font-normal opacity-80">
|
<span className="text-[0.625rem] font-normal opacity-80">
|
||||||
Only repeated by new repeaters
|
Only repeated by new repeaters
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -237,7 +248,7 @@ export function PathModal({
|
|||||||
>
|
>
|
||||||
<span className="flex flex-col items-center leading-tight">
|
<span className="flex flex-col items-center leading-tight">
|
||||||
<span>↻ Resend as new</span>
|
<span>↻ Resend as new</span>
|
||||||
<span className="text-[10px] font-normal opacity-80">
|
<span className="text-[0.625rem] font-normal opacity-80">
|
||||||
Will appear as duplicate to receivers
|
Will appear as duplicate to receivers
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -406,9 +417,12 @@ interface HopNodeProps {
|
|||||||
distanceUnit: DistanceUnit;
|
distanceUnit: DistanceUnit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AMBIGUOUS_MATCH_PREVIEW_LIMIT = 3;
|
||||||
|
|
||||||
function HopNode({ hop, hopNumber, prevLocation, distanceUnit }: HopNodeProps) {
|
function HopNode({ hop, hopNumber, prevLocation, distanceUnit }: HopNodeProps) {
|
||||||
const isAmbiguous = hop.matches.length > 1;
|
const isAmbiguous = hop.matches.length > 1;
|
||||||
const isUnknown = hop.matches.length === 0;
|
const isUnknown = hop.matches.length === 0;
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
// Calculate distance from previous location for a contact
|
// Calculate distance from previous location for a contact
|
||||||
// Returns null if prev location unknown/ambiguous or contact has no valid location
|
// 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>
|
<div className="font-medium text-muted-foreground"><UNKNOWN></div>
|
||||||
) : isAmbiguous ? (
|
) : isAmbiguous ? (
|
||||||
<div>
|
<div>
|
||||||
{hop.matches.map((contact) => {
|
{(expanded ? hop.matches : hop.matches.slice(0, AMBIGUOUS_MATCH_PREVIEW_LIMIT)).map(
|
||||||
const dist = getDistanceForContact(contact);
|
(contact) => {
|
||||||
const hasLocation = isValidLocation(contact.lat, contact.lon);
|
const dist = getDistanceForContact(contact);
|
||||||
return (
|
const hasLocation = isValidLocation(contact.lat, contact.lon);
|
||||||
<div key={contact.public_key} className="font-medium truncate">
|
return (
|
||||||
{contact.name || contact.public_key.slice(0, 12)}
|
<div key={contact.public_key} className="font-medium truncate">
|
||||||
{dist !== null && (
|
{contact.name || contact.public_key.slice(0, 12)}
|
||||||
<span className="text-xs text-muted-foreground ml-1">
|
{dist !== null && (
|
||||||
- {formatDistance(dist, distanceUnit)}
|
<span className="text-xs text-muted-foreground ml-1">
|
||||||
</span>
|
- {formatDistance(dist, distanceUnit)}
|
||||||
)}
|
</span>
|
||||||
{hasLocation && (
|
)}
|
||||||
<CoordinateLink
|
{hasLocation && (
|
||||||
lat={contact.lat!}
|
<CoordinateLink
|
||||||
lon={contact.lon!}
|
lat={contact.lat!}
|
||||||
publicKey={contact.public_key}
|
lon={contact.lon!}
|
||||||
/>
|
publicKey={contact.public_key}
|
||||||
)}
|
/>
|
||||||
</div>
|
)}
|
||||||
);
|
</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>
|
||||||
) : (
|
) : (
|
||||||
<div className="font-medium truncate">
|
<div className="font-medium truncate">
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ type RawPacketInspectorDialogSource =
|
|||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface SignalOverride {
|
||||||
|
rssi: number | null;
|
||||||
|
snr: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface RawPacketInspectorDialogProps {
|
interface RawPacketInspectorDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
@@ -43,10 +48,12 @@ interface RawPacketInspectorDialogProps {
|
|||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
notice?: ReactNode;
|
notice?: ReactNode;
|
||||||
|
signalOverride?: SignalOverride;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RawPacketInspectionPanelProps {
|
interface RawPacketInspectionPanelProps {
|
||||||
packet: RawPacket;
|
packet: RawPacket;
|
||||||
|
signalOverride?: SignalOverride;
|
||||||
channels: Channel[];
|
channels: Channel[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,15 +132,21 @@ function formatTimestamp(timestamp: number): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSignal(packet: RawPacket): string {
|
function formatSignal(
|
||||||
const parts: string[] = [];
|
packet: RawPacket,
|
||||||
if (packet.rssi !== null) {
|
signalOverride?: SignalOverride
|
||||||
parts.push(`${packet.rssi} dBm RSSI`);
|
): { lines: string[]; label: string } {
|
||||||
}
|
const rssi = signalOverride?.rssi ?? packet.rssi;
|
||||||
if (packet.snr !== null) {
|
const snr = signalOverride?.snr ?? packet.snr;
|
||||||
parts.push(`${packet.snr.toFixed(1)} dB SNR`);
|
const lines: string[] = [];
|
||||||
}
|
if (rssi !== null) lines.push(`${rssi} dBm RSSI`);
|
||||||
return parts.length > 0 ? parts.join(' · ') : 'No signal sample';
|
if (snr !== null) lines.push(`${snr.toFixed(1)} dB SNR`);
|
||||||
|
const isOverride =
|
||||||
|
signalOverride != null && (signalOverride.rssi != null || signalOverride.snr != null);
|
||||||
|
return {
|
||||||
|
lines: lines.length > 0 ? lines : ['No signal sample'],
|
||||||
|
label: isOverride ? 'Last Hop Signal' : 'Signal',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatByteRange(field: PacketByteField): string {
|
function formatByteRange(field: PacketByteField): string {
|
||||||
@@ -312,7 +325,7 @@ function CompactMetaCard({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-border/70 bg-card/70 p-2.5">
|
<div className="rounded-lg border border-border/70 bg-card/70 p-2.5">
|
||||||
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">{label}</div>
|
<div className="text-[0.625rem] uppercase tracking-wider text-muted-foreground">{label}</div>
|
||||||
<div className="mt-1 text-sm font-medium leading-tight text-foreground">{primary}</div>
|
<div className="mt-1 text-sm font-medium leading-tight text-foreground">{primary}</div>
|
||||||
{secondary ? (
|
{secondary ? (
|
||||||
<div className="mt-1 text-xs leading-tight text-muted-foreground">{secondary}</div>
|
<div className="mt-1 text-xs leading-tight text-muted-foreground">{secondary}</div>
|
||||||
@@ -340,7 +353,7 @@ function FullPacketHex({
|
|||||||
const byteRuns = useMemo(() => buildByteRuns(bytes, byteOwners), [byteOwners, bytes]);
|
const byteRuns = useMemo(() => buildByteRuns(bytes, byteOwners), [byteOwners, bytes]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="font-mono text-[15px] leading-7 text-foreground">
|
<div className="font-mono text-[0.9375rem] leading-7 text-foreground">
|
||||||
{byteRuns.map((run, index) => {
|
{byteRuns.map((run, index) => {
|
||||||
const fieldId = run.fieldId;
|
const fieldId = run.fieldId;
|
||||||
const palette = fieldId ? colorMap.get(fieldId) : null;
|
const palette = fieldId ? colorMap.get(fieldId) : null;
|
||||||
@@ -446,7 +459,9 @@ function FieldBox({
|
|||||||
<div className="flex flex-col items-start gap-2 sm:flex-row sm:justify-between">
|
<div className="flex flex-col items-start gap-2 sm:flex-row sm:justify-between">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-base font-semibold leading-tight text-foreground">{field.name}</div>
|
<div className="text-base font-semibold leading-tight text-foreground">{field.name}</div>
|
||||||
<div className="mt-0.5 text-[11px] text-muted-foreground">{formatByteRange(field)}</div>
|
<div className="mt-0.5 text-[0.6875rem] text-muted-foreground">
|
||||||
|
{formatByteRange(field)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -464,7 +479,7 @@ function FieldBox({
|
|||||||
|
|
||||||
{field.decryptedMessage ? (
|
{field.decryptedMessage ? (
|
||||||
<div className="mt-2 rounded border border-border/50 bg-background/40 p-2">
|
<div className="mt-2 rounded border border-border/50 bg-background/40 p-2">
|
||||||
<div className="text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
|
<div className="text-[0.625rem] uppercase tracking-wider text-muted-foreground">
|
||||||
{field.name === 'Ciphertext' ? 'Plaintext' : 'Decoded value'}
|
{field.name === 'Ciphertext' ? 'Plaintext' : 'Decoded value'}
|
||||||
</div>
|
</div>
|
||||||
<PlaintextContent text={field.decryptedMessage} />
|
<PlaintextContent text={field.decryptedMessage} />
|
||||||
@@ -486,11 +501,13 @@ function FieldBox({
|
|||||||
<div className="text-sm font-medium leading-tight text-foreground">
|
<div className="text-sm font-medium leading-tight text-foreground">
|
||||||
{part.field}
|
{part.field}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 text-[11px] text-muted-foreground">Bits {part.bits}</div>
|
<div className="mt-0.5 text-[0.6875rem] text-muted-foreground">
|
||||||
|
Bits {part.bits}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="font-mono text-sm text-foreground">{part.binary}</div>
|
<div className="font-mono text-sm text-foreground">{part.binary}</div>
|
||||||
<div className="mt-0.5 text-[11px] text-muted-foreground">{part.value}</div>
|
<div className="mt-0.5 text-[0.6875rem] text-muted-foreground">{part.value}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -565,7 +582,11 @@ function FieldSection({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RawPacketInspectionPanel({ packet, channels }: RawPacketInspectionPanelProps) {
|
export function RawPacketInspectionPanel({
|
||||||
|
packet,
|
||||||
|
channels,
|
||||||
|
signalOverride,
|
||||||
|
}: RawPacketInspectionPanelProps) {
|
||||||
const decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]);
|
const decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]);
|
||||||
const groupTextCandidates = useMemo(
|
const groupTextCandidates = useMemo(
|
||||||
() => buildGroupTextResolutionCandidates(channels),
|
() => buildGroupTextResolutionCandidates(channels),
|
||||||
@@ -598,7 +619,7 @@ export function RawPacketInspectionPanel({ packet, channels }: RawPacketInspecti
|
|||||||
<section className="rounded-lg border border-border/70 bg-card/70 p-3">
|
<section className="rounded-lg border border-border/70 bg-card/70 p-3">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
|
<div className="text-[0.625rem] uppercase tracking-wider text-muted-foreground">
|
||||||
Summary
|
Summary
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-base font-semibold leading-tight text-foreground">
|
<div className="mt-1 text-base font-semibold leading-tight text-foreground">
|
||||||
@@ -611,7 +632,7 @@ export function RawPacketInspectionPanel({ packet, channels }: RawPacketInspecti
|
|||||||
</div>
|
</div>
|
||||||
{packetContext ? (
|
{packetContext ? (
|
||||||
<div className="mt-2 rounded-md border border-border/60 bg-background/35 px-2.5 py-2">
|
<div className="mt-2 rounded-md border border-border/60 bg-background/35 px-2.5 py-2">
|
||||||
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
|
<div className="text-[0.625rem] uppercase tracking-wider text-muted-foreground">
|
||||||
{packetContext.title}
|
{packetContext.title}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-sm font-medium leading-tight text-foreground">
|
<div className="mt-1 text-sm font-medium leading-tight text-foreground">
|
||||||
@@ -637,11 +658,24 @@ export function RawPacketInspectionPanel({ packet, channels }: RawPacketInspecti
|
|||||||
primary={`${inspection.routeTypeName} · ${inspection.payloadTypeName}`}
|
primary={`${inspection.routeTypeName} · ${inspection.payloadTypeName}`}
|
||||||
secondary={`${inspection.payloadVersionName} · ${formatPathMode(inspection.decoded?.pathHashSize, inspection.pathTokens.length)}`}
|
secondary={`${inspection.payloadVersionName} · ${formatPathMode(inspection.decoded?.pathHashSize, inspection.pathTokens.length)}`}
|
||||||
/>
|
/>
|
||||||
<CompactMetaCard
|
{(() => {
|
||||||
label="Signal"
|
const sig = formatSignal(packet, signalOverride);
|
||||||
primary={formatSignal(packet)}
|
return (
|
||||||
secondary={packetContext ? null : undefined}
|
<div className="rounded-lg border border-border/70 bg-card/70 p-2.5">
|
||||||
/>
|
<div className="text-[0.625rem] uppercase tracking-wider text-muted-foreground">
|
||||||
|
{sig.label}
|
||||||
|
</div>
|
||||||
|
{sig.lines.map((line, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`${i === 0 ? 'mt-1' : 'mt-0.5'} text-sm font-medium leading-tight text-foreground`}
|
||||||
|
>
|
||||||
|
{line}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -711,6 +745,7 @@ export function RawPacketInspectorDialog({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
notice,
|
notice,
|
||||||
|
signalOverride,
|
||||||
}: RawPacketInspectorDialogProps) {
|
}: RawPacketInspectorDialogProps) {
|
||||||
const [packetInput, setPacketInput] = useState('');
|
const [packetInput, setPacketInput] = useState('');
|
||||||
|
|
||||||
@@ -735,7 +770,13 @@ export function RawPacketInspectorDialog({
|
|||||||
|
|
||||||
let body: ReactNode;
|
let body: ReactNode;
|
||||||
if (source.kind === 'packet') {
|
if (source.kind === 'packet') {
|
||||||
body = <RawPacketInspectionPanel packet={source.packet} channels={channels} />;
|
body = (
|
||||||
|
<RawPacketInspectionPanel
|
||||||
|
packet={source.packet}
|
||||||
|
channels={channels}
|
||||||
|
signalOverride={signalOverride}
|
||||||
|
/>
|
||||||
|
);
|
||||||
} else if (source.kind === 'paste') {
|
} else if (source.kind === 'paste') {
|
||||||
body = (
|
body = (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -211,7 +211,9 @@ function getCoverageMessage(
|
|||||||
function StatTile({ label, value, detail }: { label: string; value: string; detail?: string }) {
|
function StatTile({ label, value, detail }: { label: string; value: string; detail?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="break-inside-avoid rounded-lg border border-border/70 bg-card/80 p-3">
|
<div className="break-inside-avoid rounded-lg border border-border/70 bg-card/80 p-3">
|
||||||
<div className="text-[11px] uppercase tracking-wide text-muted-foreground">{label}</div>
|
<div className="text-[0.625rem] uppercase tracking-wider font-medium text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
<div className="mt-1 text-xl font-semibold tabular-nums text-foreground">{value}</div>
|
<div className="mt-1 text-xl font-semibold tabular-nums text-foreground">{value}</div>
|
||||||
{detail ? <div className="mt-1 text-xs text-muted-foreground">{detail}</div> : null}
|
{detail ? <div className="mt-1 text-xs text-muted-foreground">{detail}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -329,7 +331,7 @@ function NeighborList({
|
|||||||
: `Last seen ${new Date(item.lastSeen * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`}
|
: `Last seen ${new Date(item.lastSeen * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`}
|
||||||
</div>
|
</div>
|
||||||
{!isNeighborIdentityResolvable(item, contacts) ? (
|
{!isNeighborIdentityResolvable(item, contacts) ? (
|
||||||
<div className="text-[11px] text-warning">Identity not resolvable</div>
|
<div className="text-[0.6875rem] text-warning">Identity not resolvable</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{mode !== 'signal' ? (
|
{mode !== 'signal' ? (
|
||||||
@@ -363,7 +365,7 @@ function TimelineChart({ bins }: { bins: PacketTimelineBin[] }) {
|
|||||||
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
|
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<h3 className="text-sm font-semibold text-foreground">Traffic Timeline</h3>
|
<h3 className="text-sm font-semibold text-foreground">Traffic Timeline</h3>
|
||||||
<div className="flex flex-wrap justify-end gap-2 text-[11px] text-muted-foreground">
|
<div className="flex flex-wrap justify-end gap-2 text-[0.6875rem] text-muted-foreground">
|
||||||
{typeOrder.map((type, i) => (
|
{typeOrder.map((type, i) => (
|
||||||
<span key={type} className="inline-flex items-center gap-1">
|
<span key={type} className="inline-flex items-center gap-1">
|
||||||
<span
|
<span
|
||||||
@@ -513,7 +515,7 @@ export function RawPacketFeedView({
|
|||||||
<div className="break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
|
<div className="break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[11px] uppercase tracking-wide text-muted-foreground">
|
<div className="text-[0.625rem] uppercase tracking-wider font-medium text-muted-foreground">
|
||||||
Coverage
|
Coverage
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export function RawPacketList({ packets, channels, onPacketClick }: RawPacketLis
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Route type badge */}
|
{/* Route type badge */}
|
||||||
<span
|
<span
|
||||||
className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${getRouteTypeColor(decoded.routeType)}`}
|
className={`text-[0.625rem] font-mono px-1.5 py-0.5 rounded ${getRouteTypeColor(decoded.routeType)}`}
|
||||||
title={decoded.routeType}
|
title={decoded.routeType}
|
||||||
>
|
>
|
||||||
{getRouteTypeLabel(decoded.routeType)}
|
{getRouteTypeLabel(decoded.routeType)}
|
||||||
@@ -117,26 +117,29 @@ export function RawPacketList({ packets, channels, onPacketClick }: RawPacketLis
|
|||||||
|
|
||||||
{/* Summary */}
|
{/* Summary */}
|
||||||
<span
|
<span
|
||||||
className={cn('text-[13px]', packet.decrypted ? 'text-primary' : 'text-foreground')}
|
className={cn(
|
||||||
|
'text-[0.8125rem]',
|
||||||
|
packet.decrypted ? 'text-primary' : 'text-foreground'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{decoded.summary}
|
{decoded.summary}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Time */}
|
{/* Time */}
|
||||||
<span className="text-muted-foreground ml-auto text-[12px] tabular-nums">
|
<span className="text-muted-foreground ml-auto text-xs tabular-nums">
|
||||||
{formatTime(packet.timestamp)}
|
{formatTime(packet.timestamp)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Signal info */}
|
{/* Signal info */}
|
||||||
{(packet.snr !== null || packet.rssi !== null) && (
|
{(packet.snr !== null || packet.rssi !== null) && (
|
||||||
<div className="text-[11px] text-muted-foreground mt-0.5 tabular-nums">
|
<div className="text-[0.6875rem] text-muted-foreground mt-0.5 tabular-nums">
|
||||||
{formatSignalInfo(packet)}
|
{formatSignalInfo(packet)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Raw hex data (always visible) */}
|
{/* Raw hex data (always visible) */}
|
||||||
<div className="font-mono text-[10px] break-all text-muted-foreground mt-1.5 p-1.5 bg-background/60 rounded">
|
<div className="font-mono text-[0.625rem] break-all text-muted-foreground mt-1.5 p-1.5 bg-background/60 rounded">
|
||||||
{packet.data.toUpperCase()}
|
{packet.data.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { api } from '../api';
|
||||||
import { toast } from './ui/sonner';
|
import { toast } from './ui/sonner';
|
||||||
import { Button } from './ui/button';
|
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 { DirectTraceIcon } from './DirectTraceIcon';
|
||||||
import { RepeaterLogin } from './RepeaterLogin';
|
import { RepeaterLogin } from './RepeaterLogin';
|
||||||
import { ServerLoginStatusBanner } from './ServerLoginStatusBanner';
|
import { ServerLoginStatusBanner } from './ServerLoginStatusBanner';
|
||||||
@@ -12,7 +13,13 @@ import { isFavorite } from '../utils/favorites';
|
|||||||
import { handleKeyboardActivate } from '../utils/a11y';
|
import { handleKeyboardActivate } from '../utils/a11y';
|
||||||
import { isValidLocation } from '../utils/pathUtils';
|
import { isValidLocation } from '../utils/pathUtils';
|
||||||
import { ContactStatusInfo } from './ContactStatusInfo';
|
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 { cn } from '../lib/utils';
|
||||||
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
|
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
|
||||||
import { NeighborsPane } from './repeater/RepeaterNeighborsPane';
|
import { NeighborsPane } from './repeater/RepeaterNeighborsPane';
|
||||||
@@ -23,6 +30,7 @@ import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane';
|
|||||||
import { OwnerInfoPane } from './repeater/RepeaterOwnerInfoPane';
|
import { OwnerInfoPane } from './repeater/RepeaterOwnerInfoPane';
|
||||||
import { ActionsPane } from './repeater/RepeaterActionsPane';
|
import { ActionsPane } from './repeater/RepeaterActionsPane';
|
||||||
import { ConsolePane } from './repeater/RepeaterConsolePane';
|
import { ConsolePane } from './repeater/RepeaterConsolePane';
|
||||||
|
import { TelemetryHistoryPane } from './repeater/RepeaterTelemetryHistoryPane';
|
||||||
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
|
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
|
||||||
|
|
||||||
// Re-export for backwards compatibility (used by repeaterFormatters.test.ts)
|
// Re-export for backwards compatibility (used by repeaterFormatters.test.ts)
|
||||||
@@ -45,6 +53,9 @@ interface RepeaterDashboardProps {
|
|||||||
onToggleNotifications: () => void;
|
onToggleNotifications: () => void;
|
||||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
||||||
onDeleteContact: (publicKey: string) => void;
|
onDeleteContact: (publicKey: string) => void;
|
||||||
|
onOpenContactInfo?: (publicKey: string) => void;
|
||||||
|
trackedTelemetryRepeaters: string[];
|
||||||
|
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RepeaterDashboard({
|
export function RepeaterDashboard({
|
||||||
@@ -62,6 +73,9 @@ export function RepeaterDashboard({
|
|||||||
onToggleNotifications,
|
onToggleNotifications,
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
onDeleteContact,
|
onDeleteContact,
|
||||||
|
onOpenContactInfo,
|
||||||
|
trackedTelemetryRepeaters,
|
||||||
|
onToggleTrackedTelemetry,
|
||||||
}: RepeaterDashboardProps) {
|
}: RepeaterDashboardProps) {
|
||||||
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
|
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
|
||||||
const contact = contacts.find((c) => c.public_key === conversation.id) ?? null;
|
const contact = contacts.find((c) => c.public_key === conversation.id) ?? null;
|
||||||
@@ -88,7 +102,40 @@ export function RepeaterDashboard({
|
|||||||
const { password, setPassword, rememberPassword, setRememberPassword, persistAfterLogin } =
|
const { password, setPassword, rememberPassword, setRememberPassword, persistAfterLogin } =
|
||||||
useRememberedServerPassword('repeater', conversation.id);
|
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 isFav = isFavorite(favorites, 'contact', conversation.id);
|
||||||
|
|
||||||
const handleRepeaterLogin = async (nextPassword: string) => {
|
const handleRepeaterLogin = async (nextPassword: string) => {
|
||||||
await login(nextPassword);
|
await login(nextPassword);
|
||||||
persistAfterLogin(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-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-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||||
<span className="flex min-w-0 flex-1 items-baseline gap-2">
|
<span className="flex min-w-0 flex-1 items-baseline gap-2">
|
||||||
<span className="min-w-0 flex-shrink truncate font-semibold text-base">
|
<h2 className="min-w-0 flex-shrink font-semibold text-base">
|
||||||
{conversation.name}
|
{onOpenContactInfo ? (
|
||||||
</span>
|
<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
|
<span
|
||||||
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary"
|
className="min-w-0 flex-1 truncate font-mono text-[0.6875rem] text-muted-foreground transition-colors hover:text-primary"
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={handleKeyboardActivate}
|
onKeyDown={handleKeyboardActivate}
|
||||||
@@ -135,7 +197,7 @@ export function RepeaterDashboard({
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{contact && (
|
{contact && (
|
||||||
<div className="col-span-2 row-start-2 min-w-0 text-[11px] text-muted-foreground min-[1100px]:col-span-1 min-[1100px]:col-start-2 min-[1100px]:row-start-1">
|
<div className="col-span-2 row-start-2 min-w-0 text-[0.6875rem] text-muted-foreground min-[1100px]:col-span-1 min-[1100px]:col-start-2 min-[1100px]:row-start-1">
|
||||||
<ContactStatusInfo contact={contact} ourLat={radioLat} ourLon={radioLon} />
|
<ContactStatusInfo contact={contact} ourLat={radioLat} ourLon={radioLon} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -146,7 +208,7 @@ export function RepeaterDashboard({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={loadAll}
|
onClick={loadAll}
|
||||||
disabled={anyLoading}
|
disabled={anyLoading}
|
||||||
className="h-7 px-2 text-[11px] leading-none border-success text-success hover:bg-success/10 hover:text-success sm:h-8 sm:px-3 sm:text-xs"
|
className="h-7 px-2 text-[0.6875rem] leading-none border-success text-success hover:bg-success/10 hover:text-success sm:h-8 sm:px-3 sm:text-xs"
|
||||||
>
|
>
|
||||||
{anyLoading ? 'Loading...' : 'Load All'}
|
{anyLoading ? 'Loading...' : 'Load All'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -192,7 +254,7 @@ export function RepeaterDashboard({
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
{notificationsEnabled && (
|
{notificationsEnabled && (
|
||||||
<span className="hidden md:inline text-[11px] font-medium text-status-connected">
|
<span className="hidden md:inline text-[0.6875rem] font-medium text-status-connected">
|
||||||
Notifications On
|
Notifications On
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -336,6 +398,15 @@ export function RepeaterDashboard({
|
|||||||
loading={consoleLoading}
|
loading={consoleLoading}
|
||||||
onSend={sendConsoleCommand}
|
onSend={sendConsoleCommand}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Telemetry history chart — full width, below console */}
|
||||||
|
<TelemetryHistoryPane
|
||||||
|
entries={telemetryHistory}
|
||||||
|
publicKey={conversation.id}
|
||||||
|
contacts={contacts}
|
||||||
|
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
|
||||||
|
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -290,7 +290,7 @@ export function SearchView({
|
|||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-[10px] font-medium px-1.5 py-0.5 rounded',
|
'text-[0.625rem] font-medium px-1.5 py-0.5 rounded',
|
||||||
result.type === 'CHAN'
|
result.type === 'CHAN'
|
||||||
? 'bg-primary/20 text-primary'
|
? 'bg-primary/20 text-primary'
|
||||||
: 'bg-secondary text-secondary-foreground'
|
: 'bg-secondary text-secondary-foreground'
|
||||||
@@ -298,12 +298,12 @@ export function SearchView({
|
|||||||
>
|
>
|
||||||
{typeBadge}
|
{typeBadge}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[12px] font-medium text-foreground truncate">{convName}</span>
|
<span className="text-xs font-medium text-foreground truncate">{convName}</span>
|
||||||
<span className="text-[11px] text-muted-foreground ml-auto flex-shrink-0">
|
<span className="text-[0.6875rem] text-muted-foreground ml-auto flex-shrink-0">
|
||||||
{formatTime(result.received_at)}
|
{formatTime(result.received_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[13px] text-foreground/80 line-clamp-2 break-words">
|
<div className="text-[0.8125rem] text-foreground/80 line-clamp-2 break-words">
|
||||||
{result.sender_name && !result.outgoing && (
|
{result.sender_name && !result.outgoing && (
|
||||||
<span className="text-muted-foreground">{result.sender_name}: </span>
|
<span className="text-muted-foreground">{result.sender_name}: </span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect, type ReactNode } from 'react';
|
|||||||
import type {
|
import type {
|
||||||
AppSettings,
|
AppSettings,
|
||||||
AppSettingsUpdate,
|
AppSettingsUpdate,
|
||||||
|
Contact,
|
||||||
HealthStatus,
|
HealthStatus,
|
||||||
RadioAdvertMode,
|
RadioAdvertMode,
|
||||||
RadioConfig,
|
RadioConfig,
|
||||||
@@ -47,6 +48,10 @@ interface SettingsModalBaseProps {
|
|||||||
blockedNames?: string[];
|
blockedNames?: string[];
|
||||||
onToggleBlockedKey?: (key: string) => void;
|
onToggleBlockedKey?: (key: string) => void;
|
||||||
onToggleBlockedName?: (name: string) => void;
|
onToggleBlockedName?: (name: string) => void;
|
||||||
|
contacts?: Contact[];
|
||||||
|
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
|
||||||
|
trackedTelemetryRepeaters?: string[];
|
||||||
|
onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SettingsModalProps = SettingsModalBaseProps &
|
export type SettingsModalProps = SettingsModalBaseProps &
|
||||||
@@ -80,6 +85,10 @@ export function SettingsModal(props: SettingsModalProps) {
|
|||||||
blockedNames,
|
blockedNames,
|
||||||
onToggleBlockedKey,
|
onToggleBlockedKey,
|
||||||
onToggleBlockedName,
|
onToggleBlockedName,
|
||||||
|
contacts,
|
||||||
|
onBulkDeleteContacts,
|
||||||
|
trackedTelemetryRepeaters,
|
||||||
|
onToggleTrackedTelemetry,
|
||||||
} = props;
|
} = props;
|
||||||
const externalSidebarNav = props.externalSidebarNav === true;
|
const externalSidebarNav = props.externalSidebarNav === true;
|
||||||
const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined;
|
const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined;
|
||||||
@@ -239,6 +248,10 @@ export function SettingsModal(props: SettingsModalProps) {
|
|||||||
blockedNames={blockedNames}
|
blockedNames={blockedNames}
|
||||||
onToggleBlockedKey={onToggleBlockedKey}
|
onToggleBlockedKey={onToggleBlockedKey}
|
||||||
onToggleBlockedName={onToggleBlockedName}
|
onToggleBlockedName={onToggleBlockedName}
|
||||||
|
contacts={contacts}
|
||||||
|
onBulkDeleteContacts={onBulkDeleteContacts}
|
||||||
|
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
|
||||||
|
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
|
||||||
className={sectionContentClass}
|
className={sectionContentClass}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ interface SidebarProps {
|
|||||||
channels: Channel[];
|
channels: Channel[];
|
||||||
activeConversation: Conversation | null;
|
activeConversation: Conversation | null;
|
||||||
onSelectConversation: (conversation: Conversation) => void;
|
onSelectConversation: (conversation: Conversation) => void;
|
||||||
onNewMessage: () => void;
|
onNewMessage: (event?: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
lastMessageTimes: ConversationTimes;
|
lastMessageTimes: ConversationTimes;
|
||||||
unreadCounts: Record<string, number>;
|
unreadCounts: Record<string, number>;
|
||||||
/** Tracks which conversations have unread messages that mention the user */
|
/** Tracks which conversations have unread messages that mention the user */
|
||||||
@@ -107,34 +107,19 @@ interface SidebarProps {
|
|||||||
onToggleCracker: () => void;
|
onToggleCracker: () => void;
|
||||||
onMarkAllRead: () => void;
|
onMarkAllRead: () => void;
|
||||||
favorites: Favorite[];
|
favorites: Favorite[];
|
||||||
/** Legacy global sort order, used only to seed per-section local preferences. */
|
|
||||||
legacySortOrder?: SortOrder;
|
|
||||||
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
|
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
|
||||||
|
blockedKeys?: string[];
|
||||||
|
blockedNames?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type InitialSectionSortState = {
|
function loadInitialSectionSortOrders(): SidebarSectionSortOrders {
|
||||||
orders: SidebarSectionSortOrders;
|
|
||||||
source: 'section' | 'legacy' | 'none';
|
|
||||||
};
|
|
||||||
|
|
||||||
function loadInitialSectionSortOrders(): InitialSectionSortState {
|
|
||||||
const storedOrders = loadLocalStorageSidebarSectionSortOrders();
|
const storedOrders = loadLocalStorageSidebarSectionSortOrders();
|
||||||
if (storedOrders) {
|
if (storedOrders) return storedOrders;
|
||||||
return { orders: storedOrders, source: 'section' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const legacyOrder = loadLegacyLocalStorageSortOrder();
|
const legacyOrder = loadLegacyLocalStorageSortOrder();
|
||||||
if (legacyOrder) {
|
const orders = buildSidebarSectionSortOrders(legacyOrder ?? undefined);
|
||||||
return {
|
saveLocalStorageSidebarSectionSortOrders(orders);
|
||||||
orders: buildSidebarSectionSortOrders(legacyOrder),
|
return orders;
|
||||||
source: 'legacy',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
orders: buildSidebarSectionSortOrders(),
|
|
||||||
source: 'none',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({
|
export function Sidebar({
|
||||||
@@ -151,12 +136,20 @@ export function Sidebar({
|
|||||||
onToggleCracker,
|
onToggleCracker,
|
||||||
onMarkAllRead,
|
onMarkAllRead,
|
||||||
favorites,
|
favorites,
|
||||||
legacySortOrder,
|
|
||||||
isConversationNotificationsEnabled,
|
isConversationNotificationsEnabled,
|
||||||
|
blockedKeys = [],
|
||||||
|
blockedNames = [],
|
||||||
}: SidebarProps) {
|
}: 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 [searchQuery, setSearchQuery] = useState('');
|
||||||
const initialSectionSortState = useMemo(loadInitialSectionSortOrders, []);
|
const initialSectionSortOrders = useMemo(loadInitialSectionSortOrders, []);
|
||||||
const [sectionSortOrders, setSectionSortOrders] = useState(initialSectionSortState.orders);
|
const [sectionSortOrders, setSectionSortOrders] = useState(initialSectionSortOrders);
|
||||||
const initialCollapsedState = useMemo(loadCollapsedState, []);
|
const initialCollapsedState = useMemo(loadCollapsedState, []);
|
||||||
const [toolsCollapsed, setToolsCollapsed] = useState(initialCollapsedState.tools);
|
const [toolsCollapsed, setToolsCollapsed] = useState(initialCollapsedState.tools);
|
||||||
const [favoritesCollapsed, setFavoritesCollapsed] = useState(initialCollapsedState.favorites);
|
const [favoritesCollapsed, setFavoritesCollapsed] = useState(initialCollapsedState.favorites);
|
||||||
@@ -165,29 +158,12 @@ export function Sidebar({
|
|||||||
const [roomsCollapsed, setRoomsCollapsed] = useState(initialCollapsedState.rooms);
|
const [roomsCollapsed, setRoomsCollapsed] = useState(initialCollapsedState.rooms);
|
||||||
const [repeatersCollapsed, setRepeatersCollapsed] = useState(initialCollapsedState.repeaters);
|
const [repeatersCollapsed, setRepeatersCollapsed] = useState(initialCollapsedState.repeaters);
|
||||||
const collapseSnapshotRef = useRef<CollapseState | null>(null);
|
const collapseSnapshotRef = useRef<CollapseState | null>(null);
|
||||||
const sectionSortSourceRef = useRef(initialSectionSortState.source);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (sectionSortSourceRef.current === 'legacy') {
|
|
||||||
saveLocalStorageSidebarSectionSortOrders(sectionSortOrders);
|
|
||||||
sectionSortSourceRef.current = 'section';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sectionSortSourceRef.current !== 'none' || legacySortOrder === undefined) return;
|
|
||||||
|
|
||||||
const seededOrders = buildSidebarSectionSortOrders(legacySortOrder);
|
|
||||||
setSectionSortOrders(seededOrders);
|
|
||||||
saveLocalStorageSidebarSectionSortOrders(seededOrders);
|
|
||||||
sectionSortSourceRef.current = 'section';
|
|
||||||
}, [legacySortOrder, sectionSortOrders]);
|
|
||||||
|
|
||||||
const handleSortToggle = (section: SidebarSortableSection) => {
|
const handleSortToggle = (section: SidebarSortableSection) => {
|
||||||
setSectionSortOrders((prev) => {
|
setSectionSortOrders((prev) => {
|
||||||
const nextOrder = prev[section] === 'alpha' ? 'recent' : 'alpha';
|
const nextOrder = prev[section] === 'alpha' ? 'recent' : 'alpha';
|
||||||
const updated = { ...prev, [section]: nextOrder };
|
const updated = { ...prev, [section]: nextOrder };
|
||||||
saveLocalStorageSidebarSectionSortOrders(updated);
|
saveLocalStorageSidebarSectionSortOrders(updated);
|
||||||
sectionSortSourceRef.current = 'section';
|
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -398,38 +374,32 @@ export function Sidebar({
|
|||||||
[sortedChannels, query]
|
[sortedChannels, query]
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredNonRepeaterContacts = useMemo(
|
const filteredNonRepeaterContacts = useMemo(() => {
|
||||||
() =>
|
const visible = sortedNonRepeaterContacts.filter((c) => !isContactBlocked(c));
|
||||||
query
|
return query
|
||||||
? sortedNonRepeaterContacts.filter(
|
? visible.filter(
|
||||||
(c) =>
|
(c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
||||||
c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
)
|
||||||
)
|
: visible;
|
||||||
: sortedNonRepeaterContacts,
|
}, [sortedNonRepeaterContacts, query, isContactBlocked]);
|
||||||
[sortedNonRepeaterContacts, query]
|
|
||||||
);
|
|
||||||
|
|
||||||
const filteredRooms = useMemo(
|
const filteredRooms = useMemo(() => {
|
||||||
() =>
|
const visible = sortedRooms.filter((c) => !isContactBlocked(c));
|
||||||
query
|
return query
|
||||||
? sortedRooms.filter(
|
? visible.filter(
|
||||||
(c) =>
|
(c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
||||||
c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
)
|
||||||
)
|
: visible;
|
||||||
: sortedRooms,
|
}, [sortedRooms, query, isContactBlocked]);
|
||||||
[sortedRooms, query]
|
|
||||||
);
|
|
||||||
|
|
||||||
const filteredRepeaters = useMemo(
|
const filteredRepeaters = useMemo(() => {
|
||||||
() =>
|
const visible = sortedRepeaters.filter((c) => !isContactBlocked(c));
|
||||||
query
|
return query
|
||||||
? sortedRepeaters.filter(
|
? visible.filter(
|
||||||
(c) =>
|
(c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
||||||
c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
)
|
||||||
)
|
: visible;
|
||||||
: sortedRepeaters,
|
}, [sortedRepeaters, query, isContactBlocked]);
|
||||||
[sortedRepeaters, query]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Expand sections while searching; restore prior collapse state when search ends.
|
// Expand sections while searching; restore prior collapse state when search ends.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -614,7 +584,7 @@ export function Sidebar({
|
|||||||
contactType={row.contact.type}
|
contactType={row.contact.type}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span className="name flex-1 truncate text-[13px]">{row.name}</span>
|
<span className="name flex-1 truncate text-[0.8125rem]">{row.name}</span>
|
||||||
<span className="ml-auto flex items-center gap-1">
|
<span className="ml-auto flex items-center gap-1">
|
||||||
{row.notificationsEnabled && (
|
{row.notificationsEnabled && (
|
||||||
<span aria-label="Notifications enabled" title="Notifications enabled">
|
<span aria-label="Notifications enabled" title="Notifications enabled">
|
||||||
@@ -624,7 +594,7 @@ export function Sidebar({
|
|||||||
{row.unreadCount > 0 && (
|
{row.unreadCount > 0 && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
|
'text-[0.625rem] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
|
||||||
highlightUnread
|
highlightUnread
|
||||||
? 'bg-badge-mention text-badge-mention-foreground'
|
? 'bg-badge-mention text-badge-mention-foreground'
|
||||||
: 'bg-badge-unread/90 text-badge-unread-foreground'
|
: 'bg-badge-unread/90 text-badge-unread-foreground'
|
||||||
@@ -654,8 +624,9 @@ export function Sidebar({
|
|||||||
}) => (
|
}) => (
|
||||||
<div
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
|
data-active={active ? 'true' : undefined}
|
||||||
className={cn(
|
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'
|
active && 'bg-accent border-l-primary'
|
||||||
)}
|
)}
|
||||||
role="button"
|
role="button"
|
||||||
@@ -664,10 +635,10 @@ export function Sidebar({
|
|||||||
onKeyDown={handleKeyboardActivate}
|
onKeyDown={handleKeyboardActivate}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<span className="sidebar-tool-icon text-muted-foreground" aria-hidden="true">
|
<span className="sidebar-tool-icon" aria-hidden="true">
|
||||||
{icon}
|
{icon}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -764,7 +735,7 @@ export function Sidebar({
|
|||||||
{showCracker ? 'Hide' : 'Show'} Channel Finder
|
{showCracker ? 'Hide' : 'Show'} Channel Finder
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'ml-1 text-[11px]',
|
'ml-1 text-[0.6875rem]',
|
||||||
crackerRunning ? 'text-primary' : 'text-muted-foreground'
|
crackerRunning ? 'text-primary' : 'text-muted-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -792,7 +763,7 @@ export function Sidebar({
|
|||||||
<div className="flex justify-between items-center px-3 py-2 pt-3.5">
|
<div className="flex justify-between items-center px-3 py-2 pt-3.5">
|
||||||
<button
|
<button
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded',
|
'flex items-center gap-1.5 text-[0.625rem] uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded',
|
||||||
isSearching && 'cursor-default'
|
isSearching && 'cursor-default'
|
||||||
)}
|
)}
|
||||||
aria-expanded={!effectiveCollapsed}
|
aria-expanded={!effectiveCollapsed}
|
||||||
@@ -812,7 +783,7 @@ export function Sidebar({
|
|||||||
<div className="ml-auto flex items-center gap-1.5">
|
<div className="ml-auto flex items-center gap-1.5">
|
||||||
{sortSection && sectionSortOrder && (
|
{sortSection && sectionSortOrder && (
|
||||||
<button
|
<button
|
||||||
className="bg-transparent text-muted-foreground/60 px-1 py-0.5 text-[10px] rounded hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="bg-transparent text-muted-foreground/60 px-1 py-0.5 text-[0.625rem] rounded hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
onClick={() => handleSortToggle(sortSection)}
|
onClick={() => handleSortToggle(sortSection)}
|
||||||
aria-label={
|
aria-label={
|
||||||
sectionSortOrder === 'alpha'
|
sectionSortOrder === 'alpha'
|
||||||
@@ -831,7 +802,7 @@ export function Sidebar({
|
|||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-[10px] font-medium px-1.5 py-0.5 rounded-full',
|
'text-[0.625rem] font-medium px-1.5 py-0.5 rounded-full',
|
||||||
highlightUnread
|
highlightUnread
|
||||||
? 'bg-badge-mention text-badge-mention-foreground'
|
? 'bg-badge-mention text-badge-mention-foreground'
|
||||||
: 'bg-secondary text-muted-foreground'
|
: 'bg-secondary text-muted-foreground'
|
||||||
@@ -860,7 +831,7 @@ export function Sidebar({
|
|||||||
onClick={onNewMessage}
|
onClick={onNewMessage}
|
||||||
title="Add channel or contact"
|
title="Add channel or contact"
|
||||||
aria-label="Add channel or contact"
|
aria-label="Add channel or contact"
|
||||||
className="h-8 w-full justify-start gap-2 border-primary/20 bg-primary/5 px-3 text-[13px] text-primary hover:bg-primary/10 hover:text-primary"
|
className="h-8 w-full justify-start gap-2 border-primary/20 bg-primary/5 px-3 text-[0.8125rem] text-primary hover:bg-primary/10 hover:text-primary"
|
||||||
>
|
>
|
||||||
<SquarePen className="h-4 w-4" />
|
<SquarePen className="h-4 w-4" />
|
||||||
<span>Add Channel/Contact</span>
|
<span>Add Channel/Contact</span>
|
||||||
@@ -877,7 +848,7 @@ export function Sidebar({
|
|||||||
aria-label="Search conversations"
|
aria-label="Search conversations"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className={cn('h-7 text-[13px] bg-background/50', searchQuery ? 'pr-8' : 'pr-3')}
|
className={cn('h-7 text-[0.8125rem] bg-background/50', searchQuery ? 'pr-8' : 'pr-3')}
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<button
|
<button
|
||||||
@@ -903,7 +874,7 @@ export function Sidebar({
|
|||||||
{/* Mark All Read */}
|
{/* Mark All Read */}
|
||||||
{!query && Object.values(unreadCounts).some((c) => c > 0) && (
|
{!query && Object.values(unreadCounts).some((c) => c > 0) && (
|
||||||
<div
|
<div
|
||||||
className="px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[0.8125rem] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={handleKeyboardActivate}
|
onKeyDown={handleKeyboardActivate}
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export function StatusBar({
|
|||||||
<div className="hidden lg:flex items-center gap-2 text-muted-foreground">
|
<div className="hidden lg:flex items-center gap-2 text-muted-foreground">
|
||||||
<span className="text-foreground font-medium">{config.name || 'Unnamed'}</span>
|
<span className="text-foreground font-medium">{config.name || 'Unnamed'}</span>
|
||||||
<span
|
<span
|
||||||
className="font-mono text-[11px] text-muted-foreground cursor-pointer hover:text-primary transition-colors"
|
className="font-mono text-[0.6875rem] text-muted-foreground cursor-pointer hover:text-primary transition-colors"
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={handleKeyboardActivate}
|
onKeyDown={handleKeyboardActivate}
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ function TraceNodeRow({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-9 w-9 items-center justify-center rounded-full border text-[11px] font-semibold uppercase tracking-wide',
|
'flex h-9 w-9 items-center justify-center rounded-full border text-[0.6875rem] font-semibold uppercase tracking-wide',
|
||||||
fixed
|
fixed
|
||||||
? 'border-primary/30 bg-primary/10 text-primary'
|
? 'border-primary/30 bg-primary/10 text-primary'
|
||||||
: 'border-border bg-muted text-muted-foreground'
|
: 'border-border bg-muted text-muted-foreground'
|
||||||
@@ -129,12 +129,12 @@ function TraceNodeRow({
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="truncate text-sm font-medium">{title}</div>
|
<div className="truncate text-sm font-medium">{title}</div>
|
||||||
<div className="truncate text-xs text-muted-foreground">{subtitle}</div>
|
<div className="truncate text-xs text-muted-foreground">{subtitle}</div>
|
||||||
{meta ? <div className="mt-1 text-[11px] text-muted-foreground">{meta}</div> : null}
|
{meta ? <div className="mt-1 text-[0.6875rem] text-muted-foreground">{meta}</div> : null}
|
||||||
{note ? <div className="mt-1 text-[11px] text-muted-foreground">{note}</div> : null}
|
{note ? <div className="mt-1 text-[0.6875rem] text-muted-foreground">{note}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
{snr ? (
|
{snr ? (
|
||||||
<div className="shrink-0 text-right">
|
<div className="shrink-0 text-right">
|
||||||
<div className="text-[11px] text-muted-foreground">SNR</div>
|
<div className="text-[0.6875rem] text-muted-foreground">SNR</div>
|
||||||
<div className="font-mono text-sm">{snr}</div>
|
<div className="font-mono text-sm">{snr}</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -370,7 +370,7 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{sortMode === 'distance' && !canSortByDistance ? (
|
{sortMode === 'distance' && !canSortByDistance ? (
|
||||||
<p className="mt-2 text-[11px] text-muted-foreground">
|
<p className="mt-2 text-[0.6875rem] text-muted-foreground">
|
||||||
Distance sorting is using known repeater coordinates, but the local radio does not
|
Distance sorting is using known repeater coordinates, but the local radio does not
|
||||||
currently have a valid location.
|
currently have a valid location.
|
||||||
</p>
|
</p>
|
||||||
@@ -421,12 +421,12 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
|
|||||||
{getShortKey(contact.public_key)}
|
{getShortKey(contact.public_key)}
|
||||||
</div>
|
</div>
|
||||||
{sortMode === 'distance' && distanceKm !== null ? (
|
{sortMode === 'distance' && distanceKm !== null ? (
|
||||||
<div className="mt-1 text-[11px] text-muted-foreground">
|
<div className="mt-1 text-[0.6875rem] text-muted-foreground">
|
||||||
{distanceKm.toFixed(1)} km away
|
{distanceKm.toFixed(1)} km away
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{selectedCount > 0 ? (
|
{selectedCount > 0 ? (
|
||||||
<div className="mt-1 text-[11px] text-muted-foreground">
|
<div className="mt-1 text-[0.6875rem] text-muted-foreground">
|
||||||
Added {selectedCount} time{selectedCount === 1 ? '' : 's'}
|
Added {selectedCount} time{selectedCount === 1 ? '' : 's'}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -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="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h3 className="text-sm font-medium">{title}</h3>
|
<h3 className="text-sm font-medium">{title}</h3>
|
||||||
{headerNote && <p className="text-[11px] text-muted-foreground">{headerNote}</p>}
|
{headerNote && <p className="text-[0.6875rem] text-muted-foreground">{headerNote}</p>}
|
||||||
{fetchedAt && (
|
{fetchedAt && (
|
||||||
<p
|
<p
|
||||||
className="text-[11px] text-muted-foreground"
|
className="text-[0.6875rem] text-muted-foreground"
|
||||||
title={new Date(fetchedAt).toLocaleString()}
|
title={new Date(fetchedAt).toLocaleString()}
|
||||||
>
|
>
|
||||||
Fetched {formatFetchedTime(fetchedAt)} ({formatFetchedRelative(fetchedAt)})
|
Fetched {formatFetchedTime(fetchedAt)} ({formatFetchedRelative(fetchedAt)})
|
||||||
|
|||||||
@@ -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 { toast } from '../ui/sonner';
|
||||||
import { api } from '../../api';
|
import { api } from '../../api';
|
||||||
import { formatTime } from '../../utils/messageParser';
|
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({
|
export function SettingsDatabaseSection({
|
||||||
appSettings,
|
appSettings,
|
||||||
@@ -17,6 +18,10 @@ export function SettingsDatabaseSection({
|
|||||||
blockedNames = [],
|
blockedNames = [],
|
||||||
onToggleBlockedKey,
|
onToggleBlockedKey,
|
||||||
onToggleBlockedName,
|
onToggleBlockedName,
|
||||||
|
contacts = [],
|
||||||
|
onBulkDeleteContacts,
|
||||||
|
trackedTelemetryRepeaters = [],
|
||||||
|
onToggleTrackedTelemetry,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
appSettings: AppSettings;
|
appSettings: AppSettings;
|
||||||
@@ -27,18 +32,25 @@ export function SettingsDatabaseSection({
|
|||||||
blockedNames?: string[];
|
blockedNames?: string[];
|
||||||
onToggleBlockedKey?: (key: string) => void;
|
onToggleBlockedKey?: (key: string) => void;
|
||||||
onToggleBlockedName?: (name: string) => void;
|
onToggleBlockedName?: (name: string) => void;
|
||||||
|
contacts?: Contact[];
|
||||||
|
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
|
||||||
|
trackedTelemetryRepeaters?: string[];
|
||||||
|
onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const [retentionDays, setRetentionDays] = useState('14');
|
const [retentionDays, setRetentionDays] = useState('14');
|
||||||
const [cleaning, setCleaning] = useState(false);
|
const [cleaning, setCleaning] = useState(false);
|
||||||
const [purgingDecryptedRaw, setPurgingDecryptedRaw] = useState(false);
|
const [purgingDecryptedRaw, setPurgingDecryptedRaw] = useState(false);
|
||||||
const [autoDecryptOnAdvert, setAutoDecryptOnAdvert] = useState(false);
|
const [autoDecryptOnAdvert, setAutoDecryptOnAdvert] = useState(false);
|
||||||
|
const [discoveryBlockedTypes, setDiscoveryBlockedTypes] = useState<number[]>([]);
|
||||||
|
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
|
||||||
|
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert);
|
setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert);
|
||||||
|
setDiscoveryBlockedTypes(appSettings.discovery_blocked_types ?? []);
|
||||||
}, [appSettings]);
|
}, [appSettings]);
|
||||||
|
|
||||||
const handleCleanup = async () => {
|
const handleCleanup = async () => {
|
||||||
@@ -92,7 +104,15 @@ export function SettingsDatabaseSection({
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
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');
|
toast.success('Database settings saved');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to save database settings:', err);
|
console.error('Failed to save database settings:', err);
|
||||||
@@ -105,93 +125,93 @@ export function SettingsDatabaseSection({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
|
{/* ── Database Overview ── */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex justify-between items-center">
|
<Label className="text-base">Database Overview</Label>
|
||||||
<span className="text-sm text-muted-foreground">Database size</span>
|
<div className="rounded-md border border-border bg-muted/30 p-3 space-y-2">
|
||||||
<span className="font-medium">{health?.database_size_mb ?? '?'} MB</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{health?.oldest_undecrypted_timestamp ? (
|
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-sm text-muted-foreground">Oldest undecrypted packet</span>
|
<span className="text-sm">Database size</span>
|
||||||
<span className="font-medium">
|
<span className="text-sm font-semibold">{health?.database_size_mb ?? '?'} MB</span>
|
||||||
{formatTime(health.oldest_undecrypted_timestamp)}
|
</div>
|
||||||
<span className="text-muted-foreground ml-1">
|
<div className="flex justify-between items-center">
|
||||||
({Math.floor((Date.now() / 1000 - health.oldest_undecrypted_timestamp) / 86400)}{' '}
|
<span className="text-sm">Oldest undecrypted packet</span>
|
||||||
days old)
|
{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>
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground">None</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</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 />
|
<Separator />
|
||||||
|
|
||||||
<div className="space-y-3">
|
{/* ── Storage Cleanup ── */}
|
||||||
<Label>Delete Undecrypted Packets</Label>
|
<div className="space-y-4">
|
||||||
<p className="text-xs text-muted-foreground">
|
<Label className="text-base">Storage Cleanup</Label>
|
||||||
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 —
|
<div className="rounded-md border border-border p-3 space-y-2">
|
||||||
once deleted, these messages can never be recovered or decrypted.
|
<Label className="text-sm">Delete Undecrypted Packets</Label>
|
||||||
</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
<div className="flex gap-2 items-end">
|
Permanently deletes stored raw packets that have not yet been decrypted. These are
|
||||||
<div className="space-y-1">
|
retained in case you later obtain the correct key — once deleted, these messages can
|
||||||
<Label htmlFor="retention-days" className="text-xs">
|
never be recovered.
|
||||||
Older than (days)
|
</p>
|
||||||
</Label>
|
<div className="flex gap-2 items-end">
|
||||||
<Input
|
<div className="space-y-1">
|
||||||
id="retention-days"
|
<Label htmlFor="retention-days" className="text-xs text-muted-foreground">
|
||||||
type="number"
|
Older than (days)
|
||||||
min="1"
|
</Label>
|
||||||
max="365"
|
<Input
|
||||||
value={retentionDays}
|
id="retention-days"
|
||||||
onChange={(e) => setRetentionDays(e.target.value)}
|
type="number"
|
||||||
className="w-24"
|
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>
|
||||||
|
|
||||||
|
<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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleCleanup}
|
onClick={handlePurgeDecryptedRawPackets}
|
||||||
disabled={cleaning}
|
disabled={purgingDecryptedRaw}
|
||||||
className="border-destructive/50 text-destructive hover:bg-destructive/10"
|
className="w-full border-warning/50 text-warning hover:bg-warning/10"
|
||||||
>
|
>
|
||||||
{cleaning ? 'Deleting...' : 'Permanently Delete'}
|
{purgingDecryptedRaw ? 'Purging...' : 'Purge Archival Packets'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
{/* ── DM Decryption ── */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>Purge Archival Raw Packets</Label>
|
<Label className="text-base">DM Decryption</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="flex items-center gap-3 cursor-pointer">
|
<label className="flex items-center gap-3 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -209,15 +229,129 @@ export function SettingsDatabaseSection({
|
|||||||
|
|
||||||
<Separator />
|
<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">
|
<div className="space-y-3">
|
||||||
<Label>Blocked Contacts</Label>
|
<Label>Blocked Contacts</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Blocking only hides messages from the UI. MQTT forwarding and bot responses are not
|
Blocked contacts are hidden from the sidebar. Blocking only hides messages from the UI —
|
||||||
affected. Messages are still stored and will reappear if unblocked.
|
MQTT forwarding and bot responses are not affected. Messages are still stored and will
|
||||||
|
reappear if unblocked.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{blockedKeys.length === 0 && blockedNames.length === 0 ? (
|
{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">
|
<div className="space-y-2">
|
||||||
{blockedKeys.length > 0 && (
|
{blockedKeys.length > 0 && (
|
||||||
@@ -268,15 +402,25 @@ export function SettingsDatabaseSection({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
<Separator />
|
||||||
<div className="text-sm text-destructive" role="alert">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button onClick={handleSave} disabled={busy} className="w-full">
|
{/* Bulk delete */}
|
||||||
{busy ? 'Saving...' : 'Save Settings'}
|
<div className="space-y-3">
|
||||||
</Button>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -537,7 +537,7 @@ function CreateIntegrationDialog({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{sectionedOptions.map((group) => (
|
{sectionedOptions.map((group) => (
|
||||||
<div key={group.section} className="space-y-1.5">
|
<div key={group.section} className="space-y-1.5">
|
||||||
<div className="px-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
<div className="px-2 text-[0.6875rem] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
{group.section}
|
{group.section}
|
||||||
</div>
|
</div>
|
||||||
{group.options.map((option) => {
|
{group.options.map((option) => {
|
||||||
@@ -577,7 +577,7 @@ function CreateIntegrationDialog({
|
|||||||
{selectedOption ? (
|
{selectedOption ? (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
{selectedOption.section}
|
{selectedOption.section}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold">{selectedOption.label}</h3>
|
<h3 className="text-lg font-semibold">{selectedOption.label}</h3>
|
||||||
@@ -643,16 +643,20 @@ function formatPrivateTopicSummary(config: Record<string, unknown>) {
|
|||||||
return `${prefix}/dm:<pubkey>, ${prefix}/gm:<channel>, ${prefix}/raw/...`;
|
return `${prefix}/dm:<pubkey>, ${prefix}/gm:<channel>, ${prefix}/raw/...`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatAppriseTargets(urls: string | undefined, maxLength = 80) {
|
function censorAppriseUrl(url: string): string {
|
||||||
|
const protoMatch = url.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//);
|
||||||
|
if (protoMatch) return `${protoMatch[0]}********`;
|
||||||
|
return '********';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAppriseTargets(urls: string | undefined) {
|
||||||
const targets = (urls || '')
|
const targets = (urls || '')
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
if (targets.length === 0) return 'No targets configured';
|
if (targets.length === 0) return 'No targets configured';
|
||||||
|
|
||||||
const joined = targets.join(', ');
|
return targets.map(censorAppriseUrl).join(', ');
|
||||||
if (joined.length <= maxLength) return joined;
|
|
||||||
return `${joined.slice(0, maxLength - 3)}...`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSqsQueueSummary(config: Record<string, unknown>) {
|
function formatSqsQueueSummary(config: Record<string, unknown>) {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Logs, MessageSquare } from 'lucide-react';
|
import { ChevronRight, Logs, MessageSquare, Send, Settings } from 'lucide-react';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
import { Input } from '../ui/input';
|
import { Input } from '../ui/input';
|
||||||
import { Label } from '../ui/label';
|
import { Label } from '../ui/label';
|
||||||
import { Separator } from '../ui/separator';
|
import { Separator } from '../ui/separator';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
import { ContactAvatar } from '../ContactAvatar';
|
import { ContactAvatar } from '../ContactAvatar';
|
||||||
import {
|
import {
|
||||||
captureLastViewedConversationFromHash,
|
captureLastViewedConversationFromHash,
|
||||||
@@ -37,6 +39,13 @@ export function SettingsLocalSection({
|
|||||||
const [reopenLastConversation, setReopenLastConversation] = useState(
|
const [reopenLastConversation, setReopenLastConversation] = useState(
|
||||||
getReopenLastConversationEnabled
|
getReopenLastConversationEnabled
|
||||||
);
|
);
|
||||||
|
const [darkMap, setDarkMap] = useState(() => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem('remoteterm-dark-map') === 'true';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
const [localLabelText, setLocalLabelText] = useState(() => getLocalLabel().text);
|
const [localLabelText, setLocalLabelText] = useState(() => getLocalLabel().text);
|
||||||
const [localLabelColor, setLocalLabelColor] = useState(() => getLocalLabel().color);
|
const [localLabelColor, setLocalLabelColor] = useState(() => getLocalLabel().color);
|
||||||
const [fontScale, setFontScale] = useState(getSavedFontScale);
|
const [fontScale, setFontScale] = useState(getSavedFontScale);
|
||||||
@@ -233,11 +242,31 @@ export function SettingsLocalSection({
|
|||||||
/>
|
/>
|
||||||
<span className="text-sm">Reopen to last viewed channel/conversation</span>
|
<span className="text-sm">Reopen to last viewed channel/conversation</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={darkMap}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.checked;
|
||||||
|
setDarkMap(v);
|
||||||
|
try {
|
||||||
|
localStorage.setItem('remoteterm-dark-map', String(v));
|
||||||
|
} catch {
|
||||||
|
// localStorage may be disabled
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-4 h-4 rounded border-input accent-primary"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Dark mode map tiles</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ThemePreview({ className }: { className?: string }) {
|
function ThemePreview({ className }: { className?: string }) {
|
||||||
|
const [showStyleRef, setShowStyleRef] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-lg border border-border bg-card p-3 ${className ?? ''}`}>
|
<div className={`rounded-lg border border-border bg-card p-3 ${className ?? ''}`}>
|
||||||
<p className="text-xs text-muted-foreground mb-3">
|
<p className="text-xs text-muted-foreground mb-3">
|
||||||
@@ -271,7 +300,7 @@ function ThemePreview({ className }: { className?: string }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 rounded-md border border-border bg-background p-2">
|
<div className="mt-4 rounded-md border border-border bg-background p-2">
|
||||||
<p className="mb-2 text-[11px] font-medium text-muted-foreground">Sidebar preview</p>
|
<p className="mb-2 text-[0.6875rem] font-medium text-muted-foreground">Sidebar preview</p>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<PreviewSidebarRow
|
<PreviewSidebarRow
|
||||||
active
|
active
|
||||||
@@ -289,7 +318,7 @@ function ThemePreview({ className }: { className?: string }) {
|
|||||||
leading={<ContactAvatar name="Alice" publicKey={'ab'.repeat(32)} size={24} />}
|
leading={<ContactAvatar name="Alice" publicKey={'ab'.repeat(32)} size={24} />}
|
||||||
label="Alice"
|
label="Alice"
|
||||||
badge={
|
badge={
|
||||||
<span className="rounded-full bg-badge-unread/90 px-1.5 py-0.5 text-[10px] font-semibold text-badge-unread-foreground">
|
<span className="rounded-full bg-badge-unread/90 px-1.5 py-0.5 text-[0.625rem] font-semibold text-badge-unread-foreground">
|
||||||
3
|
3
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
@@ -298,13 +327,267 @@ function ThemePreview({ className }: { className?: string }) {
|
|||||||
leading={<ContactAvatar name="Mesh Ops" publicKey={'cd'.repeat(32)} size={24} />}
|
leading={<ContactAvatar name="Mesh Ops" publicKey={'cd'.repeat(32)} size={24} />}
|
||||||
label="Mesh Ops"
|
label="Mesh Ops"
|
||||||
badge={
|
badge={
|
||||||
<span className="rounded-full bg-badge-mention px-1.5 py-0.5 text-[10px] font-semibold text-badge-mention-foreground">
|
<span className="rounded-full bg-badge-mention px-1.5 py-0.5 text-[0.625rem] font-semibold text-badge-mention-foreground">
|
||||||
@2
|
@2
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Style Reference (collapsible) ── */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowStyleRef((v) => !v)}
|
||||||
|
className="mt-4 flex w-full items-center gap-1.5 text-[0.6875rem] font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
className={cn('h-3.5 w-3.5 transition-transform', showStyleRef && 'rotate-90')}
|
||||||
|
/>
|
||||||
|
Canonical style reference
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showStyleRef && (
|
||||||
|
<>
|
||||||
|
{/* ── Text Hierarchy ── */}
|
||||||
|
<PreviewSection title="Text hierarchy">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<PreviewTextRow
|
||||||
|
classes="text-xl font-semibold"
|
||||||
|
label="text-xl font-semibold"
|
||||||
|
desc="Hero / large data"
|
||||||
|
/>
|
||||||
|
<PreviewTextRow
|
||||||
|
classes="text-lg font-semibold"
|
||||||
|
label="text-lg font-semibold"
|
||||||
|
desc="Sheet / dialog title"
|
||||||
|
/>
|
||||||
|
<PreviewTextRow
|
||||||
|
classes="text-base font-semibold"
|
||||||
|
label="text-base font-semibold"
|
||||||
|
desc="Section title"
|
||||||
|
/>
|
||||||
|
<PreviewTextRow classes="text-sm" label="text-sm" desc="Body text, form labels" />
|
||||||
|
<PreviewTextRow
|
||||||
|
classes="text-xs text-muted-foreground"
|
||||||
|
label="text-xs text-muted-foreground"
|
||||||
|
desc="Helper text"
|
||||||
|
/>
|
||||||
|
<PreviewTextRow
|
||||||
|
classes="text-[0.6875rem] text-muted-foreground"
|
||||||
|
label="text-[0.6875rem] text-muted-foreground"
|
||||||
|
desc="Metadata, timestamps"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
|
||||||
|
Section Label
|
||||||
|
</p>
|
||||||
|
<p className="text-[0.625rem] text-muted-foreground/60 mt-0.5">
|
||||||
|
text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PreviewSection>
|
||||||
|
|
||||||
|
{/* ── Mono Text ── */}
|
||||||
|
<PreviewSection title="Mono text">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-mono text-muted-foreground">
|
||||||
|
a1b2c3d4e5f6...7890abcdef01
|
||||||
|
</p>
|
||||||
|
<p className="text-[0.625rem] text-muted-foreground/60">
|
||||||
|
text-xs font-mono — keys, identifiers
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[0.6875rem] font-mono">1h 23m 45s uptime</p>
|
||||||
|
<p className="text-[0.625rem] text-muted-foreground/60">
|
||||||
|
text-[0.6875rem] font-mono — metadata mono
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-mono">$ req_status_sync 0xA1B2...</p>
|
||||||
|
<p className="text-[0.625rem] text-muted-foreground/60">
|
||||||
|
text-sm font-mono — console / code
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PreviewSection>
|
||||||
|
|
||||||
|
{/* ── Badges ── */}
|
||||||
|
<PreviewSection title="Badges and tags">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
|
||||||
|
Hashtag
|
||||||
|
</span>
|
||||||
|
<span className="text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
|
||||||
|
Repeater
|
||||||
|
</span>
|
||||||
|
<span className="text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">
|
||||||
|
On Radio
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full bg-badge-unread/90 px-1.5 py-0.5 text-[0.625rem] font-semibold text-badge-unread-foreground">
|
||||||
|
3
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full bg-badge-mention px-1.5 py-0.5 text-[0.625rem] font-semibold text-badge-mention-foreground">
|
||||||
|
@2
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[0.625rem] text-muted-foreground/60 mt-1.5">
|
||||||
|
Muted: bg-muted · Primary: bg-primary/10 · Unread/Mention: bg-badge-*
|
||||||
|
</p>
|
||||||
|
</PreviewSection>
|
||||||
|
|
||||||
|
{/* ── Buttons ── */}
|
||||||
|
<PreviewSection title="Buttons">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-[0.625rem] text-muted-foreground/60 mb-1.5">
|
||||||
|
Standard variants (size sm)
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<Button size="sm">Default</Button>
|
||||||
|
<Button size="sm" variant="outline">
|
||||||
|
Outline
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="secondary">
|
||||||
|
Secondary
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="destructive">
|
||||||
|
Destructive
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost">
|
||||||
|
Ghost
|
||||||
|
</Button>
|
||||||
|
<Button size="icon" variant="outline">
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button size="icon" variant="outline">
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[0.625rem] text-muted-foreground/60 mb-1.5">
|
||||||
|
Semantic outline variants
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="border-destructive/50 text-destructive hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
Danger
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="border-warning/50 text-warning hover:bg-warning/10"
|
||||||
|
>
|
||||||
|
Warning
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="border-green-600/50 text-green-600 hover:bg-green-600/10"
|
||||||
|
>
|
||||||
|
Success
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[0.625rem] text-muted-foreground/60 mb-1.5">
|
||||||
|
Metric selector pills
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{['Voltage', 'Noise Floor', 'Packets'].map((label, i) => (
|
||||||
|
<button
|
||||||
|
key={label}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'text-[0.6875rem] px-2 py-0.5 rounded transition-colors',
|
||||||
|
i === 0
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PreviewSection>
|
||||||
|
|
||||||
|
{/* ── Clickable Text ── */}
|
||||||
|
<PreviewSection title="Clickable text">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
className="text-xs font-mono text-muted-foreground cursor-pointer hover:text-primary transition-colors block"
|
||||||
|
>
|
||||||
|
a1b2c3d4e5f6 (click to copy)
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
className="text-sm cursor-pointer underline underline-offset-2 decoration-muted-foreground/50 hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
Underlined navigational link
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[0.625rem] text-muted-foreground/60 mt-1.5">
|
||||||
|
cursor-pointer hover:text-primary transition-colors — use role="button" +
|
||||||
|
tabIndex
|
||||||
|
</p>
|
||||||
|
</PreviewSection>
|
||||||
|
|
||||||
|
{/* ── Inline Alerts ── */}
|
||||||
|
<PreviewSection title="Inline alerts">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="rounded-md border border-info/30 bg-info/10 px-3 py-2 text-xs text-info">
|
||||||
|
Info: channel slot cache refreshed from radio.
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-xs text-warning">
|
||||||
|
Warning: radio clock skew detected.
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||||
|
Error: post-connect setup timed out. Reboot the radio and restart.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PreviewSection>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreviewSection({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="mt-4 rounded-md border border-border bg-background p-2">
|
||||||
|
<p className="mb-2 text-[0.6875rem] font-medium text-muted-foreground">{title}</p>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreviewTextRow({
|
||||||
|
classes,
|
||||||
|
label,
|
||||||
|
desc,
|
||||||
|
}: {
|
||||||
|
classes: string;
|
||||||
|
label: string;
|
||||||
|
desc: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className={classes}>Sample text at this size</p>
|
||||||
|
<p className="text-[0.625rem] text-muted-foreground/60">
|
||||||
|
{label} — {desc}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -327,7 +610,7 @@ function PreviewMessage({
|
|||||||
return (
|
return (
|
||||||
<div className={`flex ${alignRight ? 'justify-end' : 'justify-start'}`}>
|
<div className={`flex ${alignRight ? 'justify-end' : 'justify-start'}`}>
|
||||||
<div className={`max-w-[85%] ${alignRight ? 'items-end' : 'items-start'} flex flex-col`}>
|
<div className={`max-w-[85%] ${alignRight ? 'items-end' : 'items-start'} flex flex-col`}>
|
||||||
<span className="mb-1 text-[11px] text-muted-foreground">{sender}</span>
|
<span className="mb-1 text-[0.6875rem] text-muted-foreground">{sender}</span>
|
||||||
<div className={`rounded-2xl px-3 py-2 text-sm break-words ${bubbleClassName}`}>{text}</div>
|
<div className={`rounded-2xl px-3 py-2 text-sm break-words ${bubbleClassName}`}>{text}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -347,17 +630,20 @@ function PreviewSidebarRow({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<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'
|
active ? 'border-l-primary bg-accent text-foreground' : 'border-l-transparent'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{leading}
|
<span className="sidebar-tool-icon" aria-hidden="true">
|
||||||
<span className={`min-w-0 flex-1 truncate ${active ? 'font-medium' : 'text-foreground'}`}>
|
{leading}
|
||||||
|
</span>
|
||||||
|
<span className={`sidebar-tool-label min-w-0 flex-1 truncate ${active ? 'font-medium' : ''}`}>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
{badge}
|
{badge}
|
||||||
{!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" />
|
<MessageSquare className="h-3.5 w-3.5" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -390,9 +390,9 @@ export function SettingsRadioSection({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
{/* Connection display */}
|
{/* ── Connection ── */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>Connection</Label>
|
<Label className="text-base">Connection</Label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className={`w-2 h-2 rounded-full ${
|
className={`w-2 h-2 rounded-full ${
|
||||||
@@ -428,15 +428,58 @@ export function SettingsRadioSection({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Radio Name */}
|
<Separator />
|
||||||
|
|
||||||
|
{/* ── Identity ── */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-base">Identity</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name">Radio Name</Label>
|
<Label htmlFor="name">Radio Name</Label>
|
||||||
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} />
|
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
</div>
|
</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 />
|
<Separator />
|
||||||
|
|
||||||
{/* Radio Config */}
|
{/* ── Radio Parameters ── */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-base">Radio Parameters</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="preset">Preset</Label>
|
<Label htmlFor="preset">Preset</Label>
|
||||||
<select
|
<select
|
||||||
@@ -518,11 +561,36 @@ export function SettingsRadioSection({
|
|||||||
</div>
|
</div>
|
||||||
</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 />
|
<Separator />
|
||||||
|
|
||||||
|
{/* ── Location ── */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label>Location</Label>
|
<Label className="text-base">Location</Label>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -585,53 +653,8 @@ export function SettingsRadioSection({
|
|||||||
library.
|
library.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</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 && (
|
{error && (
|
||||||
<div className="text-sm text-destructive" role="alert">
|
<div className="text-sm text-destructive" role="alert">
|
||||||
{error}
|
{error}
|
||||||
@@ -657,64 +680,48 @@ export function SettingsRadioSection({
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* Keys */}
|
{/* ── Messaging ── */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="public-key">Public Key</Label>
|
<Label className="text-base">Messaging</Label>
|
||||||
<Input id="public-key" value={config.public_key} disabled className="font-mono text-xs" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="private-key">Set Private Key (write-only)</Label>
|
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3">
|
||||||
<Input
|
<Checkbox
|
||||||
id="private-key"
|
id="multi-acks-enabled"
|
||||||
type="password"
|
checked={multiAcksEnabled}
|
||||||
autoComplete="off"
|
onCheckedChange={(checked) => setMultiAcksEnabled(checked === true)}
|
||||||
value={privateKey}
|
className="mt-0.5"
|
||||||
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"
|
|
||||||
/>
|
/>
|
||||||
<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>
|
</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>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -746,6 +753,13 @@ export function SettingsRadioSection({
|
|||||||
Configured radio contact capacity. Favorites reload first, then background maintenance
|
Configured radio contact capacity. Favorites reload first, then background maintenance
|
||||||
refills to about 80% of this value and offloads once occupancy reaches about 95%.
|
refills to about 80% of this value and offloads once occupancy reaches about 95%.
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
|
||||||
{floodError && (
|
{floodError && (
|
||||||
@@ -760,8 +774,28 @@ export function SettingsRadioSection({
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
{/* ── Advertising & Discovery ── */}
|
||||||
<div className="space-y-2">
|
<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>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
|||||||
'group-[.toaster]:bg-toast-error group-[.toaster]:text-toast-error-foreground group-[.toaster]:border-toast-error-border [&_[data-description]]:text-toast-error-foreground',
|
'group-[.toaster]:bg-toast-error group-[.toaster]:text-toast-error-foreground group-[.toaster]:border-toast-error-border [&_[data-description]]:text-toast-error-foreground',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
closeButton
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export function VisualizerControls({
|
|||||||
{PACKET_LEGEND_ITEMS.map((item) => (
|
{PACKET_LEGEND_ITEMS.map((item) => (
|
||||||
<div key={item.label} className="flex items-center gap-2">
|
<div key={item.label} className="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className="w-5 h-5 rounded-full flex items-center justify-center text-[8px] font-bold text-white"
|
className="w-5 h-5 rounded-full flex items-center justify-center text-[0.5rem] font-bold text-white"
|
||||||
style={{ backgroundColor: item.color }}
|
style={{ backgroundColor: item.color }}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
|
|||||||
@@ -2,17 +2,8 @@ import { useState, useCallback, useEffect, useRef } from 'react';
|
|||||||
import { api } from '../api';
|
import { api } from '../api';
|
||||||
import { takePrefetchOrFetch } from '../prefetch';
|
import { takePrefetchOrFetch } from '../prefetch';
|
||||||
import { toast } from '../components/ui/sonner';
|
import { toast } from '../components/ui/sonner';
|
||||||
import {
|
import { initLastMessageTimes } from '../utils/conversationState';
|
||||||
initLastMessageTimes,
|
import { isFavorite } from '../utils/favorites';
|
||||||
loadLocalStorageLastMessageTimes,
|
|
||||||
loadLocalStorageSortOrder,
|
|
||||||
clearLocalStorageConversationState,
|
|
||||||
} from '../utils/conversationState';
|
|
||||||
import {
|
|
||||||
isFavorite,
|
|
||||||
loadLocalStorageFavorites,
|
|
||||||
clearLocalStorageFavorites,
|
|
||||||
} from '../utils/favorites';
|
|
||||||
import type { AppSettings, AppSettingsUpdate, Favorite } from '../types';
|
import type { AppSettings, AppSettingsUpdate, Favorite } from '../types';
|
||||||
|
|
||||||
export function useAppSettings() {
|
export function useAppSettings() {
|
||||||
@@ -120,59 +111,68 @@ export function useAppSettings() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// One-time migration of localStorage preferences to server
|
const handleToggleTrackedTelemetry = useCallback(async (publicKey: string) => {
|
||||||
|
const key = publicKey.toLowerCase();
|
||||||
|
setAppSettings((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const current = prev.tracked_telemetry_repeaters ?? [];
|
||||||
|
const wasTracked = current.includes(key);
|
||||||
|
const optimistic = wasTracked ? current.filter((k) => k !== key) : [...current, key];
|
||||||
|
return { ...prev, tracked_telemetry_repeaters: optimistic };
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.toggleTrackedTelemetry(publicKey);
|
||||||
|
setAppSettings((prev) =>
|
||||||
|
prev ? { ...prev, tracked_telemetry_repeaters: result.tracked_telemetry_repeaters } : prev
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to toggle tracked telemetry:', err);
|
||||||
|
try {
|
||||||
|
const settings = await api.getSettings();
|
||||||
|
setAppSettings(settings);
|
||||||
|
} catch {
|
||||||
|
// If refetch also fails, leave optimistic state
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const detail = (err as any)?.body?.detail;
|
||||||
|
if (typeof detail === 'object' && detail?.message) {
|
||||||
|
toast.error(detail.message);
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to update tracked telemetry');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Legacy favorites migration: if pre-server-side favorites exist in
|
||||||
|
// localStorage, toggle each one via the existing API and clear the key.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!appSettings || hasMigratedRef.current) return;
|
if (!appSettings || hasMigratedRef.current) return;
|
||||||
|
|
||||||
if (appSettings.preferences_migrated) {
|
|
||||||
clearLocalStorageFavorites();
|
|
||||||
clearLocalStorageConversationState();
|
|
||||||
hasMigratedRef.current = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const localFavorites = loadLocalStorageFavorites();
|
|
||||||
const localSortOrder = loadLocalStorageSortOrder();
|
|
||||||
const localLastMessageTimes = loadLocalStorageLastMessageTimes();
|
|
||||||
|
|
||||||
const hasLocalData =
|
|
||||||
localFavorites.length > 0 ||
|
|
||||||
localSortOrder !== 'recent' ||
|
|
||||||
Object.keys(localLastMessageTimes).length > 0;
|
|
||||||
|
|
||||||
if (!hasLocalData) {
|
|
||||||
hasMigratedRef.current = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
hasMigratedRef.current = true;
|
hasMigratedRef.current = true;
|
||||||
|
|
||||||
const migratePreferences = async () => {
|
const FAVORITES_KEY = 'remoteterm-favorites';
|
||||||
|
let localFavorites: Favorite[] = [];
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(FAVORITES_KEY);
|
||||||
|
if (stored) localFavorites = JSON.parse(stored);
|
||||||
|
} catch {
|
||||||
|
// corrupt or unavailable
|
||||||
|
}
|
||||||
|
if (localFavorites.length === 0) return;
|
||||||
|
|
||||||
|
const migrate = async () => {
|
||||||
try {
|
try {
|
||||||
const result = await api.migratePreferences({
|
for (const f of localFavorites) {
|
||||||
favorites: localFavorites,
|
await api.toggleFavorite(f.type, f.id);
|
||||||
sort_order: localSortOrder,
|
|
||||||
last_message_times: localLastMessageTimes,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.migrated) {
|
|
||||||
toast.success('Preferences migrated', {
|
|
||||||
description: `Migrated ${localFavorites.length} favorites to server`,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
localStorage.removeItem(FAVORITES_KEY);
|
||||||
setAppSettings(result.settings);
|
await fetchAppSettings();
|
||||||
initLastMessageTimes(result.settings.last_message_times ?? {});
|
|
||||||
|
|
||||||
clearLocalStorageFavorites();
|
|
||||||
clearLocalStorageConversationState();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to migrate preferences:', err);
|
console.error('Failed to migrate legacy favorites:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
migrate();
|
||||||
migratePreferences();
|
}, [appSettings, fetchAppSettings]);
|
||||||
}, [appSettings]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appSettings,
|
appSettings,
|
||||||
@@ -182,5 +182,6 @@ export function useAppSettings() {
|
|||||||
handleToggleFavorite,
|
handleToggleFavorite,
|
||||||
handleToggleBlockedKey,
|
handleToggleBlockedKey,
|
||||||
handleToggleBlockedName,
|
handleToggleBlockedName,
|
||||||
|
handleToggleTrackedTelemetry,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { takePrefetchOrFetch } from '../prefetch';
|
|||||||
import { toast } from '../components/ui/sonner';
|
import { toast } from '../components/ui/sonner';
|
||||||
import { getContactDisplayName } from '../utils/pubkey';
|
import { getContactDisplayName } from '../utils/pubkey';
|
||||||
import { findPublicChannel, PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME } from '../utils/publicChannel';
|
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 {
|
interface UseContactsAndChannelsArgs {
|
||||||
setActiveConversation: (conv: Conversation | null) => void;
|
setActiveConversation: (conv: Conversation | null) => void;
|
||||||
@@ -112,6 +112,24 @@ export function useContactsAndChannels({
|
|||||||
[fetchUndecryptedCountInternal, setActiveConversation]
|
[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(
|
const handleDeleteChannel = useCallback(
|
||||||
async (key: string) => {
|
async (key: string) => {
|
||||||
if (!confirm('Delete this channel? Message history will be preserved.')) return;
|
if (!confirm('Delete this channel? Message history will be preserved.')) return;
|
||||||
@@ -190,6 +208,7 @@ export function useContactsAndChannels({
|
|||||||
handleCreateContact,
|
handleCreateContact,
|
||||||
handleCreateChannel,
|
handleCreateChannel,
|
||||||
handleCreateHashtagChannel,
|
handleCreateHashtagChannel,
|
||||||
|
handleBulkCreateHashtagChannels,
|
||||||
handleDeleteChannel,
|
handleDeleteChannel,
|
||||||
handleDeleteContact,
|
handleDeleteContact,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ interface UseConversationActionsResult {
|
|||||||
channelKey: string,
|
channelKey: string,
|
||||||
floodScopeOverride: string
|
floodScopeOverride: string
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
handleSetChannelPathHashModeOverride: (
|
||||||
|
channelKey: string,
|
||||||
|
pathHashModeOverride: number | null
|
||||||
|
) => Promise<void>;
|
||||||
handleSenderClick: (sender: string) => void;
|
handleSenderClick: (sender: string) => void;
|
||||||
handleTrace: () => Promise<void>;
|
handleTrace: () => Promise<void>;
|
||||||
handlePathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
|
handlePathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
|
||||||
@@ -106,6 +110,25 @@ export function useConversationActions({
|
|||||||
[mergeChannelIntoList]
|
[mergeChannelIntoList]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleSetChannelPathHashModeOverride = useCallback(
|
||||||
|
async (channelKey: string, pathHashModeOverride: number | null) => {
|
||||||
|
try {
|
||||||
|
const updated = await api.setChannelPathHashModeOverride(channelKey, pathHashModeOverride);
|
||||||
|
mergeChannelIntoList(updated);
|
||||||
|
toast.success(
|
||||||
|
updated.path_hash_mode_override != null
|
||||||
|
? 'Path hop width override saved'
|
||||||
|
: 'Path hop width override cleared'
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Failed to update path hop width override', {
|
||||||
|
description: err instanceof Error ? err.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mergeChannelIntoList]
|
||||||
|
);
|
||||||
|
|
||||||
const handleSenderClick = useCallback(
|
const handleSenderClick = useCallback(
|
||||||
(sender: string) => {
|
(sender: string) => {
|
||||||
messageInputRef.current?.appendText(`@[${sender}] `);
|
messageInputRef.current?.appendText(`@[${sender}] `);
|
||||||
@@ -143,6 +166,7 @@ export function useConversationActions({
|
|||||||
handleSendMessage,
|
handleSendMessage,
|
||||||
handleResendChannelMessage,
|
handleResendChannelMessage,
|
||||||
handleSetChannelFloodScopeOverride,
|
handleSetChannelFloodScopeOverride,
|
||||||
|
handleSetChannelPathHashModeOverride,
|
||||||
handleSenderClick,
|
handleSenderClick,
|
||||||
handleTrace,
|
handleTrace,
|
||||||
handlePathDiscovery,
|
handlePathDiscovery,
|
||||||
|
|||||||
@@ -56,6 +56,14 @@
|
|||||||
--badge-mention: var(--destructive);
|
--badge-mention: var(--destructive);
|
||||||
--badge-mention-foreground: var(--destructive-foreground);
|
--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 */
|
/* Error toast */
|
||||||
--toast-error: 0 30% 14%;
|
--toast-error: 0 30% 14%;
|
||||||
--toast-error-foreground: 0 56% 77%;
|
--toast-error-foreground: 0 56% 77%;
|
||||||
@@ -126,6 +134,50 @@
|
|||||||
animation: message-highlight 2s ease-out forwards;
|
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 */
|
/* Constrain CodeMirror editor width */
|
||||||
.cm-editor {
|
.cm-editor {
|
||||||
max-width: 100% !important;
|
max-width: 100% !important;
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ const mocks = vi.hoisted(() => ({
|
|||||||
requestTrace: vi.fn(),
|
requestTrace: vi.fn(),
|
||||||
updateRadioConfig: vi.fn(),
|
updateRadioConfig: vi.fn(),
|
||||||
setPrivateKey: vi.fn(),
|
setPrivateKey: vi.fn(),
|
||||||
migratePreferences: vi.fn(),
|
|
||||||
},
|
},
|
||||||
toast: {
|
toast: {
|
||||||
success: vi.fn(),
|
success: vi.fn(),
|
||||||
@@ -190,9 +189,8 @@ const baseSettings = {
|
|||||||
max_radio_contacts: 200,
|
max_radio_contacts: 200,
|
||||||
favorites: [] as Array<{ type: 'channel' | 'contact'; id: string }>,
|
favorites: [] as Array<{ type: 'channel' | 'contact'; id: string }>,
|
||||||
auto_decrypt_dm_on_advert: false,
|
auto_decrypt_dm_on_advert: false,
|
||||||
sidebar_sort_order: 'recent' as const,
|
|
||||||
last_message_times: {},
|
last_message_times: {},
|
||||||
preferences_migrated: false,
|
|
||||||
advert_interval: 0,
|
advert_interval: 0,
|
||||||
last_advert_time: 0,
|
last_advert_time: 0,
|
||||||
flood_scope: '',
|
flood_scope: '',
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ const mocks = vi.hoisted(() => ({
|
|||||||
getUndecryptedPacketCount: vi.fn(),
|
getUndecryptedPacketCount: vi.fn(),
|
||||||
getChannels: vi.fn(),
|
getChannels: vi.fn(),
|
||||||
getContacts: vi.fn(),
|
getContacts: vi.fn(),
|
||||||
migratePreferences: vi.fn(),
|
|
||||||
},
|
},
|
||||||
useConversationMessagesCalls: vi.fn(),
|
useConversationMessagesCalls: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -218,9 +217,8 @@ describe('App search jump target handling', () => {
|
|||||||
max_radio_contacts: 200,
|
max_radio_contacts: 200,
|
||||||
favorites: [],
|
favorites: [],
|
||||||
auto_decrypt_dm_on_advert: false,
|
auto_decrypt_dm_on_advert: false,
|
||||||
sidebar_sort_order: 'recent',
|
|
||||||
last_message_times: {},
|
last_message_times: {},
|
||||||
preferences_migrated: true,
|
|
||||||
advert_interval: 0,
|
advert_interval: 0,
|
||||||
last_advert_time: 0,
|
last_advert_time: 0,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ const mocks = vi.hoisted(() => ({
|
|||||||
getUndecryptedPacketCount: vi.fn(),
|
getUndecryptedPacketCount: vi.fn(),
|
||||||
getChannels: vi.fn(),
|
getChannels: vi.fn(),
|
||||||
getContacts: vi.fn(),
|
getContacts: vi.fn(),
|
||||||
migratePreferences: vi.fn(),
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -169,9 +168,8 @@ describe('App startup hash resolution', () => {
|
|||||||
max_radio_contacts: 200,
|
max_radio_contacts: 200,
|
||||||
favorites: [],
|
favorites: [],
|
||||||
auto_decrypt_dm_on_advert: false,
|
auto_decrypt_dm_on_advert: false,
|
||||||
sidebar_sort_order: 'recent',
|
|
||||||
last_message_times: {},
|
last_message_times: {},
|
||||||
preferences_migrated: true,
|
|
||||||
advert_interval: 0,
|
advert_interval: 0,
|
||||||
last_advert_time: 0,
|
last_advert_time: 0,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
first_message_at: null,
|
||||||
unique_sender_count: 0,
|
unique_sender_count: 0,
|
||||||
top_senders_24h: [],
|
top_senders_24h: [],
|
||||||
|
path_hash_width_24h: {
|
||||||
|
total_packets: 0,
|
||||||
|
single_byte: 0,
|
||||||
|
double_byte: 0,
|
||||||
|
triple_byte: 0,
|
||||||
|
single_byte_pct: 0,
|
||||||
|
double_byte_pct: 0,
|
||||||
|
triple_byte_pct: 0,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -164,6 +164,8 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
|
|||||||
onDismissUnreadMarker: vi.fn(),
|
onDismissUnreadMarker: vi.fn(),
|
||||||
onSendMessage: vi.fn(async () => {}),
|
onSendMessage: vi.fn(async () => {}),
|
||||||
onToggleNotifications: vi.fn(),
|
onToggleNotifications: vi.fn(),
|
||||||
|
trackedTelemetryRepeaters: [],
|
||||||
|
onToggleTrackedTelemetry: vi.fn(async () => {}),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1227,7 +1227,7 @@ describe('SettingsFanoutSection', () => {
|
|||||||
|
|
||||||
const group = await screen.findByRole('group', { name: 'Integration Apprise Feed' });
|
const group = await screen.findByRole('group', { name: 'Integration Apprise Feed' });
|
||||||
expect(
|
expect(
|
||||||
within(group).getByText(/discord:\/\/abc, mailto:\/\/one@example.com/)
|
within(group).getByText(/discord:\/\/\*{8}, mailto:\/\/\*{8}, mailto:\/\/\*{8}/)
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -169,6 +169,32 @@ describe('MessageList channel sender rendering', () => {
|
|||||||
expect(onChannelReferenceClick).toHaveBeenCalledWith('#mesh-room');
|
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 () => {
|
it('links valid channel references in direct messages too', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onChannelReferenceClick = vi.fn();
|
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(
|
expect(
|
||||||
findLinkedChannelReferences(
|
findLinkedChannelReferences('Join #mesh-room, then #ops2; finally #alpha-room.')
|
||||||
'skip #Bad #bad--name abc#ops #ops- #opsRoom #ops_room #good-room,'
|
).toEqual([
|
||||||
)
|
{ label: '#mesh-room', start: 5, end: 15 },
|
||||||
).toEqual([]);
|
{ 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 onCreateContact = vi.fn().mockResolvedValue(undefined);
|
||||||
const onCreateChannel = vi.fn().mockResolvedValue(undefined);
|
const onCreateChannel = vi.fn().mockResolvedValue(undefined);
|
||||||
const onCreateHashtagChannel = vi.fn().mockResolvedValue(undefined);
|
const onCreateHashtagChannel = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const onBulkAddHashtagChannels = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -44,6 +45,7 @@ describe('NewMessageModal form reset', () => {
|
|||||||
onCreateContact={onCreateContact}
|
onCreateContact={onCreateContact}
|
||||||
onCreateChannel={onCreateChannel}
|
onCreateChannel={onCreateChannel}
|
||||||
onCreateHashtagChannel={onCreateHashtagChannel}
|
onCreateHashtagChannel={onCreateHashtagChannel}
|
||||||
|
onBulkAddHashtagChannels={onBulkAddHashtagChannels}
|
||||||
{...overrides}
|
{...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', () => {
|
describe('new-contact tab', () => {
|
||||||
it('clears name and key after successful Create', async () => {
|
it('clears name and key after successful Create', async () => {
|
||||||
const user = userEvent.setup();
|
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,
|
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
|
// Mock sonner toast
|
||||||
vi.mock('../components/ui/sonner', () => ({
|
vi.mock('../components/ui/sonner', () => ({
|
||||||
toast: {
|
toast: {
|
||||||
@@ -116,8 +124,20 @@ const defaultProps = {
|
|||||||
onToggleNotifications: vi.fn(),
|
onToggleNotifications: vi.fn(),
|
||||||
onToggleFavorite: vi.fn(),
|
onToggleFavorite: vi.fn(),
|
||||||
onDeleteContact: vi.fn(),
|
onDeleteContact: vi.fn(),
|
||||||
|
trackedTelemetryRepeaters: [] as string[],
|
||||||
|
onToggleTrackedTelemetry: vi.fn(async () => {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function createDeferred<T>() {
|
||||||
|
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', () => {
|
describe('RepeaterDashboard', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -418,6 +438,7 @@ describe('RepeaterDashboard', () => {
|
|||||||
flood_dups: 1,
|
flood_dups: 1,
|
||||||
direct_dups: 0,
|
direct_dups: 0,
|
||||||
full_events: 0,
|
full_events: 0,
|
||||||
|
telemetry_history: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
render(<RepeaterDashboard {...defaultProps} />);
|
render(<RepeaterDashboard {...defaultProps} />);
|
||||||
@@ -634,4 +655,106 @@ describe('RepeaterDashboard', () => {
|
|||||||
overrideSpy.mockRestore();
|
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,
|
max_radio_contacts: 200,
|
||||||
favorites: [],
|
favorites: [],
|
||||||
auto_decrypt_dm_on_advert: false,
|
auto_decrypt_dm_on_advert: false,
|
||||||
sidebar_sort_order: 'recent',
|
|
||||||
last_message_times: {},
|
last_message_times: {},
|
||||||
preferences_migrated: false,
|
|
||||||
advert_interval: 0,
|
advert_interval: 0,
|
||||||
last_advert_time: 0,
|
last_advert_time: 0,
|
||||||
flood_scope: '',
|
flood_scope: '',
|
||||||
blocked_keys: [],
|
blocked_keys: [],
|
||||||
blocked_names: [],
|
blocked_names: [],
|
||||||
|
discovery_blocked_types: [],
|
||||||
|
tracked_telemetry_repeaters: [],
|
||||||
|
auto_resend_channel: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
function renderModal(overrides?: {
|
function renderModal(overrides?: {
|
||||||
@@ -615,10 +617,10 @@ describe('SettingsModal', () => {
|
|||||||
openDatabaseSection();
|
openDatabaseSection();
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(/remove packet-analysis availability for those historical messages/i)
|
screen.getByText(/removes packet-analysis availability for those messages/i)
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Purge Archival Raw Packets' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Purge Archival Packets' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(runMaintenanceSpy).toHaveBeenCalledWith({ purgeLinkedRawPackets: true });
|
expect(runMaintenanceSpy).toHaveBeenCalledWith({ purgeLinkedRawPackets: true });
|
||||||
|
|||||||
@@ -7,3 +7,19 @@ class ResizeObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
globalThis.ResizeObserver = 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()}
|
onToggleCracker={vi.fn()}
|
||||||
onMarkAllRead={vi.fn()}
|
onMarkAllRead={vi.fn()}
|
||||||
favorites={favorites}
|
favorites={favorites}
|
||||||
legacySortOrder="recent"
|
|
||||||
isConversationNotificationsEnabled={overrides?.isConversationNotificationsEnabled}
|
isConversationNotificationsEnabled={overrides?.isConversationNotificationsEnabled}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -140,7 +139,6 @@ describe('Sidebar section summaries', () => {
|
|||||||
onToggleCracker={vi.fn()}
|
onToggleCracker={vi.fn()}
|
||||||
onMarkAllRead={vi.fn()}
|
onMarkAllRead={vi.fn()}
|
||||||
favorites={[]}
|
favorites={[]}
|
||||||
legacySortOrder="recent"
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -300,7 +298,6 @@ describe('Sidebar section summaries', () => {
|
|||||||
onToggleCracker={vi.fn()}
|
onToggleCracker={vi.fn()}
|
||||||
onMarkAllRead={vi.fn()}
|
onMarkAllRead={vi.fn()}
|
||||||
favorites={[]}
|
favorites={[]}
|
||||||
legacySortOrder="recent"
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -397,7 +394,6 @@ describe('Sidebar section summaries', () => {
|
|||||||
onToggleCracker: vi.fn(),
|
onToggleCracker: vi.fn(),
|
||||||
onMarkAllRead: vi.fn(),
|
onMarkAllRead: vi.fn(),
|
||||||
favorites: [],
|
favorites: [],
|
||||||
legacySortOrder: 'recent' as const,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getChannelsOrder = () => screen.getAllByText(/^#/).map((node) => node.textContent);
|
const getChannelsOrder = () => screen.getAllByText(/^#/).map((node) => node.textContent);
|
||||||
@@ -469,7 +465,6 @@ describe('Sidebar section summaries', () => {
|
|||||||
onToggleCracker={vi.fn()}
|
onToggleCracker={vi.fn()}
|
||||||
onMarkAllRead={vi.fn()}
|
onMarkAllRead={vi.fn()}
|
||||||
favorites={[]}
|
favorites={[]}
|
||||||
legacySortOrder="recent"
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -504,7 +499,6 @@ describe('Sidebar section summaries', () => {
|
|||||||
onToggleCracker={vi.fn()}
|
onToggleCracker={vi.fn()}
|
||||||
onMarkAllRead={vi.fn()}
|
onMarkAllRead={vi.fn()}
|
||||||
favorites={[]}
|
favorites={[]}
|
||||||
legacySortOrder="recent"
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -553,7 +547,6 @@ describe('Sidebar section summaries', () => {
|
|||||||
onToggleCracker={vi.fn()}
|
onToggleCracker={vi.fn()}
|
||||||
onMarkAllRead={vi.fn()}
|
onMarkAllRead={vi.fn()}
|
||||||
favorites={[]}
|
favorites={[]}
|
||||||
legacySortOrder="recent"
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -586,7 +579,6 @@ describe('Sidebar section summaries', () => {
|
|||||||
onToggleCracker={vi.fn()}
|
onToggleCracker={vi.fn()}
|
||||||
onMarkAllRead={vi.fn()}
|
onMarkAllRead={vi.fn()}
|
||||||
favorites={[]}
|
favorites={[]}
|
||||||
legacySortOrder="alpha"
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -623,7 +615,6 @@ describe('Sidebar section summaries', () => {
|
|||||||
{ type: 'contact', id: zed.public_key },
|
{ type: 'contact', id: zed.public_key },
|
||||||
{ type: 'contact', id: amy.public_key },
|
{ type: 'contact', id: amy.public_key },
|
||||||
] satisfies Favorite[],
|
] satisfies Favorite[],
|
||||||
legacySortOrder: 'recent' as const,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFavoritesOrder = () =>
|
const getFavoritesOrder = () =>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { act, renderHook } from '@testing-library/react';
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
import { useContactsAndChannels } from '../hooks/useContactsAndChannels';
|
import { useContactsAndChannels } from '../hooks/useContactsAndChannels';
|
||||||
import type { Contact } from '../types';
|
import type { BulkCreateHashtagChannelsResult, Contact } from '../types';
|
||||||
|
|
||||||
// Mock api module
|
// Mock api module
|
||||||
vi.mock('../api', () => ({
|
vi.mock('../api', () => ({
|
||||||
@@ -18,6 +18,7 @@ vi.mock('../api', () => ({
|
|||||||
getChannels: vi.fn(),
|
getChannels: vi.fn(),
|
||||||
createContact: vi.fn(),
|
createContact: vi.fn(),
|
||||||
createChannel: vi.fn(),
|
createChannel: vi.fn(),
|
||||||
|
bulkCreateHashtagChannels: vi.fn(),
|
||||||
deleteContact: vi.fn(),
|
deleteContact: vi.fn(),
|
||||||
deleteChannel: vi.fn(),
|
deleteChannel: vi.fn(),
|
||||||
decryptHistoricalPackets: vi.fn(),
|
decryptHistoricalPackets: vi.fn(),
|
||||||
@@ -171,4 +172,41 @@ describe('useContactsAndChannels', () => {
|
|||||||
expect(api.getContacts).toHaveBeenCalledTimes(2);
|
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%;
|
--overlay: 220 20% 10%;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme='light'] .sidebar-tool-label {
|
|
||||||
color: hsl(var(--foreground));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Windows 95 ───────────────────────────────────────────── */
|
/* ── Windows 95 ───────────────────────────────────────────── */
|
||||||
:root[data-theme='windows-95'] {
|
:root[data-theme='windows-95'] {
|
||||||
--background: 180 100% 25%;
|
--background: 180 100% 25%;
|
||||||
|
|||||||
+39
-29
@@ -166,23 +166,6 @@ export interface NearestRepeater {
|
|||||||
heard_count: number;
|
heard_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContactDetail {
|
|
||||||
contact: Contact;
|
|
||||||
name_history: ContactNameHistory[];
|
|
||||||
dm_message_count: number;
|
|
||||||
channel_message_count: number;
|
|
||||||
most_active_rooms: ContactActiveRoom[];
|
|
||||||
advert_paths: ContactAdvertPath[];
|
|
||||||
advert_frequency: number | null;
|
|
||||||
nearest_repeaters: NearestRepeater[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NameOnlyContactDetail {
|
|
||||||
name: string;
|
|
||||||
channel_message_count: number;
|
|
||||||
most_active_rooms: ContactActiveRoom[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ContactAnalyticsHourlyBucket {
|
export interface ContactAnalyticsHourlyBucket {
|
||||||
bucket_start: number;
|
bucket_start: number;
|
||||||
last_24h_count: number;
|
last_24h_count: number;
|
||||||
@@ -218,6 +201,7 @@ export interface Channel {
|
|||||||
is_hashtag: boolean;
|
is_hashtag: boolean;
|
||||||
on_radio: boolean;
|
on_radio: boolean;
|
||||||
flood_scope_override?: string | null;
|
flood_scope_override?: string | null;
|
||||||
|
path_hash_mode_override?: number | null;
|
||||||
last_read_at: number | null;
|
last_read_at: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,12 +219,32 @@ export interface ChannelTopSender {
|
|||||||
message_count: number;
|
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 {
|
export interface ChannelDetail {
|
||||||
channel: Channel;
|
channel: Channel;
|
||||||
message_counts: ChannelMessageCounts;
|
message_counts: ChannelMessageCounts;
|
||||||
first_message_at: number | null;
|
first_message_at: number | null;
|
||||||
unique_sender_count: number;
|
unique_sender_count: number;
|
||||||
top_senders_24h: ChannelTopSender[];
|
top_senders_24h: ChannelTopSender[];
|
||||||
|
path_hash_width_24h: PathHashWidthStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A single path that a message took to reach us */
|
/** A single path that a message took to reach us */
|
||||||
@@ -251,6 +255,10 @@ export interface MessagePath {
|
|||||||
received_at: number;
|
received_at: number;
|
||||||
/** Hop count (number of intermediate nodes). Null for legacy data (infer as len(path)/2). */
|
/** Hop count (number of intermediate nodes). Null for legacy data (infer as len(path)/2). */
|
||||||
path_len?: number | null;
|
path_len?: number | null;
|
||||||
|
/** Last-hop RSSI in dBm (null if not available, e.g. older data) */
|
||||||
|
rssi?: number | null;
|
||||||
|
/** Last-hop SNR in dB (null if not available, e.g. older data) */
|
||||||
|
snr?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
@@ -324,35 +332,31 @@ export interface AppSettings {
|
|||||||
max_radio_contacts: number;
|
max_radio_contacts: number;
|
||||||
favorites: Favorite[];
|
favorites: Favorite[];
|
||||||
auto_decrypt_dm_on_advert: boolean;
|
auto_decrypt_dm_on_advert: boolean;
|
||||||
sidebar_sort_order: 'recent' | 'alpha';
|
|
||||||
last_message_times: Record<string, number>;
|
last_message_times: Record<string, number>;
|
||||||
preferences_migrated: boolean;
|
|
||||||
advert_interval: number;
|
advert_interval: number;
|
||||||
last_advert_time: number;
|
last_advert_time: number;
|
||||||
flood_scope: string;
|
flood_scope: string;
|
||||||
blocked_keys: string[];
|
blocked_keys: string[];
|
||||||
blocked_names: string[];
|
blocked_names: string[];
|
||||||
|
discovery_blocked_types: number[];
|
||||||
|
tracked_telemetry_repeaters: string[];
|
||||||
|
auto_resend_channel: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppSettingsUpdate {
|
export interface AppSettingsUpdate {
|
||||||
max_radio_contacts?: number;
|
max_radio_contacts?: number;
|
||||||
auto_decrypt_dm_on_advert?: boolean;
|
auto_decrypt_dm_on_advert?: boolean;
|
||||||
sidebar_sort_order?: 'recent' | 'alpha';
|
|
||||||
advert_interval?: number;
|
advert_interval?: number;
|
||||||
|
auto_resend_channel?: boolean;
|
||||||
flood_scope?: string;
|
flood_scope?: string;
|
||||||
blocked_keys?: string[];
|
blocked_keys?: string[];
|
||||||
blocked_names?: string[];
|
blocked_names?: string[];
|
||||||
|
discovery_blocked_types?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MigratePreferencesRequest {
|
export interface TrackedTelemetryResponse {
|
||||||
favorites: Favorite[];
|
tracked_telemetry_repeaters: string[];
|
||||||
sort_order: string;
|
names: Record<string, string>;
|
||||||
last_message_times: Record<string, number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MigratePreferencesResponse {
|
|
||||||
migrated: boolean;
|
|
||||||
settings: AppSettings;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Contact type constants */
|
/** Contact type constants */
|
||||||
@@ -405,6 +409,7 @@ export interface RepeaterStatusResponse {
|
|||||||
flood_dups: number;
|
flood_dups: number;
|
||||||
direct_dups: number;
|
direct_dups: number;
|
||||||
full_events: number;
|
full_events: number;
|
||||||
|
telemetry_history: TelemetryHistoryEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RepeaterNeighborsResponse {
|
export interface RepeaterNeighborsResponse {
|
||||||
@@ -468,6 +473,11 @@ export interface PaneState {
|
|||||||
fetched_at?: number | null;
|
fetched_at?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TelemetryHistoryEntry {
|
||||||
|
timestamp: number;
|
||||||
|
data: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TraceResponse {
|
export interface TraceResponse {
|
||||||
remote_snr: number | null;
|
remote_snr: number | null;
|
||||||
local_snr: number | null;
|
local_snr: number | null;
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
* across devices - see useUnreadCounts hook.
|
* across devices - see useUnreadCounts hook.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const LAST_MESSAGE_KEY = 'remoteterm-lastMessageTime';
|
|
||||||
const SORT_ORDER_KEY = 'remoteterm-sortOrder';
|
const SORT_ORDER_KEY = 'remoteterm-sortOrder';
|
||||||
const SIDEBAR_SECTION_SORT_ORDERS_KEY = 'remoteterm-sidebar-section-sort-orders';
|
const SIDEBAR_SECTION_SORT_ORDERS_KEY = 'remoteterm-sidebar-section-sort-orders';
|
||||||
|
|
||||||
@@ -72,30 +71,6 @@ export function getStateKey(type: 'channel' | 'contact', id: string): string {
|
|||||||
return `${type}-${id}`;
|
return `${type}-${id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Load last message times from localStorage (for migration only)
|
|
||||||
*/
|
|
||||||
export function loadLocalStorageLastMessageTimes(): ConversationTimes {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(LAST_MESSAGE_KEY);
|
|
||||||
return stored ? JSON.parse(stored) : {};
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load sort order from localStorage (for migration only)
|
|
||||||
*/
|
|
||||||
export function loadLocalStorageSortOrder(): SortOrder {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(SORT_ORDER_KEY);
|
|
||||||
return stored === 'alpha' ? 'alpha' : 'recent';
|
|
||||||
} catch {
|
|
||||||
return 'recent';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load the legacy single sidebar sort order from localStorage, if present.
|
* Load the legacy single sidebar sort order from localStorage, if present.
|
||||||
*/
|
*/
|
||||||
@@ -149,15 +124,3 @@ export function saveLocalStorageSidebarSectionSortOrders(orders: SidebarSectionS
|
|||||||
// localStorage might be disabled
|
// localStorage might be disabled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear conversation state from localStorage (after migration)
|
|
||||||
*/
|
|
||||||
export function clearLocalStorageConversationState(): void {
|
|
||||||
try {
|
|
||||||
localStorage.removeItem(LAST_MESSAGE_KEY);
|
|
||||||
localStorage.removeItem(SORT_ORDER_KEY);
|
|
||||||
} catch {
|
|
||||||
// localStorage might be disabled
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Favorites utilities.
|
* Favorites utilities.
|
||||||
*
|
*
|
||||||
* Favorites are now stored server-side in the database.
|
* Favorites are stored server-side in the database.
|
||||||
* This file provides helper functions for checking favorites
|
|
||||||
* and loading legacy localStorage data for migration.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Favorite } from '../types';
|
import type { Favorite } from '../types';
|
||||||
|
|
||||||
const FAVORITES_KEY = 'remoteterm-favorites';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a conversation is favorited (from provided favorites array)
|
* Check if a conversation is favorited (from provided favorites array)
|
||||||
*/
|
*/
|
||||||
@@ -20,26 +16,3 @@ export function isFavorite(
|
|||||||
): boolean {
|
): boolean {
|
||||||
return favorites.some((f) => f.type === type && f.id === id);
|
return favorites.some((f) => f.type === type && f.id === id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Load favorites from localStorage (for migration only)
|
|
||||||
*/
|
|
||||||
export function loadLocalStorageFavorites(): Favorite[] {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(FAVORITES_KEY);
|
|
||||||
return stored ? JSON.parse(stored) : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear favorites from localStorage (after migration)
|
|
||||||
*/
|
|
||||||
export function clearLocalStorageFavorites(): void {
|
|
||||||
try {
|
|
||||||
localStorage.removeItem(FAVORITES_KEY);
|
|
||||||
} catch {
|
|
||||||
// localStorage might be disabled
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Channel messages have format "sender: message".
|
* Channel messages have format "sender: message".
|
||||||
*/
|
*/
|
||||||
const HASHTAG_CHANNEL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
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 } {
|
export function parseSenderFromText(text: string): { sender: string | null; content: string } {
|
||||||
const colonIndex = text.indexOf(': ');
|
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