mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-12 20:36:05 +02:00
Compare commits
170 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2547d7f62 | |||
| 5e1bdb2cc1 | |||
| 4420d44838 | |||
| ead1774cd3 | |||
| 0d45cbd849 | |||
| 456f739f51 | |||
| 80c6cc44e5 | |||
| 35265d8ae8 | |||
| 4a2d7ed100 | |||
| 47c4f038fe | |||
| 630ba67ef0 | |||
| fd1188abcd | |||
| 94513d7177 | |||
| fbff9821be | |||
| 1fd281121b | |||
| 5653a43941 | |||
| 7f07aedb8a | |||
| e437ce74c6 | |||
| 4ff6d2018a | |||
| 1c634da687 | |||
| 738c21dd66 | |||
| 7d72448ebf | |||
| b4f3d1f14c | |||
| 416166b07c | |||
| 480798e117 | |||
| 704a3d8a87 | |||
| 96e108037c | |||
| 97aade3632 | |||
| e43584912b | |||
| fccde36ecb | |||
| e631f9b0cc | |||
| b52431616e | |||
| 8446d99df1 | |||
| 8e1e913fcd | |||
| b74137dc72 | |||
| c83f9b0005 | |||
| 9f4737d350 | |||
| 29e9a5f701 | |||
| f0f06671cc | |||
| b1595e479c | |||
| 25df69bfbc | |||
| 88140081b9 | |||
| 4326f57977 | |||
| 43abcd07b2 | |||
| 5c60559cb8 | |||
| 3c0d6a4466 | |||
| 7b9d8f6a23 | |||
| 44d6fcac24 | |||
| 788d1cbdca | |||
| 26e8150092 | |||
| 3a1c2d691b | |||
| 134e8d0d29 | |||
| eb1f7ae638 | |||
| 14ba342160 | |||
| 7460c3ea9d | |||
| 6534946bc7 | |||
| 4847813ae1 | |||
| 3f6efaae1d | |||
| 60f3fa8e36 | |||
| b42ca44ba7 | |||
| d4bbb8a542 | |||
| db248302e9 | |||
| 7aa4f76064 | |||
| f01e91defc | |||
| 8ee08ff44a | |||
| 6d9ea552bd | |||
| 2cd71bf086 | |||
| 08d55dec72 | |||
| 20532f70a3 | |||
| 659370e1eb | |||
| 7151cf3846 | |||
| 6e5256acce | |||
| 7d27567ae9 | |||
| 5f0d042252 | |||
| 6f68dfc609 | |||
| a32ddda79d | |||
| ac6a5774af | |||
| b12e612596 | |||
| d1499ad75f | |||
| 79d5e69ee0 | |||
| 498770bd88 | |||
| 1405df6039 | |||
| ac5e71d6f2 | |||
| 650a24a68c | |||
| 53f122e503 | |||
| bf0533807a | |||
| 094058bad7 | |||
| efeb047116 | |||
| 88c99e0983 | |||
| 983a37f68f | |||
| bea3495b79 | |||
| 54c24c50d3 | |||
| b7972f50a8 | |||
| bab1693c82 | |||
| f93844a01b | |||
| 26b740fe3c | |||
| b0f5930e01 | |||
| 5b05fdefa1 | |||
| b63153b3a1 | |||
| 3c5a832bef | |||
| fd8bc4b56a | |||
| 2d943dedc5 | |||
| 137f41970d | |||
| c833f1036b | |||
| e15e6d83f7 | |||
| 4ead2ffcde | |||
| f9ca35b3ae | |||
| 7c4a244e05 | |||
| 6eab75ec7e | |||
| caf4bf4eff | |||
| 74e1f49db8 | |||
| 95c874e643 | |||
| 3b28ebfa49 | |||
| d36c63f6b1 | |||
| e8a4f5c349 | |||
| b022aea71f | |||
| 5225a1c766 | |||
| 41400c0528 | |||
| 07928d930c | |||
| 26742d0c88 | |||
| 8b73bef30b | |||
| 4b583fe337 | |||
| e6e7267eb1 | |||
| 36eeeae64d | |||
| 7c988ae3d0 | |||
| 1a0c4833d5 | |||
| 84c500d018 | |||
| 1960a16fb0 | |||
| 3580aeda5a | |||
| bb97b983bb | |||
| da31b67d54 | |||
| d840159f9c | |||
| 9de4158a6c | |||
| 1e21644d74 | |||
| df0ed8452b | |||
| d4a5f0f728 | |||
| 3e2c48457d | |||
| d4f518df0c | |||
| 5213c8c84c | |||
| 33c2b0c948 | |||
| b021a4a8ac | |||
| c74fdec10b | |||
| cf314e02ff | |||
| 8ae600d010 | |||
| fdd82e1f77 | |||
| 9d129260fd | |||
| 2b80760696 | |||
| c2655c1809 | |||
| cee7103ec6 | |||
| d05312c157 | |||
| 5b166c4b66 | |||
| dbe2915635 | |||
| 2337d7b592 | |||
| 62080424bb | |||
| 1ae76848fe | |||
| 45ed430580 | |||
| 5f8ce16855 | |||
| b79249c4a0 | |||
| 85d1a940dc | |||
| b85d451e26 | |||
| 41a297c944 | |||
| 41d64d86d4 | |||
| bd336e3ee2 | |||
| cf585cdf87 | |||
| 417a583696 | |||
| 541dba6a75 | |||
| 720b8be64f | |||
| 2b5083e889 | |||
| 5975006cf7 | |||
| 69e09378f5 |
@@ -2,6 +2,8 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[oc]
|
*.py[oc]
|
||||||
build/
|
build/
|
||||||
|
!scripts/build/
|
||||||
|
!scripts/build/**
|
||||||
wheels/
|
wheels/
|
||||||
*.egg-info
|
*.egg-info
|
||||||
|
|
||||||
@@ -23,3 +25,8 @@ references/
|
|||||||
|
|
||||||
# ancillary LLM files
|
# ancillary LLM files
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
# local Docker compose files
|
||||||
|
docker-compose.yml
|
||||||
|
docker-compose.yaml
|
||||||
|
.docker-certs/
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
If instructed to "run all tests" or "get ready for a commit" or other summative, work ending directives, run:
|
If instructed to "run all tests" or "get ready for a commit" or other summative, work ending directives, run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/all_quality.sh
|
./scripts/quality/all_quality.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
This is the repo's end-to-end quality gate. It runs backend/frontend autofixers first, then type checking, tests, and the standard frontend build. All checks must pass green, and the script may leave formatting/lint edits behind.
|
This is the repo's end-to-end quality gate. It runs backend/frontend autofixers first, then type checking, tests, and the standard frontend build. All checks must pass green, and the script may leave formatting/lint edits behind.
|
||||||
@@ -138,8 +138,12 @@ MeshCore firmware can encode path hops as 1-byte, 2-byte, or 3-byte identifiers.
|
|||||||
- `path_hash_mode` values are `0` = 1-byte, `1` = 2-byte, `2` = 3-byte.
|
- `path_hash_mode` values are `0` = 1-byte, `1` = 2-byte, `2` = 3-byte.
|
||||||
- `GET /api/radio/config` exposes both the current `path_hash_mode` and `path_hash_mode_supported`.
|
- `GET /api/radio/config` exposes both the current `path_hash_mode` and `path_hash_mode_supported`.
|
||||||
- `PATCH /api/radio/config` may update `path_hash_mode` only when the connected firmware supports it.
|
- `PATCH /api/radio/config` may update `path_hash_mode` only when the connected firmware supports it.
|
||||||
- Contacts persist `out_path_hash_mode` separately from `last_path` so contact sync and DM send paths can round-trip correctly even when hop bytes are ambiguous.
|
- Contact routing now uses canonical route fields: `direct_path`, `direct_path_len`, `direct_path_hash_mode`, plus optional `route_override_*`.
|
||||||
- Contacts may also persist an explicit routing override (`route_override_*`). When set, radio-bound operations use the override instead of the learned `last_path*`, but learned paths still keep updating from adverts.
|
- The contact/API surface also exposes backend-computed `effective_route`, `effective_route_source`, `direct_route`, and `route_override` so send logic and UI do not reimplement precedence rules independently.
|
||||||
|
- Legacy `last_path`, `last_path_len`, and `out_path_hash_mode` are no longer part of the contact model or API contract.
|
||||||
|
- Route precedence for direct-message sends is: explicit override, then learned direct route, then flood.
|
||||||
|
- The learned direct route is sourced from radio contact sync (`out_path`) and PATH/path-discovery updates, matching how firmware updates `ContactInfo.out_path`.
|
||||||
|
- Advertisement paths are informational only. They are retained in `contact_advert_paths` for the contact pane and visualizer, but they are not used as DM send routes.
|
||||||
- `path_len` in API payloads is always hop count, not byte count. The actual path byte length is `hop_count * hash_size`.
|
- `path_len` in API payloads is always hop count, not byte count. The actual path byte length is `hop_count * hash_size`.
|
||||||
|
|
||||||
## Data Flow
|
## Data Flow
|
||||||
@@ -159,11 +163,21 @@ MeshCore firmware can encode path hops as 1-byte, 2-byte, or 3-byte identifiers.
|
|||||||
4. Message stored in database with `outgoing=true`
|
4. Message stored in database with `outgoing=true`
|
||||||
5. For direct messages: ACK tracked; for channel: repeat detection
|
5. For direct messages: ACK tracked; for channel: repeat detection
|
||||||
|
|
||||||
|
Direct-message send behavior intentionally mirrors the firmware/library `send_msg_with_retry(...)` flow:
|
||||||
|
- We push the contact's effective route to the radio via `add_contact(...)` before sending.
|
||||||
|
- If the initial `MSG_SENT` result includes an expected ACK code, background retries are armed.
|
||||||
|
- Non-final retry attempts use the effective route (`override > direct > flood`).
|
||||||
|
- Retry timing follows the radio's `suggested_timeout`.
|
||||||
|
- The final retry is sent as flood by resetting the path on the radio first, even if an override or direct route exists.
|
||||||
|
- Path math is always hop-count based; hop bytes are interpreted using the stored `path_hash_mode`.
|
||||||
|
|
||||||
### ACK and Repeat Detection
|
### ACK and Repeat Detection
|
||||||
|
|
||||||
**Direct messages**: Expected ACK code is tracked. When ACK event arrives, message marked as acked.
|
**Direct messages**: Expected ACK code is tracked. When ACK event arrives, message marked as acked.
|
||||||
|
|
||||||
Outgoing DMs send once immediately, then may retry up to 2 more times in the background if still unacked. Retry timing follows the radio's `suggested_timeout` from `PACKET_MSG_SENT`, and the final retry is sent as flood even when a routing override is configured. DM ACK state is terminal on first ACK: sibling retry ACK codes are cleared so one DM should not accumulate multiple delivery confirmations from different retry attempts.
|
Outgoing DMs send once immediately, then may retry up to 2 more times in the background only when the initial `MSG_SENT` result includes an expected ACK code and the message remains unacked. Retry timing follows the radio's `suggested_timeout` from `PACKET_MSG_SENT`, and the final retry is sent as flood even when a routing override is configured. DM ACK state is terminal on first ACK: sibling retry ACK codes are cleared so one DM should not accumulate multiple delivery confirmations from different retry attempts.
|
||||||
|
|
||||||
|
ACKs are not a contact-route source. They drive message delivery state and may appear in analytics/detail surfaces, but they do not update `direct_path*` or otherwise influence route selection for future sends.
|
||||||
|
|
||||||
**Channel messages**: Flood messages echo back through repeaters. Repeats are identified by the database UNIQUE constraint on `(type, conversation_key, text, sender_timestamp)` — when an INSERT hits a duplicate, `_handle_duplicate_message()` in `packet_processor.py` adds the new path and, for outgoing messages only, increments the ack count. Incoming repeats add path data but do not change the ack count. There is no timestamp-windowed matching; deduplication is exact-match only.
|
**Channel messages**: Flood messages echo back through repeaters. Repeats are identified by the database UNIQUE constraint on `(type, conversation_key, text, sender_timestamp)` — when an INSERT hits a duplicate, `_handle_duplicate_message()` in `packet_processor.py` adds the new path and, for outgoing messages only, increments the ack count. Incoming repeats add path data but do not change the ack count. There is no timestamp-windowed matching; deduplication is exact-match only.
|
||||||
|
|
||||||
@@ -196,10 +210,16 @@ This message-layer echo/path handling is independent of raw-packet storage dedup
|
|||||||
│ │ └── ...
|
│ │ └── ...
|
||||||
│ └── vite.config.ts
|
│ └── vite.config.ts
|
||||||
├── scripts/ # Quality / release helpers (listing below is representative, not exhaustive)
|
├── scripts/ # Quality / release helpers (listing below is representative, not exhaustive)
|
||||||
│ ├── all_quality.sh # Repo-standard autofix + validate gate
|
│ ├── build/
|
||||||
│ ├── collect_licenses.sh # Gather third-party license attributions
|
│ │ ├── collect_licenses.sh # Gather third-party license attributions
|
||||||
│ ├── e2e.sh # End-to-end test runner
|
│ │ └── publish.sh # Version bump, changelog, docker build & push
|
||||||
│ └── publish.sh # Version bump, changelog, docker build & push
|
│ ├── quality/
|
||||||
|
│ │ ├── all_quality.sh # Repo-standard autofix + validate gate
|
||||||
|
│ │ ├── e2e.sh # End-to-end test runner
|
||||||
|
│ │ └── extended_quality.sh # Quality gate plus e2e and Docker matrix
|
||||||
|
│ └── setup/
|
||||||
|
│ ├── fetch_prebuilt_frontend.py # Download release frontend fallback
|
||||||
|
│ └── install_service.sh # Install/configure Linux systemd service
|
||||||
├── README_ADVANCED.md # Advanced setup, troubleshooting, and service guidance
|
├── README_ADVANCED.md # Advanced setup, troubleshooting, and service guidance
|
||||||
├── CONTRIBUTING.md # Contributor workflow and testing guidance
|
├── CONTRIBUTING.md # Contributor workflow and testing guidance
|
||||||
├── tests/ # Backend tests (pytest)
|
├── tests/ # Backend tests (pytest)
|
||||||
@@ -257,23 +277,23 @@ PYTHONPATH=. uv run pytest tests/ -v
|
|||||||
```
|
```
|
||||||
|
|
||||||
Key test files:
|
Key test files:
|
||||||
- `tests/test_decoder.py` - Channel + direct message decryption, key exchange
|
- `tests/test_api.py` - Broad API integration coverage across routers and read-state flows
|
||||||
- `tests/test_keystore.py` - Ephemeral key store
|
- `tests/test_packet_pipeline.py` - End-to-end packet processing, decrypt, dedup, and message creation
|
||||||
- `tests/test_event_handlers.py` - ACK tracking, repeat detection
|
- `tests/test_event_handlers.py` - ACK tracking, fallback DM handling, and event subscription cleanup
|
||||||
- `tests/test_packet_pipeline.py` - End-to-end packet processing
|
- `tests/test_send_messages.py` - Outgoing DM/channel send workflows, retries, and bot-trigger wiring
|
||||||
- `tests/test_api.py` - API endpoints, read state tracking
|
- `tests/test_packets_router.py` - Historical decrypt, maintenance, and raw-packet detail endpoints
|
||||||
- `tests/test_migrations.py` - Database migration system
|
- `tests/test_repeater_routes.py` - Repeater command/telemetry/trace pane endpoints
|
||||||
- `tests/test_frontend_static.py` - Frontend static route registration (missing `dist`/`index.html` handling)
|
- `tests/test_room_routes.py` - Room-server login/status/ACL/telemetry endpoints
|
||||||
- `tests/test_messages_search.py` - Message search, around endpoint, forward pagination
|
- `tests/test_radio_router.py` - Radio config, advert, discovery, trace, and reconnect endpoints
|
||||||
- `tests/test_rx_log_data.py` - on_rx_log_data event handler integration
|
- `tests/test_radio_sync.py` - Radio sync, periodic tasks, contact offload/reload, and pending-message flushes
|
||||||
- `tests/test_ack_tracking_wiring.py` - DM ACK tracking extraction and wiring
|
- `tests/test_fanout.py` - Fanout config CRUD, scope matching, and manager dispatch
|
||||||
- `tests/test_radio_lifecycle_service.py` - Radio reconnect/setup orchestration helpers
|
- `tests/test_fanout_integration.py` - Integration-module lifecycle and delivery behavior
|
||||||
- `tests/test_radio_commands_service.py` - Radio config/private-key service workflows
|
- `tests/test_statistics.py` - Aggregated mesh/network statistics and noise-floor snapshots
|
||||||
- `tests/test_health_mqtt_status.py` - Health endpoint MQTT status field
|
- `tests/test_version_info.py` - Version/build metadata resolution
|
||||||
- `tests/test_community_mqtt.py` - Community MQTT publisher (JWT, packet format, hash, broadcast)
|
- `tests/test_websocket.py` - WS manager broadcast and cleanup behavior
|
||||||
- `tests/test_radio_sync.py` - Radio sync, periodic tasks, and contact offload back to the radio
|
- `tests/test_frontend_static.py` - Frontend static route registration and fallback behavior
|
||||||
- `tests/test_real_crypto.py` - Real cryptographic operations
|
|
||||||
- `tests/test_disable_bots.py` - MESHCORE_DISABLE_BOTS=true feature
|
For the fuller backend inventory, see `app/AGENTS.md`. For frontend-specific suites, see `frontend/AGENTS.md`.
|
||||||
|
|
||||||
### Frontend (Vitest)
|
### Frontend (Vitest)
|
||||||
|
|
||||||
@@ -282,9 +302,9 @@ cd frontend
|
|||||||
npm run test:run
|
npm run test:run
|
||||||
```
|
```
|
||||||
|
|
||||||
### Before Completing Changes
|
### Before Completing Major Changes
|
||||||
|
|
||||||
**Always run `./scripts/all_quality.sh` before finishing any changes that have modified code or tests.** It is the standard repo gate: autofix first, then type checks, tests, and the standard frontend build. This is not necessary for docs-only changes.
|
**Run `./scripts/quality/all_quality.sh` before finishing major changes that have modified code or tests.** It is the standard repo gate: autofix first, then type checks, tests, and the standard frontend build. This is not necessary for docs-only changes. For minor changes (like wording, color, spacing, etc.), wait until prompted to run the quality gate.
|
||||||
|
|
||||||
## API Summary
|
## API Summary
|
||||||
|
|
||||||
@@ -294,11 +314,12 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
|||||||
|--------|----------|-------------|
|
|--------|----------|-------------|
|
||||||
| GET | `/api/health` | Connection status, fanout statuses, bots_disabled flag |
|
| GET | `/api/health` | Connection status, fanout statuses, bots_disabled flag |
|
||||||
| GET | `/api/debug` | Support snapshot: recent logs, live radio probe, contact/channel drift audit, and running version/git info |
|
| GET | `/api/debug` | Support snapshot: recent logs, live radio probe, contact/channel drift audit, and running version/git info |
|
||||||
| GET | `/api/radio/config` | Radio configuration, including `path_hash_mode`, `path_hash_mode_supported`, and whether adverts include current node location |
|
| GET | `/api/radio/config` | Radio configuration, including `path_hash_mode`, `path_hash_mode_supported`, advert-location on/off, and `multi_acks_enabled` |
|
||||||
| PATCH | `/api/radio/config` | Update name, location, advert-location on/off, radio params, and `path_hash_mode` when supported |
|
| PATCH | `/api/radio/config` | Update name, location, advert-location on/off, `multi_acks_enabled`, radio params, and `path_hash_mode` when supported |
|
||||||
| PUT | `/api/radio/private-key` | Import private key to radio |
|
| PUT | `/api/radio/private-key` | Import private key to radio |
|
||||||
| POST | `/api/radio/advertise` | Send advertisement (`mode`: `flood` or `zero_hop`, default `flood`) |
|
| POST | `/api/radio/advertise` | Send advertisement (`mode`: `flood` or `zero_hop`, default `flood`) |
|
||||||
| POST | `/api/radio/discover` | Run a short mesh discovery sweep for nearby repeaters/sensors |
|
| POST | `/api/radio/discover` | Run a short mesh discovery sweep for nearby repeaters/sensors |
|
||||||
|
| POST | `/api/radio/trace` | Send a multi-hop trace loop through known repeaters and back to the local radio |
|
||||||
| POST | `/api/radio/reboot` | Reboot radio or reconnect if disconnected |
|
| POST | `/api/radio/reboot` | Reboot radio or reconnect if disconnected |
|
||||||
| POST | `/api/radio/disconnect` | Disconnect from radio and pause automatic reconnect attempts |
|
| POST | `/api/radio/disconnect` | Disconnect from radio and pause automatic reconnect attempts |
|
||||||
| POST | `/api/radio/reconnect` | Manual radio reconnection |
|
| POST | `/api/radio/reconnect` | Manual radio reconnection |
|
||||||
@@ -311,6 +332,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
|||||||
| POST | `/api/contacts/{public_key}/command` | Send CLI command to repeater |
|
| POST | `/api/contacts/{public_key}/command` | Send CLI command to repeater |
|
||||||
| POST | `/api/contacts/{public_key}/routing-override` | Set or clear a forced routing override |
|
| POST | `/api/contacts/{public_key}/routing-override` | Set or clear a forced routing override |
|
||||||
| POST | `/api/contacts/{public_key}/trace` | Trace route to contact |
|
| POST | `/api/contacts/{public_key}/trace` | Trace route to contact |
|
||||||
|
| POST | `/api/contacts/{public_key}/path-discovery` | Discover forward/return paths and persist the learned direct route |
|
||||||
| POST | `/api/contacts/{public_key}/repeater/login` | Log in to a repeater |
|
| POST | `/api/contacts/{public_key}/repeater/login` | Log in to a repeater |
|
||||||
| POST | `/api/contacts/{public_key}/repeater/status` | Fetch repeater status telemetry |
|
| POST | `/api/contacts/{public_key}/repeater/status` | Fetch repeater status telemetry |
|
||||||
| POST | `/api/contacts/{public_key}/repeater/lpp-telemetry` | Fetch CayenneLPP sensor data |
|
| POST | `/api/contacts/{public_key}/repeater/lpp-telemetry` | Fetch CayenneLPP sensor data |
|
||||||
@@ -320,6 +342,10 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
|||||||
| POST | `/api/contacts/{public_key}/repeater/radio-settings` | Fetch repeater radio config via CLI |
|
| POST | `/api/contacts/{public_key}/repeater/radio-settings` | Fetch repeater radio config via CLI |
|
||||||
| POST | `/api/contacts/{public_key}/repeater/advert-intervals` | Fetch advert intervals |
|
| POST | `/api/contacts/{public_key}/repeater/advert-intervals` | Fetch advert intervals |
|
||||||
| POST | `/api/contacts/{public_key}/repeater/owner-info` | Fetch owner info |
|
| POST | `/api/contacts/{public_key}/repeater/owner-info` | Fetch owner info |
|
||||||
|
| POST | `/api/contacts/{public_key}/room/login` | Log in to a room server |
|
||||||
|
| POST | `/api/contacts/{public_key}/room/status` | Fetch room-server status telemetry |
|
||||||
|
| POST | `/api/contacts/{public_key}/room/lpp-telemetry` | Fetch room-server CayenneLPP sensor data |
|
||||||
|
| POST | `/api/contacts/{public_key}/room/acl` | Fetch room-server ACL entries |
|
||||||
|
|
||||||
| GET | `/api/channels` | List channels |
|
| GET | `/api/channels` | 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) |
|
||||||
@@ -333,9 +359,10 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
|||||||
| POST | `/api/messages/channel` | Send channel message |
|
| POST | `/api/messages/channel` | Send channel message |
|
||||||
| POST | `/api/messages/channel/{message_id}/resend` | Resend channel message (default: byte-perfect within 30s; `?new_timestamp=true`: fresh timestamp, no time limit, creates new message row) |
|
| POST | `/api/messages/channel/{message_id}/resend` | Resend channel message (default: byte-perfect within 30s; `?new_timestamp=true`: fresh timestamp, no time limit, creates new message row) |
|
||||||
| GET | `/api/packets/undecrypted/count` | Count of undecrypted packets |
|
| GET | `/api/packets/undecrypted/count` | Count of undecrypted packets |
|
||||||
|
| GET | `/api/packets/{packet_id}` | Fetch one stored raw packet by row ID for on-demand inspection |
|
||||||
| POST | `/api/packets/decrypt/historical` | Decrypt stored packets |
|
| POST | `/api/packets/decrypt/historical` | Decrypt stored packets |
|
||||||
| POST | `/api/packets/maintenance` | Delete old packets and vacuum |
|
| POST | `/api/packets/maintenance` | Delete old packets and vacuum |
|
||||||
| GET | `/api/read-state/unreads` | Server-computed unread counts, mentions, last message times |
|
| GET | `/api/read-state/unreads` | Server-computed unread counts, mentions, last message times, and `last_read_ats` boundaries |
|
||||||
| POST | `/api/read-state/mark-all-read` | Mark all conversations as read |
|
| POST | `/api/read-state/mark-all-read` | Mark all conversations as read |
|
||||||
| GET | `/api/settings` | Get app settings |
|
| GET | `/api/settings` | Get app settings |
|
||||||
| PATCH | `/api/settings` | Update app settings |
|
| PATCH | `/api/settings` | Update app settings |
|
||||||
@@ -347,6 +374,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
|||||||
| POST | `/api/fanout` | Create new fanout config |
|
| POST | `/api/fanout` | Create new fanout config |
|
||||||
| PATCH | `/api/fanout/{id}` | Update fanout config (triggers module reload) |
|
| PATCH | `/api/fanout/{id}` | Update fanout config (triggers module reload) |
|
||||||
| DELETE | `/api/fanout/{id}` | Delete fanout config (stops module) |
|
| DELETE | `/api/fanout/{id}` | Delete fanout config (stops module) |
|
||||||
|
| POST | `/api/fanout/bots/disable-until-restart` | Stop bot fanout modules and keep bots disabled until the process restarts |
|
||||||
| GET | `/api/statistics` | Aggregated mesh network statistics |
|
| GET | `/api/statistics` | Aggregated mesh network statistics |
|
||||||
| WS | `/api/ws` | Real-time updates |
|
| WS | `/api/ws` | Real-time updates |
|
||||||
|
|
||||||
@@ -385,7 +413,7 @@ Read state (`last_read_at`) is tracked **server-side** for consistency across de
|
|||||||
- Stored as Unix timestamp in `contacts.last_read_at` and `channels.last_read_at`
|
- Stored as Unix timestamp in `contacts.last_read_at` and `channels.last_read_at`
|
||||||
- Updated via `POST /api/contacts/{public_key}/mark-read` and `POST /api/channels/{key}/mark-read`
|
- Updated via `POST /api/contacts/{public_key}/mark-read` and `POST /api/channels/{key}/mark-read`
|
||||||
- Bulk update via `POST /api/read-state/mark-all-read`
|
- Bulk update via `POST /api/read-state/mark-all-read`
|
||||||
- Aggregated counts via `GET /api/read-state/unreads` (server-side computation)
|
- Aggregated counts via `GET /api/read-state/unreads` (server-side computation of counts, mention flags, `last_message_times`, and `last_read_ats`)
|
||||||
|
|
||||||
**State Tracking Keys (Frontend)**: Generated by `getStateKey()` for message times (sidebar sorting):
|
**State Tracking Keys (Frontend)**: Generated by `getStateKey()` for message times (sidebar sorting):
|
||||||
- Channels: `channel-{channel_key}`
|
- Channels: `channel-{channel_key}`
|
||||||
|
|||||||
+368
-280
@@ -1,103 +1,191 @@
|
|||||||
|
## [3.6.7] - 2026-03-31
|
||||||
|
|
||||||
|
* Misc: Remove armv7 (for now)
|
||||||
|
|
||||||
|
## [3.6.6] - 2026-03-31
|
||||||
|
|
||||||
|
* Misc: Please I'm begging for the build scripts to be working now
|
||||||
|
|
||||||
|
## [3.6.5] - 2026-03-31
|
||||||
|
|
||||||
|
* Bugfix: Maybe fix problem with publish script
|
||||||
|
|
||||||
|
## [3.6.4] - 2026-03-31
|
||||||
|
|
||||||
|
* Feature: Clarify New Channel/Contact button
|
||||||
|
* Bugfix: Rename "Best RSSI" to "Strongest Neighbor"
|
||||||
|
* Bugfix: Improve layout of Trace pane
|
||||||
|
* Misc: Docker setup improvements
|
||||||
|
|
||||||
|
## [3.6.3] - 2026-03-30
|
||||||
|
|
||||||
|
* Feature: Add multi-byte trace
|
||||||
|
* Feature: Show node name on discovered node if we know it
|
||||||
|
* Feature: Add docker installation script
|
||||||
|
* Feature: Add historical noise floor to stats
|
||||||
|
* Feature: Add trace tool
|
||||||
|
* Bugfix: 100x performance on statistics endpoint with indices and better queries
|
||||||
|
* Misc: Performance and correctness improvements for backend-of-the-frontend
|
||||||
|
* Misc: Reorganize scripts
|
||||||
|
|
||||||
|
## [3.6.2] - 2026-03-29
|
||||||
|
|
||||||
|
* Feature: Be more flexible about timing and volume of full contact offload
|
||||||
|
* Feature: Improve room server and repeater ops to be much more clearer about auth status
|
||||||
|
* Feature: Show last error status on integrations
|
||||||
|
* Feature: Push multi-platform docker builds
|
||||||
|
* Bugfix: Fix advert interval time unit display
|
||||||
|
* Bugfix: Don't cast RSSI/SNR to string for community MQTT
|
||||||
|
* Bugfix: Map uploader follows redirect
|
||||||
|
* Misc: Thin out unnecessary cruft in unreads endpoint
|
||||||
|
* Misc: Fall back gracefully if linked to an unknown contact
|
||||||
|
|
||||||
|
## [3.6.1] - 2026-03-26
|
||||||
|
|
||||||
|
* Feature: MeshCore Map integration
|
||||||
|
* Feature: Add warning screen about bots
|
||||||
|
* Feature: Favicon reflects unread message state
|
||||||
|
* Feature: Show hop map in larger modal
|
||||||
|
* Feature: Add prebuilt frontend install script
|
||||||
|
* Feature: Add clean service installer script
|
||||||
|
* Feature: Swipe in to show menu
|
||||||
|
* Bugfix: Invalid backend API path serves error, not fallback index
|
||||||
|
* Bugfix: Fix some spacing/page height issues
|
||||||
|
* Misc: Misc. bugfixes and performance and test improvements
|
||||||
|
|
||||||
|
## [3.6.0] - 2026-03-22
|
||||||
|
|
||||||
|
* Feature: Add incoming-packet analytics
|
||||||
|
* Feature: BYOPacket for analysis
|
||||||
|
* Feature: Add room activity to stats view
|
||||||
|
* Bugfix: Handle Heltec v3 serial noise
|
||||||
|
* Misc: Swap repeaters and room servers for better ordering
|
||||||
|
|
||||||
|
## [3.5.0] - 2026-03-19
|
||||||
|
|
||||||
|
* Feature: Add room server alpha support
|
||||||
|
* Feature: Add option to force-reset node clock when it's too far ahead
|
||||||
|
* Feature: DMs auto-retry before resorting to flood
|
||||||
|
* Feature: Add impulse zero-hop advert
|
||||||
|
* Feature: Utilize PATH packets to correctly source a contact's route
|
||||||
|
* Feature: Metrics view on raw packet pane
|
||||||
|
* Feature: Metric, Imperial, and Smoots are now selectable for distance display
|
||||||
|
* Feature: Allow favorites to be sorted
|
||||||
|
* Feature: Add multi-ack support
|
||||||
|
* Feature: Password-remember checkbox on repeaters + room servers
|
||||||
|
* Bugfix: Serialize radio disconnect in a lock
|
||||||
|
* Bugfix: Fix contact bar layout issues
|
||||||
|
* Bugfix: Fix sidebar ordering for contacts by advert recency
|
||||||
|
* Bugfix: Fix version reporting in community MQTT
|
||||||
|
* Bugfix: Fix Apprise duplicate names
|
||||||
|
* Bugfix: Be better about identity resolution in the stats pane
|
||||||
|
* Misc: Docs, test, and performance enhancements
|
||||||
|
* Misc: Don't prompt "Are you sure" when leaving an unedited interation
|
||||||
|
* Misc: Log node time on startup
|
||||||
|
* Misc: Improve community MQTT error bubble-up
|
||||||
|
* Misc: Unread DMs always have a red unread counter
|
||||||
|
* Misc: Improve information in the debug view to show DB status
|
||||||
|
|
||||||
## [3.4.1] - 2026-03-16
|
## [3.4.1] - 2026-03-16
|
||||||
|
|
||||||
Bugfix: Improve handling of version information on prebuilt bundles
|
* Bugfix: Improve handling of version information on prebuilt bundles
|
||||||
Bugfix: Improve frontend usability on disconnected radio
|
* Bugfix: Improve frontend usability on disconnected radio
|
||||||
Misc: Docs and readme updates
|
* Misc: Docs and readme updates
|
||||||
Misc: Overhaul DM ingest and frontend state handling
|
* Misc: Overhaul DM ingest and frontend state handling
|
||||||
|
|
||||||
## [3.4.0] - 2026-03-16
|
## [3.4.0] - 2026-03-16
|
||||||
|
|
||||||
Feature: Add radio model and stats display
|
* Feature: Add radio model and stats display
|
||||||
Feature: Add prebuilt frontends, then deleted that and moved to prebuilt release artifacts
|
* Feature: Add prebuilt frontends, then deleted that and moved to prebuilt release artifacts
|
||||||
Bugfix: Misc. frontend performance and correctness fixes
|
* Bugfix: Misc. frontend performance and correctness fixes
|
||||||
Bugfix: Fix same-second same-content DM send collition
|
* Bugfix: Fix same-second same-content DM send collition
|
||||||
Bugfix: Discard clearly-wrong GPS data
|
* Bugfix: Discard clearly-wrong GPS data
|
||||||
Bugfix: Prevent repeater clock skew drift on page nav
|
* Bugfix: Prevent repeater clock skew drift on page nav
|
||||||
Misc: Use repeater's advertised location if we haven't loaded one from repeater admin
|
* Misc: Use repeater's advertised location if we haven't loaded one from repeater admin
|
||||||
Misc: Don't permit invalid fanout configs to be saved ever`
|
* Misc: Don't permit invalid fanout configs to be saved ever`
|
||||||
|
|
||||||
## [3.3.0] - 2026-03-13
|
## [3.3.0] - 2026-03-13
|
||||||
|
|
||||||
Feature: Use dashed lines to show collapsed ambiguous router results
|
* Feature: Use dashed lines to show collapsed ambiguous router results
|
||||||
Feature: Jump to unred
|
* Feature: Jump to unred
|
||||||
Feature: Local channel management to prevent need to reload channel every time
|
* Feature: Local channel management to prevent need to reload channel every time
|
||||||
Feature: Debug endpoint
|
* Feature: Debug endpoint
|
||||||
Feature: Force-singleton channel management
|
* Feature: Force-singleton channel management
|
||||||
Feature: Local node discovery
|
* Feature: Local node discovery
|
||||||
Feature: Node routing discovery
|
* Feature: Node routing discovery
|
||||||
Bugfix: Don't tell users to us npm ci
|
* Bugfix: Don't tell users to us npm ci
|
||||||
Bugfix: Fallback polling dm message persistence
|
* Bugfix: Fallback polling dm message persistence
|
||||||
Bugfix: All native-JS inputs are now modals
|
* Bugfix: All native-JS inputs are now modals
|
||||||
Bugfix: Same-second send collision resolution
|
* Bugfix: Same-second send collision resolution
|
||||||
Bugfix: Proper browser updates on resend
|
* Bugfix: Proper browser updates on resend
|
||||||
Bugfix: Don't use last-heard when we actually want last-advert for path discovery for nodes
|
* Bugfix: Don't use last-heard when we actually want last-advert for path discovery for nodes
|
||||||
Bugfix: Don't treat prefix-matching DM echoes as acks like we do for channel messages
|
* Bugfix: Don't treat prefix-matching DM echoes as acks like we do for channel messages
|
||||||
Misc: Visualizer data layer overhaul for future map work
|
* Misc: Visualizer data layer overhaul for future map work
|
||||||
Misc: Parallelize docker tests
|
* Misc: Parallelize docker tests
|
||||||
|
|
||||||
## [3.2.0] - 2026-03-12
|
## [3.2.0] - 2026-03-12
|
||||||
|
|
||||||
Feature: Improve ambiguous-sender DM handling and visibility
|
* Feature: Improve ambiguous-sender DM handling and visibility
|
||||||
Feature: Allow for toggling of node GPS broadcast
|
* Feature: Allow for toggling of node GPS broadcast
|
||||||
Feature: Add path width to bot and move example to full kwargs
|
* Feature: Add path width to bot and move example to full kwargs
|
||||||
Feature: Improve node map color contrast
|
* Feature: Improve node map color contrast
|
||||||
Bugfix: More accurate tracking of contact data
|
* Bugfix: More accurate tracking of contact data
|
||||||
Bugfix: Misc. frontend performance and bugfixes
|
* Bugfix: Misc. frontend performance and bugfixes
|
||||||
Misc: Clearer warnings on user-key linkage
|
* Misc: Clearer warnings on user-key linkage
|
||||||
Misc: Documentation improvements
|
* Misc: Documentation improvements
|
||||||
|
|
||||||
## [3.1.1] - 2026-03-11
|
## [3.1.1] - 2026-03-11
|
||||||
|
|
||||||
Feature: Add basic auth
|
* Feature: Add basic auth
|
||||||
Feature: SQS fanout
|
* Feature: SQS fanout
|
||||||
Feature: Enrich contact info pane
|
* Feature: Enrich contact info pane
|
||||||
Feature: Search operators for node and channel
|
* Feature: Search operators for node and channel
|
||||||
Feature: Pause radio connection attempts from Radio settings
|
* Feature: Pause radio connection attempts from Radio settings
|
||||||
Feature: New themes! What a great use of time!
|
* Feature: New themes! What a great use of time!
|
||||||
Feature: Github workflows runs for validation
|
* Feature: Github workflows runs for validation
|
||||||
Bugfix: More consistent log format with times
|
* Bugfix: More consistent log format with times
|
||||||
Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses
|
* Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses
|
||||||
|
|
||||||
## [3.1.0] - 2026-03-11
|
## [3.1.0] - 2026-03-11
|
||||||
|
|
||||||
Feature: Add basic auth
|
* Feature: Add basic auth
|
||||||
Feature: SQS fanout
|
* Feature: SQS fanout
|
||||||
Feature: Enrich contact info pane
|
* Feature: Enrich contact info pane
|
||||||
Feature: Search operators for node and channel
|
* Feature: Search operators for node and channel
|
||||||
Feature: Pause radio connection attempts from Radio settings
|
* Feature: Pause radio connection attempts from Radio settings
|
||||||
Feature: New themes! What a great use of time!
|
* Feature: New themes! What a great use of time!
|
||||||
Feature: Github workflows runs for validation
|
* Feature: Github workflows runs for validation
|
||||||
Bugfix: More consistent log format with times
|
* Bugfix: More consistent log format with times
|
||||||
Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses
|
* Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses
|
||||||
|
|
||||||
## [3.0.0] - 2026-03-10
|
## [3.0.0] - 2026-03-10
|
||||||
|
|
||||||
Feature: Custom regions per-channel
|
* Feature: Custom regions per-channel
|
||||||
Feature: Add custom contact pathing
|
* Feature: Add custom contact pathing
|
||||||
Feature: Corrupt packets are more clear that they're corrupt
|
* Feature: Corrupt packets are more clear that they're corrupt
|
||||||
Feature: Better, faster patterns around background fetching with explicit opt-in for recurring sync if the app detects you need it
|
* Feature: Better, faster patterns around background fetching with explicit opt-in for recurring sync if the app detects you need it
|
||||||
Feature: More consistent icons
|
* Feature: More consistent icons
|
||||||
Feature: Add per-channel local notifications
|
* Feature: Add per-channel local notifications
|
||||||
Feature: New themes
|
* Feature: New themes
|
||||||
Feature: Massive codebase refactor and overhaul
|
* Feature: Massive codebase refactor and overhaul
|
||||||
Bugfix: Fix packet parsing for trace packets
|
* Bugfix: Fix packet parsing for trace packets
|
||||||
Bugfix: Refetch channels on reconnect
|
* Bugfix: Refetch channels on reconnect
|
||||||
Bugfix: Load All on repeater pane on mobile doesn't etend into lower text
|
* Bugfix: Load All on repeater pane on mobile doesn't etend into lower text
|
||||||
Bugfix: Timestamps in logs
|
* Bugfix: Timestamps in logs
|
||||||
Bugfix: Correct wrong clock sync command
|
* Bugfix: Correct wrong clock sync command
|
||||||
Misc: Improve bot error bubble up
|
* Misc: Improve bot error bubble up
|
||||||
Misc: Update to non-lib-included meshcore-decoder version
|
* Misc: Update to non-lib-included meshcore-decoder version
|
||||||
Misc: Revise refactors to be more LLM friendly
|
* Misc: Revise refactors to be more LLM friendly
|
||||||
Misc: Fix script executability
|
* Misc: Fix script executability
|
||||||
Misc: Better logging format with timestamp
|
* Misc: Better logging format with timestamp
|
||||||
Misc: Repeater advert buttons separate flood and one-hop
|
* Misc: Repeater advert buttons separate flood and one-hop
|
||||||
Misc: Preserve repeater pane on navigation away
|
* Misc: Preserve repeater pane on navigation away
|
||||||
Misc: Clearer iconography and coloring for status bar buttons
|
* Misc: Clearer iconography and coloring for status bar buttons
|
||||||
Misc: Search bar to top bar
|
* Misc: Search bar to top bar
|
||||||
|
|
||||||
## [2.7.9] - 2026-03-08
|
## [2.7.9] - 2026-03-08
|
||||||
|
|
||||||
Bugfix: Don't obscure new integration dropdown on session boundary
|
* Bugfix: Don't obscure new integration dropdown on session boundary
|
||||||
|
|
||||||
## [2.7.8] - 2026-03-08
|
## [2.7.8] - 2026-03-08
|
||||||
|
|
||||||
@@ -105,287 +193,287 @@ Bugfix: Don't obscure new integration dropdown on session boundary
|
|||||||
|
|
||||||
## [2.7.8] - 2026-03-08
|
## [2.7.8] - 2026-03-08
|
||||||
|
|
||||||
Bugfix: Improve frontend asset resolution and fixup the build/push script
|
* Bugfix: Improve frontend asset resolution and fixup the build/push script
|
||||||
|
|
||||||
## [2.7.1] - 2026-03-08
|
## [2.7.1] - 2026-03-08
|
||||||
|
|
||||||
Bugfix: Fix historical DM packet length passing
|
* Bugfix: Fix historical DM packet length passing
|
||||||
Misc: Follow better inclusion patterns for the patched meshcore-decoder and just publish the dang package
|
* Misc: Follow better inclusion patterns for the patched meshcore-decoder and just publish the dang package
|
||||||
Misc: Patch a bewildering browser quirk that cause large raw packet lists to extend past the bottom of the page
|
* Misc: Patch a bewildering browser quirk that cause large raw packet lists to extend past the bottom of the page
|
||||||
|
|
||||||
## [2.7.0] - 2026-03-08
|
## [2.7.0] - 2026-03-08
|
||||||
|
|
||||||
Feature: Multibyte path support
|
* Feature: Multibyte path support
|
||||||
Feature: Add multibyte statistics to statistics pane
|
* Feature: Add multibyte statistics to statistics pane
|
||||||
Feature: Add path bittage to contact info pane
|
* Feature: Add path bittage to contact info pane
|
||||||
Feature: Put tools in a collapsible
|
* Feature: Put tools in a collapsible
|
||||||
|
|
||||||
## [2.6.1] - 2026-03-08
|
## [2.6.1] - 2026-03-08
|
||||||
|
|
||||||
Misc: Fix busted docker builds; we don't have a 2.6.0 build sorry
|
* Misc: Fix busted docker builds; we don't have a 2.6.0 build sorry
|
||||||
|
|
||||||
## [2.6.0] - 2026-03-08
|
## [2.6.0] - 2026-03-08
|
||||||
|
|
||||||
Feature: A11y improvements
|
* Feature: A11y improvements
|
||||||
Feature: New themes
|
* Feature: New themes
|
||||||
Feature: Backfill channel sender identity when available
|
* Feature: Backfill channel sender identity when available
|
||||||
Feature: Modular fanout bus, including Webhooks, more customizable community MQTT, and Apprise
|
* Feature: Modular fanout bus, including Webhooks, more customizable community MQTT, and Apprise
|
||||||
Bugfix: Unreads now respect blocklist
|
* Bugfix: Unreads now respect blocklist
|
||||||
Bugfix: Unreads can't accumulate on an open thread
|
* Bugfix: Unreads can't accumulate on an open thread
|
||||||
Bugfix: Channel name in broadcasts
|
* Bugfix: Channel name in broadcasts
|
||||||
Bugfix: Add missing httpx dependency
|
* Bugfix: Add missing httpx dependency
|
||||||
Bugfix: Improvements to radio startup frontend-blocking time and radio status reporting
|
* Bugfix: Improvements to radio startup frontend-blocking time and radio status reporting
|
||||||
Misc: Improved button signage for app movement
|
* Misc: Improved button signage for app movement
|
||||||
Misc: Test, performance, and documentation improvements
|
* Misc: Test, performance, and documentation improvements
|
||||||
|
|
||||||
## [2.5.0] - 2026-03-05
|
## [2.5.0] - 2026-03-05
|
||||||
|
|
||||||
Feature: Far better accessibility across the app (with far to go)
|
* Feature: Far better accessibility across the app (with far to go)
|
||||||
Feature: Add community MQTT stats reporting, and improve over a few commits
|
* Feature: Add community MQTT stats reporting, and improve over a few commits
|
||||||
Feature: Color schemes and misc. settings reorg
|
* Feature: Color schemes and misc. settings reorg
|
||||||
Feature: Add why-active to filtered nodes
|
* Feature: Add why-active to filtered nodes
|
||||||
Feature: Add channel and contact info box
|
* Feature: Add channel and contact info box
|
||||||
Feature: Add contact blocking
|
* Feature: Add contact blocking
|
||||||
Feature: Add potential repeater path map display
|
* Feature: Add potential repeater path map display
|
||||||
Feature: Add flood scoping/regions
|
* Feature: Add flood scoping/regions
|
||||||
Feature: Global message search
|
* Feature: Global message search
|
||||||
Feature: Fully safe bot disable
|
* Feature: Fully safe bot disable
|
||||||
Feature: Add default #remoteterm channel (lol sorry I had to)
|
* Feature: Add default #remoteterm channel (lol sorry I had to)
|
||||||
Feature: Custom recency pruning in visualizer
|
* Feature: Custom recency pruning in visualizer
|
||||||
Bugfix: Be more cautious around null byte stripping
|
* Bugfix: Be more cautious around null byte stripping
|
||||||
Bugfix: Clear channel-add interface on not-add-another
|
* Bugfix: Clear channel-add interface on not-add-another
|
||||||
Bugfix: Add status/name/MQTT LWT
|
* Bugfix: Add status/name/MQTT LWT
|
||||||
Bugfix: Channel deletion propagates over WS
|
* Bugfix: Channel deletion propagates over WS
|
||||||
Bugfix: Show map location for all nodes on link, not 7-day-limited
|
* Bugfix: Show map location for all nodes on link, not 7-day-limited
|
||||||
Bugfix: Hide private key channel keys by default
|
* Bugfix: Hide private key channel keys by default
|
||||||
Misc: Logline to show if cleanup loop on non-sync'd meshcore radio links fixes anything
|
* Misc: Logline to show if cleanup loop on non-sync'd meshcore radio links fixes anything
|
||||||
Misc: Doc, changelog, and test improvements
|
* Misc: Doc, changelog, and test improvements
|
||||||
Misc: Add, and remove, package lock (sorry Windows users)
|
* Misc: Add, and remove, package lock (sorry Windows users)
|
||||||
Misc: Don't show mark all as read if not necessary
|
* Misc: Don't show mark all as read if not necessary
|
||||||
Misc: Fix stale closures and misc. frontend perf/correctness improvements
|
* Misc: Fix stale closures and misc. frontend perf/correctness improvements
|
||||||
Misc: Add Windows startup notes
|
* Misc: Add Windows startup notes
|
||||||
Misc: E2E expansion + improvement
|
* Misc: E2E expansion + improvement
|
||||||
Misc: Move around visualizer settings
|
* Misc: Move around visualizer settings
|
||||||
|
|
||||||
## [2.4.0] - 2026-03-02
|
## [2.4.0] - 2026-03-02
|
||||||
|
|
||||||
Feature: Add community MQTT reporting (e.g. LetsMesh.net)
|
* Feature: Add community MQTT reporting (e.g. LetsMesh.net)
|
||||||
Misc: Build scripts and library attribution
|
* Misc: Build scripts and library attribution
|
||||||
Misc: Add sign of life to E2E tests
|
* Misc: Add sign of life to E2E tests
|
||||||
|
|
||||||
## [2.3.0] - 2026-03-01
|
## [2.3.0] - 2026-03-01
|
||||||
|
|
||||||
Feature: Click path description to reset to flood
|
* Feature: Click path description to reset to flood
|
||||||
Feature: Add MQTT publishing
|
* Feature: Add MQTT publishing
|
||||||
Feature: Visualizer remembers settings
|
* Feature: Visualizer remembers settings
|
||||||
Bugfix: Fix prefetch usage
|
* Bugfix: Fix prefetch usage
|
||||||
Bugfix: Fixed an issue where busy channels can result in double-display of incoming messages
|
* Bugfix: Fixed an issue where busy channels can result in double-display of incoming messages
|
||||||
Misc: Drop py3.12 requirement
|
* Misc: Drop py3.12 requirement
|
||||||
Misc: Performance, documentation, test, and file structure optimizations
|
* Misc: Performance, documentation, test, and file structure optimizations
|
||||||
Misc: Add arrows between route nodes on contact info
|
* Misc: Add arrows between route nodes on contact info
|
||||||
Misc: Show repeater path/type in title bar
|
* Misc: Show repeater path/type in title bar
|
||||||
|
|
||||||
## [2.2.0] - 2026-02-28
|
## [2.2.0] - 2026-02-28
|
||||||
|
|
||||||
Feature: Track advert paths and use to disambiguate repeater identity in visualizer
|
* Feature: Track advert paths and use to disambiguate repeater identity in visualizer
|
||||||
Feature: Contact info pane
|
* Feature: Contact info pane
|
||||||
Feature: Overhaul repeater interface
|
* Feature: Overhaul repeater interface
|
||||||
Bugfix: Misc. frontend rendering + perf improvements
|
* Bugfix: Misc. frontend rendering + perf improvements
|
||||||
Bugfix: Better behavior around radio locking and autofetch/polling
|
* Bugfix: Better behavior around radio locking and autofetch/polling
|
||||||
Bugfix: Clear channel name field on new-channel modal tab change
|
* Bugfix: Clear channel name field on new-channel modal tab change
|
||||||
Bugfix: Repeater inforbox can scroll
|
* Bugfix: Repeater inforbox can scroll
|
||||||
Bugfix: Better handling of historical DM encrypts
|
* Bugfix: Better handling of historical DM encrypts
|
||||||
Bugfix: Handle errors if returned in prefetch phase
|
* Bugfix: Handle errors if returned in prefetch phase
|
||||||
Misc: Radio event response failure is logged/surfaced better
|
* Misc: Radio event response failure is logged/surfaced better
|
||||||
Misc: Improve test coverage and remove dead code
|
* Misc: Improve test coverage and remove dead code
|
||||||
Misc: Documentation and errata improvements
|
* Misc: Documentation and errata improvements
|
||||||
Misc: Database storage optimization
|
* Misc: Database storage optimization
|
||||||
|
|
||||||
## [2.1.0] - 2026-02-23
|
## [2.1.0] - 2026-02-23
|
||||||
|
|
||||||
Feature: Add ability to remember last-used channel on load
|
* Feature: Add ability to remember last-used channel on load
|
||||||
Feature: Add `docker compose` support (thanks @suymur !)
|
* Feature: Add `docker compose` support (thanks @suymur !)
|
||||||
Feature: Better-aligned favicon (lol)
|
* Feature: Better-aligned favicon (lol)
|
||||||
Bugfix: Disable autocomplete on message field
|
* Bugfix: Disable autocomplete on message field
|
||||||
Bugfix: Legacy hash restoration on page load
|
* Bugfix: Legacy hash restoration on page load
|
||||||
Bugfix: Align resend buttons in pathing modal
|
* Bugfix: Align resend buttons in pathing modal
|
||||||
Bugfix: Update README.md (briefly), then docker-compose.yaml, to reflect correct docker image host
|
* Bugfix: Update README.md (briefly), then docker-compose.yaml, to reflect correct docker image host
|
||||||
Bugfix: Correct settings pane scroll lock on zoom (thanks @yellowcooln !)
|
* Bugfix: Correct settings pane scroll lock on zoom (thanks @yellowcooln !)
|
||||||
Bugfix: Improved repeater comms on busy meshes
|
* Bugfix: Improved repeater comms on busy meshes
|
||||||
Bugfix: Drain before autofetch from radio
|
* Bugfix: Drain before autofetch from radio
|
||||||
Bugfix: Fix, or document exceptions to, sub-second resolution message failure
|
* Bugfix: Fix, or document exceptions to, sub-second resolution message failure
|
||||||
Bugfix: Improved handling of radio connection, disconnection, and connection-aliveness-status
|
* Bugfix: Improved handling of radio connection, disconnection, and connection-aliveness-status
|
||||||
Bugfix: Force server-side keystore update when radio key changes
|
* Bugfix: Force server-side keystore update when radio key changes
|
||||||
Bugfix: Reduce WS churn for incoming message handling
|
* Bugfix: Reduce WS churn for incoming message handling
|
||||||
Bugfix: Fix content type signalling for irrelevant endpoints
|
* Bugfix: Fix content type signalling for irrelevant endpoints
|
||||||
Bugfix: Handle stuck post-connect failure state
|
* Bugfix: Handle stuck post-connect failure state
|
||||||
Misc: Documentation & version parsing improvements
|
* Misc: Documentation & version parsing improvements
|
||||||
Misc: Hide char counter on mobile for short messages
|
* Misc: Hide char counter on mobile for short messages
|
||||||
Misc: Typo fixes in docs and settings
|
* Misc: Typo fixes in docs and settings
|
||||||
Misc: Add dynamic webmanifest for hosts that can support it
|
* Misc: Add dynamic webmanifest for hosts that can support it
|
||||||
Misc: Improve DB size via dropping unnecessary uniqs, indices, vacuum, and offering ability to drop historical matches packets
|
* Misc: Improve DB size via dropping unnecessary uniqs, indices, vacuum, and offering ability to drop historical matches packets
|
||||||
Misc: Drop weird rounded bounding box for settings
|
* Misc: Drop weird rounded bounding box for settings
|
||||||
Misc: Move resend buttons to pathing modal
|
* Misc: Move resend buttons to pathing modal
|
||||||
Misc: Improved comments around database ownership on *nix systems
|
* Misc: Improved comments around database ownership on *nix systems
|
||||||
Misc: Move to SSoT for message dedupe on frontend
|
* Misc: Move to SSoT for message dedupe on frontend
|
||||||
Misc: Move DM ack clearing to standard poll, and increase hold time between polling
|
* Misc: Move DM ack clearing to standard poll, and increase hold time between polling
|
||||||
Misc: Holistic testing overhaul
|
* Misc: Holistic testing overhaul
|
||||||
|
|
||||||
## [2.0.1] - 2026-02-16
|
## [2.0.1] - 2026-02-16
|
||||||
|
|
||||||
Bugfix: Fix missing trigger condition on statistics pane expansion on mobile
|
* Bugfix: Fix missing trigger condition on statistics pane expansion on mobile
|
||||||
|
|
||||||
## [2.0.0] - 2026-02-16
|
## [2.0.0] - 2026-02-16
|
||||||
|
|
||||||
Feature: Frontend UX + log overhaul
|
* Feature: Frontend UX + log overhaul
|
||||||
Bugfix: Use contact object from DB for broadcast rather than handrolling
|
* Bugfix: Use contact object from DB for broadcast rather than handrolling
|
||||||
Bugfix: Fix out of order path WS messages overwriting each other
|
* Bugfix: Fix out of order path WS messages overwriting each other
|
||||||
Bugfix: Make broadcast timestamp match fallback logic used in storage code
|
* Bugfix: Make broadcast timestamp match fallback logic used in storage code
|
||||||
Bugfix: Fix repeater command timestamp selection logic
|
* Bugfix: Fix repeater command timestamp selection logic
|
||||||
Bugfix: Use actual pubkey matching for path update, and don't action serial path update events (use RX packet)
|
* Bugfix: Use actual pubkey matching for path update, and don't action serial path update events (use RX packet)
|
||||||
Bugfix: Add missing radio operation locks in a few spots
|
* Bugfix: Add missing radio operation locks in a few spots
|
||||||
Bugfix: Fix dedupe for frontend raw packet delivery (mesh visualizer much more active now!)
|
* Bugfix: Fix dedupe for frontend raw packet delivery (mesh visualizer much more active now!)
|
||||||
Bugfix: Less aggressive dedupe for advert packets (we don't care about the payload, we care about the path, duh)
|
* Bugfix: Less aggressive dedupe for advert packets (we don't care about the payload, we care about the path, duh)
|
||||||
Misc: Visualizer layout refinement & option labels
|
* Misc: Visualizer layout refinement & option labels
|
||||||
|
|
||||||
## [1.10.0] - 2026-02-16
|
## [1.10.0] - 2026-02-16
|
||||||
|
|
||||||
Feature: Collapsible sidebar sections with per-section unread badge (thanks @rgregg !)
|
* Feature: Collapsible sidebar sections with per-section unread badge (thanks @rgregg !)
|
||||||
Feature: 3D mesh visualizer
|
* Feature: 3D mesh visualizer
|
||||||
Feature: Statistics pane
|
* Feature: Statistics pane
|
||||||
Feature: Support incoming/outgoing indication for bot invocations
|
* Feature: Support incoming/outgoing indication for bot invocations
|
||||||
Feature: Quick byte-perfect message resend if you got unlucky with repeats (thanks @rgregg -- we had a parallel implementation but I appreciate your work!)
|
* Feature: Quick byte-perfect message resend if you got unlucky with repeats (thanks @rgregg -- we had a parallel implementation but I appreciate your work!)
|
||||||
Bugfix: Fix top padding out outgoing message
|
* Bugfix: Fix top padding out outgoing message
|
||||||
Bugfix: Frontend performance, appearance, and Lighthouse improvements (prefetches, form labelling, contrast, channel/roomlist changes)
|
* Bugfix: Frontend performance, appearance, and Lighthouse improvements (prefetches, form labelling, contrast, channel/roomlist changes)
|
||||||
Bugfix: Multiple-sent messages had path appearing delays until rerender
|
* Bugfix: Multiple-sent messages had path appearing delays until rerender
|
||||||
Bugfix: Fix ack/message race condition that caused dropped ack displays until rerender
|
* Bugfix: Fix ack/message race condition that caused dropped ack displays until rerender
|
||||||
Misc: Dedupe contacts/rooms by key and not name to prevent name collisions creating unreachable conversations
|
* Misc: Dedupe contacts/rooms by key and not name to prevent name collisions creating unreachable conversations
|
||||||
Misc: s/stopped/idle/ for room finder
|
* Misc: s/stopped/idle/ for room finder
|
||||||
|
|
||||||
## [1.9.3] - 2026-02-12
|
## [1.9.3] - 2026-02-12
|
||||||
|
|
||||||
Feature: Upgrade the room finder to support two-word rooms
|
* Feature: Upgrade the room finder to support two-word rooms
|
||||||
|
|
||||||
## [1.9.2] - 2026-02-12
|
## [1.9.2] - 2026-02-12
|
||||||
|
|
||||||
Feature: Options dialog sucks less
|
* Feature: Options dialog sucks less
|
||||||
Bugfix: Move tests to isolated memory DB
|
* Bugfix: Move tests to isolated memory DB
|
||||||
Bugfix: Mention case sensitivity
|
* Bugfix: Mention case sensitivity
|
||||||
Bugfix: Stale header retention on settings page view
|
* Bugfix: Stale header retention on settings page view
|
||||||
Bugfix: Non-isolated path writing
|
* Bugfix: Non-isolated path writing
|
||||||
Bugfix: Nullable contact fields are now passed as real nulls
|
* Bugfix: Nullable contact fields are now passed as real nulls
|
||||||
Bugfix: Look at all fields on message reconcile, not just text
|
* Bugfix: Look at all fields on message reconcile, not just text
|
||||||
Bugfix: Make mark-all-as-read atomic
|
* Bugfix: Make mark-all-as-read atomic
|
||||||
Misc: Purge unused WS handlers from back when we did chans and contacts over WS, not API
|
* Misc: Purge unused WS handlers from back when we did chans and contacts over WS, not API
|
||||||
Misc: Massive test and AGENTS.md overhauls and additions
|
* Misc: Massive test and AGENTS.md overhauls and additions
|
||||||
|
|
||||||
## [1.9.1] - 2026-02-10
|
## [1.9.1] - 2026-02-10
|
||||||
|
|
||||||
Feature: Contacts and channels use keys, not names
|
* Feature: Contacts and channels use keys, not names
|
||||||
Bugfix: Fix falsy casting of 0 in lat lon and timing data
|
* Bugfix: Fix falsy casting of 0 in lat lon and timing data
|
||||||
Bugfix: Show message length in bytes, not chars
|
* Bugfix: Show message length in bytes, not chars
|
||||||
Bugfix: Fix phantom unread badges on focused convos
|
* Bugfix: Fix phantom unread badges on focused convos
|
||||||
Misc: Bot invocation to async
|
* Misc: Bot invocation to async
|
||||||
Misc: Use full key, not prefix, where we can
|
* Misc: Use full key, not prefix, where we can
|
||||||
|
|
||||||
## [1.9.0] - 2026-02-10
|
## [1.9.0] - 2026-02-10
|
||||||
|
|
||||||
Feature: Favorited contacts are preferentially loaded onto the radio
|
* Feature: Favorited contacts are preferentially loaded onto the radio
|
||||||
Feature: Add recent-message caching for fast switching
|
* Feature: Add recent-message caching for fast switching
|
||||||
Feature: Add echo paths modal when echo-heard checkbox is clicked
|
* Feature: Add echo paths modal when echo-heard checkbox is clicked
|
||||||
Feature: Add experimental byte-perfect double-send for bad RF environments to try to punch the message out
|
* Feature: Add experimental byte-perfect double-send for bad RF environments to try to punch the message out
|
||||||
Frontend: Better styling on echo + message path display
|
Frontend: Better styling on echo + message path display
|
||||||
Bugfix: Prevent frontend static file serving path traversal vuln
|
* Bugfix: Prevent frontend static file serving path traversal vuln
|
||||||
Bugfix: Safer prefix-claiming for DMs we don't have the key for
|
* Bugfix: Safer prefix-claiming for DMs we don't have the key for
|
||||||
Bugfix: Prevent injection from mentions with special characters
|
* Bugfix: Prevent injection from mentions with special characters
|
||||||
Bugfix: Fix repeaters comms showing in wrong channel when repeater operations are in flight and the channel is changed quickly
|
* Bugfix: Fix repeaters comms showing in wrong channel when repeater operations are in flight and the channel is changed quickly
|
||||||
Bugfix: App can boot and test without a frontend dir
|
* Bugfix: App can boot and test without a frontend dir
|
||||||
Misc: Improve and consistent-ify (?) backend radio operation lock management
|
* Misc: Improve and consistent-ify (?) backend radio operation lock management
|
||||||
Misc: Frontend performance and safety enhancements
|
* Misc: Frontend performance and safety enhancements
|
||||||
Misc: Move builds to non-bundled; usage requires building the Frontend
|
* Misc: Move builds to non-bundled; usage requires building the Frontend
|
||||||
Misc: Update tests and agent docs
|
* Misc: Update tests and agent docs
|
||||||
|
|
||||||
## [1.8.0] - 2026-02-07
|
## [1.8.0] - 2026-02-07
|
||||||
|
|
||||||
Feature: Single hop ping
|
* Feature: Single hop ping
|
||||||
Feature: PWA viewport fixes(thanks @rgregg)
|
* Feature: PWA viewport fixes(thanks @rgregg)
|
||||||
Feature (?): No frontend distribution; build it yourself ;P
|
Feature (?): No frontend distribution; build it yourself ;P
|
||||||
Bugfix: Fix channel message send race condition (concurrent sends could corrupt shared radio slot)
|
* Bugfix: Fix channel message send race condition (concurrent sends could corrupt shared radio slot)
|
||||||
Bugfix: Fix TOCTOU race in radio reconnect (duplicate connections under contention)
|
* Bugfix: Fix TOCTOU race in radio reconnect (duplicate connections under contention)
|
||||||
Bugfix: Better guarding around reconnection
|
* Bugfix: Better guarding around reconnection
|
||||||
Bugfix: Duplicate websocket connection fixes
|
* Bugfix: Duplicate websocket connection fixes
|
||||||
Bugfix: Settings tab error cleanliness on tab swap
|
* Bugfix: Settings tab error cleanliness on tab swap
|
||||||
Bugfix: Fix path traversal vuln
|
* Bugfix: Fix path traversal vuln
|
||||||
UI: Swap visualizer legend ordering (yay prettier)
|
UI: Swap visualizer legend ordering (yay prettier)
|
||||||
Misc: Perf and locking improvements
|
* Misc: Perf and locking improvements
|
||||||
Misc: Always flood advertisements
|
* Misc: Always flood advertisements
|
||||||
Misc: Better packet dupe handling
|
* Misc: Better packet dupe handling
|
||||||
Misc: Dead code cleanup, test improvements
|
* Misc: Dead code cleanup, test improvements
|
||||||
|
|
||||||
## [1.7.1] - 2026-02-03
|
## [1.7.1] - 2026-02-03
|
||||||
|
|
||||||
Feature: Clickable hyperlinks
|
* Feature: Clickable hyperlinks
|
||||||
Bugfix: More consistent public key normalization
|
* Bugfix: More consistent public key normalization
|
||||||
Bugfix: Use more reliable cursor paging
|
* Bugfix: Use more reliable cursor paging
|
||||||
Bugfix: Fix null timestamp dedupe failure
|
* Bugfix: Fix null timestamp dedupe failure
|
||||||
Bugfix: More consistent prefix-based message claiming on key receipt
|
* Bugfix: More consistent prefix-based message claiming on key receipt
|
||||||
Misc: Bot can respond to its own messages
|
* Misc: Bot can respond to its own messages
|
||||||
Misc: Additional tests
|
* Misc: Additional tests
|
||||||
Misc: Remove unneeded message dedupe logic
|
* Misc: Remove unneeded message dedupe logic
|
||||||
Misc: Resync settings after radio settings mutation
|
* Misc: Resync settings after radio settings mutation
|
||||||
|
|
||||||
## [1.7.0] - 2026-01-27
|
## [1.7.0] - 2026-01-27
|
||||||
|
|
||||||
Feature: Multi-bot functionality
|
* Feature: Multi-bot functionality
|
||||||
Bugfix: Adjust bot code editor display and add line numbers
|
* Bugfix: Adjust bot code editor display and add line numbers
|
||||||
Bugfix: Fix clock filtering and contact lookup behavior bugs
|
* Bugfix: Fix clock filtering and contact lookup behavior bugs
|
||||||
Bugfix: Fix repeater message duplication issue
|
* Bugfix: Fix repeater message duplication issue
|
||||||
Bugfix: Correct outbound message timestamp assignment (affecting outgoing messages seen as incoming)
|
* Bugfix: Correct outbound message timestamp assignment (affecting outgoing messages seen as incoming)
|
||||||
UI: Move advertise button to identity tab
|
UI: Move advertise button to identity tab
|
||||||
Misc: Clarify fallback functionality for missing private key export in logs
|
* Misc: Clarify fallback functionality for missing private key export in logs
|
||||||
|
|
||||||
## [1.6.0] - 2026-01-26
|
## [1.6.0] - 2026-01-26
|
||||||
|
|
||||||
Feature: Visualizer: extract public key from AnonReq, add heuristic repeater disambiguation, add reset button, draggable nodes
|
* Feature: Visualizer: extract public key from AnonReq, add heuristic repeater disambiguation, add reset button, draggable nodes
|
||||||
Feature: Customizable advertising interval
|
* Feature: Customizable advertising interval
|
||||||
Feature: In-app bot setup
|
* Feature: In-app bot setup
|
||||||
Bugfix: Force contact onto radio before DM send
|
* Bugfix: Force contact onto radio before DM send
|
||||||
Misc: Remove unused code
|
* Misc: Remove unused code
|
||||||
|
|
||||||
## [1.5.0] - 2026-01-19
|
## [1.5.0] - 2026-01-19
|
||||||
|
|
||||||
Feature: Network visualizer
|
* Feature: Network visualizer
|
||||||
|
|
||||||
## [1.4.1] - 2026-01-19
|
## [1.4.1] - 2026-01-19
|
||||||
|
|
||||||
Feature: Add option to attempt historical DM decrypt on new-contact advertisement (disabled by default)
|
* Feature: Add option to attempt historical DM decrypt on new-contact advertisement (disabled by default)
|
||||||
Feature: Server-side preference management for favorites, read status, etc.
|
* Feature: Server-side preference management for favorites, read status, etc.
|
||||||
UI: More compact hop labelling
|
UI: More compact hop labelling
|
||||||
Bugfix: Misc. race conditions and websocket handling
|
* Bugfix: Misc. race conditions and websocket handling
|
||||||
Bugfix: Reduce fetching cadence by loading all contact data at start to prevent fetches on advertise-driven update
|
* Bugfix: Reduce fetching cadence by loading all contact data at start to prevent fetches on advertise-driven update
|
||||||
|
|
||||||
## [1.4.0] - 2026-01-18
|
## [1.4.0] - 2026-01-18
|
||||||
|
|
||||||
UI: Improve button layout for room searcher
|
UI: Improve button layout for room searcher
|
||||||
UI: Improve favicon coloring
|
UI: Improve favicon coloring
|
||||||
UI: Improve status bar button layout on small screen
|
UI: Improve status bar button layout on small screen
|
||||||
Feature: Show multi-path hop display with distance estimates
|
* Feature: Show multi-path hop display with distance estimates
|
||||||
Feature: Search rooms and contacts by key, not just name
|
* Feature: Search rooms and contacts by key, not just name
|
||||||
Bugfix: Historical DM decryption now works as expected
|
* Bugfix: Historical DM decryption now works as expected
|
||||||
Bugfix: Don't double-set active conversation after addition; wait for backend room name normalization
|
* Bugfix: Don't double-set active conversation after addition; wait for backend room name normalization
|
||||||
|
|
||||||
## [1.3.1] - 2026-01-17
|
## [1.3.1] - 2026-01-17
|
||||||
|
|
||||||
UI: Rework restart handling
|
UI: Rework restart handling
|
||||||
Feature: Add `dutycyle_start` command to logged-in repeater session to start five min duty cycle tracking
|
* Feature: Add `dutycyle_start` command to logged-in repeater session to start five min duty cycle tracking
|
||||||
Bug: Improve error message rendering from server-side errors
|
Bug: Improve error message rendering from server-side errors
|
||||||
UI: Remove octothorpe from channel listing
|
UI: Remove octothorpe from channel listing
|
||||||
|
|
||||||
## [1.3.0] - 2026-01-17
|
## [1.3.0] - 2026-01-17
|
||||||
|
|
||||||
Feature: Rework database schema to drop unnecessary columns and dedupe payloads at the DB level
|
* Feature: Rework database schema to drop unnecessary columns and dedupe payloads at the DB level
|
||||||
Feature: Massive frontend settings overhaul. It ain't gorgeous but it's easier to navigate.
|
* Feature: Massive frontend settings overhaul. It ain't gorgeous but it's easier to navigate.
|
||||||
Feature: Drop repeater login wait time; vestigial from debugging a different issue
|
* Feature: Drop repeater login wait time; vestigial from debugging a different issue
|
||||||
|
|
||||||
## [1.2.1] - 2026-01-17
|
## [1.2.1] - 2026-01-17
|
||||||
|
|
||||||
@@ -393,27 +481,27 @@ Update: Update meshcore-hashtag-cracker to include sender-identification correct
|
|||||||
|
|
||||||
## [1.2.0] - 2026-01-16
|
## [1.2.0] - 2026-01-16
|
||||||
|
|
||||||
Feature: Add favorites
|
* Feature: Add favorites
|
||||||
|
|
||||||
## [1.1.0] - 2026-01-14
|
## [1.1.0] - 2026-01-14
|
||||||
|
|
||||||
Bugfix: Use actual pathing data from advertisements, not just always flood (oops)
|
* Bugfix: Use actual pathing data from advertisements, not just always flood (oops)
|
||||||
Bugfix: Autosync radio clock periodically to prevent drift (would show up most commonly as issues with repeater comms)
|
* Bugfix: Autosync radio clock periodically to prevent drift (would show up most commonly as issues with repeater comms)
|
||||||
|
|
||||||
## [1.0.3] - 2026-01-13
|
## [1.0.3] - 2026-01-13
|
||||||
|
|
||||||
Bugfix: Add missing test management packages
|
* Bugfix: Add missing test management packages
|
||||||
Improvement: Drop unnecessary repeater timeouts, and retain timeout for login only -- repeater ops are faster AND more reliable!
|
* Improvement: Drop unnecessary repeater timeouts, and retain timeout for login only -- repeater ops are faster AND more reliable!
|
||||||
|
|
||||||
## [1.0.2] - 2026-01-13
|
## [1.0.2] - 2026-01-13
|
||||||
|
|
||||||
Improvement: Add delays between router ops to prevent traffic collisions
|
* Improvement: Add delays between router ops to prevent traffic collisions
|
||||||
|
|
||||||
## [1.0.1] - 2026-01-13
|
## [1.0.1] - 2026-01-13
|
||||||
|
|
||||||
Bugixes: Cleaner DB shutdown, radio reconnect contention, packet dedupe garbage removal
|
* Bugixes: Cleaner DB shutdown, radio reconnect contention, packet dedupe garbage removal
|
||||||
|
|
||||||
## [1.0.0] - 2026-01-13
|
## [1.0.0] - 2026-01-13
|
||||||
|
|
||||||
Initial full release!
|
* Initial full release!
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -48,7 +48,7 @@ Run both the backend and `npm run dev` for hot-reloading frontend development.
|
|||||||
Run the full quality suite before proposing or handing off code changes:
|
Run the full quality suite before proposing or handing off code changes:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/all_quality.sh
|
./scripts/quality/all_quality.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
That runs linting, formatting, type checking, tests, and builds for both backend and frontend.
|
That runs linting, formatting, type checking, tests, and builds for both backend and frontend.
|
||||||
@@ -78,6 +78,7 @@ These tests are only guaranteed to run correctly in a narrow subset of environme
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd tests/e2e
|
cd tests/e2e
|
||||||
|
npm install
|
||||||
npx playwright test # headless
|
npx playwright test # headless
|
||||||
npx playwright test --headed # you can probably guess
|
npx playwright test --headed # you can probably guess
|
||||||
```
|
```
|
||||||
|
|||||||
+66
-2
@@ -1,6 +1,6 @@
|
|||||||
# Third-Party Licenses
|
# Third-Party Licenses
|
||||||
|
|
||||||
Auto-generated by `scripts/collect_licenses.sh` — do not edit by hand.
|
Auto-generated by `scripts/build/collect_licenses.sh` — do not edit by hand.
|
||||||
|
|
||||||
## Backend (Python) Dependencies
|
## Backend (Python) Dependencies
|
||||||
|
|
||||||
@@ -330,7 +330,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
### meshcore (2.2.29) — MIT
|
### meshcore (2.3.2) — MIT
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Full license text</summary>
|
<summary>Full license text</summary>
|
||||||
@@ -1592,6 +1592,70 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
### react-swipeable (7.0.2) — MIT
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Full license text</summary>
|
||||||
|
|
||||||
|
```
|
||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (C) 2014-2022 Josh Perez
|
||||||
|
Copyright (C) 2014-2022 Brian Emil Hartz
|
||||||
|
Copyright (C) 2022 Formidable Labs, Inc.
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### recharts (3.8.1) — MIT
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Full license text</summary>
|
||||||
|
|
||||||
|
```
|
||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2015-present recharts
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
### sonner (2.0.7) — MIT
|
### sonner (2.0.7) — MIT
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Backend server + browser interface for MeshCore mesh radio networks. Connect you
|
|||||||
* Run multiple Python bots that can analyze messages and respond to DMs and channels
|
* Run multiple Python bots that can analyze messages and respond to DMs and channels
|
||||||
* Monitor unlimited contacts and channels (radio limits don't apply -- packets are decrypted server-side)
|
* Monitor unlimited contacts and channels (radio limits don't apply -- packets are decrypted server-side)
|
||||||
* Access your radio remotely over your network or VPN
|
* Access your radio remotely over your network or VPN
|
||||||
* Search for hashtag room names for channels you don't have keys for yet
|
* Search for hashtag channel names for channels you don't have keys for yet
|
||||||
* Forward packets to MQTT, LetsMesh, MeshRank, SQS, Apprise, etc.
|
* Forward packets to MQTT, LetsMesh, MeshRank, SQS, Apprise, etc.
|
||||||
* Use the more recent 1.14 firmwares which support multibyte pathing
|
* Use the more recent 1.14 firmwares which support multibyte pathing
|
||||||
* Visualize the mesh as a map or node set, view repeater stats, and more!
|
* Visualize the mesh as a map or node set, view repeater stats, and more!
|
||||||
@@ -16,12 +16,6 @@ Backend server + browser interface for MeshCore mesh radio networks. Connect you
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Disclaimer
|
|
||||||
|
|
||||||
This is developed with very heavy agentic assistance -- there is no warranty of fitness for any purpose. It's been lovingly guided by an engineer with a passion for clean code and good tests, but it's still mostly LLM output, so you may find some bugs.
|
|
||||||
|
|
||||||
If extending, have your LLM read the three `AGENTS.md` files: `./AGENTS.md`, `./frontend/AGENTS.md`, and `./app/AGENTS.md`.
|
|
||||||
|
|
||||||
## Start Here
|
## Start Here
|
||||||
|
|
||||||
Most users should choose one of these paths:
|
Most users should choose one of these paths:
|
||||||
@@ -41,8 +35,6 @@ If you plan to contribute, read [CONTRIBUTING.md](CONTRIBUTING.md).
|
|||||||
- [UV](https://astral.sh/uv) package manager: `curl -LsSf https://astral.sh/uv/install.sh | sh`
|
- [UV](https://astral.sh/uv) package manager: `curl -LsSf https://astral.sh/uv/install.sh | sh`
|
||||||
- MeshCore radio connected via USB serial, TCP, or BLE
|
- MeshCore radio connected via USB serial, TCP, or BLE
|
||||||
|
|
||||||
If you are on a low-resource system and do not want to build the frontend locally, download the release zip named `remoteterm-prebuilt-frontend-vX.X.X-<short hash>.zip`. That bundle includes `frontend/prebuilt`, so you can run the app without doing a frontend build from source.
|
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Finding your serial port</summary>
|
<summary>Finding your serial port</summary>
|
||||||
|
|
||||||
@@ -97,61 +89,83 @@ Access the app at http://localhost:8000.
|
|||||||
|
|
||||||
Source checkouts expect a normal frontend build in `frontend/dist`.
|
Source checkouts expect a normal frontend build in `frontend/dist`.
|
||||||
|
|
||||||
## Path 1.5: Use The Prebuilt Release Zip
|
> [!NOTE]
|
||||||
|
> Running on lightweight hardware, or just do not want to build the frontend locally? From a cloned checkout, run `python3 scripts/setup/fetch_prebuilt_frontend.py` to fetch and unpack a prebuilt frontend into `frontend/prebuilt`, then start the app normally with `uv run uvicorn app.main:app --host 0.0.0.0 --port 8000`.
|
||||||
|
|
||||||
Release zips can be found as an asset within the [releases listed here](https://github.com/jkingsman/Remote-Terminal-for-MeshCore/releases). This can be beneficial on resource constrained systems that cannot cope with the RAM-hungry frontend build process.
|
> [!TIP]
|
||||||
|
> On Linux, you can also install RemoteTerm as a persistent `systemd` service that starts on boot and restarts automatically on failure:
|
||||||
If you downloaded the release zip instead of cloning the repo, unpack it and run:
|
>
|
||||||
|
> ```bash
|
||||||
```bash
|
> bash scripts/setup/install_service.sh
|
||||||
cd Remote-Terminal-for-MeshCore
|
> ```
|
||||||
uv sync
|
>
|
||||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
> For the full service workflow and post-install operations, see [README_ADVANCED.md](README_ADVANCED.md).
|
||||||
```
|
|
||||||
|
|
||||||
The release bundle includes `frontend/prebuilt`, so it does not require a local frontend build.
|
|
||||||
|
|
||||||
## Path 2: Docker
|
## Path 2: Docker
|
||||||
|
|
||||||
> **Warning:** Docker has had reports intermittent issues with serial event subscriptions. The native method above is more reliable.
|
> **Warning:** Docker has had reports intermittent issues with serial event subscriptions. The native method above is more reliable.
|
||||||
|
|
||||||
Edit `docker-compose.yaml` to set a serial device for passthrough, or uncomment your transport (serial or TCP). Then:
|
Local Docker builds are architecture-native by default. On Apple Silicon Macs and ARM64 Linux hosts such as Raspberry Pi, `docker compose build` / `docker compose up --build` will produce an ARM64 image unless you override the platform.
|
||||||
|
|
||||||
|
For serial-device passthrough, use rootful Docker. In practice that usually means starting the stack with `sudo docker compose ...` unless your Docker daemon is already configured for rootful access via your user/group. Rootless Docker has been observed to fail on serial-device mappings even when the compose file itself is correct.
|
||||||
|
|
||||||
|
Create a local `docker-compose.yml` in one of two ways:
|
||||||
|
|
||||||
|
1. Copy the example file and edit it by hand:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
cp docker-compose.example.yml docker-compose.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
The database is stored in `./data/` (bind-mounted), so the container shares the same database as the native app. To rebuild after pulling updates:
|
2. Or generate one interactively:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d --build
|
bash scripts/setup/install_docker.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
To use the prebuilt Docker Hub image instead of building locally, replace:
|
Your local `docker-compose.yml` is gitignored so future pulls do not overwrite your Docker settings.
|
||||||
|
|
||||||
|
The guided Docker flow can collect BLE settings, but BLE access from Docker still needs manual compose customization such as Bluetooth passthrough and possibly privileged mode or host networking. If you want the simpler path for BLE, use the regular Python launch flow instead.
|
||||||
|
|
||||||
|
Then customize the local compose file for your transport and launch:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo docker compose up # add -d for background once you validate it's working
|
||||||
|
```
|
||||||
|
|
||||||
|
The database is stored in `./data/` (bind-mounted), so the container shares the same database as the native app.
|
||||||
|
|
||||||
|
To rebuild after pulling updates:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo docker compose pull
|
||||||
|
sudo docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
The example file and setup script default to the published Docker Hub image. To build locally from your checkout instead, replace:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
build: .
|
image: docker.io/jkingsman/remoteterm-meshcore:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
with:
|
with:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
image: jkingsman/remoteterm-meshcore:latest
|
build: .
|
||||||
```
|
```
|
||||||
|
|
||||||
Then run:
|
Then run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose pull
|
sudo docker compose up -d --build
|
||||||
docker compose up -d
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The container runs as root by default for maximum serial passthrough compatibility across host setups. On Linux, if you switch between native and Docker runs, `./data` can end up root-owned. If you do not need that serial compatibility behavior, you can enable the optional `user: "${UID:-1000}:${GID:-1000}"` line in `docker-compose.yaml` to keep ownership aligned with your host user.
|
The container runs as root by default for maximum serial passthrough compatibility across host setups. On Linux, if you switch between native and Docker runs, `./data` can end up root-owned. If you do not need that serial compatibility behavior, you can enable the optional `user: "${UID:-1000}:${GID:-1000}"` line in `docker-compose.yml` to keep ownership aligned with your host user.
|
||||||
|
|
||||||
To stop:
|
To stop:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose down
|
sudo docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
## Standard Environment Variables
|
## Standard Environment Variables
|
||||||
@@ -192,10 +206,16 @@ $env:MESHCORE_SERIAL_PORT="COM8" # or your COM port
|
|||||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
If you enable Basic Auth, protect the app with HTTPS. HTTP Basic credentials are not safe on plain HTTP.
|
If you enable Basic Auth, protect the app with HTTPS. HTTP Basic credentials are not safe on plain HTTP. Also note that the app's permissive CORS policy is a deliberate trusted-network tradeoff, so cross-origin browser JavaScript is not a reliable way to use that Basic Auth gate.
|
||||||
|
|
||||||
## Where To Go Next
|
## Where To Go Next
|
||||||
|
|
||||||
- Advanced setup, troubleshooting, HTTPS, systemd, remediation variables, and debug logging: [README_ADVANCED.md](README_ADVANCED.md)
|
- Advanced setup, troubleshooting, HTTPS, systemd, remediation variables, and debug logging: [README_ADVANCED.md](README_ADVANCED.md)
|
||||||
- Contributing, tests, linting, E2E notes, and important AGENTS files: [CONTRIBUTING.md](CONTRIBUTING.md)
|
- Contributing, tests, linting, E2E notes, and important AGENTS files: [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||||
- Live API docs after the backend is running: http://localhost:8000/docs
|
- Live API docs after the backend is running: http://localhost:8000/docs
|
||||||
|
|
||||||
|
## Disclaimer
|
||||||
|
|
||||||
|
This is developed with very heavy agentic assistance -- there is no warranty of fitness for any purpose. It's been lovingly guided by an engineer with a passion for clean code and good tests, but it's still mostly LLM output, so you may find some bugs.
|
||||||
|
|
||||||
|
If extending, have your LLM read the three `AGENTS.md` files: `./AGENTS.md`, `./frontend/AGENTS.md`, and `./app/AGENTS.md`.
|
||||||
|
|||||||
+4
-51
@@ -21,7 +21,7 @@ If the audit finds a mismatch, you'll see an error in the application UI and you
|
|||||||
|
|
||||||
## HTTPS
|
## HTTPS
|
||||||
|
|
||||||
WebGPU room-finding requires a secure context when you are not on `localhost`.
|
WebGPU channel-finding requires a secure context when you are not on `localhost`.
|
||||||
|
|
||||||
Generate a local cert and start the backend with TLS:
|
Generate a local cert and start the backend with TLS:
|
||||||
|
|
||||||
@@ -46,60 +46,13 @@ Accept the browser warning, or use [mkcert](https://github.com/FiloSottile/mkcer
|
|||||||
|
|
||||||
## Systemd Service
|
## Systemd Service
|
||||||
|
|
||||||
Assumes you are running from `/opt/remoteterm`; adjust paths if you deploy elsewhere.
|
On Linux systems, this is the recommended installation method if you want RemoteTerm set up as a persistent systemd service that starts automatically on boot and restarts automatically if it crashes. Run the installer script from the repo root. It runs as your current user, installs from wherever you cloned the repo, and prints a quick-reference cheatsheet when done — no separate service account or path juggling required.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create service user
|
bash scripts/setup/install_service.sh
|
||||||
sudo useradd -r -m -s /bin/false remoteterm
|
|
||||||
|
|
||||||
# Install to /opt/remoteterm
|
|
||||||
sudo mkdir -p /opt/remoteterm
|
|
||||||
sudo cp -r . /opt/remoteterm/
|
|
||||||
sudo chown -R remoteterm:remoteterm /opt/remoteterm
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
cd /opt/remoteterm
|
|
||||||
sudo -u remoteterm uv venv
|
|
||||||
sudo -u remoteterm uv sync
|
|
||||||
|
|
||||||
# If deploying from a source checkout, build the frontend first
|
|
||||||
sudo -u remoteterm bash -lc 'cd /opt/remoteterm/frontend && npm install && npm run build'
|
|
||||||
|
|
||||||
# If deploying from the release zip artifact, frontend/prebuilt is already present
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Create `/etc/systemd/system/remoteterm.service` with:
|
You can also rerun the script later to change transport, bot, or auth settings. If the service is already running, the installer stops it, rewrites the unit file, reloads systemd, and starts it again with the new configuration.
|
||||||
|
|
||||||
```ini
|
|
||||||
[Unit]
|
|
||||||
Description=RemoteTerm for MeshCore
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=remoteterm
|
|
||||||
Group=remoteterm
|
|
||||||
WorkingDirectory=/opt/remoteterm
|
|
||||||
ExecStart=/opt/remoteterm/.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
Environment=MESHCORE_DATABASE_PATH=/opt/remoteterm/data/meshcore.db
|
|
||||||
# Uncomment and set if auto-detection doesn't work:
|
|
||||||
# Environment=MESHCORE_SERIAL_PORT=/dev/ttyUSB0
|
|
||||||
SupplementaryGroups=dialout
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
```
|
|
||||||
|
|
||||||
Then install and start it:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
sudo systemctl enable --now remoteterm
|
|
||||||
sudo systemctl status remoteterm
|
|
||||||
sudo journalctl -u remoteterm -f
|
|
||||||
```
|
|
||||||
|
|
||||||
## Debug Logging And Bug Reports
|
## Debug Logging And Bug Reports
|
||||||
|
|
||||||
|
|||||||
+48
-20
@@ -25,18 +25,22 @@ Keep it aligned with `app/` source files and router behavior.
|
|||||||
app/
|
app/
|
||||||
├── main.py # App startup/lifespan, router registration, static frontend mounting
|
├── main.py # App startup/lifespan, router registration, static frontend mounting
|
||||||
├── config.py # Env-driven runtime settings
|
├── config.py # Env-driven runtime settings
|
||||||
|
├── channel_constants.py # Public/default channel constants shared across sync/send logic
|
||||||
├── database.py # SQLite connection + base schema + migration runner
|
├── database.py # SQLite connection + base schema + migration runner
|
||||||
├── migrations.py # Schema migrations (SQLite user_version)
|
├── migrations.py # Schema migrations (SQLite user_version)
|
||||||
├── models.py # Pydantic request/response models and typed write contracts (for example ContactUpsert)
|
├── models.py # Pydantic request/response models and typed write contracts (for example ContactUpsert)
|
||||||
|
├── version_info.py # Unified version/build metadata resolution for debug + startup surfaces
|
||||||
├── repository/ # Data access layer (contacts, channels, messages, raw_packets, settings, fanout)
|
├── repository/ # Data access layer (contacts, channels, messages, raw_packets, settings, fanout)
|
||||||
├── services/ # Shared orchestration/domain services
|
├── services/ # Shared orchestration/domain services
|
||||||
│ ├── messages.py # Shared message creation, dedup, ACK application
|
│ ├── messages.py # Shared message creation, dedup, ACK application
|
||||||
│ ├── message_send.py # Direct send, channel send, resend workflows
|
│ ├── message_send.py # Direct send, channel send, resend workflows
|
||||||
│ ├── dm_ingest.py # Shared direct-message ingest / dedup seam for packet + fallback paths
|
│ ├── dm_ingest.py # Shared direct-message ingest / dedup seam for packet + fallback paths
|
||||||
|
│ ├── dm_ack_apply.py # Shared DM ACK application over pending/buffered ACK state
|
||||||
│ ├── dm_ack_tracker.py # Pending DM ACK state
|
│ ├── dm_ack_tracker.py # Pending DM ACK state
|
||||||
│ ├── contact_reconciliation.py # Prefix-claim, sender-key backfill, name-history wiring
|
│ ├── contact_reconciliation.py # Prefix-claim, sender-key backfill, name-history wiring
|
||||||
│ ├── radio_lifecycle.py # Post-connect setup and reconnect/setup helpers
|
│ ├── radio_lifecycle.py # Post-connect setup and reconnect/setup helpers
|
||||||
│ ├── radio_commands.py # Radio config/private-key command workflows
|
│ ├── radio_commands.py # Radio config/private-key command workflows
|
||||||
|
│ ├── radio_noise_floor.py # In-memory local radio noise-floor sampling/history
|
||||||
│ └── radio_runtime.py # Router/dependency seam over the global RadioManager
|
│ └── radio_runtime.py # Router/dependency seam over the global RadioManager
|
||||||
├── radio.py # RadioManager transport/session state + lock management
|
├── radio.py # RadioManager transport/session state + lock management
|
||||||
├── radio_sync.py # Polling, sync, periodic advertisement loop
|
├── radio_sync.py # Polling, sync, periodic advertisement loop
|
||||||
@@ -61,6 +65,8 @@ app/
|
|||||||
├── messages.py
|
├── messages.py
|
||||||
├── packets.py
|
├── packets.py
|
||||||
├── read_state.py
|
├── read_state.py
|
||||||
|
├── rooms.py
|
||||||
|
├── server_control.py
|
||||||
├── settings.py
|
├── settings.py
|
||||||
├── fanout.py
|
├── fanout.py
|
||||||
├── repeaters.py
|
├── repeaters.py
|
||||||
@@ -101,27 +107,31 @@ app/
|
|||||||
- Packet `path_len` values are hop counts, not byte counts.
|
- Packet `path_len` values are hop counts, not byte counts.
|
||||||
- Hop width comes from the packet or radio `path_hash_mode`: `0` = 1-byte, `1` = 2-byte, `2` = 3-byte.
|
- Hop width comes from the packet or radio `path_hash_mode`: `0` = 1-byte, `1` = 2-byte, `2` = 3-byte.
|
||||||
- Channel slot count comes from firmware-reported `DEVICE_INFO.max_channels`; do not hardcode `40` when scanning/offloading channel slots.
|
- Channel slot count comes from firmware-reported `DEVICE_INFO.max_channels`; do not hardcode `40` when scanning/offloading channel slots.
|
||||||
- Channel sends use a session-local LRU slot cache after startup channel offload clears the radio. Repeated sends to the same room reuse the loaded slot; new rooms fill free slots up to the discovered channel capacity, then evict the least recently used cached room.
|
- Channel sends use a session-local LRU slot cache after startup channel offload clears the radio. Repeated sends to the same channel reuse the loaded slot; new channels fill free slots up to the discovered channel capacity, then evict the least recently used cached channel.
|
||||||
- TCP radios do not reuse cached slot contents. For TCP, channel sends still force `set_channel(...)` before every send because this backend does not have exclusive device access.
|
- TCP radios do not reuse cached slot contents. For TCP, channel sends still force `set_channel(...)` before every send because this backend does not have exclusive device access.
|
||||||
- `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true` disables slot reuse on all transports and forces the old always-`set_channel(...)` behavior before every channel send.
|
- `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true` disables slot reuse on all transports and forces the old always-`set_channel(...)` behavior before every channel send.
|
||||||
- Contacts persist `out_path_hash_mode` in the database so contact sync and outbound DM routing reuse the exact stored mode instead of inferring from path bytes.
|
- Contacts persist canonical direct-route fields (`direct_path`, `direct_path_len`, `direct_path_hash_mode`) so contact sync and outbound DM routing reuse the exact stored hop width instead of inferring from path bytes.
|
||||||
- Contacts may also persist `route_override_path`, `route_override_len`, and `route_override_hash_mode`. `Contact.to_radio_dict()` gives these override fields precedence over learned `last_path*`, while advert processing still updates the learned route for telemetry/fallback.
|
- Direct-route sources are limited to radio contact sync (`out_path`) and PATH/path-discovery updates. This mirrors firmware `onContactPathRecv(...)`, which replaces `ContactInfo.out_path` when a new returned path is heard.
|
||||||
|
- `route_override_path`, `route_override_len`, and `route_override_hash_mode` take precedence over the learned direct route for radio-bound sends.
|
||||||
|
- Advertisement paths are stored only in `contact_advert_paths` for analytics/visualization. They are not part of `Contact.to_radio_dict()` or DM route selection.
|
||||||
- `contact_advert_paths` identity is `(public_key, path_hex, path_len)` because the same hex bytes can represent different routes at different hop widths.
|
- `contact_advert_paths` identity is `(public_key, path_hex, path_len)` because the same hex bytes can represent different routes at different hop widths.
|
||||||
|
|
||||||
### Read/unread state
|
### Read/unread state
|
||||||
|
|
||||||
- Server is source of truth (`contacts.last_read_at`, `channels.last_read_at`).
|
- Server is source of truth (`contacts.last_read_at`, `channels.last_read_at`).
|
||||||
- `GET /api/read-state/unreads` returns counts, mention flags, and `last_message_times`.
|
- `GET /api/read-state/unreads` returns counts, mention flags, `last_message_times`, and `last_read_ats`.
|
||||||
|
|
||||||
### DM ingest + ACKs
|
### DM ingest + ACKs
|
||||||
|
|
||||||
- `services/dm_ingest.py` is the one place that should decide fallback-context resolution, DM dedup/reconciliation, and packet-linked vs. content-based storage behavior.
|
- `services/dm_ingest.py` is the one place that should decide fallback-context resolution, DM dedup/reconciliation, and packet-linked vs. content-based storage behavior.
|
||||||
- `CONTACT_MSG_RECV` is a fallback path, not a parallel source of truth. If you change DM storage behavior, trace both `event_handlers.py` and `packet_processor.py`.
|
- `CONTACT_MSG_RECV` is a fallback path, not a parallel source of truth. If you change DM storage behavior, trace both `event_handlers.py` and `packet_processor.py`.
|
||||||
- DM ACK tracking is an in-memory pending/buffered map in `services/dm_ack_tracker.py`, with periodic expiry from `radio_sync.py`.
|
- DM ACK tracking is an in-memory pending/buffered map in `services/dm_ack_tracker.py`, with periodic expiry from `radio_sync.py`.
|
||||||
- Outgoing DMs send once inline, store/broadcast immediately after the first successful `MSG_SENT`, then may retry up to 2 more times in the background if still unacked.
|
- Outgoing DMs send once inline, store/broadcast immediately after the first successful `MSG_SENT`, then may retry up to 2 more times in the background only when the initial `MSG_SENT` result includes an expected ACK code and the message remains unacked.
|
||||||
- DM retry timing follows the firmware-provided `suggested_timeout` from `PACKET_MSG_SENT`; do not replace it with a fixed app timeout unless you intentionally want more aggressive duplicate-prone retries.
|
- DM retry timing follows the firmware-provided `suggested_timeout` from `PACKET_MSG_SENT`; do not replace it with a fixed app timeout unless you intentionally want more aggressive duplicate-prone retries.
|
||||||
- The final DM retry is intentionally sent as flood via `reset_path(...)`, even when a routing override exists.
|
- Direct-message send behavior is intended to emulate `meshcore_py.commands.send_msg_with_retry(...)` when the radio provides an expected ACK code: stage the effective contact route on the radio, send, wait for ACK, and on the final retry force flood via `reset_path(...)`.
|
||||||
|
- Non-final DM attempts use the contact's effective route (`override > direct > flood`). The final retry is intentionally sent as flood even when a routing override exists.
|
||||||
- DM ACK state is terminal on first ACK. Retry attempts may register multiple expected ACK codes for the same message, but sibling pending codes are cleared once one ACK wins so a DM should not accrue multiple delivery confirmations from retries.
|
- DM ACK state is terminal on first ACK. Retry attempts may register multiple expected ACK codes for the same message, but sibling pending codes are cleared once one ACK wins so a DM should not accrue multiple delivery confirmations from retries.
|
||||||
|
- ACKs are delivery state, not routing state. Bundled ACKs inside PATH packets still satisfy pending DM sends, but ACK history does not feed contact route learning.
|
||||||
|
|
||||||
### Echo/repeat dedup
|
### Echo/repeat dedup
|
||||||
|
|
||||||
@@ -165,11 +175,12 @@ app/
|
|||||||
- `GET /debug` — support snapshot with recent logs, live radio probe, slot/contact audits, and version/git info
|
- `GET /debug` — support snapshot with recent logs, live radio probe, slot/contact audits, and version/git info
|
||||||
|
|
||||||
### Radio
|
### Radio
|
||||||
- `GET /radio/config` — includes `path_hash_mode`, `path_hash_mode_supported`, and advert-location on/off
|
- `GET /radio/config` — includes `path_hash_mode`, `path_hash_mode_supported`, advert-location on/off, and `multi_acks_enabled`
|
||||||
- `PATCH /radio/config` — may update `path_hash_mode` (`0..2`) when firmware supports it
|
- `PATCH /radio/config` — may update `path_hash_mode` (`0..2`) when firmware supports it, and `multi_acks_enabled`
|
||||||
- `PUT /radio/private-key`
|
- `PUT /radio/private-key`
|
||||||
- `POST /radio/advertise` — manual advert send; request body may set `mode` to `flood` or `zero_hop` (defaults to `flood`)
|
- `POST /radio/advertise` — manual advert send; request body may set `mode` to `flood` or `zero_hop` (defaults to `flood`)
|
||||||
- `POST /radio/discover` — short mesh discovery sweep for nearby repeaters/sensors
|
- `POST /radio/discover` — short mesh discovery sweep for nearby repeaters/sensors
|
||||||
|
- `POST /radio/trace` — send a multi-hop trace loop through known repeaters and back to the local radio
|
||||||
- `POST /radio/disconnect`
|
- `POST /radio/disconnect`
|
||||||
- `POST /radio/reboot`
|
- `POST /radio/reboot`
|
||||||
- `POST /radio/reconnect`
|
- `POST /radio/reconnect`
|
||||||
@@ -184,6 +195,7 @@ app/
|
|||||||
- `POST /contacts/{public_key}/command`
|
- `POST /contacts/{public_key}/command`
|
||||||
- `POST /contacts/{public_key}/routing-override`
|
- `POST /contacts/{public_key}/routing-override`
|
||||||
- `POST /contacts/{public_key}/trace`
|
- `POST /contacts/{public_key}/trace`
|
||||||
|
- `POST /contacts/{public_key}/path-discovery` — discover forward/return paths, persist the learned direct route, and sync it back to the radio best-effort
|
||||||
- `POST /contacts/{public_key}/repeater/login`
|
- `POST /contacts/{public_key}/repeater/login`
|
||||||
- `POST /contacts/{public_key}/repeater/status`
|
- `POST /contacts/{public_key}/repeater/status`
|
||||||
- `POST /contacts/{public_key}/repeater/lpp-telemetry`
|
- `POST /contacts/{public_key}/repeater/lpp-telemetry`
|
||||||
@@ -193,6 +205,10 @@ app/
|
|||||||
- `POST /contacts/{public_key}/repeater/radio-settings`
|
- `POST /contacts/{public_key}/repeater/radio-settings`
|
||||||
- `POST /contacts/{public_key}/repeater/advert-intervals`
|
- `POST /contacts/{public_key}/repeater/advert-intervals`
|
||||||
- `POST /contacts/{public_key}/repeater/owner-info`
|
- `POST /contacts/{public_key}/repeater/owner-info`
|
||||||
|
- `POST /contacts/{public_key}/room/login`
|
||||||
|
- `POST /contacts/{public_key}/room/status`
|
||||||
|
- `POST /contacts/{public_key}/room/lpp-telemetry`
|
||||||
|
- `POST /contacts/{public_key}/room/acl`
|
||||||
|
|
||||||
### Channels
|
### Channels
|
||||||
- `GET /channels`
|
- `GET /channels`
|
||||||
@@ -211,11 +227,12 @@ app/
|
|||||||
|
|
||||||
### Packets
|
### Packets
|
||||||
- `GET /packets/undecrypted/count`
|
- `GET /packets/undecrypted/count`
|
||||||
|
- `GET /packets/{packet_id}` — fetch one stored raw packet by row ID for on-demand inspection
|
||||||
- `POST /packets/decrypt/historical`
|
- `POST /packets/decrypt/historical`
|
||||||
- `POST /packets/maintenance`
|
- `POST /packets/maintenance`
|
||||||
|
|
||||||
### Read state
|
### Read state
|
||||||
- `GET /read-state/unreads`
|
- `GET /read-state/unreads` — counts, mention flags, `last_message_times`, and `last_read_ats`
|
||||||
- `POST /read-state/mark-all-read`
|
- `POST /read-state/mark-all-read`
|
||||||
|
|
||||||
### Settings
|
### Settings
|
||||||
@@ -231,6 +248,7 @@ app/
|
|||||||
- `POST /fanout` — create new fanout config
|
- `POST /fanout` — create new fanout config
|
||||||
- `PATCH /fanout/{id}` — update fanout config (triggers module reload)
|
- `PATCH /fanout/{id}` — update fanout config (triggers module reload)
|
||||||
- `DELETE /fanout/{id}` — delete fanout config (stops module)
|
- `DELETE /fanout/{id}` — delete fanout config (stops module)
|
||||||
|
- `POST /fanout/bots/disable-until-restart` — stop bot modules and keep bots disabled until restart
|
||||||
|
|
||||||
### Statistics
|
### Statistics
|
||||||
- `GET /statistics` — aggregated mesh network stats (entity counts, message/packet splits, activity windows, busiest channels)
|
- `GET /statistics` — aggregated mesh network stats (entity counts, message/packet splits, activity windows, busiest channels)
|
||||||
@@ -258,7 +276,7 @@ Client sends `"ping"` text; server replies `{"type":"pong"}`.
|
|||||||
## Data Model Notes
|
## Data Model Notes
|
||||||
|
|
||||||
Main tables:
|
Main tables:
|
||||||
- `contacts` (includes `first_seen` for contact age tracking and `out_path_hash_mode` for route round-tripping)
|
- `contacts` (includes `first_seen` for contact age tracking and `direct_path_hash_mode` / `route_override_*` for DM routing)
|
||||||
- `channels`
|
- `channels`
|
||||||
Includes optional `flood_scope_override` for channel-specific regional sends.
|
Includes optional `flood_scope_override` for channel-specific regional sends.
|
||||||
- `messages` (includes `sender_name`, `sender_key` for per-contact channel message attribution)
|
- `messages` (includes `sender_name`, `sender_key` for per-contact channel message attribution)
|
||||||
@@ -267,6 +285,13 @@ Main tables:
|
|||||||
- `contact_name_history` (tracks name changes over time)
|
- `contact_name_history` (tracks name changes over time)
|
||||||
- `app_settings`
|
- `app_settings`
|
||||||
|
|
||||||
|
Contact route state is canonicalized on the backend:
|
||||||
|
- stored route inputs: `direct_path`, `direct_path_len`, `direct_path_hash_mode`, `direct_path_updated_at`, plus optional `route_override_*`
|
||||||
|
- computed route surface: `effective_route`, `effective_route_source`, `direct_route`, `route_override`
|
||||||
|
- removed legacy names: `last_path`, `last_path_len`, `out_path_hash_mode`
|
||||||
|
|
||||||
|
Frontend and send paths should consume the canonical route surface rather than reconstructing precedence from raw fields.
|
||||||
|
|
||||||
Repository writes should prefer typed models such as `ContactUpsert` over ad hoc dict payloads when adding or updating schema-coupled data.
|
Repository writes should prefer typed models such as `ContactUpsert` over ad hoc dict payloads when adding or updating schema-coupled data.
|
||||||
|
|
||||||
`max_radio_contacts` is the configured radio contact capacity baseline. Favorites reload first, the app refills non-favorite working-set contacts to about 80% of that capacity, and periodic offload triggers once occupancy reaches about 95%.
|
`max_radio_contacts` is the configured radio contact capacity baseline. Favorites reload first, the app refills non-favorite working-set contacts to about 80% of that capacity, and periodic offload triggers once occupancy reaches about 95%.
|
||||||
@@ -310,9 +335,11 @@ tests/
|
|||||||
├── conftest.py # Shared fixtures
|
├── conftest.py # Shared fixtures
|
||||||
├── test_ack_tracking_wiring.py # DM ACK tracking extraction and wiring
|
├── test_ack_tracking_wiring.py # DM ACK tracking extraction and wiring
|
||||||
├── test_api.py # REST endpoint integration tests
|
├── test_api.py # REST endpoint integration tests
|
||||||
|
├── test_block_lists.py # Blocked keys/names filtering across list/search surfaces
|
||||||
├── test_bot.py # Bot execution and sandboxing
|
├── test_bot.py # Bot execution and sandboxing
|
||||||
├── test_channels_router.py # Channels router endpoints
|
|
||||||
├── test_channel_sender_backfill.py # Sender-key backfill uniqueness rules for channel messages
|
├── test_channel_sender_backfill.py # Sender-key backfill uniqueness rules for channel messages
|
||||||
|
├── test_channels_router.py # Channels router endpoints
|
||||||
|
├── test_community_mqtt.py # Community MQTT publisher (JWT, packet format, hash, broadcast)
|
||||||
├── test_config.py # Configuration validation
|
├── test_config.py # Configuration validation
|
||||||
├── test_contact_reconciliation_service.py # Prefix/contact reconciliation service helpers
|
├── test_contact_reconciliation_service.py # Prefix/contact reconciliation service helpers
|
||||||
├── test_contacts_router.py # Contacts router endpoints
|
├── test_contacts_router.py # Contacts router endpoints
|
||||||
@@ -320,40 +347,41 @@ tests/
|
|||||||
├── test_disable_bots.py # MESHCORE_DISABLE_BOTS=true feature
|
├── test_disable_bots.py # MESHCORE_DISABLE_BOTS=true feature
|
||||||
├── test_echo_dedup.py # Echo/repeat deduplication (incl. concurrent)
|
├── test_echo_dedup.py # Echo/repeat deduplication (incl. concurrent)
|
||||||
├── test_fanout.py # Fanout bus CRUD, scope matching, manager dispatch
|
├── test_fanout.py # Fanout bus CRUD, scope matching, manager dispatch
|
||||||
├── test_fanout_integration.py # Fanout integration tests
|
|
||||||
├── test_fanout_hitlist.py # Fanout-related hitlist regression tests
|
├── test_fanout_hitlist.py # Fanout-related hitlist regression tests
|
||||||
|
├── test_fanout_integration.py # Fanout integration tests
|
||||||
├── test_event_handlers.py # ACK tracking, event registration, cleanup
|
├── test_event_handlers.py # ACK tracking, event registration, cleanup
|
||||||
├── test_frontend_static.py # Frontend static file serving
|
├── test_frontend_static.py # Frontend static file serving
|
||||||
├── test_health_mqtt_status.py # Health endpoint MQTT status field
|
├── test_health_mqtt_status.py # Health endpoint MQTT status field
|
||||||
├── test_http_quality.py # Cache-control / gzip / basic-auth HTTP quality checks
|
├── test_http_quality.py # Cache-control / gzip / basic-auth HTTP quality checks
|
||||||
├── test_key_normalization.py # Public key normalization
|
├── test_key_normalization.py # Public key normalization
|
||||||
├── test_keystore.py # Ephemeral keystore
|
├── test_keystore.py # Ephemeral keystore
|
||||||
|
├── test_main_startup.py # App startup and lifespan
|
||||||
|
├── test_map_upload.py # Map upload fanout module
|
||||||
├── test_message_pagination.py # Cursor-based message pagination
|
├── test_message_pagination.py # Cursor-based message pagination
|
||||||
├── test_message_prefix_claim.py # Message prefix claim logic
|
├── test_message_prefix_claim.py # Message prefix claim logic
|
||||||
├── test_migrations.py # Schema migration system
|
|
||||||
├── test_community_mqtt.py # Community MQTT publisher (JWT, packet format, hash, broadcast)
|
|
||||||
├── test_mqtt.py # MQTT publisher topic routing and lifecycle
|
├── test_mqtt.py # MQTT publisher topic routing and lifecycle
|
||||||
|
├── test_messages_search.py # Message search, around, forward pagination
|
||||||
|
├── test_migrations.py # Schema migration system
|
||||||
├── test_packet_pipeline.py # End-to-end packet processing
|
├── test_packet_pipeline.py # End-to-end packet processing
|
||||||
├── test_packets_router.py # Packets router endpoints (decrypt, maintenance)
|
├── test_packets_router.py # Packets router endpoints (decrypt, maintenance)
|
||||||
|
├── test_path_utils.py # Path hex rendering helpers
|
||||||
├── test_radio.py # RadioManager, serial detection
|
├── test_radio.py # RadioManager, serial detection
|
||||||
├── test_radio_commands_service.py # Radio config/private-key service workflows
|
├── test_radio_commands_service.py # Radio config/private-key service workflows
|
||||||
├── test_radio_lifecycle_service.py # Reconnect/setup orchestration helpers
|
├── test_radio_lifecycle_service.py # Reconnect/setup orchestration helpers
|
||||||
├── test_radio_runtime_service.py # radio_runtime seam behavior and helpers
|
|
||||||
├── test_real_crypto.py # Real cryptographic operations
|
|
||||||
├── test_radio_operation.py # radio_operation() context manager
|
├── test_radio_operation.py # radio_operation() context manager
|
||||||
├── test_radio_router.py # Radio router endpoints
|
├── test_radio_router.py # Radio router endpoints
|
||||||
|
├── test_radio_runtime_service.py # radio_runtime seam behavior and helpers
|
||||||
├── test_radio_sync.py # Polling, sync, advertisement
|
├── test_radio_sync.py # Polling, sync, advertisement
|
||||||
|
├── test_real_crypto.py # Real cryptographic operations
|
||||||
├── test_repeater_routes.py # Repeater command/telemetry/trace + granular pane endpoints
|
├── test_repeater_routes.py # Repeater command/telemetry/trace + granular pane endpoints
|
||||||
├── test_repository.py # Data access layer
|
├── test_repository.py # Data access layer
|
||||||
|
├── test_room_routes.py # Room-server login/status/telemetry/ACL endpoints
|
||||||
├── test_rx_log_data.py # on_rx_log_data event handler integration
|
├── test_rx_log_data.py # on_rx_log_data event handler integration
|
||||||
├── test_messages_search.py # Message search, around, forward pagination
|
|
||||||
├── test_block_lists.py # Blocked keys/names filtering
|
|
||||||
├── test_security.py # Optional Basic Auth middleware / config behavior
|
├── test_security.py # Optional Basic Auth middleware / config behavior
|
||||||
├── test_send_messages.py # Outgoing messages, bot triggers, concurrent sends
|
├── test_send_messages.py # Outgoing messages, bot triggers, concurrent sends
|
||||||
├── test_settings_router.py # Settings endpoints, advert validation
|
├── test_settings_router.py # Settings endpoints, advert validation
|
||||||
├── test_statistics.py # Statistics aggregation
|
├── test_statistics.py # Statistics aggregation
|
||||||
├── test_main_startup.py # App startup and lifespan
|
├── test_version_info.py # Version/build metadata resolution
|
||||||
├── test_path_utils.py # Path hex rendering helpers
|
|
||||||
├── test_websocket.py # WS manager broadcast/cleanup
|
├── test_websocket.py # WS manager broadcast/cleanup
|
||||||
└── test_websocket_route.py # WS endpoint lifecycle
|
└── test_websocket_route.py # WS endpoint lifecycle
|
||||||
```
|
```
|
||||||
|
|||||||
+25
-8
@@ -13,9 +13,10 @@ CREATE TABLE IF NOT EXISTS contacts (
|
|||||||
name TEXT,
|
name TEXT,
|
||||||
type INTEGER DEFAULT 0,
|
type INTEGER DEFAULT 0,
|
||||||
flags INTEGER DEFAULT 0,
|
flags INTEGER DEFAULT 0,
|
||||||
last_path TEXT,
|
direct_path TEXT,
|
||||||
last_path_len INTEGER DEFAULT -1,
|
direct_path_len INTEGER,
|
||||||
out_path_hash_mode INTEGER DEFAULT 0,
|
direct_path_hash_mode INTEGER,
|
||||||
|
direct_path_updated_at INTEGER,
|
||||||
route_override_path TEXT,
|
route_override_path TEXT,
|
||||||
route_override_len INTEGER,
|
route_override_len INTEGER,
|
||||||
route_override_hash_mode INTEGER,
|
route_override_hash_mode INTEGER,
|
||||||
@@ -25,7 +26,8 @@ CREATE TABLE IF NOT EXISTS contacts (
|
|||||||
last_seen INTEGER,
|
last_seen INTEGER,
|
||||||
on_radio INTEGER DEFAULT 0,
|
on_radio INTEGER DEFAULT 0,
|
||||||
last_contacted INTEGER,
|
last_contacted INTEGER,
|
||||||
first_seen INTEGER
|
first_seen INTEGER,
|
||||||
|
last_read_at INTEGER
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS channels (
|
CREATE TABLE IF NOT EXISTS channels (
|
||||||
@@ -33,7 +35,8 @@ CREATE TABLE IF NOT EXISTS channels (
|
|||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
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,
|
||||||
|
last_read_at INTEGER
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS messages (
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
@@ -63,7 +66,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 (
|
||||||
@@ -75,7 +78,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 (
|
||||||
@@ -85,7 +88,7 @@ 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 INDEX IF NOT EXISTS idx_messages_received ON messages(received_at);
|
CREATE INDEX IF NOT EXISTS idx_messages_received ON messages(received_at);
|
||||||
@@ -93,8 +96,12 @@ 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 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 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_on_radio ON contacts(on_radio);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_contacts_type_last_seen ON contacts(type, last_seen);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_type_received_conversation
|
||||||
|
ON messages(type, received_at, conversation_key);
|
||||||
-- idx_messages_sender_key is created by migration 25 (after adding the sender_key column)
|
-- idx_messages_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
|
-- 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
|
||||||
@@ -125,6 +132,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")
|
||||||
@@ -134,6 +147,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()
|
||||||
|
|||||||
+115
@@ -58,6 +58,28 @@ class DecryptedDirectMessage:
|
|||||||
message: str
|
message: str
|
||||||
dest_hash: str # First byte of destination pubkey as hex
|
dest_hash: str # First byte of destination pubkey as hex
|
||||||
src_hash: str # First byte of sender pubkey as hex
|
src_hash: str # First byte of sender pubkey as hex
|
||||||
|
signed_sender_prefix: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def txt_type(self) -> int:
|
||||||
|
return self.flags >> 2
|
||||||
|
|
||||||
|
@property
|
||||||
|
def attempt(self) -> int:
|
||||||
|
return self.flags & 0x03
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DecryptedPathPayload:
|
||||||
|
"""Result of decrypting a PATH payload."""
|
||||||
|
|
||||||
|
dest_hash: str
|
||||||
|
src_hash: str
|
||||||
|
returned_path: bytes
|
||||||
|
returned_path_len: int
|
||||||
|
returned_path_hash_mode: int
|
||||||
|
extra_type: int
|
||||||
|
extra: bytes
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -485,6 +507,13 @@ def decrypt_direct_message(payload: bytes, shared_secret: bytes) -> DecryptedDir
|
|||||||
|
|
||||||
# Extract message text (UTF-8, null-padded)
|
# Extract message text (UTF-8, null-padded)
|
||||||
message_bytes = decrypted[5:]
|
message_bytes = decrypted[5:]
|
||||||
|
signed_sender_prefix: str | None = None
|
||||||
|
txt_type = flags >> 2
|
||||||
|
if txt_type == 2:
|
||||||
|
if len(message_bytes) < 4:
|
||||||
|
return None
|
||||||
|
signed_sender_prefix = message_bytes[:4].hex()
|
||||||
|
message_bytes = message_bytes[4:]
|
||||||
try:
|
try:
|
||||||
message_text = message_bytes.decode("utf-8")
|
message_text = message_bytes.decode("utf-8")
|
||||||
# Truncate at first null terminator (consistent with channel message handling)
|
# Truncate at first null terminator (consistent with channel message handling)
|
||||||
@@ -500,6 +529,7 @@ def decrypt_direct_message(payload: bytes, shared_secret: bytes) -> DecryptedDir
|
|||||||
message=message_text,
|
message=message_text,
|
||||||
dest_hash=dest_hash,
|
dest_hash=dest_hash,
|
||||||
src_hash=src_hash,
|
src_hash=src_hash,
|
||||||
|
signed_sender_prefix=signed_sender_prefix,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -563,3 +593,88 @@ def try_decrypt_dm(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
return decrypt_direct_message(packet_info.payload, shared_secret)
|
return decrypt_direct_message(packet_info.payload, shared_secret)
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_path_payload(payload: bytes, shared_secret: bytes) -> DecryptedPathPayload | None:
|
||||||
|
"""Decrypt a PATH payload using the ECDH shared secret."""
|
||||||
|
if len(payload) < 4:
|
||||||
|
return None
|
||||||
|
|
||||||
|
dest_hash = format(payload[0], "02x")
|
||||||
|
src_hash = format(payload[1], "02x")
|
||||||
|
mac = payload[2:4]
|
||||||
|
ciphertext = payload[4:]
|
||||||
|
|
||||||
|
if len(ciphertext) == 0 or len(ciphertext) % 16 != 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
calculated_mac = hmac.new(shared_secret, ciphertext, hashlib.sha256).digest()[:2]
|
||||||
|
if calculated_mac != mac:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
cipher = AES.new(shared_secret[:16], AES.MODE_ECB)
|
||||||
|
decrypted = cipher.decrypt(ciphertext)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("AES decryption failed for PATH payload: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if len(decrypted) < 2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
from app.path_utils import decode_path_byte
|
||||||
|
|
||||||
|
packed_len = decrypted[0]
|
||||||
|
try:
|
||||||
|
returned_path_len, hash_size = decode_path_byte(packed_len)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
path_byte_len = returned_path_len * hash_size
|
||||||
|
if len(decrypted) < 1 + path_byte_len + 1:
|
||||||
|
return None
|
||||||
|
|
||||||
|
offset = 1
|
||||||
|
returned_path = decrypted[offset : offset + path_byte_len]
|
||||||
|
offset += path_byte_len
|
||||||
|
extra_type = decrypted[offset] & 0x0F
|
||||||
|
offset += 1
|
||||||
|
extra = decrypted[offset:]
|
||||||
|
|
||||||
|
return DecryptedPathPayload(
|
||||||
|
dest_hash=dest_hash,
|
||||||
|
src_hash=src_hash,
|
||||||
|
returned_path=returned_path,
|
||||||
|
returned_path_len=returned_path_len,
|
||||||
|
returned_path_hash_mode=hash_size - 1,
|
||||||
|
extra_type=extra_type,
|
||||||
|
extra=extra,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def try_decrypt_path(
|
||||||
|
raw_packet: bytes,
|
||||||
|
our_private_key: bytes,
|
||||||
|
their_public_key: bytes,
|
||||||
|
our_public_key: bytes,
|
||||||
|
) -> DecryptedPathPayload | None:
|
||||||
|
"""Try to decrypt a raw packet as a PATH packet."""
|
||||||
|
packet_info = parse_packet(raw_packet)
|
||||||
|
if packet_info is None or packet_info.payload_type != PayloadType.PATH:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if len(packet_info.payload) < 4:
|
||||||
|
return None
|
||||||
|
|
||||||
|
dest_hash = packet_info.payload[0]
|
||||||
|
src_hash = packet_info.payload[1]
|
||||||
|
if dest_hash != our_public_key[0] or src_hash != their_public_key[0]:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
shared_secret = derive_shared_secret(our_private_key, their_public_key)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Failed to derive shared secret for PATH payload: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return decrypt_path_payload(packet_info.payload, shared_secret)
|
||||||
|
|||||||
+28
-20
@@ -4,7 +4,7 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
from meshcore import EventType
|
from meshcore import EventType
|
||||||
|
|
||||||
from app.models import Contact, ContactUpsert
|
from app.models import CONTACT_TYPE_ROOM, Contact, ContactUpsert
|
||||||
from app.packet_processor import process_raw_packet
|
from app.packet_processor import process_raw_packet
|
||||||
from app.repository import (
|
from app.repository import (
|
||||||
ContactRepository,
|
ContactRepository,
|
||||||
@@ -14,11 +14,12 @@ from app.services.contact_reconciliation import (
|
|||||||
promote_prefix_contacts_for_contact,
|
promote_prefix_contacts_for_contact,
|
||||||
record_contact_name_and_reconcile,
|
record_contact_name_and_reconcile,
|
||||||
)
|
)
|
||||||
|
from app.services.dm_ack_apply import apply_dm_ack_code
|
||||||
from app.services.dm_ingest import (
|
from app.services.dm_ingest import (
|
||||||
ingest_fallback_direct_message,
|
ingest_fallback_direct_message,
|
||||||
|
resolve_direct_message_sender_metadata,
|
||||||
resolve_fallback_direct_message_context,
|
resolve_fallback_direct_message_context,
|
||||||
)
|
)
|
||||||
from app.services.messages import increment_ack_and_broadcast
|
|
||||||
from app.websocket import broadcast_event
|
from app.websocket import broadcast_event
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -29,8 +30,6 @@ logger = logging.getLogger(__name__)
|
|||||||
# Track active subscriptions so we can unsubscribe before re-registering
|
# Track active subscriptions so we can unsubscribe before re-registering
|
||||||
# This prevents handler duplication after reconnects
|
# This prevents handler duplication after reconnects
|
||||||
_active_subscriptions: list["Subscription"] = []
|
_active_subscriptions: list["Subscription"] = []
|
||||||
_pending_acks = dm_ack_tracker._pending_acks
|
|
||||||
_buffered_acks = dm_ack_tracker._buffered_acks
|
|
||||||
|
|
||||||
|
|
||||||
def track_pending_ack(expected_ack: str, message_id: int, timeout_ms: int) -> bool:
|
def track_pending_ack(expected_ack: str, message_id: int, timeout_ms: int) -> bool:
|
||||||
@@ -87,6 +86,23 @@ async def on_contact_message(event: "Event") -> None:
|
|||||||
sender_timestamp = ts if ts is not None else received_at
|
sender_timestamp = ts if ts is not None else received_at
|
||||||
path = payload.get("path")
|
path = payload.get("path")
|
||||||
path_len = payload.get("path_len")
|
path_len = payload.get("path_len")
|
||||||
|
sender_name = context.sender_name
|
||||||
|
sender_key = context.sender_key
|
||||||
|
signature = payload.get("signature")
|
||||||
|
if (
|
||||||
|
context.contact is not None
|
||||||
|
and context.contact.type == CONTACT_TYPE_ROOM
|
||||||
|
and txt_type == 2
|
||||||
|
and isinstance(signature, str)
|
||||||
|
and signature
|
||||||
|
):
|
||||||
|
sender_name, sender_key = await resolve_direct_message_sender_metadata(
|
||||||
|
sender_public_key=signature,
|
||||||
|
received_at=received_at,
|
||||||
|
broadcast_fn=broadcast_event,
|
||||||
|
contact_repository=ContactRepository,
|
||||||
|
log=logger,
|
||||||
|
)
|
||||||
message = await ingest_fallback_direct_message(
|
message = await ingest_fallback_direct_message(
|
||||||
conversation_key=context.conversation_key,
|
conversation_key=context.conversation_key,
|
||||||
text=payload.get("text", ""),
|
text=payload.get("text", ""),
|
||||||
@@ -95,9 +111,9 @@ async def on_contact_message(event: "Event") -> None:
|
|||||||
path=path,
|
path=path,
|
||||||
path_len=path_len,
|
path_len=path_len,
|
||||||
txt_type=txt_type,
|
txt_type=txt_type,
|
||||||
signature=payload.get("signature"),
|
signature=signature,
|
||||||
sender_name=context.sender_name,
|
sender_name=sender_name,
|
||||||
sender_key=context.sender_key,
|
sender_key=sender_key,
|
||||||
broadcast_fn=broadcast_event,
|
broadcast_fn=broadcast_event,
|
||||||
update_last_contacted_key=context.contact.public_key.lower() if context.contact else None,
|
update_last_contacted_key=context.contact.public_key.lower() if context.contact else None,
|
||||||
)
|
)
|
||||||
@@ -197,11 +213,12 @@ async def on_path_update(event: "Event") -> None:
|
|||||||
)
|
)
|
||||||
normalized_path_hash_mode = None
|
normalized_path_hash_mode = None
|
||||||
|
|
||||||
await ContactRepository.update_path(
|
await ContactRepository.update_direct_path(
|
||||||
contact.public_key,
|
contact.public_key,
|
||||||
str(path),
|
str(path),
|
||||||
normalized_path_len,
|
normalized_path_len,
|
||||||
normalized_path_hash_mode,
|
normalized_path_hash_mode,
|
||||||
|
updated_at=int(time.time()),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -268,19 +285,10 @@ async def on_ack(event: "Event") -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
logger.debug("Received ACK with code %s", ack_code)
|
logger.debug("Received ACK with code %s", ack_code)
|
||||||
|
matched = await apply_dm_ack_code(ack_code, broadcast_fn=broadcast_event)
|
||||||
cleanup_expired_acks()
|
if matched:
|
||||||
|
logger.info("ACK received for code %s", ack_code)
|
||||||
message_id = dm_ack_tracker.pop_pending_ack(ack_code)
|
|
||||||
if message_id is not None:
|
|
||||||
dm_ack_tracker.clear_pending_acks_for_message(message_id)
|
|
||||||
logger.info("ACK received for message %d", message_id)
|
|
||||||
# DM ACKs don't carry path data, so paths is intentionally omitted.
|
|
||||||
# The frontend's mergePendingAck handles the missing field correctly,
|
|
||||||
# preserving any previously known paths.
|
|
||||||
await increment_ack_and_broadcast(message_id=message_id, broadcast_fn=broadcast_event)
|
|
||||||
else:
|
else:
|
||||||
dm_ack_tracker.buffer_unmatched_ack(ack_code)
|
|
||||||
logger.debug("ACK code %s does not match any pending messages", ack_code)
|
logger.debug("ACK code %s does not match any pending messages", ack_code)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ class MessageAckedPayload(TypedDict):
|
|||||||
message_id: int
|
message_id: int
|
||||||
ack_count: int
|
ack_count: int
|
||||||
paths: NotRequired[list[MessagePath]]
|
paths: NotRequired[list[MessagePath]]
|
||||||
|
packet_id: NotRequired[int | None]
|
||||||
|
|
||||||
|
|
||||||
class ToastPayload(TypedDict):
|
class ToastPayload(TypedDict):
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ Wraps bot code execution via `app/fanout/bot_exec.py`. Config blob:
|
|||||||
- `code` — Python bot function source code
|
- `code` — Python bot function source code
|
||||||
- Executes in a thread pool with timeout and semaphore concurrency control
|
- Executes in a thread pool with timeout and semaphore concurrency control
|
||||||
- Rate-limits outgoing messages for repeater compatibility
|
- Rate-limits outgoing messages for repeater compatibility
|
||||||
|
- Channel `message_text` passed to bot code is normalized for human readability by stripping a leading `"{sender_name}: "` prefix when it matches the payload sender.
|
||||||
|
|
||||||
### webhook (webhook.py)
|
### webhook (webhook.py)
|
||||||
HTTP webhook delivery. Config blob:
|
HTTP webhook delivery. Config blob:
|
||||||
@@ -78,6 +79,7 @@ Push notifications via Apprise library. Config blob:
|
|||||||
- `urls` — newline-separated Apprise notification service URLs
|
- `urls` — newline-separated Apprise notification service URLs
|
||||||
- `preserve_identity` — suppress Discord webhook name/avatar override
|
- `preserve_identity` — suppress Discord webhook name/avatar override
|
||||||
- `include_path` — include routing path in notification body
|
- `include_path` — include routing path in notification body
|
||||||
|
- Channel notifications normalize stored message text by stripping a leading `"{sender_name}: "` prefix when it matches the payload sender so alerts do not duplicate the name.
|
||||||
|
|
||||||
### sqs (sqs.py)
|
### sqs (sqs.py)
|
||||||
Amazon SQS delivery. Config blob:
|
Amazon SQS delivery. Config blob:
|
||||||
@@ -87,6 +89,19 @@ Amazon SQS delivery. Config blob:
|
|||||||
- Publishes a JSON envelope of the form `{"event_type":"message"|"raw_packet","data":...}`
|
- Publishes a JSON envelope of the form `{"event_type":"message"|"raw_packet","data":...}`
|
||||||
- Supports both decoded messages and raw packets via normal scope selection
|
- Supports both decoded messages and raw packets via normal scope selection
|
||||||
|
|
||||||
|
### map_upload (map_upload.py)
|
||||||
|
Uploads heard repeater and room-server advertisements to map.meshcore.dev. Config blob:
|
||||||
|
- `api_url` (optional, default `""`) — upload endpoint; empty falls back to the public map.meshcore.dev API
|
||||||
|
- `dry_run` (bool, default `true`) — when true, logs the payload at INFO level without sending
|
||||||
|
- `geofence_enabled` (bool, default `false`) — when true, only uploads nodes within `geofence_radius_km` of the radio's own configured lat/lon
|
||||||
|
- `geofence_radius_km` (float, default `0`) — filter radius in kilometres
|
||||||
|
|
||||||
|
Geofence notes:
|
||||||
|
- The reference center is always the radio's own `adv_lat`/`adv_lon` from `radio_runtime.meshcore.self_info`, read **live at upload time** — no lat/lon is stored in the fanout config itself.
|
||||||
|
- If the radio's lat/lon is `(0, 0)` or the radio is not connected, the geofence check is silently skipped so uploads continue normally until coordinates are configured.
|
||||||
|
- Requires the radio to have `ENABLE_PRIVATE_KEY_EXPORT=1` firmware to sign uploads.
|
||||||
|
- Scope is always `{"messages": "none", "raw_packets": "all"}` — only raw RF packets are processed.
|
||||||
|
|
||||||
## Adding a New Integration Type
|
## Adding a New Integration Type
|
||||||
|
|
||||||
### Step-by-step checklist
|
### Step-by-step checklist
|
||||||
@@ -289,6 +304,7 @@ Migrations:
|
|||||||
- `app/fanout/webhook.py` — Webhook fanout module
|
- `app/fanout/webhook.py` — Webhook fanout module
|
||||||
- `app/fanout/apprise_mod.py` — Apprise fanout module
|
- `app/fanout/apprise_mod.py` — Apprise fanout module
|
||||||
- `app/fanout/sqs.py` — Amazon SQS fanout module
|
- `app/fanout/sqs.py` — Amazon SQS fanout module
|
||||||
|
- `app/fanout/map_upload.py` — Map Upload fanout module
|
||||||
- `app/repository/fanout.py` — Database CRUD
|
- `app/repository/fanout.py` — Database CRUD
|
||||||
- `app/routers/fanout.py` — REST API
|
- `app/routers/fanout.py` — REST API
|
||||||
- `app/websocket.py` — `broadcast_event()` dispatches to fanout
|
- `app/websocket.py` — `broadcast_event()` dispatches to fanout
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||||
|
|
||||||
from app.fanout.base import FanoutModule
|
from app.fanout.base import FanoutModule, get_fanout_message_text
|
||||||
from app.path_utils import split_path_hex
|
from app.path_utils import split_path_hex
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -39,7 +39,7 @@ def _normalize_discord_url(url: str) -> str:
|
|||||||
def _format_body(data: dict, *, include_path: bool) -> str:
|
def _format_body(data: dict, *, include_path: bool) -> str:
|
||||||
"""Build a human-readable notification body from message data."""
|
"""Build a human-readable notification body from message data."""
|
||||||
msg_type = data.get("type", "")
|
msg_type = data.get("type", "")
|
||||||
text = data.get("text", "")
|
text = get_fanout_message_text(data)
|
||||||
sender_name = data.get("sender_name") or "Unknown"
|
sender_name = data.get("sender_name") or "Unknown"
|
||||||
|
|
||||||
via = ""
|
via = ""
|
||||||
@@ -95,7 +95,6 @@ class AppriseModule(FanoutModule):
|
|||||||
|
|
||||||
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
|
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
|
||||||
super().__init__(config_id, config, name=name)
|
super().__init__(config_id, config, name=name)
|
||||||
self._last_error: str | None = None
|
|
||||||
|
|
||||||
async def on_message(self, data: dict) -> None:
|
async def on_message(self, data: dict) -> None:
|
||||||
# Skip outgoing messages — only notify on incoming
|
# Skip outgoing messages — only notify on incoming
|
||||||
@@ -114,17 +113,17 @@ class AppriseModule(FanoutModule):
|
|||||||
success = await asyncio.to_thread(
|
success = await asyncio.to_thread(
|
||||||
_send_sync, urls, body, preserve_identity=preserve_identity
|
_send_sync, urls, body, preserve_identity=preserve_identity
|
||||||
)
|
)
|
||||||
self._last_error = None if success else "Apprise notify returned failure"
|
self._set_last_error(None if success else "Apprise notify returned failure")
|
||||||
if not success:
|
if not success:
|
||||||
logger.warning("Apprise notification failed for module %s", self.config_id)
|
logger.warning("Apprise notification failed for module %s", self.config_id)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._last_error = str(exc)
|
self._set_last_error(str(exc))
|
||||||
logger.exception("Apprise send error for module %s", self.config_id)
|
logger.exception("Apprise send error for module %s", self.config_id)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def status(self) -> str:
|
def status(self) -> str:
|
||||||
if not self.config.get("urls", "").strip():
|
if not self.config.get("urls", "").strip():
|
||||||
return "disconnected"
|
return "disconnected"
|
||||||
if self._last_error:
|
if self.last_error:
|
||||||
return "error"
|
return "error"
|
||||||
return "connected"
|
return "connected"
|
||||||
|
|||||||
@@ -3,6 +3,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def _broadcast_fanout_health() -> None:
|
||||||
|
"""Push updated fanout status to connected frontend clients."""
|
||||||
|
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||||
|
from app.websocket import broadcast_health
|
||||||
|
|
||||||
|
broadcast_health(radio_manager.is_connected, radio_manager.connection_info)
|
||||||
|
|
||||||
|
|
||||||
class FanoutModule:
|
class FanoutModule:
|
||||||
"""Base class for all fanout integrations.
|
"""Base class for all fanout integrations.
|
||||||
|
|
||||||
@@ -16,6 +24,7 @@ class FanoutModule:
|
|||||||
self.config_id = config_id
|
self.config_id = config_id
|
||||||
self.config = config
|
self.config = config
|
||||||
self.name = name
|
self.name = name
|
||||||
|
self._last_error: str | None = None
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Start the module (e.g. connect to broker). Override for persistent connections."""
|
"""Start the module (e.g. connect to broker). Override for persistent connections."""
|
||||||
@@ -33,3 +42,42 @@ class FanoutModule:
|
|||||||
def status(self) -> str:
|
def status(self) -> str:
|
||||||
"""Return 'connected', 'disconnected', or 'error'."""
|
"""Return 'connected', 'disconnected', or 'error'."""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_error(self) -> str | None:
|
||||||
|
"""Return the most recent retained operator-facing error, if any."""
|
||||||
|
return self._last_error
|
||||||
|
|
||||||
|
def _set_last_error(self, value: str | None) -> None:
|
||||||
|
"""Update the retained error and broadcast health when it changes."""
|
||||||
|
if self._last_error == value:
|
||||||
|
return
|
||||||
|
self._last_error = value
|
||||||
|
_broadcast_fanout_health()
|
||||||
|
|
||||||
|
|
||||||
|
def get_fanout_message_text(data: dict) -> str:
|
||||||
|
"""Return the best human-readable message body for fanout consumers.
|
||||||
|
|
||||||
|
Channel messages are stored with the rendered sender label embedded in the
|
||||||
|
text (for example ``"Alice: hello"``). Human-facing integrations that also
|
||||||
|
carry ``sender_name`` should strip that duplicated prefix when it matches
|
||||||
|
the payload sender exactly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
text = data.get("text", "")
|
||||||
|
if not isinstance(text, str):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
if data.get("type") != "CHAN":
|
||||||
|
return text
|
||||||
|
|
||||||
|
sender_name = data.get("sender_name")
|
||||||
|
if not isinstance(sender_name, str) or not sender_name:
|
||||||
|
return text
|
||||||
|
|
||||||
|
prefix, separator, remainder = text.partition(": ")
|
||||||
|
if separator and prefix == sender_name:
|
||||||
|
return remainder
|
||||||
|
|
||||||
|
return text
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ from datetime import datetime
|
|||||||
from typing import Any, Protocol
|
from typing import Any, Protocol
|
||||||
|
|
||||||
import aiomqtt
|
import aiomqtt
|
||||||
import nacl.bindings
|
|
||||||
|
|
||||||
from app.fanout.mqtt_base import BaseMqttPublisher
|
from app.fanout.mqtt_base import BaseMqttPublisher
|
||||||
|
from app.keystore import ed25519_sign_expanded
|
||||||
from app.path_utils import parse_packet_envelope, split_path_hex
|
from app.path_utils import parse_packet_envelope, split_path_hex
|
||||||
from app.version_info import get_app_build_info
|
from app.version_info import get_app_build_info
|
||||||
|
|
||||||
@@ -40,9 +40,6 @@ _TOKEN_RENEWAL_THRESHOLD = _TOKEN_LIFETIME - 3600 # 23 hours
|
|||||||
_STATS_REFRESH_INTERVAL = 300 # 5 minutes
|
_STATS_REFRESH_INTERVAL = 300 # 5 minutes
|
||||||
_STATS_MIN_CACHE_SECS = 60 # Don't re-fetch stats within 60s
|
_STATS_MIN_CACHE_SECS = 60 # Don't re-fetch stats within 60s
|
||||||
|
|
||||||
# Ed25519 group order
|
|
||||||
_L = 2**252 + 27742317777372353535851937790883648493
|
|
||||||
|
|
||||||
# Route type mapping: bottom 2 bits of first byte
|
# Route type mapping: bottom 2 bits of first byte
|
||||||
_ROUTE_MAP = {0: "F", 1: "F", 2: "D", 3: "T"}
|
_ROUTE_MAP = {0: "F", 1: "F", 2: "D", 3: "T"}
|
||||||
|
|
||||||
@@ -69,28 +66,6 @@ def _base64url_encode(data: bytes) -> str:
|
|||||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
def _ed25519_sign_expanded(
|
|
||||||
message: bytes, scalar: bytes, prefix: bytes, public_key: bytes
|
|
||||||
) -> bytes:
|
|
||||||
"""Sign a message using MeshCore's expanded Ed25519 key format.
|
|
||||||
|
|
||||||
MeshCore stores 64-byte "orlp" format keys: scalar(32) || prefix(32).
|
|
||||||
Standard Ed25519 libraries expect seed format and would re-SHA-512 the key.
|
|
||||||
This performs the signing manually using the already-expanded key material.
|
|
||||||
|
|
||||||
Port of meshcore-packet-capture's ed25519_sign_with_expanded_key().
|
|
||||||
"""
|
|
||||||
# r = SHA-512(prefix || message) mod L
|
|
||||||
r = int.from_bytes(hashlib.sha512(prefix + message).digest(), "little") % _L
|
|
||||||
# R = r * B (base point multiplication)
|
|
||||||
R = nacl.bindings.crypto_scalarmult_ed25519_base_noclamp(r.to_bytes(32, "little"))
|
|
||||||
# k = SHA-512(R || public_key || message) mod L
|
|
||||||
k = int.from_bytes(hashlib.sha512(R + public_key + message).digest(), "little") % _L
|
|
||||||
# s = (r + k * scalar) mod L
|
|
||||||
s = (r + k * int.from_bytes(scalar, "little")) % _L
|
|
||||||
return R + s.to_bytes(32, "little")
|
|
||||||
|
|
||||||
|
|
||||||
def _generate_jwt_token(
|
def _generate_jwt_token(
|
||||||
private_key: bytes,
|
private_key: bytes,
|
||||||
public_key: bytes,
|
public_key: bytes,
|
||||||
@@ -115,7 +90,7 @@ def _generate_jwt_token(
|
|||||||
"exp": now + _TOKEN_LIFETIME,
|
"exp": now + _TOKEN_LIFETIME,
|
||||||
"aud": audience,
|
"aud": audience,
|
||||||
"owner": pubkey_hex,
|
"owner": pubkey_hex,
|
||||||
"client": _CLIENT_ID,
|
"client": _get_client_version(),
|
||||||
}
|
}
|
||||||
if email:
|
if email:
|
||||||
payload["email"] = email
|
payload["email"] = email
|
||||||
@@ -127,7 +102,7 @@ def _generate_jwt_token(
|
|||||||
|
|
||||||
scalar = private_key[:32]
|
scalar = private_key[:32]
|
||||||
prefix = private_key[32:]
|
prefix = private_key[32:]
|
||||||
signature = _ed25519_sign_expanded(signing_input, scalar, prefix, public_key)
|
signature = ed25519_sign_expanded(signing_input, scalar, prefix, public_key)
|
||||||
|
|
||||||
return f"{header_b64}.{payload_b64}.{signature.hex()}"
|
return f"{header_b64}.{payload_b64}.{signature.hex()}"
|
||||||
|
|
||||||
@@ -200,11 +175,12 @@ def _format_raw_packet(data: dict[str, Any], device_name: str, public_key_hex: s
|
|||||||
current_time = datetime.now()
|
current_time = datetime.now()
|
||||||
ts_str = current_time.isoformat()
|
ts_str = current_time.isoformat()
|
||||||
|
|
||||||
# SNR/RSSI are always strings in reference output.
|
# Keep numeric telemetry numeric so downstream analyzers can ingest it.
|
||||||
|
# Preserve the existing "Unknown" fallback for missing values.
|
||||||
snr_val = data.get("snr")
|
snr_val = data.get("snr")
|
||||||
rssi_val = data.get("rssi")
|
rssi_val = data.get("rssi")
|
||||||
snr = str(snr_val) if snr_val is not None else "Unknown"
|
snr: float | str = float(snr_val) if snr_val is not None else "Unknown"
|
||||||
rssi = str(rssi_val) if rssi_val is not None else "Unknown"
|
rssi: int | str = int(rssi_val) if rssi_val is not None else "Unknown"
|
||||||
|
|
||||||
packet_hash = _calculate_packet_hash(raw_bytes)
|
packet_hash = _calculate_packet_hash(raw_bytes)
|
||||||
|
|
||||||
@@ -260,8 +236,10 @@ def _build_radio_info() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _get_client_version() -> str:
|
def _get_client_version() -> str:
|
||||||
"""Return the app version string for community MQTT payloads."""
|
"""Return the canonical client/version identifier for community MQTT."""
|
||||||
return get_app_build_info().version
|
build = get_app_build_info()
|
||||||
|
commit_hash = build.commit_hash or "unknown"
|
||||||
|
return f"{_CLIENT_ID}/{build.version}-{commit_hash}"
|
||||||
|
|
||||||
|
|
||||||
class CommunityMqttPublisher(BaseMqttPublisher):
|
class CommunityMqttPublisher(BaseMqttPublisher):
|
||||||
|
|||||||
+108
-13
@@ -15,12 +15,21 @@ _DISPATCH_TIMEOUT_SECONDS = 30.0
|
|||||||
_MODULE_TYPES: dict[str, type] = {}
|
_MODULE_TYPES: dict[str, type] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _format_error_detail(exc: Exception) -> str:
|
||||||
|
"""Return a short operator-facing error string."""
|
||||||
|
message = str(exc).strip()
|
||||||
|
if message:
|
||||||
|
return f"{type(exc).__name__}: {message}"
|
||||||
|
return type(exc).__name__
|
||||||
|
|
||||||
|
|
||||||
def _register_module_types() -> None:
|
def _register_module_types() -> None:
|
||||||
"""Lazily populate the type registry to avoid circular imports."""
|
"""Lazily populate the type registry to avoid circular imports."""
|
||||||
if _MODULE_TYPES:
|
if _MODULE_TYPES:
|
||||||
return
|
return
|
||||||
from app.fanout.apprise_mod import AppriseModule
|
from app.fanout.apprise_mod import AppriseModule
|
||||||
from app.fanout.bot import BotModule
|
from app.fanout.bot import BotModule
|
||||||
|
from app.fanout.map_upload import MapUploadModule
|
||||||
from app.fanout.mqtt_community import MqttCommunityModule
|
from app.fanout.mqtt_community import MqttCommunityModule
|
||||||
from app.fanout.mqtt_private import MqttPrivateModule
|
from app.fanout.mqtt_private import MqttPrivateModule
|
||||||
from app.fanout.sqs import SqsModule
|
from app.fanout.sqs import SqsModule
|
||||||
@@ -32,6 +41,7 @@ def _register_module_types() -> None:
|
|||||||
_MODULE_TYPES["webhook"] = WebhookModule
|
_MODULE_TYPES["webhook"] = WebhookModule
|
||||||
_MODULE_TYPES["apprise"] = AppriseModule
|
_MODULE_TYPES["apprise"] = AppriseModule
|
||||||
_MODULE_TYPES["sqs"] = SqsModule
|
_MODULE_TYPES["sqs"] = SqsModule
|
||||||
|
_MODULE_TYPES["map_upload"] = MapUploadModule
|
||||||
|
|
||||||
|
|
||||||
def _matches_filter(filter_value: Any, key: str) -> bool:
|
def _matches_filter(filter_value: Any, key: str) -> bool:
|
||||||
@@ -82,6 +92,38 @@ class FanoutManager:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._modules: dict[str, tuple[FanoutModule, dict]] = {} # id -> (module, scope)
|
self._modules: dict[str, tuple[FanoutModule, dict]] = {} # id -> (module, scope)
|
||||||
self._restart_locks: dict[str, asyncio.Lock] = {}
|
self._restart_locks: dict[str, asyncio.Lock] = {}
|
||||||
|
self._bots_disabled_until_restart = False
|
||||||
|
self._module_errors: dict[str, str] = {}
|
||||||
|
|
||||||
|
def _broadcast_health_update(self) -> None:
|
||||||
|
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||||
|
from app.websocket import broadcast_health
|
||||||
|
|
||||||
|
broadcast_health(radio_manager.is_connected, radio_manager.connection_info)
|
||||||
|
|
||||||
|
def _set_module_error(self, config_id: str, error: str) -> None:
|
||||||
|
if self._module_errors.get(config_id) == error:
|
||||||
|
return
|
||||||
|
self._module_errors[config_id] = error
|
||||||
|
self._broadcast_health_update()
|
||||||
|
|
||||||
|
def _clear_module_error(self, config_id: str) -> None:
|
||||||
|
if self._module_errors.pop(config_id, None) is not None:
|
||||||
|
self._broadcast_health_update()
|
||||||
|
|
||||||
|
def get_bots_disabled_source(self) -> str | None:
|
||||||
|
"""Return why bot modules are unavailable, if at all."""
|
||||||
|
from app.config import settings as server_settings
|
||||||
|
|
||||||
|
if server_settings.disable_bots:
|
||||||
|
return "env"
|
||||||
|
if self._bots_disabled_until_restart:
|
||||||
|
return "until_restart"
|
||||||
|
return None
|
||||||
|
|
||||||
|
def bots_disabled_effective(self) -> bool:
|
||||||
|
"""Return True when bot modules should be treated as unavailable."""
|
||||||
|
return self.get_bots_disabled_source() is not None
|
||||||
|
|
||||||
async def load_from_db(self) -> None:
|
async def load_from_db(self) -> None:
|
||||||
"""Read enabled fanout_configs and instantiate modules."""
|
"""Read enabled fanout_configs and instantiate modules."""
|
||||||
@@ -99,13 +141,14 @@ class FanoutManager:
|
|||||||
config_blob = cfg["config"]
|
config_blob = cfg["config"]
|
||||||
scope = cfg["scope"]
|
scope = cfg["scope"]
|
||||||
|
|
||||||
# Skip bot modules when bots are disabled server-wide
|
# Skip bot modules when bots are disabled server-wide or until restart.
|
||||||
if config_type == "bot":
|
if config_type == "bot" and self.bots_disabled_effective():
|
||||||
from app.config import settings as server_settings
|
logger.info(
|
||||||
|
"Skipping bot module %s (bots disabled: %s)",
|
||||||
if server_settings.disable_bots:
|
config_id,
|
||||||
logger.info("Skipping bot module %s (bots disabled by server config)", config_id)
|
self.get_bots_disabled_source(),
|
||||||
return
|
)
|
||||||
|
return
|
||||||
|
|
||||||
cls = _MODULE_TYPES.get(config_type)
|
cls = _MODULE_TYPES.get(config_type)
|
||||||
if cls is None:
|
if cls is None:
|
||||||
@@ -116,11 +159,13 @@ class FanoutManager:
|
|||||||
module = cls(config_id, config_blob, name=cfg.get("name", ""))
|
module = cls(config_id, config_blob, name=cfg.get("name", ""))
|
||||||
await module.start()
|
await module.start()
|
||||||
self._modules[config_id] = (module, scope)
|
self._modules[config_id] = (module, scope)
|
||||||
|
self._clear_module_error(config_id)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Started fanout module %s (type=%s)", cfg.get("name", config_id), config_type
|
"Started fanout module %s (type=%s)", cfg.get("name", config_id), config_type
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
logger.exception("Failed to start fanout module %s", config_id)
|
logger.exception("Failed to start fanout module %s", config_id)
|
||||||
|
self._set_module_error(config_id, _format_error_detail(exc))
|
||||||
|
|
||||||
async def reload_config(self, config_id: str) -> None:
|
async def reload_config(self, config_id: str) -> None:
|
||||||
"""Stop old module (if any) and start updated config."""
|
"""Stop old module (if any) and start updated config."""
|
||||||
@@ -144,6 +189,7 @@ class FanoutManager:
|
|||||||
await module.stop()
|
await module.stop()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Error stopping fanout module %s", config_id)
|
logger.exception("Error stopping fanout module %s", config_id)
|
||||||
|
self._clear_module_error(config_id)
|
||||||
|
|
||||||
async def _dispatch_matching(
|
async def _dispatch_matching(
|
||||||
self,
|
self,
|
||||||
@@ -173,7 +219,10 @@ class FanoutManager:
|
|||||||
try:
|
try:
|
||||||
handler = getattr(module, handler_name)
|
handler = getattr(module, handler_name)
|
||||||
await asyncio.wait_for(handler(data), timeout=_DISPATCH_TIMEOUT_SECONDS)
|
await asyncio.wait_for(handler(data), timeout=_DISPATCH_TIMEOUT_SECONDS)
|
||||||
|
self._clear_module_error(config_id)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
|
timeout_error = f"{handler_name} timed out after {_DISPATCH_TIMEOUT_SECONDS:.1f}s"
|
||||||
|
self._set_module_error(config_id, timeout_error)
|
||||||
logger.error(
|
logger.error(
|
||||||
"Fanout %s %s timed out after %.1fs; restarting module",
|
"Fanout %s %s timed out after %.1fs; restarting module",
|
||||||
config_id,
|
config_id,
|
||||||
@@ -181,7 +230,8 @@ class FanoutManager:
|
|||||||
_DISPATCH_TIMEOUT_SECONDS,
|
_DISPATCH_TIMEOUT_SECONDS,
|
||||||
)
|
)
|
||||||
await self._restart_module(config_id, module)
|
await self._restart_module(config_id, module)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
|
self._set_module_error(config_id, _format_error_detail(exc))
|
||||||
logger.exception("Fanout %s %s error", config_id, log_label)
|
logger.exception("Fanout %s %s error", config_id, log_label)
|
||||||
|
|
||||||
async def _restart_module(self, config_id: str, module: FanoutModule) -> None:
|
async def _restart_module(self, config_id: str, module: FanoutModule) -> None:
|
||||||
@@ -197,6 +247,10 @@ class FanoutManager:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to restart timed-out fanout module %s", config_id)
|
logger.exception("Failed to restart timed-out fanout module %s", config_id)
|
||||||
self._modules.pop(config_id, None)
|
self._modules.pop(config_id, None)
|
||||||
|
self._set_module_error(
|
||||||
|
config_id,
|
||||||
|
"Module restart failed after timeout",
|
||||||
|
)
|
||||||
|
|
||||||
async def broadcast_message(self, data: dict) -> None:
|
async def broadcast_message(self, data: dict) -> None:
|
||||||
"""Dispatch a decoded message to modules whose scope matches."""
|
"""Dispatch a decoded message to modules whose scope matches."""
|
||||||
@@ -225,21 +279,62 @@ class FanoutManager:
|
|||||||
logger.exception("Error stopping fanout module %s", config_id)
|
logger.exception("Error stopping fanout module %s", config_id)
|
||||||
self._modules.clear()
|
self._modules.clear()
|
||||||
self._restart_locks.clear()
|
self._restart_locks.clear()
|
||||||
|
self._module_errors.clear()
|
||||||
|
|
||||||
def get_statuses(self) -> dict[str, dict[str, str]]:
|
def get_statuses(self) -> dict[str, dict[str, str | None]]:
|
||||||
"""Return status info for each active module."""
|
"""Return status info for each active module."""
|
||||||
from app.repository.fanout import _configs_cache
|
from app.repository.fanout import _configs_cache
|
||||||
|
|
||||||
result: dict[str, dict[str, str]] = {}
|
result: dict[str, dict[str, str | None]] = {}
|
||||||
for config_id, (module, _) in self._modules.items():
|
all_ids = set(_configs_cache) | set(self._modules) | set(self._module_errors)
|
||||||
|
for config_id in all_ids:
|
||||||
info = _configs_cache.get(config_id, {})
|
info = _configs_cache.get(config_id, {})
|
||||||
|
if info.get("enabled") is False:
|
||||||
|
continue
|
||||||
|
|
||||||
|
module_entry = self._modules.get(config_id)
|
||||||
|
module = module_entry[0] if module_entry is not None else None
|
||||||
|
last_error = module.last_error if module is not None else None
|
||||||
|
status = module.status if module is not None else "error"
|
||||||
|
|
||||||
|
manager_error = self._module_errors.get(config_id)
|
||||||
|
if manager_error is not None:
|
||||||
|
status = "error"
|
||||||
|
last_error = manager_error
|
||||||
|
elif last_error is not None and status != "error":
|
||||||
|
status = "error"
|
||||||
|
|
||||||
|
if module is None and last_error is None:
|
||||||
|
continue
|
||||||
|
|
||||||
result[config_id] = {
|
result[config_id] = {
|
||||||
"name": info.get("name", config_id),
|
"name": info.get("name", config_id),
|
||||||
"type": info.get("type", "unknown"),
|
"type": info.get("type", "unknown"),
|
||||||
"status": module.status,
|
"status": status,
|
||||||
|
"last_error": last_error,
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
async def disable_bots_until_restart(self) -> str:
|
||||||
|
"""Stop active bot modules and prevent them from starting again until restart."""
|
||||||
|
source = self.get_bots_disabled_source()
|
||||||
|
if source == "env":
|
||||||
|
return source
|
||||||
|
|
||||||
|
self._bots_disabled_until_restart = True
|
||||||
|
|
||||||
|
from app.repository.fanout import _configs_cache
|
||||||
|
|
||||||
|
bot_ids = [
|
||||||
|
config_id
|
||||||
|
for config_id in list(self._modules)
|
||||||
|
if _configs_cache.get(config_id, {}).get("type") == "bot"
|
||||||
|
]
|
||||||
|
for config_id in bot_ids:
|
||||||
|
await self.remove_config(config_id)
|
||||||
|
|
||||||
|
return "until_restart"
|
||||||
|
|
||||||
|
|
||||||
# Module-level singleton
|
# Module-level singleton
|
||||||
fanout_manager = FanoutManager()
|
fanout_manager = FanoutManager()
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
"""Fanout module for uploading heard advert packets to map.meshcore.dev.
|
||||||
|
|
||||||
|
Mirrors the logic of the standalone map.meshcore.dev-uploader project:
|
||||||
|
- Listens on raw RF packets via on_raw
|
||||||
|
- Filters for ADVERT packets, only processes repeaters (role 2) and rooms (role 3)
|
||||||
|
- Skips nodes with no valid location (lat/lon None)
|
||||||
|
- Applies per-pubkey rate-limiting (1-hour window, matching the uploader)
|
||||||
|
- Signs the upload request with the radio's own Ed25519 private key
|
||||||
|
- POSTs to the map API (or logs in dry-run mode)
|
||||||
|
|
||||||
|
Dry-run mode (default: True) logs the full would-be payload at INFO level
|
||||||
|
without making any HTTP requests. Disable it only after verifying the log
|
||||||
|
output looks correct — in particular the radio params (freq/bw/sf/cr) and
|
||||||
|
the raw hex link.
|
||||||
|
|
||||||
|
Config keys
|
||||||
|
-----------
|
||||||
|
api_url : str, default ""
|
||||||
|
Upload endpoint. Empty string falls back to the public map.meshcore.dev API.
|
||||||
|
dry_run : bool, default True
|
||||||
|
When True, log the payload at INFO level instead of sending it.
|
||||||
|
geofence_enabled : bool, default False
|
||||||
|
When True, only upload nodes whose location falls within geofence_radius_km of
|
||||||
|
the radio's own configured latitude/longitude (read live from the radio at upload
|
||||||
|
time — no lat/lon is stored in this config). When the radio's lat/lon is not set
|
||||||
|
(0, 0) or unavailable, the geofence check is silently skipped so uploads continue
|
||||||
|
normally until coordinates are configured.
|
||||||
|
geofence_radius_km : float, default 0.0
|
||||||
|
Radius of the geofence in kilometres. Nodes further than this distance
|
||||||
|
from the radio's own position are skipped.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.decoder import parse_advertisement, parse_packet
|
||||||
|
from app.fanout.base import FanoutModule
|
||||||
|
from app.keystore import ed25519_sign_expanded, get_private_key, get_public_key
|
||||||
|
from app.services.radio_runtime import radio_runtime
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_DEFAULT_API_URL = "https://map.meshcore.dev/api/v1/uploader/node"
|
||||||
|
|
||||||
|
# Re-upload guard: skip re-uploading a pubkey seen within this window (AU parity)
|
||||||
|
_REUPLOAD_SECONDS = 3600
|
||||||
|
|
||||||
|
# Only upload repeaters (2) and rooms (3). Any other role — including future
|
||||||
|
# roles not yet defined — is rejected. An allowlist is used rather than a
|
||||||
|
# blocklist so that new roles cannot accidentally start populating the map.
|
||||||
|
_ALLOWED_DEVICE_ROLES = {2, 3}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_radio_params() -> dict:
|
||||||
|
"""Read radio frequency parameters from the connected radio's self_info.
|
||||||
|
|
||||||
|
The Python meshcore library returns radio_freq in MHz (e.g. 910.525) and
|
||||||
|
radio_bw in kHz (e.g. 62.5). These are exactly the units the map API
|
||||||
|
expects, matching what the JS reference uploader produces after its own
|
||||||
|
/1000 division on raw integer values. No further scaling is applied here.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
mc = radio_runtime.meshcore
|
||||||
|
if not mc:
|
||||||
|
return {"freq": 0, "cr": 0, "sf": 0, "bw": 0}
|
||||||
|
info = mc.self_info
|
||||||
|
if not isinstance(info, dict):
|
||||||
|
return {"freq": 0, "cr": 0, "sf": 0, "bw": 0}
|
||||||
|
freq = info.get("radio_freq", 0) or 0
|
||||||
|
bw = info.get("radio_bw", 0) or 0
|
||||||
|
sf = info.get("radio_sf", 0) or 0
|
||||||
|
cr = info.get("radio_cr", 0) or 0
|
||||||
|
return {
|
||||||
|
"freq": freq,
|
||||||
|
"cr": cr,
|
||||||
|
"sf": sf,
|
||||||
|
"bw": bw,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("MapUpload: could not read radio params: %s", exc)
|
||||||
|
return {"freq": 0, "cr": 0, "sf": 0, "bw": 0}
|
||||||
|
|
||||||
|
|
||||||
|
_ROLE_NAMES: dict[int, str] = {2: "repeater", 3: "room"}
|
||||||
|
|
||||||
|
|
||||||
|
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||||
|
"""Return the great-circle distance in kilometres between two lat/lon points."""
|
||||||
|
r = 6371.0
|
||||||
|
phi1, phi2 = math.radians(lat1), math.radians(lat2)
|
||||||
|
dphi = math.radians(lat2 - lat1)
|
||||||
|
dlam = math.radians(lon2 - lon1)
|
||||||
|
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2
|
||||||
|
return 2 * r * math.asin(math.sqrt(a))
|
||||||
|
|
||||||
|
|
||||||
|
class MapUploadModule(FanoutModule):
|
||||||
|
"""Uploads heard ADVERT packets to the MeshCore community map."""
|
||||||
|
|
||||||
|
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
|
||||||
|
super().__init__(config_id, config, name=name)
|
||||||
|
self._client: httpx.AsyncClient | None = None
|
||||||
|
# Per-pubkey rate limiting: pubkey_hex -> last_uploaded_advert_timestamp
|
||||||
|
self._seen: dict[str, int] = {}
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
self._client = httpx.AsyncClient(
|
||||||
|
timeout=httpx.Timeout(15.0),
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
self._last_error = None
|
||||||
|
self._seen.clear()
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
if self._client:
|
||||||
|
await self._client.aclose()
|
||||||
|
self._client = None
|
||||||
|
self._last_error = None
|
||||||
|
|
||||||
|
async def on_raw(self, data: dict) -> None:
|
||||||
|
if data.get("payload_type") != "ADVERT":
|
||||||
|
return
|
||||||
|
|
||||||
|
raw_hex = data.get("data", "")
|
||||||
|
if not raw_hex:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_bytes = bytes.fromhex(raw_hex)
|
||||||
|
except ValueError:
|
||||||
|
return
|
||||||
|
|
||||||
|
packet_info = parse_packet(raw_bytes)
|
||||||
|
if packet_info is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
advert = parse_advertisement(packet_info.payload, raw_packet=raw_bytes)
|
||||||
|
if advert is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# TODO: advert Ed25519 signature verification is skipped here.
|
||||||
|
# The radio has already validated the packet before passing it to RT,
|
||||||
|
# so re-verification is redundant in practice. If added, verify that
|
||||||
|
# nacl.bindings.crypto_sign_open(sig + (pubkey_bytes || timestamp_bytes),
|
||||||
|
# advert.public_key_bytes) succeeds before proceeding.
|
||||||
|
|
||||||
|
# Only process repeaters (2) and rooms (3) — any other role is rejected
|
||||||
|
if advert.device_role not in _ALLOWED_DEVICE_ROLES:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Skip nodes with no valid location — the decoder already nulls out
|
||||||
|
# impossible values, so None means either no location flag or bad coords.
|
||||||
|
if advert.lat is None or advert.lon is None:
|
||||||
|
logger.debug(
|
||||||
|
"MapUpload: skipping %s — no valid location",
|
||||||
|
advert.public_key[:12],
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
pubkey = advert.public_key.lower()
|
||||||
|
|
||||||
|
# Rate-limit: skip if this pubkey's timestamp hasn't advanced enough
|
||||||
|
last_seen = self._seen.get(pubkey)
|
||||||
|
if last_seen is not None:
|
||||||
|
if last_seen >= advert.timestamp:
|
||||||
|
logger.debug(
|
||||||
|
"MapUpload: skipping %s — possible replay (last=%d, advert=%d)",
|
||||||
|
pubkey[:12],
|
||||||
|
last_seen,
|
||||||
|
advert.timestamp,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if advert.timestamp < last_seen + _REUPLOAD_SECONDS:
|
||||||
|
logger.debug(
|
||||||
|
"MapUpload: skipping %s — within 1-hr rate-limit window (delta=%ds)",
|
||||||
|
pubkey[:12],
|
||||||
|
advert.timestamp - last_seen,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await self._upload(
|
||||||
|
pubkey, advert.timestamp, advert.device_role, raw_hex, advert.lat, advert.lon
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _upload(
|
||||||
|
self,
|
||||||
|
pubkey: str,
|
||||||
|
advert_timestamp: int,
|
||||||
|
device_role: int,
|
||||||
|
raw_hex: str,
|
||||||
|
lat: float,
|
||||||
|
lon: float,
|
||||||
|
) -> None:
|
||||||
|
# Geofence check: if enabled, skip nodes outside the configured radius.
|
||||||
|
# The reference center is the radio's own lat/lon read live from self_info —
|
||||||
|
# no coordinates are stored in the fanout config. If the radio lat/lon is
|
||||||
|
# (0, 0) or unavailable the check is skipped transparently so uploads
|
||||||
|
# continue normally until the operator sets coordinates in radio settings.
|
||||||
|
geofence_dist_km: float | None = None
|
||||||
|
if self.config.get("geofence_enabled"):
|
||||||
|
try:
|
||||||
|
mc = radio_runtime.meshcore
|
||||||
|
sinfo = mc.self_info if mc else None
|
||||||
|
fence_lat = float((sinfo or {}).get("adv_lat", 0) or 0)
|
||||||
|
fence_lon = float((sinfo or {}).get("adv_lon", 0) or 0)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("MapUpload: could not read radio lat/lon for geofence: %s", exc)
|
||||||
|
fence_lat = 0.0
|
||||||
|
fence_lon = 0.0
|
||||||
|
|
||||||
|
if fence_lat == 0.0 and fence_lon == 0.0:
|
||||||
|
logger.debug(
|
||||||
|
"MapUpload: geofence skipped for %s — radio lat/lon not configured",
|
||||||
|
pubkey[:12],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
fence_radius_km = float(self.config.get("geofence_radius_km", 0) or 0)
|
||||||
|
geofence_dist_km = _haversine_km(fence_lat, fence_lon, lat, lon)
|
||||||
|
if geofence_dist_km > fence_radius_km:
|
||||||
|
logger.debug(
|
||||||
|
"MapUpload: skipping %s — outside geofence (%.2f km > %.2f km)",
|
||||||
|
pubkey[:12],
|
||||||
|
geofence_dist_km,
|
||||||
|
fence_radius_km,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
private_key = get_private_key()
|
||||||
|
public_key = get_public_key()
|
||||||
|
|
||||||
|
if private_key is None or public_key is None:
|
||||||
|
logger.warning(
|
||||||
|
"MapUpload: private key not available — cannot sign upload for %s. "
|
||||||
|
"Ensure radio firmware has ENABLE_PRIVATE_KEY_EXPORT=1.",
|
||||||
|
pubkey[:12],
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
api_url = str(self.config.get("api_url", "") or _DEFAULT_API_URL).strip()
|
||||||
|
dry_run = bool(self.config.get("dry_run", True))
|
||||||
|
role_name = _ROLE_NAMES.get(device_role, f"role={device_role}")
|
||||||
|
|
||||||
|
params = _get_radio_params()
|
||||||
|
upload_data = {
|
||||||
|
"params": params,
|
||||||
|
"links": [f"meshcore://{raw_hex}"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sign: SHA-256 the compact JSON, then Ed25519-sign the hash
|
||||||
|
json_str = json.dumps(upload_data, separators=(",", ":"))
|
||||||
|
data_hash = hashlib.sha256(json_str.encode()).digest()
|
||||||
|
scalar = private_key[:32]
|
||||||
|
prefix_bytes = private_key[32:]
|
||||||
|
signature = ed25519_sign_expanded(data_hash, scalar, prefix_bytes, public_key)
|
||||||
|
|
||||||
|
request_payload = {
|
||||||
|
"data": json_str,
|
||||||
|
"signature": signature.hex(),
|
||||||
|
"publicKey": public_key.hex(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
geofence_note = (
|
||||||
|
f" | geofence: {geofence_dist_km:.2f} km from observer"
|
||||||
|
if geofence_dist_km is not None
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"MapUpload [DRY RUN] %s (%s)%s → would POST to %s\n payload: %s",
|
||||||
|
pubkey[:12],
|
||||||
|
role_name,
|
||||||
|
geofence_note,
|
||||||
|
api_url,
|
||||||
|
json.dumps(request_payload, separators=(",", ":")),
|
||||||
|
)
|
||||||
|
# Still update _seen so rate-limiting works during dry-run testing
|
||||||
|
self._seen[pubkey] = advert_timestamp
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self._client:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = await self._client.post(
|
||||||
|
api_url,
|
||||||
|
content=json.dumps(request_payload, separators=(",", ":")),
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
self._seen[pubkey] = advert_timestamp
|
||||||
|
self._set_last_error(None)
|
||||||
|
logger.info(
|
||||||
|
"MapUpload: uploaded %s (%s) → HTTP %d",
|
||||||
|
pubkey[:12],
|
||||||
|
role_name,
|
||||||
|
resp.status_code,
|
||||||
|
)
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
self._set_last_error(f"HTTP {exc.response.status_code}")
|
||||||
|
logger.warning(
|
||||||
|
"MapUpload: server returned %d for %s: %s",
|
||||||
|
exc.response.status_code,
|
||||||
|
pubkey[:12],
|
||||||
|
exc.response.text[:200],
|
||||||
|
)
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
self._set_last_error(str(exc))
|
||||||
|
logger.warning("MapUpload: request error for %s: %s", pubkey[:12], exc)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status(self) -> str:
|
||||||
|
if self._client is None:
|
||||||
|
return "disconnected"
|
||||||
|
if self.last_error:
|
||||||
|
return "error"
|
||||||
|
return "connected"
|
||||||
+21
-2
@@ -23,6 +23,14 @@ logger = logging.getLogger(__name__)
|
|||||||
_BACKOFF_MIN = 5
|
_BACKOFF_MIN = 5
|
||||||
|
|
||||||
|
|
||||||
|
def _format_error_detail(exc: Exception) -> str:
|
||||||
|
"""Return a short operator-facing error string."""
|
||||||
|
message = str(exc).strip()
|
||||||
|
if message:
|
||||||
|
return message
|
||||||
|
return type(exc).__name__
|
||||||
|
|
||||||
|
|
||||||
def _broadcast_health() -> None:
|
def _broadcast_health() -> None:
|
||||||
"""Push updated health (including MQTT status) to all WS clients."""
|
"""Push updated health (including MQTT status) to all WS clients."""
|
||||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||||
@@ -55,6 +63,7 @@ class BaseMqttPublisher(ABC):
|
|||||||
self._version_event: asyncio.Event = asyncio.Event()
|
self._version_event: asyncio.Event = asyncio.Event()
|
||||||
self.connected: bool = False
|
self.connected: bool = False
|
||||||
self.integration_name: str = ""
|
self.integration_name: str = ""
|
||||||
|
self._last_error: str | None = None
|
||||||
|
|
||||||
def set_integration_name(self, name: str) -> None:
|
def set_integration_name(self, name: str) -> None:
|
||||||
"""Attach the configured fanout-module name for operator-facing logs."""
|
"""Attach the configured fanout-module name for operator-facing logs."""
|
||||||
@@ -66,11 +75,17 @@ class BaseMqttPublisher(ABC):
|
|||||||
return f"{self._log_prefix} [{self.integration_name}]"
|
return f"{self._log_prefix} [{self.integration_name}]"
|
||||||
return self._log_prefix
|
return self._log_prefix
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_error(self) -> str | None:
|
||||||
|
"""Return the most recent retained connection/publish error."""
|
||||||
|
return self._last_error
|
||||||
|
|
||||||
# ── Lifecycle ──────────────────────────────────────────────────────
|
# ── Lifecycle ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def start(self, settings: object) -> None:
|
async def start(self, settings: object) -> None:
|
||||||
"""Start the background connection loop."""
|
"""Start the background connection loop."""
|
||||||
self._settings = settings
|
self._settings = settings
|
||||||
|
self._last_error = None
|
||||||
self._settings_version += 1
|
self._settings_version += 1
|
||||||
self._version_event.set()
|
self._version_event.set()
|
||||||
if self._task is None or self._task.done():
|
if self._task is None or self._task.done():
|
||||||
@@ -87,6 +102,7 @@ class BaseMqttPublisher(ABC):
|
|||||||
self._task = None
|
self._task = None
|
||||||
self._client = None
|
self._client = None
|
||||||
self.connected = False
|
self.connected = False
|
||||||
|
self._last_error = None
|
||||||
|
|
||||||
async def restart(self, settings: object) -> None:
|
async def restart(self, settings: object) -> None:
|
||||||
"""Called when settings change — stop + start."""
|
"""Called when settings change — stop + start."""
|
||||||
@@ -102,13 +118,14 @@ class BaseMqttPublisher(ABC):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"%s publish failed on %s. This is usually transient network noise; "
|
"%s publish failed on %s. This is usually transient network noise; "
|
||||||
"if it self-resolves and reconnects, it is generally not a concern: %s",
|
"if it self-resolves and reconnects, it is generally not a concern. Persistent errors may indicate a problem with your network connection or MQTT broker. Original error: %s",
|
||||||
self._integration_label(),
|
self._integration_label(),
|
||||||
topic,
|
topic,
|
||||||
e,
|
e,
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
self.connected = False
|
self.connected = False
|
||||||
|
self._last_error = _format_error_detail(e)
|
||||||
# Wake the connection loop so it exits the wait and reconnects
|
# Wake the connection loop so it exits the wait and reconnects
|
||||||
self._settings_version += 1
|
self._settings_version += 1
|
||||||
self._version_event.set()
|
self._version_event.set()
|
||||||
@@ -198,6 +215,7 @@ class BaseMqttPublisher(ABC):
|
|||||||
async with aiomqtt.Client(**client_kwargs) as client:
|
async with aiomqtt.Client(**client_kwargs) as client:
|
||||||
self._client = client
|
self._client = client
|
||||||
self.connected = True
|
self.connected = True
|
||||||
|
self._last_error = None
|
||||||
backoff = _BACKOFF_MIN
|
backoff = _BACKOFF_MIN
|
||||||
|
|
||||||
title, detail = self._on_connected(settings)
|
title, detail = self._on_connected(settings)
|
||||||
@@ -232,6 +250,7 @@ class BaseMqttPublisher(ABC):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.connected = False
|
self.connected = False
|
||||||
self._client = None
|
self._client = None
|
||||||
|
self._last_error = _format_error_detail(e)
|
||||||
|
|
||||||
title, detail = self._on_error()
|
title, detail = self._on_error()
|
||||||
broadcast_error(title, detail)
|
broadcast_error(title, detail)
|
||||||
@@ -239,7 +258,7 @@ class BaseMqttPublisher(ABC):
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
"%s connection error. This is usually transient network noise; "
|
"%s connection error. This is usually transient network noise; "
|
||||||
"if it self-resolves, it is generally not a concern: %s "
|
"if it self-resolves, it is generally not a concern: %s "
|
||||||
"(reconnecting in %ds)",
|
"(reconnecting in %ds). If this error persists, check your network connection and MQTT broker status.",
|
||||||
self._integration_label(),
|
self._integration_label(),
|
||||||
e,
|
e,
|
||||||
backoff,
|
backoff,
|
||||||
|
|||||||
@@ -98,9 +98,15 @@ class MqttCommunityModule(FanoutModule):
|
|||||||
@property
|
@property
|
||||||
def status(self) -> str:
|
def status(self) -> str:
|
||||||
if self._publisher._is_configured():
|
if self._publisher._is_configured():
|
||||||
|
if self._publisher.last_error:
|
||||||
|
return "error"
|
||||||
return "connected" if self._publisher.connected else "disconnected"
|
return "connected" if self._publisher.connected else "disconnected"
|
||||||
return "disconnected"
|
return "disconnected"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_error(self) -> str | None:
|
||||||
|
return self._publisher.last_error
|
||||||
|
|
||||||
|
|
||||||
async def _publish_community_packet(
|
async def _publish_community_packet(
|
||||||
publisher: CommunityMqttPublisher,
|
publisher: CommunityMqttPublisher,
|
||||||
|
|||||||
@@ -59,4 +59,10 @@ class MqttPrivateModule(FanoutModule):
|
|||||||
def status(self) -> str:
|
def status(self) -> str:
|
||||||
if not self.config.get("broker_host"):
|
if not self.config.get("broker_host"):
|
||||||
return "disconnected"
|
return "disconnected"
|
||||||
|
if self._publisher.last_error:
|
||||||
|
return "error"
|
||||||
return "connected" if self._publisher.connected else "disconnected"
|
return "connected" if self._publisher.connected else "disconnected"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_error(self) -> str | None:
|
||||||
|
return self._publisher.last_error
|
||||||
|
|||||||
+4
-5
@@ -84,7 +84,6 @@ class SqsModule(FanoutModule):
|
|||||||
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
|
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
|
||||||
super().__init__(config_id, config, name=name)
|
super().__init__(config_id, config, name=name)
|
||||||
self._client = None
|
self._client = None
|
||||||
self._last_error: str | None = None
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
kwargs: dict[str, str] = {}
|
kwargs: dict[str, str] = {}
|
||||||
@@ -147,18 +146,18 @@ class SqsModule(FanoutModule):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(partial(self._client.send_message, **request_kwargs))
|
await asyncio.to_thread(partial(self._client.send_message, **request_kwargs))
|
||||||
self._last_error = None
|
self._set_last_error(None)
|
||||||
except (ClientError, BotoCoreError) as exc:
|
except (ClientError, BotoCoreError) as exc:
|
||||||
self._last_error = str(exc)
|
self._set_last_error(str(exc))
|
||||||
logger.warning("SQS %s send error: %s", self.config_id, exc)
|
logger.warning("SQS %s send error: %s", self.config_id, exc)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._last_error = str(exc)
|
self._set_last_error(str(exc))
|
||||||
logger.exception("Unexpected SQS send error for %s", self.config_id)
|
logger.exception("Unexpected SQS send error for %s", self.config_id)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def status(self) -> str:
|
def status(self) -> str:
|
||||||
if not str(self.config.get("queue_url", "")).strip():
|
if not str(self.config.get("queue_url", "")).strip():
|
||||||
return "disconnected"
|
return "disconnected"
|
||||||
if self._last_error:
|
if self.last_error:
|
||||||
return "error"
|
return "error"
|
||||||
return "connected"
|
return "connected"
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ class WebhookModule(FanoutModule):
|
|||||||
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
|
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
|
||||||
super().__init__(config_id, config, name=name)
|
super().__init__(config_id, config, name=name)
|
||||||
self._client: httpx.AsyncClient | None = None
|
self._client: httpx.AsyncClient | None = None
|
||||||
self._last_error: str | None = None
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
self._client = httpx.AsyncClient(timeout=httpx.Timeout(10.0))
|
self._client = httpx.AsyncClient(timeout=httpx.Timeout(10.0))
|
||||||
@@ -62,9 +61,9 @@ class WebhookModule(FanoutModule):
|
|||||||
try:
|
try:
|
||||||
resp = await self._client.request(method, url, content=body_bytes, headers=headers)
|
resp = await self._client.request(method, url, content=body_bytes, headers=headers)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
self._last_error = None
|
self._set_last_error(None)
|
||||||
except httpx.HTTPStatusError as exc:
|
except httpx.HTTPStatusError as exc:
|
||||||
self._last_error = f"HTTP {exc.response.status_code}"
|
self._set_last_error(f"HTTP {exc.response.status_code}")
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Webhook %s returned %s for %s",
|
"Webhook %s returned %s for %s",
|
||||||
self.config_id,
|
self.config_id,
|
||||||
@@ -72,13 +71,13 @@ class WebhookModule(FanoutModule):
|
|||||||
url,
|
url,
|
||||||
)
|
)
|
||||||
except httpx.RequestError as exc:
|
except httpx.RequestError as exc:
|
||||||
self._last_error = str(exc)
|
self._set_last_error(str(exc))
|
||||||
logger.warning("Webhook %s request error: %s", self.config_id, exc)
|
logger.warning("Webhook %s request error: %s", self.config_id, exc)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def status(self) -> str:
|
def status(self) -> str:
|
||||||
if not self.config.get("url"):
|
if not self.config.get("url"):
|
||||||
return "disconnected"
|
return "disconnected"
|
||||||
if self._last_error:
|
if self.last_error:
|
||||||
return "error"
|
return "error"
|
||||||
return "connected"
|
return "connected"
|
||||||
|
|||||||
@@ -139,6 +139,18 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
|
|||||||
@app.get("/{path:path}")
|
@app.get("/{path:path}")
|
||||||
async def serve_frontend(path: str):
|
async def serve_frontend(path: str):
|
||||||
"""Serve frontend files, falling back to index.html for SPA routing."""
|
"""Serve frontend files, falling back to index.html for SPA routing."""
|
||||||
|
if path == "api" or path.startswith("api/"):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=404,
|
||||||
|
content={
|
||||||
|
"detail": (
|
||||||
|
"API endpoint not found. If you are seeing this in response to a "
|
||||||
|
"frontend request, you may be running a newer frontend with an older "
|
||||||
|
"backend or vice versa. A full update is suggested."
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
file_path = (frontend_dir / path).resolve()
|
file_path = (frontend_dir / path).resolve()
|
||||||
try:
|
try:
|
||||||
file_path.relative_to(frontend_dir)
|
file_path.relative_to(frontend_dir)
|
||||||
|
|||||||
+24
-1
@@ -1,14 +1,18 @@
|
|||||||
"""
|
"""
|
||||||
Ephemeral keystore for storing sensitive keys in memory.
|
Ephemeral keystore for storing sensitive keys in memory, plus the Ed25519
|
||||||
|
signing primitive used by fanout modules that need to sign requests with the
|
||||||
|
radio's own key.
|
||||||
|
|
||||||
The private key is stored in memory only and is never persisted to disk.
|
The private key is stored in memory only and is never persisted to disk.
|
||||||
It's exported from the radio on startup and reconnect, then used for
|
It's exported from the radio on startup and reconnect, then used for
|
||||||
server-side decryption of direct messages.
|
server-side decryption of direct messages.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import nacl.bindings
|
||||||
from meshcore import EventType
|
from meshcore import EventType
|
||||||
|
|
||||||
from app.decoder import derive_public_key
|
from app.decoder import derive_public_key
|
||||||
@@ -25,11 +29,30 @@ NO_EVENT_RECEIVED_GUIDANCE = (
|
|||||||
"issue commands to the radio."
|
"issue commands to the radio."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Ed25519 group order (L) — used in the expanded signing primitive below
|
||||||
|
_L = 2**252 + 27742317777372353535851937790883648493
|
||||||
|
|
||||||
# In-memory storage for the private key and derived public key
|
# In-memory storage for the private key and derived public key
|
||||||
_private_key: bytes | None = None
|
_private_key: bytes | None = None
|
||||||
_public_key: bytes | None = None
|
_public_key: bytes | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def ed25519_sign_expanded(message: bytes, scalar: bytes, prefix: bytes, public_key: bytes) -> bytes:
|
||||||
|
"""Sign a message using MeshCore's expanded Ed25519 key format.
|
||||||
|
|
||||||
|
MeshCore stores 64-byte keys as scalar(32) || prefix(32). Standard
|
||||||
|
Ed25519 libraries expect seed format and would re-SHA-512 the key, so we
|
||||||
|
perform the signing manually using the already-expanded key material.
|
||||||
|
|
||||||
|
Port of meshcore-packet-capture's ed25519_sign_with_expanded_key().
|
||||||
|
"""
|
||||||
|
r = int.from_bytes(hashlib.sha512(prefix + message).digest(), "little") % _L
|
||||||
|
R = nacl.bindings.crypto_scalarmult_ed25519_base_noclamp(r.to_bytes(32, "little"))
|
||||||
|
k = int.from_bytes(hashlib.sha512(R + public_key + message).digest(), "little") % _L
|
||||||
|
s = (r + k * int.from_bytes(scalar, "little")) % _L
|
||||||
|
return R + s.to_bytes(32, "little")
|
||||||
|
|
||||||
|
|
||||||
def clear_keys() -> None:
|
def clear_keys() -> None:
|
||||||
"""Clear any stored private/public key material from memory."""
|
"""Clear any stored private/public key material from memory."""
|
||||||
global _private_key, _public_key
|
global _private_key, _public_key
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from app.frontend_static import (
|
|||||||
)
|
)
|
||||||
from app.radio import RadioDisconnectedError
|
from app.radio import RadioDisconnectedError
|
||||||
from app.radio_sync import (
|
from app.radio_sync import (
|
||||||
|
stop_background_contact_reconciliation,
|
||||||
stop_message_polling,
|
stop_message_polling,
|
||||||
stop_periodic_advert,
|
stop_periodic_advert,
|
||||||
stop_periodic_sync,
|
stop_periodic_sync,
|
||||||
@@ -32,11 +33,13 @@ from app.routers import (
|
|||||||
radio,
|
radio,
|
||||||
read_state,
|
read_state,
|
||||||
repeaters,
|
repeaters,
|
||||||
|
rooms,
|
||||||
settings,
|
settings,
|
||||||
statistics,
|
statistics,
|
||||||
ws,
|
ws,
|
||||||
)
|
)
|
||||||
from app.security import add_optional_basic_auth_middleware
|
from app.security import add_optional_basic_auth_middleware
|
||||||
|
from app.services.radio_noise_floor import start_noise_floor_sampling, stop_noise_floor_sampling
|
||||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||||
from app.version_info import get_app_build_info
|
from app.version_info import get_app_build_info
|
||||||
|
|
||||||
@@ -68,6 +71,7 @@ async def lifespan(app: FastAPI):
|
|||||||
from app.radio_sync import ensure_default_channels
|
from app.radio_sync import ensure_default_channels
|
||||||
|
|
||||||
await ensure_default_channels()
|
await ensure_default_channels()
|
||||||
|
await start_noise_floor_sampling()
|
||||||
|
|
||||||
# Always start connection monitor (even if initial connection failed)
|
# Always start connection monitor (even if initial connection failed)
|
||||||
await radio_manager.start_connection_monitor()
|
await radio_manager.start_connection_monitor()
|
||||||
@@ -94,7 +98,9 @@ async def lifespan(app: FastAPI):
|
|||||||
pass
|
pass
|
||||||
await fanout_manager.stop_all()
|
await fanout_manager.stop_all()
|
||||||
await radio_manager.stop_connection_monitor()
|
await radio_manager.stop_connection_monitor()
|
||||||
|
await stop_background_contact_reconciliation()
|
||||||
await stop_message_polling()
|
await stop_message_polling()
|
||||||
|
await stop_noise_floor_sampling()
|
||||||
await stop_periodic_advert()
|
await stop_periodic_advert()
|
||||||
await stop_periodic_sync()
|
await stop_periodic_sync()
|
||||||
if radio_manager.meshcore:
|
if radio_manager.meshcore:
|
||||||
@@ -134,6 +140,7 @@ app.include_router(fanout.router, prefix="/api")
|
|||||||
app.include_router(radio.router, prefix="/api")
|
app.include_router(radio.router, prefix="/api")
|
||||||
app.include_router(contacts.router, prefix="/api")
|
app.include_router(contacts.router, prefix="/api")
|
||||||
app.include_router(repeaters.router, prefix="/api")
|
app.include_router(repeaters.router, prefix="/api")
|
||||||
|
app.include_router(rooms.router, prefix="/api")
|
||||||
app.include_router(channels.router, prefix="/api")
|
app.include_router(channels.router, prefix="/api")
|
||||||
app.include_router(messages.router, prefix="/api")
|
app.include_router(messages.router, prefix="/api")
|
||||||
app.include_router(packets.router, prefix="/api")
|
app.include_router(packets.router, prefix="/api")
|
||||||
|
|||||||
+496
-2
@@ -346,6 +346,49 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
|
|||||||
await set_version(conn, 44)
|
await set_version(conn, 44)
|
||||||
applied += 1
|
applied += 1
|
||||||
|
|
||||||
|
# Migration 45: Replace legacy contact route columns with direct-route columns
|
||||||
|
if version < 45:
|
||||||
|
logger.info("Applying migration 45: rebuild contacts direct-route columns")
|
||||||
|
await _migrate_045_rebuild_contacts_direct_route_columns(conn)
|
||||||
|
await set_version(conn, 45)
|
||||||
|
applied += 1
|
||||||
|
|
||||||
|
# Migration 46: Clean orphaned contact child rows left by old prefix promotion
|
||||||
|
if version < 46:
|
||||||
|
logger.info("Applying migration 46: clean orphaned contact child rows")
|
||||||
|
await _migrate_046_cleanup_orphaned_contact_child_rows(conn)
|
||||||
|
await set_version(conn, 46)
|
||||||
|
applied += 1
|
||||||
|
|
||||||
|
# Migration 47: Add statistics indexes for time-windowed scans
|
||||||
|
if version < 47:
|
||||||
|
logger.info("Applying migration 47: add statistics indexes")
|
||||||
|
await _migrate_047_add_statistics_indexes(conn)
|
||||||
|
await set_version(conn, 47)
|
||||||
|
applied += 1
|
||||||
|
|
||||||
|
# Migration 48: Add discovery_blocked_types column to app_settings
|
||||||
|
if version < 48:
|
||||||
|
logger.info("Applying migration 48: add discovery_blocked_types to app_settings")
|
||||||
|
await _migrate_048_discovery_blocked_types(conn)
|
||||||
|
await set_version(conn, 48)
|
||||||
|
applied += 1
|
||||||
|
|
||||||
|
# Migration 49: Enable foreign key enforcement — rebuild tables with
|
||||||
|
# CASCADE / SET NULL and clean up any orphaned rows first.
|
||||||
|
if version < 49:
|
||||||
|
logger.info("Applying migration 49: add foreign key cascade/set-null and clean orphans")
|
||||||
|
await _migrate_049_foreign_key_cascade(conn)
|
||||||
|
await set_version(conn, 49)
|
||||||
|
applied += 1
|
||||||
|
|
||||||
|
# Migration 50: Repeater telemetry history table
|
||||||
|
if version < 50:
|
||||||
|
logger.info("Applying migration 50: repeater telemetry history table")
|
||||||
|
await _migrate_050_repeater_telemetry_history(conn)
|
||||||
|
await set_version(conn, 50)
|
||||||
|
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)
|
||||||
@@ -808,7 +851,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
|
||||||
@@ -820,7 +863,7 @@ async def _migrate_009_create_app_settings_table(conn: aiosqlite.Connection) ->
|
|||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT OR IGNORE INTO app_settings (id, max_radio_contacts, favorites, auto_decrypt_dm_on_advert, sidebar_sort_order, last_message_times, preferences_migrated)
|
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)
|
VALUES (1, 200, '[]', 1, 'recent', '{}', 0)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2635,3 +2678,454 @@ async def _migrate_044_dedupe_incoming_direct_messages(conn: aiosqlite.Connectio
|
|||||||
WHERE type = 'PRIV' AND outgoing = 0"""
|
WHERE type = 'PRIV' AND outgoing = 0"""
|
||||||
)
|
)
|
||||||
await conn.commit()
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _migrate_045_rebuild_contacts_direct_route_columns(conn: aiosqlite.Connection) -> None:
|
||||||
|
"""Replace legacy contact route columns with canonical direct-route columns."""
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='contacts'"
|
||||||
|
)
|
||||||
|
if await cursor.fetchone() is None:
|
||||||
|
await conn.commit()
|
||||||
|
return
|
||||||
|
|
||||||
|
cursor = await conn.execute("PRAGMA table_info(contacts)")
|
||||||
|
columns = {row[1] for row in await cursor.fetchall()}
|
||||||
|
|
||||||
|
target_columns = {
|
||||||
|
"public_key",
|
||||||
|
"name",
|
||||||
|
"type",
|
||||||
|
"flags",
|
||||||
|
"direct_path",
|
||||||
|
"direct_path_len",
|
||||||
|
"direct_path_hash_mode",
|
||||||
|
"direct_path_updated_at",
|
||||||
|
"route_override_path",
|
||||||
|
"route_override_len",
|
||||||
|
"route_override_hash_mode",
|
||||||
|
"last_advert",
|
||||||
|
"lat",
|
||||||
|
"lon",
|
||||||
|
"last_seen",
|
||||||
|
"on_radio",
|
||||||
|
"last_contacted",
|
||||||
|
"first_seen",
|
||||||
|
"last_read_at",
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
target_columns.issubset(columns)
|
||||||
|
and "last_path" not in columns
|
||||||
|
and "out_path_hash_mode" not in columns
|
||||||
|
):
|
||||||
|
await conn.commit()
|
||||||
|
return
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE contacts_new (
|
||||||
|
public_key TEXT PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
type INTEGER DEFAULT 0,
|
||||||
|
flags INTEGER DEFAULT 0,
|
||||||
|
direct_path TEXT,
|
||||||
|
direct_path_len INTEGER,
|
||||||
|
direct_path_hash_mode INTEGER,
|
||||||
|
direct_path_updated_at INTEGER,
|
||||||
|
route_override_path TEXT,
|
||||||
|
route_override_len INTEGER,
|
||||||
|
route_override_hash_mode INTEGER,
|
||||||
|
last_advert INTEGER,
|
||||||
|
lat REAL,
|
||||||
|
lon REAL,
|
||||||
|
last_seen INTEGER,
|
||||||
|
on_radio INTEGER DEFAULT 0,
|
||||||
|
last_contacted INTEGER,
|
||||||
|
first_seen INTEGER,
|
||||||
|
last_read_at INTEGER
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
select_expr = {
|
||||||
|
"public_key": "public_key",
|
||||||
|
"name": "NULL",
|
||||||
|
"type": "0",
|
||||||
|
"flags": "0",
|
||||||
|
"direct_path": "NULL",
|
||||||
|
"direct_path_len": "NULL",
|
||||||
|
"direct_path_hash_mode": "NULL",
|
||||||
|
"direct_path_updated_at": "NULL",
|
||||||
|
"route_override_path": "NULL",
|
||||||
|
"route_override_len": "NULL",
|
||||||
|
"route_override_hash_mode": "NULL",
|
||||||
|
"last_advert": "NULL",
|
||||||
|
"lat": "NULL",
|
||||||
|
"lon": "NULL",
|
||||||
|
"last_seen": "NULL",
|
||||||
|
"on_radio": "0",
|
||||||
|
"last_contacted": "NULL",
|
||||||
|
"first_seen": "NULL",
|
||||||
|
"last_read_at": "NULL",
|
||||||
|
}
|
||||||
|
for name in ("name", "type", "flags"):
|
||||||
|
if name in columns:
|
||||||
|
select_expr[name] = name
|
||||||
|
|
||||||
|
if "direct_path" in columns:
|
||||||
|
select_expr["direct_path"] = "direct_path"
|
||||||
|
|
||||||
|
if "direct_path_len" in columns:
|
||||||
|
select_expr["direct_path_len"] = "direct_path_len"
|
||||||
|
|
||||||
|
if "direct_path_hash_mode" in columns:
|
||||||
|
select_expr["direct_path_hash_mode"] = "direct_path_hash_mode"
|
||||||
|
|
||||||
|
for name in (
|
||||||
|
"route_override_path",
|
||||||
|
"route_override_len",
|
||||||
|
"route_override_hash_mode",
|
||||||
|
"last_advert",
|
||||||
|
"lat",
|
||||||
|
"lon",
|
||||||
|
"last_seen",
|
||||||
|
"on_radio",
|
||||||
|
"last_contacted",
|
||||||
|
"first_seen",
|
||||||
|
"last_read_at",
|
||||||
|
):
|
||||||
|
if name in columns:
|
||||||
|
select_expr[name] = name
|
||||||
|
|
||||||
|
ordered_columns = list(select_expr.keys())
|
||||||
|
await conn.execute(
|
||||||
|
f"""
|
||||||
|
INSERT INTO contacts_new ({", ".join(ordered_columns)})
|
||||||
|
SELECT {", ".join(select_expr[name] for name in ordered_columns)}
|
||||||
|
FROM contacts
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
await conn.execute("DROP TABLE contacts")
|
||||||
|
await conn.execute("ALTER TABLE contacts_new RENAME TO contacts")
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _migrate_046_cleanup_orphaned_contact_child_rows(conn: aiosqlite.Connection) -> None:
|
||||||
|
"""Move uniquely resolvable orphan contact child rows onto full contacts, drop the rest."""
|
||||||
|
existing_tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||||
|
existing_tables = {row[0] for row in await existing_tables_cursor.fetchall()}
|
||||||
|
if "contacts" not in existing_tables:
|
||||||
|
await conn.commit()
|
||||||
|
return
|
||||||
|
|
||||||
|
child_tables = [
|
||||||
|
table
|
||||||
|
for table in ("contact_name_history", "contact_advert_paths")
|
||||||
|
if table in existing_tables
|
||||||
|
]
|
||||||
|
if not child_tables:
|
||||||
|
await conn.commit()
|
||||||
|
return
|
||||||
|
|
||||||
|
orphan_keys: set[str] = set()
|
||||||
|
|
||||||
|
for table in child_tables:
|
||||||
|
cursor = await conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT DISTINCT child.public_key
|
||||||
|
FROM {table} child
|
||||||
|
LEFT JOIN contacts c ON c.public_key = child.public_key
|
||||||
|
WHERE c.public_key IS NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
orphan_keys.update(row[0] for row in await cursor.fetchall())
|
||||||
|
|
||||||
|
for orphan_key in sorted(orphan_keys, key=len, reverse=True):
|
||||||
|
match_cursor = await conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT public_key
|
||||||
|
FROM contacts
|
||||||
|
WHERE length(public_key) = 64
|
||||||
|
AND public_key LIKE ? || '%'
|
||||||
|
ORDER BY public_key
|
||||||
|
""",
|
||||||
|
(orphan_key.lower(),),
|
||||||
|
)
|
||||||
|
matches = [row[0] for row in await match_cursor.fetchall()]
|
||||||
|
resolved_key = matches[0] if len(matches) == 1 else None
|
||||||
|
|
||||||
|
if resolved_key is not None:
|
||||||
|
if "contact_name_history" in child_tables:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO contact_name_history (public_key, name, first_seen, last_seen)
|
||||||
|
SELECT ?, name, first_seen, last_seen
|
||||||
|
FROM contact_name_history
|
||||||
|
WHERE public_key = ?
|
||||||
|
ON CONFLICT(public_key, name) DO UPDATE SET
|
||||||
|
first_seen = MIN(contact_name_history.first_seen, excluded.first_seen),
|
||||||
|
last_seen = MAX(contact_name_history.last_seen, excluded.last_seen)
|
||||||
|
""",
|
||||||
|
(resolved_key, orphan_key),
|
||||||
|
)
|
||||||
|
if "contact_advert_paths" in child_tables:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO contact_advert_paths
|
||||||
|
(public_key, path_hex, path_len, first_seen, last_seen, heard_count)
|
||||||
|
SELECT ?, path_hex, path_len, first_seen, last_seen, heard_count
|
||||||
|
FROM contact_advert_paths
|
||||||
|
WHERE public_key = ?
|
||||||
|
ON CONFLICT(public_key, path_hex, path_len) DO UPDATE SET
|
||||||
|
first_seen = MIN(contact_advert_paths.first_seen, excluded.first_seen),
|
||||||
|
last_seen = MAX(contact_advert_paths.last_seen, excluded.last_seen),
|
||||||
|
heard_count = contact_advert_paths.heard_count + excluded.heard_count
|
||||||
|
""",
|
||||||
|
(resolved_key, orphan_key),
|
||||||
|
)
|
||||||
|
|
||||||
|
if "contact_name_history" in child_tables:
|
||||||
|
await conn.execute(
|
||||||
|
"DELETE FROM contact_name_history WHERE public_key = ?",
|
||||||
|
(orphan_key,),
|
||||||
|
)
|
||||||
|
if "contact_advert_paths" in child_tables:
|
||||||
|
await conn.execute(
|
||||||
|
"DELETE FROM contact_advert_paths WHERE public_key = ?",
|
||||||
|
(orphan_key,),
|
||||||
|
)
|
||||||
|
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _migrate_047_add_statistics_indexes(conn: aiosqlite.Connection) -> None:
|
||||||
|
"""Add indexes used by the statistics endpoint's time-windowed scans."""
|
||||||
|
cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||||
|
tables = {row[0] for row in await cursor.fetchall()}
|
||||||
|
|
||||||
|
if "raw_packets" in tables:
|
||||||
|
cursor = await conn.execute("PRAGMA table_info(raw_packets)")
|
||||||
|
raw_packet_columns = {row[1] for row in await cursor.fetchall()}
|
||||||
|
if "timestamp" in raw_packet_columns:
|
||||||
|
await conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_raw_packets_timestamp ON raw_packets(timestamp)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "contacts" in tables:
|
||||||
|
cursor = await conn.execute("PRAGMA table_info(contacts)")
|
||||||
|
contact_columns = {row[1] for row in await cursor.fetchall()}
|
||||||
|
if {"type", "last_seen"}.issubset(contact_columns):
|
||||||
|
await conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_contacts_type_last_seen ON contacts(type, last_seen)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "messages" in tables:
|
||||||
|
cursor = await conn.execute("PRAGMA table_info(messages)")
|
||||||
|
message_columns = {row[1] for row in await cursor.fetchall()}
|
||||||
|
if {"type", "received_at", "conversation_key"}.issubset(message_columns):
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_type_received_conversation
|
||||||
|
ON messages(type, received_at, conversation_key)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
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.
|
||||||
|
|
||||||
|
Uses ON DELETE CASCADE so contact deletion automatically cleans up rows.
|
||||||
|
"""
|
||||||
|
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_history_key_ts "
|
||||||
|
"ON repeater_telemetry_history(public_key, timestamp DESC)"
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|||||||
+213
-25
@@ -2,7 +2,17 @@ from typing import Literal
|
|||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.path_utils import normalize_contact_route
|
from app.path_utils import normalize_contact_route, normalize_route_override
|
||||||
|
|
||||||
|
|
||||||
|
class ContactRoute(BaseModel):
|
||||||
|
"""A normalized contact route."""
|
||||||
|
|
||||||
|
path: str = Field(description="Hex-encoded path bytes (empty string for direct/flood)")
|
||||||
|
path_len: int = Field(description="Hop count (-1=flood, 0=direct, >0=explicit route)")
|
||||||
|
path_hash_mode: int = Field(
|
||||||
|
description="Path hash mode (-1=flood, 0=1-byte, 1=2-byte, 2=3-byte hop identifiers)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ContactUpsert(BaseModel):
|
class ContactUpsert(BaseModel):
|
||||||
@@ -12,9 +22,10 @@ class ContactUpsert(BaseModel):
|
|||||||
name: str | None = None
|
name: str | None = None
|
||||||
type: int = 0
|
type: int = 0
|
||||||
flags: int = 0
|
flags: int = 0
|
||||||
last_path: str | None = None
|
direct_path: str | None = None
|
||||||
last_path_len: int = -1
|
direct_path_len: int | None = None
|
||||||
out_path_hash_mode: int | None = None
|
direct_path_hash_mode: int | None = None
|
||||||
|
direct_path_updated_at: int | None = None
|
||||||
route_override_path: str | None = None
|
route_override_path: str | None = None
|
||||||
route_override_len: int | None = None
|
route_override_len: int | None = None
|
||||||
route_override_hash_mode: int | None = None
|
route_override_hash_mode: int | None = None
|
||||||
@@ -40,7 +51,7 @@ class ContactUpsert(BaseModel):
|
|||||||
cls, public_key: str, radio_data: dict, on_radio: bool = False
|
cls, public_key: str, radio_data: dict, on_radio: bool = False
|
||||||
) -> "ContactUpsert":
|
) -> "ContactUpsert":
|
||||||
"""Convert radio contact data to the contact-row write shape."""
|
"""Convert radio contact data to the contact-row write shape."""
|
||||||
last_path, last_path_len, out_path_hash_mode = normalize_contact_route(
|
direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route(
|
||||||
radio_data.get("out_path"),
|
radio_data.get("out_path"),
|
||||||
radio_data.get("out_path_len", -1),
|
radio_data.get("out_path_len", -1),
|
||||||
radio_data.get(
|
radio_data.get(
|
||||||
@@ -53,9 +64,9 @@ class ContactUpsert(BaseModel):
|
|||||||
name=radio_data.get("adv_name"),
|
name=radio_data.get("adv_name"),
|
||||||
type=radio_data.get("type", 0),
|
type=radio_data.get("type", 0),
|
||||||
flags=radio_data.get("flags", 0),
|
flags=radio_data.get("flags", 0),
|
||||||
last_path=last_path,
|
direct_path=direct_path,
|
||||||
last_path_len=last_path_len,
|
direct_path_len=direct_path_len,
|
||||||
out_path_hash_mode=out_path_hash_mode,
|
direct_path_hash_mode=direct_path_hash_mode,
|
||||||
lat=radio_data.get("adv_lat"),
|
lat=radio_data.get("adv_lat"),
|
||||||
lon=radio_data.get("adv_lon"),
|
lon=radio_data.get("adv_lon"),
|
||||||
last_advert=radio_data.get("last_advert"),
|
last_advert=radio_data.get("last_advert"),
|
||||||
@@ -68,9 +79,10 @@ class Contact(BaseModel):
|
|||||||
name: str | None = None
|
name: str | None = None
|
||||||
type: int = 0 # 0=unknown, 1=client, 2=repeater, 3=room, 4=sensor
|
type: int = 0 # 0=unknown, 1=client, 2=repeater, 3=room, 4=sensor
|
||||||
flags: int = 0
|
flags: int = 0
|
||||||
last_path: str | None = None
|
direct_path: str | None = None
|
||||||
last_path_len: int = -1
|
direct_path_len: int = -1
|
||||||
out_path_hash_mode: int = 0
|
direct_path_hash_mode: int = -1
|
||||||
|
direct_path_updated_at: int | None = None
|
||||||
route_override_path: str | None = None
|
route_override_path: str | None = None
|
||||||
route_override_len: int | None = None
|
route_override_len: int | None = None
|
||||||
route_override_hash_mode: int | None = None
|
route_override_hash_mode: int | None = None
|
||||||
@@ -82,38 +94,99 @@ class Contact(BaseModel):
|
|||||||
last_contacted: int | None = None # Last time we sent/received a message
|
last_contacted: int | None = None # Last time we sent/received a message
|
||||||
last_read_at: int | None = None # Server-side read state tracking
|
last_read_at: int | None = None # Server-side read state tracking
|
||||||
first_seen: int | None = None
|
first_seen: int | None = None
|
||||||
|
effective_route: ContactRoute | None = None
|
||||||
|
effective_route_source: Literal["override", "direct", "flood"] = "flood"
|
||||||
|
direct_route: ContactRoute | None = None
|
||||||
|
route_override: ContactRoute | None = None
|
||||||
|
|
||||||
|
def model_post_init(self, __context) -> None:
|
||||||
|
direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route(
|
||||||
|
self.direct_path,
|
||||||
|
self.direct_path_len,
|
||||||
|
self.direct_path_hash_mode,
|
||||||
|
)
|
||||||
|
self.direct_path = direct_path or None
|
||||||
|
self.direct_path_len = direct_path_len
|
||||||
|
self.direct_path_hash_mode = direct_path_hash_mode
|
||||||
|
|
||||||
|
route_override_path, route_override_len, route_override_hash_mode = (
|
||||||
|
normalize_route_override(
|
||||||
|
self.route_override_path,
|
||||||
|
self.route_override_len,
|
||||||
|
self.route_override_hash_mode,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.route_override_path = route_override_path or None
|
||||||
|
self.route_override_len = route_override_len
|
||||||
|
self.route_override_hash_mode = route_override_hash_mode
|
||||||
|
if (
|
||||||
|
route_override_path is not None
|
||||||
|
and route_override_len is not None
|
||||||
|
and route_override_hash_mode is not None
|
||||||
|
):
|
||||||
|
self.route_override = ContactRoute(
|
||||||
|
path=route_override_path,
|
||||||
|
path_len=route_override_len,
|
||||||
|
path_hash_mode=route_override_hash_mode,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.route_override = None
|
||||||
|
|
||||||
|
if direct_path_len >= 0:
|
||||||
|
self.direct_route = ContactRoute(
|
||||||
|
path=direct_path,
|
||||||
|
path_len=direct_path_len,
|
||||||
|
path_hash_mode=direct_path_hash_mode,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.direct_route = None
|
||||||
|
|
||||||
|
path, path_len, path_hash_mode = self.effective_route_tuple()
|
||||||
|
if self.has_route_override():
|
||||||
|
self.effective_route_source = "override"
|
||||||
|
elif self.direct_route is not None:
|
||||||
|
self.effective_route_source = "direct"
|
||||||
|
else:
|
||||||
|
self.effective_route_source = "flood"
|
||||||
|
self.effective_route = ContactRoute(
|
||||||
|
path=path,
|
||||||
|
path_len=path_len,
|
||||||
|
path_hash_mode=path_hash_mode,
|
||||||
|
)
|
||||||
|
|
||||||
def has_route_override(self) -> bool:
|
def has_route_override(self) -> bool:
|
||||||
return self.route_override_len is not None
|
return self.route_override_len is not None
|
||||||
|
|
||||||
def effective_route(self) -> tuple[str, int, int]:
|
def effective_route_tuple(self) -> tuple[str, int, int]:
|
||||||
if self.has_route_override():
|
if self.has_route_override():
|
||||||
return normalize_contact_route(
|
return normalize_contact_route(
|
||||||
self.route_override_path,
|
self.route_override_path,
|
||||||
self.route_override_len,
|
self.route_override_len,
|
||||||
self.route_override_hash_mode,
|
self.route_override_hash_mode,
|
||||||
)
|
)
|
||||||
return normalize_contact_route(
|
if self.direct_path_len >= 0:
|
||||||
self.last_path,
|
return normalize_contact_route(
|
||||||
self.last_path_len,
|
self.direct_path,
|
||||||
self.out_path_hash_mode,
|
self.direct_path_len,
|
||||||
)
|
self.direct_path_hash_mode,
|
||||||
|
)
|
||||||
|
return "", -1, -1
|
||||||
|
|
||||||
def to_radio_dict(self) -> dict:
|
def to_radio_dict(self) -> dict:
|
||||||
"""Convert to the dict format expected by meshcore radio commands.
|
"""Convert to the dict format expected by meshcore radio commands.
|
||||||
|
|
||||||
The radio API uses different field names (adv_name, out_path, etc.)
|
The radio API uses different field names (adv_name, out_path, etc.)
|
||||||
than our database schema (name, last_path, etc.).
|
than our database schema (name, direct_path, etc.).
|
||||||
"""
|
"""
|
||||||
last_path, last_path_len, out_path_hash_mode = self.effective_route()
|
effective_path, effective_path_len, effective_path_hash_mode = self.effective_route_tuple()
|
||||||
return {
|
return {
|
||||||
"public_key": self.public_key,
|
"public_key": self.public_key,
|
||||||
"adv_name": self.name or "",
|
"adv_name": self.name or "",
|
||||||
"type": self.type,
|
"type": self.type,
|
||||||
"flags": self.flags,
|
"flags": self.flags,
|
||||||
"out_path": last_path,
|
"out_path": effective_path,
|
||||||
"out_path_len": last_path_len,
|
"out_path_len": effective_path_len,
|
||||||
"out_path_hash_mode": out_path_hash_mode,
|
"out_path_hash_mode": effective_path_hash_mode,
|
||||||
"adv_lat": self.lat if self.lat is not None else 0.0,
|
"adv_lat": self.lat if self.lat is not None else 0.0,
|
||||||
"adv_lon": self.lon if self.lon is not None else 0.0,
|
"adv_lon": self.lon if self.lon is not None else 0.0,
|
||||||
"last_advert": self.last_advert if self.last_advert is not None else 0,
|
"last_advert": self.last_advert if self.last_advert is not None else 0,
|
||||||
@@ -149,7 +222,7 @@ class ContactRoutingOverrideRequest(BaseModel):
|
|||||||
|
|
||||||
route: str = Field(
|
route: str = Field(
|
||||||
description=(
|
description=(
|
||||||
"Blank clears the override and resets learned routing to flood, "
|
"Blank clears the override, "
|
||||||
'"-1" forces flood, "0" forces direct, and explicit routes are '
|
'"-1" forces flood, "0" forces direct, and explicit routes are '
|
||||||
"comma-separated 1/2/3-byte hop hex values"
|
"comma-separated 1/2/3-byte hop hex values"
|
||||||
)
|
)
|
||||||
@@ -158,6 +231,7 @@ class ContactRoutingOverrideRequest(BaseModel):
|
|||||||
|
|
||||||
# Contact type constants
|
# Contact type constants
|
||||||
CONTACT_TYPE_REPEATER = 2
|
CONTACT_TYPE_REPEATER = 2
|
||||||
|
CONTACT_TYPE_ROOM = 3
|
||||||
|
|
||||||
|
|
||||||
class ContactAdvertPath(BaseModel):
|
class ContactAdvertPath(BaseModel):
|
||||||
@@ -192,7 +266,7 @@ class ContactNameHistory(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ContactActiveRoom(BaseModel):
|
class ContactActiveRoom(BaseModel):
|
||||||
"""A channel/room where a contact has been active."""
|
"""A channel where a contact has been active."""
|
||||||
|
|
||||||
channel_key: str
|
channel_key: str
|
||||||
channel_name: str
|
channel_name: str
|
||||||
@@ -339,6 +413,10 @@ class Message(BaseModel):
|
|||||||
acked: int = 0
|
acked: int = 0
|
||||||
sender_name: str | None = None
|
sender_name: str | None = None
|
||||||
channel_name: str | None = None
|
channel_name: str | None = None
|
||||||
|
packet_id: int | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Representative raw packet row ID when archival raw bytes exist",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class MessagesAroundResponse(BaseModel):
|
class MessagesAroundResponse(BaseModel):
|
||||||
@@ -384,6 +462,21 @@ class RawPacketBroadcast(BaseModel):
|
|||||||
decrypted_info: RawPacketDecryptedInfo | None = None
|
decrypted_info: RawPacketDecryptedInfo | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RawPacketDetail(BaseModel):
|
||||||
|
"""Stored raw-packet detail returned by the packet API."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
timestamp: int
|
||||||
|
data: str = Field(description="Hex-encoded packet data")
|
||||||
|
payload_type: str = Field(description="Packet type name (e.g. GROUP_TEXT, ADVERT)")
|
||||||
|
snr: float | None = Field(default=None, description="Signal-to-noise ratio in dB if available")
|
||||||
|
rssi: int | None = Field(
|
||||||
|
default=None, description="Received signal strength in dBm if available"
|
||||||
|
)
|
||||||
|
decrypted: bool = False
|
||||||
|
decrypted_info: RawPacketDecryptedInfo | None = None
|
||||||
|
|
||||||
|
|
||||||
class SendMessageRequest(BaseModel):
|
class SendMessageRequest(BaseModel):
|
||||||
text: str = Field(min_length=1)
|
text: str = Field(min_length=1)
|
||||||
|
|
||||||
@@ -437,6 +530,14 @@ 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 TelemetryHistoryEntry(BaseModel):
|
||||||
|
timestamp: int
|
||||||
|
data: dict
|
||||||
|
|
||||||
|
|
||||||
class RepeaterNodeInfoResponse(BaseModel):
|
class RepeaterNodeInfoResponse(BaseModel):
|
||||||
@@ -535,6 +636,59 @@ class TraceResponse(BaseModel):
|
|||||||
path_len: int = Field(description="Number of hops in the trace path")
|
path_len: int = Field(description="Number of hops in the trace path")
|
||||||
|
|
||||||
|
|
||||||
|
class RadioTraceHopRequest(BaseModel):
|
||||||
|
"""One requested hop in a radio trace path."""
|
||||||
|
|
||||||
|
public_key: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Full repeater public key when this hop maps to a known repeater",
|
||||||
|
)
|
||||||
|
hop_hex: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Raw hop hash hex when using a custom repeater prefix",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RadioTraceRequest(BaseModel):
|
||||||
|
"""Ordered trace path for a radio trace loop."""
|
||||||
|
|
||||||
|
hop_hash_bytes: Literal[1, 2, 4] = Field(
|
||||||
|
default=4,
|
||||||
|
description="Hash width in bytes for every hop in this trace path",
|
||||||
|
)
|
||||||
|
hops: list[RadioTraceHopRequest] = Field(
|
||||||
|
min_length=1,
|
||||||
|
description="Ordered repeater hops, using either known repeater keys or custom hop hex",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RadioTraceNode(BaseModel):
|
||||||
|
"""One resolved node in a radio trace result."""
|
||||||
|
|
||||||
|
role: Literal["repeater", "custom", "local"] = Field(description="Node role in the trace")
|
||||||
|
public_key: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Resolved full public key for this node when known",
|
||||||
|
)
|
||||||
|
name: str | None = Field(default=None, description="Display name for this node when known")
|
||||||
|
observed_hash: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Observed 4-byte trace hash for this node as hex",
|
||||||
|
)
|
||||||
|
snr: float | None = Field(default=None, description="Reported SNR for this node in dB")
|
||||||
|
|
||||||
|
|
||||||
|
class RadioTraceResponse(BaseModel):
|
||||||
|
"""Resolved multi-hop radio trace result."""
|
||||||
|
|
||||||
|
path_len: int = Field(description="Number of hashed nodes returned by the trace response")
|
||||||
|
timeout_seconds: float = Field(description="Timeout window used while waiting for the trace")
|
||||||
|
nodes: list[RadioTraceNode] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Ordered trace nodes: repeater hops followed by the terminal local radio",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PathDiscoveryRoute(BaseModel):
|
class PathDiscoveryRoute(BaseModel):
|
||||||
"""One resolved route returned by contact path discovery."""
|
"""One resolved route returned by contact path discovery."""
|
||||||
|
|
||||||
@@ -588,6 +742,10 @@ class RadioDiscoveryResult(BaseModel):
|
|||||||
"""One mesh node heard during a discovery sweep."""
|
"""One mesh node heard during a discovery sweep."""
|
||||||
|
|
||||||
public_key: str = Field(description="Discovered node public key as hex")
|
public_key: str = Field(description="Discovered node public key as hex")
|
||||||
|
name: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Known name for this node from contacts DB, if any",
|
||||||
|
)
|
||||||
node_type: Literal["repeater", "sensor"] = Field(description="Discovered node class")
|
node_type: Literal["repeater", "sensor"] = Field(description="Discovered node class")
|
||||||
heard_count: int = Field(default=1, description="How many responses were heard from this node")
|
heard_count: int = Field(default=1, description="How many responses were heard from this node")
|
||||||
local_snr: float | None = Field(
|
local_snr: float | None = Field(
|
||||||
@@ -655,7 +813,7 @@ 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(
|
sidebar_sort_order: Literal["recent", "alpha"] = Field(
|
||||||
@@ -690,6 +848,13 @@ 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,
|
||||||
|
description=(
|
||||||
|
"Contact type codes (1=Client, 2=Repeater, 3=Room, 4=Sensor) whose "
|
||||||
|
"advertisements should not create new contacts; existing contacts are still updated"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class FanoutConfig(BaseModel):
|
class FanoutConfig(BaseModel):
|
||||||
@@ -727,6 +892,27 @@ class PathHashWidthStats(BaseModel):
|
|||||||
triple_byte_pct: float
|
triple_byte_pct: float
|
||||||
|
|
||||||
|
|
||||||
|
class NoiseFloorSample(BaseModel):
|
||||||
|
timestamp: int = Field(description="Unix timestamp of the sampled reading")
|
||||||
|
noise_floor_dbm: int = Field(description="Noise floor in dBm")
|
||||||
|
|
||||||
|
|
||||||
|
class NoiseFloorHistoryStats(BaseModel):
|
||||||
|
sample_interval_seconds: int = Field(description="Expected spacing between samples")
|
||||||
|
coverage_seconds: int = Field(description="How much of the last 24 hours is represented")
|
||||||
|
latest_noise_floor_dbm: int | None = Field(
|
||||||
|
default=None, description="Most recent sampled noise floor in dBm"
|
||||||
|
)
|
||||||
|
latest_timestamp: int | None = Field(
|
||||||
|
default=None, description="Unix timestamp of the most recent sample"
|
||||||
|
)
|
||||||
|
supported: bool | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Whether the connected radio appears to support radio stats sampling",
|
||||||
|
)
|
||||||
|
samples: list[NoiseFloorSample] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class StatisticsResponse(BaseModel):
|
class StatisticsResponse(BaseModel):
|
||||||
busiest_channels_24h: list[BusyChannel]
|
busiest_channels_24h: list[BusyChannel]
|
||||||
contact_count: int
|
contact_count: int
|
||||||
@@ -740,4 +926,6 @@ class StatisticsResponse(BaseModel):
|
|||||||
total_outgoing: int
|
total_outgoing: int
|
||||||
contacts_heard: ContactActivityCounts
|
contacts_heard: ContactActivityCounts
|
||||||
repeaters_heard: ContactActivityCounts
|
repeaters_heard: ContactActivityCounts
|
||||||
|
known_channels_active: ContactActivityCounts
|
||||||
path_hash_width_24h: PathHashWidthStats
|
path_hash_width_24h: PathHashWidthStats
|
||||||
|
noise_floor_24h: NoiseFloorHistoryStats
|
||||||
|
|||||||
+135
-57
@@ -26,6 +26,7 @@ from app.decoder import (
|
|||||||
parse_packet,
|
parse_packet,
|
||||||
try_decrypt_dm,
|
try_decrypt_dm,
|
||||||
try_decrypt_packet_with_channel_key,
|
try_decrypt_packet_with_channel_key,
|
||||||
|
try_decrypt_path,
|
||||||
)
|
)
|
||||||
from app.keystore import get_private_key, get_public_key, has_private_key
|
from app.keystore import get_private_key, get_public_key, has_private_key
|
||||||
from app.models import (
|
from app.models import (
|
||||||
@@ -44,6 +45,7 @@ from app.services.contact_reconciliation import (
|
|||||||
promote_prefix_contacts_for_contact,
|
promote_prefix_contacts_for_contact,
|
||||||
record_contact_name_and_reconcile,
|
record_contact_name_and_reconcile,
|
||||||
)
|
)
|
||||||
|
from app.services.dm_ack_apply import apply_dm_ack_code
|
||||||
from app.services.messages import (
|
from app.services.messages import (
|
||||||
create_dm_message_from_decrypted as _create_dm_message_from_decrypted,
|
create_dm_message_from_decrypted as _create_dm_message_from_decrypted,
|
||||||
)
|
)
|
||||||
@@ -120,20 +122,20 @@ async def run_historical_dm_decryption(
|
|||||||
"""Background task to decrypt historical DM packets with contact's key."""
|
"""Background task to decrypt historical DM packets with contact's key."""
|
||||||
from app.websocket import broadcast_success
|
from app.websocket import broadcast_success
|
||||||
|
|
||||||
packets = await RawPacketRepository.get_undecrypted_text_messages()
|
total = 0
|
||||||
total = len(packets)
|
|
||||||
decrypted_count = 0
|
decrypted_count = 0
|
||||||
|
|
||||||
if total == 0:
|
logger.info("Starting historical DM decryption scan for undecrypted TEXT_MESSAGE packets")
|
||||||
logger.info("No undecrypted TEXT_MESSAGE packets to process")
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info("Starting historical DM decryption of %d TEXT_MESSAGE packets", total)
|
|
||||||
|
|
||||||
# Derive our public key from the private key
|
# Derive our public key from the private key
|
||||||
our_public_key_bytes = derive_public_key(private_key_bytes)
|
our_public_key_bytes = derive_public_key(private_key_bytes)
|
||||||
|
|
||||||
for packet_id, packet_data, packet_timestamp in packets:
|
async for (
|
||||||
|
packet_id,
|
||||||
|
packet_data,
|
||||||
|
packet_timestamp,
|
||||||
|
) in RawPacketRepository.stream_undecrypted_text_messages():
|
||||||
|
total += 1
|
||||||
# Note: passing our_public_key=None disables the outbound hash check in
|
# Note: passing our_public_key=None disables the outbound hash check in
|
||||||
# try_decrypt_dm (only the inbound check src_hash == their_first_byte runs).
|
# try_decrypt_dm (only the inbound check src_hash == their_first_byte runs).
|
||||||
# For the 255/256 case where our first byte differs from the contact's,
|
# For the 255/256 case where our first byte differs from the contact's,
|
||||||
@@ -185,6 +187,10 @@ async def run_historical_dm_decryption(
|
|||||||
if msg_id is not None:
|
if msg_id is not None:
|
||||||
decrypted_count += 1
|
decrypted_count += 1
|
||||||
|
|
||||||
|
if total == 0:
|
||||||
|
logger.info("No undecrypted TEXT_MESSAGE packets to process")
|
||||||
|
return
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Historical DM decryption complete: %d/%d packets decrypted",
|
"Historical DM decryption complete: %d/%d packets decrypted",
|
||||||
decrypted_count,
|
decrypted_count,
|
||||||
@@ -262,9 +268,10 @@ async def process_raw_packet(
|
|||||||
This is the main entry point for all incoming RF packets.
|
This is the main entry point for all incoming RF packets.
|
||||||
|
|
||||||
Note: Packets are deduplicated by payload hash in the database. If we receive
|
Note: Packets are deduplicated by payload hash in the database. If we receive
|
||||||
a duplicate packet (same payload, different path), we still broadcast it to
|
a duplicate payload (same payload, different path), we still broadcast it to
|
||||||
the frontend (for the real-time packet feed) but skip decryption processing
|
the frontend for realtime packet-feed fidelity. Some payload types are also
|
||||||
since the original packet was already processed.
|
intentionally reprocessed on duplicate arrival so message-level dedup/path
|
||||||
|
merge logic and advert/path-history tracking still see each observation.
|
||||||
"""
|
"""
|
||||||
ts = timestamp or int(time.time())
|
ts = timestamp or int(time.time())
|
||||||
observation_id = next(_raw_observation_counter)
|
observation_id = next(_raw_observation_counter)
|
||||||
@@ -318,8 +325,7 @@ async def process_raw_packet(
|
|||||||
|
|
||||||
elif payload_type == PayloadType.ADVERT:
|
elif payload_type == PayloadType.ADVERT:
|
||||||
# Process all advert arrivals (even payload-hash duplicates) so the
|
# Process all advert arrivals (even payload-hash duplicates) so the
|
||||||
# path-freshness logic in _process_advertisement can pick the shortest
|
# advert-history table retains recent path observations.
|
||||||
# path heard within the freshness window.
|
|
||||||
await _process_advertisement(raw_bytes, ts, packet_info)
|
await _process_advertisement(raw_bytes, ts, packet_info)
|
||||||
|
|
||||||
elif payload_type == PayloadType.TEXT_MESSAGE:
|
elif payload_type == PayloadType.TEXT_MESSAGE:
|
||||||
@@ -328,6 +334,9 @@ async def process_raw_packet(
|
|||||||
if decrypt_result:
|
if decrypt_result:
|
||||||
result.update(decrypt_result)
|
result.update(decrypt_result)
|
||||||
|
|
||||||
|
elif payload_type == PayloadType.PATH:
|
||||||
|
await _process_path_packet(raw_bytes, ts, packet_info)
|
||||||
|
|
||||||
# Always broadcast raw packet for the packet feed UI (even duplicates)
|
# Always broadcast raw packet for the packet feed UI (even duplicates)
|
||||||
# This enables the frontend cracker to see all incoming packets in real-time
|
# This enables the frontend cracker to see all incoming packets in real-time
|
||||||
broadcast_payload = RawPacketBroadcast(
|
broadcast_payload = RawPacketBroadcast(
|
||||||
@@ -430,51 +439,20 @@ async def _process_advertisement(
|
|||||||
logger.debug("Failed to parse advertisement payload")
|
logger.debug("Failed to parse advertisement payload")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Extract path info from packet
|
|
||||||
new_path_len = packet_info.path_length
|
new_path_len = packet_info.path_length
|
||||||
new_path_hex = packet_info.path.hex() if packet_info.path else ""
|
new_path_hex = packet_info.path.hex() if packet_info.path else ""
|
||||||
|
|
||||||
# Try to find existing contact
|
# Try to find existing contact
|
||||||
existing = await ContactRepository.get_by_key(advert.public_key.lower())
|
existing = await ContactRepository.get_by_key(advert.public_key.lower())
|
||||||
|
|
||||||
# Determine which path to use: keep shorter path if heard recently (within 60s)
|
|
||||||
# This handles advertisement echoes through different routes
|
|
||||||
PATH_FRESHNESS_SECONDS = 60
|
|
||||||
use_existing_path = False
|
|
||||||
|
|
||||||
if existing and existing.last_advert:
|
|
||||||
path_age = timestamp - existing.last_advert
|
|
||||||
existing_path_len = existing.last_path_len if existing.last_path_len >= 0 else float("inf")
|
|
||||||
|
|
||||||
# Keep existing path if it's fresh and shorter (or equal)
|
|
||||||
if path_age <= PATH_FRESHNESS_SECONDS and existing_path_len <= new_path_len:
|
|
||||||
use_existing_path = True
|
|
||||||
logger.debug(
|
|
||||||
"Keeping existing shorter path for %s (existing=%d, new=%d, age=%ds)",
|
|
||||||
advert.public_key[:12],
|
|
||||||
existing_path_len,
|
|
||||||
new_path_len,
|
|
||||||
path_age,
|
|
||||||
)
|
|
||||||
|
|
||||||
if use_existing_path:
|
|
||||||
assert existing is not None # Guaranteed by the conditions that set use_existing_path
|
|
||||||
path_len = existing.last_path_len if existing.last_path_len is not None else -1
|
|
||||||
path_hex = existing.last_path or ""
|
|
||||||
out_path_hash_mode = existing.out_path_hash_mode
|
|
||||||
else:
|
|
||||||
path_len = new_path_len
|
|
||||||
path_hex = new_path_hex
|
|
||||||
out_path_hash_mode = packet_info.path_hash_size - 1
|
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Parsed advertisement from %s: %s (role=%d, lat=%s, lon=%s, path_len=%d)",
|
"Parsed advertisement from %s: %s (role=%d, lat=%s, lon=%s, advert_path_len=%d)",
|
||||||
advert.public_key[:12],
|
advert.public_key[:12],
|
||||||
advert.name,
|
advert.name,
|
||||||
advert.device_role,
|
advert.device_role,
|
||||||
advert.lat,
|
advert.lat,
|
||||||
advert.lon,
|
advert.lon,
|
||||||
path_len,
|
new_path_len,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Use device_role from advertisement for contact type (1=Chat, 2=Repeater, 3=Room, 4=Sensor).
|
# Use device_role from advertisement for contact type (1=Chat, 2=Repeater, 3=Room, 4=Sensor).
|
||||||
@@ -484,14 +462,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(),
|
||||||
@@ -501,13 +484,21 @@ async def _process_advertisement(
|
|||||||
lon=advert.lon,
|
lon=advert.lon,
|
||||||
last_advert=timestamp,
|
last_advert=timestamp,
|
||||||
last_seen=timestamp,
|
last_seen=timestamp,
|
||||||
last_path=path_hex,
|
|
||||||
last_path_len=path_len,
|
|
||||||
out_path_hash_mode=out_path_hash_mode,
|
|
||||||
first_seen=timestamp, # COALESCE in upsert preserves existing value
|
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,
|
||||||
@@ -667,3 +658,90 @@ async def _process_direct_message(
|
|||||||
# Couldn't decrypt with any known contact
|
# Couldn't decrypt with any known contact
|
||||||
logger.debug("Could not decrypt DM with any of %d candidate contacts", len(candidate_contacts))
|
logger.debug("Could not decrypt DM with any of %d candidate contacts", len(candidate_contacts))
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _process_path_packet(
|
||||||
|
raw_bytes: bytes,
|
||||||
|
timestamp: int,
|
||||||
|
packet_info: PacketInfo | None,
|
||||||
|
) -> None:
|
||||||
|
"""Process a PATH packet and update the learned direct route."""
|
||||||
|
if not has_private_key():
|
||||||
|
return
|
||||||
|
|
||||||
|
private_key = get_private_key()
|
||||||
|
our_public_key = get_public_key()
|
||||||
|
if private_key is None or our_public_key is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if packet_info is None:
|
||||||
|
packet_info = parse_packet(raw_bytes)
|
||||||
|
if packet_info is None or packet_info.payload is None or len(packet_info.payload) < 4:
|
||||||
|
return
|
||||||
|
|
||||||
|
dest_hash = format(packet_info.payload[0], "02x").lower()
|
||||||
|
src_hash = format(packet_info.payload[1], "02x").lower()
|
||||||
|
our_first_byte = format(our_public_key[0], "02x").lower()
|
||||||
|
if dest_hash != our_first_byte:
|
||||||
|
return
|
||||||
|
|
||||||
|
candidate_contacts = await ContactRepository.get_by_pubkey_first_byte(src_hash)
|
||||||
|
if not candidate_contacts:
|
||||||
|
logger.debug("No contacts found matching hash %s for PATH decryption", src_hash)
|
||||||
|
return
|
||||||
|
|
||||||
|
for contact in candidate_contacts:
|
||||||
|
if len(contact.public_key) != 64:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
contact_public_key = bytes.fromhex(contact.public_key)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
result = try_decrypt_path(
|
||||||
|
raw_packet=raw_bytes,
|
||||||
|
our_private_key=private_key,
|
||||||
|
their_public_key=contact_public_key,
|
||||||
|
our_public_key=our_public_key,
|
||||||
|
)
|
||||||
|
if result is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
await ContactRepository.update_direct_path(
|
||||||
|
contact.public_key,
|
||||||
|
result.returned_path.hex(),
|
||||||
|
result.returned_path_len,
|
||||||
|
result.returned_path_hash_mode,
|
||||||
|
updated_at=timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.extra_type == PayloadType.ACK and len(result.extra) >= 4:
|
||||||
|
ack_code = result.extra[:4].hex()
|
||||||
|
matched = await apply_dm_ack_code(ack_code, broadcast_fn=broadcast_event)
|
||||||
|
if matched:
|
||||||
|
logger.info(
|
||||||
|
"Applied bundled PATH ACK for %s via contact %s",
|
||||||
|
ack_code,
|
||||||
|
contact.public_key[:12],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
"Buffered bundled PATH ACK %s via contact %s",
|
||||||
|
ack_code,
|
||||||
|
contact.public_key[:12],
|
||||||
|
)
|
||||||
|
elif result.extra_type == PayloadType.RESPONSE and len(result.extra) > 0:
|
||||||
|
logger.debug(
|
||||||
|
"Observed bundled PATH RESPONSE from %s (%d bytes)",
|
||||||
|
contact.public_key[:12],
|
||||||
|
len(result.extra),
|
||||||
|
)
|
||||||
|
|
||||||
|
refreshed_contact = await ContactRepository.get_by_key(contact.public_key)
|
||||||
|
if refreshed_contact is not None:
|
||||||
|
broadcast_event("contact", refreshed_contact.model_dump())
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Could not decrypt PATH packet with any of %d candidate contacts", len(candidate_contacts)
|
||||||
|
)
|
||||||
|
|||||||
+5
-5
@@ -153,12 +153,12 @@ def first_hop_hex(path_hex: str, hop_count: int) -> str | None:
|
|||||||
def normalize_contact_route(
|
def normalize_contact_route(
|
||||||
path_hex: str | None,
|
path_hex: str | None,
|
||||||
path_len: int | None,
|
path_len: int | None,
|
||||||
out_path_hash_mode: int | None,
|
path_hash_mode: int | None,
|
||||||
) -> tuple[str, int, int]:
|
) -> tuple[str, int, int]:
|
||||||
"""Normalize stored contact route fields.
|
"""Normalize stored contact route fields.
|
||||||
|
|
||||||
Handles legacy/bad rows where the packed wire path byte was stored directly
|
Handles legacy/bad rows where the packed wire path byte was stored directly
|
||||||
in `last_path_len` (sometimes as a signed byte, e.g. `-125` for `0x83`).
|
in the hop-count column (sometimes as a signed byte, e.g. `-125` for `0x83`).
|
||||||
Returns `(path_hex, hop_count, hash_mode)`.
|
Returns `(path_hex, hop_count, hash_mode)`.
|
||||||
"""
|
"""
|
||||||
normalized_path = path_hex or ""
|
normalized_path = path_hex or ""
|
||||||
@@ -169,7 +169,7 @@ def normalize_contact_route(
|
|||||||
normalized_len = -1
|
normalized_len = -1
|
||||||
|
|
||||||
try:
|
try:
|
||||||
normalized_mode = int(out_path_hash_mode) if out_path_hash_mode is not None else None
|
normalized_mode = int(path_hash_mode) if path_hash_mode is not None else None
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
normalized_mode = None
|
normalized_mode = None
|
||||||
|
|
||||||
@@ -207,7 +207,7 @@ def normalize_contact_route(
|
|||||||
def normalize_route_override(
|
def normalize_route_override(
|
||||||
path_hex: str | None,
|
path_hex: str | None,
|
||||||
path_len: int | None,
|
path_len: int | None,
|
||||||
out_path_hash_mode: int | None,
|
path_hash_mode: int | None,
|
||||||
) -> tuple[str | None, int | None, int | None]:
|
) -> tuple[str | None, int | None, int | None]:
|
||||||
"""Normalize optional route-override fields while preserving the unset state."""
|
"""Normalize optional route-override fields while preserving the unset state."""
|
||||||
if path_len is None:
|
if path_len is None:
|
||||||
@@ -216,7 +216,7 @@ def normalize_route_override(
|
|||||||
normalized_path, normalized_len, normalized_mode = normalize_contact_route(
|
normalized_path, normalized_len, normalized_mode = normalize_contact_route(
|
||||||
path_hex,
|
path_hex,
|
||||||
path_len,
|
path_len,
|
||||||
out_path_hash_mode,
|
path_hash_mode,
|
||||||
)
|
)
|
||||||
return normalized_path, normalized_len, normalized_mode
|
return normalized_path, normalized_len, normalized_mode
|
||||||
|
|
||||||
|
|||||||
@@ -548,11 +548,14 @@ class RadioManager:
|
|||||||
|
|
||||||
async def disconnect(self) -> None:
|
async def disconnect(self) -> None:
|
||||||
"""Disconnect from the radio."""
|
"""Disconnect from the radio."""
|
||||||
|
from app.radio_sync import stop_background_contact_reconciliation
|
||||||
|
|
||||||
clear_keys()
|
clear_keys()
|
||||||
self._reset_reconnect_error_broadcasts()
|
self._reset_reconnect_error_broadcasts()
|
||||||
if self._meshcore is None:
|
if self._meshcore is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
await stop_background_contact_reconciliation()
|
||||||
await self._acquire_operation_lock("disconnect", blocking=True)
|
await self._acquire_operation_lock("disconnect", blocking=True)
|
||||||
try:
|
try:
|
||||||
mc = self._meshcore
|
mc = self._meshcore
|
||||||
|
|||||||
+332
-40
@@ -20,7 +20,7 @@ from meshcore import EventType, MeshCore
|
|||||||
|
|
||||||
from app.channel_constants import PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME
|
from app.channel_constants import PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.event_handlers import cleanup_expired_acks
|
from app.event_handlers import cleanup_expired_acks, on_contact_message
|
||||||
from app.models import Contact, ContactUpsert
|
from app.models import Contact, ContactUpsert
|
||||||
from app.radio import RadioOperationBusyError
|
from app.radio import RadioOperationBusyError
|
||||||
from app.repository import (
|
from app.repository import (
|
||||||
@@ -29,7 +29,10 @@ from app.repository import (
|
|||||||
ChannelRepository,
|
ChannelRepository,
|
||||||
ContactRepository,
|
ContactRepository,
|
||||||
)
|
)
|
||||||
from app.services.contact_reconciliation import reconcile_contact_messages
|
from app.services.contact_reconciliation import (
|
||||||
|
promote_prefix_contacts_for_contact,
|
||||||
|
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
|
||||||
@@ -46,9 +49,10 @@ def _contact_sync_debug_fields(contact: Contact) -> dict[str, object]:
|
|||||||
return {
|
return {
|
||||||
"type": contact.type,
|
"type": contact.type,
|
||||||
"flags": contact.flags,
|
"flags": contact.flags,
|
||||||
"last_path": contact.last_path,
|
"direct_path": contact.direct_path,
|
||||||
"last_path_len": contact.last_path_len,
|
"direct_path_len": contact.direct_path_len,
|
||||||
"out_path_hash_mode": contact.out_path_hash_mode,
|
"direct_path_hash_mode": contact.direct_path_hash_mode,
|
||||||
|
"direct_path_updated_at": contact.direct_path_updated_at,
|
||||||
"route_override_path": contact.route_override_path,
|
"route_override_path": contact.route_override_path,
|
||||||
"route_override_len": contact.route_override_len,
|
"route_override_len": contact.route_override_len,
|
||||||
"route_override_hash_mode": contact.route_override_hash_mode,
|
"route_override_hash_mode": contact.route_override_hash_mode,
|
||||||
@@ -62,13 +66,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",
|
||||||
@@ -165,6 +181,9 @@ async def pause_polling():
|
|||||||
# Background task handle
|
# Background task handle
|
||||||
_sync_task: asyncio.Task | None = None
|
_sync_task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
# Startup/background contact reconciliation task handle
|
||||||
|
_contact_reconcile_task: asyncio.Task | None = None
|
||||||
|
|
||||||
# Periodic maintenance check interval in seconds (5 minutes)
|
# Periodic maintenance check interval in seconds (5 minutes)
|
||||||
SYNC_INTERVAL = 300
|
SYNC_INTERVAL = 300
|
||||||
|
|
||||||
@@ -175,6 +194,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)
|
||||||
@@ -189,7 +224,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()
|
||||||
@@ -265,30 +300,7 @@ async def sync_and_offload_contacts(mc: MeshCore) -> dict:
|
|||||||
remove_result = await mc.commands.remove_contact(contact_data)
|
remove_result = await mc.commands.remove_contact(contact_data)
|
||||||
if remove_result.type == EventType.OK:
|
if remove_result.type == EventType.OK:
|
||||||
removed += 1
|
removed += 1
|
||||||
|
_evict_removed_contact_from_library_cache(mc, public_key)
|
||||||
# LIBRARY INTERNAL FIXUP: The MeshCore library's
|
|
||||||
# commands.remove_contact() sends the remove command over
|
|
||||||
# the wire but does NOT update the library's in-memory
|
|
||||||
# contact cache (mc._contacts). This is a gap in the
|
|
||||||
# library — there's no public API to clear a single
|
|
||||||
# contact from the cache, and the library only refreshes
|
|
||||||
# it on a full get_contacts() call.
|
|
||||||
#
|
|
||||||
# Why this matters: sync_recent_contacts_to_radio() uses
|
|
||||||
# mc.get_contact_by_key_prefix() to check whether a
|
|
||||||
# contact is already loaded on the radio. That method
|
|
||||||
# searches mc._contacts. If we don't evict the removed
|
|
||||||
# contact from the cache here, get_contact_by_key_prefix()
|
|
||||||
# will still find it and skip the add_contact() call —
|
|
||||||
# meaning contacts never get loaded back onto the radio
|
|
||||||
# after offload. The result: no DM ACKs, degraded routing
|
|
||||||
# for potentially minutes until the next periodic sync
|
|
||||||
# refreshes the cache from the (now-empty) radio.
|
|
||||||
#
|
|
||||||
# We access mc._contacts directly because the library
|
|
||||||
# exposes it as a read-only property (mc.contacts) with
|
|
||||||
# no removal API. The dict is keyed by public_key string.
|
|
||||||
mc._contacts.pop(public_key, None)
|
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Failed to remove contact %s: %s", public_key[:12], remove_result.payload
|
"Failed to remove contact %s: %s", public_key[:12], remove_result.payload
|
||||||
@@ -398,6 +410,14 @@ async def _resolve_channel_for_pending_message(
|
|||||||
return cached_key, channel.name if channel else None
|
return cached_key, channel.name if channel else None
|
||||||
|
|
||||||
|
|
||||||
|
async def _store_pending_direct_message(event) -> None:
|
||||||
|
"""Route a CONTACT_MSG_RECV event pulled via get_msg() through the DM ingest path."""
|
||||||
|
try:
|
||||||
|
await on_contact_message(event)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to store pending direct message", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
async def _store_pending_channel_message(mc: MeshCore, payload: dict) -> None:
|
async def _store_pending_channel_message(mc: MeshCore, payload: dict) -> None:
|
||||||
"""Persist a CHANNEL_MSG_RECV event pulled via get_msg()."""
|
"""Persist a CHANNEL_MSG_RECV event pulled via get_msg()."""
|
||||||
channel_idx = payload.get("channel_idx")
|
channel_idx = payload.get("channel_idx")
|
||||||
@@ -422,7 +442,8 @@ async def _store_pending_channel_message(mc: MeshCore, payload: dict) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
received_at = int(time.time())
|
received_at = int(time.time())
|
||||||
sender_timestamp = payload.get("sender_timestamp") or received_at
|
ts = payload.get("sender_timestamp")
|
||||||
|
sender_timestamp = ts if ts is not None else received_at
|
||||||
sender_name, message_text = _split_channel_sender_and_text(payload.get("text", ""))
|
sender_name, message_text = _split_channel_sender_and_text(payload.get("text", ""))
|
||||||
|
|
||||||
await create_fallback_channel_message(
|
await create_fallback_channel_message(
|
||||||
@@ -460,28 +481,28 @@ async def ensure_default_channels() -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def sync_and_offload_all(mc: MeshCore) -> dict:
|
async def sync_and_offload_all(mc: MeshCore) -> dict:
|
||||||
"""Sync and offload both contacts and channels, then ensure defaults exist."""
|
"""Run fast startup sync, then background contact reconcile."""
|
||||||
logger.info("Starting full radio sync and offload")
|
logger.info("Starting full radio sync and offload")
|
||||||
|
|
||||||
# Contact on_radio is legacy/stale metadata. Clear it during the offload/reload
|
# Contact on_radio is legacy/stale metadata. Clear it during the offload/reload
|
||||||
# cycle so old rows stop claiming radio residency we do not actively track.
|
# cycle so old rows stop claiming radio residency we do not actively track.
|
||||||
await ContactRepository.clear_on_radio_except([])
|
await ContactRepository.clear_on_radio_except([])
|
||||||
|
|
||||||
contacts_result = await sync_and_offload_contacts(mc)
|
contacts_result = await sync_contacts_from_radio(mc)
|
||||||
channels_result = await sync_and_offload_channels(mc)
|
channels_result = await sync_and_offload_channels(mc)
|
||||||
|
|
||||||
# Ensure default channels exist
|
# Ensure default channels exist
|
||||||
await ensure_default_channels()
|
await ensure_default_channels()
|
||||||
|
|
||||||
# Reload favorites plus a working-set fill back onto the radio immediately.
|
start_background_contact_reconciliation(
|
||||||
# Pass mc directly since the caller already holds the radio operation lock
|
initial_radio_contacts=contacts_result.get("radio_contacts", {}),
|
||||||
# (asyncio.Lock is not reentrant).
|
expected_mc=mc,
|
||||||
reload_result = await sync_recent_contacts_to_radio(force=True, mc=mc)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"contacts": contacts_result,
|
"contacts": contacts_result,
|
||||||
"channels": channels_result,
|
"channels": channels_result,
|
||||||
"reloaded": reload_result,
|
"contact_reconcile_started": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -507,6 +528,8 @@ async def drain_pending_messages(mc: MeshCore) -> int:
|
|||||||
elif result.type in (EventType.CONTACT_MSG_RECV, EventType.CHANNEL_MSG_RECV):
|
elif result.type in (EventType.CONTACT_MSG_RECV, EventType.CHANNEL_MSG_RECV):
|
||||||
if result.type == EventType.CHANNEL_MSG_RECV:
|
if result.type == EventType.CHANNEL_MSG_RECV:
|
||||||
await _store_pending_channel_message(mc, result.payload)
|
await _store_pending_channel_message(mc, result.payload)
|
||||||
|
elif result.type == EventType.CONTACT_MSG_RECV:
|
||||||
|
await _store_pending_direct_message(result)
|
||||||
count += 1
|
count += 1
|
||||||
|
|
||||||
# Small delay between fetches
|
# Small delay between fetches
|
||||||
@@ -544,6 +567,8 @@ async def poll_for_messages(mc: MeshCore) -> int:
|
|||||||
elif result.type in (EventType.CONTACT_MSG_RECV, EventType.CHANNEL_MSG_RECV):
|
elif result.type in (EventType.CONTACT_MSG_RECV, EventType.CHANNEL_MSG_RECV):
|
||||||
if result.type == EventType.CHANNEL_MSG_RECV:
|
if result.type == EventType.CHANNEL_MSG_RECV:
|
||||||
await _store_pending_channel_message(mc, result.payload)
|
await _store_pending_channel_message(mc, result.payload)
|
||||||
|
elif result.type == EventType.CONTACT_MSG_RECV:
|
||||||
|
await _store_pending_direct_message(result)
|
||||||
count += 1
|
count += 1
|
||||||
# If we got a message, there might be more - drain them
|
# If we got a message, there might be more - drain them
|
||||||
count += await drain_pending_messages(mc)
|
count += await drain_pending_messages(mc)
|
||||||
@@ -1035,12 +1060,279 @@ async def stop_periodic_sync():
|
|||||||
# Throttling for contact sync to radio
|
# Throttling for contact sync to radio
|
||||||
_last_contact_sync: float = 0.0
|
_last_contact_sync: float = 0.0
|
||||||
CONTACT_SYNC_THROTTLE_SECONDS = 30 # Don't sync more than once per 30 seconds
|
CONTACT_SYNC_THROTTLE_SECONDS = 30 # Don't sync more than once per 30 seconds
|
||||||
|
CONTACT_RECONCILE_BATCH_SIZE = 2
|
||||||
|
CONTACT_RECONCILE_YIELD_SECONDS = 0.05
|
||||||
|
CONTACT_RECONCILE_BUSY_BACKOFF_SECONDS = 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def _evict_removed_contact_from_library_cache(mc: MeshCore, public_key: str) -> None:
|
||||||
|
"""Keep the library's contact cache consistent after a successful removal."""
|
||||||
|
# LIBRARY INTERNAL FIXUP: The MeshCore library's remove_contact() sends the
|
||||||
|
# remove command over the wire but does NOT update the library's in-memory
|
||||||
|
# contact cache (mc._contacts). This is a gap in the library — there's no
|
||||||
|
# public API to clear a single contact from the cache, and the library only
|
||||||
|
# refreshes it on a full get_contacts() call.
|
||||||
|
#
|
||||||
|
# Why this matters: contact sync and targeted ensure/load paths use
|
||||||
|
# mc.get_contact_by_key_prefix() to check whether a contact is already
|
||||||
|
# loaded on the radio. That method searches mc._contacts. If we don't evict
|
||||||
|
# the removed contact from the cache here, later syncs will still find it
|
||||||
|
# and skip add_contact() calls, leaving the radio without the contact even
|
||||||
|
# though the app thinks it is resident.
|
||||||
|
mc._contacts.pop(public_key, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_radio_contacts_payload(contacts: dict | None) -> dict[str, dict]:
|
||||||
|
"""Return radio contacts keyed by normalized lowercase full public key."""
|
||||||
|
normalized: dict[str, dict] = {}
|
||||||
|
for public_key, contact_data in (contacts or {}).items():
|
||||||
|
normalized[str(public_key).lower()] = contact_data
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_contacts_from_radio(mc: MeshCore) -> dict:
|
||||||
|
"""Pull contacts from the radio and persist them to the database without removing them."""
|
||||||
|
synced = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
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, "radio_contacts": {}, "error": str(result)}
|
||||||
|
|
||||||
|
contacts = _normalize_radio_contacts_payload(result.payload)
|
||||||
|
logger.info("Found %d contacts on radio", len(contacts))
|
||||||
|
|
||||||
|
for public_key, contact_data in contacts.items():
|
||||||
|
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
|
||||||
|
|
||||||
|
logger.info("Synced %d contacts from radio snapshot", synced)
|
||||||
|
return {"synced": synced, "radio_contacts": contacts}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error during contact snapshot sync: %s", e)
|
||||||
|
return {"synced": synced, "radio_contacts": {}, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
async def _reconcile_radio_contacts_in_background(
|
||||||
|
*,
|
||||||
|
initial_radio_contacts: dict[str, dict],
|
||||||
|
expected_mc: MeshCore,
|
||||||
|
) -> None:
|
||||||
|
"""Converge radio contacts toward the desired favorites+recents working set."""
|
||||||
|
radio_contacts = dict(initial_radio_contacts)
|
||||||
|
removed = 0
|
||||||
|
loaded = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
if not radio_manager.is_connected or radio_manager.meshcore is not expected_mc:
|
||||||
|
logger.info("Stopping background contact reconcile: radio transport changed")
|
||||||
|
break
|
||||||
|
|
||||||
|
selected_contacts = await get_contacts_selected_for_radio_sync()
|
||||||
|
desired_contacts = {
|
||||||
|
contact.public_key.lower(): contact
|
||||||
|
for contact in selected_contacts
|
||||||
|
if len(contact.public_key) >= 64
|
||||||
|
}
|
||||||
|
removable_keys = [key for key in radio_contacts if key not in desired_contacts]
|
||||||
|
missing_contacts = [
|
||||||
|
contact for key, contact in desired_contacts.items() if key not in radio_contacts
|
||||||
|
]
|
||||||
|
|
||||||
|
if not removable_keys and not missing_contacts:
|
||||||
|
logger.info(
|
||||||
|
"Background contact reconcile complete: %d contacts on radio working set",
|
||||||
|
len(radio_contacts),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
progressed = False
|
||||||
|
try:
|
||||||
|
async with radio_manager.radio_operation(
|
||||||
|
"background_contact_reconcile",
|
||||||
|
blocking=False,
|
||||||
|
) as mc:
|
||||||
|
if mc is not expected_mc:
|
||||||
|
logger.info(
|
||||||
|
"Stopping background contact reconcile: radio transport changed"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
budget = CONTACT_RECONCILE_BATCH_SIZE
|
||||||
|
selected_contacts = await get_contacts_selected_for_radio_sync()
|
||||||
|
desired_contacts = {
|
||||||
|
contact.public_key.lower(): contact
|
||||||
|
for contact in selected_contacts
|
||||||
|
if len(contact.public_key) >= 64
|
||||||
|
}
|
||||||
|
|
||||||
|
for public_key in list(radio_contacts):
|
||||||
|
if budget <= 0:
|
||||||
|
break
|
||||||
|
if public_key in desired_contacts:
|
||||||
|
continue
|
||||||
|
|
||||||
|
remove_payload = (
|
||||||
|
mc.get_contact_by_key_prefix(public_key[:12])
|
||||||
|
or radio_contacts.get(public_key)
|
||||||
|
or {"public_key": public_key}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
remove_result = await mc.commands.remove_contact(remove_payload)
|
||||||
|
except Exception as exc:
|
||||||
|
failed += 1
|
||||||
|
budget -= 1
|
||||||
|
logger.warning(
|
||||||
|
"Error removing contact %s during background reconcile: %s",
|
||||||
|
public_key[:12],
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
budget -= 1
|
||||||
|
if remove_result.type == EventType.OK:
|
||||||
|
radio_contacts.pop(public_key, None)
|
||||||
|
_evict_removed_contact_from_library_cache(mc, public_key)
|
||||||
|
removed += 1
|
||||||
|
progressed = True
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
logger.warning(
|
||||||
|
"Failed to remove contact %s during background reconcile: %s",
|
||||||
|
public_key[:12],
|
||||||
|
remove_result.payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
if budget > 0:
|
||||||
|
for public_key, contact in desired_contacts.items():
|
||||||
|
if budget <= 0:
|
||||||
|
break
|
||||||
|
if public_key in radio_contacts:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if mc.get_contact_by_key_prefix(public_key[:12]):
|
||||||
|
radio_contacts[public_key] = {"public_key": public_key}
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
add_payload = contact.to_radio_dict()
|
||||||
|
add_result = await mc.commands.add_contact(add_payload)
|
||||||
|
except Exception as exc:
|
||||||
|
failed += 1
|
||||||
|
budget -= 1
|
||||||
|
logger.warning(
|
||||||
|
"Error adding contact %s during background reconcile: %s",
|
||||||
|
public_key[:12],
|
||||||
|
exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
budget -= 1
|
||||||
|
if add_result.type == EventType.OK:
|
||||||
|
radio_contacts[public_key] = add_payload
|
||||||
|
loaded += 1
|
||||||
|
progressed = True
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
reason = add_result.payload
|
||||||
|
hint = ""
|
||||||
|
if reason is None:
|
||||||
|
hint = (
|
||||||
|
" (no response from radio — if this repeats, check for "
|
||||||
|
"serial port contention from another process or try a "
|
||||||
|
"power cycle)"
|
||||||
|
)
|
||||||
|
logger.warning(
|
||||||
|
"Failed to add contact %s during background reconcile: %s%s",
|
||||||
|
public_key[:12],
|
||||||
|
reason,
|
||||||
|
hint,
|
||||||
|
)
|
||||||
|
except RadioOperationBusyError:
|
||||||
|
logger.debug("Background contact reconcile yielding: radio busy")
|
||||||
|
await asyncio.sleep(CONTACT_RECONCILE_BUSY_BACKOFF_SECONDS)
|
||||||
|
continue
|
||||||
|
|
||||||
|
await asyncio.sleep(CONTACT_RECONCILE_YIELD_SECONDS)
|
||||||
|
if not progressed:
|
||||||
|
continue
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("Background contact reconcile task cancelled")
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Background contact reconcile failed: %s", exc, exc_info=True)
|
||||||
|
finally:
|
||||||
|
if removed > 0 or loaded > 0 or failed > 0:
|
||||||
|
logger.info(
|
||||||
|
"Background contact reconcile summary: removed %d, loaded %d, failed %d",
|
||||||
|
removed,
|
||||||
|
loaded,
|
||||||
|
failed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def start_background_contact_reconciliation(
|
||||||
|
*,
|
||||||
|
initial_radio_contacts: dict[str, dict],
|
||||||
|
expected_mc: MeshCore,
|
||||||
|
) -> None:
|
||||||
|
"""Start or replace the background contact reconcile task for the current radio."""
|
||||||
|
global _contact_reconcile_task
|
||||||
|
|
||||||
|
if _contact_reconcile_task is not None and not _contact_reconcile_task.done():
|
||||||
|
_contact_reconcile_task.cancel()
|
||||||
|
|
||||||
|
_contact_reconcile_task = asyncio.create_task(
|
||||||
|
_reconcile_radio_contacts_in_background(
|
||||||
|
initial_radio_contacts=initial_radio_contacts,
|
||||||
|
expected_mc=expected_mc,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Started background contact reconcile for %d radio contact(s)",
|
||||||
|
len(initial_radio_contacts),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def stop_background_contact_reconciliation() -> None:
|
||||||
|
"""Stop the background contact reconcile task."""
|
||||||
|
global _contact_reconcile_task
|
||||||
|
|
||||||
|
if _contact_reconcile_task and not _contact_reconcile_task.done():
|
||||||
|
_contact_reconcile_task.cancel()
|
||||||
|
try:
|
||||||
|
await _contact_reconcile_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
_contact_reconcile_task = 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()
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
+156
-81
@@ -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."""
|
||||||
@@ -36,11 +39,20 @@ class ContactRepository:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
async def upsert(contact: ContactUpsert | Contact | Mapping[str, Any]) -> None:
|
async def upsert(contact: ContactUpsert | Contact | Mapping[str, Any]) -> None:
|
||||||
contact_row = ContactRepository._coerce_contact_upsert(contact)
|
contact_row = ContactRepository._coerce_contact_upsert(contact)
|
||||||
last_path, last_path_len, out_path_hash_mode = normalize_contact_route(
|
if (
|
||||||
contact_row.last_path,
|
contact_row.direct_path is None
|
||||||
contact_row.last_path_len,
|
and contact_row.direct_path_len is None
|
||||||
contact_row.out_path_hash_mode,
|
and contact_row.direct_path_hash_mode is None
|
||||||
)
|
):
|
||||||
|
direct_path = None
|
||||||
|
direct_path_len = None
|
||||||
|
direct_path_hash_mode = None
|
||||||
|
else:
|
||||||
|
direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route(
|
||||||
|
contact_row.direct_path,
|
||||||
|
contact_row.direct_path_len,
|
||||||
|
contact_row.direct_path_hash_mode,
|
||||||
|
)
|
||||||
route_override_path, route_override_len, route_override_hash_mode = (
|
route_override_path, route_override_len, route_override_hash_mode = (
|
||||||
normalize_route_override(
|
normalize_route_override(
|
||||||
contact_row.route_override_path,
|
contact_row.route_override_path,
|
||||||
@@ -51,20 +63,25 @@ class ContactRepository:
|
|||||||
|
|
||||||
await db.conn.execute(
|
await db.conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO contacts (public_key, name, type, flags, last_path, last_path_len,
|
INSERT INTO contacts (public_key, name, type, flags, direct_path, direct_path_len,
|
||||||
out_path_hash_mode,
|
direct_path_hash_mode, direct_path_updated_at,
|
||||||
route_override_path, route_override_len,
|
route_override_path, route_override_len,
|
||||||
route_override_hash_mode,
|
route_override_hash_mode,
|
||||||
last_advert, lat, lon, last_seen,
|
last_advert, lat, lon, last_seen,
|
||||||
on_radio, last_contacted, first_seen)
|
on_radio, last_contacted, first_seen)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(public_key) DO UPDATE SET
|
ON CONFLICT(public_key) DO UPDATE SET
|
||||||
name = COALESCE(excluded.name, contacts.name),
|
name = COALESCE(excluded.name, contacts.name),
|
||||||
type = CASE WHEN excluded.type = 0 THEN contacts.type ELSE excluded.type END,
|
type = CASE WHEN excluded.type = 0 THEN contacts.type ELSE excluded.type END,
|
||||||
flags = excluded.flags,
|
flags = excluded.flags,
|
||||||
last_path = COALESCE(excluded.last_path, contacts.last_path),
|
direct_path = COALESCE(excluded.direct_path, contacts.direct_path),
|
||||||
last_path_len = excluded.last_path_len,
|
direct_path_len = COALESCE(excluded.direct_path_len, contacts.direct_path_len),
|
||||||
out_path_hash_mode = excluded.out_path_hash_mode,
|
direct_path_hash_mode = COALESCE(
|
||||||
|
excluded.direct_path_hash_mode, contacts.direct_path_hash_mode
|
||||||
|
),
|
||||||
|
direct_path_updated_at = COALESCE(
|
||||||
|
excluded.direct_path_updated_at, contacts.direct_path_updated_at
|
||||||
|
),
|
||||||
route_override_path = COALESCE(
|
route_override_path = COALESCE(
|
||||||
excluded.route_override_path, contacts.route_override_path
|
excluded.route_override_path, contacts.route_override_path
|
||||||
),
|
),
|
||||||
@@ -87,9 +104,10 @@ class ContactRepository:
|
|||||||
contact_row.name,
|
contact_row.name,
|
||||||
contact_row.type,
|
contact_row.type,
|
||||||
contact_row.flags,
|
contact_row.flags,
|
||||||
last_path,
|
direct_path,
|
||||||
last_path_len,
|
direct_path_len,
|
||||||
out_path_hash_mode,
|
direct_path_hash_mode,
|
||||||
|
contact_row.direct_path_updated_at,
|
||||||
route_override_path,
|
route_override_path,
|
||||||
route_override_len,
|
route_override_len,
|
||||||
route_override_hash_mode,
|
route_override_hash_mode,
|
||||||
@@ -107,12 +125,12 @@ class ContactRepository:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _row_to_contact(row) -> Contact:
|
def _row_to_contact(row) -> Contact:
|
||||||
"""Convert a database row to a Contact model."""
|
"""Convert a database row to a Contact model."""
|
||||||
last_path, last_path_len, out_path_hash_mode = normalize_contact_route(
|
|
||||||
row["last_path"],
|
|
||||||
row["last_path_len"],
|
|
||||||
row["out_path_hash_mode"],
|
|
||||||
)
|
|
||||||
available_columns = set(row.keys())
|
available_columns = set(row.keys())
|
||||||
|
direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route(
|
||||||
|
row["direct_path"] if "direct_path" in available_columns else None,
|
||||||
|
row["direct_path_len"] if "direct_path_len" in available_columns else None,
|
||||||
|
row["direct_path_hash_mode"] if "direct_path_hash_mode" in available_columns else None,
|
||||||
|
)
|
||||||
route_override_path = (
|
route_override_path = (
|
||||||
row["route_override_path"] if "route_override_path" in available_columns else None
|
row["route_override_path"] if "route_override_path" in available_columns else None
|
||||||
)
|
)
|
||||||
@@ -136,9 +154,14 @@ class ContactRepository:
|
|||||||
name=row["name"],
|
name=row["name"],
|
||||||
type=row["type"],
|
type=row["type"],
|
||||||
flags=row["flags"],
|
flags=row["flags"],
|
||||||
last_path=last_path,
|
direct_path=direct_path,
|
||||||
last_path_len=last_path_len,
|
direct_path_len=direct_path_len,
|
||||||
out_path_hash_mode=out_path_hash_mode,
|
direct_path_hash_mode=direct_path_hash_mode,
|
||||||
|
direct_path_updated_at=(
|
||||||
|
row["direct_path_updated_at"]
|
||||||
|
if "direct_path_updated_at" in available_columns
|
||||||
|
else None
|
||||||
|
),
|
||||||
route_override_path=route_override_path,
|
route_override_path=route_override_path,
|
||||||
route_override_len=route_override_len,
|
route_override_len=route_override_len,
|
||||||
route_override_hash_mode=route_override_hash_mode,
|
route_override_hash_mode=route_override_hash_mode,
|
||||||
@@ -286,26 +309,30 @@ class ContactRepository:
|
|||||||
return [ContactRepository._row_to_contact(row) for row in rows]
|
return [ContactRepository._row_to_contact(row) for row in rows]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def update_path(
|
async def update_direct_path(
|
||||||
public_key: str,
|
public_key: str,
|
||||||
path: str,
|
path: str,
|
||||||
path_len: int,
|
path_len: int,
|
||||||
out_path_hash_mode: int | None = None,
|
path_hash_mode: int | None = None,
|
||||||
|
updated_at: int | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
normalized_path, normalized_path_len, normalized_hash_mode = normalize_contact_route(
|
normalized_path, normalized_path_len, normalized_hash_mode = normalize_contact_route(
|
||||||
path,
|
path,
|
||||||
path_len,
|
path_len,
|
||||||
out_path_hash_mode,
|
path_hash_mode,
|
||||||
)
|
)
|
||||||
|
ts = updated_at if updated_at is not None else int(time.time())
|
||||||
await db.conn.execute(
|
await db.conn.execute(
|
||||||
"""UPDATE contacts SET last_path = ?, last_path_len = ?,
|
"""UPDATE contacts SET direct_path = ?, direct_path_len = ?,
|
||||||
out_path_hash_mode = COALESCE(?, out_path_hash_mode),
|
direct_path_hash_mode = COALESCE(?, direct_path_hash_mode),
|
||||||
|
direct_path_updated_at = ?,
|
||||||
last_seen = ? WHERE public_key = ?""",
|
last_seen = ? WHERE public_key = ?""",
|
||||||
(
|
(
|
||||||
normalized_path,
|
normalized_path,
|
||||||
normalized_path_len,
|
normalized_path_len,
|
||||||
normalized_hash_mode,
|
normalized_hash_mode,
|
||||||
int(time.time()),
|
ts,
|
||||||
|
ts,
|
||||||
public_key.lower(),
|
public_key.lower(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -316,12 +343,12 @@ class ContactRepository:
|
|||||||
public_key: str,
|
public_key: str,
|
||||||
path: str | None,
|
path: str | None,
|
||||||
path_len: int | None,
|
path_len: int | None,
|
||||||
out_path_hash_mode: int | None = None,
|
path_hash_mode: int | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
normalized_path, normalized_len, normalized_hash_mode = normalize_route_override(
|
normalized_path, normalized_len, normalized_hash_mode = normalize_route_override(
|
||||||
path,
|
path,
|
||||||
path_len,
|
path_len,
|
||||||
out_path_hash_mode,
|
path_hash_mode,
|
||||||
)
|
)
|
||||||
await db.conn.execute(
|
await db.conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -407,6 +434,43 @@ class ContactRepository:
|
|||||||
|
|
||||||
Returns the placeholder public keys that were merged into the full key.
|
Returns the placeholder public keys that were merged into the full key.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
async def migrate_child_rows(old_key: str, new_key: str) -> None:
|
||||||
|
await db.conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO contact_name_history (public_key, name, first_seen, last_seen)
|
||||||
|
SELECT ?, name, first_seen, last_seen
|
||||||
|
FROM contact_name_history
|
||||||
|
WHERE public_key = ?
|
||||||
|
ON CONFLICT(public_key, name) DO UPDATE SET
|
||||||
|
first_seen = MIN(contact_name_history.first_seen, excluded.first_seen),
|
||||||
|
last_seen = MAX(contact_name_history.last_seen, excluded.last_seen)
|
||||||
|
""",
|
||||||
|
(new_key, old_key),
|
||||||
|
)
|
||||||
|
await db.conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO contact_advert_paths
|
||||||
|
(public_key, path_hex, path_len, first_seen, last_seen, heard_count)
|
||||||
|
SELECT ?, path_hex, path_len, first_seen, last_seen, heard_count
|
||||||
|
FROM contact_advert_paths
|
||||||
|
WHERE public_key = ?
|
||||||
|
ON CONFLICT(public_key, path_hex, path_len) DO UPDATE SET
|
||||||
|
first_seen = MIN(contact_advert_paths.first_seen, excluded.first_seen),
|
||||||
|
last_seen = MAX(contact_advert_paths.last_seen, excluded.last_seen),
|
||||||
|
heard_count = contact_advert_paths.heard_count + excluded.heard_count
|
||||||
|
""",
|
||||||
|
(new_key, old_key),
|
||||||
|
)
|
||||||
|
await db.conn.execute(
|
||||||
|
"DELETE FROM contact_name_history WHERE public_key = ?",
|
||||||
|
(old_key,),
|
||||||
|
)
|
||||||
|
await db.conn.execute(
|
||||||
|
"DELETE FROM contact_advert_paths WHERE public_key = ?",
|
||||||
|
(old_key,),
|
||||||
|
)
|
||||||
|
|
||||||
normalized_full_key = full_key.lower()
|
normalized_full_key = full_key.lower()
|
||||||
cursor = await db.conn.execute(
|
cursor = await db.conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -423,7 +487,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"]
|
||||||
@@ -440,58 +503,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
|
||||||
|
|
||||||
if full_exists:
|
await migrate_child_rows(old_key, normalized_full_key)
|
||||||
await db.conn.execute(
|
|
||||||
"""
|
# Merge timestamp metadata from the old prefix contact into the
|
||||||
UPDATE contacts
|
# full-key contact (which all callers guarantee already exists),
|
||||||
SET last_seen = CASE
|
# then delete the prefix placeholder.
|
||||||
WHEN contacts.last_seen IS NULL THEN ?
|
await db.conn.execute(
|
||||||
WHEN ? IS NULL THEN contacts.last_seen
|
"""
|
||||||
WHEN ? > contacts.last_seen THEN ?
|
UPDATE contacts
|
||||||
ELSE contacts.last_seen
|
SET last_seen = CASE
|
||||||
END,
|
WHEN contacts.last_seen IS NULL THEN ?
|
||||||
last_contacted = CASE
|
WHEN ? IS NULL THEN contacts.last_seen
|
||||||
WHEN contacts.last_contacted IS NULL THEN ?
|
WHEN ? > contacts.last_seen THEN ?
|
||||||
WHEN ? IS NULL THEN contacts.last_contacted
|
ELSE contacts.last_seen
|
||||||
WHEN ? > contacts.last_contacted THEN ?
|
END,
|
||||||
ELSE contacts.last_contacted
|
last_contacted = CASE
|
||||||
END,
|
WHEN contacts.last_contacted IS NULL THEN ?
|
||||||
first_seen = CASE
|
WHEN ? IS NULL THEN contacts.last_contacted
|
||||||
WHEN contacts.first_seen IS NULL THEN ?
|
WHEN ? > contacts.last_contacted THEN ?
|
||||||
WHEN ? IS NULL THEN contacts.first_seen
|
ELSE contacts.last_contacted
|
||||||
WHEN ? < contacts.first_seen THEN ?
|
END,
|
||||||
ELSE contacts.first_seen
|
first_seen = CASE
|
||||||
END,
|
WHEN contacts.first_seen IS NULL THEN ?
|
||||||
last_read_at = COALESCE(contacts.last_read_at, ?)
|
WHEN ? IS NULL THEN contacts.first_seen
|
||||||
WHERE public_key = ?
|
WHEN ? < contacts.first_seen THEN ?
|
||||||
""",
|
ELSE contacts.first_seen
|
||||||
(
|
END,
|
||||||
row["last_seen"],
|
last_read_at = CASE
|
||||||
row["last_seen"],
|
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_contacted"],
|
ELSE contacts.last_read_at
|
||||||
row["last_contacted"],
|
END
|
||||||
row["last_contacted"],
|
WHERE public_key = ?
|
||||||
row["last_contacted"],
|
""",
|
||||||
row["first_seen"],
|
(
|
||||||
row["first_seen"],
|
row["last_seen"],
|
||||||
row["first_seen"],
|
row["last_seen"],
|
||||||
row["first_seen"],
|
row["last_seen"],
|
||||||
row["last_read_at"],
|
row["last_seen"],
|
||||||
normalized_full_key,
|
row["last_contacted"],
|
||||||
),
|
row["last_contacted"],
|
||||||
)
|
row["last_contacted"],
|
||||||
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (old_key,))
|
row["last_contacted"],
|
||||||
else:
|
row["first_seen"],
|
||||||
await db.conn.execute(
|
row["first_seen"],
|
||||||
"UPDATE contacts SET public_key = ? WHERE public_key = ?",
|
row["first_seen"],
|
||||||
(normalized_full_key, old_key),
|
row["first_seen"],
|
||||||
)
|
row["last_read_at"],
|
||||||
full_exists = True
|
row["last_read_at"],
|
||||||
|
row["last_read_at"],
|
||||||
|
row["last_read_at"],
|
||||||
|
normalized_full_key,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (old_key,))
|
||||||
|
|
||||||
promoted_keys.append(old_key)
|
promoted_keys.append(old_key)
|
||||||
|
|
||||||
|
|||||||
@@ -158,7 +158,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 +170,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
|
||||||
@@ -331,6 +335,12 @@ class MessageRepository:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _row_to_message(row: Any) -> Message:
|
def _row_to_message(row: Any) -> Message:
|
||||||
"""Convert a database row to a Message model."""
|
"""Convert a database row to a Message model."""
|
||||||
|
packet_id = None
|
||||||
|
if hasattr(row, "keys"):
|
||||||
|
row_keys = row.keys()
|
||||||
|
if "packet_id" in row_keys:
|
||||||
|
packet_id = row["packet_id"]
|
||||||
|
|
||||||
return Message(
|
return Message(
|
||||||
id=row["id"],
|
id=row["id"],
|
||||||
type=row["type"],
|
type=row["type"],
|
||||||
@@ -345,6 +355,14 @@ class MessageRepository:
|
|||||||
outgoing=bool(row["outgoing"]),
|
outgoing=bool(row["outgoing"]),
|
||||||
acked=row["acked"],
|
acked=row["acked"],
|
||||||
sender_name=row["sender_name"],
|
sender_name=row["sender_name"],
|
||||||
|
packet_id=packet_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _message_select(message_alias: str = "messages") -> str:
|
||||||
|
return (
|
||||||
|
f"{message_alias}.*, "
|
||||||
|
f"(SELECT MIN(id) FROM raw_packets WHERE message_id = {message_alias}.id) AS packet_id"
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -363,7 +381,7 @@ class MessageRepository:
|
|||||||
) -> list[Message]:
|
) -> list[Message]:
|
||||||
search_query = MessageRepository._parse_search_query(q) if q else None
|
search_query = MessageRepository._parse_search_query(q) if q else None
|
||||||
query = (
|
query = (
|
||||||
"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 LOWER(messages.conversation_key) = LOWER(contacts.public_key) "
|
||||||
"LEFT JOIN channels ON messages.type = 'CHAN' "
|
"LEFT JOIN channels ON messages.type = 'CHAN' "
|
||||||
@@ -470,7 +488,8 @@ class MessageRepository:
|
|||||||
|
|
||||||
# 1. Get the target message (must satisfy filters if provided)
|
# 1. Get the target message (must satisfy filters if provided)
|
||||||
target_cursor = await db.conn.execute(
|
target_cursor = await db.conn.execute(
|
||||||
f"SELECT * FROM messages WHERE id = ? AND {where_sql}",
|
f"SELECT {MessageRepository._message_select('messages')} "
|
||||||
|
f"FROM messages WHERE id = ? AND {where_sql}",
|
||||||
(message_id, *base_params),
|
(message_id, *base_params),
|
||||||
)
|
)
|
||||||
target_row = await target_cursor.fetchone()
|
target_row = await target_cursor.fetchone()
|
||||||
@@ -481,7 +500,7 @@ class MessageRepository:
|
|||||||
|
|
||||||
# 2. Get context_size+1 messages before target (DESC)
|
# 2. Get context_size+1 messages before target (DESC)
|
||||||
before_query = f"""
|
before_query = f"""
|
||||||
SELECT * FROM messages WHERE {where_sql}
|
SELECT {MessageRepository._message_select("messages")} FROM messages WHERE {where_sql}
|
||||||
AND (received_at < ? OR (received_at = ? AND id < ?))
|
AND (received_at < ? OR (received_at = ? AND id < ?))
|
||||||
ORDER BY received_at DESC, id DESC LIMIT ?
|
ORDER BY received_at DESC, id DESC LIMIT ?
|
||||||
"""
|
"""
|
||||||
@@ -500,7 +519,7 @@ class MessageRepository:
|
|||||||
|
|
||||||
# 3. Get context_size+1 messages after target (ASC)
|
# 3. Get context_size+1 messages after target (ASC)
|
||||||
after_query = f"""
|
after_query = f"""
|
||||||
SELECT * FROM messages WHERE {where_sql}
|
SELECT {MessageRepository._message_select("messages")} FROM messages WHERE {where_sql}
|
||||||
AND (received_at > ? OR (received_at = ? AND id > ?))
|
AND (received_at > ? OR (received_at = ? AND id > ?))
|
||||||
ORDER BY received_at ASC, id ASC LIMIT ?
|
ORDER BY received_at ASC, id ASC LIMIT ?
|
||||||
"""
|
"""
|
||||||
@@ -545,7 +564,7 @@ class MessageRepository:
|
|||||||
async def get_by_id(message_id: int) -> "Message | None":
|
async def get_by_id(message_id: int) -> "Message | None":
|
||||||
"""Look up a message by its ID."""
|
"""Look up a message by its ID."""
|
||||||
cursor = await db.conn.execute(
|
cursor = await db.conn.execute(
|
||||||
"SELECT * FROM messages WHERE id = ?",
|
f"SELECT {MessageRepository._message_select('messages')} FROM messages WHERE id = ?",
|
||||||
(message_id,),
|
(message_id,),
|
||||||
)
|
)
|
||||||
row = await cursor.fetchone()
|
row = await cursor.fetchone()
|
||||||
@@ -554,6 +573,15 @@ class MessageRepository:
|
|||||||
|
|
||||||
return MessageRepository._row_to_message(row)
|
return MessageRepository._row_to_message(row)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def delete_by_id(message_id: int) -> None:
|
||||||
|
"""Delete a message row by ID."""
|
||||||
|
await db.conn.execute(
|
||||||
|
"UPDATE raw_packets SET message_id = NULL WHERE message_id = ?", (message_id,)
|
||||||
|
)
|
||||||
|
await db.conn.execute("DELETE FROM messages WHERE id = ?", (message_id,))
|
||||||
|
await db.conn.commit()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_by_content(
|
async def get_by_content(
|
||||||
msg_type: str,
|
msg_type: str,
|
||||||
@@ -564,7 +592,9 @@ class MessageRepository:
|
|||||||
) -> "Message | None":
|
) -> "Message | None":
|
||||||
"""Look up a message by its unique content fields."""
|
"""Look up a message by its unique content fields."""
|
||||||
query = """
|
query = """
|
||||||
SELECT * FROM messages
|
SELECT messages.*,
|
||||||
|
(SELECT MIN(id) FROM raw_packets WHERE message_id = messages.id) AS packet_id
|
||||||
|
FROM messages
|
||||||
WHERE type = ? AND conversation_key = ? AND text = ?
|
WHERE type = ? AND conversation_key = ? AND text = ?
|
||||||
AND (sender_timestamp = ? OR (sender_timestamp IS NULL AND ? IS NULL))
|
AND (sender_timestamp = ? OR (sender_timestamp IS NULL AND ? IS NULL))
|
||||||
"""
|
"""
|
||||||
@@ -700,6 +730,11 @@ class MessageRepository:
|
|||||||
state_key = f"{prefix}-{row['conversation_key']}"
|
state_key = f"{prefix}-{row['conversation_key']}"
|
||||||
last_message_times[state_key] = row["last_message_time"]
|
last_message_times[state_key] = row["last_message_time"]
|
||||||
|
|
||||||
|
# Only include last_read_ats for conversations that actually have messages.
|
||||||
|
# Without this filter, every contact heard via advertisement (even without
|
||||||
|
# any DMs) bloats the payload — 391KB down to ~46KB on a typical database.
|
||||||
|
last_read_ats = {k: v for k, v in last_read_ats.items() if k in last_message_times}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"counts": counts,
|
"counts": counts,
|
||||||
"mentions": mention_flags,
|
"mentions": mention_flags,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
|
|
||||||
from app.database import db
|
from app.database import db
|
||||||
@@ -8,6 +9,8 @@ from app.decoder import PayloadType, extract_payload, get_packet_payload_type
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
UNDECRYPTED_PACKET_BATCH_SIZE = 500
|
||||||
|
|
||||||
|
|
||||||
class RawPacketRepository:
|
class RawPacketRepository:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -100,6 +103,40 @@ class RawPacketRepository:
|
|||||||
rows = await cursor.fetchall()
|
rows = await cursor.fetchall()
|
||||||
return [(row["id"], bytes(row["data"]), row["timestamp"]) for row in rows]
|
return [(row["id"], bytes(row["data"]), row["timestamp"]) for row in rows]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def stream_undecrypted_text_messages(
|
||||||
|
batch_size: int = UNDECRYPTED_PACKET_BATCH_SIZE,
|
||||||
|
) -> AsyncIterator[tuple[int, bytes, int]]:
|
||||||
|
"""Yield undecrypted TEXT_MESSAGE packets in bounded-size batches."""
|
||||||
|
cursor = await db.conn.execute(
|
||||||
|
"SELECT id, data, timestamp FROM raw_packets WHERE message_id IS NULL ORDER BY timestamp ASC"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
rows = await cursor.fetchmany(batch_size)
|
||||||
|
if not rows:
|
||||||
|
break
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
data = bytes(row["data"])
|
||||||
|
payload_type = get_packet_payload_type(data)
|
||||||
|
if payload_type == PayloadType.TEXT_MESSAGE:
|
||||||
|
yield (row["id"], data, row["timestamp"])
|
||||||
|
finally:
|
||||||
|
await cursor.close()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def count_undecrypted_text_messages(
|
||||||
|
batch_size: int = UNDECRYPTED_PACKET_BATCH_SIZE,
|
||||||
|
) -> int:
|
||||||
|
"""Count undecrypted TEXT_MESSAGE packets without materializing them all."""
|
||||||
|
count = 0
|
||||||
|
async for _packet in RawPacketRepository.stream_undecrypted_text_messages(
|
||||||
|
batch_size=batch_size
|
||||||
|
):
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def mark_decrypted(packet_id: int, message_id: int) -> None:
|
async def mark_decrypted(packet_id: int, message_id: int) -> None:
|
||||||
"""Link a raw packet to its decrypted message."""
|
"""Link a raw packet to its decrypted message."""
|
||||||
@@ -121,6 +158,18 @@ class RawPacketRepository:
|
|||||||
return None
|
return None
|
||||||
return row["message_id"]
|
return row["message_id"]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_by_id(packet_id: int) -> tuple[int, bytes, int, int | None] | None:
|
||||||
|
"""Return a raw packet row as (id, data, timestamp, message_id)."""
|
||||||
|
cursor = await db.conn.execute(
|
||||||
|
"SELECT id, data, timestamp, message_id FROM raw_packets WHERE id = ?",
|
||||||
|
(packet_id,),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return (row["id"], bytes(row["data"]), row["timestamp"], row["message_id"])
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def prune_old_undecrypted(max_age_days: int) -> int:
|
async def prune_old_undecrypted(max_age_days: int) -> int:
|
||||||
"""Delete undecrypted packets older than max_age_days. Returns count deleted."""
|
"""Delete undecrypted packets older than max_age_days. Returns count deleted."""
|
||||||
@@ -146,17 +195,4 @@ class RawPacketRepository:
|
|||||||
Filters raw packets to only include those with PayloadType.TEXT_MESSAGE (0x02).
|
Filters raw packets to only include those with PayloadType.TEXT_MESSAGE (0x02).
|
||||||
These are direct messages that can be decrypted with contact ECDH keys.
|
These are direct messages that can be decrypted with contact ECDH keys.
|
||||||
"""
|
"""
|
||||||
cursor = await db.conn.execute(
|
return [packet async for packet in RawPacketRepository.stream_undecrypted_text_messages()]
|
||||||
"SELECT id, data, timestamp FROM raw_packets WHERE message_id IS NULL ORDER BY timestamp ASC"
|
|
||||||
)
|
|
||||||
rows = await cursor.fetchall()
|
|
||||||
|
|
||||||
# Filter for TEXT_MESSAGE packets
|
|
||||||
result = []
|
|
||||||
for row in rows:
|
|
||||||
data = bytes(row["data"])
|
|
||||||
payload_type = get_packet_payload_type(data)
|
|
||||||
if payload_type == PayloadType.TEXT_MESSAGE:
|
|
||||||
result.append((row["id"], data, row["timestamp"]))
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
]
|
||||||
+90
-31
@@ -12,6 +12,7 @@ logger = logging.getLogger(__name__)
|
|||||||
SECONDS_1H = 3600
|
SECONDS_1H = 3600
|
||||||
SECONDS_24H = 86400
|
SECONDS_24H = 86400
|
||||||
SECONDS_7D = 604800
|
SECONDS_7D = 604800
|
||||||
|
RAW_PACKET_STATS_BATCH_SIZE = 500
|
||||||
|
|
||||||
|
|
||||||
class AppSettingsRepository:
|
class AppSettingsRepository:
|
||||||
@@ -28,7 +29,7 @@ class AppSettingsRepository:
|
|||||||
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,
|
sidebar_sort_order, last_message_times, preferences_migrated,
|
||||||
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
|
||||||
FROM app_settings WHERE id = 1
|
FROM app_settings WHERE id = 1
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
@@ -80,6 +81,14 @@ class AppSettingsRepository:
|
|||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
blocked_names = []
|
blocked_names = []
|
||||||
|
|
||||||
|
# Parse discovery_blocked_types JSON
|
||||||
|
discovery_blocked_types: list[int] = []
|
||||||
|
if row["discovery_blocked_types"]:
|
||||||
|
try:
|
||||||
|
discovery_blocked_types = json.loads(row["discovery_blocked_types"])
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
discovery_blocked_types = []
|
||||||
|
|
||||||
# Validate sidebar_sort_order (fallback to "recent" if invalid)
|
# Validate sidebar_sort_order (fallback to "recent" if invalid)
|
||||||
sort_order = row["sidebar_sort_order"]
|
sort_order = row["sidebar_sort_order"]
|
||||||
if sort_order not in ("recent", "alpha"):
|
if sort_order not in ("recent", "alpha"):
|
||||||
@@ -97,6 +106,7 @@ class AppSettingsRepository:
|
|||||||
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -112,6 +122,7 @@ class AppSettingsRepository:
|
|||||||
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,
|
||||||
) -> AppSettings:
|
) -> AppSettings:
|
||||||
"""Update app settings. Only provided fields are updated."""
|
"""Update app settings. Only provided fields are updated."""
|
||||||
updates = []
|
updates = []
|
||||||
@@ -162,6 +173,10 @@ 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 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)
|
||||||
@@ -246,6 +261,26 @@ class AppSettingsRepository:
|
|||||||
|
|
||||||
|
|
||||||
class StatisticsRepository:
|
class StatisticsRepository:
|
||||||
|
@staticmethod
|
||||||
|
async def get_database_message_totals() -> dict[str, int]:
|
||||||
|
"""Return message totals needed by lightweight debug surfaces."""
|
||||||
|
cursor = await db.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
SUM(CASE WHEN type = 'PRIV' THEN 1 ELSE 0 END) AS total_dms,
|
||||||
|
SUM(CASE WHEN type = 'CHAN' THEN 1 ELSE 0 END) AS total_channel_messages,
|
||||||
|
SUM(CASE WHEN outgoing = 1 THEN 1 ELSE 0 END) AS total_outgoing
|
||||||
|
FROM messages
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
assert row is not None
|
||||||
|
return {
|
||||||
|
"total_dms": row["total_dms"] or 0,
|
||||||
|
"total_channel_messages": row["total_channel_messages"] or 0,
|
||||||
|
"total_outgoing": row["total_outgoing"] or 0,
|
||||||
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _activity_counts(*, contact_type: int, exclude: bool = False) -> dict[str, int]:
|
async def _activity_counts(*, contact_type: int, exclude: bool = False) -> dict[str, int]:
|
||||||
"""Get time-windowed counts for contacts/repeaters heard."""
|
"""Get time-windowed counts for contacts/repeaters heard."""
|
||||||
@@ -270,6 +305,39 @@ class StatisticsRepository:
|
|||||||
"last_week": row["last_week"] or 0,
|
"last_week": row["last_week"] or 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _known_channels_active() -> dict[str, int]:
|
||||||
|
"""Count known channel keys with any traffic in each time window.
|
||||||
|
|
||||||
|
Channel keys are stored canonically as uppercase hex, so we can avoid
|
||||||
|
the old UPPER(...) join and aggregate per known channel directly.
|
||||||
|
"""
|
||||||
|
now = int(time.time())
|
||||||
|
cursor = await db.conn.execute(
|
||||||
|
"""
|
||||||
|
WITH known AS (
|
||||||
|
SELECT conversation_key, MAX(received_at) AS last_received_at
|
||||||
|
FROM messages
|
||||||
|
WHERE type = 'CHAN'
|
||||||
|
AND conversation_key IN (SELECT key FROM channels)
|
||||||
|
GROUP BY conversation_key
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
SUM(CASE WHEN last_received_at >= ? THEN 1 ELSE 0 END) AS last_hour,
|
||||||
|
SUM(CASE WHEN last_received_at >= ? THEN 1 ELSE 0 END) AS last_24_hours,
|
||||||
|
SUM(CASE WHEN last_received_at >= ? THEN 1 ELSE 0 END) AS last_week
|
||||||
|
FROM known
|
||||||
|
""",
|
||||||
|
(now - SECONDS_1H, now - SECONDS_24H, now - SECONDS_7D),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
assert row is not None
|
||||||
|
return {
|
||||||
|
"last_hour": row["last_hour"] or 0,
|
||||||
|
"last_24_hours": row["last_24_hours"] or 0,
|
||||||
|
"last_week": row["last_week"] or 0,
|
||||||
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _path_hash_width_24h() -> dict[str, int | float]:
|
async def _path_hash_width_24h() -> dict[str, int | float]:
|
||||||
"""Count parsed raw packets from the last 24h by hop hash width."""
|
"""Count parsed raw packets from the last 24h by hop hash width."""
|
||||||
@@ -278,22 +346,26 @@ class StatisticsRepository:
|
|||||||
"SELECT data FROM raw_packets WHERE timestamp >= ?",
|
"SELECT data FROM raw_packets WHERE timestamp >= ?",
|
||||||
(now - SECONDS_24H,),
|
(now - SECONDS_24H,),
|
||||||
)
|
)
|
||||||
rows = await cursor.fetchall()
|
|
||||||
|
|
||||||
single_byte = 0
|
single_byte = 0
|
||||||
double_byte = 0
|
double_byte = 0
|
||||||
triple_byte = 0
|
triple_byte = 0
|
||||||
|
|
||||||
for row in rows:
|
while True:
|
||||||
envelope = parse_packet_envelope(bytes(row["data"]))
|
rows = await cursor.fetchmany(RAW_PACKET_STATS_BATCH_SIZE)
|
||||||
if envelope is None:
|
if not rows:
|
||||||
continue
|
break
|
||||||
if envelope.hash_size == 1:
|
|
||||||
single_byte += 1
|
for row in rows:
|
||||||
elif envelope.hash_size == 2:
|
envelope = parse_packet_envelope(bytes(row["data"]))
|
||||||
double_byte += 1
|
if envelope is None:
|
||||||
elif envelope.hash_size == 3:
|
continue
|
||||||
triple_byte += 1
|
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
|
total_packets = single_byte + double_byte + triple_byte
|
||||||
if total_packets == 0:
|
if total_packets == 0:
|
||||||
@@ -376,26 +448,12 @@ class StatisticsRepository:
|
|||||||
decrypted_packets = pkt_row["decrypted"] or 0
|
decrypted_packets = pkt_row["decrypted"] or 0
|
||||||
undecrypted_packets = total_packets - decrypted_packets
|
undecrypted_packets = total_packets - decrypted_packets
|
||||||
|
|
||||||
# Message type counts
|
message_totals = await StatisticsRepository.get_database_message_totals()
|
||||||
cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM messages WHERE type = 'PRIV'")
|
|
||||||
row = await cursor.fetchone()
|
|
||||||
assert row is not None
|
|
||||||
total_dms: int = row["cnt"]
|
|
||||||
|
|
||||||
cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM messages WHERE type = 'CHAN'")
|
|
||||||
row = await cursor.fetchone()
|
|
||||||
assert row is not None
|
|
||||||
total_channel_messages: int = row["cnt"]
|
|
||||||
|
|
||||||
# Outgoing count
|
|
||||||
cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM messages WHERE outgoing = 1")
|
|
||||||
row = await cursor.fetchone()
|
|
||||||
assert row is not None
|
|
||||||
total_outgoing: int = row["cnt"]
|
|
||||||
|
|
||||||
# Activity windows
|
# Activity windows
|
||||||
contacts_heard = await StatisticsRepository._activity_counts(contact_type=2, exclude=True)
|
contacts_heard = await StatisticsRepository._activity_counts(contact_type=2, exclude=True)
|
||||||
repeaters_heard = await StatisticsRepository._activity_counts(contact_type=2)
|
repeaters_heard = await StatisticsRepository._activity_counts(contact_type=2)
|
||||||
|
known_channels_active = await StatisticsRepository._known_channels_active()
|
||||||
path_hash_width_24h = await StatisticsRepository._path_hash_width_24h()
|
path_hash_width_24h = await StatisticsRepository._path_hash_width_24h()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -406,10 +464,11 @@ class StatisticsRepository:
|
|||||||
"total_packets": total_packets,
|
"total_packets": total_packets,
|
||||||
"decrypted_packets": decrypted_packets,
|
"decrypted_packets": decrypted_packets,
|
||||||
"undecrypted_packets": undecrypted_packets,
|
"undecrypted_packets": undecrypted_packets,
|
||||||
"total_dms": total_dms,
|
"total_dms": message_totals["total_dms"],
|
||||||
"total_channel_messages": total_channel_messages,
|
"total_channel_messages": message_totals["total_channel_messages"],
|
||||||
"total_outgoing": total_outgoing,
|
"total_outgoing": message_totals["total_outgoing"],
|
||||||
"contacts_heard": contacts_heard,
|
"contacts_heard": contacts_heard,
|
||||||
"repeaters_heard": repeaters_heard,
|
"repeaters_heard": repeaters_heard,
|
||||||
|
"known_channels_active": known_channels_active,
|
||||||
"path_hash_width_24h": path_hash_width_24h,
|
"path_hash_width_24h": path_hash_width_24h,
|
||||||
}
|
}
|
||||||
|
|||||||
+224
-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,154 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
packets = await RawPacketRepository.get_all_undecrypted()
|
||||||
|
total = len(packets)
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
|
||||||
|
for packet_id, packet_data, packet_timestamp in packets:
|
||||||
|
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."""
|
||||||
@@ -69,50 +214,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 room 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 +234,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)."""
|
||||||
|
|||||||
+66
-13
@@ -1,10 +1,12 @@
|
|||||||
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.dependencies import require_connected
|
||||||
from app.models import (
|
from app.models import (
|
||||||
@@ -31,7 +33,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
|
||||||
|
|
||||||
@@ -40,6 +42,10 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(prefix="/contacts", tags=["contacts"])
|
router = APIRouter(prefix="/contacts", tags=["contacts"])
|
||||||
|
|
||||||
|
|
||||||
|
TRACE_HASH_BYTES = 4
|
||||||
|
TRACE_FLAGS_4BYTE = 2
|
||||||
|
|
||||||
|
|
||||||
def _ambiguous_contact_detail(err: AmbiguousPublicKeyPrefixError) -> str:
|
def _ambiguous_contact_detail(err: AmbiguousPublicKeyPrefixError) -> str:
|
||||||
sample = ", ".join(key[:12] for key in err.matches[:2])
|
sample = ", ".join(key[:12] for key in err.matches[:2])
|
||||||
return (
|
return (
|
||||||
@@ -273,12 +279,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,
|
||||||
@@ -304,7 +316,6 @@ async def create_contact(
|
|||||||
contact_upsert = ContactUpsert(
|
contact_upsert = ContactUpsert(
|
||||||
public_key=lower_key,
|
public_key=lower_key,
|
||||||
name=request.name,
|
name=request.name,
|
||||||
out_path_hash_mode=-1,
|
|
||||||
on_radio=False,
|
on_radio=False,
|
||||||
)
|
)
|
||||||
await ContactRepository.upsert(contact_upsert)
|
await ContactRepository.upsert(contact_upsert)
|
||||||
@@ -314,9 +325,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,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -344,6 +356,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)."""
|
||||||
@@ -374,17 +424,17 @@ async def delete_contact(public_key: str) -> dict:
|
|||||||
async def request_trace(public_key: str) -> TraceResponse:
|
async def request_trace(public_key: str) -> TraceResponse:
|
||||||
"""Send a single-hop trace to a contact and wait for the result.
|
"""Send a single-hop trace to a contact and wait for the result.
|
||||||
|
|
||||||
The trace path contains the contact's 1-byte pubkey hash as the sole hop
|
The trace path contains the contact's 4-byte pubkey hash as the sole hop
|
||||||
(no intermediate repeaters). The radio firmware requires at least one
|
(no intermediate repeaters). This uses TRACE's dedicated width flags rather
|
||||||
node in the path.
|
than the radio's normal path_hash_mode setting.
|
||||||
"""
|
"""
|
||||||
require_connected()
|
require_connected()
|
||||||
|
|
||||||
contact = await _resolve_contact_or_404(public_key)
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
|
|
||||||
tag = random.randint(1, 0xFFFFFFFF)
|
tag = random.randint(1, 0xFFFFFFFF)
|
||||||
# First 2 hex chars of pubkey = 1-byte hash used by the trace protocol
|
# Use a 4-byte contact hash for low-collision direct trace targeting.
|
||||||
contact_hash = contact.public_key[:2]
|
contact_hash = contact.public_key[: TRACE_HASH_BYTES * 2]
|
||||||
|
|
||||||
# Trace does not need auto-fetch suspension: response arrives as TRACE_DATA
|
# Trace does not need auto-fetch suspension: response arrives as TRACE_DATA
|
||||||
# from the reader loop, not via get_msg().
|
# from the reader loop, not via get_msg().
|
||||||
@@ -395,7 +445,11 @@ async def request_trace(public_key: str) -> TraceResponse:
|
|||||||
logger.info(
|
logger.info(
|
||||||
"Sending trace to %s (tag=%d, hash=%s)", contact.public_key[:12], tag, contact_hash
|
"Sending trace to %s (tag=%d, hash=%s)", contact.public_key[:12], tag, contact_hash
|
||||||
)
|
)
|
||||||
result = await mc.commands.send_trace(path=contact_hash, tag=tag)
|
result = await mc.commands.send_trace(
|
||||||
|
path=contact_hash,
|
||||||
|
tag=tag,
|
||||||
|
flags=TRACE_FLAGS_4BYTE,
|
||||||
|
)
|
||||||
|
|
||||||
if result.type == EventType.ERROR:
|
if result.type == EventType.ERROR:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to send trace: {result.payload}")
|
raise HTTPException(status_code=500, detail=f"Failed to send trace: {result.payload}")
|
||||||
@@ -474,7 +528,7 @@ async def request_path_discovery(public_key: str) -> PathDiscoveryResponse:
|
|||||||
return_len = int(payload.get("in_path_len") or 0)
|
return_len = int(payload.get("in_path_len") or 0)
|
||||||
return_mode = _path_hash_mode_from_hop_width(payload.get("in_path_hash_len"))
|
return_mode = _path_hash_mode_from_hop_width(payload.get("in_path_hash_len"))
|
||||||
|
|
||||||
await ContactRepository.update_path(
|
await ContactRepository.update_direct_path(
|
||||||
contact.public_key,
|
contact.public_key,
|
||||||
forward_path,
|
forward_path,
|
||||||
forward_len,
|
forward_len,
|
||||||
@@ -524,9 +578,8 @@ async def set_contact_routing_override(
|
|||||||
route_text = request.route.strip()
|
route_text = request.route.strip()
|
||||||
if route_text == "":
|
if route_text == "":
|
||||||
await ContactRepository.clear_routing_override(contact.public_key)
|
await ContactRepository.clear_routing_override(contact.public_key)
|
||||||
await ContactRepository.update_path(contact.public_key, "", -1, -1)
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Cleared routing override and reset learned path to flood for %s",
|
"Cleared routing override for %s",
|
||||||
contact.public_key[:12],
|
contact.public_key[:12],
|
||||||
)
|
)
|
||||||
elif route_text == "-1":
|
elif route_text == "-1":
|
||||||
|
|||||||
+151
-15
@@ -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
|
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
|
||||||
@@ -86,15 +95,59 @@ class DebugRadioProbe(BaseModel):
|
|||||||
channels: DebugChannelAudit | None = None
|
channels: DebugChannelAudit | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DebugDatabaseInfo(BaseModel):
|
||||||
|
total_dms: int
|
||||||
|
total_channel_messages: 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
|
||||||
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")
|
||||||
@@ -150,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:
|
||||||
@@ -234,7 +349,7 @@ async def _probe_radio() -> DebugRadioProbe:
|
|||||||
return DebugRadioProbe(
|
return DebugRadioProbe(
|
||||||
performed=True,
|
performed=True,
|
||||||
errors=errors,
|
errors=errors,
|
||||||
self_info=dict(mc.self_info or {}),
|
self_info=_sanitize_radio_probe_self_info(mc.self_info),
|
||||||
device_info=device_info,
|
device_info=device_info,
|
||||||
stats_core=stats_core,
|
stats_core=stats_core,
|
||||||
stats_radio=stats_radio,
|
stats_radio=stats_radio,
|
||||||
@@ -253,23 +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()
|
||||||
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(),
|
||||||
@@ -278,6 +409,11 @@ async def debug_support_snapshot() -> DebugSnapshotResponse:
|
|||||||
"force_channel_slot_reconfigure": settings.force_channel_slot_reconfigure,
|
"force_channel_slot_reconfigure": settings.force_channel_slot_reconfigure,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
database=DebugDatabaseInfo(
|
||||||
|
total_dms=message_totals["total_dms"],
|
||||||
|
total_channel_messages=message_totals["total_channel_messages"],
|
||||||
|
total_outgoing=message_totals["total_outgoing"],
|
||||||
|
),
|
||||||
radio_probe=radio_probe,
|
radio_probe=radio_probe,
|
||||||
logs=[*LOG_COPY_BOUNDARY_PREFIX, *get_recent_log_lines(limit=1000)],
|
logs=[*LOG_COPY_BOUNDARY_PREFIX, *get_recent_log_lines(limit=1000)],
|
||||||
)
|
)
|
||||||
|
|||||||
+60
-12
@@ -9,14 +9,14 @@ import string
|
|||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.config import settings as server_settings
|
|
||||||
from app.fanout.bot_exec import _analyze_bot_signature
|
from app.fanout.bot_exec import _analyze_bot_signature
|
||||||
|
from app.fanout.manager import fanout_manager
|
||||||
from app.repository.fanout import FanoutConfigRepository
|
from app.repository.fanout import FanoutConfigRepository
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/fanout", tags=["fanout"])
|
router = APIRouter(prefix="/fanout", tags=["fanout"])
|
||||||
|
|
||||||
_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot", "webhook", "apprise", "sqs"}
|
_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot", "webhook", "apprise", "sqs", "map_upload"}
|
||||||
|
|
||||||
_IATA_RE = re.compile(r"^[A-Z]{3}$")
|
_IATA_RE = re.compile(r"^[A-Z]{3}$")
|
||||||
_DEFAULT_COMMUNITY_MQTT_TOPIC_TEMPLATE = "meshcore/{IATA}/{PUBLIC_KEY}/packets"
|
_DEFAULT_COMMUNITY_MQTT_TOPIC_TEMPLATE = "meshcore/{IATA}/{PUBLIC_KEY}/packets"
|
||||||
@@ -94,6 +94,8 @@ def _validate_and_normalize_config(config_type: str, config: dict) -> dict:
|
|||||||
_validate_apprise_config(normalized)
|
_validate_apprise_config(normalized)
|
||||||
elif config_type == "sqs":
|
elif config_type == "sqs":
|
||||||
_validate_sqs_config(normalized)
|
_validate_sqs_config(normalized)
|
||||||
|
elif config_type == "map_upload":
|
||||||
|
_validate_map_upload_config(normalized)
|
||||||
|
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
@@ -295,10 +297,33 @@ def _validate_sqs_config(config: dict) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_map_upload_config(config: dict) -> None:
|
||||||
|
"""Validate and normalize map_upload config blob."""
|
||||||
|
api_url = str(config.get("api_url", "")).strip()
|
||||||
|
if api_url and not api_url.startswith(("http://", "https://")):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="api_url must start with http:// or https://",
|
||||||
|
)
|
||||||
|
# Persist the cleaned value (empty string means use the module default)
|
||||||
|
config["api_url"] = api_url
|
||||||
|
config["dry_run"] = bool(config.get("dry_run", True))
|
||||||
|
config["geofence_enabled"] = bool(config.get("geofence_enabled", False))
|
||||||
|
try:
|
||||||
|
radius = float(config.get("geofence_radius_km", 0) or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise HTTPException(status_code=400, detail="geofence_radius_km must be a number") from None
|
||||||
|
if radius < 0:
|
||||||
|
raise HTTPException(status_code=400, detail="geofence_radius_km must be >= 0")
|
||||||
|
config["geofence_radius_km"] = radius
|
||||||
|
|
||||||
|
|
||||||
def _enforce_scope(config_type: str, scope: dict) -> dict:
|
def _enforce_scope(config_type: str, scope: dict) -> dict:
|
||||||
"""Enforce type-specific scope constraints. Returns normalized scope."""
|
"""Enforce type-specific scope constraints. Returns normalized scope."""
|
||||||
if config_type == "mqtt_community":
|
if config_type == "mqtt_community":
|
||||||
return {"messages": "none", "raw_packets": "all"}
|
return {"messages": "none", "raw_packets": "all"}
|
||||||
|
if config_type == "map_upload":
|
||||||
|
return {"messages": "none", "raw_packets": "all"}
|
||||||
if config_type == "bot":
|
if config_type == "bot":
|
||||||
return {"messages": "all", "raw_packets": "none"}
|
return {"messages": "all", "raw_packets": "none"}
|
||||||
if config_type in ("webhook", "apprise"):
|
if config_type in ("webhook", "apprise"):
|
||||||
@@ -325,6 +350,15 @@ def _enforce_scope(config_type: str, scope: dict) -> dict:
|
|||||||
return {"messages": messages, "raw_packets": raw_packets}
|
return {"messages": messages, "raw_packets": raw_packets}
|
||||||
|
|
||||||
|
|
||||||
|
def _bot_system_disabled_detail() -> str | None:
|
||||||
|
source = fanout_manager.get_bots_disabled_source()
|
||||||
|
if source == "env":
|
||||||
|
return "Bot system disabled by server configuration (MESHCORE_DISABLE_BOTS)"
|
||||||
|
if source == "until_restart":
|
||||||
|
return "Bot system disabled until the server restarts"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_fanout_configs() -> list[dict]:
|
async def list_fanout_configs() -> list[dict]:
|
||||||
"""List all fanout configs."""
|
"""List all fanout configs."""
|
||||||
@@ -340,8 +374,10 @@ async def create_fanout_config(body: FanoutConfigCreate) -> dict:
|
|||||||
detail=f"Invalid type '{body.type}'. Must be one of: {', '.join(sorted(_VALID_TYPES))}",
|
detail=f"Invalid type '{body.type}'. Must be one of: {', '.join(sorted(_VALID_TYPES))}",
|
||||||
)
|
)
|
||||||
|
|
||||||
if body.type == "bot" and server_settings.disable_bots:
|
if body.type == "bot":
|
||||||
raise HTTPException(status_code=403, detail="Bot system disabled by server configuration")
|
disabled_detail = _bot_system_disabled_detail()
|
||||||
|
if disabled_detail:
|
||||||
|
raise HTTPException(status_code=403, detail=disabled_detail)
|
||||||
|
|
||||||
normalized_config = _validate_and_normalize_config(body.type, body.config)
|
normalized_config = _validate_and_normalize_config(body.type, body.config)
|
||||||
scope = _enforce_scope(body.type, body.scope)
|
scope = _enforce_scope(body.type, body.scope)
|
||||||
@@ -356,8 +392,6 @@ async def create_fanout_config(body: FanoutConfigCreate) -> dict:
|
|||||||
|
|
||||||
# Start the module if enabled
|
# Start the module if enabled
|
||||||
if cfg["enabled"]:
|
if cfg["enabled"]:
|
||||||
from app.fanout.manager import fanout_manager
|
|
||||||
|
|
||||||
await fanout_manager.reload_config(cfg["id"])
|
await fanout_manager.reload_config(cfg["id"])
|
||||||
|
|
||||||
logger.info("Created fanout config %s (type=%s, name=%s)", cfg["id"], body.type, body.name)
|
logger.info("Created fanout config %s (type=%s, name=%s)", cfg["id"], body.type, body.name)
|
||||||
@@ -371,8 +405,10 @@ async def update_fanout_config(config_id: str, body: FanoutConfigUpdate) -> dict
|
|||||||
if existing is None:
|
if existing is None:
|
||||||
raise HTTPException(status_code=404, detail="Fanout config not found")
|
raise HTTPException(status_code=404, detail="Fanout config not found")
|
||||||
|
|
||||||
if existing["type"] == "bot" and server_settings.disable_bots:
|
if existing["type"] == "bot":
|
||||||
raise HTTPException(status_code=403, detail="Bot system disabled by server configuration")
|
disabled_detail = _bot_system_disabled_detail()
|
||||||
|
if disabled_detail:
|
||||||
|
raise HTTPException(status_code=403, detail=disabled_detail)
|
||||||
|
|
||||||
kwargs = {}
|
kwargs = {}
|
||||||
if body.name is not None:
|
if body.name is not None:
|
||||||
@@ -390,8 +426,6 @@ async def update_fanout_config(config_id: str, body: FanoutConfigUpdate) -> dict
|
|||||||
raise HTTPException(status_code=404, detail="Fanout config not found")
|
raise HTTPException(status_code=404, detail="Fanout config not found")
|
||||||
|
|
||||||
# Reload the module to pick up changes
|
# Reload the module to pick up changes
|
||||||
from app.fanout.manager import fanout_manager
|
|
||||||
|
|
||||||
await fanout_manager.reload_config(config_id)
|
await fanout_manager.reload_config(config_id)
|
||||||
|
|
||||||
logger.info("Updated fanout config %s", config_id)
|
logger.info("Updated fanout config %s", config_id)
|
||||||
@@ -406,10 +440,24 @@ async def delete_fanout_config(config_id: str) -> dict:
|
|||||||
raise HTTPException(status_code=404, detail="Fanout config not found")
|
raise HTTPException(status_code=404, detail="Fanout config not found")
|
||||||
|
|
||||||
# Stop the module first
|
# Stop the module first
|
||||||
from app.fanout.manager import fanout_manager
|
|
||||||
|
|
||||||
await fanout_manager.remove_config(config_id)
|
await fanout_manager.remove_config(config_id)
|
||||||
await FanoutConfigRepository.delete(config_id)
|
await FanoutConfigRepository.delete(config_id)
|
||||||
|
|
||||||
logger.info("Deleted fanout config %s", config_id)
|
logger.info("Deleted fanout config %s", config_id)
|
||||||
return {"deleted": True}
|
return {"deleted": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bots/disable-until-restart")
|
||||||
|
async def disable_bots_until_restart() -> dict:
|
||||||
|
"""Stop active bot modules and prevent them from running again until restart."""
|
||||||
|
source = await fanout_manager.disable_bots_until_restart()
|
||||||
|
|
||||||
|
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||||
|
from app.websocket import broadcast_health
|
||||||
|
|
||||||
|
broadcast_health(radio_manager.is_connected, radio_manager.connection_info)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"bots_disabled": True,
|
||||||
|
"bots_disabled_source": source,
|
||||||
|
}
|
||||||
|
|||||||
+24
-4
@@ -1,8 +1,8 @@
|
|||||||
import os
|
import os
|
||||||
from typing import Any
|
from typing import Any, Literal
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.repository import RawPacketRepository
|
from app.repository import RawPacketRepository
|
||||||
@@ -25,6 +25,13 @@ class AppInfoResponse(BaseModel):
|
|||||||
commit_hash: str | None = None
|
commit_hash: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FanoutStatusResponse(BaseModel):
|
||||||
|
name: str
|
||||||
|
type: str
|
||||||
|
status: str
|
||||||
|
last_error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class HealthResponse(BaseModel):
|
class HealthResponse(BaseModel):
|
||||||
status: str
|
status: str
|
||||||
radio_connected: bool
|
radio_connected: bool
|
||||||
@@ -35,8 +42,10 @@ class HealthResponse(BaseModel):
|
|||||||
radio_device_info: RadioDeviceInfoResponse | None = None
|
radio_device_info: RadioDeviceInfoResponse | None = None
|
||||||
database_size_mb: float
|
database_size_mb: float
|
||||||
oldest_undecrypted_timestamp: int | None
|
oldest_undecrypted_timestamp: int | None
|
||||||
fanout_statuses: dict[str, dict[str, str]] = {}
|
fanout_statuses: dict[str, FanoutStatusResponse] = Field(default_factory=dict)
|
||||||
bots_disabled: bool = False
|
bots_disabled: bool = False
|
||||||
|
bots_disabled_source: Literal["env", "until_restart"] | None = None
|
||||||
|
basic_auth_enabled: bool = False
|
||||||
|
|
||||||
|
|
||||||
def _clean_optional_str(value: object) -> str | None:
|
def _clean_optional_str(value: object) -> str | None:
|
||||||
@@ -46,6 +55,11 @@ def _clean_optional_str(value: object) -> str | None:
|
|||||||
return cleaned or None
|
return cleaned or None
|
||||||
|
|
||||||
|
|
||||||
|
def _read_optional_bool_setting(name: str) -> bool:
|
||||||
|
value = getattr(settings, name, False)
|
||||||
|
return value if isinstance(value, bool) else False
|
||||||
|
|
||||||
|
|
||||||
async def build_health_data(radio_connected: bool, connection_info: str | None) -> dict:
|
async def build_health_data(radio_connected: bool, connection_info: str | None) -> dict:
|
||||||
"""Build the health status payload used by REST endpoint and WebSocket broadcasts."""
|
"""Build the health status payload used by REST endpoint and WebSocket broadcasts."""
|
||||||
app_build_info = get_app_build_info()
|
app_build_info = get_app_build_info()
|
||||||
@@ -64,10 +78,14 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
|
|||||||
|
|
||||||
# Fanout module statuses
|
# Fanout module statuses
|
||||||
fanout_statuses: dict[str, Any] = {}
|
fanout_statuses: dict[str, Any] = {}
|
||||||
|
bots_disabled_source = "env" if _read_optional_bool_setting("disable_bots") else None
|
||||||
try:
|
try:
|
||||||
from app.fanout.manager import fanout_manager
|
from app.fanout.manager import fanout_manager
|
||||||
|
|
||||||
fanout_statuses = fanout_manager.get_statuses()
|
fanout_statuses = fanout_manager.get_statuses()
|
||||||
|
manager_bots_disabled_source = fanout_manager.get_bots_disabled_source()
|
||||||
|
if manager_bots_disabled_source is not None:
|
||||||
|
bots_disabled_source = manager_bots_disabled_source
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -118,7 +136,9 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
|
|||||||
"database_size_mb": db_size_mb,
|
"database_size_mb": db_size_mb,
|
||||||
"oldest_undecrypted_timestamp": oldest_ts,
|
"oldest_undecrypted_timestamp": oldest_ts,
|
||||||
"fanout_statuses": fanout_statuses,
|
"fanout_statuses": fanout_statuses,
|
||||||
"bots_disabled": settings.disable_bots,
|
"bots_disabled": bots_disabled_source is not None,
|
||||||
|
"bots_disabled_source": bots_disabled_source,
|
||||||
|
"basic_auth_enabled": _read_optional_bool_setting("basic_auth_enabled"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+42
-3
@@ -8,8 +8,9 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
from app.database import db
|
from app.database import db
|
||||||
from app.decoder import parse_packet, try_decrypt_packet_with_channel_key
|
from app.decoder import parse_packet, try_decrypt_packet_with_channel_key
|
||||||
|
from app.models import RawPacketDecryptedInfo, RawPacketDetail
|
||||||
from app.packet_processor import create_message_from_decrypted, run_historical_dm_decryption
|
from app.packet_processor import create_message_from_decrypted, run_historical_dm_decryption
|
||||||
from app.repository import ChannelRepository, RawPacketRepository
|
from app.repository import ChannelRepository, MessageRepository, RawPacketRepository
|
||||||
from app.websocket import broadcast_success
|
from app.websocket import broadcast_success
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -102,6 +103,45 @@ async def get_undecrypted_count() -> dict:
|
|||||||
return {"count": count}
|
return {"count": count}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{packet_id}", response_model=RawPacketDetail)
|
||||||
|
async def get_raw_packet(packet_id: int) -> RawPacketDetail:
|
||||||
|
"""Fetch one stored raw packet by row ID for on-demand inspection."""
|
||||||
|
packet_row = await RawPacketRepository.get_by_id(packet_id)
|
||||||
|
if packet_row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Raw packet not found")
|
||||||
|
|
||||||
|
stored_packet_id, packet_data, packet_timestamp, message_id = packet_row
|
||||||
|
packet_info = parse_packet(packet_data)
|
||||||
|
payload_type_name = packet_info.payload_type.name if packet_info else "Unknown"
|
||||||
|
|
||||||
|
decrypted_info: RawPacketDecryptedInfo | None = None
|
||||||
|
if message_id is not None:
|
||||||
|
message = await MessageRepository.get_by_id(message_id)
|
||||||
|
if message is not None:
|
||||||
|
if message.type == "CHAN":
|
||||||
|
channel = await ChannelRepository.get_by_key(message.conversation_key)
|
||||||
|
decrypted_info = RawPacketDecryptedInfo(
|
||||||
|
channel_name=channel.name if channel else None,
|
||||||
|
sender=message.sender_name,
|
||||||
|
channel_key=message.conversation_key,
|
||||||
|
contact_key=message.sender_key,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
decrypted_info = RawPacketDecryptedInfo(
|
||||||
|
sender=message.sender_name,
|
||||||
|
contact_key=message.conversation_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
return RawPacketDetail(
|
||||||
|
id=stored_packet_id,
|
||||||
|
timestamp=packet_timestamp,
|
||||||
|
data=packet_data.hex(),
|
||||||
|
payload_type=payload_type_name,
|
||||||
|
decrypted=message_id is not None,
|
||||||
|
decrypted_info=decrypted_info,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/decrypt/historical", response_model=DecryptResult)
|
@router.post("/decrypt/historical", response_model=DecryptResult)
|
||||||
async def decrypt_historical_packets(
|
async def decrypt_historical_packets(
|
||||||
request: DecryptRequest, background_tasks: BackgroundTasks, response: Response
|
request: DecryptRequest, background_tasks: BackgroundTasks, response: Response
|
||||||
@@ -170,8 +210,7 @@ async def decrypt_historical_packets(
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
raise _bad_request("Invalid hex string for contact public key") from None
|
raise _bad_request("Invalid hex string for contact public key") from None
|
||||||
|
|
||||||
packets = await RawPacketRepository.get_undecrypted_text_messages()
|
count = await RawPacketRepository.count_undecrypted_text_messages()
|
||||||
count = len(packets)
|
|
||||||
if count == 0:
|
if count == 0:
|
||||||
return DecryptResult(
|
return DecryptResult(
|
||||||
started=False,
|
started=False,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
|
from contextlib import suppress
|
||||||
from typing import Literal, TypeAlias
|
from typing import Literal, TypeAlias
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
@@ -10,14 +11,23 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
from app.dependencies import require_connected
|
from app.dependencies import require_connected
|
||||||
from app.models import (
|
from app.models import (
|
||||||
|
CONTACT_TYPE_REPEATER,
|
||||||
ContactUpsert,
|
ContactUpsert,
|
||||||
RadioDiscoveryRequest,
|
RadioDiscoveryRequest,
|
||||||
RadioDiscoveryResponse,
|
RadioDiscoveryResponse,
|
||||||
RadioDiscoveryResult,
|
RadioDiscoveryResult,
|
||||||
|
RadioTraceHopRequest,
|
||||||
|
RadioTraceNode,
|
||||||
|
RadioTraceRequest,
|
||||||
|
RadioTraceResponse,
|
||||||
)
|
)
|
||||||
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,
|
||||||
|
reconcile_contact_messages,
|
||||||
|
)
|
||||||
from app.services.radio_commands import (
|
from app.services.radio_commands import (
|
||||||
KeystoreRefreshError,
|
KeystoreRefreshError,
|
||||||
PathHashModeUnsupportedError,
|
PathHashModeUnsupportedError,
|
||||||
@@ -44,6 +54,12 @@ _DISCOVERY_NODE_TYPES: dict[int, DiscoveryNodeType] = {
|
|||||||
2: "repeater",
|
2: "repeater",
|
||||||
4: "sensor",
|
4: "sensor",
|
||||||
}
|
}
|
||||||
|
TRACE_WAIT_TIMEOUT_SECONDS = 45.0
|
||||||
|
TRACE_DEFAULT_TIMEOUT_SECONDS = 15.0
|
||||||
|
TRACE_TIMEOUT_MIN_SECONDS = 5.0
|
||||||
|
TRACE_TIMEOUT_MAX_SECONDS = 30.0
|
||||||
|
TRACE_TIMEOUT_MARGIN = 1.2
|
||||||
|
TRACE_HASH_FLAGS = {1: 0, 2: 1, 4: 2}
|
||||||
|
|
||||||
|
|
||||||
async def _prepare_connected(*, broadcast_on_success: bool) -> bool:
|
async def _prepare_connected(*, broadcast_on_success: bool) -> bool:
|
||||||
@@ -81,6 +97,10 @@ class RadioConfigResponse(BaseModel):
|
|||||||
default="current",
|
default="current",
|
||||||
description="Whether adverts include the node's current location state",
|
description="Whether adverts include the node's current location state",
|
||||||
)
|
)
|
||||||
|
multi_acks_enabled: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Whether the radio sends an extra direct ACK transmission",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RadioConfigUpdate(BaseModel):
|
class RadioConfigUpdate(BaseModel):
|
||||||
@@ -99,6 +119,10 @@ class RadioConfigUpdate(BaseModel):
|
|||||||
default=None,
|
default=None,
|
||||||
description="Whether adverts include the node's current location state",
|
description="Whether adverts include the node's current location state",
|
||||||
)
|
)
|
||||||
|
multi_acks_enabled: bool | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Whether the radio sends an extra direct ACK transmission",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PrivateKeyUpdate(BaseModel):
|
class PrivateKeyUpdate(BaseModel):
|
||||||
@@ -189,9 +213,126 @@ async def _persist_new_discovery_contacts(results: list[RadioDiscoveryResult]) -
|
|||||||
on_radio=False,
|
on_radio=False,
|
||||||
)
|
)
|
||||||
await ContactRepository.upsert(contact)
|
await ContactRepository.upsert(contact)
|
||||||
|
promoted_keys = await promote_prefix_contacts_for_contact(
|
||||||
|
public_key=result.public_key,
|
||||||
|
log=logger,
|
||||||
|
)
|
||||||
|
await reconcile_contact_messages(
|
||||||
|
public_key=result.public_key,
|
||||||
|
contact_name=result.name,
|
||||||
|
log=logger,
|
||||||
|
)
|
||||||
created = await ContactRepository.get_by_key(result.public_key)
|
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:
|
||||||
|
broadcast_event(
|
||||||
|
"contact_resolved",
|
||||||
|
{"previous_public_key": old_key, "contact": created.model_dump()},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _attach_known_names(results: list[RadioDiscoveryResult]) -> None:
|
||||||
|
"""Resolve known contact names for discovery results from the DB."""
|
||||||
|
for result in results:
|
||||||
|
contact = await ContactRepository.get_by_key(result.public_key)
|
||||||
|
if contact is not None and contact.name:
|
||||||
|
result.name = contact.name
|
||||||
|
|
||||||
|
|
||||||
|
def _trace_hash_for_key(public_key: str, hop_hash_bytes: int) -> str:
|
||||||
|
return public_key[: hop_hash_bytes * 2].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _trace_timeout_seconds(send_result: object) -> float:
|
||||||
|
payload = getattr(send_result, "payload", None) or {}
|
||||||
|
suggested_timeout = payload.get("suggested_timeout")
|
||||||
|
try:
|
||||||
|
if suggested_timeout is None:
|
||||||
|
raise TypeError
|
||||||
|
timeout_seconds = float(suggested_timeout) / 1000.0 * TRACE_TIMEOUT_MARGIN
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
timeout_seconds = TRACE_DEFAULT_TIMEOUT_SECONDS
|
||||||
|
return max(TRACE_TIMEOUT_MIN_SECONDS, min(TRACE_TIMEOUT_MAX_SECONDS, timeout_seconds))
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_trace_hops(
|
||||||
|
hops: list[RadioTraceHopRequest], hop_hash_bytes: int
|
||||||
|
) -> tuple[list[RadioTraceNode], list[str]]:
|
||||||
|
trace_nodes: list[RadioTraceNode] = []
|
||||||
|
requested_hashes: list[str] = []
|
||||||
|
expected_hex_len = hop_hash_bytes * 2
|
||||||
|
|
||||||
|
for hop in hops:
|
||||||
|
public_key = hop.public_key.strip().lower() if isinstance(hop.public_key, str) else None
|
||||||
|
hop_hex = hop.hop_hex.strip().lower() if isinstance(hop.hop_hex, str) else None
|
||||||
|
if bool(public_key) == bool(hop_hex):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Each trace hop must provide exactly one of public_key or hop_hex",
|
||||||
|
)
|
||||||
|
|
||||||
|
if public_key:
|
||||||
|
if len(public_key) != 64:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Trace repeater keys must be full 64-character public keys",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
bytes.fromhex(public_key)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Trace repeater keys must be valid hex public keys",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
contact = await ContactRepository.get_by_key(public_key)
|
||||||
|
if contact is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404, detail=f"Trace repeater not found: {public_key}"
|
||||||
|
)
|
||||||
|
if contact.type != CONTACT_TYPE_REPEATER:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Trace node is not a repeater: {public_key[:12]}",
|
||||||
|
)
|
||||||
|
requested_hashes.append(_trace_hash_for_key(contact.public_key, hop_hash_bytes))
|
||||||
|
trace_nodes.append(
|
||||||
|
RadioTraceNode(
|
||||||
|
role="repeater",
|
||||||
|
public_key=contact.public_key,
|
||||||
|
name=contact.name,
|
||||||
|
observed_hash=None,
|
||||||
|
snr=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
assert hop_hex is not None
|
||||||
|
if len(hop_hex) != expected_hex_len:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Custom trace hops must be exactly {expected_hex_len} hex characters",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
bytes.fromhex(hop_hex)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Custom trace hops must be valid hex",
|
||||||
|
) from exc
|
||||||
|
requested_hashes.append(hop_hex)
|
||||||
|
trace_nodes.append(
|
||||||
|
RadioTraceNode(
|
||||||
|
role="custom",
|
||||||
|
public_key=None,
|
||||||
|
name=None,
|
||||||
|
observed_hash=hop_hex,
|
||||||
|
snr=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return trace_nodes, requested_hashes
|
||||||
|
|
||||||
|
|
||||||
@router.get("/config", response_model=RadioConfigResponse)
|
@router.get("/config", response_model=RadioConfigResponse)
|
||||||
@@ -222,6 +363,7 @@ async def get_radio_config() -> RadioConfigResponse:
|
|||||||
path_hash_mode=radio_manager.path_hash_mode,
|
path_hash_mode=radio_manager.path_hash_mode,
|
||||||
path_hash_mode_supported=radio_manager.path_hash_mode_supported,
|
path_hash_mode_supported=radio_manager.path_hash_mode_supported,
|
||||||
advert_location_source=advert_location_source,
|
advert_location_source=advert_location_source,
|
||||||
|
multi_acks_enabled=bool(info.get("multi_acks", 0)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -356,6 +498,7 @@ async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryRespons
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
await _persist_new_discovery_contacts(results)
|
await _persist_new_discovery_contacts(results)
|
||||||
|
await _attach_known_names(results)
|
||||||
return RadioDiscoveryResponse(
|
return RadioDiscoveryResponse(
|
||||||
target=request.target,
|
target=request.target,
|
||||||
duration_seconds=DISCOVERY_WINDOW_SECONDS,
|
duration_seconds=DISCOVERY_WINDOW_SECONDS,
|
||||||
@@ -363,6 +506,105 @@ async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryRespons
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/trace", response_model=RadioTraceResponse)
|
||||||
|
async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse:
|
||||||
|
"""Send a multi-hop trace loop through known repeaters and back to the local radio."""
|
||||||
|
require_connected()
|
||||||
|
trace_nodes, requested_hashes = await _resolve_trace_hops(request.hops, request.hop_hash_bytes)
|
||||||
|
|
||||||
|
tag = random.randint(1, 0xFFFFFFFF)
|
||||||
|
trace_flags = TRACE_HASH_FLAGS[request.hop_hash_bytes]
|
||||||
|
|
||||||
|
async with radio_manager.radio_operation("radio_trace", pause_polling=True) as mc:
|
||||||
|
local_public_key = str((mc.self_info or {}).get("public_key") or "").lower()
|
||||||
|
if len(local_public_key) != 64:
|
||||||
|
raise HTTPException(status_code=503, detail="Local radio public key is unavailable")
|
||||||
|
local_name = (mc.self_info or {}).get("name")
|
||||||
|
|
||||||
|
response_task = asyncio.create_task(
|
||||||
|
mc.wait_for_event(
|
||||||
|
EventType.TRACE_DATA,
|
||||||
|
attribute_filters={"tag": tag},
|
||||||
|
timeout=TRACE_WAIT_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
send_result = await mc.commands.send_trace(
|
||||||
|
path=",".join(requested_hashes),
|
||||||
|
tag=tag,
|
||||||
|
flags=trace_flags,
|
||||||
|
)
|
||||||
|
if send_result is None or send_result.type == EventType.ERROR:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to send trace")
|
||||||
|
|
||||||
|
timeout_seconds = _trace_timeout_seconds(send_result)
|
||||||
|
try:
|
||||||
|
event = await asyncio.wait_for(response_task, timeout=timeout_seconds)
|
||||||
|
except asyncio.TimeoutError as exc:
|
||||||
|
raise HTTPException(status_code=504, detail="No trace response heard") from exc
|
||||||
|
finally:
|
||||||
|
if not response_task.done():
|
||||||
|
response_task.cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await response_task
|
||||||
|
|
||||||
|
if event is None:
|
||||||
|
raise HTTPException(status_code=504, detail="No trace response heard")
|
||||||
|
|
||||||
|
payload = event.payload if isinstance(event.payload, dict) else {}
|
||||||
|
path_len = payload.get("path_len")
|
||||||
|
if not isinstance(path_len, int):
|
||||||
|
raise HTTPException(status_code=500, detail="Trace response was malformed")
|
||||||
|
|
||||||
|
raw_path = payload.get("path")
|
||||||
|
path_nodes = raw_path if isinstance(raw_path, list) else []
|
||||||
|
final_local_node = (
|
||||||
|
path_nodes[-1]
|
||||||
|
if path_nodes
|
||||||
|
and isinstance(path_nodes[-1], dict)
|
||||||
|
and not isinstance(path_nodes[-1].get("hash"), str)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
hashed_nodes = path_nodes[:-1] if final_local_node is not None else path_nodes
|
||||||
|
|
||||||
|
if len(hashed_nodes) < len(trace_nodes):
|
||||||
|
raise HTTPException(status_code=500, detail="Trace response was incomplete")
|
||||||
|
|
||||||
|
nodes: list[RadioTraceNode] = []
|
||||||
|
for index, trace_node in enumerate(trace_nodes):
|
||||||
|
observed = hashed_nodes[index] if index < len(hashed_nodes) else {}
|
||||||
|
observed_hash = observed.get("hash") if isinstance(observed, dict) else None
|
||||||
|
observed_snr = observed.get("snr") if isinstance(observed, dict) else None
|
||||||
|
nodes.append(
|
||||||
|
RadioTraceNode(
|
||||||
|
role=trace_node.role,
|
||||||
|
public_key=trace_node.public_key,
|
||||||
|
name=trace_node.name,
|
||||||
|
observed_hash=(
|
||||||
|
observed_hash if isinstance(observed_hash, str) else trace_node.observed_hash
|
||||||
|
),
|
||||||
|
snr=float(observed_snr) if isinstance(observed_snr, (int, float)) else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
terminal_snr_value = final_local_node.get("snr") if isinstance(final_local_node, dict) else None
|
||||||
|
nodes.append(
|
||||||
|
RadioTraceNode(
|
||||||
|
role="local",
|
||||||
|
public_key=local_public_key,
|
||||||
|
name=local_name if isinstance(local_name, str) and local_name else None,
|
||||||
|
observed_hash=None,
|
||||||
|
snr=float(terminal_snr_value) if isinstance(terminal_snr_value, (int, float)) else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return RadioTraceResponse(
|
||||||
|
path_len=path_len,
|
||||||
|
timeout_seconds=timeout_seconds,
|
||||||
|
nodes=nodes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _attempt_reconnect() -> dict:
|
async def _attempt_reconnect() -> dict:
|
||||||
"""Shared reconnection logic for reboot and reconnect endpoints."""
|
"""Shared reconnection logic for reboot and reconnect endpoints."""
|
||||||
radio_manager.resume_connection()
|
radio_manager.resume_connection()
|
||||||
|
|||||||
+61
-291
@@ -1,10 +1,7 @@
|
|||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from meshcore import EventType
|
|
||||||
|
|
||||||
from app.dependencies import require_connected
|
from app.dependencies import require_connected
|
||||||
from app.models import (
|
from app.models import (
|
||||||
@@ -25,14 +22,19 @@ 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 (
|
||||||
|
batch_cli_fetch,
|
||||||
|
extract_response_text,
|
||||||
|
prepare_authenticated_contact_connection,
|
||||||
|
require_server_capable_contact,
|
||||||
|
send_contact_cli_command,
|
||||||
|
)
|
||||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from meshcore.events import Event
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ACL permission level names
|
# ACL permission level names
|
||||||
@@ -43,194 +45,21 @@ ACL_PERMISSION_NAMES = {
|
|||||||
3: "Admin",
|
3: "Admin",
|
||||||
}
|
}
|
||||||
router = APIRouter(prefix="/contacts", tags=["repeaters"])
|
router = APIRouter(prefix="/contacts", tags=["repeaters"])
|
||||||
|
|
||||||
REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS = 5.0
|
REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS = 5.0
|
||||||
REPEATER_LOGIN_REJECTED_MESSAGE = (
|
|
||||||
"The repeater replied but did not confirm this login. "
|
|
||||||
"Existing access may still allow some repeater operations, but admin actions may fail."
|
|
||||||
)
|
|
||||||
REPEATER_LOGIN_SEND_FAILED_MESSAGE = (
|
|
||||||
"The login request could not be sent to the repeater. "
|
|
||||||
"The dashboard is still available, but repeater operations may fail until a login succeeds."
|
|
||||||
)
|
|
||||||
REPEATER_LOGIN_TIMEOUT_MESSAGE = (
|
|
||||||
"No login confirmation was heard from the repeater. "
|
|
||||||
"On current repeater firmware, that can mean the password was wrong, "
|
|
||||||
"blank-password login was not allowed by the ACL, or the reply was missed in transit. "
|
|
||||||
"The dashboard is still available; try logging in again if admin actions fail."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _monotonic() -> float:
|
|
||||||
"""Wrapper around time.monotonic() for testability.
|
|
||||||
|
|
||||||
Patching time.monotonic directly breaks the asyncio event loop which also
|
|
||||||
uses it. This indirection allows tests to control the clock safely.
|
|
||||||
"""
|
|
||||||
return time.monotonic()
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_response_text(event) -> str:
|
def _extract_response_text(event) -> str:
|
||||||
"""Extract text from a CLI response event, stripping the firmware '> ' prefix."""
|
return extract_response_text(event)
|
||||||
text = event.payload.get("text", str(event.payload))
|
|
||||||
if text.startswith("> "):
|
|
||||||
text = text[2:]
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_repeater_response(
|
|
||||||
mc,
|
|
||||||
target_pubkey_prefix: str,
|
|
||||||
timeout: float = 20.0,
|
|
||||||
) -> "Event | None":
|
|
||||||
"""Fetch a CLI response from a specific repeater via a validated get_msg() loop.
|
|
||||||
|
|
||||||
Calls get_msg() repeatedly until a matching CLI response (txt_type=1) from the
|
|
||||||
target repeater arrives or the wall-clock deadline expires. Unrelated messages
|
|
||||||
are safe to skip — meshcore's event dispatcher already delivers them to the
|
|
||||||
normal subscription handlers (on_contact_message, etc.) when get_msg() returns.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
mc: MeshCore instance
|
|
||||||
target_pubkey_prefix: 12-char hex prefix of the repeater's public key
|
|
||||||
timeout: Wall-clock seconds before giving up
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The matching Event, or None if no response arrived before the deadline.
|
|
||||||
"""
|
|
||||||
deadline = _monotonic() + timeout
|
|
||||||
|
|
||||||
while _monotonic() < deadline:
|
|
||||||
try:
|
|
||||||
result = await mc.commands.get_msg(timeout=2.0)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
continue
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("get_msg() exception: %s", e)
|
|
||||||
await asyncio.sleep(1.0)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if result.type == EventType.NO_MORE_MSGS:
|
|
||||||
# No messages queued yet — wait and retry
|
|
||||||
await asyncio.sleep(1.0)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if result.type == EventType.ERROR:
|
|
||||||
logger.debug("get_msg() error: %s", result.payload)
|
|
||||||
await asyncio.sleep(1.0)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if result.type == EventType.CONTACT_MSG_RECV:
|
|
||||||
msg_prefix = result.payload.get("pubkey_prefix", "")
|
|
||||||
txt_type = result.payload.get("txt_type", 0)
|
|
||||||
if msg_prefix == target_pubkey_prefix and txt_type == 1:
|
|
||||||
return result
|
|
||||||
# Not our target — already dispatched to subscribers by meshcore,
|
|
||||||
# so just continue draining the queue.
|
|
||||||
logger.debug(
|
|
||||||
"Skipping non-target message (from=%s, txt_type=%d) while waiting for %s",
|
|
||||||
msg_prefix,
|
|
||||||
txt_type,
|
|
||||||
target_pubkey_prefix,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if result.type == EventType.CHANNEL_MSG_RECV:
|
|
||||||
# Already dispatched to subscribers by meshcore; skip.
|
|
||||||
logger.debug(
|
|
||||||
"Skipping channel message (channel_idx=%s) during repeater fetch",
|
|
||||||
result.payload.get("channel_idx"),
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
logger.debug("Unexpected event type %s during repeater fetch, skipping", result.type)
|
|
||||||
|
|
||||||
logger.warning("No CLI response from repeater %s within %.1fs", target_pubkey_prefix, timeout)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def prepare_repeater_connection(mc, contact: Contact, password: str) -> RepeaterLoginResponse:
|
async def prepare_repeater_connection(mc, contact: Contact, password: str) -> RepeaterLoginResponse:
|
||||||
"""Prepare connection to a repeater by adding to radio and attempting login.
|
return await prepare_authenticated_contact_connection(
|
||||||
|
mc,
|
||||||
Args:
|
contact,
|
||||||
mc: MeshCore instance
|
password,
|
||||||
contact: The repeater contact
|
label="repeater",
|
||||||
password: Password for login (empty string for no password)
|
response_timeout=REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS,
|
||||||
"""
|
|
||||||
pubkey_prefix = contact.public_key[:12].lower()
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
login_future = loop.create_future()
|
|
||||||
|
|
||||||
def _resolve_login(event_type: EventType, message: str | None = None) -> None:
|
|
||||||
if login_future.done():
|
|
||||||
return
|
|
||||||
login_future.set_result(
|
|
||||||
RepeaterLoginResponse(
|
|
||||||
status="ok" if event_type == EventType.LOGIN_SUCCESS else "error",
|
|
||||||
authenticated=event_type == EventType.LOGIN_SUCCESS,
|
|
||||||
message=message,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
success_subscription = mc.subscribe(
|
|
||||||
EventType.LOGIN_SUCCESS,
|
|
||||||
lambda _event: _resolve_login(EventType.LOGIN_SUCCESS),
|
|
||||||
attribute_filters={"pubkey_prefix": pubkey_prefix},
|
|
||||||
)
|
)
|
||||||
failed_subscription = mc.subscribe(
|
|
||||||
EventType.LOGIN_FAILED,
|
|
||||||
lambda _event: _resolve_login(
|
|
||||||
EventType.LOGIN_FAILED,
|
|
||||||
REPEATER_LOGIN_REJECTED_MESSAGE,
|
|
||||||
),
|
|
||||||
attribute_filters={"pubkey_prefix": pubkey_prefix},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add contact to radio with path from DB (non-fatal — contact may already be loaded)
|
|
||||||
try:
|
|
||||||
logger.info("Adding repeater %s to radio", contact.public_key[:12])
|
|
||||||
await _ensure_on_radio(mc, contact)
|
|
||||||
|
|
||||||
logger.info("Sending login to repeater %s", contact.public_key[:12])
|
|
||||||
login_result = await mc.commands.send_login(contact.public_key, password)
|
|
||||||
|
|
||||||
if login_result.type == EventType.ERROR:
|
|
||||||
return RepeaterLoginResponse(
|
|
||||||
status="error",
|
|
||||||
authenticated=False,
|
|
||||||
message=f"{REPEATER_LOGIN_SEND_FAILED_MESSAGE} ({login_result.payload})",
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
return await asyncio.wait_for(
|
|
||||||
login_future,
|
|
||||||
timeout=REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS,
|
|
||||||
)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.warning(
|
|
||||||
"No login response from repeater %s within %.1fs",
|
|
||||||
contact.public_key[:12],
|
|
||||||
REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS,
|
|
||||||
)
|
|
||||||
return RepeaterLoginResponse(
|
|
||||||
status="timeout",
|
|
||||||
authenticated=False,
|
|
||||||
message=REPEATER_LOGIN_TIMEOUT_MESSAGE,
|
|
||||||
)
|
|
||||||
except HTTPException as exc:
|
|
||||||
logger.warning(
|
|
||||||
"Repeater login setup failed for %s: %s",
|
|
||||||
contact.public_key[:12],
|
|
||||||
exc.detail,
|
|
||||||
)
|
|
||||||
return RepeaterLoginResponse(
|
|
||||||
status="error",
|
|
||||||
authenticated=False,
|
|
||||||
message=f"{REPEATER_LOGIN_SEND_FAILED_MESSAGE} ({exc.detail})",
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
success_subscription.unsubscribe()
|
|
||||||
failed_subscription.unsubscribe()
|
|
||||||
|
|
||||||
|
|
||||||
def _require_repeater(contact: Contact) -> None:
|
def _require_repeater(contact: Contact) -> None:
|
||||||
@@ -281,7 +110,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),
|
||||||
@@ -301,6 +130,42 @@ 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:
|
||||||
@@ -403,43 +268,7 @@ 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 repeater and collect responses.
|
return await batch_cli_fetch(contact, operation_name, commands)
|
||||||
|
|
||||||
Opens a radio operation with polling paused and auto-fetch suspended (since
|
|
||||||
we call get_msg() directly via _fetch_repeater_response), adds the contact
|
|
||||||
to the radio for routing, then sends each command sequentially with a 1-second
|
|
||||||
gap between them.
|
|
||||||
|
|
||||||
Returns a dict mapping field names to response strings (or None on timeout).
|
|
||||||
"""
|
|
||||||
results: dict[str, str | None] = {field: None for _, field in commands}
|
|
||||||
|
|
||||||
async with radio_manager.radio_operation(
|
|
||||||
operation_name,
|
|
||||||
pause_polling=True,
|
|
||||||
suspend_auto_fetch=True,
|
|
||||||
) as mc:
|
|
||||||
await _ensure_on_radio(mc, contact)
|
|
||||||
await asyncio.sleep(1.0)
|
|
||||||
|
|
||||||
for i, (cmd, field) in enumerate(commands):
|
|
||||||
if i > 0:
|
|
||||||
await asyncio.sleep(1.0)
|
|
||||||
|
|
||||||
send_result = await mc.commands.send_cmd(contact.public_key, cmd)
|
|
||||||
if send_result.type == EventType.ERROR:
|
|
||||||
logger.debug("Command '%s' send error: %s", cmd, send_result.payload)
|
|
||||||
continue
|
|
||||||
|
|
||||||
response_event = await _fetch_repeater_response(
|
|
||||||
mc, contact.public_key[:12], timeout=10.0
|
|
||||||
)
|
|
||||||
if response_event is not None:
|
|
||||||
results[field] = _extract_response_text(response_event)
|
|
||||||
else:
|
|
||||||
logger.warning("No response for command '%s' (%s)", cmd, field)
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{public_key}/repeater/node-info", response_model=RepeaterNodeInfoResponse)
|
@router.post("/{public_key}/repeater/node-info", response_model=RepeaterNodeInfoResponse)
|
||||||
@@ -524,72 +353,13 @@ 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.
|
"""Send a CLI command to a repeater or room server."""
|
||||||
|
|
||||||
The contact must be a repeater (type=2). The user must have already logged in
|
|
||||||
via the repeater/login endpoint. This endpoint ensures the contact is on the
|
|
||||||
radio before sending commands (the repeater remembers ACL permissions after login).
|
|
||||||
|
|
||||||
Common commands:
|
|
||||||
- get name, set name <value>
|
|
||||||
- get tx, set tx <dbm>
|
|
||||||
- get radio, set radio <freq,bw,sf,cr>
|
|
||||||
- tempradio <freq,bw,sf,cr,minutes>
|
|
||||||
- setperm <pubkey> <permission> (0=guest, 1=read-only, 2=read-write, 3=admin)
|
|
||||||
- clock, clock sync, time <epoch_seconds>
|
|
||||||
- reboot
|
|
||||||
- ver
|
|
||||||
"""
|
|
||||||
require_connected()
|
require_connected()
|
||||||
|
|
||||||
# Get contact from database
|
|
||||||
contact = await _resolve_contact_or_404(public_key)
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
_require_repeater(contact)
|
require_server_capable_contact(contact)
|
||||||
|
return await send_contact_cli_command(
|
||||||
async with radio_manager.radio_operation(
|
contact,
|
||||||
"send_repeater_command",
|
request.command,
|
||||||
pause_polling=True,
|
operation_name="send_repeater_command",
|
||||||
suspend_auto_fetch=True,
|
)
|
||||||
) as mc:
|
|
||||||
# Add contact to radio with path from DB (non-fatal — contact may already be loaded)
|
|
||||||
logger.info("Adding repeater %s to radio", contact.public_key[:12])
|
|
||||||
await _ensure_on_radio(mc, contact)
|
|
||||||
await asyncio.sleep(1.0)
|
|
||||||
|
|
||||||
# Send the command
|
|
||||||
logger.info("Sending command to repeater %s: %s", contact.public_key[:12], request.command)
|
|
||||||
|
|
||||||
send_result = await mc.commands.send_cmd(contact.public_key, request.command)
|
|
||||||
|
|
||||||
if send_result.type == EventType.ERROR:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500, detail=f"Failed to send command: {send_result.payload}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Wait for response using validated fetch loop
|
|
||||||
response_event = await _fetch_repeater_response(mc, contact.public_key[:12])
|
|
||||||
|
|
||||||
if response_event is None:
|
|
||||||
logger.warning(
|
|
||||||
"No response from repeater %s for command: %s",
|
|
||||||
contact.public_key[:12],
|
|
||||||
request.command,
|
|
||||||
)
|
|
||||||
return CommandResponse(
|
|
||||||
command=request.command,
|
|
||||||
response="(no response - command may have been processed)",
|
|
||||||
)
|
|
||||||
|
|
||||||
# CONTACT_MSG_RECV payloads use sender_timestamp in meshcore.
|
|
||||||
response_text = _extract_response_text(response_event)
|
|
||||||
sender_timestamp = response_event.payload.get(
|
|
||||||
"sender_timestamp",
|
|
||||||
response_event.payload.get("timestamp"),
|
|
||||||
)
|
|
||||||
logger.info("Received response from %s: %s", contact.public_key[:12], response_text)
|
|
||||||
|
|
||||||
return CommandResponse(
|
|
||||||
command=request.command,
|
|
||||||
response=response_text,
|
|
||||||
sender_timestamp=sender_timestamp,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
|
from app.dependencies import require_connected
|
||||||
|
from app.models import (
|
||||||
|
CONTACT_TYPE_ROOM,
|
||||||
|
AclEntry,
|
||||||
|
LppSensor,
|
||||||
|
RepeaterAclResponse,
|
||||||
|
RepeaterLoginRequest,
|
||||||
|
RepeaterLoginResponse,
|
||||||
|
RepeaterLppTelemetryResponse,
|
||||||
|
RepeaterStatusResponse,
|
||||||
|
)
|
||||||
|
from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404
|
||||||
|
from app.routers.server_control import (
|
||||||
|
prepare_authenticated_contact_connection,
|
||||||
|
require_server_capable_contact,
|
||||||
|
)
|
||||||
|
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/contacts", tags=["rooms"])
|
||||||
|
|
||||||
|
|
||||||
|
def _require_room(contact) -> None:
|
||||||
|
require_server_capable_contact(contact, allowed_types=(CONTACT_TYPE_ROOM,))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{public_key}/room/login", response_model=RepeaterLoginResponse)
|
||||||
|
async def room_login(public_key: str, request: RepeaterLoginRequest) -> RepeaterLoginResponse:
|
||||||
|
"""Attempt room-server login and report whether auth was confirmed."""
|
||||||
|
require_connected()
|
||||||
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
|
_require_room(contact)
|
||||||
|
|
||||||
|
async with radio_manager.radio_operation(
|
||||||
|
"room_login",
|
||||||
|
pause_polling=True,
|
||||||
|
suspend_auto_fetch=True,
|
||||||
|
) as mc:
|
||||||
|
return await prepare_authenticated_contact_connection(
|
||||||
|
mc,
|
||||||
|
contact,
|
||||||
|
request.password,
|
||||||
|
label="room server",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{public_key}/room/status", response_model=RepeaterStatusResponse)
|
||||||
|
async def room_status(public_key: str) -> RepeaterStatusResponse:
|
||||||
|
"""Fetch status telemetry from a room server."""
|
||||||
|
require_connected()
|
||||||
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
|
_require_room(contact)
|
||||||
|
|
||||||
|
async with radio_manager.radio_operation(
|
||||||
|
"room_status", pause_polling=True, suspend_auto_fetch=True
|
||||||
|
) as mc:
|
||||||
|
await _ensure_on_radio(mc, contact)
|
||||||
|
status = await mc.commands.req_status_sync(contact.public_key, timeout=10, min_timeout=5)
|
||||||
|
|
||||||
|
if status is None:
|
||||||
|
raise HTTPException(status_code=504, detail="No status response from room server")
|
||||||
|
|
||||||
|
return RepeaterStatusResponse(
|
||||||
|
battery_volts=status.get("bat", 0) / 1000.0,
|
||||||
|
tx_queue_len=status.get("tx_queue_len", 0),
|
||||||
|
noise_floor_dbm=status.get("noise_floor", 0),
|
||||||
|
last_rssi_dbm=status.get("last_rssi", 0),
|
||||||
|
last_snr_db=status.get("last_snr", 0.0),
|
||||||
|
packets_received=status.get("nb_recv", 0),
|
||||||
|
packets_sent=status.get("nb_sent", 0),
|
||||||
|
airtime_seconds=status.get("airtime", 0),
|
||||||
|
rx_airtime_seconds=status.get("rx_airtime", 0),
|
||||||
|
uptime_seconds=status.get("uptime", 0),
|
||||||
|
sent_flood=status.get("sent_flood", 0),
|
||||||
|
sent_direct=status.get("sent_direct", 0),
|
||||||
|
recv_flood=status.get("recv_flood", 0),
|
||||||
|
recv_direct=status.get("recv_direct", 0),
|
||||||
|
flood_dups=status.get("flood_dups", 0),
|
||||||
|
direct_dups=status.get("direct_dups", 0),
|
||||||
|
full_events=status.get("full_evts", 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{public_key}/room/lpp-telemetry", response_model=RepeaterLppTelemetryResponse)
|
||||||
|
async def room_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
|
||||||
|
"""Fetch CayenneLPP telemetry from a room server."""
|
||||||
|
require_connected()
|
||||||
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
|
_require_room(contact)
|
||||||
|
|
||||||
|
async with radio_manager.radio_operation(
|
||||||
|
"room_lpp_telemetry", pause_polling=True, suspend_auto_fetch=True
|
||||||
|
) as mc:
|
||||||
|
await _ensure_on_radio(mc, contact)
|
||||||
|
telemetry = await mc.commands.req_telemetry_sync(
|
||||||
|
contact.public_key, timeout=10, min_timeout=5
|
||||||
|
)
|
||||||
|
|
||||||
|
if telemetry is None:
|
||||||
|
raise HTTPException(status_code=504, detail="No telemetry response from room server")
|
||||||
|
|
||||||
|
sensors = [
|
||||||
|
LppSensor(
|
||||||
|
channel=entry.get("channel", 0),
|
||||||
|
type_name=str(entry.get("type", "unknown")),
|
||||||
|
value=entry.get("value", 0),
|
||||||
|
)
|
||||||
|
for entry in telemetry
|
||||||
|
]
|
||||||
|
return RepeaterLppTelemetryResponse(sensors=sensors)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{public_key}/room/acl", response_model=RepeaterAclResponse)
|
||||||
|
async def room_acl(public_key: str) -> RepeaterAclResponse:
|
||||||
|
"""Fetch ACL entries from a room server."""
|
||||||
|
require_connected()
|
||||||
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
|
_require_room(contact)
|
||||||
|
|
||||||
|
async with radio_manager.radio_operation(
|
||||||
|
"room_acl", pause_polling=True, suspend_auto_fetch=True
|
||||||
|
) as mc:
|
||||||
|
await _ensure_on_radio(mc, contact)
|
||||||
|
acl_data = await mc.commands.req_acl_sync(contact.public_key, timeout=10, min_timeout=5)
|
||||||
|
|
||||||
|
acl_entries = []
|
||||||
|
if acl_data and isinstance(acl_data, list):
|
||||||
|
from app.repository import ContactRepository
|
||||||
|
from app.routers.repeaters import ACL_PERMISSION_NAMES
|
||||||
|
|
||||||
|
for entry in acl_data:
|
||||||
|
pubkey_prefix = entry.get("key", "")
|
||||||
|
perm = entry.get("perm", 0)
|
||||||
|
resolved_contact = await ContactRepository.get_by_key_prefix(pubkey_prefix)
|
||||||
|
acl_entries.append(
|
||||||
|
AclEntry(
|
||||||
|
pubkey_prefix=pubkey_prefix,
|
||||||
|
name=resolved_contact.name if resolved_contact else None,
|
||||||
|
permission=perm,
|
||||||
|
permission_name=ACL_PERMISSION_NAMES.get(perm, f"Unknown({perm})"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return RepeaterAclResponse(acl=acl_entries)
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from meshcore import EventType
|
||||||
|
|
||||||
|
from app.models import (
|
||||||
|
CONTACT_TYPE_REPEATER,
|
||||||
|
CONTACT_TYPE_ROOM,
|
||||||
|
CommandResponse,
|
||||||
|
Contact,
|
||||||
|
RepeaterLoginResponse,
|
||||||
|
)
|
||||||
|
from app.radio_sync import _store_pending_channel_message, _store_pending_direct_message
|
||||||
|
from app.routers.contacts import _ensure_on_radio
|
||||||
|
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from meshcore.events import Event
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SERVER_LOGIN_RESPONSE_TIMEOUT_SECONDS = 5.0
|
||||||
|
|
||||||
|
|
||||||
|
def _monotonic() -> float:
|
||||||
|
"""Wrapper around time.monotonic() for testability."""
|
||||||
|
return time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
|
def get_server_contact_label(contact: Contact) -> str:
|
||||||
|
"""Return a user-facing label for server-capable contacts."""
|
||||||
|
if contact.type == CONTACT_TYPE_REPEATER:
|
||||||
|
return "repeater"
|
||||||
|
if contact.type == CONTACT_TYPE_ROOM:
|
||||||
|
return "room server"
|
||||||
|
return "server"
|
||||||
|
|
||||||
|
|
||||||
|
def require_server_capable_contact(
|
||||||
|
contact: Contact,
|
||||||
|
*,
|
||||||
|
allowed_types: tuple[int, ...] = (CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM),
|
||||||
|
) -> None:
|
||||||
|
"""Raise 400 if the contact does not support server control/login features."""
|
||||||
|
if contact.type not in allowed_types:
|
||||||
|
expected = ", ".join(str(value) for value in allowed_types)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Contact is not a supported server contact (type={contact.type}, expected one of {expected})",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _login_rejected_message(label: str) -> str:
|
||||||
|
return (
|
||||||
|
f"The {label} replied but did not confirm this login. "
|
||||||
|
f"Existing access may still allow some {label} operations, but privileged actions may fail."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _login_send_failed_message(label: str) -> str:
|
||||||
|
return (
|
||||||
|
f"The login request could not be sent to the {label}. "
|
||||||
|
f"You're free to attempt interaction; try logging in again if authenticated actions fail."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _login_timeout_message(label: str) -> str:
|
||||||
|
return (
|
||||||
|
f"No login confirmation was heard from the {label}. "
|
||||||
|
"That can mean the password was wrong or the reply was missed in transit. "
|
||||||
|
"You're free to attempt interaction; try logging in again if authenticated actions fail."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_response_text(event) -> str:
|
||||||
|
"""Extract text from a CLI response event, stripping the firmware '> ' prefix."""
|
||||||
|
text = event.payload.get("text", str(event.payload))
|
||||||
|
if text.startswith("> "):
|
||||||
|
text = text[2:]
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_contact_cli_response(
|
||||||
|
mc,
|
||||||
|
target_pubkey_prefix: str,
|
||||||
|
timeout: float = 20.0,
|
||||||
|
) -> "Event | None":
|
||||||
|
"""Fetch a CLI response from a specific contact via a validated get_msg() loop."""
|
||||||
|
deadline = _monotonic() + timeout
|
||||||
|
|
||||||
|
while _monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
result = await mc.commands.get_msg(timeout=2.0)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
continue
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("get_msg() exception: %s", exc)
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if result.type == EventType.NO_MORE_MSGS:
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if result.type == EventType.ERROR:
|
||||||
|
logger.debug("get_msg() error: %s", result.payload)
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if result.type == EventType.CONTACT_MSG_RECV:
|
||||||
|
msg_prefix = result.payload.get("pubkey_prefix", "")
|
||||||
|
txt_type = result.payload.get("txt_type", 0)
|
||||||
|
if msg_prefix == target_pubkey_prefix and txt_type == 1:
|
||||||
|
return result
|
||||||
|
logger.debug(
|
||||||
|
"Storing non-target DM (from=%s, txt_type=%d) consumed while waiting for %s",
|
||||||
|
msg_prefix,
|
||||||
|
txt_type,
|
||||||
|
target_pubkey_prefix,
|
||||||
|
)
|
||||||
|
await _store_pending_direct_message(result)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if result.type == EventType.CHANNEL_MSG_RECV:
|
||||||
|
logger.debug(
|
||||||
|
"Storing channel message (channel_idx=%s) consumed during CLI fetch",
|
||||||
|
result.payload.get("channel_idx"),
|
||||||
|
)
|
||||||
|
await _store_pending_channel_message(mc, result.payload)
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.debug("Unexpected event type %s during CLI fetch, skipping", result.type)
|
||||||
|
|
||||||
|
logger.warning("No CLI response from contact %s within %.1fs", target_pubkey_prefix, timeout)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def prepare_authenticated_contact_connection(
|
||||||
|
mc,
|
||||||
|
contact: Contact,
|
||||||
|
password: str,
|
||||||
|
*,
|
||||||
|
label: str | None = None,
|
||||||
|
response_timeout: float = SERVER_LOGIN_RESPONSE_TIMEOUT_SECONDS,
|
||||||
|
) -> RepeaterLoginResponse:
|
||||||
|
"""Prepare connection to a server-capable contact by adding it to the radio and logging in."""
|
||||||
|
pubkey_prefix = contact.public_key[:12].lower()
|
||||||
|
contact_label = label or get_server_contact_label(contact)
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
login_future = loop.create_future()
|
||||||
|
|
||||||
|
def _resolve_login(event_type: EventType, message: str | None = None) -> None:
|
||||||
|
if login_future.done():
|
||||||
|
return
|
||||||
|
login_future.set_result(
|
||||||
|
RepeaterLoginResponse(
|
||||||
|
status="ok" if event_type == EventType.LOGIN_SUCCESS else "error",
|
||||||
|
authenticated=event_type == EventType.LOGIN_SUCCESS,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
success_subscription = mc.subscribe(
|
||||||
|
EventType.LOGIN_SUCCESS,
|
||||||
|
lambda _event: _resolve_login(EventType.LOGIN_SUCCESS),
|
||||||
|
attribute_filters={"pubkey_prefix": pubkey_prefix},
|
||||||
|
)
|
||||||
|
failed_subscription = mc.subscribe(
|
||||||
|
EventType.LOGIN_FAILED,
|
||||||
|
lambda _event: _resolve_login(
|
||||||
|
EventType.LOGIN_FAILED,
|
||||||
|
_login_rejected_message(contact_label),
|
||||||
|
),
|
||||||
|
attribute_filters={"pubkey_prefix": pubkey_prefix},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info("Adding %s %s to radio", contact_label, contact.public_key[:12])
|
||||||
|
await _ensure_on_radio(mc, contact)
|
||||||
|
|
||||||
|
logger.info("Sending login to %s %s", contact_label, contact.public_key[:12])
|
||||||
|
login_result = await mc.commands.send_login(contact.public_key, password)
|
||||||
|
|
||||||
|
if login_result.type == EventType.ERROR:
|
||||||
|
return RepeaterLoginResponse(
|
||||||
|
status="error",
|
||||||
|
authenticated=False,
|
||||||
|
message=f"{_login_send_failed_message(contact_label)} ({login_result.payload})",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await asyncio.wait_for(
|
||||||
|
login_future,
|
||||||
|
timeout=response_timeout,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning(
|
||||||
|
"No login response from %s %s within %.1fs",
|
||||||
|
contact_label,
|
||||||
|
contact.public_key[:12],
|
||||||
|
response_timeout,
|
||||||
|
)
|
||||||
|
return RepeaterLoginResponse(
|
||||||
|
status="timeout",
|
||||||
|
authenticated=False,
|
||||||
|
message=_login_timeout_message(contact_label),
|
||||||
|
)
|
||||||
|
except HTTPException as exc:
|
||||||
|
logger.warning(
|
||||||
|
"%s login setup failed for %s: %s",
|
||||||
|
contact_label.capitalize(),
|
||||||
|
contact.public_key[:12],
|
||||||
|
exc.detail,
|
||||||
|
)
|
||||||
|
return RepeaterLoginResponse(
|
||||||
|
status="error",
|
||||||
|
authenticated=False,
|
||||||
|
message=f"{_login_send_failed_message(contact_label)} ({exc.detail})",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
success_subscription.unsubscribe()
|
||||||
|
failed_subscription.unsubscribe()
|
||||||
|
|
||||||
|
|
||||||
|
async def batch_cli_fetch(
|
||||||
|
contact: Contact,
|
||||||
|
operation_name: str,
|
||||||
|
commands: list[tuple[str, str]],
|
||||||
|
) -> dict[str, str | None]:
|
||||||
|
"""Send a batch of CLI commands to a server-capable contact and collect responses."""
|
||||||
|
results: dict[str, str | None] = {field: None for _, field in commands}
|
||||||
|
|
||||||
|
async with radio_manager.radio_operation(
|
||||||
|
operation_name,
|
||||||
|
pause_polling=True,
|
||||||
|
suspend_auto_fetch=True,
|
||||||
|
) as mc:
|
||||||
|
await _ensure_on_radio(mc, contact)
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
|
||||||
|
for index, (cmd, field) in enumerate(commands):
|
||||||
|
if index > 0:
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
|
||||||
|
send_result = await mc.commands.send_cmd(contact.public_key, cmd)
|
||||||
|
if send_result.type == EventType.ERROR:
|
||||||
|
logger.debug("Command '%s' send error: %s", cmd, send_result.payload)
|
||||||
|
continue
|
||||||
|
|
||||||
|
response_event = await fetch_contact_cli_response(
|
||||||
|
mc, contact.public_key[:12], timeout=10.0
|
||||||
|
)
|
||||||
|
if response_event is not None:
|
||||||
|
results[field] = extract_response_text(response_event)
|
||||||
|
else:
|
||||||
|
logger.warning("No response for command '%s' (%s)", cmd, field)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
async def send_contact_cli_command(
|
||||||
|
contact: Contact,
|
||||||
|
command: str,
|
||||||
|
*,
|
||||||
|
operation_name: str,
|
||||||
|
) -> CommandResponse:
|
||||||
|
"""Send a CLI command to a server-capable contact and return the text response."""
|
||||||
|
label = get_server_contact_label(contact)
|
||||||
|
|
||||||
|
async with radio_manager.radio_operation(
|
||||||
|
operation_name,
|
||||||
|
pause_polling=True,
|
||||||
|
suspend_auto_fetch=True,
|
||||||
|
) as mc:
|
||||||
|
logger.info("Adding %s %s to radio", label, contact.public_key[:12])
|
||||||
|
await _ensure_on_radio(mc, contact)
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
|
||||||
|
logger.info("Sending command to %s %s: %s", label, contact.public_key[:12], command)
|
||||||
|
send_result = await mc.commands.send_cmd(contact.public_key, command)
|
||||||
|
|
||||||
|
if send_result.type == EventType.ERROR:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to send command: {send_result.payload}"
|
||||||
|
)
|
||||||
|
|
||||||
|
response_event = await fetch_contact_cli_response(mc, contact.public_key[:12])
|
||||||
|
|
||||||
|
if response_event is None:
|
||||||
|
logger.warning(
|
||||||
|
"No response from %s %s for command: %s",
|
||||||
|
label,
|
||||||
|
contact.public_key[:12],
|
||||||
|
command,
|
||||||
|
)
|
||||||
|
return CommandResponse(
|
||||||
|
command=command,
|
||||||
|
response="(no response - command may have been processed)",
|
||||||
|
)
|
||||||
|
|
||||||
|
response_text = extract_response_text(response_event)
|
||||||
|
sender_timestamp = response_event.payload.get(
|
||||||
|
"sender_timestamp",
|
||||||
|
response_event.payload.get("timestamp"),
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Received response from %s %s: %s",
|
||||||
|
label,
|
||||||
|
contact.public_key[:12],
|
||||||
|
response_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
return CommandResponse(
|
||||||
|
command=command,
|
||||||
|
response=response_text,
|
||||||
|
sender_timestamp=sender_timestamp,
|
||||||
|
)
|
||||||
@@ -48,6 +48,13 @@ 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"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BlockKeyRequest(BaseModel):
|
class BlockKeyRequest(BaseModel):
|
||||||
@@ -122,6 +129,12 @@ 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))
|
||||||
|
|
||||||
# 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:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from fastapi import APIRouter
|
|||||||
|
|
||||||
from app.models import StatisticsResponse
|
from app.models import StatisticsResponse
|
||||||
from app.repository import StatisticsRepository
|
from app.repository import StatisticsRepository
|
||||||
|
from app.services.radio_noise_floor import get_noise_floor_history
|
||||||
|
|
||||||
router = APIRouter(prefix="/statistics", tags=["statistics"])
|
router = APIRouter(prefix="/statistics", tags=["statistics"])
|
||||||
|
|
||||||
@@ -9,4 +10,5 @@ router = APIRouter(prefix="/statistics", tags=["statistics"])
|
|||||||
@router.get("", response_model=StatisticsResponse)
|
@router.get("", response_model=StatisticsResponse)
|
||||||
async def get_statistics() -> StatisticsResponse:
|
async def get_statistics() -> StatisticsResponse:
|
||||||
data = await StatisticsRepository.get_all()
|
data = await StatisticsRepository.get_all()
|
||||||
|
data["noise_floor_24h"] = await get_noise_floor_history()
|
||||||
return StatisticsResponse(**data)
|
return StatisticsResponse(**data)
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""Shared direct-message ACK application logic."""
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.services import dm_ack_tracker
|
||||||
|
from app.services.messages import increment_ack_and_broadcast
|
||||||
|
|
||||||
|
BroadcastFn = Callable[..., Any]
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_dm_ack_code(ack_code: str, *, broadcast_fn: BroadcastFn) -> bool:
|
||||||
|
"""Apply a DM ACK code using the shared pending/buffered state machine.
|
||||||
|
|
||||||
|
Returns True when the ACK matched a pending message, False when it was buffered.
|
||||||
|
"""
|
||||||
|
dm_ack_tracker.cleanup_expired_acks()
|
||||||
|
|
||||||
|
message_id = dm_ack_tracker.pop_pending_ack(ack_code)
|
||||||
|
if message_id is None:
|
||||||
|
dm_ack_tracker.buffer_unmatched_ack(ack_code)
|
||||||
|
return False
|
||||||
|
|
||||||
|
dm_ack_tracker.clear_pending_acks_for_message(message_id)
|
||||||
|
await increment_ack_and_broadcast(message_id=message_id, broadcast_fn=broadcast_fn)
|
||||||
|
return True
|
||||||
@@ -5,7 +5,7 @@ from collections.abc import Callable
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from app.models import CONTACT_TYPE_REPEATER, Contact, ContactUpsert, Message
|
from app.models import CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM, Contact, ContactUpsert, Message
|
||||||
from app.repository import (
|
from app.repository import (
|
||||||
AmbiguousPublicKeyPrefixError,
|
AmbiguousPublicKeyPrefixError,
|
||||||
ContactRepository,
|
ContactRepository,
|
||||||
@@ -92,7 +92,6 @@ async def resolve_fallback_direct_message_context(
|
|||||||
last_contacted=received_at,
|
last_contacted=received_at,
|
||||||
first_seen=received_at,
|
first_seen=received_at,
|
||||||
on_radio=False,
|
on_radio=False,
|
||||||
out_path_hash_mode=-1,
|
|
||||||
)
|
)
|
||||||
await contact_repository.upsert(placeholder_upsert)
|
await contact_repository.upsert(placeholder_upsert)
|
||||||
contact = await contact_repository.get_by_key(normalized_sender)
|
contact = await contact_repository.get_by_key(normalized_sender)
|
||||||
@@ -107,6 +106,35 @@ async def resolve_fallback_direct_message_context(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_direct_message_sender_metadata(
|
||||||
|
*,
|
||||||
|
sender_public_key: str,
|
||||||
|
received_at: int,
|
||||||
|
broadcast_fn: BroadcastFn,
|
||||||
|
contact_repository=ContactRepository,
|
||||||
|
log: logging.Logger | None = None,
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
"""Resolve sender attribution for direct-message variants such as room-server posts."""
|
||||||
|
normalized_sender = sender_public_key.lower()
|
||||||
|
|
||||||
|
try:
|
||||||
|
contact = await contact_repository.get_by_key_or_prefix(normalized_sender)
|
||||||
|
except AmbiguousPublicKeyPrefixError:
|
||||||
|
(log or logger).warning(
|
||||||
|
"Sender prefix '%s' is ambiguous; preserving prefix-only attribution",
|
||||||
|
sender_public_key,
|
||||||
|
)
|
||||||
|
contact = None
|
||||||
|
|
||||||
|
if contact is not None:
|
||||||
|
await claim_prefix_messages_for_contact(
|
||||||
|
public_key=contact.public_key.lower(), log=log or logger
|
||||||
|
)
|
||||||
|
return contact.name, contact.public_key.lower()
|
||||||
|
|
||||||
|
return None, normalized_sender or None
|
||||||
|
|
||||||
|
|
||||||
async def _store_direct_message(
|
async def _store_direct_message(
|
||||||
*,
|
*,
|
||||||
packet_id: int | None,
|
packet_id: int | None,
|
||||||
@@ -210,6 +238,7 @@ async def _store_direct_message(
|
|||||||
sender_key=sender_key,
|
sender_key=sender_key,
|
||||||
outgoing=outgoing,
|
outgoing=outgoing,
|
||||||
sender_name=sender_name,
|
sender_name=sender_name,
|
||||||
|
packet_id=packet_id,
|
||||||
)
|
)
|
||||||
broadcast_message(message=message, broadcast_fn=broadcast_fn, realtime=realtime)
|
broadcast_message(message=message, broadcast_fn=broadcast_fn, realtime=realtime)
|
||||||
|
|
||||||
@@ -238,8 +267,19 @@ async def ingest_decrypted_direct_message(
|
|||||||
contact_repository=ContactRepository,
|
contact_repository=ContactRepository,
|
||||||
) -> Message | None:
|
) -> Message | None:
|
||||||
conversation_key = their_public_key.lower()
|
conversation_key = their_public_key.lower()
|
||||||
|
|
||||||
|
if not outgoing and decrypted.txt_type == 1:
|
||||||
|
logger.debug(
|
||||||
|
"Skipping CLI response from %s (txt_type=1): %s",
|
||||||
|
conversation_key[:12],
|
||||||
|
(decrypted.message or "")[:50],
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
contact = await contact_repository.get_by_key(conversation_key)
|
contact = await contact_repository.get_by_key(conversation_key)
|
||||||
sender_name: str | None = None
|
sender_name: str | None = None
|
||||||
|
sender_key: str | None = conversation_key if not outgoing else None
|
||||||
|
signature: str | None = None
|
||||||
if contact is not None:
|
if contact is not None:
|
||||||
conversation_key, skip_storage = await _prepare_resolved_contact(contact, log=logger)
|
conversation_key, skip_storage = await _prepare_resolved_contact(contact, log=logger)
|
||||||
if skip_storage:
|
if skip_storage:
|
||||||
@@ -250,7 +290,17 @@ async def ingest_decrypted_direct_message(
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
if not outgoing:
|
if not outgoing:
|
||||||
sender_name = contact.name
|
if contact.type == CONTACT_TYPE_ROOM and decrypted.signed_sender_prefix:
|
||||||
|
sender_name, sender_key = await resolve_direct_message_sender_metadata(
|
||||||
|
sender_public_key=decrypted.signed_sender_prefix,
|
||||||
|
received_at=received_at or int(time.time()),
|
||||||
|
broadcast_fn=broadcast_fn,
|
||||||
|
contact_repository=contact_repository,
|
||||||
|
log=logger,
|
||||||
|
)
|
||||||
|
signature = decrypted.signed_sender_prefix
|
||||||
|
else:
|
||||||
|
sender_name = contact.name
|
||||||
|
|
||||||
received = received_at or int(time.time())
|
received = received_at or int(time.time())
|
||||||
message = await _store_direct_message(
|
message = await _store_direct_message(
|
||||||
@@ -262,10 +312,10 @@ async def ingest_decrypted_direct_message(
|
|||||||
path=path,
|
path=path,
|
||||||
path_len=path_len,
|
path_len=path_len,
|
||||||
outgoing=outgoing,
|
outgoing=outgoing,
|
||||||
txt_type=0,
|
txt_type=decrypted.txt_type,
|
||||||
signature=None,
|
signature=signature,
|
||||||
sender_name=sender_name,
|
sender_name=sender_name,
|
||||||
sender_key=conversation_key if not outgoing else None,
|
sender_key=sender_key,
|
||||||
realtime=realtime,
|
realtime=realtime,
|
||||||
broadcast_fn=broadcast_fn,
|
broadcast_fn=broadcast_fn,
|
||||||
update_last_contacted_key=conversation_key,
|
update_last_contacted_key=conversation_key,
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ from app.region_scope import normalize_region_scope
|
|||||||
from app.repository import AppSettingsRepository, ContactRepository, MessageRepository
|
from app.repository import AppSettingsRepository, 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 (
|
||||||
build_message_model,
|
broadcast_message,
|
||||||
|
build_stored_outgoing_channel_message,
|
||||||
create_outgoing_channel_message,
|
create_outgoing_channel_message,
|
||||||
create_outgoing_direct_message,
|
create_outgoing_direct_message,
|
||||||
increment_ack_and_broadcast,
|
increment_ack_and_broadcast,
|
||||||
@@ -586,6 +587,23 @@ async def send_channel_message_to_channel(
|
|||||||
requested_timestamp=sent_at,
|
requested_timestamp=sent_at,
|
||||||
)
|
)
|
||||||
timestamp_bytes = sender_timestamp.to_bytes(4, "little")
|
timestamp_bytes = sender_timestamp.to_bytes(4, "little")
|
||||||
|
outgoing_message = await create_outgoing_channel_message(
|
||||||
|
conversation_key=channel_key_upper,
|
||||||
|
text=text_with_sender,
|
||||||
|
sender_timestamp=sender_timestamp,
|
||||||
|
received_at=sent_at,
|
||||||
|
sender_name=radio_name or None,
|
||||||
|
sender_key=our_public_key,
|
||||||
|
channel_name=channel.name,
|
||||||
|
broadcast_fn=broadcast_fn,
|
||||||
|
broadcast=False,
|
||||||
|
message_repository=message_repository,
|
||||||
|
)
|
||||||
|
if outgoing_message is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="Failed to store outgoing message - unexpected duplicate",
|
||||||
|
)
|
||||||
|
|
||||||
result = await send_channel_message_with_effective_scope(
|
result = await send_channel_message_with_effective_scope(
|
||||||
mc=mc,
|
mc=mc,
|
||||||
@@ -611,23 +629,11 @@ async def send_channel_message_to_channel(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail=f"Failed to send message: {result.payload}"
|
status_code=500, detail=f"Failed to send message: {result.payload}"
|
||||||
)
|
)
|
||||||
|
except Exception:
|
||||||
outgoing_message = await create_outgoing_channel_message(
|
if outgoing_message is not None:
|
||||||
conversation_key=channel_key_upper,
|
await message_repository.delete_by_id(outgoing_message.id)
|
||||||
text=text_with_sender,
|
outgoing_message = None
|
||||||
sender_timestamp=sender_timestamp,
|
raise
|
||||||
received_at=sent_at,
|
|
||||||
sender_name=radio_name or None,
|
|
||||||
sender_key=our_public_key,
|
|
||||||
channel_name=channel.name,
|
|
||||||
broadcast_fn=broadcast_fn,
|
|
||||||
message_repository=message_repository,
|
|
||||||
)
|
|
||||||
if outgoing_message is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail="Failed to store outgoing message - unexpected duplicate",
|
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
if sender_timestamp is not None:
|
if sender_timestamp is not None:
|
||||||
await release_outgoing_sender_timestamp(
|
await release_outgoing_sender_timestamp(
|
||||||
@@ -640,22 +646,19 @@ async def send_channel_message_to_channel(
|
|||||||
if sent_at is None or sender_timestamp is None or outgoing_message is None:
|
if sent_at is None or sender_timestamp is None or outgoing_message is None:
|
||||||
raise HTTPException(status_code=500, detail="Failed to store outgoing message")
|
raise HTTPException(status_code=500, detail="Failed to store outgoing message")
|
||||||
|
|
||||||
message_id = outgoing_message.id
|
outgoing_message = await build_stored_outgoing_channel_message(
|
||||||
acked_count, paths = await message_repository.get_ack_and_paths(message_id)
|
message_id=outgoing_message.id,
|
||||||
return build_message_model(
|
|
||||||
message_id=message_id,
|
|
||||||
msg_type="CHAN",
|
|
||||||
conversation_key=channel_key_upper,
|
conversation_key=channel_key_upper,
|
||||||
text=text_with_sender,
|
text=text_with_sender,
|
||||||
sender_timestamp=sender_timestamp,
|
sender_timestamp=sender_timestamp,
|
||||||
received_at=sent_at,
|
received_at=sent_at,
|
||||||
paths=paths,
|
|
||||||
outgoing=True,
|
|
||||||
acked=acked_count,
|
|
||||||
sender_name=radio_name or None,
|
sender_name=radio_name or None,
|
||||||
sender_key=our_public_key,
|
sender_key=our_public_key,
|
||||||
channel_name=channel.name,
|
channel_name=channel.name,
|
||||||
|
message_repository=message_repository,
|
||||||
)
|
)
|
||||||
|
broadcast_message(message=outgoing_message, broadcast_fn=broadcast_fn)
|
||||||
|
return outgoing_message
|
||||||
|
|
||||||
|
|
||||||
async def resend_channel_message_record(
|
async def resend_channel_message_record(
|
||||||
@@ -705,6 +708,23 @@ async def resend_channel_message_record(
|
|||||||
requested_timestamp=sent_at,
|
requested_timestamp=sent_at,
|
||||||
)
|
)
|
||||||
timestamp_bytes = sender_timestamp.to_bytes(4, "little")
|
timestamp_bytes = sender_timestamp.to_bytes(4, "little")
|
||||||
|
new_message = await create_outgoing_channel_message(
|
||||||
|
conversation_key=message.conversation_key,
|
||||||
|
text=message.text,
|
||||||
|
sender_timestamp=sender_timestamp,
|
||||||
|
received_at=sent_at,
|
||||||
|
sender_name=radio_name or None,
|
||||||
|
sender_key=resend_public_key,
|
||||||
|
channel_name=channel.name,
|
||||||
|
broadcast_fn=broadcast_fn,
|
||||||
|
broadcast=False,
|
||||||
|
message_repository=message_repository,
|
||||||
|
)
|
||||||
|
if new_message is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="Failed to store resent message - unexpected duplicate",
|
||||||
|
)
|
||||||
|
|
||||||
result = await send_channel_message_with_effective_scope(
|
result = await send_channel_message_with_effective_scope(
|
||||||
mc=mc,
|
mc=mc,
|
||||||
@@ -729,26 +749,11 @@ async def resend_channel_message_record(
|
|||||||
status_code=500,
|
status_code=500,
|
||||||
detail=f"Failed to resend message: {result.payload}",
|
detail=f"Failed to resend message: {result.payload}",
|
||||||
)
|
)
|
||||||
|
except Exception:
|
||||||
if new_timestamp:
|
if new_message is not None:
|
||||||
if sent_at is None:
|
await message_repository.delete_by_id(new_message.id)
|
||||||
raise HTTPException(status_code=500, detail="Failed to assign resend timestamp")
|
new_message = None
|
||||||
new_message = await create_outgoing_channel_message(
|
raise
|
||||||
conversation_key=message.conversation_key,
|
|
||||||
text=message.text,
|
|
||||||
sender_timestamp=sender_timestamp,
|
|
||||||
received_at=sent_at,
|
|
||||||
sender_name=radio_name or None,
|
|
||||||
sender_key=resend_public_key,
|
|
||||||
channel_name=channel.name,
|
|
||||||
broadcast_fn=broadcast_fn,
|
|
||||||
message_repository=message_repository,
|
|
||||||
)
|
|
||||||
if new_message is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail="Failed to store resent message - unexpected duplicate",
|
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
if new_timestamp and sent_at is not None:
|
if new_timestamp and sent_at is not None:
|
||||||
await release_outgoing_sender_timestamp(
|
await release_outgoing_sender_timestamp(
|
||||||
@@ -762,6 +767,19 @@ async def resend_channel_message_record(
|
|||||||
if sent_at is None or new_message is None:
|
if sent_at is None or new_message is None:
|
||||||
raise HTTPException(status_code=500, detail="Failed to assign resend timestamp")
|
raise HTTPException(status_code=500, detail="Failed to assign resend timestamp")
|
||||||
|
|
||||||
|
new_message = await build_stored_outgoing_channel_message(
|
||||||
|
message_id=new_message.id,
|
||||||
|
conversation_key=message.conversation_key,
|
||||||
|
text=message.text,
|
||||||
|
sender_timestamp=sender_timestamp,
|
||||||
|
received_at=sent_at,
|
||||||
|
sender_name=radio_name or None,
|
||||||
|
sender_key=resend_public_key,
|
||||||
|
channel_name=channel.name,
|
||||||
|
message_repository=message_repository,
|
||||||
|
)
|
||||||
|
broadcast_message(message=new_message, broadcast_fn=broadcast_fn)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Resent channel message %d as new message %d to %s",
|
"Resent channel message %d as new message %d to %s",
|
||||||
message.id,
|
message.id,
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ def build_message_model(
|
|||||||
acked: int = 0,
|
acked: int = 0,
|
||||||
sender_name: str | None = None,
|
sender_name: str | None = None,
|
||||||
channel_name: str | None = None,
|
channel_name: str | None = None,
|
||||||
|
packet_id: int | None = None,
|
||||||
) -> Message:
|
) -> Message:
|
||||||
"""Build a Message model with the canonical backend payload shape."""
|
"""Build a Message model with the canonical backend payload shape."""
|
||||||
return Message(
|
return Message(
|
||||||
@@ -79,6 +80,7 @@ def build_message_model(
|
|||||||
acked=acked,
|
acked=acked,
|
||||||
sender_name=sender_name,
|
sender_name=sender_name,
|
||||||
channel_name=channel_name,
|
channel_name=channel_name,
|
||||||
|
packet_id=packet_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -96,11 +98,42 @@ def broadcast_message(
|
|||||||
broadcast_fn("message", payload, realtime=realtime)
|
broadcast_fn("message", payload, realtime=realtime)
|
||||||
|
|
||||||
|
|
||||||
|
async def build_stored_outgoing_channel_message(
|
||||||
|
*,
|
||||||
|
message_id: int,
|
||||||
|
conversation_key: str,
|
||||||
|
text: str,
|
||||||
|
sender_timestamp: int,
|
||||||
|
received_at: int,
|
||||||
|
sender_name: str | None,
|
||||||
|
sender_key: str | None,
|
||||||
|
channel_name: str | None,
|
||||||
|
message_repository=MessageRepository,
|
||||||
|
) -> Message:
|
||||||
|
"""Build the current payload for a stored outgoing channel message."""
|
||||||
|
acked_count, paths = await message_repository.get_ack_and_paths(message_id)
|
||||||
|
return build_message_model(
|
||||||
|
message_id=message_id,
|
||||||
|
msg_type="CHAN",
|
||||||
|
conversation_key=conversation_key,
|
||||||
|
text=text,
|
||||||
|
sender_timestamp=sender_timestamp,
|
||||||
|
received_at=received_at,
|
||||||
|
paths=paths,
|
||||||
|
outgoing=True,
|
||||||
|
acked=acked_count,
|
||||||
|
sender_name=sender_name,
|
||||||
|
sender_key=sender_key,
|
||||||
|
channel_name=channel_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def broadcast_message_acked(
|
def broadcast_message_acked(
|
||||||
*,
|
*,
|
||||||
message_id: int,
|
message_id: int,
|
||||||
ack_count: int,
|
ack_count: int,
|
||||||
paths: list[MessagePath] | None,
|
paths: list[MessagePath] | None,
|
||||||
|
packet_id: int | None,
|
||||||
broadcast_fn: BroadcastFn,
|
broadcast_fn: BroadcastFn,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Broadcast a message_acked payload."""
|
"""Broadcast a message_acked payload."""
|
||||||
@@ -110,6 +143,7 @@ def broadcast_message_acked(
|
|||||||
"message_id": message_id,
|
"message_id": message_id,
|
||||||
"ack_count": ack_count,
|
"ack_count": ack_count,
|
||||||
"paths": [path.model_dump() for path in paths] if paths else [],
|
"paths": [path.model_dump() for path in paths] if paths else [],
|
||||||
|
"packet_id": packet_id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -152,11 +186,16 @@ async def reconcile_duplicate_message(
|
|||||||
else:
|
else:
|
||||||
ack_count = existing_msg.acked
|
ack_count = existing_msg.acked
|
||||||
|
|
||||||
|
representative_packet_id = (
|
||||||
|
existing_msg.packet_id if existing_msg.packet_id is not None else packet_id
|
||||||
|
)
|
||||||
|
|
||||||
if existing_msg.outgoing or path is not None:
|
if existing_msg.outgoing or path is not None:
|
||||||
broadcast_message_acked(
|
broadcast_message_acked(
|
||||||
message_id=existing_msg.id,
|
message_id=existing_msg.id,
|
||||||
ack_count=ack_count,
|
ack_count=ack_count,
|
||||||
paths=paths,
|
paths=paths,
|
||||||
|
packet_id=representative_packet_id,
|
||||||
broadcast_fn=broadcast_fn,
|
broadcast_fn=broadcast_fn,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -277,6 +316,7 @@ async def create_message_from_decrypted(
|
|||||||
sender_name=sender,
|
sender_name=sender,
|
||||||
sender_key=resolved_sender_key,
|
sender_key=resolved_sender_key,
|
||||||
channel_name=channel_name,
|
channel_name=channel_name,
|
||||||
|
packet_id=packet_id,
|
||||||
),
|
),
|
||||||
broadcast_fn=broadcast_fn,
|
broadcast_fn=broadcast_fn,
|
||||||
realtime=realtime,
|
realtime=realtime,
|
||||||
@@ -428,6 +468,7 @@ async def create_outgoing_channel_message(
|
|||||||
sender_key: str | None,
|
sender_key: str | None,
|
||||||
channel_name: str | None,
|
channel_name: str | None,
|
||||||
broadcast_fn: BroadcastFn,
|
broadcast_fn: BroadcastFn,
|
||||||
|
broadcast: bool = True,
|
||||||
message_repository=MessageRepository,
|
message_repository=MessageRepository,
|
||||||
) -> Message | None:
|
) -> Message | None:
|
||||||
"""Store and broadcast an outgoing channel message."""
|
"""Store and broadcast an outgoing channel message."""
|
||||||
@@ -444,18 +485,17 @@ async def create_outgoing_channel_message(
|
|||||||
if msg_id is None:
|
if msg_id is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
message = build_message_model(
|
message = await build_stored_outgoing_channel_message(
|
||||||
message_id=msg_id,
|
message_id=msg_id,
|
||||||
msg_type="CHAN",
|
|
||||||
conversation_key=conversation_key,
|
conversation_key=conversation_key,
|
||||||
text=text,
|
text=text,
|
||||||
sender_timestamp=sender_timestamp,
|
sender_timestamp=sender_timestamp,
|
||||||
received_at=received_at,
|
received_at=received_at,
|
||||||
outgoing=True,
|
|
||||||
acked=0,
|
|
||||||
sender_name=sender_name,
|
sender_name=sender_name,
|
||||||
sender_key=sender_key,
|
sender_key=sender_key,
|
||||||
channel_name=channel_name,
|
channel_name=channel_name,
|
||||||
|
message_repository=message_repository,
|
||||||
)
|
)
|
||||||
broadcast_message(message=message, broadcast_fn=broadcast_fn)
|
if broadcast:
|
||||||
|
broadcast_message(message=message, broadcast_fn=broadcast_fn)
|
||||||
return message
|
return message
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ async def apply_radio_config_update(
|
|||||||
f"Failed to set advert location policy: {result.payload}"
|
f"Failed to set advert location policy: {result.payload}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if update.multi_acks_enabled is not None:
|
||||||
|
multi_acks = 1 if update.multi_acks_enabled else 0
|
||||||
|
logger.info("Setting multi ACKs to %d", multi_acks)
|
||||||
|
result = await mc.commands.set_multi_acks(multi_acks)
|
||||||
|
if result is not None and result.type == EventType.ERROR:
|
||||||
|
raise RadioCommandRejectedError(f"Failed to set multi ACKs: {result.payload}")
|
||||||
|
|
||||||
if update.name is not None:
|
if update.name is not None:
|
||||||
logger.info("Setting radio name to %s", update.name)
|
logger.info("Setting radio name to %s", update.name)
|
||||||
await mc.commands.set_name(update.name)
|
await mc.commands.set_name(update.name)
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""In-memory local-radio noise floor history sampling."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
from meshcore import EventType
|
||||||
|
|
||||||
|
from app.radio import RadioDisconnectedError, RadioOperationBusyError
|
||||||
|
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
NOISE_FLOOR_SAMPLE_INTERVAL_SECONDS = 300
|
||||||
|
NOISE_FLOOR_WINDOW_SECONDS = 24 * 60 * 60
|
||||||
|
MAX_NOISE_FLOOR_SAMPLES = 300
|
||||||
|
|
||||||
|
_noise_floor_task: asyncio.Task | None = None
|
||||||
|
_noise_floor_samples: deque[tuple[int, int]] = deque(maxlen=MAX_NOISE_FLOOR_SAMPLES)
|
||||||
|
_noise_floor_supported: bool | None = None
|
||||||
|
_samples_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
async def _append_sample(timestamp: int, noise_floor_dbm: int) -> None:
|
||||||
|
async with _samples_lock:
|
||||||
|
_noise_floor_samples.append((timestamp, noise_floor_dbm))
|
||||||
|
|
||||||
|
|
||||||
|
async def sample_noise_floor_once(*, blocking: bool = False) -> None:
|
||||||
|
"""Fetch the current radio noise floor once and record it when available."""
|
||||||
|
global _noise_floor_supported
|
||||||
|
|
||||||
|
if not radio_manager.is_connected:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with radio_manager.radio_operation("noise_floor_sample", blocking=blocking) as mc:
|
||||||
|
event = await mc.commands.get_stats_radio()
|
||||||
|
except (RadioDisconnectedError, RadioOperationBusyError):
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Noise floor sampling failed: %s", exc)
|
||||||
|
return
|
||||||
|
|
||||||
|
if event.type == EventType.ERROR:
|
||||||
|
_noise_floor_supported = False
|
||||||
|
return
|
||||||
|
|
||||||
|
if event.type != EventType.STATS_RADIO:
|
||||||
|
return
|
||||||
|
|
||||||
|
noise_floor = event.payload.get("noise_floor")
|
||||||
|
if not isinstance(noise_floor, int):
|
||||||
|
return
|
||||||
|
|
||||||
|
_noise_floor_supported = True
|
||||||
|
await _append_sample(int(time.time()), noise_floor)
|
||||||
|
|
||||||
|
|
||||||
|
async def _noise_floor_sampling_loop() -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await sample_noise_floor_once()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Noise floor sampling loop crashed during sample")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(NOISE_FLOOR_SAMPLE_INTERVAL_SECONDS)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def start_noise_floor_sampling() -> None:
|
||||||
|
global _noise_floor_task
|
||||||
|
if _noise_floor_task is not None and not _noise_floor_task.done():
|
||||||
|
return
|
||||||
|
_noise_floor_task = asyncio.create_task(_noise_floor_sampling_loop())
|
||||||
|
|
||||||
|
|
||||||
|
async def stop_noise_floor_sampling() -> None:
|
||||||
|
global _noise_floor_task
|
||||||
|
if _noise_floor_task is None:
|
||||||
|
return
|
||||||
|
if not _noise_floor_task.done():
|
||||||
|
_noise_floor_task.cancel()
|
||||||
|
try:
|
||||||
|
await _noise_floor_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
_noise_floor_task = None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_noise_floor_history() -> dict:
|
||||||
|
"""Return the current 24-hour in-memory noise floor history snapshot."""
|
||||||
|
now = int(time.time())
|
||||||
|
cutoff = now - NOISE_FLOOR_WINDOW_SECONDS
|
||||||
|
|
||||||
|
async with _samples_lock:
|
||||||
|
samples = [
|
||||||
|
{"timestamp": timestamp, "noise_floor_dbm": noise_floor_dbm}
|
||||||
|
for timestamp, noise_floor_dbm in _noise_floor_samples
|
||||||
|
if timestamp >= cutoff
|
||||||
|
]
|
||||||
|
|
||||||
|
latest = samples[-1] if samples else None
|
||||||
|
oldest_timestamp = samples[0]["timestamp"] if samples else None
|
||||||
|
coverage_seconds = 0 if oldest_timestamp is None else max(0, now - oldest_timestamp)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"sample_interval_seconds": NOISE_FLOOR_SAMPLE_INTERVAL_SECONDS,
|
||||||
|
"coverage_seconds": coverage_seconds,
|
||||||
|
"latest_noise_floor_dbm": latest["noise_floor_dbm"] if latest else None,
|
||||||
|
"latest_timestamp": latest["timestamp"] if latest else None,
|
||||||
|
"supported": _noise_floor_supported,
|
||||||
|
"samples": samples,
|
||||||
|
}
|
||||||
@@ -43,9 +43,6 @@ class WebSocketManager:
|
|||||||
3. Send to all clients concurrently with timeout
|
3. Send to all clients concurrently with timeout
|
||||||
4. Re-acquire lock to clean up disconnected clients
|
4. Re-acquire lock to clean up disconnected clients
|
||||||
"""
|
"""
|
||||||
if not self.active_connections:
|
|
||||||
return
|
|
||||||
|
|
||||||
message = dump_ws_event(event_type, data)
|
message = dump_ws_event(event_type, data)
|
||||||
|
|
||||||
# Copy connection list under lock to avoid holding lock during I/O
|
# Copy connection list under lock to avoid holding lock during I/O
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
services:
|
||||||
|
remoteterm:
|
||||||
|
# build: .
|
||||||
|
image: docker.io/jkingsman/remoteterm-meshcore:latest
|
||||||
|
|
||||||
|
# Optional on Linux: run container as your host user to avoid root-owned files in ./data
|
||||||
|
# This is less reliable for serial-device access than running as root and may require
|
||||||
|
# extra group setup (for example dialout) or other manual customization.
|
||||||
|
# user: "${UID:-1000}:${GID:-1000}"
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
|
||||||
|
#####################################################################
|
||||||
|
# Map your radio by stable device ID if available. #
|
||||||
|
# If your by-id path contains ':' characters, Docker Compose cannot #
|
||||||
|
# represent it here directly; use a colon-free host alias instead. #
|
||||||
|
# (e.g. /dev/ttyUSB0) #
|
||||||
|
#####################################################################
|
||||||
|
devices:
|
||||||
|
- /dev/serial/by-id/your-meshcore-radio:/dev/meshcore-radio
|
||||||
|
|
||||||
|
environment:
|
||||||
|
MESHCORE_DATABASE_PATH: data/meshcore.db
|
||||||
|
|
||||||
|
# Radio connection
|
||||||
|
# Serial (USB)
|
||||||
|
MESHCORE_SERIAL_PORT: /dev/meshcore-radio
|
||||||
|
# MESHCORE_SERIAL_BAUDRATE: 115200
|
||||||
|
|
||||||
|
# TCP
|
||||||
|
# MESHCORE_TCP_HOST: 192.168.1.100
|
||||||
|
# MESHCORE_TCP_PORT: 4000
|
||||||
|
|
||||||
|
# BLE
|
||||||
|
# BLE in Docker usually needs additional manual compose changes such as
|
||||||
|
# Bluetooth device passthrough, privileged mode, host networking, or
|
||||||
|
# other host-specific tweaks before it will actually work.
|
||||||
|
# MESHCORE_BLE_ADDRESS: AA:BB:CC:DD:EE:FF
|
||||||
|
# MESHCORE_BLE_PIN: 123456
|
||||||
|
|
||||||
|
# Security
|
||||||
|
# MESHCORE_DISABLE_BOTS: "true"
|
||||||
|
# MESHCORE_BASIC_AUTH_USERNAME: changeme
|
||||||
|
# MESHCORE_BASIC_AUTH_PASSWORD: changeme
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
# MESHCORE_LOG_LEVEL: INFO
|
||||||
|
restart: unless-stopped
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
services:
|
|
||||||
remoteterm:
|
|
||||||
build: .
|
|
||||||
# image: jkingsman/remoteterm-meshcore:latest
|
|
||||||
|
|
||||||
# Optional on Linux: run container as your host user to avoid root-owned files in ./data
|
|
||||||
# user: "${UID:-1000}:${GID:-1000}"
|
|
||||||
ports:
|
|
||||||
- "8000:8000"
|
|
||||||
volumes:
|
|
||||||
- ./data:/app/data
|
|
||||||
################################################
|
|
||||||
# Set your serial device for passthrough here! #
|
|
||||||
################################################
|
|
||||||
devices:
|
|
||||||
- /dev/ttyACM0:/dev/ttyUSB0
|
|
||||||
|
|
||||||
environment:
|
|
||||||
MESHCORE_DATABASE_PATH: data/meshcore.db
|
|
||||||
# Radio connection -- optional if you map just a single serial device above, as the app will autodetect
|
|
||||||
|
|
||||||
# Serial (USB)
|
|
||||||
# MESHCORE_SERIAL_PORT: /dev/ttyUSB0
|
|
||||||
# MESHCORE_SERIAL_BAUDRATE: 115200
|
|
||||||
|
|
||||||
# TCP
|
|
||||||
# MESHCORE_TCP_HOST: 192.168.1.100
|
|
||||||
# MESHCORE_TCP_PORT: 4000
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
# MESHCORE_LOG_LEVEL: INFO
|
|
||||||
restart: unless-stopped
|
|
||||||
+60
-12
@@ -39,6 +39,8 @@ frontend/src/
|
|||||||
├── index.css # Global styles/utilities
|
├── index.css # Global styles/utilities
|
||||||
├── styles.css # Additional global app styles
|
├── styles.css # Additional global app styles
|
||||||
├── themes.css # Color theme definitions
|
├── themes.css # Color theme definitions
|
||||||
|
├── contexts/
|
||||||
|
│ └── DistanceUnitContext.tsx # Browser-local distance-unit context/provider
|
||||||
├── lib/
|
├── lib/
|
||||||
│ └── utils.ts # cn() — clsx + tailwind-merge helper
|
│ └── utils.ts # cn() — clsx + tailwind-merge helper
|
||||||
├── hooks/
|
├── hooks/
|
||||||
@@ -53,10 +55,14 @@ frontend/src/
|
|||||||
│ ├── useRadioControl.ts # Radio health/config state, reconnection, mesh discovery sweeps
|
│ ├── useRadioControl.ts # Radio health/config state, reconnection, mesh discovery sweeps
|
||||||
│ ├── useAppSettings.ts # Settings, favorites, preferences migration
|
│ ├── useAppSettings.ts # Settings, favorites, preferences migration
|
||||||
│ ├── useConversationRouter.ts # URL hash → active conversation routing
|
│ ├── useConversationRouter.ts # URL hash → active conversation routing
|
||||||
│ └── useContactsAndChannels.ts # Contact/channel loading, creation, deletion
|
│ ├── useContactsAndChannels.ts # Contact/channel loading, creation, deletion
|
||||||
|
│ ├── useBrowserNotifications.ts # Per-conversation browser notification preferences + dispatch
|
||||||
|
│ ├── useFaviconBadge.ts # Browser tab unread badge state
|
||||||
|
│ ├── useRawPacketStatsSession.ts # Session-scoped packet-feed stats history
|
||||||
|
│ └── useRememberedServerPassword.ts # Browser-local repeater/room password persistence
|
||||||
├── components/
|
├── components/
|
||||||
│ ├── AppShell.tsx # App-shell layout: status, sidebar, search/settings panes, cracker, modals
|
│ ├── AppShell.tsx # App-shell layout: status, sidebar, search/settings panes, cracker, modals, security warning
|
||||||
│ ├── ConversationPane.tsx # Active conversation surface selection (map/raw/repeater/chat/empty)
|
│ ├── ConversationPane.tsx # Active conversation surface selection (map/raw/trace/repeater/room/chat/empty)
|
||||||
│ ├── visualizer/
|
│ ├── visualizer/
|
||||||
│ │ ├── useVisualizerData3D.ts # Packet→graph data pipeline, repeat aggregation, simulation state
|
│ │ ├── useVisualizerData3D.ts # Packet→graph data pipeline, repeat aggregation, simulation state
|
||||||
│ │ ├── useVisualizer3DScene.ts # Three.js scene lifecycle, buffers, hover/pin interaction
|
│ │ ├── useVisualizer3DScene.ts # Three.js scene lifecycle, buffers, hover/pin interaction
|
||||||
@@ -73,14 +79,18 @@ frontend/src/
|
|||||||
│ ├── pubkey.ts # getContactDisplayName (12-char prefix fallback)
|
│ ├── pubkey.ts # getContactDisplayName (12-char prefix fallback)
|
||||||
│ ├── contactAvatar.ts # Avatar color derivation from public key
|
│ ├── contactAvatar.ts # Avatar color derivation from public key
|
||||||
│ ├── rawPacketIdentity.ts # observation_id vs id dedup helpers
|
│ ├── rawPacketIdentity.ts # observation_id vs id dedup helpers
|
||||||
|
│ ├── rawPacketStats.ts # Session packet stats windows, rankings, and coverage helpers
|
||||||
│ ├── regionScope.ts # Regional flood-scope label/normalization helpers
|
│ ├── regionScope.ts # Regional flood-scope label/normalization helpers
|
||||||
│ ├── visualizerUtils.ts # 3D visualizer node types, colors, particles
|
│ ├── visualizerUtils.ts # 3D visualizer node types, colors, particles
|
||||||
│ ├── visualizerSettings.ts # LocalStorage persistence for visualizer options
|
│ ├── visualizerSettings.ts # LocalStorage persistence for visualizer options
|
||||||
│ ├── a11y.ts # Keyboard accessibility helper
|
│ ├── a11y.ts # Keyboard accessibility helper
|
||||||
|
│ ├── distanceUnits.ts # Browser-local distance unit persistence/helpers
|
||||||
│ ├── lastViewedConversation.ts # localStorage for last-viewed conversation
|
│ ├── lastViewedConversation.ts # localStorage for last-viewed conversation
|
||||||
│ ├── contactMerge.ts # Merge WS contact updates into list
|
│ ├── contactMerge.ts # Merge WS contact updates into list
|
||||||
│ ├── localLabel.ts # Local label (text + color) in localStorage
|
│ ├── localLabel.ts # Local label (text + color) in localStorage
|
||||||
│ ├── radioPresets.ts # LoRa radio preset configurations
|
│ ├── radioPresets.ts # LoRa radio preset configurations
|
||||||
|
│ ├── publicChannel.ts # Public-channel resolution helpers for routing/hash defaults
|
||||||
|
│ ├── fontScale.ts # Browser-local relative font scale persistence/application
|
||||||
│ └── theme.ts # Theme switching helpers
|
│ └── theme.ts # Theme switching helpers
|
||||||
├── components/
|
├── components/
|
||||||
│ ├── StatusBar.tsx
|
│ ├── StatusBar.tsx
|
||||||
@@ -91,8 +101,12 @@ frontend/src/
|
|||||||
│ ├── NewMessageModal.tsx
|
│ ├── NewMessageModal.tsx
|
||||||
│ ├── SearchView.tsx # Full-text message search pane
|
│ ├── SearchView.tsx # Full-text message search pane
|
||||||
│ ├── SettingsModal.tsx # Layout shell — delegates to settings/ sections
|
│ ├── SettingsModal.tsx # Layout shell — delegates to settings/ sections
|
||||||
|
│ ├── SecurityWarningModal.tsx # Startup warning for trusted-network / bot execution posture
|
||||||
│ ├── RawPacketList.tsx
|
│ ├── RawPacketList.tsx
|
||||||
|
│ ├── RawPacketFeedView.tsx # Live raw packet feed + session stats drawer
|
||||||
|
│ ├── RawPacketDetailModal.tsx # On-demand packet inspector dialog
|
||||||
│ ├── MapView.tsx
|
│ ├── MapView.tsx
|
||||||
|
│ ├── TracePane.tsx # Multi-hop route trace builder/results view
|
||||||
│ ├── VisualizerView.tsx
|
│ ├── VisualizerView.tsx
|
||||||
│ ├── PacketVisualizer3D.tsx
|
│ ├── PacketVisualizer3D.tsx
|
||||||
│ ├── PathModal.tsx
|
│ ├── PathModal.tsx
|
||||||
@@ -102,15 +116,20 @@ frontend/src/
|
|||||||
│ ├── ContactAvatar.tsx
|
│ ├── ContactAvatar.tsx
|
||||||
│ ├── ContactInfoPane.tsx # Contact detail sheet (stats, name history, paths)
|
│ ├── ContactInfoPane.tsx # Contact detail sheet (stats, name history, paths)
|
||||||
│ ├── ContactStatusInfo.tsx # Contact status info component
|
│ ├── ContactStatusInfo.tsx # Contact status info component
|
||||||
|
│ ├── ContactPathDiscoveryModal.tsx # Forward/return path discovery dialog
|
||||||
|
│ ├── ContactRoutingOverrideModal.tsx # Manual direct-route override editor
|
||||||
│ ├── RepeaterDashboard.tsx # Layout shell — delegates to repeater/ panes
|
│ ├── RepeaterDashboard.tsx # Layout shell — delegates to repeater/ panes
|
||||||
│ ├── RepeaterLogin.tsx # Repeater login form (password + guest)
|
│ ├── RepeaterLogin.tsx # Repeater login form (password + guest)
|
||||||
|
│ ├── RoomServerPanel.tsx # Room-server auth gate + status banner ahead of room chat
|
||||||
|
│ ├── ServerLoginStatusBanner.tsx # Shared repeater/room login state banner
|
||||||
│ ├── ChannelInfoPane.tsx # Channel detail sheet (stats, top senders)
|
│ ├── ChannelInfoPane.tsx # Channel detail sheet (stats, top senders)
|
||||||
|
│ ├── ChannelFloodScopeOverrideModal.tsx # Per-channel flood-scope override editor
|
||||||
│ ├── DirectTraceIcon.tsx # Shared direct-trace glyph used in header/dashboard
|
│ ├── DirectTraceIcon.tsx # Shared direct-trace glyph used in header/dashboard
|
||||||
│ ├── NeighborsMiniMap.tsx # Leaflet mini-map for repeater neighbor locations
|
│ ├── NeighborsMiniMap.tsx # Leaflet mini-map for repeater neighbor locations
|
||||||
│ ├── settings/
|
│ ├── settings/
|
||||||
│ │ ├── settingsConstants.ts # Settings section type, ordering, labels
|
│ │ ├── settingsConstants.ts # Settings section type, ordering, labels
|
||||||
│ │ ├── SettingsRadioSection.tsx # Name, keys, advert interval, max contacts, radio preset, freq/bw/sf/cr, txPower, lat/lon, reboot, mesh discovery
|
│ │ ├── SettingsRadioSection.tsx # Name, keys, advert interval, max contacts, radio preset, freq/bw/sf/cr, txPower, lat/lon, reboot, mesh discovery
|
||||||
│ │ ├── SettingsLocalSection.tsx # Browser-local settings: theme, local label, reopen last conversation
|
│ │ ├── SettingsLocalSection.tsx # Browser-local settings: theme, relative font scale, local label, reopen last conversation
|
||||||
│ │ ├── SettingsFanoutSection.tsx # Fanout integrations: MQTT, bots, config CRUD
|
│ │ ├── SettingsFanoutSection.tsx # Fanout integrations: MQTT, bots, config CRUD
|
||||||
│ │ ├── SettingsDatabaseSection.tsx # DB size, cleanup, auto-decrypt, local label
|
│ │ ├── SettingsDatabaseSection.tsx # DB size, cleanup, auto-decrypt, local label
|
||||||
│ │ ├── SettingsStatisticsSection.tsx # Read-only mesh network stats
|
│ │ ├── SettingsStatisticsSection.tsx # Read-only mesh network stats
|
||||||
@@ -130,12 +149,13 @@ frontend/src/
|
|||||||
│ └── ui/ # shadcn/ui primitives
|
│ └── ui/ # shadcn/ui primitives
|
||||||
├── types/
|
├── types/
|
||||||
│ └── d3-force-3d.d.ts # Type declarations for d3-force-3d
|
│ └── d3-force-3d.d.ts # Type declarations for d3-force-3d
|
||||||
└── test/
|
└── test/ # Representative frontend test suites (not an exhaustive listing)
|
||||||
├── setup.ts
|
├── setup.ts
|
||||||
├── fixtures/websocket_events.json
|
├── fixtures/websocket_events.json
|
||||||
├── api.test.ts
|
├── api.test.ts
|
||||||
├── appFavorites.test.tsx
|
├── appFavorites.test.tsx
|
||||||
├── appStartupHash.test.tsx
|
├── appStartupHash.test.tsx
|
||||||
|
├── conversationPane.test.tsx
|
||||||
├── contactAvatar.test.ts
|
├── contactAvatar.test.ts
|
||||||
├── contactInfoPane.test.tsx
|
├── contactInfoPane.test.tsx
|
||||||
├── integration.test.ts
|
├── integration.test.ts
|
||||||
@@ -146,18 +166,23 @@ frontend/src/
|
|||||||
├── rawPacketList.test.tsx
|
├── rawPacketList.test.tsx
|
||||||
├── pathUtils.test.ts
|
├── pathUtils.test.ts
|
||||||
├── prefetch.test.ts
|
├── prefetch.test.ts
|
||||||
|
├── rawPacketDetailModal.test.tsx
|
||||||
|
├── rawPacketFeedView.test.tsx
|
||||||
├── radioPresets.test.ts
|
├── radioPresets.test.ts
|
||||||
├── rawPacketIdentity.test.ts
|
├── rawPacketIdentity.test.ts
|
||||||
├── repeaterDashboard.test.tsx
|
├── repeaterDashboard.test.tsx
|
||||||
├── repeaterFormatters.test.ts
|
├── repeaterFormatters.test.ts
|
||||||
├── repeaterLogin.test.tsx
|
├── repeaterLogin.test.tsx
|
||||||
├── repeaterMessageParsing.test.ts
|
├── repeaterMessageParsing.test.ts
|
||||||
|
├── roomServerPanel.test.tsx
|
||||||
|
├── securityWarningModal.test.tsx
|
||||||
├── localLabel.test.ts
|
├── localLabel.test.ts
|
||||||
├── messageInput.test.tsx
|
├── messageInput.test.tsx
|
||||||
├── newMessageModal.test.tsx
|
├── newMessageModal.test.tsx
|
||||||
├── settingsModal.test.tsx
|
├── settingsModal.test.tsx
|
||||||
├── sidebar.test.tsx
|
├── sidebar.test.tsx
|
||||||
├── statusBar.test.tsx
|
├── statusBar.test.tsx
|
||||||
|
├── tracePane.test.tsx
|
||||||
├── unreadCounts.test.ts
|
├── unreadCounts.test.ts
|
||||||
├── urlHash.test.ts
|
├── urlHash.test.ts
|
||||||
├── appSearchJump.test.tsx
|
├── appSearchJump.test.tsx
|
||||||
@@ -169,12 +194,17 @@ frontend/src/
|
|||||||
├── useConversationMessages.race.test.ts
|
├── useConversationMessages.race.test.ts
|
||||||
├── useConversationNavigation.test.ts
|
├── useConversationNavigation.test.ts
|
||||||
├── useAppShell.test.ts
|
├── useAppShell.test.ts
|
||||||
|
├── useBrowserNotifications.test.ts
|
||||||
|
├── useFaviconBadge.test.ts
|
||||||
├── useRepeaterDashboard.test.ts
|
├── useRepeaterDashboard.test.ts
|
||||||
|
├── useRememberedServerPassword.test.ts
|
||||||
├── useContactsAndChannels.test.ts
|
├── useContactsAndChannels.test.ts
|
||||||
├── useRealtimeAppState.test.ts
|
├── useRealtimeAppState.test.ts
|
||||||
├── useUnreadCounts.test.ts
|
├── useUnreadCounts.test.ts
|
||||||
├── useWebSocket.dispatch.test.ts
|
├── useWebSocket.dispatch.test.ts
|
||||||
├── useWebSocket.lifecycle.test.ts
|
├── useWebSocket.lifecycle.test.ts
|
||||||
|
├── rawPacketStats.test.ts
|
||||||
|
├── fontScale.test.ts
|
||||||
└── wsEvents.test.ts
|
└── wsEvents.test.ts
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -190,6 +220,7 @@ frontend/src/
|
|||||||
- search/settings surface switching
|
- search/settings surface switching
|
||||||
- global cracker mount/focus behavior
|
- global cracker mount/focus behavior
|
||||||
- new-message modal and info panes
|
- new-message modal and info panes
|
||||||
|
- trusted-network `SecurityWarningModal`
|
||||||
|
|
||||||
High-level state is delegated to hooks:
|
High-level state is delegated to hooks:
|
||||||
- `useAppShell`: app-shell view state (settings section, sidebar, cracker, new-message modal)
|
- `useAppShell`: app-shell view state (settings section, sidebar, cracker, new-message modal)
|
||||||
@@ -198,9 +229,9 @@ High-level state is delegated to hooks:
|
|||||||
- `useContactsAndChannels`: contact/channel lists, creation, deletion
|
- `useContactsAndChannels`: contact/channel lists, creation, deletion
|
||||||
- `useConversationRouter`: URL hash → active conversation routing
|
- `useConversationRouter`: URL hash → active conversation routing
|
||||||
- `useConversationNavigation`: search target, conversation selection reset, and info-pane state
|
- `useConversationNavigation`: search target, conversation selection reset, and info-pane state
|
||||||
- `useConversationActions`: send/resend/trace/block handlers and channel override updates
|
- `useConversationActions`: send/resend/trace/path-discovery/block handlers and channel override updates
|
||||||
- `useConversationMessages`: conversation switch loading, embedded conversation-scoped cache, jump-target loading, pagination, dedup/update helpers, reconnect reconciliation, and pending ACK buffering
|
- `useConversationMessages`: conversation switch loading, embedded conversation-scoped cache, jump-target loading, pagination, dedup/update helpers, reconnect reconciliation, and pending ACK buffering
|
||||||
- `useUnreadCounts`: unread counters, mention tracking, recent-sort timestamps
|
- `useUnreadCounts`: unread counters, mention tracking, recent-sort timestamps, and server `last_read_ats` boundaries
|
||||||
- `useRealtimeAppState`: typed WS event application, reconnect recovery, cache/unread coordination
|
- `useRealtimeAppState`: typed WS event application, reconnect recovery, cache/unread coordination
|
||||||
- `useRepeaterDashboard`: repeater dashboard state (login, pane data/retries, console, actions)
|
- `useRepeaterDashboard`: repeater dashboard state (login, pane data/retries, console, actions)
|
||||||
|
|
||||||
@@ -211,7 +242,9 @@ High-level state is delegated to hooks:
|
|||||||
- map view
|
- map view
|
||||||
- visualizer
|
- visualizer
|
||||||
- raw packet feed
|
- raw packet feed
|
||||||
|
- trace view
|
||||||
- repeater dashboard
|
- repeater dashboard
|
||||||
|
- room-server auth/status gate before room chat
|
||||||
- normal chat chrome (`ChatHeader` + `MessageList` + `MessageInput`)
|
- normal chat chrome (`ChatHeader` + `MessageList` + `MessageInput`)
|
||||||
|
|
||||||
### Initial load + realtime
|
### Initial load + realtime
|
||||||
@@ -245,10 +278,12 @@ High-level state is delegated to hooks:
|
|||||||
- `id`: backend storage row identity (payload-level dedup)
|
- `id`: backend storage row identity (payload-level dedup)
|
||||||
- `observation_id`: realtime per-arrival identity (session fidelity)
|
- `observation_id`: realtime per-arrival identity (session fidelity)
|
||||||
- Packet feed/visualizer render keys and dedup logic should use `observation_id` (fallback to `id` only for older payloads).
|
- Packet feed/visualizer render keys and dedup logic should use `observation_id` (fallback to `id` only for older payloads).
|
||||||
|
- The dedicated raw packet feed view now includes a frontend-only stats drawer. It tracks a separate lightweight per-observation session history for charts/rankings, so its windows are not limited by the visible packet list cap. Coverage messaging should stay honest when detailed in-memory stats history has been trimmed or the selected window predates the current browser session.
|
||||||
|
|
||||||
### Radio settings behavior
|
### Radio settings behavior
|
||||||
|
|
||||||
- `SettingsRadioSection.tsx` surfaces `path_hash_mode` only when `config.path_hash_mode_supported` is true.
|
- `SettingsRadioSection.tsx` surfaces `path_hash_mode` only when `config.path_hash_mode_supported` is true.
|
||||||
|
- `SettingsRadioSection.tsx` also exposes `multi_acks_enabled` as a checkbox for the radio's extra direct-ACK transmission behavior.
|
||||||
- Advert-location control is intentionally only `off` vs `include node location`. Companion-radio firmware does not reliably distinguish saved coordinates from live GPS in this path.
|
- Advert-location control is intentionally only `off` vs `include node location`. Companion-radio firmware does not reliably distinguish saved coordinates from live GPS in this path.
|
||||||
- The advert action is mode-aware: the radio settings section exposes both flood and zero-hop manual advert buttons, both routed through the same `onAdvertise(mode)` seam.
|
- The advert action is mode-aware: the radio settings section exposes both flood and zero-hop manual advert buttons, both routed through the same `onAdvertise(mode)` seam.
|
||||||
- Mesh discovery in the radio section is limited to node classes that currently answer discovery control-data requests in firmware: repeaters and sensors.
|
- Mesh discovery in the radio section is limited to node classes that currently answer discovery control-data requests in firmware: repeaters and sensors.
|
||||||
@@ -270,12 +305,16 @@ Supported routes:
|
|||||||
- `#map/focus/{pubkey_or_prefix}`
|
- `#map/focus/{pubkey_or_prefix}`
|
||||||
- `#visualizer`
|
- `#visualizer`
|
||||||
- `#search`
|
- `#search`
|
||||||
|
- `#trace`
|
||||||
|
- `#settings/{section}`
|
||||||
- `#channel/{channelKey}`
|
- `#channel/{channelKey}`
|
||||||
- `#channel/{channelKey}/{label}`
|
- `#channel/{channelKey}/{label}`
|
||||||
- `#contact/{publicKey}`
|
- `#contact/{publicKey}`
|
||||||
- `#contact/{publicKey}/{label}`
|
- `#contact/{publicKey}/{label}`
|
||||||
|
|
||||||
Legacy name-based hashes are still accepted for compatibility.
|
Where `{section}` is one of `radio`, `local`, `fanout`, `database`, `statistics`, or `about`.
|
||||||
|
|
||||||
|
Legacy name-based channel/contact hashes are still accepted for compatibility.
|
||||||
|
|
||||||
## Conversation State Keys (`utils/conversationState.ts`)
|
## Conversation State Keys (`utils/conversationState.ts`)
|
||||||
|
|
||||||
@@ -329,6 +368,8 @@ Note: MQTT, bot, and community MQTT settings were migrated to the `fanout_config
|
|||||||
|
|
||||||
`RawPacket.decrypted_info` includes `channel_key` and `contact_key` for MQTT topic routing.
|
`RawPacket.decrypted_info` includes `channel_key` and `contact_key` for MQTT topic routing.
|
||||||
|
|
||||||
|
`UnreadCounts` includes `counts`, `mentions`, `last_message_times`, and `last_read_ats`. The unread-boundary/jump-to-unread behavior uses the server-provided `last_read_ats` map keyed by `getStateKey(...)`.
|
||||||
|
|
||||||
## Contact Info Pane
|
## Contact Info Pane
|
||||||
|
|
||||||
Clicking a contact's avatar in `ChatHeader` or `MessageList` opens a `ContactInfoPane` sheet (right drawer) showing comprehensive contact details fetched from `GET /api/contacts/analytics` using either `?public_key=...` or `?name=...`:
|
Clicking a contact's avatar in `ChatHeader` or `MessageList` opens a `ContactInfoPane` sheet (right drawer) showing comprehensive contact details fetched from `GET /api/contacts/analytics` using either `?public_key=...` or `?name=...`:
|
||||||
@@ -340,9 +381,10 @@ Clicking a contact's avatar in `ChatHeader` or `MessageList` opens a `ContactInf
|
|||||||
- Name history ("Also Known As") — shown only when the contact has used multiple names
|
- Name history ("Also Known As") — shown only when the contact has used multiple names
|
||||||
- Message stats: DM count, channel message count
|
- Message stats: DM count, channel message count
|
||||||
- Most active rooms (clickable → navigate to channel)
|
- Most active rooms (clickable → navigate to channel)
|
||||||
|
- Route details from the canonical backend surface (`effective_route`, `effective_route_source`, `direct_route`, `route_override`)
|
||||||
- Advert observation rate
|
- Advert observation rate
|
||||||
- Nearest repeaters (resolved from first-hop path prefixes)
|
- Nearest repeaters (resolved from first-hop path prefixes)
|
||||||
- Recent advert paths
|
- Recent advert paths (informational only; not part of DM route selection)
|
||||||
|
|
||||||
State: `useConversationNavigation` controls open/close via `infoPaneContactKey`. Live contact data from WebSocket updates is preferred over the initial detail snapshot.
|
State: `useConversationNavigation` controls open/close via `infoPaneContactKey`. Live contact data from WebSocket updates is preferred over the initial detail snapshot.
|
||||||
|
|
||||||
@@ -372,6 +414,12 @@ For repeater contacts (`type=2`), `ConversationPane.tsx` renders `RepeaterDashbo
|
|||||||
|
|
||||||
All state is managed by `useRepeaterDashboard` hook. State resets on conversation change.
|
All state is managed by `useRepeaterDashboard` hook. State resets on conversation change.
|
||||||
|
|
||||||
|
## Room Server Panel
|
||||||
|
|
||||||
|
For room contacts (`type=3`), `ConversationPane.tsx` keeps the normal chat surface but inserts `RoomServerPanel` above it. That panel handles room-server login/status messaging and gates room chat behind the room-authenticated state when required.
|
||||||
|
|
||||||
|
`ServerLoginStatusBanner` is shared between repeater and room login surfaces for inline status/error display.
|
||||||
|
|
||||||
## Message Search Pane
|
## Message Search Pane
|
||||||
|
|
||||||
The `SearchView` component (`components/SearchView.tsx`) provides full-text search across all DMs and channel messages. Key behaviors:
|
The `SearchView` component (`components/SearchView.tsx`) provides full-text search across all DMs and channel messages. Key behaviors:
|
||||||
@@ -399,7 +447,7 @@ Do not rely on old class-only layout assumptions.
|
|||||||
Run all quality checks (backend + frontend) from the repo root:
|
Run all quality checks (backend + frontend) from the repo root:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/all_quality.sh
|
./scripts/quality/all_quality.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Or run frontend checks individually:
|
Or run frontend checks individually:
|
||||||
@@ -422,9 +470,9 @@ PYTHONPATH=. uv run pytest tests/ -v
|
|||||||
|
|
||||||
## Errata & Known Non-Issues
|
## Errata & Known Non-Issues
|
||||||
|
|
||||||
### Contacts rollup uses mention styling for unread DMs
|
### Contacts use mention styling for unread DMs
|
||||||
|
|
||||||
This is intentional. In the sidebar section headers, unread direct messages are treated as mention-equivalent, so the Contacts rollup uses the highlighted mention-style badge for any unread DM. Row-level mention detection remains separate; this note is only about the section summary styling.
|
This is intentional. In the sidebar, unread direct messages for actual contact conversations are treated as mention-equivalent for badge styling. That means both the Contacts section header and contact unread badges themselves use the highlighted mention-style colors for unread DMs, including when those contacts appear in Favorites. Repeaters do not inherit this rule, and channel badges still use mention styling only for real `@[name]` mentions.
|
||||||
|
|
||||||
### RawPacketList always scrolls to bottom
|
### RawPacketList always scrolls to bottom
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -9,8 +9,8 @@
|
|||||||
<meta name="theme-color" content="#111419" />
|
<meta name="theme-color" content="#111419" />
|
||||||
<meta name="description" content="Web interface for MeshCore mesh radio networks. Send and receive messages, manage contacts and channels, and configure your radio." />
|
<meta name="description" content="Web interface for MeshCore mesh radio networks. Send and receive messages, manage contacts and channels, and configure your radio." />
|
||||||
<title>RemoteTerm for MeshCore</title>
|
<title>RemoteTerm for MeshCore</title>
|
||||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||||
<link rel="shortcut icon" href="/favicon.ico" />
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
<link rel="manifest" href="/site.webmanifest" />
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
|
|||||||
Generated
+394
-3
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "remoteterm-meshcore-frontend",
|
"name": "remoteterm-meshcore-frontend",
|
||||||
"version": "2.7.9",
|
"version": "3.6.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "remoteterm-meshcore-frontend",
|
"name": "remoteterm-meshcore-frontend",
|
||||||
"version": "2.7.9",
|
"version": "3.6.2",
|
||||||
"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",
|
||||||
@@ -29,6 +29,8 @@
|
|||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
|
"react-swipeable": "^7.0.2",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
@@ -2056,6 +2058,42 @@
|
|||||||
"react-dom": "^18.0.0"
|
"react-dom": "^18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit": {
|
||||||
|
"version": "2.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||||
|
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"@standard-schema/utils": "^0.3.0",
|
||||||
|
"immer": "^11.0.0",
|
||||||
|
"redux": "^5.0.1",
|
||||||
|
"redux-thunk": "^3.1.0",
|
||||||
|
"reselect": "^5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||||
|
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||||
|
"version": "11.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
|
||||||
|
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
@@ -2413,6 +2451,18 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/spec": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@standard-schema/utils": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@testing-library/dom": {
|
"node_modules/@testing-library/dom": {
|
||||||
"version": "10.4.1",
|
"version": "10.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||||
@@ -2563,6 +2613,24 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@babel/types": "^7.28.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-array": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/d3-force": {
|
"node_modules/@types/d3-force": {
|
||||||
"version": "3.0.10",
|
"version": "3.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
|
||||||
@@ -2570,6 +2638,51 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-shape": {
|
||||||
|
"version": "3.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||||
|
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -2662,6 +2775,12 @@
|
|||||||
"meshoptimizer": "~0.22.0"
|
"meshoptimizer": "~0.22.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/webxr": {
|
"node_modules/@types/webxr": {
|
||||||
"version": "0.5.24",
|
"version": "0.5.24",
|
||||||
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||||
@@ -3711,12 +3830,33 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/d3-binarytree": {
|
"node_modules/d3-binarytree": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz",
|
||||||
"integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==",
|
"integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/d3-dispatch": {
|
"node_modules/d3-dispatch": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||||
@@ -3726,6 +3866,15 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/d3-force": {
|
"node_modules/d3-force": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
|
||||||
@@ -3756,12 +3905,42 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/d3-octree": {
|
"node_modules/d3-octree": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz",
|
||||||
"integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==",
|
"integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/d3-quadtree": {
|
"node_modules/d3-quadtree": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
|
||||||
@@ -3771,6 +3950,58 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/d3-timer": {
|
"node_modules/d3-timer": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
@@ -3819,6 +4050,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/decimal.js-light": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/deep-eql": {
|
"node_modules/deep-eql": {
|
||||||
"version": "5.0.2",
|
"version": "5.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
||||||
@@ -3973,6 +4210,16 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-toolkit": {
|
||||||
|
"version": "1.45.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
|
||||||
|
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"docs",
|
||||||
|
"benchmarks"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||||
@@ -4215,6 +4462,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/expect-type": {
|
"node_modules/expect-type": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||||
@@ -4617,6 +4870,16 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "10.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||||
|
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -4654,6 +4917,15 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-binary-path": {
|
"node_modules/is-binary-path": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||||
@@ -5598,7 +5870,6 @@
|
|||||||
"version": "17.0.2",
|
"version": "17.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
@@ -5616,6 +5887,29 @@
|
|||||||
"react-dom": "^18.0.0"
|
"react-dom": "^18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-redux": {
|
||||||
|
"version": "9.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^18.2.25 || ^19",
|
||||||
|
"react": "^18.0 || ^19",
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||||
@@ -5695,6 +5989,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-swipeable": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-swipeable/-/react-swipeable-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-v1Qx1l+aC2fdxKa9aKJiaU/ZxmJ5o98RMoFwUqAAzVWUcxgfHFXDDruCKXhw6zIYXm6V64JiHgP9f6mlME5l8w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.3 || ^17 || ^18 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/read-cache": {
|
"node_modules/read-cache": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||||
@@ -5716,6 +6019,36 @@
|
|||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/recharts": {
|
||||||
|
"version": "3.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
|
||||||
|
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"www"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"decimal.js-light": "^2.5.1",
|
||||||
|
"es-toolkit": "^1.39.3",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"immer": "^10.1.1",
|
||||||
|
"react-redux": "8.x.x || 9.x.x",
|
||||||
|
"reselect": "5.1.1",
|
||||||
|
"tiny-invariant": "^1.3.3",
|
||||||
|
"use-sync-external-store": "^1.2.2",
|
||||||
|
"victory-vendor": "^37.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/redent": {
|
"node_modules/redent": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||||
@@ -5730,6 +6063,27 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/redux-thunk": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/reselect": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
@@ -6124,6 +6478,12 @@
|
|||||||
"integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==",
|
"integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-invariant": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinybench": {
|
"node_modules/tinybench": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
@@ -6438,12 +6798,43 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/victory-vendor": {
|
||||||
|
"version": "37.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||||
|
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||||
|
"license": "MIT AND ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-array": "^3.0.3",
|
||||||
|
"@types/d3-ease": "^3.0.0",
|
||||||
|
"@types/d3-interpolate": "^3.0.1",
|
||||||
|
"@types/d3-scale": "^4.0.2",
|
||||||
|
"@types/d3-shape": "^3.1.0",
|
||||||
|
"@types/d3-time": "^3.0.0",
|
||||||
|
"@types/d3-timer": "^3.0.0",
|
||||||
|
"d3-array": "^3.1.6",
|
||||||
|
"d3-ease": "^3.0.1",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.1.0",
|
||||||
|
"d3-time": "^3.0.0",
|
||||||
|
"d3-timer": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.4.1",
|
"version": "6.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "remoteterm-meshcore-frontend",
|
"name": "remoteterm-meshcore-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "3.4.1",
|
"version": "3.6.7",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -37,6 +37,8 @@
|
|||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
|
"react-swipeable": "^7.0.2",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
|||||||
+135
-31
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useCallback, useRef, useState } 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';
|
||||||
@@ -14,18 +14,29 @@ import {
|
|||||||
useConversationNavigation,
|
useConversationNavigation,
|
||||||
useRealtimeAppState,
|
useRealtimeAppState,
|
||||||
useBrowserNotifications,
|
useBrowserNotifications,
|
||||||
|
useFaviconBadge,
|
||||||
|
useUnreadTitle,
|
||||||
|
useRawPacketStatsSession,
|
||||||
} from './hooks';
|
} from './hooks';
|
||||||
import { AppShell } from './components/AppShell';
|
import { AppShell } from './components/AppShell';
|
||||||
import type { MessageInputHandle } from './components/MessageInput';
|
import type { MessageInputHandle } from './components/MessageInput';
|
||||||
|
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';
|
||||||
|
|
||||||
interface ChannelUnreadMarker {
|
interface ChannelUnreadMarker {
|
||||||
channelId: string;
|
channelId: string;
|
||||||
lastReadAt: number | null;
|
lastReadAt: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NewMessagePrefillRequest {
|
||||||
|
tab: 'hashtag';
|
||||||
|
hashtagName: string;
|
||||||
|
nonce: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface UnreadBoundaryBackfillParams {
|
interface UnreadBoundaryBackfillParams {
|
||||||
activeConversation: Conversation | null;
|
activeConversation: Conversation | null;
|
||||||
unreadMarker: ChannelUnreadMarker | null;
|
unreadMarker: ChannelUnreadMarker | null;
|
||||||
@@ -72,6 +83,10 @@ export function App() {
|
|||||||
const messageInputRef = useRef<MessageInputHandle>(null);
|
const messageInputRef = useRef<MessageInputHandle>(null);
|
||||||
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
|
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
|
||||||
const [channelUnreadMarker, setChannelUnreadMarker] = useState<ChannelUnreadMarker | null>(null);
|
const [channelUnreadMarker, setChannelUnreadMarker] = useState<ChannelUnreadMarker | null>(null);
|
||||||
|
const [newMessagePrefillRequest, setNewMessagePrefillRequest] =
|
||||||
|
useState<NewMessagePrefillRequest | null>(null);
|
||||||
|
const [showBulkAddChannelTab, setShowBulkAddChannelTab] = useState(false);
|
||||||
|
const [bulkAddResult, setBulkAddResult] = useState<BulkCreateHashtagChannelsResult | null>(null);
|
||||||
const [visibilityVersion, setVisibilityVersion] = useState(0);
|
const [visibilityVersion, setVisibilityVersion] = useState(0);
|
||||||
const lastUnreadBackfillAttemptRef = useRef<string | null>(null);
|
const lastUnreadBackfillAttemptRef = useRef<string | null>(null);
|
||||||
const {
|
const {
|
||||||
@@ -81,6 +96,7 @@ export function App() {
|
|||||||
toggleConversationNotifications,
|
toggleConversationNotifications,
|
||||||
notifyIncomingMessage,
|
notifyIncomingMessage,
|
||||||
} = useBrowserNotifications();
|
} = useBrowserNotifications();
|
||||||
|
const { rawPacketStatsSession, recordRawPacketObservation } = useRawPacketStatsSession();
|
||||||
const {
|
const {
|
||||||
showNewMessage,
|
showNewMessage,
|
||||||
showSettings,
|
showSettings,
|
||||||
@@ -89,14 +105,16 @@ export function App() {
|
|||||||
showCracker,
|
showCracker,
|
||||||
crackerRunning,
|
crackerRunning,
|
||||||
localLabel,
|
localLabel,
|
||||||
|
distanceUnit,
|
||||||
setSettingsSection,
|
setSettingsSection,
|
||||||
setSidebarOpen,
|
setSidebarOpen,
|
||||||
setCrackerRunning,
|
setCrackerRunning,
|
||||||
setLocalLabel,
|
setLocalLabel,
|
||||||
|
setDistanceUnit,
|
||||||
handleCloseSettingsView,
|
handleCloseSettingsView,
|
||||||
handleToggleSettingsView,
|
handleToggleSettingsView,
|
||||||
handleOpenNewMessage,
|
handleOpenNewMessage: openNewMessageModal,
|
||||||
handleCloseNewMessage,
|
handleCloseNewMessage: closeNewMessageModal,
|
||||||
handleToggleCracker,
|
handleToggleCracker,
|
||||||
} = useAppShell();
|
} = useAppShell();
|
||||||
|
|
||||||
@@ -174,6 +192,7 @@ export function App() {
|
|||||||
handleCreateContact,
|
handleCreateContact,
|
||||||
handleCreateChannel,
|
handleCreateChannel,
|
||||||
handleCreateHashtagChannel,
|
handleCreateHashtagChannel,
|
||||||
|
handleBulkCreateHashtagChannels,
|
||||||
handleDeleteChannel,
|
handleDeleteChannel,
|
||||||
handleDeleteContact,
|
handleDeleteContact,
|
||||||
} = useContactsAndChannels({
|
} = useContactsAndChannels({
|
||||||
@@ -244,6 +263,21 @@ export function App() {
|
|||||||
} = useConversationMessages(activeConversation, targetMessageId);
|
} = useConversationMessages(activeConversation, targetMessageId);
|
||||||
removeConversationMessagesRef.current = removeConversationMessages;
|
removeConversationMessagesRef.current = removeConversationMessages;
|
||||||
|
|
||||||
|
// Room servers replay stored history as a burst of DMs, all arriving with similar received_at
|
||||||
|
// but spanning a wide range of sender_timestamps. Sort by sender_timestamp for room contacts
|
||||||
|
// so the display reflects the original send order rather than our radio's receipt order.
|
||||||
|
const activeContactIsRoom =
|
||||||
|
activeConversation?.type === 'contact' &&
|
||||||
|
contacts.find((c) => c.public_key === activeConversation.id)?.type === CONTACT_TYPE_ROOM;
|
||||||
|
const sortedMessages = useMemo(() => {
|
||||||
|
if (!activeContactIsRoom || messages.length === 0) return messages;
|
||||||
|
return [...messages].sort((a, b) => {
|
||||||
|
const aTs = a.sender_timestamp ?? a.received_at;
|
||||||
|
const bTs = b.sender_timestamp ?? b.received_at;
|
||||||
|
return aTs !== bTs ? aTs - bTs : a.id - b.id;
|
||||||
|
});
|
||||||
|
}, [activeContactIsRoom, messages]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
unreadCounts,
|
unreadCounts,
|
||||||
mentions,
|
mentions,
|
||||||
@@ -251,9 +285,12 @@ export function App() {
|
|||||||
unreadLastReadAts,
|
unreadLastReadAts,
|
||||||
recordMessageEvent,
|
recordMessageEvent,
|
||||||
renameConversationState,
|
renameConversationState,
|
||||||
|
removeConversationState,
|
||||||
markAllRead,
|
markAllRead,
|
||||||
refreshUnreads,
|
refreshUnreads,
|
||||||
} = useUnreadCounts(channels, contacts, activeConversation);
|
} = useUnreadCounts(channels, contacts, activeConversation);
|
||||||
|
useFaviconBadge(unreadCounts, mentions, favorites);
|
||||||
|
useUnreadTitle(unreadCounts, favorites);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeConversation?.type !== 'channel') {
|
if (activeConversation?.type !== 'channel') {
|
||||||
@@ -324,6 +361,7 @@ export function App() {
|
|||||||
observeMessage,
|
observeMessage,
|
||||||
recordMessageEvent,
|
recordMessageEvent,
|
||||||
renameConversationState,
|
renameConversationState,
|
||||||
|
removeConversationState,
|
||||||
checkMention,
|
checkMention,
|
||||||
pendingDeleteFallbackRef,
|
pendingDeleteFallbackRef,
|
||||||
setActiveConversation,
|
setActiveConversation,
|
||||||
@@ -331,6 +369,7 @@ export function App() {
|
|||||||
removeConversationMessages,
|
removeConversationMessages,
|
||||||
receiveMessageAck,
|
receiveMessageAck,
|
||||||
notifyIncomingMessage,
|
notifyIncomingMessage,
|
||||||
|
recordRawPacketObservation,
|
||||||
});
|
});
|
||||||
const handleVisibilityPolicyChanged = useCallback(() => {
|
const handleVisibilityPolicyChanged = useCallback(() => {
|
||||||
clearConversationMessages();
|
clearConversationMessages();
|
||||||
@@ -385,6 +424,52 @@ export function App() {
|
|||||||
[fetchUndecryptedCount, setChannels]
|
[fetchUndecryptedCount, setChannels]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleOpenNewMessage = useCallback(
|
||||||
|
(event?: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
setNewMessagePrefillRequest(null);
|
||||||
|
setShowBulkAddChannelTab(event?.altKey === true);
|
||||||
|
openNewMessageModal();
|
||||||
|
},
|
||||||
|
[openNewMessageModal]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCloseNewMessage = useCallback(() => {
|
||||||
|
setNewMessagePrefillRequest(null);
|
||||||
|
setShowBulkAddChannelTab(false);
|
||||||
|
closeNewMessageModal();
|
||||||
|
}, [closeNewMessageModal]);
|
||||||
|
|
||||||
|
const handleCloseBulkAddResults = useCallback(() => {
|
||||||
|
setBulkAddResult(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChannelReferenceClick = useCallback(
|
||||||
|
(channelName: string) => {
|
||||||
|
const existingChannel = channels.find((channel) => channel.name === channelName);
|
||||||
|
if (existingChannel) {
|
||||||
|
handleNavigateToChannel(existingChannel.key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setNewMessagePrefillRequest((previous) => ({
|
||||||
|
tab: 'hashtag',
|
||||||
|
hashtagName: channelName.slice(1),
|
||||||
|
nonce: (previous?.nonce ?? 0) + 1,
|
||||||
|
}));
|
||||||
|
setShowBulkAddChannelTab(false);
|
||||||
|
openNewMessageModal();
|
||||||
|
},
|
||||||
|
[channels, handleNavigateToChannel, openNewMessageModal]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBulkAddChannels = useCallback(
|
||||||
|
async (channelNames: string[], tryHistorical: boolean) => {
|
||||||
|
const result = await handleBulkCreateHashtagChannels(channelNames, tryHistorical);
|
||||||
|
setBulkAddResult(result);
|
||||||
|
},
|
||||||
|
[handleBulkCreateHashtagChannels]
|
||||||
|
);
|
||||||
|
|
||||||
const statusProps = {
|
const statusProps = {
|
||||||
health,
|
health,
|
||||||
config,
|
config,
|
||||||
@@ -407,16 +492,22 @@ export function App() {
|
|||||||
favorites,
|
favorites,
|
||||||
legacySortOrder: appSettings?.sidebar_sort_order,
|
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,
|
||||||
contacts,
|
contacts,
|
||||||
channels,
|
channels,
|
||||||
rawPackets,
|
rawPackets,
|
||||||
|
rawPacketStatsSession,
|
||||||
config,
|
config,
|
||||||
health,
|
health,
|
||||||
favorites,
|
favorites,
|
||||||
messages,
|
messages: sortedMessages,
|
||||||
messagesLoading,
|
messagesLoading,
|
||||||
loadingOlder,
|
loadingOlder,
|
||||||
hasOlderMessages,
|
hasOlderMessages,
|
||||||
@@ -430,6 +521,7 @@ export function App() {
|
|||||||
loadingNewer,
|
loadingNewer,
|
||||||
messageInputRef,
|
messageInputRef,
|
||||||
onTrace: handleTrace,
|
onTrace: handleTrace,
|
||||||
|
onRunTracePath: api.requestRadioTrace,
|
||||||
onPathDiscovery: handlePathDiscovery,
|
onPathDiscovery: handlePathDiscovery,
|
||||||
onToggleFavorite: handleToggleFavorite,
|
onToggleFavorite: handleToggleFavorite,
|
||||||
onDeleteContact: handleDeleteContact,
|
onDeleteContact: handleDeleteContact,
|
||||||
@@ -438,6 +530,7 @@ export function App() {
|
|||||||
onOpenContactInfo: handleOpenContactInfo,
|
onOpenContactInfo: handleOpenContactInfo,
|
||||||
onOpenChannelInfo: handleOpenChannelInfo,
|
onOpenChannelInfo: handleOpenChannelInfo,
|
||||||
onSenderClick: handleSenderClick,
|
onSenderClick: handleSenderClick,
|
||||||
|
onChannelReferenceClick: handleChannelReferenceClick,
|
||||||
onLoadOlder: fetchOlderMessages,
|
onLoadOlder: fetchOlderMessages,
|
||||||
onResendChannelMessage: handleResendChannelMessage,
|
onResendChannelMessage: handleResendChannelMessage,
|
||||||
onTargetReached: () => setTargetMessageId(null),
|
onTargetReached: () => setTargetMessageId(null),
|
||||||
@@ -488,6 +581,11 @@ 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())));
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const crackerProps = {
|
const crackerProps = {
|
||||||
packets: rawPackets,
|
packets: rawPackets,
|
||||||
@@ -495,12 +593,13 @@ export function App() {
|
|||||||
onChannelCreate: handleCreateCrackedChannel,
|
onChannelCreate: handleCreateCrackedChannel,
|
||||||
};
|
};
|
||||||
const newMessageModalProps = {
|
const newMessageModalProps = {
|
||||||
contacts,
|
|
||||||
undecryptedCount,
|
undecryptedCount,
|
||||||
onSelectConversation: handleSelectConversationWithTargetReset,
|
showBulkAddChannelTab,
|
||||||
|
prefillRequest: newMessagePrefillRequest,
|
||||||
onCreateContact: handleCreateContact,
|
onCreateContact: handleCreateContact,
|
||||||
onCreateChannel: handleCreateChannel,
|
onCreateChannel: handleCreateChannel,
|
||||||
onCreateHashtagChannel: handleCreateHashtagChannel,
|
onCreateHashtagChannel: handleCreateHashtagChannel,
|
||||||
|
onBulkAddHashtagChannels: handleBulkAddChannels,
|
||||||
};
|
};
|
||||||
const contactInfoPaneProps = {
|
const contactInfoPaneProps = {
|
||||||
contactKey: infoPaneContactKey,
|
contactKey: infoPaneContactKey,
|
||||||
@@ -560,29 +659,34 @@ export function App() {
|
|||||||
setContactsLoaded,
|
setContactsLoaded,
|
||||||
]);
|
]);
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<DistanceUnitProvider distanceUnit={distanceUnit} setDistanceUnit={setDistanceUnit}>
|
||||||
localLabel={localLabel}
|
<AppShell
|
||||||
showNewMessage={showNewMessage}
|
localLabel={localLabel}
|
||||||
showSettings={showSettings}
|
showNewMessage={showNewMessage}
|
||||||
settingsSection={settingsSection}
|
showBulkAddResults={bulkAddResult !== null}
|
||||||
sidebarOpen={sidebarOpen}
|
showSettings={showSettings}
|
||||||
showCracker={showCracker}
|
settingsSection={settingsSection}
|
||||||
onSettingsSectionChange={setSettingsSection}
|
sidebarOpen={sidebarOpen}
|
||||||
onSidebarOpenChange={setSidebarOpen}
|
showCracker={showCracker}
|
||||||
onCrackerRunningChange={setCrackerRunning}
|
onSettingsSectionChange={setSettingsSection}
|
||||||
onToggleSettingsView={handleToggleSettingsView}
|
onSidebarOpenChange={setSidebarOpen}
|
||||||
onCloseSettingsView={handleCloseSettingsView}
|
onCrackerRunningChange={setCrackerRunning}
|
||||||
onCloseNewMessage={handleCloseNewMessage}
|
onToggleSettingsView={handleToggleSettingsView}
|
||||||
onLocalLabelChange={setLocalLabel}
|
onCloseSettingsView={handleCloseSettingsView}
|
||||||
statusProps={statusProps}
|
onCloseNewMessage={handleCloseNewMessage}
|
||||||
sidebarProps={sidebarProps}
|
onCloseBulkAddResults={handleCloseBulkAddResults}
|
||||||
conversationPaneProps={conversationPaneProps}
|
onLocalLabelChange={setLocalLabel}
|
||||||
searchProps={searchProps}
|
statusProps={statusProps}
|
||||||
settingsProps={settingsProps}
|
sidebarProps={sidebarProps}
|
||||||
crackerProps={crackerProps}
|
conversationPaneProps={conversationPaneProps}
|
||||||
newMessageModalProps={newMessageModalProps}
|
searchProps={searchProps}
|
||||||
contactInfoPaneProps={contactInfoPaneProps}
|
settingsProps={settingsProps}
|
||||||
channelInfoPaneProps={channelInfoPaneProps}
|
crackerProps={crackerProps}
|
||||||
/>
|
newMessageModalProps={newMessageModalProps}
|
||||||
|
bulkAddChannelResultModalProps={bulkAddChannelResultModalProps}
|
||||||
|
contactInfoPaneProps={contactInfoPaneProps}
|
||||||
|
channelInfoPaneProps={channelInfoPaneProps}
|
||||||
|
/>
|
||||||
|
</DistanceUnitProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-2
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
AppSettings,
|
AppSettings,
|
||||||
AppSettingsUpdate,
|
AppSettingsUpdate,
|
||||||
|
BulkCreateHashtagChannelsResult,
|
||||||
Channel,
|
Channel,
|
||||||
ChannelDetail,
|
ChannelDetail,
|
||||||
CommandResponse,
|
CommandResponse,
|
||||||
@@ -15,10 +16,13 @@ import type {
|
|||||||
MessagesAroundResponse,
|
MessagesAroundResponse,
|
||||||
MigratePreferencesRequest,
|
MigratePreferencesRequest,
|
||||||
MigratePreferencesResponse,
|
MigratePreferencesResponse,
|
||||||
|
RawPacket,
|
||||||
RadioAdvertMode,
|
RadioAdvertMode,
|
||||||
RadioConfig,
|
RadioConfig,
|
||||||
RadioConfigUpdate,
|
RadioConfigUpdate,
|
||||||
RadioDiscoveryResponse,
|
RadioDiscoveryResponse,
|
||||||
|
RadioTraceHopRequest,
|
||||||
|
RadioTraceResponse,
|
||||||
RadioDiscoveryTarget,
|
RadioDiscoveryTarget,
|
||||||
PathDiscoveryResponse,
|
PathDiscoveryResponse,
|
||||||
ResendChannelMessageResponse,
|
ResendChannelMessageResponse,
|
||||||
@@ -31,6 +35,7 @@ import type {
|
|||||||
RepeaterOwnerInfoResponse,
|
RepeaterOwnerInfoResponse,
|
||||||
RepeaterRadioSettingsResponse,
|
RepeaterRadioSettingsResponse,
|
||||||
RepeaterStatusResponse,
|
RepeaterStatusResponse,
|
||||||
|
TelemetryHistoryEntry,
|
||||||
StatisticsResponse,
|
StatisticsResponse,
|
||||||
TraceResponse,
|
TraceResponse,
|
||||||
UnreadCounts,
|
UnreadCounts,
|
||||||
@@ -106,6 +111,11 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ target }),
|
body: JSON.stringify({ target }),
|
||||||
}),
|
}),
|
||||||
|
requestRadioTrace: (hopHashBytes: 1 | 2 | 4, hops: RadioTraceHopRequest[]) =>
|
||||||
|
fetchJson<RadioTraceResponse>('/radio/trace', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ hop_hash_bytes: hopHashBytes, hops }),
|
||||||
|
}),
|
||||||
rebootRadio: () =>
|
rebootRadio: () =>
|
||||||
fetchJson<{ status: string; message: string }>('/radio/reboot', {
|
fetchJson<{ status: string; message: string }>('/radio/reboot', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -129,16 +139,24 @@ export const api = {
|
|||||||
fetchJson<ContactAdvertPathSummary[]>(
|
fetchJson<ContactAdvertPathSummary[]>(
|
||||||
`/contacts/repeaters/advert-paths?limit_per_repeater=${limitPerRepeater}`
|
`/contacts/repeaters/advert-paths?limit_per_repeater=${limitPerRepeater}`
|
||||||
),
|
),
|
||||||
getContactAnalytics: (params: { publicKey?: string; name?: string }) => {
|
getContactAnalytics: (params: { publicKey?: string; name?: string }, signal?: AbortSignal) => {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
if (params.publicKey) searchParams.set('public_key', params.publicKey);
|
if (params.publicKey) searchParams.set('public_key', params.publicKey);
|
||||||
if (params.name) searchParams.set('name', params.name);
|
if (params.name) searchParams.set('name', params.name);
|
||||||
return fetchJson<ContactAnalytics>(`/contacts/analytics?${searchParams.toString()}`);
|
return fetchJson<ContactAnalytics>(`/contacts/analytics?${searchParams.toString()}`, {
|
||||||
|
signal,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
deleteContact: (publicKey: string) =>
|
deleteContact: (publicKey: string) =>
|
||||||
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',
|
||||||
@@ -174,6 +192,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`),
|
||||||
@@ -247,6 +270,7 @@ export const api = {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Packets
|
// Packets
|
||||||
|
getPacket: (packetId: number) => fetchJson<RawPacket>(`/packets/${packetId}`),
|
||||||
getUndecryptedPacketCount: () => fetchJson<{ count: number }>('/packets/undecrypted/count'),
|
getUndecryptedPacketCount: () => fetchJson<{ count: number }>('/packets/undecrypted/count'),
|
||||||
decryptHistoricalPackets: (params: {
|
decryptHistoricalPackets: (params: {
|
||||||
key_type: 'channel' | 'contact';
|
key_type: 'channel' | 'contact';
|
||||||
@@ -341,6 +365,14 @@ export const api = {
|
|||||||
fetchJson<{ deleted: boolean }>(`/fanout/${id}`, {
|
fetchJson<{ deleted: boolean }>(`/fanout/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
}),
|
}),
|
||||||
|
disableBotsUntilRestart: () =>
|
||||||
|
fetchJson<{
|
||||||
|
status: string;
|
||||||
|
bots_disabled: boolean;
|
||||||
|
bots_disabled_source: 'env' | 'until_restart';
|
||||||
|
}>('/fanout/bots/disable-until-restart', {
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
|
||||||
// Statistics
|
// Statistics
|
||||||
getStatistics: () => fetchJson<StatisticsResponse>('/statistics'),
|
getStatistics: () => fetchJson<StatisticsResponse>('/statistics'),
|
||||||
@@ -383,4 +415,23 @@ 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) =>
|
||||||
|
fetchJson<RepeaterLoginResponse>(`/contacts/${publicKey}/room/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
}),
|
||||||
|
roomStatus: (publicKey: string) =>
|
||||||
|
fetchJson<RepeaterStatusResponse>(`/contacts/${publicKey}/room/status`, {
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
roomAcl: (publicKey: string) =>
|
||||||
|
fetchJson<RepeaterAclResponse>(`/contacts/${publicKey}/room/acl`, {
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
roomLppTelemetry: (publicKey: string) =>
|
||||||
|
fetchJson<RepeaterLppTelemetryResponse>(`/contacts/${publicKey}/room/lpp-telemetry`, {
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { lazy, Suspense, useRef, type ComponentProps } from 'react';
|
import { lazy, Suspense, useRef, type ComponentProps } from 'react';
|
||||||
|
import { useSwipeable } from 'react-swipeable';
|
||||||
|
|
||||||
import { StatusBar } from './StatusBar';
|
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 { Toaster } from './ui/sonner';
|
import { Toaster } from './ui/sonner';
|
||||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
|
||||||
import {
|
import {
|
||||||
@@ -31,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;
|
||||||
@@ -48,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;
|
||||||
@@ -59,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;
|
||||||
}
|
}
|
||||||
@@ -66,6 +76,7 @@ interface AppShellProps {
|
|||||||
export function AppShell({
|
export function AppShell({
|
||||||
localLabel,
|
localLabel,
|
||||||
showNewMessage,
|
showNewMessage,
|
||||||
|
showBulkAddResults,
|
||||||
showSettings,
|
showSettings,
|
||||||
settingsSection,
|
settingsSection,
|
||||||
sidebarOpen,
|
sidebarOpen,
|
||||||
@@ -77,6 +88,7 @@ export function AppShell({
|
|||||||
onToggleSettingsView,
|
onToggleSettingsView,
|
||||||
onCloseSettingsView,
|
onCloseSettingsView,
|
||||||
onCloseNewMessage,
|
onCloseNewMessage,
|
||||||
|
onCloseBulkAddResults,
|
||||||
onLocalLabelChange,
|
onLocalLabelChange,
|
||||||
statusProps,
|
statusProps,
|
||||||
sidebarProps,
|
sidebarProps,
|
||||||
@@ -85,9 +97,28 @@ export function AppShell({
|
|||||||
settingsProps,
|
settingsProps,
|
||||||
crackerProps,
|
crackerProps,
|
||||||
newMessageModalProps,
|
newMessageModalProps,
|
||||||
|
bulkAddChannelResultModalProps,
|
||||||
contactInfoPaneProps,
|
contactInfoPaneProps,
|
||||||
channelInfoPaneProps,
|
channelInfoPaneProps,
|
||||||
}: AppShellProps) {
|
}: AppShellProps) {
|
||||||
|
const swipeHandlers = useSwipeable({
|
||||||
|
onSwipedRight: ({ initial }) => {
|
||||||
|
if (initial[0] < 30 && !sidebarOpen && window.innerWidth < 768) {
|
||||||
|
onSidebarOpenChange(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trackTouch: true,
|
||||||
|
trackMouse: false,
|
||||||
|
preventScrollOnSwipe: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeSwipeHandlers = useSwipeable({
|
||||||
|
onSwipedLeft: () => onSidebarOpenChange(false),
|
||||||
|
trackTouch: true,
|
||||||
|
trackMouse: false,
|
||||||
|
preventScrollOnSwipe: false,
|
||||||
|
});
|
||||||
|
|
||||||
const searchMounted = useRef(false);
|
const searchMounted = useRef(false);
|
||||||
if (conversationPaneProps.activeConversation?.type === 'search') {
|
if (conversationPaneProps.activeConversation?.type === 'search') {
|
||||||
searchMounted.current = true;
|
searchMounted.current = true;
|
||||||
@@ -152,7 +183,7 @@ export function AppShell({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full" {...swipeHandlers}>
|
||||||
<a
|
<a
|
||||||
href="#main-content"
|
href="#main-content"
|
||||||
className="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:p-2 focus:bg-primary focus:text-primary-foreground"
|
className="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:p-2 focus:bg-primary focus:text-primary-foreground"
|
||||||
@@ -195,7 +226,9 @@ export function AppShell({
|
|||||||
<SheetTitle>Navigation</SheetTitle>
|
<SheetTitle>Navigation</SheetTitle>
|
||||||
<SheetDescription>Sidebar navigation</SheetDescription>
|
<SheetDescription>Sidebar navigation</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="flex-1 overflow-hidden">{activeSidebarContent}</div>
|
<div className="flex-1 overflow-hidden" {...closeSwipeHandlers}>
|
||||||
|
{activeSidebarContent}
|
||||||
|
</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
@@ -283,12 +316,14 @@ export function AppShell({
|
|||||||
{...newMessageModalProps}
|
{...newMessageModalProps}
|
||||||
open={showNewMessage}
|
open={showNewMessage}
|
||||||
onClose={onCloseNewMessage}
|
onClose={onCloseNewMessage}
|
||||||
onSelectConversation={(conv) => {
|
/>
|
||||||
newMessageModalProps.onSelectConversation(conv);
|
<BulkAddChannelResultModal
|
||||||
onCloseNewMessage();
|
{...bulkAddChannelResultModalProps}
|
||||||
}}
|
open={showBulkAddResults}
|
||||||
|
onClose={onCloseBulkAddResults}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SecurityWarningModal health={statusProps.health} />
|
||||||
<ContactInfoPane {...contactInfoPaneProps} />
|
<ContactInfoPane {...contactInfoPaneProps} />
|
||||||
<ChannelInfoPane {...channelInfoPaneProps} />
|
<ChannelInfoPane {...channelInfoPaneProps} />
|
||||||
<Toaster position="top-right" />
|
<Toaster position="top-right" />
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
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-xs uppercase tracking-wide 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-xs uppercase tracking-wide 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -45,8 +45,8 @@ export function ChannelFloodScopeOverrideModal({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Regional Override</DialogTitle>
|
<DialogTitle>Regional Override</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Room-level regional routing temporarily changes the radio flood scope before send and
|
Channel-level regional routing temporarily changes the radio flood scope before send and
|
||||||
restores it after. This can noticeably slow room sends.
|
restores it after. This can noticeably slow channel sends.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { handleKeyboardActivate } from '../utils/a11y';
|
|||||||
import { isPublicChannelKey } from '../utils/publicChannel';
|
import { isPublicChannelKey } from '../utils/publicChannel';
|
||||||
import { stripRegionScopePrefix } from '../utils/regionScope';
|
import { stripRegionScopePrefix } from '../utils/regionScope';
|
||||||
import { isPrefixOnlyContact } from '../utils/pubkey';
|
import { isPrefixOnlyContact } from '../utils/pubkey';
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
import { ContactAvatar } from './ContactAvatar';
|
import { ContactAvatar } from './ContactAvatar';
|
||||||
import { ContactStatusInfo } from './ContactStatusInfo';
|
import { ContactStatusInfo } from './ContactStatusInfo';
|
||||||
import type {
|
import type {
|
||||||
@@ -19,6 +20,7 @@ import type {
|
|||||||
PathDiscoveryResponse,
|
PathDiscoveryResponse,
|
||||||
RadioConfig,
|
RadioConfig,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
import { CONTACT_TYPE_ROOM } from '../types';
|
||||||
|
|
||||||
interface ChatHeaderProps {
|
interface ChatHeaderProps {
|
||||||
conversation: Conversation;
|
conversation: Conversation;
|
||||||
@@ -84,6 +86,7 @@ export function ChatHeader({
|
|||||||
conversation.type === 'contact'
|
conversation.type === 'contact'
|
||||||
? contacts.find((contact) => contact.public_key === conversation.id)
|
? contacts.find((contact) => contact.public_key === conversation.id)
|
||||||
: null;
|
: null;
|
||||||
|
const activeContactIsRoomServer = activeContact?.type === CONTACT_TYPE_ROOM;
|
||||||
const activeContactIsPrefixOnly = activeContact
|
const activeContactIsPrefixOnly = activeContact
|
||||||
? isPrefixOnlyContact(activeContact.public_key)
|
? isPrefixOnlyContact(activeContact.public_key)
|
||||||
: false;
|
: false;
|
||||||
@@ -116,8 +119,15 @@ export function ChatHeader({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="conversation-header flex justify-between items-start px-4 py-2.5 border-b border-border gap-2">
|
<header
|
||||||
<span className="flex min-w-0 flex-1 items-start gap-2">
|
className={cn(
|
||||||
|
'conversation-header grid items-start gap-x-2 gap-y-0.5 border-b border-border px-4 py-2.5',
|
||||||
|
conversation.type === 'contact' && activeContact
|
||||||
|
? 'grid-cols-[minmax(0,1fr)_auto] min-[1100px]:grid-cols-[minmax(0,1fr)_auto_auto]'
|
||||||
|
: 'grid-cols-[minmax(0,1fr)_auto]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 items-start gap-2">
|
||||||
{conversation.type === 'contact' && onOpenContactInfo && (
|
{conversation.type === 'contact' && onOpenContactInfo && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -135,16 +145,31 @@ export function ChatHeader({
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<span className="grid min-w-0 flex-1 grid-cols-1 gap-y-0.5 min-[1200px]:grid-cols-[minmax(0,1fr)_auto] min-[1200px]:items-baseline min-[1200px]:gap-x-2">
|
<span className="flex min-w-0 flex-1 flex-col">
|
||||||
<span className="flex min-w-0 items-baseline gap-2 whitespace-nowrap">
|
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||||
<h2 className="min-w-0 shrink font-semibold text-base">
|
<span className="flex min-w-0 flex-1 items-baseline gap-2 whitespace-nowrap">
|
||||||
{titleClickable ? (
|
<h2 className="min-w-0 flex-shrink font-semibold text-base">
|
||||||
<button
|
{titleClickable ? (
|
||||||
type="button"
|
<button
|
||||||
className="flex min-w-0 shrink items-center gap-1.5 text-left hover:text-primary transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
|
type="button"
|
||||||
aria-label={`View info for ${conversation.name}`}
|
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"
|
||||||
onClick={handleOpenConversationInfo}
|
aria-label={`View info for ${conversation.name}`}
|
||||||
>
|
onClick={handleOpenConversationInfo}
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{conversation.type === 'channel' &&
|
||||||
|
!conversation.name.startsWith('#') &&
|
||||||
|
activeChannel?.is_hashtag
|
||||||
|
? '#'
|
||||||
|
: ''}
|
||||||
|
{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">
|
<span className="truncate">
|
||||||
{conversation.type === 'channel' &&
|
{conversation.type === 'channel' &&
|
||||||
!conversation.name.startsWith('#') &&
|
!conversation.name.startsWith('#') &&
|
||||||
@@ -153,84 +178,75 @@ export function ChatHeader({
|
|||||||
: ''}
|
: ''}
|
||||||
{conversation.name}
|
{conversation.name}
|
||||||
</span>
|
</span>
|
||||||
<Info
|
)}
|
||||||
className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80"
|
</h2>
|
||||||
aria-hidden="true"
|
{isPrivateChannel && !showKey ? (
|
||||||
/>
|
<button
|
||||||
|
className="min-w-0 flex-shrink text-[11px] font-mono text-muted-foreground transition-colors hover:text-primary"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowKey(true);
|
||||||
|
}}
|
||||||
|
title="Reveal channel key"
|
||||||
|
>
|
||||||
|
Show Key
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<span className="truncate">
|
<span
|
||||||
{conversation.type === 'channel' &&
|
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary"
|
||||||
!conversation.name.startsWith('#') &&
|
role="button"
|
||||||
activeChannel?.is_hashtag
|
tabIndex={0}
|
||||||
? '#'
|
onKeyDown={handleKeyboardActivate}
|
||||||
: ''}
|
onClick={(e) => {
|
||||||
{conversation.name}
|
e.stopPropagation();
|
||||||
|
navigator.clipboard.writeText(conversation.id);
|
||||||
|
toast.success(
|
||||||
|
conversation.type === 'channel'
|
||||||
|
? 'Channel key copied!'
|
||||||
|
: 'Contact key copied!'
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
title="Click to copy"
|
||||||
|
aria-label={
|
||||||
|
conversation.type === 'channel' ? 'Copy channel key' : 'Copy contact key'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{conversation.type === 'channel'
|
||||||
|
? conversation.id.toLowerCase()
|
||||||
|
: conversation.id}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</h2>
|
</span>
|
||||||
{isPrivateChannel && !showKey ? (
|
{conversation.type === 'channel' && activeFloodScopeDisplay && (
|
||||||
<button
|
<button
|
||||||
className="min-w-0 flex-shrink text-[11px] font-mono text-muted-foreground transition-colors hover:text-primary"
|
className="mt-0.5 flex basis-full items-center gap-1 text-left sm:hidden"
|
||||||
onClick={(e) => {
|
onClick={handleEditFloodScopeOverride}
|
||||||
e.stopPropagation();
|
title="Set regional override"
|
||||||
setShowKey(true);
|
aria-label="Set regional override"
|
||||||
}}
|
|
||||||
title="Reveal channel key"
|
|
||||||
>
|
>
|
||||||
Show Key
|
<Globe2
|
||||||
|
className="h-3.5 w-3.5 flex-shrink-0 text-[hsl(var(--region-override))]"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 truncate text-[11px] font-medium text-[hsl(var(--region-override))]">
|
||||||
|
{activeFloodScopeDisplay}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary"
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={handleKeyboardActivate}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
navigator.clipboard.writeText(conversation.id);
|
|
||||||
toast.success(
|
|
||||||
conversation.type === 'channel' ? 'Room key copied!' : 'Contact key copied!'
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
title="Click to copy"
|
|
||||||
aria-label={
|
|
||||||
conversation.type === 'channel' ? 'Copy channel key' : 'Copy contact key'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{conversation.type === 'channel' ? conversation.id.toLowerCase() : conversation.id}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{conversation.type === 'contact' && activeContact && (
|
|
||||||
<span className="min-w-0 text-[11px] text-muted-foreground min-[1200px]:justify-self-end">
|
|
||||||
<ContactStatusInfo
|
|
||||||
contact={activeContact}
|
|
||||||
ourLat={config?.lat ?? null}
|
|
||||||
ourLon={config?.lon ?? null}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{conversation.type === 'channel' && activeFloodScopeDisplay && (
|
|
||||||
<button
|
|
||||||
className="mt-0.5 flex items-center gap-1 text-left sm:hidden"
|
|
||||||
onClick={handleEditFloodScopeOverride}
|
|
||||||
title="Set regional override"
|
|
||||||
aria-label="Set regional override"
|
|
||||||
>
|
|
||||||
<Globe2
|
|
||||||
className="h-3.5 w-3.5 flex-shrink-0 text-[hsl(var(--region-override))]"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<span className="min-w-0 truncate text-[11px] font-medium text-[hsl(var(--region-override))]">
|
|
||||||
{activeFloodScopeDisplay}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center justify-end gap-0.5 flex-shrink-0">
|
{conversation.type === 'contact' && activeContact && (
|
||||||
{conversation.type === '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">
|
||||||
|
<ContactStatusInfo
|
||||||
|
contact={activeContact}
|
||||||
|
ourLat={config?.lat ?? null}
|
||||||
|
ourLon={config?.lon ?? null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-end gap-0.5">
|
||||||
|
{conversation.type === 'contact' && !activeContactIsRoomServer && (
|
||||||
<button
|
<button
|
||||||
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
onClick={() => setPathDiscoveryOpen(true)}
|
onClick={() => setPathDiscoveryOpen(true)}
|
||||||
@@ -245,14 +261,14 @@ export function ChatHeader({
|
|||||||
<Route className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
<Route className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{conversation.type === 'contact' && (
|
{conversation.type === 'contact' && !activeContactIsRoomServer && (
|
||||||
<button
|
<button
|
||||||
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
onClick={onTrace}
|
onClick={onTrace}
|
||||||
title={
|
title={
|
||||||
activeContactIsPrefixOnly
|
activeContactIsPrefixOnly
|
||||||
? 'Direct Trace unavailable until the full contact key is known'
|
? 'Direct Trace unavailable until the full contact key is known'
|
||||||
: 'Direct Trace. Send a zero-hop packet to this contact and display out and back SNR'
|
: 'Direct Trace. Send a direct trace probe to this contact and display out and back SNR'
|
||||||
}
|
}
|
||||||
aria-label="Direct Trace"
|
aria-label="Direct Trace"
|
||||||
disabled={activeContactIsPrefixOnly}
|
disabled={activeContactIsPrefixOnly}
|
||||||
@@ -260,7 +276,7 @@ export function ChatHeader({
|
|||||||
<DirectTraceIcon className="h-4 w-4 text-muted-foreground" />
|
<DirectTraceIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{notificationsSupported && (
|
{notificationsSupported && !activeContactIsRoomServer && (
|
||||||
<button
|
<button
|
||||||
className="flex items-center gap-1 rounded px-1 py-1 hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="flex items-center gap-1 rounded px-1 py-1 hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
onClick={onToggleNotifications}
|
onClick={onToggleNotifications}
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
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 { api } from '../api';
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip as RechartsTooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Legend,
|
||||||
|
} from 'recharts';
|
||||||
|
import { api, isAbortError } from '../api';
|
||||||
import { formatTime } from '../utils/messageParser';
|
import { formatTime } from '../utils/messageParser';
|
||||||
import {
|
import {
|
||||||
getContactDisplayName,
|
getContactDisplayName,
|
||||||
@@ -12,6 +22,7 @@ import {
|
|||||||
calculateDistance,
|
calculateDistance,
|
||||||
formatDistance,
|
formatDistance,
|
||||||
formatRouteLabel,
|
formatRouteLabel,
|
||||||
|
getDirectContactRoute,
|
||||||
getEffectiveContactRoute,
|
getEffectiveContactRoute,
|
||||||
hasRoutingOverride,
|
hasRoutingOverride,
|
||||||
parsePathHops,
|
parsePathHops,
|
||||||
@@ -23,6 +34,8 @@ import { handleKeyboardActivate } from '../utils/a11y';
|
|||||||
import { ContactAvatar } from './ContactAvatar';
|
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 { CONTACT_TYPE_REPEATER } from '../types';
|
||||||
import type {
|
import type {
|
||||||
Contact,
|
Contact,
|
||||||
ContactActiveRoom,
|
ContactActiveRoom,
|
||||||
@@ -81,6 +94,7 @@ export function ContactInfoPane({
|
|||||||
onToggleBlockedKey,
|
onToggleBlockedKey,
|
||||||
onToggleBlockedName,
|
onToggleBlockedName,
|
||||||
}: ContactInfoPaneProps) {
|
}: ContactInfoPaneProps) {
|
||||||
|
const { distanceUnit } = useDistanceUnit();
|
||||||
const isNameOnly = contactKey?.startsWith('name:') ?? false;
|
const isNameOnly = contactKey?.startsWith('name:') ?? false;
|
||||||
const nameOnlyValue = isNameOnly && contactKey ? contactKey.slice(5) : null;
|
const nameOnlyValue = isNameOnly && contactKey ? contactKey.slice(5) : null;
|
||||||
|
|
||||||
@@ -97,29 +111,29 @@ export function ContactInfoPane({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
const controller = new AbortController();
|
||||||
setAnalytics(null);
|
setAnalytics(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const request =
|
const request =
|
||||||
isNameOnly && nameOnlyValue
|
isNameOnly && nameOnlyValue
|
||||||
? api.getContactAnalytics({ name: nameOnlyValue })
|
? api.getContactAnalytics({ name: nameOnlyValue }, controller.signal)
|
||||||
: api.getContactAnalytics({ publicKey: contactKey });
|
: api.getContactAnalytics({ publicKey: contactKey }, controller.signal);
|
||||||
|
|
||||||
request
|
request
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (!cancelled) setAnalytics(data);
|
if (!controller.signal.aborted) setAnalytics(data);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (!cancelled) {
|
if (!isAbortError(err)) {
|
||||||
console.error('Failed to fetch contact analytics:', err);
|
console.error('Failed to fetch contact analytics:', err);
|
||||||
toast.error('Failed to load contact info');
|
toast.error('Failed to load contact info');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setLoading(false);
|
if (!controller.signal.aborted) setLoading(false);
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
controller.abort();
|
||||||
};
|
};
|
||||||
}, [contactKey, isNameOnly, nameOnlyValue]);
|
}, [contactKey, isNameOnly, nameOnlyValue]);
|
||||||
|
|
||||||
@@ -134,16 +148,18 @@ export function ContactInfoPane({
|
|||||||
? calculateDistance(config.lat, config.lon, contact.lat, contact.lon)
|
? calculateDistance(config.lat, config.lon, contact.lat, contact.lon)
|
||||||
: null;
|
: null;
|
||||||
const effectiveRoute = contact ? getEffectiveContactRoute(contact) : null;
|
const effectiveRoute = contact ? getEffectiveContactRoute(contact) : null;
|
||||||
|
const directRoute = contact ? getDirectContactRoute(contact) : null;
|
||||||
const pathHashModeLabel =
|
const pathHashModeLabel =
|
||||||
effectiveRoute && effectiveRoute.pathLen >= 0
|
effectiveRoute && effectiveRoute.pathLen >= 0
|
||||||
? formatPathHashMode(effectiveRoute.pathHashMode)
|
? formatPathHashMode(effectiveRoute.pathHashMode)
|
||||||
: null;
|
: null;
|
||||||
const learnedRouteLabel = contact ? formatRouteLabel(contact.last_path_len, true) : null;
|
const learnedRouteLabel = directRoute ? formatRouteLabel(directRoute.path_len, true) : null;
|
||||||
const isPrefixOnlyResolvedContact = contact ? isPrefixOnlyContact(contact.public_key) : false;
|
const isPrefixOnlyResolvedContact = contact ? isPrefixOnlyContact(contact.public_key) : false;
|
||||||
const isUnknownFullKeyResolvedContact =
|
const isUnknownFullKeyResolvedContact =
|
||||||
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()}>
|
||||||
@@ -238,8 +254,8 @@ export function ContactInfoPane({
|
|||||||
|
|
||||||
<ActivityChartsSection analytics={analytics} />
|
<ActivityChartsSection analytics={analytics} />
|
||||||
|
|
||||||
<MostActiveRoomsSection
|
<MostActiveChannelsSection
|
||||||
rooms={analytics?.most_active_rooms ?? []}
|
channels={analytics?.most_active_rooms ?? []}
|
||||||
onNavigateToChannel={onNavigateToChannel}
|
onNavigateToChannel={onNavigateToChannel}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -313,7 +329,7 @@ export function ContactInfoPane({
|
|||||||
<InfoItem label="Last Contacted" value={formatTime(contact.last_contacted)} />
|
<InfoItem label="Last Contacted" value={formatTime(contact.last_contacted)} />
|
||||||
)}
|
)}
|
||||||
{distFromUs !== null && (
|
{distFromUs !== null && (
|
||||||
<InfoItem label="Distance" value={formatDistance(distFromUs)} />
|
<InfoItem label="Distance" value={formatDistance(distFromUs, distanceUnit)} />
|
||||||
)}
|
)}
|
||||||
{effectiveRoute && (
|
{effectiveRoute && (
|
||||||
<InfoItem
|
<InfoItem
|
||||||
@@ -330,7 +346,7 @@ export function ContactInfoPane({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{contact && hasRoutingOverride(contact) && learnedRouteLabel && (
|
{hasRoutingOverride(contact) && learnedRouteLabel && (
|
||||||
<InfoItem label="Learned Route" value={learnedRouteLabel} />
|
<InfoItem label="Learned Route" value={learnedRouteLabel} />
|
||||||
)}
|
)}
|
||||||
{pathHashModeLabel && <InfoItem label="Hop Width" value={pathHashModeLabel} />}
|
{pathHashModeLabel && <InfoItem label="Hop Width" value={pathHashModeLabel} />}
|
||||||
@@ -426,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"
|
||||||
@@ -439,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>
|
||||||
@@ -504,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} />
|
||||||
|
|
||||||
<MostActiveRoomsSection
|
<MostActiveChannelsSection
|
||||||
rooms={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">
|
||||||
@@ -584,23 +624,23 @@ function MessageStatsSection({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MostActiveRoomsSection({
|
function MostActiveChannelsSection({
|
||||||
rooms,
|
channels,
|
||||||
onNavigateToChannel,
|
onNavigateToChannel,
|
||||||
}: {
|
}: {
|
||||||
rooms: ContactActiveRoom[];
|
channels: ContactActiveRoom[];
|
||||||
onNavigateToChannel?: (channelKey: string) => void;
|
onNavigateToChannel?: (channelKey: string) => void;
|
||||||
}) {
|
}) {
|
||||||
if (rooms.length === 0) {
|
if (channels.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-5 py-3 border-b border-border">
|
<div className="px-5 py-3 border-b border-border">
|
||||||
<SectionLabel>Most Active Rooms</SectionLabel>
|
<SectionLabel>Most Active Channels</SectionLabel>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{rooms.map((room) => (
|
{channels.map((channel) => (
|
||||||
<div key={room.channel_key} className="flex justify-between items-center text-sm">
|
<div key={channel.channel_key} className="flex justify-between items-center text-sm">
|
||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
onNavigateToChannel
|
onNavigateToChannel
|
||||||
@@ -610,15 +650,15 @@ function MostActiveRoomsSection({
|
|||||||
role={onNavigateToChannel ? 'button' : undefined}
|
role={onNavigateToChannel ? 'button' : undefined}
|
||||||
tabIndex={onNavigateToChannel ? 0 : undefined}
|
tabIndex={onNavigateToChannel ? 0 : undefined}
|
||||||
onKeyDown={onNavigateToChannel ? handleKeyboardActivate : undefined}
|
onKeyDown={onNavigateToChannel ? handleKeyboardActivate : undefined}
|
||||||
onClick={() => onNavigateToChannel?.(room.channel_key)}
|
onClick={() => onNavigateToChannel?.(channel.channel_key)}
|
||||||
>
|
>
|
||||||
{room.channel_name.startsWith('#') || isPublicChannelKey(room.channel_key)
|
{channel.channel_name.startsWith('#') || isPublicChannelKey(channel.channel_key)
|
||||||
? room.channel_name
|
? channel.channel_name
|
||||||
: `#${room.channel_name}`}
|
: `#${channel.channel_name}`}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||||
{room.message_count.toLocaleString()} msg
|
{channel.message_count.toLocaleString()} msg
|
||||||
{room.message_count !== 1 ? 's' : ''}
|
{channel.message_count !== 1 ? 's' : ''}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -646,20 +686,18 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu
|
|||||||
{hasHourlyActivity && (
|
{hasHourlyActivity && (
|
||||||
<div>
|
<div>
|
||||||
<SectionLabel>Messages Per Hour</SectionLabel>
|
<SectionLabel>Messages Per Hour</SectionLabel>
|
||||||
<ChartLegend
|
|
||||||
items={[
|
|
||||||
{ label: 'Last 24h', color: '#2563eb' },
|
|
||||||
{ label: '7-day avg', color: '#ea580c' },
|
|
||||||
{ label: 'All-time avg', color: '#64748b' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<ActivityLineChart
|
<ActivityLineChart
|
||||||
ariaLabel="Messages per hour"
|
ariaLabel="Messages per hour"
|
||||||
points={analytics.hourly_activity}
|
points={analytics.hourly_activity}
|
||||||
series={[
|
series={[
|
||||||
{ key: 'last_24h_count', color: '#2563eb' },
|
{ key: 'last_24h_count', color: '#2563eb', label: 'Last 24h' },
|
||||||
{ key: 'last_week_average', color: '#ea580c' },
|
{ key: 'last_week_average', color: '#ea580c', label: '7-day avg' },
|
||||||
{ key: 'all_time_average', color: '#64748b' },
|
{ key: 'all_time_average', color: '#64748b', label: 'All-time avg' },
|
||||||
|
]}
|
||||||
|
legendItems={[
|
||||||
|
{ label: 'Last 24h', color: '#2563eb' },
|
||||||
|
{ label: '7-day avg', color: '#ea580c' },
|
||||||
|
{ label: 'All-time avg', color: '#64748b' },
|
||||||
]}
|
]}
|
||||||
valueFormatter={(value) => value.toFixed(value % 1 === 0 ? 0 : 1)}
|
valueFormatter={(value) => value.toFixed(value % 1 === 0 ? 0 : 1)}
|
||||||
tickFormatter={(bucket) =>
|
tickFormatter={(bucket) =>
|
||||||
@@ -679,7 +717,7 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu
|
|||||||
<ActivityLineChart
|
<ActivityLineChart
|
||||||
ariaLabel="Messages per week"
|
ariaLabel="Messages per week"
|
||||||
points={analytics.weekly_activity}
|
points={analytics.weekly_activity}
|
||||||
series={[{ key: 'message_count', color: '#16a34a' }]}
|
series={[{ key: 'message_count', color: '#16a34a', label: 'Messages' }]}
|
||||||
valueFormatter={(value) => value.toFixed(0)}
|
valueFormatter={(value) => value.toFixed(0)}
|
||||||
tickFormatter={(bucket) =>
|
tickFormatter={(bucket) =>
|
||||||
new Date(bucket.bucket_start * 1000).toLocaleDateString([], {
|
new Date(bucket.bucket_start * 1000).toLocaleDateString([], {
|
||||||
@@ -701,133 +739,169 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChartLegend({ items }: { items: Array<{ label: string; color: string }> }) {
|
const TOOLTIP_STYLE = {
|
||||||
return (
|
contentStyle: {
|
||||||
<div className="flex flex-wrap gap-x-3 gap-y-1 mb-2 text-[11px] text-muted-foreground">
|
backgroundColor: 'hsl(var(--popover))',
|
||||||
{items.map((item) => (
|
border: '1px solid hsl(var(--border))',
|
||||||
<span key={item.label} className="inline-flex items-center gap-1.5">
|
borderRadius: '6px',
|
||||||
<span
|
fontSize: '11px',
|
||||||
className="inline-block h-2 w-2 rounded-full"
|
color: 'hsl(var(--popover-foreground))',
|
||||||
style={{ backgroundColor: item.color }}
|
},
|
||||||
aria-hidden="true"
|
itemStyle: { color: 'hsl(var(--popover-foreground))' },
|
||||||
/>
|
labelStyle: { color: 'hsl(var(--muted-foreground))' },
|
||||||
{item.label}
|
} as const;
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActivityLineChart<T extends ContactAnalyticsHourlyBucket | ContactAnalyticsWeeklyBucket>({
|
function ActivityLineChart<T extends ContactAnalyticsHourlyBucket | ContactAnalyticsWeeklyBucket>({
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
points,
|
points,
|
||||||
series,
|
series,
|
||||||
|
legendItems,
|
||||||
tickFormatter,
|
tickFormatter,
|
||||||
valueFormatter,
|
valueFormatter,
|
||||||
}: {
|
}: {
|
||||||
ariaLabel: string;
|
ariaLabel: string;
|
||||||
points: T[];
|
points: T[];
|
||||||
series: Array<{ key: keyof T; color: string }>;
|
series: Array<{ key: keyof T; color: string; label?: string }>;
|
||||||
|
legendItems?: Array<{ label: string; color: string }>;
|
||||||
tickFormatter: (point: T) => string;
|
tickFormatter: (point: T) => string;
|
||||||
valueFormatter: (value: number) => string;
|
valueFormatter: (value: number) => string;
|
||||||
}) {
|
}) {
|
||||||
const width = 320;
|
const data = points.map((point, i) => {
|
||||||
const height = 132;
|
const entry: Record<string, string | number> = { idx: i, tick: tickFormatter(point) };
|
||||||
const padding = { top: 8, right: 8, bottom: 24, left: 32 };
|
for (const s of series) {
|
||||||
const plotWidth = width - padding.left - padding.right;
|
const raw = point[s.key];
|
||||||
const plotHeight = height - padding.top - padding.bottom;
|
entry[String(s.key)] = typeof raw === 'number' ? raw : 0;
|
||||||
const allValues = points.flatMap((point) =>
|
}
|
||||||
series.map((entry) => {
|
return entry;
|
||||||
const value = point[entry.key];
|
});
|
||||||
return typeof value === 'number' ? value : 0;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const maxValue = Math.max(1, ...allValues);
|
|
||||||
const tickIndices = Array.from(
|
|
||||||
new Set([
|
|
||||||
0,
|
|
||||||
Math.floor((points.length - 1) / 3),
|
|
||||||
Math.floor(((points.length - 1) * 2) / 3),
|
|
||||||
points.length - 1,
|
|
||||||
])
|
|
||||||
);
|
|
||||||
|
|
||||||
const buildPolyline = (key: keyof T) =>
|
const tickCount = Math.min(5, points.length);
|
||||||
points
|
const tickIndices: number[] = [];
|
||||||
.map((point, index) => {
|
if (points.length > 1) {
|
||||||
const rawValue = point[key];
|
for (let i = 0; i < tickCount; i++) {
|
||||||
const value = typeof rawValue === 'number' ? rawValue : 0;
|
tickIndices.push(Math.round((i / (tickCount - 1)) * (points.length - 1)));
|
||||||
const x =
|
}
|
||||||
padding.left + (points.length === 1 ? 0 : (index / (points.length - 1)) * plotWidth);
|
}
|
||||||
const y = padding.top + plotHeight - (value / maxValue) * plotHeight;
|
|
||||||
return `${x},${y}`;
|
|
||||||
})
|
|
||||||
.join(' ');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div role="img" aria-label={ariaLabel}>
|
||||||
<svg
|
<ResponsiveContainer width="100%" height={140}>
|
||||||
viewBox={`0 0 ${width} ${height}`}
|
<LineChart data={data} margin={{ top: 4, right: 4, bottom: 0, left: -16 }}>
|
||||||
className="w-full h-auto"
|
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
||||||
role="img"
|
<XAxis
|
||||||
aria-label={ariaLabel}
|
dataKey="idx"
|
||||||
>
|
type="number"
|
||||||
{[0, 0.5, 1].map((ratio) => {
|
domain={[0, Math.max(1, points.length - 1)]}
|
||||||
const y = padding.top + plotHeight - ratio * plotHeight;
|
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
const value = maxValue * ratio;
|
tickLine={false}
|
||||||
return (
|
axisLine={false}
|
||||||
<g key={ratio}>
|
ticks={tickIndices}
|
||||||
<line
|
tickFormatter={(idx) => String(data[idx]?.tick ?? '')}
|
||||||
x1={padding.left}
|
|
||||||
x2={width - padding.right}
|
|
||||||
y1={y}
|
|
||||||
y2={y}
|
|
||||||
stroke="hsl(var(--border))"
|
|
||||||
strokeWidth="1"
|
|
||||||
/>
|
|
||||||
<text
|
|
||||||
x={padding.left - 6}
|
|
||||||
y={y + 4}
|
|
||||||
fontSize="10"
|
|
||||||
textAnchor="end"
|
|
||||||
fill="hsl(var(--muted-foreground))"
|
|
||||||
>
|
|
||||||
{valueFormatter(value)}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{series.map((entry) => (
|
|
||||||
<polyline
|
|
||||||
key={String(entry.key)}
|
|
||||||
fill="none"
|
|
||||||
stroke={entry.color}
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeLinecap="round"
|
|
||||||
points={buildPolyline(entry.key)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
<YAxis
|
||||||
|
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={(v) => valueFormatter(v)}
|
||||||
|
width={40}
|
||||||
|
/>
|
||||||
|
<RechartsTooltip
|
||||||
|
{...TOOLTIP_STYLE}
|
||||||
|
cursor={{
|
||||||
|
stroke: 'hsl(var(--muted-foreground))',
|
||||||
|
strokeWidth: 1,
|
||||||
|
strokeDasharray: '3 3',
|
||||||
|
}}
|
||||||
|
labelFormatter={(idx) => String(data[Number(idx)]?.tick ?? '')}
|
||||||
|
formatter={(value, name) => {
|
||||||
|
const match = series.find((s) => String(s.key) === name);
|
||||||
|
return [valueFormatter(Number(value)), match?.label ?? String(name)];
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{legendItems && (
|
||||||
|
<Legend
|
||||||
|
content={() => (
|
||||||
|
<div className="flex flex-wrap justify-center gap-x-3 gap-y-1 mt-1 text-[11px] text-muted-foreground">
|
||||||
|
{legendItems.map((item) => (
|
||||||
|
<span key={item.label} className="inline-flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="inline-block h-2 w-2 rounded-full"
|
||||||
|
style={{ backgroundColor: item.color }}
|
||||||
|
/>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{series.map((entry) => (
|
||||||
|
<Line
|
||||||
|
key={String(entry.key)}
|
||||||
|
type="linear"
|
||||||
|
dataKey={String(entry.key)}
|
||||||
|
stroke={entry.color}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 4, strokeWidth: 2, stroke: 'hsl(var(--popover))' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{tickIndices.map((index) => {
|
function NearbyRepeatersSection({
|
||||||
const point = points[index];
|
contact,
|
||||||
const x =
|
contacts,
|
||||||
padding.left + (points.length === 1 ? 0 : (index / (points.length - 1)) * plotWidth);
|
distanceUnit,
|
||||||
return (
|
}: {
|
||||||
<text
|
contact: Contact;
|
||||||
key={`${ariaLabel}-${point.bucket_start}`}
|
contacts: Contact[];
|
||||||
x={x}
|
distanceUnit: import('../utils/distanceUnits').DistanceUnit;
|
||||||
y={height - 6}
|
}) {
|
||||||
fontSize="10"
|
const nearby = useMemo(() => {
|
||||||
textAnchor={index === 0 ? 'start' : index === points.length - 1 ? 'end' : 'middle'}
|
const sevenDaysAgo = Math.floor(Date.now() / 1000) - 7 * 86400;
|
||||||
fill="hsl(var(--muted-foreground))"
|
const results: Array<{ name: string; publicKey: string; distance: number }> = [];
|
||||||
>
|
for (const other of contacts) {
|
||||||
{tickFormatter(point)}
|
const heardAt = Math.max(other.last_seen ?? 0, other.last_advert ?? 0);
|
||||||
</text>
|
if (
|
||||||
);
|
other.public_key === contact.public_key ||
|
||||||
})}
|
other.type !== CONTACT_TYPE_REPEATER ||
|
||||||
</svg>
|
!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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { Contact, PathDiscoveryResponse, PathDiscoveryRoute } from '../type
|
|||||||
import {
|
import {
|
||||||
findContactsByPrefix,
|
findContactsByPrefix,
|
||||||
formatRouteLabel,
|
formatRouteLabel,
|
||||||
|
getDirectContactRoute,
|
||||||
getEffectiveContactRoute,
|
getEffectiveContactRoute,
|
||||||
hasRoutingOverride,
|
hasRoutingOverride,
|
||||||
parsePathHops,
|
parsePathHops,
|
||||||
@@ -99,16 +100,17 @@ export function ContactPathDiscoveryModal({
|
|||||||
const [result, setResult] = useState<PathDiscoveryResponse | null>(null);
|
const [result, setResult] = useState<PathDiscoveryResponse | null>(null);
|
||||||
|
|
||||||
const effectiveRoute = useMemo(() => getEffectiveContactRoute(contact), [contact]);
|
const effectiveRoute = useMemo(() => getEffectiveContactRoute(contact), [contact]);
|
||||||
|
const directRoute = useMemo(() => getDirectContactRoute(contact), [contact]);
|
||||||
const hasForcedRoute = hasRoutingOverride(contact);
|
const hasForcedRoute = hasRoutingOverride(contact);
|
||||||
const learnedRouteSummary = useMemo(() => {
|
const learnedRouteSummary = useMemo(() => {
|
||||||
if (contact.last_path_len === -1) {
|
if (!directRoute) {
|
||||||
return 'Flood';
|
return 'Flood';
|
||||||
}
|
}
|
||||||
const hops = parsePathHops(contact.last_path, contact.last_path_len);
|
const hops = parsePathHops(directRoute.path, directRoute.path_len);
|
||||||
return hops.length > 0
|
return hops.length > 0
|
||||||
? `${formatRouteLabel(contact.last_path_len, true)} (${hops.join(' -> ')})`
|
? `${formatRouteLabel(directRoute.path_len, true)} (${hops.join(' -> ')})`
|
||||||
: formatRouteLabel(contact.last_path_len, true);
|
: formatRouteLabel(directRoute.path_len, true);
|
||||||
}, [contact.last_path, contact.last_path_len]);
|
}, [directRoute]);
|
||||||
const forcedRouteSummary = useMemo(() => {
|
const forcedRouteSummary = useMemo(() => {
|
||||||
if (!hasForcedRoute) {
|
if (!hasForcedRoute) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { Contact } from '../types';
|
|||||||
import {
|
import {
|
||||||
formatRouteLabel,
|
formatRouteLabel,
|
||||||
formatRoutingOverrideInput,
|
formatRoutingOverrideInput,
|
||||||
|
getDirectContactRoute,
|
||||||
hasRoutingOverride,
|
hasRoutingOverride,
|
||||||
} from '../utils/pathUtils';
|
} from '../utils/pathUtils';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
@@ -28,7 +29,7 @@ interface ContactRoutingOverrideModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function summarizeLearnedRoute(contact: Contact): string {
|
function summarizeLearnedRoute(contact: Contact): string {
|
||||||
return formatRouteLabel(contact.last_path_len, true);
|
return formatRouteLabel(getDirectContactRoute(contact)?.path_len ?? -1, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function summarizeForcedRoute(contact: Contact): string | null {
|
function summarizeForcedRoute(contact: Contact): string | null {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { getMapFocusHash } from '../utils/urlHash';
|
import { getMapFocusHash } from '../utils/urlHash';
|
||||||
import { handleKeyboardActivate } from '../utils/a11y';
|
import { handleKeyboardActivate } from '../utils/a11y';
|
||||||
import type { Contact } from '../types';
|
import type { Contact } from '../types';
|
||||||
|
import { useDistanceUnit } from '../contexts/DistanceUnitContext';
|
||||||
import { ContactRoutingOverrideModal } from './ContactRoutingOverrideModal';
|
import { ContactRoutingOverrideModal } from './ContactRoutingOverrideModal';
|
||||||
|
|
||||||
interface ContactStatusInfoProps {
|
interface ContactStatusInfoProps {
|
||||||
@@ -24,6 +25,7 @@ interface ContactStatusInfoProps {
|
|||||||
* shared between ChatHeader and RepeaterDashboard.
|
* shared between ChatHeader and RepeaterDashboard.
|
||||||
*/
|
*/
|
||||||
export function ContactStatusInfo({ contact, ourLat, ourLon }: ContactStatusInfoProps) {
|
export function ContactStatusInfo({ contact, ourLat, ourLon }: ContactStatusInfoProps) {
|
||||||
|
const { distanceUnit } = useDistanceUnit();
|
||||||
const [routingModalOpen, setRoutingModalOpen] = useState(false);
|
const [routingModalOpen, setRoutingModalOpen] = useState(false);
|
||||||
const parts: ReactNode[] = [];
|
const parts: ReactNode[] = [];
|
||||||
const effectiveRoute = getEffectiveContactRoute(contact);
|
const effectiveRoute = getEffectiveContactRoute(contact);
|
||||||
@@ -74,7 +76,7 @@ export function ContactStatusInfo({ contact, ourLat, ourLon }: ContactStatusInfo
|
|||||||
>
|
>
|
||||||
{contact.lat!.toFixed(3)}, {contact.lon!.toFixed(3)}
|
{contact.lat!.toFixed(3)}, {contact.lon!.toFixed(3)}
|
||||||
</span>
|
</span>
|
||||||
{distFromUs !== null && ` (${formatDistance(distFromUs)})`}
|
{distFromUs !== null && ` (${formatDistance(distFromUs, distanceUnit)})`}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { lazy, Suspense, useMemo, type Ref } from 'react';
|
import { lazy, Suspense, useEffect, useMemo, useState, type Ref } from 'react';
|
||||||
|
|
||||||
import { ChatHeader } from './ChatHeader';
|
import { ChatHeader } from './ChatHeader';
|
||||||
import { MessageInput, type MessageInputHandle } from './MessageInput';
|
import { MessageInput, type MessageInputHandle } from './MessageInput';
|
||||||
import { MessageList } from './MessageList';
|
import { MessageList } from './MessageList';
|
||||||
import { RawPacketList } from './RawPacketList';
|
import { RawPacketFeedView } from './RawPacketFeedView';
|
||||||
|
import { RoomServerPanel } from './RoomServerPanel';
|
||||||
|
import { TracePane } from './TracePane';
|
||||||
import type {
|
import type {
|
||||||
Channel,
|
Channel,
|
||||||
Contact,
|
Contact,
|
||||||
@@ -14,8 +16,11 @@ import type {
|
|||||||
PathDiscoveryResponse,
|
PathDiscoveryResponse,
|
||||||
RawPacket,
|
RawPacket,
|
||||||
RadioConfig,
|
RadioConfig,
|
||||||
|
RadioTraceHopRequest,
|
||||||
|
RadioTraceResponse,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { CONTACT_TYPE_REPEATER } from '../types';
|
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
|
||||||
|
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
|
||||||
import { isPrefixOnlyContact, isUnknownFullKeyContact } from '../utils/pubkey';
|
import { isPrefixOnlyContact, isUnknownFullKeyContact } from '../utils/pubkey';
|
||||||
|
|
||||||
const RepeaterDashboard = lazy(() =>
|
const RepeaterDashboard = lazy(() =>
|
||||||
@@ -31,6 +36,7 @@ interface ConversationPaneProps {
|
|||||||
contacts: Contact[];
|
contacts: Contact[];
|
||||||
channels: Channel[];
|
channels: Channel[];
|
||||||
rawPackets: RawPacket[];
|
rawPackets: RawPacket[];
|
||||||
|
rawPacketStatsSession: RawPacketStatsSessionState;
|
||||||
config: RadioConfig | null;
|
config: RadioConfig | null;
|
||||||
health: HealthStatus | null;
|
health: HealthStatus | null;
|
||||||
notificationsSupported: boolean;
|
notificationsSupported: boolean;
|
||||||
@@ -47,6 +53,10 @@ interface ConversationPaneProps {
|
|||||||
loadingNewer: boolean;
|
loadingNewer: boolean;
|
||||||
messageInputRef: Ref<MessageInputHandle>;
|
messageInputRef: Ref<MessageInputHandle>;
|
||||||
onTrace: () => Promise<void>;
|
onTrace: () => Promise<void>;
|
||||||
|
onRunTracePath: (
|
||||||
|
hopHashBytes: 1 | 2 | 4,
|
||||||
|
hops: RadioTraceHopRequest[]
|
||||||
|
) => Promise<RadioTraceResponse>;
|
||||||
onPathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
|
onPathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
|
||||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => Promise<void>;
|
onToggleFavorite: (type: 'channel' | 'contact', id: string) => Promise<void>;
|
||||||
onDeleteContact: (publicKey: string) => Promise<void>;
|
onDeleteContact: (publicKey: string) => Promise<void>;
|
||||||
@@ -55,6 +65,7 @@ interface ConversationPaneProps {
|
|||||||
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;
|
||||||
|
onChannelReferenceClick?: (channelName: string) => void;
|
||||||
onLoadOlder: () => Promise<void>;
|
onLoadOlder: () => Promise<void>;
|
||||||
onResendChannelMessage: (messageId: number, newTimestamp?: boolean) => Promise<void>;
|
onResendChannelMessage: (messageId: number, newTimestamp?: boolean) => Promise<void>;
|
||||||
onTargetReached: () => void;
|
onTargetReached: () => void;
|
||||||
@@ -95,6 +106,7 @@ export function ConversationPane({
|
|||||||
contacts,
|
contacts,
|
||||||
channels,
|
channels,
|
||||||
rawPackets,
|
rawPackets,
|
||||||
|
rawPacketStatsSession,
|
||||||
config,
|
config,
|
||||||
health,
|
health,
|
||||||
notificationsSupported,
|
notificationsSupported,
|
||||||
@@ -111,6 +123,7 @@ export function ConversationPane({
|
|||||||
loadingNewer,
|
loadingNewer,
|
||||||
messageInputRef,
|
messageInputRef,
|
||||||
onTrace,
|
onTrace,
|
||||||
|
onRunTracePath,
|
||||||
onPathDiscovery,
|
onPathDiscovery,
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
onDeleteContact,
|
onDeleteContact,
|
||||||
@@ -119,6 +132,7 @@ export function ConversationPane({
|
|||||||
onOpenContactInfo,
|
onOpenContactInfo,
|
||||||
onOpenChannelInfo,
|
onOpenChannelInfo,
|
||||||
onSenderClick,
|
onSenderClick,
|
||||||
|
onChannelReferenceClick,
|
||||||
onLoadOlder,
|
onLoadOlder,
|
||||||
onResendChannelMessage,
|
onResendChannelMessage,
|
||||||
onTargetReached,
|
onTargetReached,
|
||||||
@@ -128,6 +142,7 @@ export function ConversationPane({
|
|||||||
onSendMessage,
|
onSendMessage,
|
||||||
onToggleNotifications,
|
onToggleNotifications,
|
||||||
}: ConversationPaneProps) {
|
}: ConversationPaneProps) {
|
||||||
|
const [roomAuthenticated, setRoomAuthenticated] = useState(false);
|
||||||
const activeContactIsRepeater = useMemo(() => {
|
const activeContactIsRepeater = useMemo(() => {
|
||||||
if (!activeConversation || activeConversation.type !== 'contact') return false;
|
if (!activeConversation || activeConversation.type !== 'contact') return false;
|
||||||
const contact = contacts.find((candidate) => candidate.public_key === activeConversation.id);
|
const contact = contacts.find((candidate) => candidate.public_key === activeConversation.id);
|
||||||
@@ -137,6 +152,10 @@ export function ConversationPane({
|
|||||||
if (!activeConversation || activeConversation.type !== 'contact') return null;
|
if (!activeConversation || activeConversation.type !== 'contact') return null;
|
||||||
return contacts.find((candidate) => candidate.public_key === activeConversation.id) ?? null;
|
return contacts.find((candidate) => candidate.public_key === activeConversation.id) ?? null;
|
||||||
}, [activeConversation, contacts]);
|
}, [activeConversation, contacts]);
|
||||||
|
const activeContactIsRoom = activeContact?.type === CONTACT_TYPE_ROOM;
|
||||||
|
useEffect(() => {
|
||||||
|
setRoomAuthenticated(false);
|
||||||
|
}, [activeConversation?.id]);
|
||||||
const isPrefixOnlyActiveContact = activeContact
|
const isPrefixOnlyActiveContact = activeContact
|
||||||
? isPrefixOnlyContact(activeContact.public_key)
|
? isPrefixOnlyContact(activeContact.public_key)
|
||||||
: false;
|
: false;
|
||||||
@@ -178,14 +197,12 @@ export function ConversationPane({
|
|||||||
|
|
||||||
if (activeConversation.type === 'raw') {
|
if (activeConversation.type === 'raw') {
|
||||||
return (
|
return (
|
||||||
<>
|
<RawPacketFeedView
|
||||||
<h2 className="flex justify-between items-center px-4 py-2.5 border-b border-border font-semibold text-base">
|
packets={rawPackets}
|
||||||
Raw Packet Feed
|
rawPacketStatsSession={rawPacketStatsSession}
|
||||||
</h2>
|
contacts={contacts}
|
||||||
<div className="flex-1 overflow-hidden">
|
channels={channels}
|
||||||
<RawPacketList packets={rawPackets} />
|
/>
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +210,10 @@ export function ConversationPane({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (activeConversation.type === 'trace') {
|
||||||
|
return <TracePane contacts={contacts} config={config} onRunTracePath={onRunTracePath} />;
|
||||||
|
}
|
||||||
|
|
||||||
if (activeContactIsRepeater) {
|
if (activeContactIsRepeater) {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<LoadingPane label="Loading dashboard..." />}>
|
<Suspense fallback={<LoadingPane label="Loading dashboard..." />}>
|
||||||
@@ -212,11 +233,14 @@ export function ConversationPane({
|
|||||||
onToggleNotifications={onToggleNotifications}
|
onToggleNotifications={onToggleNotifications}
|
||||||
onToggleFavorite={onToggleFavorite}
|
onToggleFavorite={onToggleFavorite}
|
||||||
onDeleteContact={onDeleteContact}
|
onDeleteContact={onDeleteContact}
|
||||||
|
onOpenContactInfo={onOpenContactInfo}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showRoomChat = !activeContactIsRoom || roomAuthenticated;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ChatHeader
|
<ChatHeader
|
||||||
@@ -244,35 +268,42 @@ export function ConversationPane({
|
|||||||
{activeConversation.type === 'contact' && isUnknownFullKeyActiveContact && (
|
{activeConversation.type === 'contact' && isUnknownFullKeyActiveContact && (
|
||||||
<ContactResolutionBanner variant="unknown-full-key" />
|
<ContactResolutionBanner variant="unknown-full-key" />
|
||||||
)}
|
)}
|
||||||
<MessageList
|
{activeContactIsRoom && activeContact && (
|
||||||
key={activeConversation.id}
|
<RoomServerPanel contact={activeContact} onAuthenticatedChange={setRoomAuthenticated} />
|
||||||
messages={messages}
|
)}
|
||||||
contacts={contacts}
|
{showRoomChat && (
|
||||||
loading={messagesLoading}
|
<MessageList
|
||||||
loadingOlder={loadingOlder}
|
key={activeConversation.id}
|
||||||
hasOlderMessages={hasOlderMessages}
|
messages={messages}
|
||||||
unreadMarkerLastReadAt={
|
contacts={contacts}
|
||||||
activeConversation.type === 'channel' ? unreadMarkerLastReadAt : undefined
|
channels={channels}
|
||||||
}
|
loading={messagesLoading}
|
||||||
onDismissUnreadMarker={
|
loadingOlder={loadingOlder}
|
||||||
activeConversation.type === 'channel' ? onDismissUnreadMarker : undefined
|
hasOlderMessages={hasOlderMessages}
|
||||||
}
|
unreadMarkerLastReadAt={
|
||||||
onSenderClick={activeConversation.type === 'channel' ? onSenderClick : undefined}
|
activeConversation.type === 'channel' ? unreadMarkerLastReadAt : undefined
|
||||||
onLoadOlder={onLoadOlder}
|
}
|
||||||
onResendChannelMessage={
|
onDismissUnreadMarker={
|
||||||
activeConversation.type === 'channel' ? onResendChannelMessage : undefined
|
activeConversation.type === 'channel' ? onDismissUnreadMarker : undefined
|
||||||
}
|
}
|
||||||
radioName={config?.name}
|
onSenderClick={activeConversation.type === 'channel' ? onSenderClick : undefined}
|
||||||
config={config}
|
onChannelReferenceClick={onChannelReferenceClick}
|
||||||
onOpenContactInfo={onOpenContactInfo}
|
onLoadOlder={onLoadOlder}
|
||||||
targetMessageId={targetMessageId}
|
onResendChannelMessage={
|
||||||
onTargetReached={onTargetReached}
|
activeConversation.type === 'channel' ? onResendChannelMessage : undefined
|
||||||
hasNewerMessages={hasNewerMessages}
|
}
|
||||||
loadingNewer={loadingNewer}
|
radioName={config?.name}
|
||||||
onLoadNewer={onLoadNewer}
|
config={config}
|
||||||
onJumpToBottom={onJumpToBottom}
|
onOpenContactInfo={onOpenContactInfo}
|
||||||
/>
|
targetMessageId={targetMessageId}
|
||||||
{activeConversation.type === 'contact' && isPrefixOnlyActiveContact ? null : (
|
onTargetReached={onTargetReached}
|
||||||
|
hasNewerMessages={hasNewerMessages}
|
||||||
|
loadingNewer={loadingNewer}
|
||||||
|
onLoadNewer={onLoadNewer}
|
||||||
|
onJumpToBottom={onJumpToBottom}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showRoomChat && !(activeConversation.type === 'contact' && isPrefixOnlyActiveContact) ? (
|
||||||
<MessageInput
|
<MessageInput
|
||||||
ref={messageInputRef}
|
ref={messageInputRef}
|
||||||
onSend={onSendMessage}
|
onSend={onSendMessage}
|
||||||
@@ -285,7 +316,7 @@ export function ConversationPane({
|
|||||||
: `Message ${activeConversation.name}...`
|
: `Message ${activeConversation.name}...`
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import { toast } from './ui/sonner';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { extractPacketPayloadHex } from '../utils/pathUtils';
|
import { extractPacketPayloadHex } from '../utils/pathUtils';
|
||||||
|
|
||||||
interface CrackedRoom {
|
interface CrackedChannel {
|
||||||
roomName: string;
|
channelName: string;
|
||||||
key: string;
|
key: string;
|
||||||
packetId: number;
|
packetId: number;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -39,13 +39,14 @@ export function CrackerPanel({
|
|||||||
}: CrackerPanelProps) {
|
}: CrackerPanelProps) {
|
||||||
const [isRunning, setIsRunning] = useState(false);
|
const [isRunning, setIsRunning] = useState(false);
|
||||||
const [maxLength, setMaxLength] = useState(6);
|
const [maxLength, setMaxLength] = useState(6);
|
||||||
|
const [maxLengthInput, setMaxLengthInput] = useState('6');
|
||||||
const [retryFailedAtNextLength, setRetryFailedAtNextLength] = useState(false);
|
const [retryFailedAtNextLength, setRetryFailedAtNextLength] = useState(false);
|
||||||
const [decryptHistorical, setDecryptHistorical] = useState(true);
|
const [decryptHistorical, setDecryptHistorical] = useState(true);
|
||||||
const [turboMode, setTurboMode] = useState(false);
|
const [turboMode, setTurboMode] = useState(false);
|
||||||
const [twoWordMode, setTwoWordMode] = useState(false);
|
const [twoWordMode, setTwoWordMode] = useState(false);
|
||||||
const [progress, setProgress] = useState<ProgressReport | null>(null);
|
const [progress, setProgress] = useState<ProgressReport | null>(null);
|
||||||
const [queue, setQueue] = useState<Map<number, QueueItem>>(new Map());
|
const [queue, setQueue] = useState<Map<number, QueueItem>>(new Map());
|
||||||
const [crackedRooms, setCrackedRooms] = useState<CrackedRoom[]>([]);
|
const [crackedChannels, setCrackedChannels] = useState<CrackedChannel[]>([]);
|
||||||
const [wordlistLoaded, setWordlistLoaded] = useState(false);
|
const [wordlistLoaded, setWordlistLoaded] = useState(false);
|
||||||
const [gpuAvailable, setGpuAvailable] = useState<boolean | null>(null);
|
const [gpuAvailable, setGpuAvailable] = useState<boolean | null>(null);
|
||||||
const [undecryptedPacketCount, setUndecryptedPacketCount] = useState<number | null>(null);
|
const [undecryptedPacketCount, setUndecryptedPacketCount] = useState<number | null>(null);
|
||||||
@@ -127,8 +128,9 @@ export function CrackerPanel({
|
|||||||
}, [existingChannelKeys]);
|
}, [existingChannelKeys]);
|
||||||
|
|
||||||
// Filter packets to only undecrypted GROUP_TEXT
|
// Filter packets to only undecrypted GROUP_TEXT
|
||||||
const undecryptedGroupText = packets.filter(
|
const undecryptedGroupText = useMemo(
|
||||||
(p) => p.payload_type === 'GROUP_TEXT' && !p.decrypted
|
() => packets.filter((p) => p.payload_type === 'GROUP_TEXT' && !p.decrypted),
|
||||||
|
[packets]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update queue when packets change (deduplicated by payload)
|
// Update queue when packets change (deduplicated by payload)
|
||||||
@@ -191,6 +193,10 @@ export function CrackerPanel({
|
|||||||
maxLengthRef.current = maxLength;
|
maxLengthRef.current = maxLength;
|
||||||
}, [maxLength]);
|
}, [maxLength]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMaxLengthInput(String(maxLength));
|
||||||
|
}, [maxLength]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
decryptHistoricalRef.current = decryptHistorical;
|
decryptHistoricalRef.current = decryptHistorical;
|
||||||
}, [decryptHistorical]);
|
}, [decryptHistorical]);
|
||||||
@@ -325,14 +331,14 @@ export function CrackerPanel({
|
|||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
|
|
||||||
const newRoom: CrackedRoom = {
|
const newCracked: CrackedChannel = {
|
||||||
roomName: result.roomName,
|
channelName: result.roomName,
|
||||||
key: result.key,
|
key: result.key,
|
||||||
packetId: nextId!,
|
packetId: nextId!,
|
||||||
message: result.decryptedMessage || '',
|
message: result.decryptedMessage || '',
|
||||||
crackedAt: Date.now(),
|
crackedAt: Date.now(),
|
||||||
};
|
};
|
||||||
setCrackedRooms((prev) => [...prev, newRoom]);
|
setCrackedChannels((prev) => [...prev, newCracked]);
|
||||||
|
|
||||||
// Auto-add channel if not already exists
|
// Auto-add channel if not already exists
|
||||||
const keyUpper = result.key.toUpperCase();
|
const keyUpper = result.key.toUpperCase();
|
||||||
@@ -434,8 +440,25 @@ export function CrackerPanel({
|
|||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
max={10}
|
max={10}
|
||||||
value={maxLength}
|
value={maxLengthInput}
|
||||||
onChange={(e) => setMaxLength(Math.min(10, Math.max(1, parseInt(e.target.value) || 6)))}
|
onChange={(e) => {
|
||||||
|
const nextValue = e.target.value;
|
||||||
|
setMaxLengthInput(nextValue);
|
||||||
|
if (nextValue === '') return;
|
||||||
|
const parsed = Number.parseInt(nextValue, 10);
|
||||||
|
if (Number.isNaN(parsed)) return;
|
||||||
|
setMaxLength(Math.min(10, Math.max(1, parsed)));
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
const parsed = Number.parseInt(maxLengthInput, 10);
|
||||||
|
const nextValue = Number.isNaN(parsed)
|
||||||
|
? maxLength
|
||||||
|
: Math.min(10, Math.max(1, parsed));
|
||||||
|
setMaxLengthInput(String(nextValue));
|
||||||
|
if (nextValue !== maxLength) {
|
||||||
|
setMaxLength(nextValue);
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="w-14 px-2 py-1 text-sm bg-muted border border-border rounded"
|
className="w-14 px-2 py-1 text-sm bg-muted border border-border rounded"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -505,7 +528,7 @@ export function CrackerPanel({
|
|||||||
? 'GPU Not Available'
|
? 'GPU Not Available'
|
||||||
: !wordlistLoaded
|
: !wordlistLoaded
|
||||||
? 'Loading dictionary...'
|
? 'Loading dictionary...'
|
||||||
: 'Find Rooms'}
|
: 'Find Channels'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Status */}
|
{/* Status */}
|
||||||
@@ -580,20 +603,20 @@ export function CrackerPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Cracked rooms list */}
|
{/* Cracked channels list */}
|
||||||
{crackedRooms.length > 0 && (
|
{crackedChannels.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-muted-foreground mb-1">Cracked Rooms:</div>
|
<div className="text-xs text-muted-foreground mb-1">Cracked Channels:</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{crackedRooms.map((room, i) => (
|
{crackedChannels.map((channel, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="text-sm bg-success/10 border border-success/20 rounded px-2 py-1"
|
className="text-sm bg-success/10 border border-success/20 rounded px-2 py-1"
|
||||||
>
|
>
|
||||||
<span className="text-success font-medium">#{room.roomName}</span>
|
<span className="text-success font-medium">#{channel.channelName}</span>
|
||||||
<span className="text-muted-foreground ml-2 text-xs">
|
<span className="text-muted-foreground ml-2 text-xs">
|
||||||
"{room.message.slice(0, 50)}
|
"{channel.message.slice(0, 50)}
|
||||||
{room.message.length > 50 ? '...' : ''}"
|
{channel.message.length > 50 ? '...' : ''}"
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -604,16 +627,17 @@ export function CrackerPanel({
|
|||||||
<hr className="border-border" />
|
<hr className="border-border" />
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
For unknown-keyed GroupText packets, this will attempt to dictionary attack, then brute
|
For unknown-keyed GroupText packets, this will attempt to dictionary attack, then brute
|
||||||
force payloads as they arrive, testing room names up to the specified length to discover
|
force payloads as they arrive, testing channel names up to the specified length to discover
|
||||||
active rooms on the local mesh (GroupText packets may not be hashtag messages; we have no
|
active channels on the local mesh (GroupText packets may not be hashtag messages; we have no
|
||||||
way of knowing but try as if they are).
|
way of knowing but try as if they are).
|
||||||
<strong> Retry failed at n+1</strong> will let the cracker return to the failed queue and
|
<strong> Retry failed at n+1</strong> will let the cracker return to the failed queue and
|
||||||
pick up messages it couldn't crack, attempting them at one longer length.
|
pick up messages it couldn't crack, attempting them at one longer length.
|
||||||
<strong> Try word pairs</strong> will also try every combination of two dictionary words
|
<strong> Try word pairs</strong> will also try every combination of two dictionary words
|
||||||
concatenated together (e.g. "hello" + "world" = "#helloworld") after the single-word
|
concatenated together (e.g. "hello" + "world" = "#helloworld") after the single-word
|
||||||
dictionary pass; this can substantially increase search time.
|
dictionary pass; this can substantially increase search time and also result in
|
||||||
<strong> Decrypt historical</strong> will run an async job on any room name it finds to see
|
false-positives.
|
||||||
if any historically captured packets will decrypt with that key.
|
<strong> Decrypt historical</strong> will run an async job on any channel name it finds to
|
||||||
|
see if any historically captured packets will decrypt with that key.
|
||||||
<strong> Turbo mode</strong> will push your GPU to the max (target dispatch time of 10s) and
|
<strong> Turbo mode</strong> will push your GPU to the max (target dispatch time of 10s) and
|
||||||
may allow accelerated cracking and/or system instability.
|
may allow accelerated cracking and/or system instability.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -131,9 +131,23 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
|||||||
|
|
||||||
// Store ref for a marker
|
// Store ref for a marker
|
||||||
const setMarkerRef = useCallback((key: string, ref: LeafletCircleMarker | null) => {
|
const setMarkerRef = useCallback((key: string, ref: LeafletCircleMarker | null) => {
|
||||||
|
if (ref === null) {
|
||||||
|
delete markerRefs.current[key];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
markerRefs.current[key] = ref;
|
markerRefs.current[key] = ref;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentKeys = new Set(mappableContacts.map((contact) => contact.public_key));
|
||||||
|
for (const key of Object.keys(markerRefs.current)) {
|
||||||
|
if (!currentKeys.has(key)) {
|
||||||
|
delete markerRefs.current[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [mappableContacts]);
|
||||||
|
|
||||||
// Open popup for focused contact after map is ready
|
// 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]) {
|
||||||
|
|||||||
@@ -8,18 +8,27 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import type { Contact, Message, MessagePath, RadioConfig } from '../types';
|
import type { Channel, Contact, Message, MessagePath, RadioConfig, RawPacket } from '../types';
|
||||||
import { CONTACT_TYPE_REPEATER } from '../types';
|
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
|
||||||
import { formatTime, parseSenderFromText } from '../utils/messageParser';
|
import { api } from '../api';
|
||||||
|
import {
|
||||||
|
findLinkedChannelReferences,
|
||||||
|
formatTime,
|
||||||
|
parseSenderFromText,
|
||||||
|
} from '../utils/messageParser';
|
||||||
import { formatHopCounts, type SenderInfo } from '../utils/pathUtils';
|
import { formatHopCounts, type SenderInfo } from '../utils/pathUtils';
|
||||||
|
import { getDirectContactRoute } from '../utils/pathUtils';
|
||||||
import { ContactAvatar } from './ContactAvatar';
|
import { ContactAvatar } from './ContactAvatar';
|
||||||
import { PathModal } from './PathModal';
|
import { PathModal } from './PathModal';
|
||||||
|
import { RawPacketInspectorDialog } from './RawPacketDetailModal';
|
||||||
|
import { toast } from './ui/sonner';
|
||||||
import { handleKeyboardActivate } from '../utils/a11y';
|
import { handleKeyboardActivate } from '../utils/a11y';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface MessageListProps {
|
interface MessageListProps {
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
contacts: Contact[];
|
contacts: Contact[];
|
||||||
|
channels?: Channel[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
loadingOlder?: boolean;
|
loadingOlder?: boolean;
|
||||||
hasOlderMessages?: boolean;
|
hasOlderMessages?: boolean;
|
||||||
@@ -28,6 +37,7 @@ interface MessageListProps {
|
|||||||
onSenderClick?: (sender: string) => void;
|
onSenderClick?: (sender: string) => void;
|
||||||
onLoadOlder?: () => void;
|
onLoadOlder?: () => void;
|
||||||
onResendChannelMessage?: (messageId: number, newTimestamp?: boolean) => void;
|
onResendChannelMessage?: (messageId: number, newTimestamp?: boolean) => void;
|
||||||
|
onChannelReferenceClick?: (channelName: string) => void;
|
||||||
radioName?: string;
|
radioName?: string;
|
||||||
config?: RadioConfig | null;
|
config?: RadioConfig | null;
|
||||||
onOpenContactInfo?: (publicKey: string, fromChannel?: boolean) => void;
|
onOpenContactInfo?: (publicKey: string, fromChannel?: boolean) => void;
|
||||||
@@ -43,8 +53,64 @@ interface MessageListProps {
|
|||||||
const URL_PATTERN =
|
const URL_PATTERN =
|
||||||
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/g;
|
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/g;
|
||||||
|
|
||||||
// Helper to convert URLs in a plain text string into clickable links
|
function renderChannelReferences(
|
||||||
function linkifyText(text: string, keyPrefix: string): ReactNode[] {
|
text: string,
|
||||||
|
keyPrefix: string,
|
||||||
|
onChannelReferenceClick?: (channelName: string) => void
|
||||||
|
): ReactNode[] {
|
||||||
|
const references = findLinkedChannelReferences(text);
|
||||||
|
if (references.length === 0) {
|
||||||
|
return [text];
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts: ReactNode[] = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
|
||||||
|
references.forEach((reference, index) => {
|
||||||
|
if (reference.start > lastIndex) {
|
||||||
|
parts.push(text.slice(lastIndex, reference.start));
|
||||||
|
}
|
||||||
|
|
||||||
|
const className =
|
||||||
|
'rounded px-0.5 font-medium text-primary underline underline-offset-2 transition-colors';
|
||||||
|
if (onChannelReferenceClick) {
|
||||||
|
parts.push(
|
||||||
|
<button
|
||||||
|
key={`${keyPrefix}-channel-${index}`}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
className,
|
||||||
|
'inline border-0 bg-transparent p-0 align-baseline hover:text-primary/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
||||||
|
)}
|
||||||
|
onClick={() => onChannelReferenceClick(reference.label)}
|
||||||
|
>
|
||||||
|
{reference.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
parts.push(
|
||||||
|
<span key={`${keyPrefix}-channel-${index}`} className={className}>
|
||||||
|
{reference.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastIndex = reference.end;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
parts.push(text.slice(lastIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to convert URLs and channel references in a plain text string into rich content
|
||||||
|
function linkifyText(
|
||||||
|
text: string,
|
||||||
|
keyPrefix: string,
|
||||||
|
onChannelReferenceClick?: (channelName: string) => void
|
||||||
|
): ReactNode[] {
|
||||||
const parts: ReactNode[] = [];
|
const parts: ReactNode[] = [];
|
||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
let match: RegExpExecArray | null;
|
let match: RegExpExecArray | null;
|
||||||
@@ -53,7 +119,13 @@ function linkifyText(text: string, keyPrefix: string): ReactNode[] {
|
|||||||
URL_PATTERN.lastIndex = 0;
|
URL_PATTERN.lastIndex = 0;
|
||||||
while ((match = URL_PATTERN.exec(text)) !== null) {
|
while ((match = URL_PATTERN.exec(text)) !== null) {
|
||||||
if (match.index > lastIndex) {
|
if (match.index > lastIndex) {
|
||||||
parts.push(text.slice(lastIndex, match.index));
|
parts.push(
|
||||||
|
...renderChannelReferences(
|
||||||
|
text.slice(lastIndex, match.index),
|
||||||
|
`${keyPrefix}-text-${keyIndex}`,
|
||||||
|
onChannelReferenceClick
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
parts.push(
|
parts.push(
|
||||||
<a
|
<a
|
||||||
@@ -69,15 +141,27 @@ function linkifyText(text: string, keyPrefix: string): ReactNode[] {
|
|||||||
lastIndex = match.index + match[0].length;
|
lastIndex = match.index + match[0].length;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastIndex === 0) return [text];
|
if (lastIndex === 0) {
|
||||||
|
return renderChannelReferences(text, keyPrefix, onChannelReferenceClick);
|
||||||
|
}
|
||||||
if (lastIndex < text.length) {
|
if (lastIndex < text.length) {
|
||||||
parts.push(text.slice(lastIndex));
|
parts.push(
|
||||||
|
...renderChannelReferences(
|
||||||
|
text.slice(lastIndex),
|
||||||
|
`${keyPrefix}-tail`,
|
||||||
|
onChannelReferenceClick
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return parts;
|
return parts;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to render text with highlighted @[Name] mentions and clickable URLs
|
// Helper to render text with highlighted @[Name] mentions and clickable URLs
|
||||||
function renderTextWithMentions(text: string, radioName?: string): ReactNode {
|
function renderTextWithMentions(
|
||||||
|
text: string,
|
||||||
|
radioName?: string,
|
||||||
|
onChannelReferenceClick?: (channelName: string) => void
|
||||||
|
): ReactNode {
|
||||||
const mentionPattern = /@\[([^\]]+)\]/g;
|
const mentionPattern = /@\[([^\]]+)\]/g;
|
||||||
const parts: ReactNode[] = [];
|
const parts: ReactNode[] = [];
|
||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
@@ -87,7 +171,13 @@ function renderTextWithMentions(text: string, radioName?: string): ReactNode {
|
|||||||
while ((match = mentionPattern.exec(text)) !== null) {
|
while ((match = mentionPattern.exec(text)) !== null) {
|
||||||
// Add text before the match (with linkification)
|
// Add text before the match (with linkification)
|
||||||
if (match.index > lastIndex) {
|
if (match.index > lastIndex) {
|
||||||
parts.push(...linkifyText(text.slice(lastIndex, match.index), `pre-${keyIndex}`));
|
parts.push(
|
||||||
|
...linkifyText(
|
||||||
|
text.slice(lastIndex, match.index),
|
||||||
|
`pre-${keyIndex}`,
|
||||||
|
onChannelReferenceClick
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mentionedName = match[1];
|
const mentionedName = match[1];
|
||||||
@@ -110,7 +200,7 @@ function renderTextWithMentions(text: string, radioName?: string): ReactNode {
|
|||||||
|
|
||||||
// Add remaining text after last match (with linkification)
|
// Add remaining text after last match (with linkification)
|
||||||
if (lastIndex < text.length) {
|
if (lastIndex < text.length) {
|
||||||
parts.push(...linkifyText(text.slice(lastIndex), `post-${keyIndex}`));
|
parts.push(...linkifyText(text.slice(lastIndex), `post-${keyIndex}`, onChannelReferenceClick));
|
||||||
}
|
}
|
||||||
|
|
||||||
return parts.length > 0 ? parts : text;
|
return parts.length > 0 ? parts : text;
|
||||||
@@ -152,6 +242,8 @@ function HopCountBadge({ paths, onClick, variant }: HopCountBadgeProps) {
|
|||||||
|
|
||||||
const RESEND_WINDOW_SECONDS = 30;
|
const RESEND_WINDOW_SECONDS = 30;
|
||||||
const CORRUPT_SENDER_LABEL = '<No name -- corrupt packet?>';
|
const CORRUPT_SENDER_LABEL = '<No name -- corrupt packet?>';
|
||||||
|
const ANALYZE_PACKET_NOTICE =
|
||||||
|
'This analyzer shows one stored full packet copy only. When multiple receives have identical payloads, the backend deduplicates them to a single stored packet and appends any additional receive paths onto the message path history instead of storing multiple full packet copies.';
|
||||||
|
|
||||||
function hasUnexpectedControlChars(text: string): boolean {
|
function hasUnexpectedControlChars(text: string): boolean {
|
||||||
for (const char of text) {
|
for (const char of text) {
|
||||||
@@ -172,6 +264,7 @@ function hasUnexpectedControlChars(text: string): boolean {
|
|||||||
export function MessageList({
|
export function MessageList({
|
||||||
messages,
|
messages,
|
||||||
contacts,
|
contacts,
|
||||||
|
channels = [],
|
||||||
loading,
|
loading,
|
||||||
loadingOlder = false,
|
loadingOlder = false,
|
||||||
hasOlderMessages = false,
|
hasOlderMessages = false,
|
||||||
@@ -180,6 +273,7 @@ export function MessageList({
|
|||||||
onSenderClick,
|
onSenderClick,
|
||||||
onLoadOlder,
|
onLoadOlder,
|
||||||
onResendChannelMessage,
|
onResendChannelMessage,
|
||||||
|
onChannelReferenceClick,
|
||||||
radioName,
|
radioName,
|
||||||
config,
|
config,
|
||||||
onOpenContactInfo,
|
onOpenContactInfo,
|
||||||
@@ -198,10 +292,18 @@ export function MessageList({
|
|||||||
paths: MessagePath[];
|
paths: MessagePath[];
|
||||||
senderInfo: SenderInfo;
|
senderInfo: SenderInfo;
|
||||||
messageId?: number;
|
messageId?: number;
|
||||||
|
packetId?: number | null;
|
||||||
isOutgoingChan?: boolean;
|
isOutgoingChan?: boolean;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
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 [packetInspectorSource, setPacketInspectorSource] = useState<
|
||||||
|
| { kind: 'packet'; packet: RawPacket }
|
||||||
|
| { kind: 'loading'; message: string }
|
||||||
|
| { kind: 'unavailable'; message: string }
|
||||||
|
| null
|
||||||
|
>(null);
|
||||||
const [highlightedMessageId, setHighlightedMessageId] = useState<number | null>(null);
|
const [highlightedMessageId, setHighlightedMessageId] = useState<number | null>(null);
|
||||||
const [showJumpToUnread, setShowJumpToUnread] = useState(false);
|
const [showJumpToUnread, setShowJumpToUnread] = useState(false);
|
||||||
const [jumpToUnreadDismissed, setJumpToUnreadDismissed] = useState(false);
|
const [jumpToUnreadDismissed, setJumpToUnreadDismissed] = useState(false);
|
||||||
@@ -220,6 +322,43 @@ export function MessageList({
|
|||||||
// Track conversation key to detect when entire message set changes
|
// Track conversation key to detect when entire message set changes
|
||||||
const prevConvKeyRef = useRef<string | null>(null);
|
const prevConvKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const handleAnalyzePacket = useCallback(async (message: Message) => {
|
||||||
|
if (message.packet_id == null) {
|
||||||
|
setPacketInspectorSource({
|
||||||
|
kind: 'unavailable',
|
||||||
|
message:
|
||||||
|
'No archival raw packet is available for this message, so packet analysis cannot be shown.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = packetCacheRef.current.get(message.packet_id);
|
||||||
|
if (cached) {
|
||||||
|
setPacketInspectorSource({ kind: 'packet', packet: cached });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPacketInspectorSource({ kind: 'loading', message: 'Loading packet analysis...' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const packet = await api.getPacket(message.packet_id);
|
||||||
|
packetCacheRef.current.set(message.packet_id, packet);
|
||||||
|
setPacketInspectorSource({ kind: 'packet', packet });
|
||||||
|
} catch (error) {
|
||||||
|
const description = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
const isMissing = error instanceof Error && /not found/i.test(error.message);
|
||||||
|
if (!isMissing) {
|
||||||
|
toast.error('Failed to load raw packet', { description });
|
||||||
|
}
|
||||||
|
setPacketInspectorSource({
|
||||||
|
kind: 'unavailable',
|
||||||
|
message: isMissing
|
||||||
|
? 'The archival raw packet for this message is no longer available. It may have been purged from Settings > Database, so only the stored message and merged route history remain.'
|
||||||
|
: `Could not load the archival raw packet for this message: ${description}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Handle scroll position AFTER render
|
// Handle scroll position AFTER render
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!listRef.current) return;
|
if (!listRef.current) return;
|
||||||
@@ -320,7 +459,22 @@ export function MessageList({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setResendableIds(newResendable);
|
setResendableIds((prev) => {
|
||||||
|
if (prev.size === newResendable.size) {
|
||||||
|
let changed = false;
|
||||||
|
for (const id of newResendable) {
|
||||||
|
if (!prev.has(id)) {
|
||||||
|
changed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!changed) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newResendable;
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
for (const timer of timers.values()) clearTimeout(timer);
|
for (const timer of timers.values()) clearTimeout(timer);
|
||||||
@@ -499,13 +653,41 @@ export function MessageList({
|
|||||||
contact: Contact | null,
|
contact: Contact | null,
|
||||||
parsedSender: string | null
|
parsedSender: string | null
|
||||||
): SenderInfo => {
|
): SenderInfo => {
|
||||||
|
if (
|
||||||
|
msg.type === 'PRIV' &&
|
||||||
|
contact?.type === CONTACT_TYPE_ROOM &&
|
||||||
|
(msg.sender_key || msg.sender_name)
|
||||||
|
) {
|
||||||
|
const authorContact =
|
||||||
|
(msg.sender_key
|
||||||
|
? contacts.find((candidate) => candidate.public_key === msg.sender_key)
|
||||||
|
: null) || (msg.sender_name ? getContactByName(msg.sender_name) : null);
|
||||||
|
if (authorContact) {
|
||||||
|
const directRoute = getDirectContactRoute(authorContact);
|
||||||
|
return {
|
||||||
|
name: authorContact.name || msg.sender_name || authorContact.public_key.slice(0, 12),
|
||||||
|
publicKeyOrPrefix: authorContact.public_key,
|
||||||
|
lat: authorContact.lat,
|
||||||
|
lon: authorContact.lon,
|
||||||
|
pathHashMode: directRoute?.path_hash_mode ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: msg.sender_name || msg.sender_key || 'Unknown',
|
||||||
|
publicKeyOrPrefix: msg.sender_key || '',
|
||||||
|
lat: null,
|
||||||
|
lon: null,
|
||||||
|
pathHashMode: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
if (msg.type === 'PRIV' && contact) {
|
if (msg.type === 'PRIV' && contact) {
|
||||||
|
const directRoute = getDirectContactRoute(contact);
|
||||||
return {
|
return {
|
||||||
name: contact.name || contact.public_key.slice(0, 12),
|
name: contact.name || contact.public_key.slice(0, 12),
|
||||||
publicKeyOrPrefix: contact.public_key,
|
publicKeyOrPrefix: contact.public_key,
|
||||||
lat: contact.lat,
|
lat: contact.lat,
|
||||||
lon: contact.lon,
|
lon: contact.lon,
|
||||||
pathHashMode: contact.out_path_hash_mode,
|
pathHashMode: directRoute?.path_hash_mode ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (msg.type === 'CHAN') {
|
if (msg.type === 'CHAN') {
|
||||||
@@ -515,12 +697,13 @@ export function MessageList({
|
|||||||
? contacts.find((candidate) => candidate.public_key === msg.sender_key)
|
? contacts.find((candidate) => candidate.public_key === msg.sender_key)
|
||||||
: null) || (senderName ? getContactByName(senderName) : null);
|
: null) || (senderName ? getContactByName(senderName) : null);
|
||||||
if (senderContact) {
|
if (senderContact) {
|
||||||
|
const directRoute = getDirectContactRoute(senderContact);
|
||||||
return {
|
return {
|
||||||
name: senderContact.name || senderName || senderContact.public_key.slice(0, 12),
|
name: senderContact.name || senderName || senderContact.public_key.slice(0, 12),
|
||||||
publicKeyOrPrefix: senderContact.public_key,
|
publicKeyOrPrefix: senderContact.public_key,
|
||||||
lat: senderContact.lat,
|
lat: senderContact.lat,
|
||||||
lon: senderContact.lon,
|
lon: senderContact.lon,
|
||||||
pathHashMode: senderContact.out_path_hash_mode,
|
pathHashMode: directRoute?.path_hash_mode ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (senderName || msg.sender_key) {
|
if (senderName || msg.sender_key) {
|
||||||
@@ -538,12 +721,13 @@ export function MessageList({
|
|||||||
if (parsedSender) {
|
if (parsedSender) {
|
||||||
const senderContact = getContactByName(parsedSender);
|
const senderContact = getContactByName(parsedSender);
|
||||||
if (senderContact) {
|
if (senderContact) {
|
||||||
|
const directRoute = getDirectContactRoute(senderContact);
|
||||||
return {
|
return {
|
||||||
name: parsedSender,
|
name: parsedSender,
|
||||||
publicKeyOrPrefix: senderContact.public_key,
|
publicKeyOrPrefix: senderContact.public_key,
|
||||||
lat: senderContact.lat,
|
lat: senderContact.lat,
|
||||||
lon: senderContact.lon,
|
lon: senderContact.lon,
|
||||||
pathHashMode: senderContact.out_path_hash_mode,
|
pathHashMode: directRoute?.path_hash_mode ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -580,6 +764,8 @@ export function MessageList({
|
|||||||
isCorruptChannelMessage: boolean
|
isCorruptChannelMessage: boolean
|
||||||
): string => {
|
): string => {
|
||||||
if (msg.outgoing) return '__outgoing__';
|
if (msg.outgoing) return '__outgoing__';
|
||||||
|
if (msg.type === 'PRIV' && msg.sender_key) return `key:${msg.sender_key}`;
|
||||||
|
if (msg.type === 'PRIV' && senderName) return `name:${senderName}`;
|
||||||
if (msg.type === 'PRIV' && msg.conversation_key) return msg.conversation_key;
|
if (msg.type === 'PRIV' && msg.conversation_key) return msg.conversation_key;
|
||||||
if (msg.sender_key) return `key:${msg.sender_key}`;
|
if (msg.sender_key) return `key:${msg.sender_key}`;
|
||||||
if (senderName) return `name:${senderName}`;
|
if (senderName) return `name:${senderName}`;
|
||||||
@@ -608,18 +794,24 @@ export function MessageList({
|
|||||||
// For DMs, look up contact; for channel messages, use parsed sender
|
// For DMs, look up contact; for channel messages, use parsed sender
|
||||||
const contact = msg.type === 'PRIV' ? getContact(msg.conversation_key) : null;
|
const contact = msg.type === 'PRIV' ? getContact(msg.conversation_key) : null;
|
||||||
const isRepeater = contact?.type === CONTACT_TYPE_REPEATER;
|
const isRepeater = contact?.type === CONTACT_TYPE_REPEATER;
|
||||||
|
const isRoomServer = contact?.type === CONTACT_TYPE_ROOM;
|
||||||
|
|
||||||
// Skip sender parsing for repeater messages (CLI responses often have colons)
|
// Skip sender parsing for repeater messages (CLI responses often have colons)
|
||||||
const { sender, content } = isRepeater
|
const { sender, content } =
|
||||||
? { sender: null, content: msg.text }
|
isRepeater || (isRoomServer && msg.type === 'PRIV')
|
||||||
: parseSenderFromText(msg.text);
|
? { sender: null, content: msg.text }
|
||||||
|
: parseSenderFromText(msg.text);
|
||||||
|
const directSenderName =
|
||||||
|
msg.type === 'PRIV' && isRoomServer ? msg.sender_name || null : null;
|
||||||
const channelSenderName = msg.type === 'CHAN' ? msg.sender_name || sender : null;
|
const channelSenderName = msg.type === 'CHAN' ? msg.sender_name || sender : null;
|
||||||
const channelSenderContact =
|
const channelSenderContact =
|
||||||
msg.type === 'CHAN' && channelSenderName ? getContactByName(channelSenderName) : null;
|
msg.type === 'CHAN' && channelSenderName ? getContactByName(channelSenderName) : null;
|
||||||
const isCorruptChannelMessage = isCorruptUnnamedChannelMessage(msg, sender);
|
const isCorruptChannelMessage = isCorruptUnnamedChannelMessage(msg, sender);
|
||||||
const displaySender = msg.outgoing
|
const displaySender = msg.outgoing
|
||||||
? 'You'
|
? 'You'
|
||||||
: contact?.name ||
|
: directSenderName ||
|
||||||
|
(isRoomServer && msg.sender_key ? msg.sender_key.slice(0, 8) : null) ||
|
||||||
|
contact?.name ||
|
||||||
channelSenderName ||
|
channelSenderName ||
|
||||||
(isCorruptChannelMessage
|
(isCorruptChannelMessage
|
||||||
? CORRUPT_SENDER_LABEL
|
? CORRUPT_SENDER_LABEL
|
||||||
@@ -632,15 +824,22 @@ export function MessageList({
|
|||||||
displaySender !== CORRUPT_SENDER_LABEL;
|
displaySender !== CORRUPT_SENDER_LABEL;
|
||||||
|
|
||||||
// Determine if we should show avatar (first message in a chunk from same sender)
|
// Determine if we should show avatar (first message in a chunk from same sender)
|
||||||
const currentSenderKey = getSenderKey(msg, channelSenderName, isCorruptChannelMessage);
|
const currentSenderKey = getSenderKey(
|
||||||
|
msg,
|
||||||
|
directSenderName || channelSenderName,
|
||||||
|
isCorruptChannelMessage
|
||||||
|
);
|
||||||
const prevMsg = sortedMessages[index - 1];
|
const prevMsg = sortedMessages[index - 1];
|
||||||
const prevParsedSender = prevMsg ? parseSenderFromText(prevMsg.text).sender : null;
|
const prevParsedSender = prevMsg ? parseSenderFromText(prevMsg.text).sender : null;
|
||||||
const prevSenderKey = prevMsg
|
const prevSenderKey = prevMsg
|
||||||
? getSenderKey(
|
? getSenderKey(
|
||||||
prevMsg,
|
prevMsg,
|
||||||
prevMsg.type === 'CHAN'
|
prevMsg.type === 'PRIV' &&
|
||||||
? prevMsg.sender_name || prevParsedSender
|
getContact(prevMsg.conversation_key)?.type === CONTACT_TYPE_ROOM
|
||||||
: prevParsedSender,
|
? prevMsg.sender_name
|
||||||
|
: prevMsg.type === 'CHAN'
|
||||||
|
? prevMsg.sender_name || prevParsedSender
|
||||||
|
: prevParsedSender,
|
||||||
isCorruptUnnamedChannelMessage(prevMsg, prevParsedSender)
|
isCorruptUnnamedChannelMessage(prevMsg, prevParsedSender)
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
@@ -654,9 +853,14 @@ export function MessageList({
|
|||||||
let avatarVariant: 'default' | 'corrupt' = 'default';
|
let avatarVariant: 'default' | 'corrupt' = 'default';
|
||||||
if (!msg.outgoing) {
|
if (!msg.outgoing) {
|
||||||
if (msg.type === 'PRIV' && msg.conversation_key) {
|
if (msg.type === 'PRIV' && msg.conversation_key) {
|
||||||
// DM: use conversation_key (sender's public key)
|
if (isRoomServer) {
|
||||||
avatarName = contact?.name || null;
|
avatarName = directSenderName;
|
||||||
avatarKey = msg.conversation_key;
|
avatarKey =
|
||||||
|
msg.sender_key || (avatarName ? `name:${avatarName}` : msg.conversation_key);
|
||||||
|
} else {
|
||||||
|
avatarName = contact?.name || null;
|
||||||
|
avatarKey = msg.conversation_key;
|
||||||
|
}
|
||||||
} else if (isCorruptChannelMessage) {
|
} else if (isCorruptChannelMessage) {
|
||||||
avatarName = CORRUPT_SENDER_LABEL;
|
avatarName = CORRUPT_SENDER_LABEL;
|
||||||
avatarKey = `corrupt:${msg.id}`;
|
avatarKey = `corrupt:${msg.id}`;
|
||||||
@@ -721,7 +925,12 @@ export function MessageList({
|
|||||||
type="button"
|
type="button"
|
||||||
className="avatar-action-button rounded-full border-none bg-transparent p-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="avatar-action-button rounded-full border-none bg-transparent p-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
aria-label={avatarActionLabel}
|
aria-label={avatarActionLabel}
|
||||||
onClick={() => onOpenContactInfo(avatarKey, msg.type === 'CHAN')}
|
onClick={() =>
|
||||||
|
onOpenContactInfo(
|
||||||
|
avatarKey,
|
||||||
|
msg.type === 'CHAN' || (msg.type === 'PRIV' && isRoomServer)
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ContactAvatar
|
<ContactAvatar
|
||||||
name={avatarName}
|
name={avatarName}
|
||||||
@@ -776,7 +985,9 @@ export function MessageList({
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
setSelectedPath({
|
setSelectedPath({
|
||||||
paths: msg.paths!,
|
paths: msg.paths!,
|
||||||
senderInfo: getSenderInfo(msg, contact, sender),
|
senderInfo: getSenderInfo(msg, contact, directSenderName || sender),
|
||||||
|
messageId: msg.id,
|
||||||
|
packetId: msg.packet_id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -786,7 +997,7 @@ export function MessageList({
|
|||||||
<div className="break-words whitespace-pre-wrap">
|
<div className="break-words whitespace-pre-wrap">
|
||||||
{content.split('\n').map((line, i, arr) => (
|
{content.split('\n').map((line, i, arr) => (
|
||||||
<span key={i}>
|
<span key={i}>
|
||||||
{renderTextWithMentions(line, radioName)}
|
{renderTextWithMentions(line, radioName, onChannelReferenceClick)}
|
||||||
{i < arr.length - 1 && <br />}
|
{i < arr.length - 1 && <br />}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -802,7 +1013,9 @@ export function MessageList({
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
setSelectedPath({
|
setSelectedPath({
|
||||||
paths: msg.paths!,
|
paths: msg.paths!,
|
||||||
senderInfo: getSenderInfo(msg, contact, sender),
|
senderInfo: getSenderInfo(msg, contact, directSenderName || sender),
|
||||||
|
messageId: msg.id,
|
||||||
|
packetId: msg.packet_id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -823,6 +1036,7 @@ export function MessageList({
|
|||||||
paths: msg.paths!,
|
paths: msg.paths!,
|
||||||
senderInfo: selfSenderInfo,
|
senderInfo: selfSenderInfo,
|
||||||
messageId: msg.id,
|
messageId: msg.id,
|
||||||
|
packetId: msg.packet_id,
|
||||||
isOutgoingChan: msg.type === 'CHAN' && !!onResendChannelMessage,
|
isOutgoingChan: msg.type === 'CHAN' && !!onResendChannelMessage,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@@ -844,6 +1058,7 @@ export function MessageList({
|
|||||||
paths: [],
|
paths: [],
|
||||||
senderInfo: selfSenderInfo,
|
senderInfo: selfSenderInfo,
|
||||||
messageId: msg.id,
|
messageId: msg.id,
|
||||||
|
packetId: msg.packet_id,
|
||||||
isOutgoingChan: true,
|
isOutgoingChan: true,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@@ -941,9 +1156,31 @@ export function MessageList({
|
|||||||
contacts={contacts}
|
contacts={contacts}
|
||||||
config={config ?? null}
|
config={config ?? null}
|
||||||
messageId={selectedPath.messageId}
|
messageId={selectedPath.messageId}
|
||||||
|
packetId={selectedPath.packetId}
|
||||||
isOutgoingChan={selectedPath.isOutgoingChan}
|
isOutgoingChan={selectedPath.isOutgoingChan}
|
||||||
isResendable={isSelectedMessageResendable}
|
isResendable={isSelectedMessageResendable}
|
||||||
onResend={onResendChannelMessage}
|
onResend={onResendChannelMessage}
|
||||||
|
onAnalyzePacket={
|
||||||
|
selectedPath.packetId != null
|
||||||
|
? () => {
|
||||||
|
const message = messages.find((entry) => entry.id === selectedPath.messageId);
|
||||||
|
if (message) {
|
||||||
|
void handleAnalyzePacket(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{packetInspectorSource && (
|
||||||
|
<RawPacketInspectorDialog
|
||||||
|
open={packetInspectorSource !== null}
|
||||||
|
onOpenChange={(isOpen) => !isOpen && setPacketInspectorSource(null)}
|
||||||
|
channels={channels}
|
||||||
|
source={packetInspectorSource}
|
||||||
|
title="Analyze Packet"
|
||||||
|
description="On-demand raw packet analysis for a message-backed archival packet."
|
||||||
|
notice={ANALYZE_PACKET_NOTICE}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,64 +1,156 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Dice5 } from 'lucide-react';
|
import { Dice5 } from 'lucide-react';
|
||||||
import type { Contact, Conversation } from '../types';
|
|
||||||
import { getContactDisplayName } from '../utils/pubkey';
|
|
||||||
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 = 'existing' | 'new-contact' | 'new-room' | 'hashtag';
|
type Tab = 'new-contact' | 'new-channel' | 'hashtag' | 'bulk-hashtag';
|
||||||
|
|
||||||
|
interface BulkParseResult {
|
||||||
|
channelNames: string[];
|
||||||
|
invalidNames: string[];
|
||||||
|
}
|
||||||
|
|
||||||
interface NewMessageModalProps {
|
interface NewMessageModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
contacts: Contact[];
|
|
||||||
undecryptedCount: number;
|
undecryptedCount: number;
|
||||||
|
showBulkAddChannelTab?: boolean;
|
||||||
|
prefillRequest?: {
|
||||||
|
tab: 'hashtag';
|
||||||
|
hashtagName: string;
|
||||||
|
nonce: number;
|
||||||
|
} | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSelectConversation: (conversation: Conversation) => void;
|
|
||||||
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,
|
||||||
contacts,
|
|
||||||
undecryptedCount,
|
undecryptedCount,
|
||||||
|
showBulkAddChannelTab = false,
|
||||||
|
prefillRequest = null,
|
||||||
onClose,
|
onClose,
|
||||||
onSelectConversation,
|
|
||||||
onCreateContact,
|
onCreateContact,
|
||||||
onCreateChannel,
|
onCreateChannel,
|
||||||
onCreateHashtagChannel,
|
onCreateHashtagChannel,
|
||||||
|
onBulkAddHashtagChannels,
|
||||||
}: NewMessageModalProps) {
|
}: NewMessageModalProps) {
|
||||||
const [tab, setTab] = useState<Tab>('existing');
|
const [tab, setTab] = useState<Tab>('new-contact');
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [contactKey, setContactKey] = useState('');
|
const [contactKey, setContactKey] = useState('');
|
||||||
const [roomKey, setRoomKey] = 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('');
|
||||||
setRoomKey('');
|
setChannelKey('');
|
||||||
|
setBulkChannelText('');
|
||||||
setTryHistorical(false);
|
setTryHistorical(false);
|
||||||
setPermitCapitals(false);
|
setPermitCapitals(false);
|
||||||
setError('');
|
setError('');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prefillRequest) {
|
||||||
|
setTab(prefillRequest.tab);
|
||||||
|
setName(prefillRequest.hashtagName);
|
||||||
|
setContactKey('');
|
||||||
|
setChannelKey('');
|
||||||
|
setBulkChannelText('');
|
||||||
|
setTryHistorical(false);
|
||||||
|
setPermitCapitals(false);
|
||||||
|
setError('');
|
||||||
|
setLoading(false);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
hashtagInputRef.current?.focus();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showBulkAddChannelTab) {
|
||||||
|
setTab('bulk-hashtag');
|
||||||
|
setName('');
|
||||||
|
setContactKey('');
|
||||||
|
setChannelKey('');
|
||||||
|
setBulkChannelText('');
|
||||||
|
setTryHistorical(false);
|
||||||
|
setPermitCapitals(false);
|
||||||
|
setError('');
|
||||||
|
setLoading(false);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
bulkTextareaRef.current?.focus();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTab('new-contact');
|
||||||
|
}, [open, prefillRequest, showBulkAddChannelTab]);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
setError('');
|
setError('');
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -69,14 +161,13 @@ 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-room') {
|
} else if (tab === 'new-channel') {
|
||||||
if (!name.trim() || !roomKey.trim()) {
|
if (!name.trim() || !channelKey.trim()) {
|
||||||
setError('Room name and key are required');
|
setError('Channel name and key are required');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await onCreateChannel(name.trim(), roomKey.trim(), tryHistorical);
|
await onCreateChannel(name.trim(), channelKey.trim(), tryHistorical);
|
||||||
} else if (tab === 'hashtag') {
|
} else if (tab === 'hashtag') {
|
||||||
const channelName = name.trim();
|
const channelName = name.trim();
|
||||||
const validationError = validateHashtagName(channelName);
|
const validationError = validateHashtagName(channelName);
|
||||||
@@ -84,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) {
|
||||||
@@ -100,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();
|
||||||
@@ -121,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('');
|
||||||
@@ -136,7 +230,7 @@ export function NewMessageModal({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const showHistoricalOption = tab !== 'existing' && undecryptedCount > 0;
|
const showHistoricalOption = undecryptedCount > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -148,72 +242,38 @@ 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 === 'existing' && 'Select an existing contact to start a conversation'}
|
|
||||||
{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-room' && 'Create a private room 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-4">
|
<TabsList
|
||||||
<TabsTrigger value="existing">Existing</TabsTrigger>
|
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-room">Room</TabsTrigger>
|
<TabsTrigger value="new-channel">Private Channel</TabsTrigger>
|
||||||
<TabsTrigger value="hashtag">Hashtag</TabsTrigger>
|
<TabsTrigger value="hashtag">Hashtag Channel</TabsTrigger>
|
||||||
|
{showBulkAddChannelTab && (
|
||||||
|
<TabsTrigger value="bulk-hashtag">Bulk Add Channel</TabsTrigger>
|
||||||
|
)}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="existing" className="mt-4">
|
|
||||||
<div className="max-h-[300px] overflow-y-auto rounded-md border">
|
|
||||||
{contacts.filter((contact) => contact.public_key.length === 64).length === 0 ? (
|
|
||||||
<div className="p-4 text-center text-muted-foreground">No contacts available</div>
|
|
||||||
) : (
|
|
||||||
contacts
|
|
||||||
.filter((contact) => contact.public_key.length === 64)
|
|
||||||
.map((contact) => (
|
|
||||||
<div
|
|
||||||
key={contact.public_key}
|
|
||||||
className="cursor-pointer px-4 py-2 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
(e.currentTarget as HTMLElement).click();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
onSelectConversation({
|
|
||||||
type: 'contact',
|
|
||||||
id: contact.public_key,
|
|
||||||
name: getContactDisplayName(
|
|
||||||
contact.name,
|
|
||||||
contact.public_key,
|
|
||||||
contact.last_advert
|
|
||||||
),
|
|
||||||
});
|
|
||||||
resetForm();
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{getContactDisplayName(contact.name, contact.public_key, contact.last_advert)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="new-contact" className="mt-4 space-y-4">
|
<TabsContent value="new-contact" className="mt-4 space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="contact-name">Name</Label>
|
<Label htmlFor="contact-name">Name</Label>
|
||||||
@@ -235,23 +295,23 @@ export function NewMessageModal({
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="new-room" className="mt-4 space-y-4">
|
<TabsContent value="new-channel" className="mt-4 space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="room-name">Room Name</Label>
|
<Label htmlFor="channel-name">Channel Name</Label>
|
||||||
<Input
|
<Input
|
||||||
id="room-name"
|
id="channel-name"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="Room name"
|
placeholder="Channel name"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="room-key">Room Key</Label>
|
<Label htmlFor="channel-key">Channel Key</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
id="room-key"
|
id="channel-key"
|
||||||
value={roomKey}
|
value={channelKey}
|
||||||
onChange={(e) => setRoomKey(e.target.value)}
|
onChange={(e) => setChannelKey(e.target.value)}
|
||||||
placeholder="Pre-shared key (hex)"
|
placeholder="Pre-shared key (hex)"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
@@ -263,9 +323,9 @@ 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('');
|
||||||
setRoomKey(hex);
|
setChannelKey(hex);
|
||||||
}}
|
}}
|
||||||
title="Generate random key"
|
title="Generate random key"
|
||||||
aria-label="Generate random key"
|
aria-label="Generate random key"
|
||||||
@@ -292,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 room 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 && (
|
||||||
@@ -313,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' : ''}
|
||||||
@@ -325,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>
|
||||||
)}
|
)}
|
||||||
@@ -353,11 +448,15 @@ export function NewMessageModal({
|
|||||||
{loading ? 'Creating...' : 'Create & Add Another'}
|
{loading ? 'Creating...' : 'Create & Add Another'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{tab !== 'existing' && (
|
<Button onClick={handleCreate} disabled={loading}>
|
||||||
<Button onClick={handleCreate} disabled={loading}>
|
{loading
|
||||||
{loading ? 'Creating...' : 'Create'}
|
? tab === 'bulk-hashtag'
|
||||||
</Button>
|
? 'Adding...'
|
||||||
)}
|
: 'Creating...'
|
||||||
|
: tab === 'bulk-hashtag'
|
||||||
|
? 'Add Channels'
|
||||||
|
: 'Create'}
|
||||||
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
} from '../utils/pathUtils';
|
} from '../utils/pathUtils';
|
||||||
import { formatTime } from '../utils/messageParser';
|
import { formatTime } from '../utils/messageParser';
|
||||||
import { getMapFocusHash } from '../utils/urlHash';
|
import { getMapFocusHash } from '../utils/urlHash';
|
||||||
|
import { useDistanceUnit } from '../contexts/DistanceUnitContext';
|
||||||
|
import type { DistanceUnit } from '../utils/distanceUnits';
|
||||||
|
|
||||||
const PathRouteMap = lazy(() =>
|
const PathRouteMap = lazy(() =>
|
||||||
import('./PathRouteMap').then((m) => ({ default: m.PathRouteMap }))
|
import('./PathRouteMap').then((m) => ({ default: m.PathRouteMap }))
|
||||||
@@ -27,9 +29,11 @@ interface PathModalProps {
|
|||||||
contacts: Contact[];
|
contacts: Contact[];
|
||||||
config: RadioConfig | null;
|
config: RadioConfig | null;
|
||||||
messageId?: number;
|
messageId?: number;
|
||||||
|
packetId?: number | null;
|
||||||
isOutgoingChan?: boolean;
|
isOutgoingChan?: boolean;
|
||||||
isResendable?: boolean;
|
isResendable?: boolean;
|
||||||
onResend?: (messageId: number, newTimestamp?: boolean) => void;
|
onResend?: (messageId: number, newTimestamp?: boolean) => void;
|
||||||
|
onAnalyzePacket?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PathModal({
|
export function PathModal({
|
||||||
@@ -40,13 +44,17 @@ export function PathModal({
|
|||||||
contacts,
|
contacts,
|
||||||
config,
|
config,
|
||||||
messageId,
|
messageId,
|
||||||
|
packetId,
|
||||||
isOutgoingChan,
|
isOutgoingChan,
|
||||||
isResendable,
|
isResendable,
|
||||||
onResend,
|
onResend,
|
||||||
|
onAnalyzePacket,
|
||||||
}: PathModalProps) {
|
}: PathModalProps) {
|
||||||
const [expandedMaps, setExpandedMaps] = useState<Set<number>>(new Set());
|
const { distanceUnit } = useDistanceUnit();
|
||||||
|
const [mapModalIndex, setMapModalIndex] = useState<number | null>(null);
|
||||||
const hasResendActions = isOutgoingChan && messageId !== undefined && onResend;
|
const hasResendActions = isOutgoingChan && messageId !== undefined && onResend;
|
||||||
const hasPaths = paths.length > 0;
|
const hasPaths = paths.length > 0;
|
||||||
|
const showAnalyzePacket = hasPaths && packetId != null && onAnalyzePacket;
|
||||||
|
|
||||||
// Resolve all paths
|
// Resolve all paths
|
||||||
const resolvedPaths = hasPaths
|
const resolvedPaths = hasPaths
|
||||||
@@ -60,7 +68,7 @@ export function PathModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||||
<DialogContent className="max-w-md max-h-[80vh] flex flex-col">
|
<DialogContent className="max-w-md max-h-[80dvh] flex flex-col">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{hasPaths
|
{hasPaths
|
||||||
@@ -73,13 +81,14 @@ export function PathModal({
|
|||||||
) : hasSinglePath ? (
|
) : hasSinglePath ? (
|
||||||
<>
|
<>
|
||||||
This shows <em>one route</em> that this message traveled through the mesh network.
|
This shows <em>one route</em> that this message traveled through the mesh network.
|
||||||
Repeaters may be incorrectly identified due to prefix collisions between heard and
|
Repeater identities are inferred from locally known advert and path data, so some
|
||||||
non-heard repeater advertisements.
|
hops may be missing or misidentified when that data is incomplete.
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
This message was received via <strong>{paths.length} different routes</strong>.
|
This message was received via <strong>{paths.length} different routes</strong>.
|
||||||
Repeaters may be incorrectly identified due to prefix collisions.
|
Repeater identities are inferred from locally known advert and path data, so some
|
||||||
|
hops may be missing or misidentified when that data is incomplete.
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
@@ -87,6 +96,12 @@ export function PathModal({
|
|||||||
|
|
||||||
{hasPaths && (
|
{hasPaths && (
|
||||||
<div className="flex-1 overflow-y-auto py-2 space-y-4">
|
<div className="flex-1 overflow-y-auto py-2 space-y-4">
|
||||||
|
{showAnalyzePacket ? (
|
||||||
|
<Button type="button" variant="outline" className="w-full" onClick={onAnalyzePacket}>
|
||||||
|
Analyze Packet
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Raw path summary */}
|
{/* Raw path summary */}
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
{paths.map((p, index) => {
|
{paths.map((p, index) => {
|
||||||
@@ -120,61 +135,75 @@ export function PathModal({
|
|||||||
resolvedPaths[0].resolved.sender.lon,
|
resolvedPaths[0].resolved.sender.lon,
|
||||||
resolvedPaths[0].resolved.receiver.lat,
|
resolvedPaths[0].resolved.receiver.lat,
|
||||||
resolvedPaths[0].resolved.receiver.lon
|
resolvedPaths[0].resolved.receiver.lon
|
||||||
)!
|
)!,
|
||||||
|
distanceUnit
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{resolvedPaths.map((pathData, index) => {
|
{resolvedPaths.map((pathData, index) => (
|
||||||
const mapExpanded = expandedMaps.has(index);
|
<div key={index}>
|
||||||
const toggleMap = () =>
|
<div className="flex items-center justify-between mb-2 pb-1 border-b border-border">
|
||||||
setExpandedMaps((prev) => {
|
{!hasSinglePath ? (
|
||||||
const next = new Set(prev);
|
<div className="text-sm text-foreground/70 font-semibold">
|
||||||
if (next.has(index)) next.delete(index);
|
Path {index + 1}{' '}
|
||||||
else next.add(index);
|
<span className="font-normal text-muted-foreground">
|
||||||
return next;
|
— received {formatTime(pathData.received_at)}
|
||||||
});
|
</span>
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={index}>
|
|
||||||
<div className="flex items-center justify-between mb-2 pb-1 border-b border-border">
|
|
||||||
{!hasSinglePath ? (
|
|
||||||
<div className="text-sm text-foreground/70 font-semibold">
|
|
||||||
Path {index + 1}{' '}
|
|
||||||
<span className="font-normal text-muted-foreground">
|
|
||||||
— received {formatTime(pathData.received_at)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div />
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={toggleMap}
|
|
||||||
aria-expanded={mapExpanded}
|
|
||||||
className="text-xs text-primary hover:underline cursor-pointer shrink-0 ml-2"
|
|
||||||
>
|
|
||||||
{mapExpanded ? 'Hide map' : 'Map route'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{mapExpanded && (
|
|
||||||
<div className="mb-2">
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<div
|
|
||||||
className="rounded border border-border bg-muted/30 animate-pulse"
|
|
||||||
style={{ height: 220 }}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<PathRouteMap resolved={pathData.resolved} senderInfo={senderInfo} />
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
)}
|
)}
|
||||||
<PathVisualization resolved={pathData.resolved} senderInfo={senderInfo} />
|
<button
|
||||||
|
onClick={() => setMapModalIndex(index)}
|
||||||
|
className="text-xs text-primary hover:underline cursor-pointer shrink-0 ml-2"
|
||||||
|
>
|
||||||
|
Map route
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
<PathVisualization
|
||||||
})}
|
resolved={pathData.resolved}
|
||||||
|
senderInfo={senderInfo}
|
||||||
|
distanceUnit={distanceUnit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Map modal — opens when a "Map route" button is clicked */}
|
||||||
|
<Dialog
|
||||||
|
open={mapModalIndex !== null}
|
||||||
|
onOpenChange={(open) => !open && setMapModalIndex(null)}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{mapModalIndex !== null && !hasSinglePath
|
||||||
|
? `Path ${mapModalIndex + 1} Route Map`
|
||||||
|
: 'Route Map'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Map of known node locations along this message route.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{mapModalIndex !== null && (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div
|
||||||
|
className="rounded border border-border bg-muted/30 animate-pulse"
|
||||||
|
style={{ height: 400 }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PathRouteMap
|
||||||
|
resolved={resolvedPaths[mapModalIndex].resolved}
|
||||||
|
senderInfo={senderInfo}
|
||||||
|
height={400}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -227,9 +256,10 @@ export function PathModal({
|
|||||||
interface PathVisualizationProps {
|
interface PathVisualizationProps {
|
||||||
resolved: ResolvedPath;
|
resolved: ResolvedPath;
|
||||||
senderInfo: SenderInfo;
|
senderInfo: SenderInfo;
|
||||||
|
distanceUnit: DistanceUnit;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PathVisualization({ resolved, senderInfo }: PathVisualizationProps) {
|
function PathVisualization({ resolved, senderInfo, distanceUnit }: PathVisualizationProps) {
|
||||||
// Track previous location for each hop to calculate distances
|
// Track previous location for each hop to calculate distances
|
||||||
// Returns null if previous hop was ambiguous or has invalid location
|
// Returns null if previous hop was ambiguous or has invalid location
|
||||||
const getPrevLocation = (hopIndex: number): { lat: number | null; lon: number | null } | null => {
|
const getPrevLocation = (hopIndex: number): { lat: number | null; lon: number | null } | null => {
|
||||||
@@ -264,6 +294,7 @@ function PathVisualization({ resolved, senderInfo }: PathVisualizationProps) {
|
|||||||
name={resolved.sender.name}
|
name={resolved.sender.name}
|
||||||
prefix={resolved.sender.prefix}
|
prefix={resolved.sender.prefix}
|
||||||
distance={null}
|
distance={null}
|
||||||
|
distanceUnit={distanceUnit}
|
||||||
isFirst
|
isFirst
|
||||||
lat={resolved.sender.lat}
|
lat={resolved.sender.lat}
|
||||||
lon={resolved.sender.lon}
|
lon={resolved.sender.lon}
|
||||||
@@ -277,6 +308,7 @@ function PathVisualization({ resolved, senderInfo }: PathVisualizationProps) {
|
|||||||
hop={hop}
|
hop={hop}
|
||||||
hopNumber={index + 1}
|
hopNumber={index + 1}
|
||||||
prevLocation={getPrevLocation(index)}
|
prevLocation={getPrevLocation(index)}
|
||||||
|
distanceUnit={distanceUnit}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -286,6 +318,7 @@ function PathVisualization({ resolved, senderInfo }: PathVisualizationProps) {
|
|||||||
name={resolved.receiver.name}
|
name={resolved.receiver.name}
|
||||||
prefix={resolved.receiver.prefix}
|
prefix={resolved.receiver.prefix}
|
||||||
distance={calculateReceiverDistance(resolved)}
|
distance={calculateReceiverDistance(resolved)}
|
||||||
|
distanceUnit={distanceUnit}
|
||||||
isLast
|
isLast
|
||||||
lat={resolved.receiver.lat}
|
lat={resolved.receiver.lat}
|
||||||
lon={resolved.receiver.lon}
|
lon={resolved.receiver.lon}
|
||||||
@@ -300,7 +333,7 @@ function PathVisualization({ resolved, senderInfo }: PathVisualizationProps) {
|
|||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{resolved.hasGaps ? '>' : ''}
|
{resolved.hasGaps ? '>' : ''}
|
||||||
{formatDistance(resolved.totalDistances[0])}
|
{formatDistance(resolved.totalDistances[0], distanceUnit)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -313,6 +346,7 @@ interface PathNodeProps {
|
|||||||
name: string;
|
name: string;
|
||||||
prefix: string;
|
prefix: string;
|
||||||
distance: number | null;
|
distance: number | null;
|
||||||
|
distanceUnit: DistanceUnit;
|
||||||
isFirst?: boolean;
|
isFirst?: boolean;
|
||||||
isLast?: boolean;
|
isLast?: boolean;
|
||||||
/** Optional coordinates for map link */
|
/** Optional coordinates for map link */
|
||||||
@@ -327,6 +361,7 @@ function PathNode({
|
|||||||
name,
|
name,
|
||||||
prefix,
|
prefix,
|
||||||
distance,
|
distance,
|
||||||
|
distanceUnit,
|
||||||
isFirst,
|
isFirst,
|
||||||
isLast,
|
isLast,
|
||||||
lat,
|
lat,
|
||||||
@@ -353,7 +388,9 @@ function PathNode({
|
|||||||
<div className="font-medium truncate">
|
<div className="font-medium truncate">
|
||||||
{name}
|
{name}
|
||||||
{distance !== null && (
|
{distance !== null && (
|
||||||
<span className="text-xs text-muted-foreground ml-1">- {formatDistance(distance)}</span>
|
<span className="text-xs text-muted-foreground ml-1">
|
||||||
|
- {formatDistance(distance, distanceUnit)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{hasLocation && <CoordinateLink lat={lat!} lon={lon!} publicKey={publicKey!} />}
|
{hasLocation && <CoordinateLink lat={lat!} lon={lon!} publicKey={publicKey!} />}
|
||||||
</div>
|
</div>
|
||||||
@@ -366,11 +403,15 @@ interface HopNodeProps {
|
|||||||
hop: PathHop;
|
hop: PathHop;
|
||||||
hopNumber: number;
|
hopNumber: number;
|
||||||
prevLocation: { lat: number | null; lon: number | null } | null;
|
prevLocation: { lat: number | null; lon: number | null } | null;
|
||||||
|
distanceUnit: DistanceUnit;
|
||||||
}
|
}
|
||||||
|
|
||||||
function HopNode({ hop, hopNumber, prevLocation }: HopNodeProps) {
|
const AMBIGUOUS_MATCH_PREVIEW_LIMIT = 3;
|
||||||
|
|
||||||
|
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
|
||||||
@@ -409,34 +450,45 @@ function HopNode({ hop, hopNumber, prevLocation }: 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)}
|
<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">
|
||||||
{hop.matches[0].name || hop.matches[0].public_key.slice(0, 12)}
|
{hop.matches[0].name || hop.matches[0].public_key.slice(0, 12)}
|
||||||
{hop.distanceFromPrev !== null && (
|
{hop.distanceFromPrev !== null && (
|
||||||
<span className="text-xs text-muted-foreground ml-1">
|
<span className="text-xs text-muted-foreground ml-1">
|
||||||
- {formatDistance(hop.distanceFromPrev)}
|
- {formatDistance(hop.distanceFromPrev, distanceUnit)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{isValidLocation(hop.matches[0].lat, hop.matches[0].lon) && (
|
{isValidLocation(hop.matches[0].lat, hop.matches[0].lon) && (
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { ResolvedPath, SenderInfo } from '../utils/pathUtils';
|
|||||||
interface PathRouteMapProps {
|
interface PathRouteMapProps {
|
||||||
resolved: ResolvedPath;
|
resolved: ResolvedPath;
|
||||||
senderInfo: SenderInfo;
|
senderInfo: SenderInfo;
|
||||||
|
height?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Colors for hop markers (indexed by hop number - 1)
|
// Colors for hop markers (indexed by hop number - 1)
|
||||||
@@ -82,7 +83,7 @@ function RouteMapBounds({ points }: { points: [number, number][] }) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PathRouteMap({ resolved, senderInfo }: PathRouteMapProps) {
|
export function PathRouteMap({ resolved, senderInfo, height = 220 }: PathRouteMapProps) {
|
||||||
const points = collectPoints(resolved);
|
const points = collectPoints(resolved);
|
||||||
const hasAnyGps = points.length > 0;
|
const hasAnyGps = points.length > 0;
|
||||||
|
|
||||||
@@ -117,7 +118,7 @@ export function PathRouteMap({ resolved, senderInfo }: PathRouteMapProps) {
|
|||||||
className="rounded border border-border overflow-hidden"
|
className="rounded border border-border overflow-hidden"
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="Map showing message route between nodes"
|
aria-label="Map showing message route between nodes"
|
||||||
style={{ height: 220 }}
|
style={{ height }}
|
||||||
>
|
>
|
||||||
<MapContainer
|
<MapContainer
|
||||||
center={center}
|
center={center}
|
||||||
@@ -138,6 +139,8 @@ export function PathRouteMap({ resolved, senderInfo }: PathRouteMapProps) {
|
|||||||
icon={makeIcon('S', SENDER_COLOR)}
|
icon={makeIcon('S', SENDER_COLOR)}
|
||||||
>
|
>
|
||||||
<Tooltip direction="top" offset={[0, -14]}>
|
<Tooltip direction="top" offset={[0, -14]}>
|
||||||
|
<span className="font-mono">{resolved.sender.prefix}</span>
|
||||||
|
{' · '}
|
||||||
{senderInfo.name || 'Sender'}
|
{senderInfo.name || 'Sender'}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Marker>
|
</Marker>
|
||||||
@@ -154,6 +157,8 @@ export function PathRouteMap({ resolved, senderInfo }: PathRouteMapProps) {
|
|||||||
icon={makeIcon(String(hopIdx + 1), getHopColor(hopIdx))}
|
icon={makeIcon(String(hopIdx + 1), getHopColor(hopIdx))}
|
||||||
>
|
>
|
||||||
<Tooltip direction="top" offset={[0, -14]}>
|
<Tooltip direction="top" offset={[0, -14]}>
|
||||||
|
<span className="font-mono">{hop.prefix}</span>
|
||||||
|
{' · '}
|
||||||
{m.name || m.public_key.slice(0, 12)}
|
{m.name || m.public_key.slice(0, 12)}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Marker>
|
</Marker>
|
||||||
@@ -167,6 +172,8 @@ export function PathRouteMap({ resolved, senderInfo }: PathRouteMapProps) {
|
|||||||
icon={makeIcon('R', RECEIVER_COLOR)}
|
icon={makeIcon('R', RECEIVER_COLOR)}
|
||||||
>
|
>
|
||||||
<Tooltip direction="top" offset={[0, -14]}>
|
<Tooltip direction="top" offset={[0, -14]}>
|
||||||
|
<span className="font-mono">{resolved.receiver.prefix}</span>
|
||||||
|
{' · '}
|
||||||
{resolved.receiver.name || 'Receiver'}
|
{resolved.receiver.name || 'Receiver'}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Marker>
|
</Marker>
|
||||||
|
|||||||
@@ -0,0 +1,820 @@
|
|||||||
|
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||||
|
import { ChannelCrypto, PayloadType } from '@michaelhart/meshcore-decoder';
|
||||||
|
|
||||||
|
import type { Channel, RawPacket } from '../types';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
createDecoderOptions,
|
||||||
|
inspectRawPacketWithOptions,
|
||||||
|
type PacketByteField,
|
||||||
|
} from '../utils/rawPacketInspector';
|
||||||
|
import { toast } from './ui/sonner';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog';
|
||||||
|
|
||||||
|
interface RawPacketDetailModalProps {
|
||||||
|
packet: RawPacket | null;
|
||||||
|
channels: Channel[];
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawPacketInspectorDialogSource =
|
||||||
|
| {
|
||||||
|
kind: 'packet';
|
||||||
|
packet: RawPacket;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'paste';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'loading';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'unavailable';
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RawPacketInspectorDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
channels: Channel[];
|
||||||
|
source: RawPacketInspectorDialogSource;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
notice?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawPacketInspectionPanelProps {
|
||||||
|
packet: RawPacket;
|
||||||
|
channels: Channel[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FieldPaletteEntry {
|
||||||
|
box: string;
|
||||||
|
boxActive: string;
|
||||||
|
hex: string;
|
||||||
|
hexActive: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupTextResolutionCandidate {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
hash: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELD_PALETTE: FieldPaletteEntry[] = [
|
||||||
|
{
|
||||||
|
box: 'border-sky-500/30 bg-sky-500/10',
|
||||||
|
boxActive: 'border-sky-600 bg-sky-500/20 shadow-sm shadow-sky-500/20',
|
||||||
|
hex: 'bg-sky-500/20 ring-1 ring-inset ring-sky-500/35',
|
||||||
|
hexActive: 'bg-sky-500/40 ring-1 ring-inset ring-sky-600/70',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
box: 'border-emerald-500/30 bg-emerald-500/10',
|
||||||
|
boxActive: 'border-emerald-600 bg-emerald-500/20 shadow-sm shadow-emerald-500/20',
|
||||||
|
hex: 'bg-emerald-500/20 ring-1 ring-inset ring-emerald-500/35',
|
||||||
|
hexActive: 'bg-emerald-500/40 ring-1 ring-inset ring-emerald-600/70',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
box: 'border-amber-500/30 bg-amber-500/10',
|
||||||
|
boxActive: 'border-amber-600 bg-amber-500/20 shadow-sm shadow-amber-500/20',
|
||||||
|
hex: 'bg-amber-500/20 ring-1 ring-inset ring-amber-500/35',
|
||||||
|
hexActive: 'bg-amber-500/40 ring-1 ring-inset ring-amber-600/70',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
box: 'border-rose-500/30 bg-rose-500/10',
|
||||||
|
boxActive: 'border-rose-600 bg-rose-500/20 shadow-sm shadow-rose-500/20',
|
||||||
|
hex: 'bg-rose-500/20 ring-1 ring-inset ring-rose-500/35',
|
||||||
|
hexActive: 'bg-rose-500/40 ring-1 ring-inset ring-rose-600/70',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
box: 'border-violet-500/30 bg-violet-500/10',
|
||||||
|
boxActive: 'border-violet-600 bg-violet-500/20 shadow-sm shadow-violet-500/20',
|
||||||
|
hex: 'bg-violet-500/20 ring-1 ring-inset ring-violet-500/35',
|
||||||
|
hexActive: 'bg-violet-500/40 ring-1 ring-inset ring-violet-600/70',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
box: 'border-cyan-500/30 bg-cyan-500/10',
|
||||||
|
boxActive: 'border-cyan-600 bg-cyan-500/20 shadow-sm shadow-cyan-500/20',
|
||||||
|
hex: 'bg-cyan-500/20 ring-1 ring-inset ring-cyan-500/35',
|
||||||
|
hexActive: 'bg-cyan-500/40 ring-1 ring-inset ring-cyan-600/70',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
box: 'border-lime-500/30 bg-lime-500/10',
|
||||||
|
boxActive: 'border-lime-600 bg-lime-500/20 shadow-sm shadow-lime-500/20',
|
||||||
|
hex: 'bg-lime-500/20 ring-1 ring-inset ring-lime-500/35',
|
||||||
|
hexActive: 'bg-lime-500/40 ring-1 ring-inset ring-lime-600/70',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
box: 'border-fuchsia-500/30 bg-fuchsia-500/10',
|
||||||
|
boxActive: 'border-fuchsia-600 bg-fuchsia-500/20 shadow-sm shadow-fuchsia-500/20',
|
||||||
|
hex: 'bg-fuchsia-500/20 ring-1 ring-inset ring-fuchsia-500/35',
|
||||||
|
hexActive: 'bg-fuchsia-500/40 ring-1 ring-inset ring-fuchsia-600/70',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatTimestamp(timestamp: number): string {
|
||||||
|
return new Date(timestamp * 1000).toLocaleString([], {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSignal(packet: RawPacket): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (packet.rssi !== null) {
|
||||||
|
parts.push(`${packet.rssi} dBm RSSI`);
|
||||||
|
}
|
||||||
|
if (packet.snr !== null) {
|
||||||
|
parts.push(`${packet.snr.toFixed(1)} dB SNR`);
|
||||||
|
}
|
||||||
|
return parts.length > 0 ? parts.join(' · ') : 'No signal sample';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatByteRange(field: PacketByteField): string {
|
||||||
|
if (field.absoluteStartByte === field.absoluteEndByte) {
|
||||||
|
return `Byte ${field.absoluteStartByte}`;
|
||||||
|
}
|
||||||
|
return `Bytes ${field.absoluteStartByte}-${field.absoluteEndByte}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPathMode(hashSize: number | undefined, hopCount: number): string {
|
||||||
|
if (hopCount === 0) {
|
||||||
|
return 'No path hops';
|
||||||
|
}
|
||||||
|
if (!hashSize) {
|
||||||
|
return `${hopCount} hop${hopCount === 1 ? '' : 's'}`;
|
||||||
|
}
|
||||||
|
return `${hopCount} hop${hopCount === 1 ? '' : 's'} · ${hashSize} byte hash${hashSize === 1 ? '' : 'es'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGroupTextResolutionCandidates(channels: Channel[]): GroupTextResolutionCandidate[] {
|
||||||
|
return channels.map((channel) => ({
|
||||||
|
key: channel.key,
|
||||||
|
name: channel.name,
|
||||||
|
hash: ChannelCrypto.calculateChannelHash(channel.key).toUpperCase(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveGroupTextChannelName(
|
||||||
|
payload: {
|
||||||
|
channelHash?: string;
|
||||||
|
cipherMac?: string;
|
||||||
|
ciphertext?: string;
|
||||||
|
decrypted?: { message?: string };
|
||||||
|
},
|
||||||
|
candidates: GroupTextResolutionCandidate[]
|
||||||
|
): string | null {
|
||||||
|
if (!payload.channelHash) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashMatches = candidates.filter(
|
||||||
|
(candidate) => candidate.hash === payload.channelHash?.toUpperCase()
|
||||||
|
);
|
||||||
|
if (hashMatches.length === 1) {
|
||||||
|
return hashMatches[0].name;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
hashMatches.length <= 1 ||
|
||||||
|
!payload.cipherMac ||
|
||||||
|
!payload.ciphertext ||
|
||||||
|
!payload.decrypted?.message
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decryptMatches = hashMatches.filter(
|
||||||
|
(candidate) =>
|
||||||
|
ChannelCrypto.decryptGroupTextMessage(payload.ciphertext!, payload.cipherMac!, candidate.key)
|
||||||
|
.success
|
||||||
|
);
|
||||||
|
return decryptMatches.length === 1 ? decryptMatches[0].name : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function packetShowsDecryptedState(
|
||||||
|
packet: RawPacket,
|
||||||
|
inspection: ReturnType<typeof inspectRawPacketWithOptions>
|
||||||
|
): boolean {
|
||||||
|
const payload = inspection.decoded?.payload.decoded as { decrypted?: unknown } | null | undefined;
|
||||||
|
return packet.decrypted || Boolean(packet.decrypted_info) || Boolean(payload?.decrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPacketContext(
|
||||||
|
packet: RawPacket,
|
||||||
|
inspection: ReturnType<typeof inspectRawPacketWithOptions>,
|
||||||
|
groupTextCandidates: GroupTextResolutionCandidate[]
|
||||||
|
) {
|
||||||
|
const fallbackSender = packet.decrypted_info?.sender ?? null;
|
||||||
|
const fallbackChannel = packet.decrypted_info?.channel_name ?? null;
|
||||||
|
|
||||||
|
if (!inspection.decoded?.payload.decoded) {
|
||||||
|
if (!fallbackSender && !fallbackChannel) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: fallbackChannel ? 'Channel' : 'Context',
|
||||||
|
primary: fallbackChannel ?? 'Sender metadata available',
|
||||||
|
secondary: fallbackSender ? `Sender: ${fallbackSender}` : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inspection.decoded.payloadType === PayloadType.GroupText) {
|
||||||
|
const payload = inspection.decoded.payload.decoded as {
|
||||||
|
channelHash?: string;
|
||||||
|
cipherMac?: string;
|
||||||
|
ciphertext?: string;
|
||||||
|
decrypted?: { sender?: string; message?: string };
|
||||||
|
};
|
||||||
|
const channelName =
|
||||||
|
fallbackChannel ?? resolveGroupTextChannelName(payload, groupTextCandidates);
|
||||||
|
return {
|
||||||
|
title: 'Channel',
|
||||||
|
primary:
|
||||||
|
channelName ?? (payload.channelHash ? `Channel hash ${payload.channelHash}` : 'GroupText'),
|
||||||
|
secondary: payload.decrypted?.sender
|
||||||
|
? `Sender: ${payload.decrypted.sender}`
|
||||||
|
: fallbackSender
|
||||||
|
? `Sender: ${fallbackSender}`
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fallbackSender) {
|
||||||
|
return {
|
||||||
|
title: 'Context',
|
||||||
|
primary: fallbackSender,
|
||||||
|
secondary: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDisplayFields(inspection: ReturnType<typeof inspectRawPacketWithOptions>) {
|
||||||
|
return [
|
||||||
|
...inspection.packetFields.filter((field) => field.name !== 'Payload'),
|
||||||
|
...inspection.payloadFields,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFieldColorMap(fields: PacketByteField[]) {
|
||||||
|
return new Map(
|
||||||
|
fields.map((field, index) => [field.id, FIELD_PALETTE[index % FIELD_PALETTE.length]])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildByteOwners(totalBytes: number, fields: PacketByteField[]) {
|
||||||
|
const owners = new Array<string | null>(totalBytes).fill(null);
|
||||||
|
for (const field of fields) {
|
||||||
|
for (let index = field.absoluteStartByte; index <= field.absoluteEndByte; index += 1) {
|
||||||
|
if (index >= 0 && index < owners.length) {
|
||||||
|
owners[index] = field.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return owners;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildByteRuns(bytes: string[], owners: Array<string | null>) {
|
||||||
|
const runs: Array<{ fieldId: string | null; text: string }> = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < bytes.length; index += 1) {
|
||||||
|
const fieldId = owners[index];
|
||||||
|
const lastRun = runs[runs.length - 1];
|
||||||
|
if (lastRun && lastRun.fieldId === fieldId) {
|
||||||
|
lastRun.text += ` ${bytes[index]}`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
runs.push({
|
||||||
|
fieldId,
|
||||||
|
text: bytes[index],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return runs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompactMetaCard({
|
||||||
|
label,
|
||||||
|
primary,
|
||||||
|
secondary,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
primary: string;
|
||||||
|
secondary?: string | null;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border/70 bg-card/70 p-2.5">
|
||||||
|
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">{label}</div>
|
||||||
|
<div className="mt-1 text-sm font-medium leading-tight text-foreground">{primary}</div>
|
||||||
|
{secondary ? (
|
||||||
|
<div className="mt-1 text-xs leading-tight text-muted-foreground">{secondary}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FullPacketHex({
|
||||||
|
packetHex,
|
||||||
|
fields,
|
||||||
|
colorMap,
|
||||||
|
hoveredFieldId,
|
||||||
|
onHoverField,
|
||||||
|
}: {
|
||||||
|
packetHex: string;
|
||||||
|
fields: PacketByteField[];
|
||||||
|
colorMap: Map<string, FieldPaletteEntry>;
|
||||||
|
hoveredFieldId: string | null;
|
||||||
|
onHoverField: (fieldId: string | null) => void;
|
||||||
|
}) {
|
||||||
|
const normalized = packetHex.toUpperCase();
|
||||||
|
const bytes = useMemo(() => normalized.match(/.{1,2}/g) ?? [], [normalized]);
|
||||||
|
const byteOwners = useMemo(() => buildByteOwners(bytes.length, fields), [bytes.length, fields]);
|
||||||
|
const byteRuns = useMemo(() => buildByteRuns(bytes, byteOwners), [byteOwners, bytes]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="font-mono text-[15px] leading-7 text-foreground">
|
||||||
|
{byteRuns.map((run, index) => {
|
||||||
|
const fieldId = run.fieldId;
|
||||||
|
const palette = fieldId ? colorMap.get(fieldId) : null;
|
||||||
|
const active = fieldId !== null && hoveredFieldId === fieldId;
|
||||||
|
return (
|
||||||
|
<span key={`${fieldId ?? 'plain'}-${index}`}>
|
||||||
|
<span
|
||||||
|
onMouseEnter={() => onHoverField(fieldId)}
|
||||||
|
onMouseLeave={() => onHoverField(null)}
|
||||||
|
className={cn(
|
||||||
|
'inline rounded-sm px-0.5 py-0.5 transition-colors',
|
||||||
|
palette ? (active ? palette.hexActive : palette.hex) : ''
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{run.text}
|
||||||
|
</span>
|
||||||
|
{index < byteRuns.length - 1 ? ' ' : ''}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFieldValue(field: PacketByteField) {
|
||||||
|
if (field.name !== 'Path Data') {
|
||||||
|
return field.value.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = field.value
|
||||||
|
.toUpperCase()
|
||||||
|
.split(' → ')
|
||||||
|
.filter((part) => part.length > 0);
|
||||||
|
|
||||||
|
if (parts.length <= 1) {
|
||||||
|
return field.value.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="inline-flex flex-wrap justify-start gap-x-1 sm:justify-end">
|
||||||
|
{parts.map((part, index) => {
|
||||||
|
const isLast = index === parts.length - 1;
|
||||||
|
return (
|
||||||
|
<span key={`${field.id}-${part}-${index}`} className="whitespace-nowrap">
|
||||||
|
{isLast ? part : `${part} →`}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePacketHex(input: string): string {
|
||||||
|
return input.replace(/\s+/g, '').toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePacketHex(input: string): string | null {
|
||||||
|
if (!input) {
|
||||||
|
return 'Paste a packet hex string to analyze.';
|
||||||
|
}
|
||||||
|
if (!/^[0-9A-F]+$/.test(input)) {
|
||||||
|
return 'Packet hex may only contain 0-9 and A-F characters.';
|
||||||
|
}
|
||||||
|
if (input.length % 2 !== 0) {
|
||||||
|
return 'Packet hex must contain an even number of characters.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPastedRawPacket(packetHex: string): RawPacket {
|
||||||
|
return {
|
||||||
|
id: -1,
|
||||||
|
timestamp: Math.floor(Date.now() / 1000),
|
||||||
|
data: packetHex,
|
||||||
|
payload_type: 'Unknown',
|
||||||
|
snr: null,
|
||||||
|
rssi: null,
|
||||||
|
decrypted: false,
|
||||||
|
decrypted_info: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldBox({
|
||||||
|
field,
|
||||||
|
palette,
|
||||||
|
active,
|
||||||
|
onHoverField,
|
||||||
|
}: {
|
||||||
|
field: PacketByteField;
|
||||||
|
palette: FieldPaletteEntry;
|
||||||
|
active: boolean;
|
||||||
|
onHoverField: (fieldId: string | null) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onMouseEnter={() => onHoverField(field.id)}
|
||||||
|
onMouseLeave={() => onHoverField(null)}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border p-2.5 transition-colors',
|
||||||
|
active ? palette.boxActive : palette.box
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-start gap-2 sm:flex-row sm:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-base font-semibold leading-tight text-foreground">{field.name}</div>
|
||||||
|
<div className="mt-0.5 text-[11px] text-muted-foreground">{formatByteRange(field)}</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-full font-mono text-sm leading-5 text-foreground sm:max-w-[14rem] sm:text-right',
|
||||||
|
field.name === 'Path Data' ? 'break-normal' : 'break-all'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{renderFieldValue(field)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 whitespace-pre-wrap text-sm leading-5 text-foreground">
|
||||||
|
{field.description}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{field.decryptedMessage ? (
|
||||||
|
<div className="mt-2 rounded border border-border/50 bg-background/40 p-2">
|
||||||
|
<div className="text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
|
||||||
|
{field.name === 'Ciphertext' ? 'Plaintext' : 'Decoded value'}
|
||||||
|
</div>
|
||||||
|
<PlaintextContent text={field.decryptedMessage} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{field.headerBreakdown ? (
|
||||||
|
<div className="mt-2 space-y-1.5">
|
||||||
|
<div className="font-mono text-xs tracking-[0.16em] text-muted-foreground">
|
||||||
|
{field.headerBreakdown.fullBinary}
|
||||||
|
</div>
|
||||||
|
{field.headerBreakdown.fields.map((part) => (
|
||||||
|
<div
|
||||||
|
key={`${field.id}-${part.bits}-${part.field}`}
|
||||||
|
className="rounded border border-border/50 bg-background/40 p-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium leading-tight text-foreground">
|
||||||
|
{part.field}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-[11px] text-muted-foreground">Bits {part.bits}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="font-mono text-sm text-foreground">{part.binary}</div>
|
||||||
|
<div className="mt-0.5 text-[11px] text-muted-foreground">{part.value}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlaintextContent({ text }: { text: string }) {
|
||||||
|
const lines = text.split('\n');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-1 space-y-1 text-sm leading-5 text-foreground">
|
||||||
|
{lines.map((line, index) => {
|
||||||
|
const separatorIndex = line.indexOf(': ');
|
||||||
|
if (separatorIndex === -1) {
|
||||||
|
return (
|
||||||
|
<div key={`${line}-${index}`} className="font-mono">
|
||||||
|
{line}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = line.slice(0, separatorIndex + 1);
|
||||||
|
const value = line.slice(separatorIndex + 2);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`${line}-${index}`}>
|
||||||
|
<span>{label} </span>
|
||||||
|
<span className="font-mono">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldSection({
|
||||||
|
title,
|
||||||
|
fields,
|
||||||
|
colorMap,
|
||||||
|
hoveredFieldId,
|
||||||
|
onHoverField,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
fields: PacketByteField[];
|
||||||
|
colorMap: Map<string, FieldPaletteEntry>;
|
||||||
|
hoveredFieldId: string | null;
|
||||||
|
onHoverField: (fieldId: string | null) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="rounded-lg border border-border/70 bg-card/70 p-3">
|
||||||
|
<div className="mb-2 text-sm font-semibold text-foreground">{title}</div>
|
||||||
|
{fields.length === 0 ? (
|
||||||
|
<div className="text-sm text-muted-foreground">No decoded fields available.</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{fields.map((field) => (
|
||||||
|
<FieldBox
|
||||||
|
key={field.id}
|
||||||
|
field={field}
|
||||||
|
palette={colorMap.get(field.id) ?? FIELD_PALETTE[0]}
|
||||||
|
active={hoveredFieldId === field.id}
|
||||||
|
onHoverField={onHoverField}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RawPacketInspectionPanel({ packet, channels }: RawPacketInspectionPanelProps) {
|
||||||
|
const decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]);
|
||||||
|
const groupTextCandidates = useMemo(
|
||||||
|
() => buildGroupTextResolutionCandidates(channels),
|
||||||
|
[channels]
|
||||||
|
);
|
||||||
|
const inspection = useMemo(
|
||||||
|
() => inspectRawPacketWithOptions(packet, decoderOptions),
|
||||||
|
[decoderOptions, packet]
|
||||||
|
);
|
||||||
|
const [hoveredFieldId, setHoveredFieldId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const packetDisplayFields = useMemo(
|
||||||
|
() => inspection.packetFields.filter((field) => field.name !== 'Payload'),
|
||||||
|
[inspection]
|
||||||
|
);
|
||||||
|
const fullPacketFields = useMemo(() => buildDisplayFields(inspection), [inspection]);
|
||||||
|
const colorMap = useMemo(() => buildFieldColorMap(fullPacketFields), [fullPacketFields]);
|
||||||
|
const packetContext = useMemo(
|
||||||
|
() => getPacketContext(packet, inspection, groupTextCandidates),
|
||||||
|
[groupTextCandidates, inspection, packet]
|
||||||
|
);
|
||||||
|
const packetIsDecrypted = useMemo(
|
||||||
|
() => packetShowsDecryptedState(packet, inspection),
|
||||||
|
[inspection, packet]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||||
|
<div className="grid gap-2 lg:grid-cols-[minmax(0,1.45fr)_minmax(0,1fr)]">
|
||||||
|
<section className="rounded-lg border border-border/70 bg-card/70 p-3">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
Summary
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-base font-semibold leading-tight text-foreground">
|
||||||
|
{inspection.summary.summary}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-xs text-muted-foreground">
|
||||||
|
{formatTimestamp(packet.timestamp)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{packetContext ? (
|
||||||
|
<div className="mt-2 rounded-md border border-border/60 bg-background/35 px-2.5 py-2">
|
||||||
|
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
{packetContext.title}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-sm font-medium leading-tight text-foreground">
|
||||||
|
{packetContext.primary}
|
||||||
|
</div>
|
||||||
|
{packetContext.secondary ? (
|
||||||
|
<div className="mt-1 text-xs leading-tight text-muted-foreground">
|
||||||
|
{packetContext.secondary}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-2 sm:grid-cols-3 lg:grid-cols-1 xl:grid-cols-3">
|
||||||
|
<CompactMetaCard
|
||||||
|
label="Packet"
|
||||||
|
primary={`${packet.data.length / 2} bytes · ${packetIsDecrypted ? 'Decrypted' : 'Encrypted'}`}
|
||||||
|
secondary={`Storage #${packet.id}${packet.observation_id !== undefined ? ` · Observation #${packet.observation_id}` : ''}`}
|
||||||
|
/>
|
||||||
|
<CompactMetaCard
|
||||||
|
label="Transport"
|
||||||
|
primary={`${inspection.routeTypeName} · ${inspection.payloadTypeName}`}
|
||||||
|
secondary={`${inspection.payloadVersionName} · ${formatPathMode(inspection.decoded?.pathHashSize, inspection.pathTokens.length)}`}
|
||||||
|
/>
|
||||||
|
<CompactMetaCard
|
||||||
|
label="Signal"
|
||||||
|
primary={formatSignal(packet)}
|
||||||
|
secondary={packetContext ? null : undefined}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{inspection.validationErrors.length > 0 ? (
|
||||||
|
<div className="mt-3 rounded-lg border border-warning/40 bg-warning/10 p-2.5">
|
||||||
|
<div className="text-sm font-semibold text-foreground">Validation notes</div>
|
||||||
|
<div className="mt-1.5 space-y-1 text-sm text-foreground">
|
||||||
|
{inspection.validationErrors.map((error) => (
|
||||||
|
<div key={error}>{error}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-3 rounded-lg border border-border/70 bg-card/70 p-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="text-xl font-semibold text-foreground">Full packet hex</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(packet.data);
|
||||||
|
toast.success('Packet hex copied!');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2.5">
|
||||||
|
<FullPacketHex
|
||||||
|
packetHex={packet.data}
|
||||||
|
fields={fullPacketFields}
|
||||||
|
colorMap={colorMap}
|
||||||
|
hoveredFieldId={hoveredFieldId}
|
||||||
|
onHoverField={setHoveredFieldId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 grid gap-3 xl:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)]">
|
||||||
|
<FieldSection
|
||||||
|
title="Packet fields"
|
||||||
|
fields={packetDisplayFields}
|
||||||
|
colorMap={colorMap}
|
||||||
|
hoveredFieldId={hoveredFieldId}
|
||||||
|
onHoverField={setHoveredFieldId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FieldSection
|
||||||
|
title="Payload fields"
|
||||||
|
fields={inspection.payloadFields}
|
||||||
|
colorMap={colorMap}
|
||||||
|
hoveredFieldId={hoveredFieldId}
|
||||||
|
onHoverField={setHoveredFieldId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RawPacketInspectorDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
channels,
|
||||||
|
source,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
notice,
|
||||||
|
}: RawPacketInspectorDialogProps) {
|
||||||
|
const [packetInput, setPacketInput] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || source.kind !== 'paste') {
|
||||||
|
setPacketInput('');
|
||||||
|
}
|
||||||
|
}, [open, source.kind]);
|
||||||
|
|
||||||
|
const normalizedPacketInput = useMemo(() => normalizePacketHex(packetInput), [packetInput]);
|
||||||
|
const packetInputError = useMemo(
|
||||||
|
() => (normalizedPacketInput.length > 0 ? validatePacketHex(normalizedPacketInput) : null),
|
||||||
|
[normalizedPacketInput]
|
||||||
|
);
|
||||||
|
const analyzedPacket = useMemo(
|
||||||
|
() =>
|
||||||
|
normalizedPacketInput.length > 0 && packetInputError === null
|
||||||
|
? buildPastedRawPacket(normalizedPacketInput)
|
||||||
|
: null,
|
||||||
|
[normalizedPacketInput, packetInputError]
|
||||||
|
);
|
||||||
|
|
||||||
|
let body: ReactNode;
|
||||||
|
if (source.kind === 'packet') {
|
||||||
|
body = <RawPacketInspectionPanel packet={source.packet} channels={channels} />;
|
||||||
|
} else if (source.kind === 'paste') {
|
||||||
|
body = (
|
||||||
|
<>
|
||||||
|
<div className="border-b border-border px-4 py-3 pr-14">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<label className="text-sm font-medium text-foreground" htmlFor="raw-packet-input">
|
||||||
|
Packet Hex
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="raw-packet-input"
|
||||||
|
value={packetInput}
|
||||||
|
onChange={(event) => setPacketInput(event.target.value)}
|
||||||
|
placeholder="Paste raw packet hex here..."
|
||||||
|
className="min-h-14 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm text-foreground outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
{packetInputError ? (
|
||||||
|
<div className="text-sm text-destructive">{packetInputError}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{analyzedPacket ? (
|
||||||
|
<RawPacketInspectionPanel packet={analyzedPacket} channels={channels} />
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-1 items-center justify-center p-6 text-sm text-muted-foreground">
|
||||||
|
Paste a packet above to inspect it.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
} else if (source.kind === 'loading') {
|
||||||
|
body = (
|
||||||
|
<div className="flex flex-1 items-center justify-center p-6 text-sm text-muted-foreground">
|
||||||
|
{source.message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
body = (
|
||||||
|
<div className="flex flex-1 items-center justify-center p-6">
|
||||||
|
<div className="max-w-xl rounded-lg border border-warning/40 bg-warning/10 p-4 text-sm text-foreground">
|
||||||
|
{source.message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="flex h-[92dvh] max-w-[min(96vw,82rem)] flex-col gap-0 overflow-hidden p-0">
|
||||||
|
<DialogHeader className="border-b border-border px-5 py-3">
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{notice ? (
|
||||||
|
<div className="border-b border-border px-3 py-3 text-sm text-foreground">
|
||||||
|
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-destructive">
|
||||||
|
{notice}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{body}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RawPacketDetailModal({ packet, channels, onClose }: RawPacketDetailModalProps) {
|
||||||
|
if (!packet) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RawPacketInspectorDialog
|
||||||
|
open={packet !== null}
|
||||||
|
onOpenChange={(isOpen) => !isOpen && onClose()}
|
||||||
|
channels={channels}
|
||||||
|
source={{ kind: 'packet', packet }}
|
||||||
|
title="Packet Details"
|
||||||
|
description="Detailed byte and field breakdown for the selected raw packet."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,681 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip as RechartsTooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Cell,
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
|
import { RawPacketList } from './RawPacketList';
|
||||||
|
import { RawPacketInspectorDialog } from './RawPacketDetailModal';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import type { Channel, Contact, RawPacket } from '../types';
|
||||||
|
import {
|
||||||
|
RAW_PACKET_STATS_WINDOWS,
|
||||||
|
buildRawPacketStatsSnapshot,
|
||||||
|
type NeighborStat,
|
||||||
|
type PacketTimelineBin,
|
||||||
|
type RankedPacketStat,
|
||||||
|
type RawPacketStatsSessionState,
|
||||||
|
type RawPacketStatsWindow,
|
||||||
|
} from '../utils/rawPacketStats';
|
||||||
|
import { getContactDisplayName } from '../utils/pubkey';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface RawPacketFeedViewProps {
|
||||||
|
packets: RawPacket[];
|
||||||
|
rawPacketStatsSession: RawPacketStatsSessionState;
|
||||||
|
contacts: Contact[];
|
||||||
|
channels: Channel[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const 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;
|
||||||
|
|
||||||
|
const WINDOW_LABELS: Record<RawPacketStatsWindow, string> = {
|
||||||
|
'1m': '1 min',
|
||||||
|
'5m': '5 min',
|
||||||
|
'10m': '10 min',
|
||||||
|
'30m': '30 min',
|
||||||
|
session: 'Session',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TIMELINE_FILL_COLORS = ['#0ea5e9', '#10b981', '#f59e0b', '#f43f5e', '#8b5cf6'];
|
||||||
|
|
||||||
|
function formatTimestamp(timestampMs: number): string {
|
||||||
|
return new Date(timestampMs).toLocaleString([], {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds: number): string {
|
||||||
|
if (seconds < 60) {
|
||||||
|
return `${Math.max(1, Math.round(seconds))} sec`;
|
||||||
|
}
|
||||||
|
if (seconds < 3600) {
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainder = Math.round(seconds % 60);
|
||||||
|
return remainder > 0 ? `${minutes}m ${remainder}s` : `${minutes}m`;
|
||||||
|
}
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.round((seconds % 3600) / 60);
|
||||||
|
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRate(value: number): string {
|
||||||
|
if (value >= 100) return value.toFixed(0);
|
||||||
|
if (value >= 10) return value.toFixed(1);
|
||||||
|
return value.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPercent(value: number): string {
|
||||||
|
return `${Math.round(value * 100)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRssi(value: number | null): string {
|
||||||
|
return value === null ? '-' : `${Math.round(value)} dBm`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeResolvableSourceKey(sourceKey: string): string {
|
||||||
|
return sourceKey.startsWith('hash1:') ? sourceKey.slice(6) : sourceKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveContact(sourceKey: string | null, contacts: Contact[]): Contact | null {
|
||||||
|
if (!sourceKey || sourceKey.startsWith('name:')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedSourceKey = normalizeResolvableSourceKey(sourceKey).toLowerCase();
|
||||||
|
const matches = contacts.filter((contact) =>
|
||||||
|
contact.public_key.toLowerCase().startsWith(normalizedSourceKey)
|
||||||
|
);
|
||||||
|
if (matches.length !== 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveContactLabel(sourceKey: string | null, contacts: Contact[]): string | null {
|
||||||
|
const contact = resolveContact(sourceKey, contacts);
|
||||||
|
if (!contact) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return getContactDisplayName(contact.name, contact.public_key, contact.last_advert);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveNeighbor(item: NeighborStat, contacts: Contact[]): NeighborStat {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
label: resolveContactLabel(item.key, contacts) ?? item.label,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeResolvedNeighbors(items: NeighborStat[], contacts: Contact[]): NeighborStat[] {
|
||||||
|
const merged = new Map<string, NeighborStat>();
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const contact = resolveContact(item.key, contacts);
|
||||||
|
const canonicalKey = contact?.public_key ?? item.key;
|
||||||
|
const resolvedLabel =
|
||||||
|
contact != null
|
||||||
|
? getContactDisplayName(contact.name, contact.public_key, contact.last_advert)
|
||||||
|
: item.label;
|
||||||
|
const existing = merged.get(canonicalKey);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
merged.set(canonicalKey, {
|
||||||
|
...item,
|
||||||
|
key: canonicalKey,
|
||||||
|
label: resolvedLabel,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.count += item.count;
|
||||||
|
existing.lastSeen = Math.max(existing.lastSeen, item.lastSeen);
|
||||||
|
existing.bestRssi =
|
||||||
|
existing.bestRssi === null
|
||||||
|
? item.bestRssi
|
||||||
|
: item.bestRssi === null
|
||||||
|
? existing.bestRssi
|
||||||
|
: Math.max(existing.bestRssi, item.bestRssi);
|
||||||
|
existing.label = resolvedLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(merged.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNeighborIdentityResolvable(item: NeighborStat, contacts: Contact[]): boolean {
|
||||||
|
if (item.key.startsWith('name:')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return resolveContact(item.key, contacts) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStrongestNeighborDetail(
|
||||||
|
stats: ReturnType<typeof buildRawPacketStatsSnapshot>,
|
||||||
|
contacts: Contact[]
|
||||||
|
): string | undefined {
|
||||||
|
const strongestNeighbor = stats.strongestNeighbors[0];
|
||||||
|
if (!strongestNeighbor || strongestNeighbor.bestRssi === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedNeighbor = resolveNeighbor(strongestNeighbor, contacts);
|
||||||
|
return `${formatRssi(resolvedNeighbor.bestRssi)} best heard`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCoverageMessage(
|
||||||
|
stats: ReturnType<typeof buildRawPacketStatsSnapshot>,
|
||||||
|
session: RawPacketStatsSessionState
|
||||||
|
): { tone: 'default' | 'warning'; message: string } {
|
||||||
|
if (session.trimmedObservationCount > 0 && stats.window === 'session') {
|
||||||
|
return {
|
||||||
|
tone: 'warning',
|
||||||
|
message: `Detailed session history was trimmed after ${session.totalObservedPackets.toLocaleString()} observations.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stats.windowFullyCovered) {
|
||||||
|
return {
|
||||||
|
tone: 'warning',
|
||||||
|
message: `This window is only covered for ${formatDuration(stats.coverageSeconds)} of frontend-collected history.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tone: 'default',
|
||||||
|
message: `Tracking ${session.observations.length.toLocaleString()} detailed observations from this browser session.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatTile({ label, value, detail }: { label: string; value: string; detail?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="break-inside-avoid rounded-lg border border-border/70 bg-card/80 p-3">
|
||||||
|
<div className="text-[11px] uppercase tracking-wide text-muted-foreground">{label}</div>
|
||||||
|
<div className="mt-1 text-xl font-semibold tabular-nums text-foreground">{value}</div>
|
||||||
|
{detail ? <div className="mt-1 text-xs text-muted-foreground">{detail}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RankedBars({
|
||||||
|
title,
|
||||||
|
items,
|
||||||
|
emptyLabel,
|
||||||
|
formatter,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
items: RankedPacketStat[];
|
||||||
|
emptyLabel: string;
|
||||||
|
formatter?: (item: RankedPacketStat) => string;
|
||||||
|
}) {
|
||||||
|
const data = items.map((item) => ({
|
||||||
|
name: item.label,
|
||||||
|
value: item.count,
|
||||||
|
detail: formatter
|
||||||
|
? formatter(item)
|
||||||
|
: `${item.count.toLocaleString()} · ${formatPercent(item.share)}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="mt-3 text-sm text-muted-foreground">{emptyLabel}</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2">
|
||||||
|
<ResponsiveContainer width="100%" height={items.length * 28 + 8}>
|
||||||
|
<BarChart
|
||||||
|
data={data}
|
||||||
|
layout="vertical"
|
||||||
|
margin={{ top: 0, right: 4, bottom: 0, left: 0 }}
|
||||||
|
barCategoryGap="20%"
|
||||||
|
>
|
||||||
|
<XAxis type="number" hide />
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="name"
|
||||||
|
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={80}
|
||||||
|
/>
|
||||||
|
<RechartsTooltip
|
||||||
|
{...TOOLTIP_STYLE}
|
||||||
|
cursor={{ fill: 'hsl(var(--muted))', opacity: 0.5 }}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
formatter={(_v: any, _n: any, props: any) => [props.payload.detail, null]}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="value" radius={[0, 4, 4, 0]} maxBarSize={16}>
|
||||||
|
{data.map((_, i) => (
|
||||||
|
<Cell key={i} fill={TIMELINE_FILL_COLORS[i % TIMELINE_FILL_COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NeighborList({
|
||||||
|
title,
|
||||||
|
items,
|
||||||
|
emptyLabel,
|
||||||
|
mode,
|
||||||
|
contacts,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
items: NeighborStat[];
|
||||||
|
emptyLabel: string;
|
||||||
|
mode: 'heard' | 'signal' | 'recent';
|
||||||
|
contacts: Contact[];
|
||||||
|
}) {
|
||||||
|
const mergedItems = mergeResolvedNeighbors(items, contacts);
|
||||||
|
const sortedItems = [...mergedItems].sort((a, b) => {
|
||||||
|
if (mode === 'heard') {
|
||||||
|
return b.count - a.count || b.lastSeen - a.lastSeen || a.label.localeCompare(b.label);
|
||||||
|
}
|
||||||
|
if (mode === 'signal') {
|
||||||
|
return (
|
||||||
|
(b.bestRssi ?? Number.NEGATIVE_INFINITY) - (a.bestRssi ?? Number.NEGATIVE_INFINITY) ||
|
||||||
|
b.count - a.count ||
|
||||||
|
a.label.localeCompare(b.label)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return b.lastSeen - a.lastSeen || b.count - a.count || a.label.localeCompare(b.label);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
|
||||||
|
{sortedItems.length === 0 ? (
|
||||||
|
<p className="mt-3 text-sm text-muted-foreground">{emptyLabel}</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{sortedItems.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.key}
|
||||||
|
className="flex items-center justify-between gap-3 rounded-md bg-background/70 px-2 py-1.5"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm text-foreground">{item.label}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{mode === 'heard'
|
||||||
|
? `${item.count.toLocaleString()} packets`
|
||||||
|
: mode === 'signal'
|
||||||
|
? `${formatRssi(item.bestRssi)} best`
|
||||||
|
: `Last seen ${new Date(item.lastSeen * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`}
|
||||||
|
</div>
|
||||||
|
{!isNeighborIdentityResolvable(item, contacts) ? (
|
||||||
|
<div className="text-[11px] text-warning">Identity not resolvable</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{mode !== 'signal' ? (
|
||||||
|
<div className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||||
|
{mode === 'recent' ? formatRssi(item.bestRssi) : formatRssi(item.bestRssi)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimelineChart({ bins }: { bins: PacketTimelineBin[] }) {
|
||||||
|
const typeOrder = Array.from(new Set(bins.flatMap((bin) => Object.keys(bin.countsByType)))).slice(
|
||||||
|
0,
|
||||||
|
TIMELINE_FILL_COLORS.length
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = bins.map((bin) => {
|
||||||
|
const entry: Record<string, string | number> = { label: bin.label };
|
||||||
|
for (const type of typeOrder) {
|
||||||
|
entry[type] = bin.countsByType[type] ?? 0;
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">Traffic Timeline</h3>
|
||||||
|
<div className="flex flex-wrap justify-end gap-2 text-[11px] text-muted-foreground">
|
||||||
|
{typeOrder.map((type, i) => (
|
||||||
|
<span key={type} className="inline-flex items-center gap-1">
|
||||||
|
<span
|
||||||
|
className="h-2 w-2 rounded-full"
|
||||||
|
style={{ backgroundColor: TIMELINE_FILL_COLORS[i] }}
|
||||||
|
/>
|
||||||
|
<span>{type}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<ResponsiveContainer width="100%" height={110}>
|
||||||
|
<BarChart data={data} margin={{ top: 4, right: 0, bottom: 0, left: -24 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="label"
|
||||||
|
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
allowDecimals={false}
|
||||||
|
/>
|
||||||
|
<RechartsTooltip
|
||||||
|
{...TOOLTIP_STYLE}
|
||||||
|
cursor={{ fill: 'hsl(var(--muted))', opacity: 0.5 }}
|
||||||
|
/>
|
||||||
|
{typeOrder.map((type, i) => (
|
||||||
|
<Bar
|
||||||
|
key={type}
|
||||||
|
dataKey={type}
|
||||||
|
stackId="packets"
|
||||||
|
fill={TIMELINE_FILL_COLORS[i]}
|
||||||
|
radius={i === typeOrder.length - 1 ? [2, 2, 0, 0] : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RawPacketFeedView({
|
||||||
|
packets,
|
||||||
|
rawPacketStatsSession,
|
||||||
|
contacts,
|
||||||
|
channels,
|
||||||
|
}: RawPacketFeedViewProps) {
|
||||||
|
const [statsOpen, setStatsOpen] = useState(() =>
|
||||||
|
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
||||||
|
? window.matchMedia('(min-width: 768px)').matches
|
||||||
|
: false
|
||||||
|
);
|
||||||
|
const [selectedWindow, setSelectedWindow] = useState<RawPacketStatsWindow>('10m');
|
||||||
|
const [nowSec, setNowSec] = useState(() => Math.floor(Date.now() / 1000));
|
||||||
|
const [selectedPacket, setSelectedPacket] = useState<RawPacket | null>(null);
|
||||||
|
const [analyzeModalOpen, setAnalyzeModalOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
setNowSec(Math.floor(Date.now() / 1000));
|
||||||
|
}, 30000);
|
||||||
|
return () => window.clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNowSec(Math.floor(Date.now() / 1000));
|
||||||
|
}, [packets, rawPacketStatsSession]);
|
||||||
|
|
||||||
|
const stats = useMemo(
|
||||||
|
() => buildRawPacketStatsSnapshot(rawPacketStatsSession, selectedWindow, nowSec),
|
||||||
|
[nowSec, rawPacketStatsSession, selectedWindow]
|
||||||
|
);
|
||||||
|
const coverageMessage = getCoverageMessage(stats, rawPacketStatsSession);
|
||||||
|
const strongestNeighbor = useMemo(() => {
|
||||||
|
const topNeighbor = stats.strongestNeighbors[0];
|
||||||
|
return topNeighbor ? resolveNeighbor(topNeighbor, contacts) : null;
|
||||||
|
}, [contacts, stats]);
|
||||||
|
|
||||||
|
const strongestNeighborDetail = useMemo(
|
||||||
|
() => formatStrongestNeighborDetail(stats, contacts),
|
||||||
|
[contacts, stats]
|
||||||
|
);
|
||||||
|
const strongestNeighbors = useMemo(
|
||||||
|
() => stats.strongestNeighbors.map((item) => resolveNeighbor(item, contacts)),
|
||||||
|
[contacts, stats.strongestNeighbors]
|
||||||
|
);
|
||||||
|
const mostActiveNeighbors = useMemo(
|
||||||
|
() => stats.mostActiveNeighbors.map((item) => resolveNeighbor(item, contacts)),
|
||||||
|
[contacts, stats.mostActiveNeighbors]
|
||||||
|
);
|
||||||
|
const newestNeighbors = useMemo(
|
||||||
|
() => stats.newestNeighbors.map((item) => resolveNeighbor(item, contacts)),
|
||||||
|
[contacts, stats.newestNeighbors]
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between gap-3 border-b border-border px-4 py-2.5">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-base text-foreground">Raw Packet Feed</h2>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Collecting stats since {formatTimestamp(rawPacketStatsSession.sessionStartedAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setAnalyzeModalOpen(true)}
|
||||||
|
>
|
||||||
|
Analyze Packet
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStatsOpen((current) => !current)}
|
||||||
|
aria-expanded={statsOpen}
|
||||||
|
>
|
||||||
|
{statsOpen ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
|
||||||
|
{statsOpen ? 'Hide Stats' : 'Show Stats'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
|
||||||
|
<div className={cn('min-h-0 min-w-0 flex-1', statsOpen && 'md:border-r md:border-border')}>
|
||||||
|
<RawPacketList packets={packets} channels={channels} onPacketClick={setSelectedPacket} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 overflow-hidden border-t border-border transition-all duration-300 md:border-l md:border-t-0',
|
||||||
|
statsOpen
|
||||||
|
? 'max-h-[42rem] md:max-h-none md:w-1/2 md:min-w-[30rem]'
|
||||||
|
: 'max-h-0 md:w-0 md:min-w-0 border-transparent'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{statsOpen ? (
|
||||||
|
<div className="h-full overflow-y-auto bg-background p-4 [contain:layout_paint]">
|
||||||
|
<div className="break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
Coverage
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'mt-1 text-sm',
|
||||||
|
coverageMessage.tone === 'warning'
|
||||||
|
? 'text-warning'
|
||||||
|
: 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{coverageMessage.message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||||
|
<span className="text-muted-foreground">Window</span>
|
||||||
|
<select
|
||||||
|
value={selectedWindow}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSelectedWindow(event.target.value as RawPacketStatsWindow)
|
||||||
|
}
|
||||||
|
className="rounded-md border border-input bg-background px-2 py-1 text-sm"
|
||||||
|
aria-label="Stats window"
|
||||||
|
>
|
||||||
|
{RAW_PACKET_STATS_WINDOWS.map((option) => (
|
||||||
|
<option key={option} value={option}>
|
||||||
|
{WINDOW_LABELS[option]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-xs text-muted-foreground">
|
||||||
|
{stats.packetCount.toLocaleString()} packets in{' '}
|
||||||
|
{WINDOW_LABELS[selectedWindow].toLowerCase()} window
|
||||||
|
{' · '}
|
||||||
|
{rawPacketStatsSession.totalObservedPackets.toLocaleString()} observed this
|
||||||
|
session
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-3 md:grid-cols-3">
|
||||||
|
<StatTile
|
||||||
|
label="Packets / min"
|
||||||
|
value={formatRate(stats.packetsPerMinute)}
|
||||||
|
detail={`${stats.packetCount.toLocaleString()} total in window`}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label="Unique Sources"
|
||||||
|
value={stats.uniqueSources.toLocaleString()}
|
||||||
|
detail="Distinct identified senders"
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label="Decrypt Rate"
|
||||||
|
value={formatPercent(stats.decryptRate)}
|
||||||
|
detail={`${stats.decryptedCount.toLocaleString()} decrypted / ${stats.undecryptedCount.toLocaleString()} locked`}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label="Path Diversity"
|
||||||
|
value={stats.distinctPaths.toLocaleString()}
|
||||||
|
detail={`${formatPercent(stats.pathBearingRate)} path-bearing packets`}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label="Strongest Neighbor"
|
||||||
|
value={strongestNeighbor?.label ?? '-'}
|
||||||
|
detail={strongestNeighborDetail ?? 'No neighbor RSSI sample in window'}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label="Median RSSI"
|
||||||
|
value={formatRssi(stats.medianRssi)}
|
||||||
|
detail={
|
||||||
|
stats.averageRssi === null
|
||||||
|
? 'No signal sample in window'
|
||||||
|
: `Average ${formatRssi(stats.averageRssi)}`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<TimelineChart bins={stats.timeline} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:columns-2 md:gap-4">
|
||||||
|
<RankedBars
|
||||||
|
title="Packet Types"
|
||||||
|
items={stats.payloadBreakdown}
|
||||||
|
emptyLabel="No packets in this window yet."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RankedBars
|
||||||
|
title="Route Mix"
|
||||||
|
items={stats.routeBreakdown}
|
||||||
|
emptyLabel="No packets in this window yet."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RankedBars
|
||||||
|
title="Hop Profile"
|
||||||
|
items={stats.hopProfile}
|
||||||
|
emptyLabel="No packets in this window yet."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RankedBars
|
||||||
|
title="Hop Byte Width"
|
||||||
|
items={stats.hopByteWidthProfile}
|
||||||
|
emptyLabel="No packets in this window yet."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RankedBars
|
||||||
|
title="Signal Distribution"
|
||||||
|
items={stats.rssiBuckets}
|
||||||
|
emptyLabel="No RSSI samples in this window yet."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NeighborList
|
||||||
|
title="Most-Heard Neighbors"
|
||||||
|
items={mostActiveNeighbors}
|
||||||
|
emptyLabel="No sender identities resolved in this window yet."
|
||||||
|
mode="heard"
|
||||||
|
contacts={contacts}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NeighborList
|
||||||
|
title="Strongest Recent Neighbors"
|
||||||
|
items={strongestNeighbors}
|
||||||
|
emptyLabel="No RSSI-tagged neighbors in this window yet."
|
||||||
|
mode="signal"
|
||||||
|
contacts={contacts}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NeighborList
|
||||||
|
title="Newest Heard Neighbors"
|
||||||
|
items={newestNeighbors}
|
||||||
|
emptyLabel="No newly identified neighbors in this window yet."
|
||||||
|
mode="recent"
|
||||||
|
contacts={contacts}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RawPacketInspectorDialog
|
||||||
|
open={selectedPacket !== null}
|
||||||
|
onOpenChange={(isOpen) => !isOpen && setSelectedPacket(null)}
|
||||||
|
channels={channels}
|
||||||
|
source={
|
||||||
|
selectedPacket
|
||||||
|
? { kind: 'packet', packet: selectedPacket }
|
||||||
|
: { kind: 'loading', message: 'Loading packet...' }
|
||||||
|
}
|
||||||
|
title="Packet Details"
|
||||||
|
description="Detailed byte and field breakdown for the selected raw packet."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RawPacketInspectorDialog
|
||||||
|
open={analyzeModalOpen}
|
||||||
|
onOpenChange={setAnalyzeModalOpen}
|
||||||
|
channels={channels}
|
||||||
|
source={{ kind: 'paste' }}
|
||||||
|
title="Analyze Packet"
|
||||||
|
description="Paste and inspect a raw packet hex string."
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useEffect, useRef, useMemo } from 'react';
|
import { useEffect, useRef, useMemo } from 'react';
|
||||||
import { MeshCoreDecoder, PayloadType, Utils } from '@michaelhart/meshcore-decoder';
|
import type { Channel, RawPacket } from '../types';
|
||||||
import type { RawPacket } from '../types';
|
|
||||||
import { getRawPacketObservationKey } from '../utils/rawPacketIdentity';
|
import { getRawPacketObservationKey } from '../utils/rawPacketIdentity';
|
||||||
|
import { createDecoderOptions, decodePacketSummary } from '../utils/rawPacketInspector';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface RawPacketListProps {
|
interface RawPacketListProps {
|
||||||
packets: RawPacket[];
|
packets: RawPacket[];
|
||||||
|
channels?: Channel[];
|
||||||
|
onPacketClick?: (packet: RawPacket) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(timestamp: number): string {
|
function formatTime(timestamp: number): string {
|
||||||
@@ -24,132 +26,6 @@ function formatSignalInfo(packet: RawPacket): string {
|
|||||||
return parts.join(' | ');
|
return parts.join(' | ');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrypted info from the packet (validated by backend)
|
|
||||||
interface DecryptedInfo {
|
|
||||||
channel_name: string | null;
|
|
||||||
sender: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode a packet and generate a human-readable summary
|
|
||||||
// Uses backend's decrypted_info when available (validated), falls back to decoder
|
|
||||||
function decodePacketSummary(
|
|
||||||
hexData: string,
|
|
||||||
decryptedInfo: DecryptedInfo | null
|
|
||||||
): {
|
|
||||||
summary: string;
|
|
||||||
routeType: string;
|
|
||||||
details?: string;
|
|
||||||
} {
|
|
||||||
try {
|
|
||||||
const decoded = MeshCoreDecoder.decode(hexData);
|
|
||||||
|
|
||||||
if (!decoded.isValid) {
|
|
||||||
return { summary: 'Invalid packet', routeType: 'Unknown' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const routeType = Utils.getRouteTypeName(decoded.routeType);
|
|
||||||
const payloadTypeName = Utils.getPayloadTypeName(decoded.payloadType);
|
|
||||||
const tracePayload =
|
|
||||||
decoded.payloadType === PayloadType.Trace && decoded.payload.decoded
|
|
||||||
? (decoded.payload.decoded as { pathHashes?: string[] })
|
|
||||||
: null;
|
|
||||||
const pathTokens = tracePayload?.pathHashes || decoded.path || [];
|
|
||||||
|
|
||||||
// Build path string if available
|
|
||||||
const pathStr = pathTokens.length > 0 ? ` via ${pathTokens.join('-')}` : '';
|
|
||||||
|
|
||||||
// Generate summary based on payload type
|
|
||||||
let summary = payloadTypeName;
|
|
||||||
let details: string | undefined;
|
|
||||||
|
|
||||||
switch (decoded.payloadType) {
|
|
||||||
case PayloadType.TextMessage: {
|
|
||||||
const payload = decoded.payload.decoded as {
|
|
||||||
destinationHash?: string;
|
|
||||||
sourceHash?: string;
|
|
||||||
} | null;
|
|
||||||
if (payload?.sourceHash && payload?.destinationHash) {
|
|
||||||
summary = `DM from ${payload.sourceHash} to ${payload.destinationHash}${pathStr}`;
|
|
||||||
} else {
|
|
||||||
summary = `DM${pathStr}`;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case PayloadType.GroupText: {
|
|
||||||
const payload = decoded.payload.decoded as {
|
|
||||||
channelHash?: string;
|
|
||||||
} | null;
|
|
||||||
// Use backend's validated decrypted_info when available
|
|
||||||
if (decryptedInfo?.channel_name) {
|
|
||||||
if (decryptedInfo.sender) {
|
|
||||||
summary = `GT from ${decryptedInfo.sender} in ${decryptedInfo.channel_name}${pathStr}`;
|
|
||||||
} else {
|
|
||||||
summary = `GT in ${decryptedInfo.channel_name}${pathStr}`;
|
|
||||||
}
|
|
||||||
} else if (payload?.channelHash) {
|
|
||||||
// Fallback to showing channel hash when not decrypted
|
|
||||||
summary = `GT ch:${payload.channelHash}${pathStr}`;
|
|
||||||
} else {
|
|
||||||
summary = `GroupText${pathStr}`;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case PayloadType.Advert: {
|
|
||||||
const payload = decoded.payload.decoded as {
|
|
||||||
publicKey?: string;
|
|
||||||
appData?: { name?: string; deviceRole?: number };
|
|
||||||
} | null;
|
|
||||||
if (payload?.appData?.name) {
|
|
||||||
const role =
|
|
||||||
payload.appData.deviceRole !== undefined
|
|
||||||
? Utils.getDeviceRoleName(payload.appData.deviceRole)
|
|
||||||
: '';
|
|
||||||
summary = `Advert: ${payload.appData.name}${role ? ` (${role})` : ''}${pathStr}`;
|
|
||||||
} else if (payload?.publicKey) {
|
|
||||||
summary = `Advert: ${payload.publicKey.slice(0, 8)}...${pathStr}`;
|
|
||||||
} else {
|
|
||||||
summary = `Advert${pathStr}`;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case PayloadType.Ack: {
|
|
||||||
summary = `ACK${pathStr}`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case PayloadType.Request: {
|
|
||||||
summary = `Request${pathStr}`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case PayloadType.Response: {
|
|
||||||
summary = `Response${pathStr}`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case PayloadType.Trace: {
|
|
||||||
summary = `Trace${pathStr}`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case PayloadType.Path: {
|
|
||||||
summary = `Path${pathStr}`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
summary = `${payloadTypeName}${pathStr}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { summary, routeType, details };
|
|
||||||
} catch {
|
|
||||||
return { summary: 'Decode error', routeType: 'Unknown' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get route type badge color
|
// Get route type badge color
|
||||||
function getRouteTypeColor(routeType: string): string {
|
function getRouteTypeColor(routeType: string): string {
|
||||||
switch (routeType) {
|
switch (routeType) {
|
||||||
@@ -182,16 +58,17 @@ function getRouteTypeLabel(routeType: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RawPacketList({ packets }: RawPacketListProps) {
|
export function RawPacketList({ packets, channels, onPacketClick }: RawPacketListProps) {
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
const decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]);
|
||||||
|
|
||||||
// Decode all packets (memoized to avoid re-decoding on every render)
|
// Decode all packets (memoized to avoid re-decoding on every render)
|
||||||
const decodedPackets = useMemo(() => {
|
const decodedPackets = useMemo(() => {
|
||||||
return packets.map((packet) => ({
|
return packets.map((packet) => ({
|
||||||
packet,
|
packet,
|
||||||
decoded: decodePacketSummary(packet.data, packet.decrypted_info),
|
decoded: decodePacketSummary(packet, decoderOptions),
|
||||||
}));
|
}));
|
||||||
}, [packets]);
|
}, [decoderOptions, packets]);
|
||||||
|
|
||||||
// Sort packets by timestamp ascending (oldest first)
|
// Sort packets by timestamp ascending (oldest first)
|
||||||
const sortedPackets = useMemo(
|
const sortedPackets = useMemo(
|
||||||
@@ -218,54 +95,78 @@ export function RawPacketList({ packets }: RawPacketListProps) {
|
|||||||
className="h-full overflow-y-auto p-4 flex flex-col gap-2 [contain:layout_paint]"
|
className="h-full overflow-y-auto p-4 flex flex-col gap-2 [contain:layout_paint]"
|
||||||
ref={listRef}
|
ref={listRef}
|
||||||
>
|
>
|
||||||
{sortedPackets.map(({ packet, decoded }) => (
|
{sortedPackets.map(({ packet, decoded }) => {
|
||||||
<div
|
const cardContent = (
|
||||||
key={getRawPacketObservationKey(packet)}
|
<>
|
||||||
className="py-2 px-3 bg-card rounded-md border border-border/50"
|
<div className="flex items-center gap-2">
|
||||||
>
|
{/* Route type badge */}
|
||||||
<div className="flex items-center gap-2">
|
<span
|
||||||
{/* Route type badge */}
|
className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${getRouteTypeColor(decoded.routeType)}`}
|
||||||
<span
|
title={decoded.routeType}
|
||||||
className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${getRouteTypeColor(decoded.routeType)}`}
|
>
|
||||||
title={decoded.routeType}
|
{getRouteTypeLabel(decoded.routeType)}
|
||||||
>
|
</span>
|
||||||
{getRouteTypeLabel(decoded.routeType)}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Encryption status */}
|
{/* Encryption status */}
|
||||||
{!packet.decrypted && (
|
{!packet.decrypted && (
|
||||||
<>
|
<>
|
||||||
<span aria-hidden="true">🔒</span>
|
<span aria-hidden="true">🔒</span>
|
||||||
<span className="sr-only">Encrypted</span>
|
<span className="sr-only">Encrypted</span>
|
||||||
</>
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<span
|
||||||
|
className={cn('text-[13px]', packet.decrypted ? 'text-primary' : 'text-foreground')}
|
||||||
|
>
|
||||||
|
{decoded.summary}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Time */}
|
||||||
|
<span className="text-muted-foreground ml-auto text-[12px] tabular-nums">
|
||||||
|
{formatTime(packet.timestamp)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Signal info */}
|
||||||
|
{(packet.snr !== null || packet.rssi !== null) && (
|
||||||
|
<div className="text-[11px] text-muted-foreground mt-0.5 tabular-nums">
|
||||||
|
{formatSignalInfo(packet)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Summary */}
|
{/* Raw hex data (always visible) */}
|
||||||
<span
|
<div className="font-mono text-[10px] break-all text-muted-foreground mt-1.5 p-1.5 bg-background/60 rounded">
|
||||||
className={cn('text-[13px]', packet.decrypted ? 'text-primary' : 'text-foreground')}
|
{packet.data.toUpperCase()}
|
||||||
>
|
|
||||||
{decoded.summary}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Time */}
|
|
||||||
<span className="text-muted-foreground ml-auto text-[12px] tabular-nums">
|
|
||||||
{formatTime(packet.timestamp)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Signal info */}
|
|
||||||
{(packet.snr !== null || packet.rssi !== null) && (
|
|
||||||
<div className="text-[11px] text-muted-foreground mt-0.5 tabular-nums">
|
|
||||||
{formatSignalInfo(packet)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
{/* Raw hex data (always visible) */}
|
const className = cn(
|
||||||
<div className="font-mono text-[10px] break-all text-muted-foreground mt-1.5 p-1.5 bg-background/60 rounded">
|
'rounded-md border border-border/50 bg-card px-3 py-2 text-left',
|
||||||
{packet.data.toUpperCase()}
|
onPacketClick &&
|
||||||
|
'cursor-pointer transition-colors hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (onPacketClick) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={getRawPacketObservationKey(packet)}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPacketClick(packet)}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{cardContent}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={getRawPacketObservationKey(packet)} className={className}>
|
||||||
|
{cardContent}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } 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 { useRememberedServerPassword } from '../hooks/useRememberedServerPassword';
|
||||||
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
|
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
|
||||||
import { isFavorite } from '../utils/favorites';
|
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 { TelemetryPane } from './repeater/RepeaterTelemetryPane';
|
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
|
||||||
import { NeighborsPane } from './repeater/RepeaterNeighborsPane';
|
import { NeighborsPane } from './repeater/RepeaterNeighborsPane';
|
||||||
import { AclPane } from './repeater/RepeaterAclPane';
|
import { AclPane } from './repeater/RepeaterAclPane';
|
||||||
@@ -20,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)
|
||||||
@@ -42,6 +53,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RepeaterDashboard({
|
export function RepeaterDashboard({
|
||||||
@@ -59,6 +71,7 @@ export function RepeaterDashboard({
|
|||||||
onToggleNotifications,
|
onToggleNotifications,
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
onDeleteContact,
|
onDeleteContact,
|
||||||
|
onOpenContactInfo,
|
||||||
}: 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;
|
||||||
@@ -67,6 +80,7 @@ export function RepeaterDashboard({
|
|||||||
loggedIn,
|
loggedIn,
|
||||||
loginLoading,
|
loginLoading,
|
||||||
loginError,
|
loginError,
|
||||||
|
lastLoginAttempt,
|
||||||
paneData,
|
paneData,
|
||||||
paneStates,
|
paneStates,
|
||||||
consoleHistory,
|
consoleHistory,
|
||||||
@@ -81,8 +95,36 @@ export function RepeaterDashboard({
|
|||||||
rebootRepeater,
|
rebootRepeater,
|
||||||
syncClock,
|
syncClock,
|
||||||
} = useRepeaterDashboard(conversation, { hasAdvertLocation });
|
} = useRepeaterDashboard(conversation, { hasAdvertLocation });
|
||||||
|
const { password, setPassword, rememberPassword, setRememberPassword, persistAfterLogin } =
|
||||||
|
useRememberedServerPassword('repeater', conversation.id);
|
||||||
|
|
||||||
|
// Telemetry history: preload from stored data, refresh from live status
|
||||||
|
const [telemetryHistory, setTelemetryHistory] = useState<TelemetryHistoryEntry[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loggedIn) return;
|
||||||
|
api
|
||||||
|
.repeaterTelemetryHistory(conversation.id)
|
||||||
|
.then(setTelemetryHistory)
|
||||||
|
.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 && liveHistory.length > 0) {
|
||||||
|
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) => {
|
||||||
|
await login(nextPassword);
|
||||||
|
persistAfterLogin(nextPassword);
|
||||||
|
};
|
||||||
|
const handleRepeaterGuestLogin = async () => {
|
||||||
|
await loginAsGuest();
|
||||||
|
persistAfterLogin('');
|
||||||
|
};
|
||||||
|
|
||||||
// Loading all panes indicator
|
// Loading all panes indicator
|
||||||
const anyLoading = Object.values(paneStates).some((s) => s.loading);
|
const anyLoading = Object.values(paneStates).some((s) => s.loading);
|
||||||
@@ -90,13 +132,35 @@ export function RepeaterDashboard({
|
|||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col min-h-0">
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="flex justify-between items-start px-4 py-2.5 border-b border-border gap-2">
|
<header
|
||||||
<span className="flex min-w-0 flex-1 flex-col">
|
className={cn(
|
||||||
|
'grid items-start gap-x-2 gap-y-0.5 border-b border-border px-4 py-2.5',
|
||||||
|
contact
|
||||||
|
? 'grid-cols-[minmax(0,1fr)_auto] min-[1100px]:grid-cols-[minmax(0,1fr)_auto_auto]'
|
||||||
|
: 'grid-cols-[minmax(0,1fr)_auto]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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-[11px] text-muted-foreground transition-colors hover:text-primary"
|
||||||
role="button"
|
role="button"
|
||||||
@@ -111,14 +175,14 @@ export function RepeaterDashboard({
|
|||||||
{conversation.id}
|
{conversation.id}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{contact && (
|
|
||||||
<span className="min-w-0 flex-none text-[11px] text-muted-foreground max-sm:basis-full">
|
|
||||||
<ContactStatusInfo contact={contact} ourLat={radioLat} ourLon={radioLon} />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
{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">
|
||||||
|
<ContactStatusInfo contact={contact} ourLat={radioLat} ourLon={radioLon} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
{loggedIn && (
|
{loggedIn && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -221,11 +285,23 @@ export function RepeaterDashboard({
|
|||||||
repeaterName={conversation.name}
|
repeaterName={conversation.name}
|
||||||
loading={loginLoading}
|
loading={loginLoading}
|
||||||
error={loginError}
|
error={loginError}
|
||||||
onLogin={login}
|
password={password}
|
||||||
onLoginAsGuest={loginAsGuest}
|
onPasswordChange={setPassword}
|
||||||
|
rememberPassword={rememberPassword}
|
||||||
|
onRememberPasswordChange={setRememberPassword}
|
||||||
|
onLogin={handleRepeaterLogin}
|
||||||
|
onLoginAsGuest={handleRepeaterGuestLogin}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<ServerLoginStatusBanner
|
||||||
|
attempt={lastLoginAttempt}
|
||||||
|
loading={loginLoading}
|
||||||
|
canRetryPassword={password.trim().length > 0}
|
||||||
|
onRetryPassword={() => handleRepeaterLogin(password)}
|
||||||
|
onRetryBlank={handleRepeaterGuestLogin}
|
||||||
|
blankRetryLabel="Retry Existing-Access Login"
|
||||||
|
/>
|
||||||
{/* Top row: Telemetry + Radio Settings | Node Info + Neighbors */}
|
{/* Top row: Telemetry + Radio Settings | Node Info + Neighbors */}
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 md:items-stretch">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 md:items-stretch">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
@@ -241,6 +317,7 @@ export function RepeaterDashboard({
|
|||||||
onRefresh={() => refreshPane('status')}
|
onRefresh={() => refreshPane('status')}
|
||||||
disabled={anyLoading}
|
disabled={anyLoading}
|
||||||
/>
|
/>
|
||||||
|
<TelemetryHistoryPane entries={telemetryHistory} />
|
||||||
<RadioSettingsPane
|
<RadioSettingsPane
|
||||||
data={paneData.radioSettings}
|
data={paneData.radioSettings}
|
||||||
state={paneStates.radioSettings}
|
state={paneStates.radioSettings}
|
||||||
|
|||||||
@@ -1,24 +1,39 @@
|
|||||||
import { useState, useCallback, type FormEvent } from 'react';
|
import { useCallback, type FormEvent } from 'react';
|
||||||
import { Input } from './ui/input';
|
import { Input } from './ui/input';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
|
import { Checkbox } from './ui/checkbox';
|
||||||
|
|
||||||
interface RepeaterLoginProps {
|
interface RepeaterLoginProps {
|
||||||
repeaterName: string;
|
repeaterName: string;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
password: string;
|
||||||
|
onPasswordChange: (password: string) => void;
|
||||||
|
rememberPassword: boolean;
|
||||||
|
onRememberPasswordChange: (checked: boolean) => void;
|
||||||
onLogin: (password: string) => Promise<void>;
|
onLogin: (password: string) => Promise<void>;
|
||||||
onLoginAsGuest: () => Promise<void>;
|
onLoginAsGuest: () => Promise<void>;
|
||||||
|
description?: string;
|
||||||
|
passwordPlaceholder?: string;
|
||||||
|
loginLabel?: string;
|
||||||
|
guestLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RepeaterLogin({
|
export function RepeaterLogin({
|
||||||
repeaterName,
|
repeaterName,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
|
password,
|
||||||
|
onPasswordChange,
|
||||||
|
rememberPassword,
|
||||||
|
onRememberPasswordChange,
|
||||||
onLogin,
|
onLogin,
|
||||||
onLoginAsGuest,
|
onLoginAsGuest,
|
||||||
|
description = 'Log in to access repeater dashboard',
|
||||||
|
passwordPlaceholder = 'Repeater password...',
|
||||||
|
loginLabel = 'Login with Password',
|
||||||
|
guestLabel = 'Login as Guest / ACLs',
|
||||||
}: RepeaterLoginProps) {
|
}: RepeaterLoginProps) {
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
async (e: FormEvent) => {
|
async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -33,7 +48,7 @@ export function RepeaterLogin({
|
|||||||
<div className="w-full max-w-sm space-y-6">
|
<div className="w-full max-w-sm space-y-6">
|
||||||
<div className="text-center space-y-1">
|
<div className="text-center space-y-1">
|
||||||
<h2 className="text-lg font-semibold">{repeaterName}</h2>
|
<h2 className="text-lg font-semibold">{repeaterName}</h2>
|
||||||
<p className="text-sm text-muted-foreground">Log in to access repeater dashboard</p>
|
<p className="text-sm text-muted-foreground">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4" autoComplete="off">
|
<form onSubmit={handleSubmit} className="space-y-4" autoComplete="off">
|
||||||
@@ -45,13 +60,34 @@ export function RepeaterLogin({
|
|||||||
data-1p-ignore="true"
|
data-1p-ignore="true"
|
||||||
data-bwignore="true"
|
data-bwignore="true"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => onPasswordChange(e.target.value)}
|
||||||
placeholder="Repeater password..."
|
placeholder={passwordPlaceholder}
|
||||||
aria-label="Repeater password"
|
aria-label="Repeater password"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<label
|
||||||
|
htmlFor="remember-server-password"
|
||||||
|
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id="remember-server-password"
|
||||||
|
checked={rememberPassword}
|
||||||
|
disabled={loading}
|
||||||
|
onCheckedChange={(checked) => onRememberPasswordChange(checked === true)}
|
||||||
|
/>
|
||||||
|
<span>Remember password</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{rememberPassword && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Passwords are stored unencrypted in local browser storage for this domain. It is
|
||||||
|
highly recommended to login via ACLs after your first successful login; saving the
|
||||||
|
password is not recommended.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-sm text-destructive text-center" role="alert">
|
<p className="text-sm text-destructive text-center" role="alert">
|
||||||
{error}
|
{error}
|
||||||
@@ -60,7 +96,7 @@ export function RepeaterLogin({
|
|||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Button type="submit" disabled={loading} className="w-full">
|
<Button type="submit" disabled={loading} className="w-full">
|
||||||
{loading ? 'Logging in...' : 'Login with Password'}
|
{loading ? 'Logging in...' : loginLabel}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -69,7 +105,7 @@ export function RepeaterLogin({
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={onLoginAsGuest}
|
onClick={onLoginAsGuest}
|
||||||
>
|
>
|
||||||
Login as Guest / ACLs
|
{guestLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -0,0 +1,357 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { api } from '../api';
|
||||||
|
import { toast } from './ui/sonner';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
|
||||||
|
import type {
|
||||||
|
Contact,
|
||||||
|
PaneState,
|
||||||
|
RepeaterAclResponse,
|
||||||
|
RepeaterLppTelemetryResponse,
|
||||||
|
RepeaterStatusResponse,
|
||||||
|
} from '../types';
|
||||||
|
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
|
||||||
|
import { AclPane } from './repeater/RepeaterAclPane';
|
||||||
|
import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane';
|
||||||
|
import { ConsolePane } from './repeater/RepeaterConsolePane';
|
||||||
|
import { RepeaterLogin } from './RepeaterLogin';
|
||||||
|
import { ServerLoginStatusBanner } from './ServerLoginStatusBanner';
|
||||||
|
import { useRememberedServerPassword } from '../hooks/useRememberedServerPassword';
|
||||||
|
import {
|
||||||
|
buildServerLoginAttemptFromError,
|
||||||
|
buildServerLoginAttemptFromResponse,
|
||||||
|
type ServerLoginAttemptState,
|
||||||
|
} from '../utils/serverLoginState';
|
||||||
|
|
||||||
|
interface RoomServerPanelProps {
|
||||||
|
contact: Contact;
|
||||||
|
onAuthenticatedChange?: (authenticated: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoomPaneKey = 'status' | 'acl' | 'lppTelemetry';
|
||||||
|
|
||||||
|
type RoomPaneData = {
|
||||||
|
status: RepeaterStatusResponse | null;
|
||||||
|
acl: RepeaterAclResponse | null;
|
||||||
|
lppTelemetry: RepeaterLppTelemetryResponse | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RoomPaneStates = Record<RoomPaneKey, PaneState>;
|
||||||
|
|
||||||
|
type ConsoleEntry = {
|
||||||
|
command: string;
|
||||||
|
response: string;
|
||||||
|
timestamp: number;
|
||||||
|
outgoing: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const INITIAL_PANE_STATE: PaneState = {
|
||||||
|
loading: false,
|
||||||
|
attempt: 0,
|
||||||
|
error: null,
|
||||||
|
fetched_at: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function createInitialPaneStates(): RoomPaneStates {
|
||||||
|
return {
|
||||||
|
status: { ...INITIAL_PANE_STATE },
|
||||||
|
acl: { ...INITIAL_PANE_STATE },
|
||||||
|
lppTelemetry: { ...INITIAL_PANE_STATE },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPanelProps) {
|
||||||
|
const { password, setPassword, rememberPassword, setRememberPassword, persistAfterLogin } =
|
||||||
|
useRememberedServerPassword('room', contact.public_key);
|
||||||
|
const [loginLoading, setLoginLoading] = useState(false);
|
||||||
|
const [loginError, setLoginError] = useState<string | null>(null);
|
||||||
|
const [authenticated, setAuthenticated] = useState(false);
|
||||||
|
const [lastLoginAttempt, setLastLoginAttempt] = useState<ServerLoginAttemptState | null>(null);
|
||||||
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||||
|
const [paneData, setPaneData] = useState<RoomPaneData>({
|
||||||
|
status: null,
|
||||||
|
acl: null,
|
||||||
|
lppTelemetry: null,
|
||||||
|
});
|
||||||
|
const [paneStates, setPaneStates] = useState<RoomPaneStates>(createInitialPaneStates);
|
||||||
|
const [consoleHistory, setConsoleHistory] = useState<ConsoleEntry[]>([]);
|
||||||
|
const [consoleLoading, setConsoleLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoginLoading(false);
|
||||||
|
setLoginError(null);
|
||||||
|
setAuthenticated(false);
|
||||||
|
setLastLoginAttempt(null);
|
||||||
|
setAdvancedOpen(false);
|
||||||
|
setPaneData({
|
||||||
|
status: null,
|
||||||
|
acl: null,
|
||||||
|
lppTelemetry: null,
|
||||||
|
});
|
||||||
|
setPaneStates(createInitialPaneStates());
|
||||||
|
setConsoleHistory([]);
|
||||||
|
setConsoleLoading(false);
|
||||||
|
}, [contact.public_key]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onAuthenticatedChange?.(authenticated);
|
||||||
|
}, [authenticated, onAuthenticatedChange]);
|
||||||
|
|
||||||
|
const refreshPane = useCallback(
|
||||||
|
async <K extends RoomPaneKey>(pane: K, loader: () => Promise<RoomPaneData[K]>) => {
|
||||||
|
setPaneStates((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[pane]: {
|
||||||
|
...prev[pane],
|
||||||
|
loading: true,
|
||||||
|
attempt: prev[pane].attempt + 1,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await loader();
|
||||||
|
setPaneData((prev) => ({ ...prev, [pane]: data }));
|
||||||
|
setPaneStates((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[pane]: {
|
||||||
|
loading: false,
|
||||||
|
attempt: prev[pane].attempt,
|
||||||
|
error: null,
|
||||||
|
fetched_at: Date.now(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
setPaneStates((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[pane]: {
|
||||||
|
...prev[pane],
|
||||||
|
loading: false,
|
||||||
|
error: err instanceof Error ? err.message : 'Unknown error',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const performLogin = useCallback(
|
||||||
|
async (nextPassword: string, method: 'password' | 'blank') => {
|
||||||
|
if (loginLoading) return;
|
||||||
|
|
||||||
|
setLoginLoading(true);
|
||||||
|
setLoginError(null);
|
||||||
|
try {
|
||||||
|
const result = await api.roomLogin(contact.public_key, nextPassword);
|
||||||
|
setLastLoginAttempt(buildServerLoginAttemptFromResponse(method, result, 'room server'));
|
||||||
|
setAuthenticated(true);
|
||||||
|
if (result.authenticated) {
|
||||||
|
toast.success('Login confirmed by the room server.');
|
||||||
|
} else {
|
||||||
|
toast.warning("Couldn't confirm room login", {
|
||||||
|
description:
|
||||||
|
result.message ??
|
||||||
|
'No confirmation came back from the room server. You can still open tools and try again.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
setLastLoginAttempt(buildServerLoginAttemptFromError(method, message, 'room server'));
|
||||||
|
setAuthenticated(true);
|
||||||
|
setLoginError(message);
|
||||||
|
toast.error('Room login request failed', {
|
||||||
|
description: `${message}. You can still open tools and retry the login from here.`,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoginLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[contact.public_key, loginLoading]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleLogin = useCallback(
|
||||||
|
async (nextPassword: string) => {
|
||||||
|
await performLogin(nextPassword, 'password');
|
||||||
|
persistAfterLogin(nextPassword);
|
||||||
|
},
|
||||||
|
[performLogin, persistAfterLogin]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleLoginAsGuest = useCallback(async () => {
|
||||||
|
await performLogin('', 'blank');
|
||||||
|
persistAfterLogin('');
|
||||||
|
}, [performLogin, persistAfterLogin]);
|
||||||
|
|
||||||
|
const handleConsoleCommand = useCallback(
|
||||||
|
async (command: string) => {
|
||||||
|
setConsoleLoading(true);
|
||||||
|
const timestamp = Date.now();
|
||||||
|
setConsoleHistory((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ command, response: command, timestamp, outgoing: true },
|
||||||
|
]);
|
||||||
|
try {
|
||||||
|
const response = await api.sendRepeaterCommand(contact.public_key, command);
|
||||||
|
setConsoleHistory((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
command,
|
||||||
|
response: response.response,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
outgoing: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
setConsoleHistory((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
command,
|
||||||
|
response: `(error) ${message}`,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
outgoing: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
setConsoleLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[contact.public_key]
|
||||||
|
);
|
||||||
|
|
||||||
|
const panelTitle = useMemo(() => contact.name || contact.public_key.slice(0, 12), [contact]);
|
||||||
|
const showLoginFailureState =
|
||||||
|
lastLoginAttempt !== null && lastLoginAttempt.outcome !== 'confirmed';
|
||||||
|
|
||||||
|
if (!authenticated) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
<div className="mx-auto flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<div className="rounded-md border border-warning/30 bg-warning/10 px-4 py-3 text-sm text-warning">
|
||||||
|
Room server access is experimental and in public alpha. Please report any issues on{' '}
|
||||||
|
<a
|
||||||
|
href="https://github.com/jkingsman/Remote-Terminal-for-MeshCore/issues"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="font-medium underline underline-offset-2 hover:text-warning/80"
|
||||||
|
>
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
<RepeaterLogin
|
||||||
|
repeaterName={panelTitle}
|
||||||
|
loading={loginLoading}
|
||||||
|
error={loginError}
|
||||||
|
password={password}
|
||||||
|
onPasswordChange={setPassword}
|
||||||
|
rememberPassword={rememberPassword}
|
||||||
|
onRememberPasswordChange={setRememberPassword}
|
||||||
|
onLogin={handleLogin}
|
||||||
|
onLoginAsGuest={handleLoginAsGuest}
|
||||||
|
description="Log in with the room password or use ACL/guest access to enter this room server"
|
||||||
|
passwordPlaceholder="Room server password..."
|
||||||
|
guestLabel="Login with Existing Access / Guest"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="border-b border-border bg-muted/20 px-4 py-3">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{showLoginFailureState ? (
|
||||||
|
<ServerLoginStatusBanner
|
||||||
|
attempt={lastLoginAttempt}
|
||||||
|
loading={loginLoading}
|
||||||
|
canRetryPassword={password.trim().length > 0}
|
||||||
|
onRetryPassword={() => handleLogin(password)}
|
||||||
|
onRetryBlank={handleLoginAsGuest}
|
||||||
|
blankRetryLabel="Retry Existing-Access Login"
|
||||||
|
showRetryActions={false}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
{showLoginFailureState ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void handleLogin(password)}
|
||||||
|
disabled={loginLoading || password.trim().length === 0}
|
||||||
|
>
|
||||||
|
Retry Password Login
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleLoginAsGuest}
|
||||||
|
disabled={loginLoading}
|
||||||
|
>
|
||||||
|
Retry Existing-Access Login
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setAdvancedOpen((prev) => !prev)}
|
||||||
|
>
|
||||||
|
{advancedOpen ? 'Hide Tools' : 'Show Tools'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Sheet open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||||
|
<SheetContent side="right" className="w-full sm:max-w-4xl p-0 flex flex-col">
|
||||||
|
<SheetHeader className="sr-only">
|
||||||
|
<SheetTitle>Room Server Tools</SheetTitle>
|
||||||
|
<SheetDescription>
|
||||||
|
Room server telemetry, ACL tools, sensor data, and CLI console
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className="border-b border-border px-4 py-3 pr-14">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 className="truncate text-base font-semibold">Room Server Tools</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">{panelTitle}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
<div className="grid gap-3 xl:grid-cols-2">
|
||||||
|
<TelemetryPane
|
||||||
|
data={paneData.status}
|
||||||
|
state={paneStates.status}
|
||||||
|
onRefresh={() => refreshPane('status', () => api.roomStatus(contact.public_key))}
|
||||||
|
/>
|
||||||
|
<AclPane
|
||||||
|
data={paneData.acl}
|
||||||
|
state={paneStates.acl}
|
||||||
|
onRefresh={() => refreshPane('acl', () => api.roomAcl(contact.public_key))}
|
||||||
|
/>
|
||||||
|
<LppTelemetryPane
|
||||||
|
data={paneData.lppTelemetry}
|
||||||
|
state={paneStates.lppTelemetry}
|
||||||
|
onRefresh={() =>
|
||||||
|
refreshPane('lppTelemetry', () => api.roomLppTelemetry(contact.public_key))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ConsolePane
|
||||||
|
history={consoleHistory}
|
||||||
|
loading={consoleLoading}
|
||||||
|
onSend={handleConsoleCommand}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -174,7 +174,11 @@ export function SearchView({
|
|||||||
api
|
api
|
||||||
.getMessages({ q: debouncedQuery, limit: SEARCH_PAGE_SIZE, offset }, controller.signal)
|
.getMessages({ q: debouncedQuery, limit: SEARCH_PAGE_SIZE, offset }, controller.signal)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
setResults((prev) => [...prev, ...(data as SearchResult[])]);
|
setResults((prev) => {
|
||||||
|
const existingIds = new Set(prev.map((r) => r.id));
|
||||||
|
const unique = (data as SearchResult[]).filter((r) => !existingIds.has(r.id));
|
||||||
|
return [...prev, ...unique];
|
||||||
|
});
|
||||||
setHasMore(data.length >= SEARCH_PAGE_SIZE);
|
setHasMore(data.length >= SEARCH_PAGE_SIZE);
|
||||||
setOffset((prev) => prev + data.length);
|
setOffset((prev) => prev + data.length);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
|
import { api } from '../api';
|
||||||
|
import type { HealthStatus } from '../types';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Checkbox } from './ui/checkbox';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog';
|
||||||
|
import { toast } from './ui/sonner';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'meshcore_security_warning_acknowledged';
|
||||||
|
|
||||||
|
function readAcknowledgedState(): boolean {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(STORAGE_KEY) === 'true';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeAcknowledgedState(): void {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, 'true');
|
||||||
|
} catch {
|
||||||
|
// Best effort only; the warning will continue to show if localStorage is unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SecurityWarningModalProps {
|
||||||
|
health: HealthStatus | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SecurityWarningModal({ health }: SecurityWarningModalProps) {
|
||||||
|
const [acknowledged, setAcknowledged] = useState(readAcknowledgedState);
|
||||||
|
const [confirmedRisk, setConfirmedRisk] = useState(false);
|
||||||
|
const [disablingBots, setDisablingBots] = useState(false);
|
||||||
|
const [botsDisabledLocally, setBotsDisabledLocally] = useState(false);
|
||||||
|
|
||||||
|
const shouldWarn =
|
||||||
|
health !== null &&
|
||||||
|
health.bots_disabled !== true &&
|
||||||
|
health.basic_auth_enabled !== true &&
|
||||||
|
!botsDisabledLocally &&
|
||||||
|
!acknowledged;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldWarn) {
|
||||||
|
setConfirmedRisk(false);
|
||||||
|
}
|
||||||
|
}, [shouldWarn]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (health?.bots_disabled !== true) {
|
||||||
|
setBotsDisabledLocally(false);
|
||||||
|
}
|
||||||
|
}, [health?.bots_disabled, health?.bots_disabled_source]);
|
||||||
|
|
||||||
|
if (!shouldWarn) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open>
|
||||||
|
<DialogContent
|
||||||
|
hideCloseButton
|
||||||
|
className="w-[calc(100vw-1rem)] max-w-[42rem] gap-5 overflow-y-auto px-4 py-5 max-h-[calc(100dvh-2rem)] sm:w-full sm:max-h-[min(85dvh,48rem)] sm:px-6"
|
||||||
|
onEscapeKeyDown={(event) => event.preventDefault()}
|
||||||
|
onInteractOutside={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
|
<DialogHeader className="space-y-0 text-left">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-destructive/30 bg-destructive/10 text-destructive">
|
||||||
|
<AlertTriangle className="h-5 w-5" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<DialogTitle className="leading-tight">
|
||||||
|
Unprotected bot execution is enabled
|
||||||
|
</DialogTitle>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<hr className="border-border" />
|
||||||
|
|
||||||
|
<div className="space-y-3 break-words text-sm leading-6 text-muted-foreground">
|
||||||
|
<DialogDescription>
|
||||||
|
Bots are not disabled, and app-wide Basic Auth is not configured.
|
||||||
|
</DialogDescription>
|
||||||
|
<p>
|
||||||
|
Without one of those protections, or another access-control layer in front of
|
||||||
|
RemoteTerm, anyone on your local network who can reach this app can run Python code on
|
||||||
|
the computer hosting this instance via the bot system.
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold text-foreground">
|
||||||
|
This is only safe on protected or isolated networks with appropriate access control. If
|
||||||
|
your network is untrusted or later compromised, this setup may expose the host system to
|
||||||
|
arbitrary code execution.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
To reduce that risk, run the server with environment variables to either disable bots
|
||||||
|
with{' '}
|
||||||
|
<code className="break-all rounded bg-muted px-1 py-0.5 text-foreground">
|
||||||
|
MESHCORE_DISABLE_BOTS=true
|
||||||
|
</code>{' '}
|
||||||
|
or enable the built-in login with{' '}
|
||||||
|
<code className="break-all rounded bg-muted px-1 py-0.5 text-foreground">
|
||||||
|
MESHCORE_BASIC_AUTH_USERNAME
|
||||||
|
</code>{' '}
|
||||||
|
/{' '}
|
||||||
|
<code className="break-all rounded bg-muted px-1 py-0.5 text-foreground">
|
||||||
|
MESHCORE_BASIC_AUTH_PASSWORD
|
||||||
|
</code>
|
||||||
|
. Another external auth or access-control system is also acceptable.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If you just want a temporary safety measure while you learn the system, you can use the
|
||||||
|
button below to disable bots until the server restarts. That is only a temporary guard;
|
||||||
|
permanent protection through Basic Auth or env-based bot disablement is still
|
||||||
|
encouraged.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="h-auto w-full whitespace-normal py-3 text-center"
|
||||||
|
disabled={disablingBots}
|
||||||
|
onClick={async () => {
|
||||||
|
setDisablingBots(true);
|
||||||
|
try {
|
||||||
|
await api.disableBotsUntilRestart();
|
||||||
|
setBotsDisabledLocally(true);
|
||||||
|
toast.success('Bots disabled until restart');
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Failed to disable bots', {
|
||||||
|
description: err instanceof Error ? err.message : undefined,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setDisablingBots(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{disablingBots ? 'Disabling Bots...' : 'Disable Bots Until Server Restart'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 rounded-md border border-input bg-muted/20 p-4">
|
||||||
|
<label className="flex items-start gap-3">
|
||||||
|
<Checkbox
|
||||||
|
checked={confirmedRisk}
|
||||||
|
onCheckedChange={(checked) => setConfirmedRisk(checked === true)}
|
||||||
|
aria-label="Acknowledge bot security risk"
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span className="text-sm leading-6 text-foreground">
|
||||||
|
I understand that continuing with my existing security setup may put me at risk on
|
||||||
|
untrusted networks or if my home network is compromised.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="h-auto w-full whitespace-normal py-3 text-center"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!confirmedRisk || disablingBots}
|
||||||
|
onClick={() => {
|
||||||
|
writeAcknowledgedState();
|
||||||
|
setAcknowledged(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Do Not Warn Me On This Device Again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { Button } from './ui/button';
|
||||||
|
import type { ServerLoginAttemptState } from '../utils/serverLoginState';
|
||||||
|
import { getServerLoginAttemptTone } from '../utils/serverLoginState';
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
|
|
||||||
|
interface ServerLoginStatusBannerProps {
|
||||||
|
attempt: ServerLoginAttemptState | null;
|
||||||
|
loading: boolean;
|
||||||
|
canRetryPassword: boolean;
|
||||||
|
onRetryPassword: () => Promise<void> | void;
|
||||||
|
onRetryBlank: () => Promise<void> | void;
|
||||||
|
passwordRetryLabel?: string;
|
||||||
|
blankRetryLabel?: string;
|
||||||
|
showRetryActions?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServerLoginStatusBanner({
|
||||||
|
attempt,
|
||||||
|
loading,
|
||||||
|
canRetryPassword,
|
||||||
|
onRetryPassword,
|
||||||
|
onRetryBlank,
|
||||||
|
passwordRetryLabel = 'Retry Password Login',
|
||||||
|
blankRetryLabel = 'Retry Existing-Access Login',
|
||||||
|
showRetryActions = true,
|
||||||
|
}: ServerLoginStatusBannerProps) {
|
||||||
|
if (attempt?.outcome === 'confirmed') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tone = getServerLoginAttemptTone(attempt);
|
||||||
|
const shouldShowActions = showRetryActions;
|
||||||
|
const toneClassName =
|
||||||
|
tone === 'success'
|
||||||
|
? 'border-success/30 bg-success/10 text-success'
|
||||||
|
: tone === 'warning'
|
||||||
|
? 'border-warning/30 bg-warning/10 text-warning'
|
||||||
|
: tone === 'destructive'
|
||||||
|
? 'border-destructive/30 bg-destructive/10 text-destructive'
|
||||||
|
: 'border-border bg-muted/40 text-foreground';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('rounded-md border px-4 py-3', toneClassName)}>
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{attempt?.summary ?? 'No server login attempt has been recorded in this view yet.'}
|
||||||
|
</p>
|
||||||
|
{attempt?.details && <p className="text-xs opacity-90">{attempt.details}</p>}
|
||||||
|
</div>
|
||||||
|
{shouldShowActions ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void onRetryPassword()}
|
||||||
|
disabled={loading || !canRetryPassword}
|
||||||
|
>
|
||||||
|
{passwordRetryLabel}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void onRetryBlank()}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{blankRetryLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,8 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SettingsModalProps = SettingsModalBaseProps &
|
export type SettingsModalProps = SettingsModalBaseProps &
|
||||||
@@ -80,6 +83,8 @@ export function SettingsModal(props: SettingsModalProps) {
|
|||||||
blockedNames,
|
blockedNames,
|
||||||
onToggleBlockedKey,
|
onToggleBlockedKey,
|
||||||
onToggleBlockedName,
|
onToggleBlockedName,
|
||||||
|
contacts,
|
||||||
|
onBulkDeleteContacts,
|
||||||
} = 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;
|
||||||
@@ -147,8 +152,8 @@ export function SettingsModal(props: SettingsModalProps) {
|
|||||||
: 'mx-auto w-full max-w-[800px] space-y-4 border-t border-input p-4';
|
: 'mx-auto w-full max-w-[800px] space-y-4 border-t border-input p-4';
|
||||||
|
|
||||||
const settingsContainerClass = externalDesktopSidebarMode
|
const settingsContainerClass = externalDesktopSidebarMode
|
||||||
? 'w-full h-full overflow-y-auto'
|
? 'w-full h-full min-w-0 overflow-x-hidden overflow-y-auto [contain:layout_paint]'
|
||||||
: 'w-full h-full overflow-y-auto space-y-3';
|
: 'w-full h-full min-w-0 overflow-x-hidden overflow-y-auto space-y-3 [contain:layout_paint]';
|
||||||
|
|
||||||
const sectionButtonClasses =
|
const sectionButtonClasses =
|
||||||
'w-full flex items-center justify-between px-4 py-3 text-left hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset';
|
'w-full flex items-center justify-between px-4 py-3 text-left hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset';
|
||||||
@@ -239,6 +244,8 @@ export function SettingsModal(props: SettingsModalProps) {
|
|||||||
blockedNames={blockedNames}
|
blockedNames={blockedNames}
|
||||||
onToggleBlockedKey={onToggleBlockedKey}
|
onToggleBlockedKey={onToggleBlockedKey}
|
||||||
onToggleBlockedName={onToggleBlockedName}
|
onToggleBlockedName={onToggleBlockedName}
|
||||||
|
contacts={contacts}
|
||||||
|
onBulkDeleteContacts={onBulkDeleteContacts}
|
||||||
className={sectionContentClass}
|
className={sectionContentClass}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
+264
-154
@@ -1,6 +1,8 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Bell,
|
Bell,
|
||||||
|
Cable,
|
||||||
|
ChartNetwork,
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@@ -9,10 +11,10 @@ import {
|
|||||||
Map,
|
Map,
|
||||||
Search as SearchIcon,
|
Search as SearchIcon,
|
||||||
SquarePen,
|
SquarePen,
|
||||||
Waypoints,
|
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
|
CONTACT_TYPE_ROOM,
|
||||||
CONTACT_TYPE_REPEATER,
|
CONTACT_TYPE_REPEATER,
|
||||||
type Contact,
|
type Contact,
|
||||||
type Channel,
|
type Channel,
|
||||||
@@ -57,6 +59,7 @@ type CollapseState = {
|
|||||||
favorites: boolean;
|
favorites: boolean;
|
||||||
channels: boolean;
|
channels: boolean;
|
||||||
contacts: boolean;
|
contacts: boolean;
|
||||||
|
rooms: boolean;
|
||||||
repeaters: boolean;
|
repeaters: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -67,6 +70,7 @@ const DEFAULT_COLLAPSE_STATE: CollapseState = {
|
|||||||
favorites: false,
|
favorites: false,
|
||||||
channels: false,
|
channels: false,
|
||||||
contacts: false,
|
contacts: false,
|
||||||
|
rooms: false,
|
||||||
repeaters: false,
|
repeaters: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -80,6 +84,7 @@ function loadCollapsedState(): CollapseState {
|
|||||||
favorites: parsed.favorites ?? DEFAULT_COLLAPSE_STATE.favorites,
|
favorites: parsed.favorites ?? DEFAULT_COLLAPSE_STATE.favorites,
|
||||||
channels: parsed.channels ?? DEFAULT_COLLAPSE_STATE.channels,
|
channels: parsed.channels ?? DEFAULT_COLLAPSE_STATE.channels,
|
||||||
contacts: parsed.contacts ?? DEFAULT_COLLAPSE_STATE.contacts,
|
contacts: parsed.contacts ?? DEFAULT_COLLAPSE_STATE.contacts,
|
||||||
|
rooms: parsed.rooms ?? DEFAULT_COLLAPSE_STATE.rooms,
|
||||||
repeaters: parsed.repeaters ?? DEFAULT_COLLAPSE_STATE.repeaters,
|
repeaters: parsed.repeaters ?? DEFAULT_COLLAPSE_STATE.repeaters,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
@@ -92,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 */
|
||||||
@@ -105,6 +110,8 @@ interface SidebarProps {
|
|||||||
/** Legacy global sort order, used only to seed per-section local preferences. */
|
/** Legacy global sort order, used only to seed per-section local preferences. */
|
||||||
legacySortOrder?: SortOrder;
|
legacySortOrder?: SortOrder;
|
||||||
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
|
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
|
||||||
|
blockedKeys?: string[];
|
||||||
|
blockedNames?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type InitialSectionSortState = {
|
type InitialSectionSortState = {
|
||||||
@@ -148,7 +155,16 @@ export function Sidebar({
|
|||||||
favorites,
|
favorites,
|
||||||
legacySortOrder,
|
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 initialSectionSortState = useMemo(loadInitialSectionSortOrders, []);
|
||||||
const [sectionSortOrders, setSectionSortOrders] = useState(initialSectionSortState.orders);
|
const [sectionSortOrders, setSectionSortOrders] = useState(initialSectionSortState.orders);
|
||||||
@@ -157,6 +173,7 @@ export function Sidebar({
|
|||||||
const [favoritesCollapsed, setFavoritesCollapsed] = useState(initialCollapsedState.favorites);
|
const [favoritesCollapsed, setFavoritesCollapsed] = useState(initialCollapsedState.favorites);
|
||||||
const [channelsCollapsed, setChannelsCollapsed] = useState(initialCollapsedState.channels);
|
const [channelsCollapsed, setChannelsCollapsed] = useState(initialCollapsedState.channels);
|
||||||
const [contactsCollapsed, setContactsCollapsed] = useState(initialCollapsedState.contacts);
|
const [contactsCollapsed, setContactsCollapsed] = useState(initialCollapsedState.contacts);
|
||||||
|
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);
|
const sectionSortSourceRef = useRef(initialSectionSortState.source);
|
||||||
@@ -192,7 +209,7 @@ export function Sidebar({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isActive = (
|
const isActive = (
|
||||||
type: 'contact' | 'channel' | 'raw' | 'map' | 'visualizer' | 'search',
|
type: 'contact' | 'channel' | 'raw' | 'map' | 'visualizer' | 'search' | 'trace',
|
||||||
id: string
|
id: string
|
||||||
) => activeConversation?.type === type && activeConversation?.id === id;
|
) => activeConversation?.type === type && activeConversation?.id === id;
|
||||||
|
|
||||||
@@ -314,16 +331,61 @@ export function Sidebar({
|
|||||||
[getContactHeardTime]
|
[getContactHeardTime]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const getFavoriteItemName = useCallback(
|
||||||
|
(item: FavoriteItem) =>
|
||||||
|
item.type === 'channel'
|
||||||
|
? item.channel.name
|
||||||
|
: getContactDisplayName(
|
||||||
|
item.contact.name,
|
||||||
|
item.contact.public_key,
|
||||||
|
item.contact.last_advert
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortFavoriteItemsByOrder = useCallback(
|
||||||
|
(items: FavoriteItem[], order: SortOrder) =>
|
||||||
|
[...items].sort((a, b) => {
|
||||||
|
if (order === 'recent') {
|
||||||
|
const timeA =
|
||||||
|
a.type === 'channel'
|
||||||
|
? getLastMessageTime('channel', a.channel.key)
|
||||||
|
: getContactRecentTime(a.contact);
|
||||||
|
const timeB =
|
||||||
|
b.type === 'channel'
|
||||||
|
? getLastMessageTime('channel', b.channel.key)
|
||||||
|
: getContactRecentTime(b.contact);
|
||||||
|
if (timeA && timeB) return timeB - timeA;
|
||||||
|
if (timeA && !timeB) return -1;
|
||||||
|
if (!timeA && timeB) return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getFavoriteItemName(a).localeCompare(getFavoriteItemName(b));
|
||||||
|
}),
|
||||||
|
[getContactRecentTime, getFavoriteItemName, getLastMessageTime]
|
||||||
|
);
|
||||||
|
|
||||||
// Split non-repeater contacts and repeater contacts into separate sorted lists
|
// Split non-repeater contacts and repeater contacts into separate sorted lists
|
||||||
const sortedNonRepeaterContacts = useMemo(
|
const sortedNonRepeaterContacts = useMemo(
|
||||||
() =>
|
() =>
|
||||||
sortContactsByOrder(
|
sortContactsByOrder(
|
||||||
uniqueContacts.filter((c) => c.type !== CONTACT_TYPE_REPEATER),
|
uniqueContacts.filter(
|
||||||
|
(c) => c.type !== CONTACT_TYPE_REPEATER && c.type !== CONTACT_TYPE_ROOM
|
||||||
|
),
|
||||||
sectionSortOrders.contacts
|
sectionSortOrders.contacts
|
||||||
),
|
),
|
||||||
[uniqueContacts, sectionSortOrders.contacts, sortContactsByOrder]
|
[uniqueContacts, sectionSortOrders.contacts, sortContactsByOrder]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const sortedRooms = useMemo(
|
||||||
|
() =>
|
||||||
|
sortContactsByOrder(
|
||||||
|
uniqueContacts.filter((c) => c.type === CONTACT_TYPE_ROOM),
|
||||||
|
sectionSortOrders.rooms
|
||||||
|
),
|
||||||
|
[uniqueContacts, sectionSortOrders.rooms, sortContactsByOrder]
|
||||||
|
);
|
||||||
|
|
||||||
const sortedRepeaters = useMemo(
|
const sortedRepeaters = useMemo(
|
||||||
() =>
|
() =>
|
||||||
sortRepeatersByOrder(
|
sortRepeatersByOrder(
|
||||||
@@ -347,27 +409,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 filteredRepeaters = useMemo(
|
const filteredRooms = useMemo(() => {
|
||||||
() =>
|
const visible = sortedRooms.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,
|
}, [sortedRooms, query, isContactBlocked]);
|
||||||
[sortedRepeaters, query]
|
|
||||||
);
|
const filteredRepeaters = useMemo(() => {
|
||||||
|
const visible = sortedRepeaters.filter((c) => !isContactBlocked(c));
|
||||||
|
return query
|
||||||
|
? visible.filter(
|
||||||
|
(c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
||||||
|
)
|
||||||
|
: visible;
|
||||||
|
}, [sortedRepeaters, query, isContactBlocked]);
|
||||||
|
|
||||||
// Expand sections while searching; restore prior collapse state when search ends.
|
// Expand sections while searching; restore prior collapse state when search ends.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -378,6 +445,7 @@ export function Sidebar({
|
|||||||
favorites: favoritesCollapsed,
|
favorites: favoritesCollapsed,
|
||||||
channels: channelsCollapsed,
|
channels: channelsCollapsed,
|
||||||
contacts: contactsCollapsed,
|
contacts: contactsCollapsed,
|
||||||
|
rooms: roomsCollapsed,
|
||||||
repeaters: repeatersCollapsed,
|
repeaters: repeatersCollapsed,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -387,12 +455,14 @@ export function Sidebar({
|
|||||||
favoritesCollapsed ||
|
favoritesCollapsed ||
|
||||||
channelsCollapsed ||
|
channelsCollapsed ||
|
||||||
contactsCollapsed ||
|
contactsCollapsed ||
|
||||||
|
roomsCollapsed ||
|
||||||
repeatersCollapsed
|
repeatersCollapsed
|
||||||
) {
|
) {
|
||||||
setToolsCollapsed(false);
|
setToolsCollapsed(false);
|
||||||
setFavoritesCollapsed(false);
|
setFavoritesCollapsed(false);
|
||||||
setChannelsCollapsed(false);
|
setChannelsCollapsed(false);
|
||||||
setContactsCollapsed(false);
|
setContactsCollapsed(false);
|
||||||
|
setRoomsCollapsed(false);
|
||||||
setRepeatersCollapsed(false);
|
setRepeatersCollapsed(false);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -405,6 +475,7 @@ export function Sidebar({
|
|||||||
setFavoritesCollapsed(prev.favorites);
|
setFavoritesCollapsed(prev.favorites);
|
||||||
setChannelsCollapsed(prev.channels);
|
setChannelsCollapsed(prev.channels);
|
||||||
setContactsCollapsed(prev.contacts);
|
setContactsCollapsed(prev.contacts);
|
||||||
|
setRoomsCollapsed(prev.rooms);
|
||||||
setRepeatersCollapsed(prev.repeaters);
|
setRepeatersCollapsed(prev.repeaters);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
@@ -413,6 +484,7 @@ export function Sidebar({
|
|||||||
favoritesCollapsed,
|
favoritesCollapsed,
|
||||||
channelsCollapsed,
|
channelsCollapsed,
|
||||||
contactsCollapsed,
|
contactsCollapsed,
|
||||||
|
roomsCollapsed,
|
||||||
repeatersCollapsed,
|
repeatersCollapsed,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -424,6 +496,7 @@ export function Sidebar({
|
|||||||
favorites: favoritesCollapsed,
|
favorites: favoritesCollapsed,
|
||||||
channels: channelsCollapsed,
|
channels: channelsCollapsed,
|
||||||
contacts: contactsCollapsed,
|
contacts: contactsCollapsed,
|
||||||
|
rooms: roomsCollapsed,
|
||||||
repeaters: repeatersCollapsed,
|
repeaters: repeatersCollapsed,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -438,62 +511,56 @@ export function Sidebar({
|
|||||||
favoritesCollapsed,
|
favoritesCollapsed,
|
||||||
channelsCollapsed,
|
channelsCollapsed,
|
||||||
contactsCollapsed,
|
contactsCollapsed,
|
||||||
|
roomsCollapsed,
|
||||||
repeatersCollapsed,
|
repeatersCollapsed,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Separate favorites from regular items, and build combined favorites list
|
// Separate favorites from regular items, and build combined favorites list
|
||||||
const { favoriteItems, nonFavoriteChannels, nonFavoriteContacts, nonFavoriteRepeaters } =
|
const {
|
||||||
useMemo(() => {
|
favoriteItems,
|
||||||
const favChannels = filteredChannels.filter((c) => isFavorite(favorites, 'channel', c.key));
|
nonFavoriteChannels,
|
||||||
const favContacts = [...filteredNonRepeaterContacts, ...filteredRepeaters].filter((c) =>
|
nonFavoriteContacts,
|
||||||
isFavorite(favorites, 'contact', c.public_key)
|
nonFavoriteRooms,
|
||||||
);
|
nonFavoriteRepeaters,
|
||||||
const nonFavChannels = filteredChannels.filter(
|
} = useMemo(() => {
|
||||||
(c) => !isFavorite(favorites, 'channel', c.key)
|
const favChannels = filteredChannels.filter((c) => isFavorite(favorites, 'channel', c.key));
|
||||||
);
|
const favContacts = [
|
||||||
const nonFavContacts = filteredNonRepeaterContacts.filter(
|
...filteredNonRepeaterContacts,
|
||||||
(c) => !isFavorite(favorites, 'contact', c.public_key)
|
...filteredRooms,
|
||||||
);
|
...filteredRepeaters,
|
||||||
const nonFavRepeaters = filteredRepeaters.filter(
|
].filter((c) => isFavorite(favorites, 'contact', c.public_key));
|
||||||
(c) => !isFavorite(favorites, 'contact', c.public_key)
|
const nonFavChannels = filteredChannels.filter((c) => !isFavorite(favorites, 'channel', c.key));
|
||||||
);
|
const nonFavContacts = filteredNonRepeaterContacts.filter(
|
||||||
|
(c) => !isFavorite(favorites, 'contact', c.public_key)
|
||||||
|
);
|
||||||
|
const nonFavRooms = filteredRooms.filter(
|
||||||
|
(c) => !isFavorite(favorites, 'contact', c.public_key)
|
||||||
|
);
|
||||||
|
const nonFavRepeaters = filteredRepeaters.filter(
|
||||||
|
(c) => !isFavorite(favorites, 'contact', c.public_key)
|
||||||
|
);
|
||||||
|
|
||||||
const items: FavoriteItem[] = [
|
const items: FavoriteItem[] = [
|
||||||
...favChannels.map((channel) => ({ type: 'channel' as const, channel })),
|
...favChannels.map((channel) => ({ type: 'channel' as const, channel })),
|
||||||
...favContacts.map((contact) => ({ type: 'contact' as const, contact })),
|
...favContacts.map((contact) => ({ type: 'contact' as const, contact })),
|
||||||
].sort((a, b) => {
|
];
|
||||||
const timeA =
|
|
||||||
a.type === 'channel'
|
|
||||||
? getLastMessageTime('channel', a.channel.key)
|
|
||||||
: getContactRecentTime(a.contact);
|
|
||||||
const timeB =
|
|
||||||
b.type === 'channel'
|
|
||||||
? getLastMessageTime('channel', b.channel.key)
|
|
||||||
: getContactRecentTime(b.contact);
|
|
||||||
if (timeA && timeB) return timeB - timeA;
|
|
||||||
if (timeA && !timeB) return -1;
|
|
||||||
if (!timeA && timeB) return 1;
|
|
||||||
const nameA =
|
|
||||||
a.type === 'channel' ? a.channel.name : a.contact.name || a.contact.public_key;
|
|
||||||
const nameB =
|
|
||||||
b.type === 'channel' ? b.channel.name : b.contact.name || b.contact.public_key;
|
|
||||||
return nameA.localeCompare(nameB);
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
favoriteItems: items,
|
favoriteItems: sortFavoriteItemsByOrder(items, sectionSortOrders.favorites),
|
||||||
nonFavoriteChannels: nonFavChannels,
|
nonFavoriteChannels: nonFavChannels,
|
||||||
nonFavoriteContacts: nonFavContacts,
|
nonFavoriteContacts: nonFavContacts,
|
||||||
nonFavoriteRepeaters: nonFavRepeaters,
|
nonFavoriteRooms: nonFavRooms,
|
||||||
};
|
nonFavoriteRepeaters: nonFavRepeaters,
|
||||||
}, [
|
};
|
||||||
filteredChannels,
|
}, [
|
||||||
filteredNonRepeaterContacts,
|
filteredChannels,
|
||||||
filteredRepeaters,
|
filteredNonRepeaterContacts,
|
||||||
favorites,
|
filteredRooms,
|
||||||
getContactRecentTime,
|
filteredRepeaters,
|
||||||
getLastMessageTime,
|
favorites,
|
||||||
]);
|
sectionSortOrders.favorites,
|
||||||
|
sortFavoriteItemsByOrder,
|
||||||
|
]);
|
||||||
|
|
||||||
const buildChannelRow = (channel: Channel, keyPrefix: string): ConversationRow => ({
|
const buildChannelRow = (channel: Channel, keyPrefix: string): ConversationRow => ({
|
||||||
key: `${keyPrefix}-${channel.key}`,
|
key: `${keyPrefix}-${channel.key}`,
|
||||||
@@ -517,57 +584,65 @@ export function Sidebar({
|
|||||||
contact,
|
contact,
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderConversationRow = (row: ConversationRow) => (
|
const renderConversationRow = (row: ConversationRow) => {
|
||||||
<div
|
const highlightUnread =
|
||||||
key={row.key}
|
row.isMention ||
|
||||||
className={cn(
|
(row.type === 'contact' &&
|
||||||
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
row.contact?.type !== CONTACT_TYPE_REPEATER &&
|
||||||
isActive(row.type, row.id) && 'bg-accent border-l-primary',
|
row.unreadCount > 0);
|
||||||
row.unreadCount > 0 && '[&_.name]:font-semibold [&_.name]:text-foreground'
|
|
||||||
)}
|
return (
|
||||||
role="button"
|
<div
|
||||||
tabIndex={0}
|
key={row.key}
|
||||||
aria-current={isActive(row.type, row.id) ? 'page' : undefined}
|
className={cn(
|
||||||
onKeyDown={handleKeyboardActivate}
|
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||||
onClick={() =>
|
isActive(row.type, row.id) && 'bg-accent border-l-primary',
|
||||||
handleSelectConversation({
|
row.unreadCount > 0 && '[&_.name]:font-semibold [&_.name]:text-foreground'
|
||||||
type: row.type,
|
|
||||||
id: row.id,
|
|
||||||
name: row.name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{row.type === 'contact' && row.contact && (
|
|
||||||
<ContactAvatar
|
|
||||||
name={row.contact.name}
|
|
||||||
publicKey={row.contact.public_key}
|
|
||||||
size={24}
|
|
||||||
contactType={row.contact.type}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span className="name flex-1 truncate text-[13px]">{row.name}</span>
|
|
||||||
<span className="ml-auto flex items-center gap-1">
|
|
||||||
{row.notificationsEnabled && (
|
|
||||||
<span aria-label="Notifications enabled" title="Notifications enabled">
|
|
||||||
<Bell className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
{row.unreadCount > 0 && (
|
role="button"
|
||||||
<span
|
tabIndex={0}
|
||||||
className={cn(
|
aria-current={isActive(row.type, row.id) ? 'page' : undefined}
|
||||||
'text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
|
onKeyDown={handleKeyboardActivate}
|
||||||
row.isMention
|
onClick={() =>
|
||||||
? 'bg-badge-mention text-badge-mention-foreground'
|
handleSelectConversation({
|
||||||
: 'bg-badge-unread/90 text-badge-unread-foreground'
|
type: row.type,
|
||||||
)}
|
id: row.id,
|
||||||
aria-label={`${row.unreadCount} unread message${row.unreadCount !== 1 ? 's' : ''}`}
|
name: row.name,
|
||||||
>
|
})
|
||||||
{row.unreadCount}
|
}
|
||||||
</span>
|
>
|
||||||
|
{row.type === 'contact' && row.contact && (
|
||||||
|
<ContactAvatar
|
||||||
|
name={row.contact.name}
|
||||||
|
publicKey={row.contact.public_key}
|
||||||
|
size={24}
|
||||||
|
contactType={row.contact.type}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</span>
|
<span className="name flex-1 truncate text-[13px]">{row.name}</span>
|
||||||
</div>
|
<span className="ml-auto flex items-center gap-1">
|
||||||
);
|
{row.notificationsEnabled && (
|
||||||
|
<span aria-label="Notifications enabled" title="Notifications enabled">
|
||||||
|
<Bell className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{row.unreadCount > 0 && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
|
||||||
|
highlightUnread
|
||||||
|
? 'bg-badge-mention text-badge-mention-foreground'
|
||||||
|
: 'bg-badge-unread/90 text-badge-unread-foreground'
|
||||||
|
)}
|
||||||
|
aria-label={`${row.unreadCount} unread message${row.unreadCount !== 1 ? 's' : ''}`}
|
||||||
|
>
|
||||||
|
{row.unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const renderSidebarActionRow = ({
|
const renderSidebarActionRow = ({
|
||||||
key,
|
key,
|
||||||
@@ -584,8 +659,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-[13px] 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"
|
||||||
@@ -594,10 +670,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>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -613,11 +689,13 @@ export function Sidebar({
|
|||||||
);
|
);
|
||||||
const channelRows = nonFavoriteChannels.map((channel) => buildChannelRow(channel, 'chan'));
|
const channelRows = nonFavoriteChannels.map((channel) => buildChannelRow(channel, 'chan'));
|
||||||
const contactRows = nonFavoriteContacts.map((contact) => buildContactRow(contact, 'contact'));
|
const contactRows = nonFavoriteContacts.map((contact) => buildContactRow(contact, 'contact'));
|
||||||
|
const roomRows = nonFavoriteRooms.map((contact) => buildContactRow(contact, 'room'));
|
||||||
const repeaterRows = nonFavoriteRepeaters.map((contact) => buildContactRow(contact, 'repeater'));
|
const repeaterRows = nonFavoriteRepeaters.map((contact) => buildContactRow(contact, 'repeater'));
|
||||||
|
|
||||||
const favoritesUnreadCount = getSectionUnreadCount(favoriteRows);
|
const favoritesUnreadCount = getSectionUnreadCount(favoriteRows);
|
||||||
const channelsUnreadCount = getSectionUnreadCount(channelRows);
|
const channelsUnreadCount = getSectionUnreadCount(channelRows);
|
||||||
const contactsUnreadCount = getSectionUnreadCount(contactRows);
|
const contactsUnreadCount = getSectionUnreadCount(contactRows);
|
||||||
|
const roomsUnreadCount = getSectionUnreadCount(roomRows);
|
||||||
const repeatersUnreadCount = getSectionUnreadCount(repeaterRows);
|
const repeatersUnreadCount = getSectionUnreadCount(repeaterRows);
|
||||||
const favoritesHasMention = sectionHasMention(favoriteRows);
|
const favoritesHasMention = sectionHasMention(favoriteRows);
|
||||||
const channelsHasMention = sectionHasMention(channelRows);
|
const channelsHasMention = sectionHasMention(channelRows);
|
||||||
@@ -650,7 +728,7 @@ export function Sidebar({
|
|||||||
renderSidebarActionRow({
|
renderSidebarActionRow({
|
||||||
key: 'tool-visualizer',
|
key: 'tool-visualizer',
|
||||||
active: isActive('visualizer', 'visualizer'),
|
active: isActive('visualizer', 'visualizer'),
|
||||||
icon: <Waypoints className="h-4 w-4" />,
|
icon: <ChartNetwork className="h-4 w-4" />,
|
||||||
label: 'Mesh Visualizer',
|
label: 'Mesh Visualizer',
|
||||||
onClick: () =>
|
onClick: () =>
|
||||||
handleSelectConversation({
|
handleSelectConversation({
|
||||||
@@ -659,6 +737,18 @@ export function Sidebar({
|
|||||||
name: 'Mesh Visualizer',
|
name: 'Mesh Visualizer',
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
renderSidebarActionRow({
|
||||||
|
key: 'tool-trace',
|
||||||
|
active: isActive('trace', 'trace'),
|
||||||
|
icon: <Cable className="h-4 w-4" />,
|
||||||
|
label: 'Trace',
|
||||||
|
onClick: () =>
|
||||||
|
handleSelectConversation({
|
||||||
|
type: 'trace',
|
||||||
|
id: 'trace',
|
||||||
|
name: 'Trace',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
renderSidebarActionRow({
|
renderSidebarActionRow({
|
||||||
key: 'tool-search',
|
key: 'tool-search',
|
||||||
active: isActive('search', 'search'),
|
active: isActive('search', 'search'),
|
||||||
@@ -677,7 +767,7 @@ export function Sidebar({
|
|||||||
icon: <LockOpen className="h-4 w-4" />,
|
icon: <LockOpen className="h-4 w-4" />,
|
||||||
label: (
|
label: (
|
||||||
<>
|
<>
|
||||||
{showCracker ? 'Hide' : 'Show'} Room Finder
|
{showCracker ? 'Hide' : 'Show'} Channel Finder
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'ml-1 text-[11px]',
|
'ml-1 text-[11px]',
|
||||||
@@ -769,41 +859,45 @@ export function Sidebar({
|
|||||||
aria-label="Conversations"
|
aria-label="Conversations"
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border">
|
<div className="px-3 py-2 border-b border-border">
|
||||||
<div className="relative min-w-0 flex-1">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search rooms/contacts..."
|
|
||||||
aria-label="Search conversations"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className={cn('h-7 text-[13px] bg-background/50', searchQuery ? 'pr-8' : 'pr-3')}
|
|
||||||
/>
|
|
||||||
{searchQuery && (
|
|
||||||
<button
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded"
|
|
||||||
onClick={() => setSearchQuery('')}
|
|
||||||
title="Clear search"
|
|
||||||
aria-label="Clear search"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onNewMessage}
|
onClick={onNewMessage}
|
||||||
title="New Message"
|
title="Add channel or contact"
|
||||||
aria-label="New message"
|
aria-label="Add channel or contact"
|
||||||
className="h-7 w-7 shrink-0 p-0 text-muted-foreground hover:text-foreground transition-colors"
|
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"
|
||||||
>
|
>
|
||||||
<SquarePen className="h-4 w-4" />
|
<SquarePen className="h-4 w-4" />
|
||||||
|
<span>Add Channel/Contact</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* List */}
|
{/* List */}
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto [contain:layout_paint]">
|
<div className="flex-1 min-h-0 overflow-y-auto [contain:layout_paint]">
|
||||||
|
<div className="px-3 py-2 border-b border-border/60">
|
||||||
|
<div className="relative min-w-0">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search channels/contacts..."
|
||||||
|
aria-label="Search conversations"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className={cn('h-7 text-[13px] bg-background/50', searchQuery ? 'pr-8' : 'pr-3')}
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded"
|
||||||
|
onClick={() => setSearchQuery('')}
|
||||||
|
title="Clear search"
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Tools */}
|
{/* Tools */}
|
||||||
{toolRows.length > 0 && (
|
{toolRows.length > 0 && (
|
||||||
<>
|
<>
|
||||||
@@ -833,7 +927,7 @@ export function Sidebar({
|
|||||||
'Favorites',
|
'Favorites',
|
||||||
favoritesCollapsed,
|
favoritesCollapsed,
|
||||||
() => setFavoritesCollapsed((prev) => !prev),
|
() => setFavoritesCollapsed((prev) => !prev),
|
||||||
null,
|
'favorites',
|
||||||
favoritesUnreadCount,
|
favoritesUnreadCount,
|
||||||
favoritesHasMention
|
favoritesHasMention
|
||||||
)}
|
)}
|
||||||
@@ -889,8 +983,24 @@ export function Sidebar({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Room Servers */}
|
||||||
|
{nonFavoriteRooms.length > 0 && (
|
||||||
|
<>
|
||||||
|
{renderSectionHeader(
|
||||||
|
'Room Servers',
|
||||||
|
roomsCollapsed,
|
||||||
|
() => setRoomsCollapsed((prev) => !prev),
|
||||||
|
'rooms',
|
||||||
|
roomsUnreadCount,
|
||||||
|
roomsUnreadCount > 0
|
||||||
|
)}
|
||||||
|
{(isSearching || !roomsCollapsed) && roomRows.map((row) => renderConversationRow(row))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Empty state */}
|
{/* Empty state */}
|
||||||
{nonFavoriteContacts.length === 0 &&
|
{nonFavoriteContacts.length === 0 &&
|
||||||
|
nonFavoriteRooms.length === 0 &&
|
||||||
nonFavoriteChannels.length === 0 &&
|
nonFavoriteChannels.length === 0 &&
|
||||||
nonFavoriteRepeaters.length === 0 &&
|
nonFavoriteRepeaters.length === 0 &&
|
||||||
favoriteItems.length === 0 && (
|
favoriteItems.length === 0 && (
|
||||||
|
|||||||
@@ -0,0 +1,721 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||||
|
import { ArrowDown, ArrowUp, Plus, X } from 'lucide-react';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Contact,
|
||||||
|
RadioConfig,
|
||||||
|
RadioTraceHopRequest,
|
||||||
|
RadioTraceNode,
|
||||||
|
RadioTraceResponse,
|
||||||
|
} from '../types';
|
||||||
|
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||||
|
import { calculateDistance, isValidLocation } from '../utils/pathUtils';
|
||||||
|
import { getContactDisplayName } from '../utils/pubkey';
|
||||||
|
import { handleKeyboardActivate } from '../utils/a11y';
|
||||||
|
import { ContactAvatar } from './ContactAvatar';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from './ui/dialog';
|
||||||
|
import { Input } from './ui/input';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
type TraceSortMode = 'alpha' | 'recent' | 'distance';
|
||||||
|
type CustomHopBytes = 1 | 2 | 4;
|
||||||
|
|
||||||
|
type TraceDraftHop =
|
||||||
|
| { id: string; kind: 'repeater'; publicKey: string }
|
||||||
|
| { id: string; kind: 'custom'; hopHex: string; hopBytes: CustomHopBytes };
|
||||||
|
|
||||||
|
interface TracePaneProps {
|
||||||
|
contacts: Contact[];
|
||||||
|
config: RadioConfig | null;
|
||||||
|
onRunTracePath: (
|
||||||
|
hopHashBytes: CustomHopBytes,
|
||||||
|
hops: RadioTraceHopRequest[]
|
||||||
|
) => Promise<RadioTraceResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHeardTimestamp(contact: Contact): number {
|
||||||
|
return Math.max(contact.last_seen ?? 0, contact.last_advert ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDistanceKm(contact: Contact, config: RadioConfig | null): number | null {
|
||||||
|
if (
|
||||||
|
!config ||
|
||||||
|
!isValidLocation(config.lat, config.lon) ||
|
||||||
|
!isValidLocation(contact.lat, contact.lon)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return calculateDistance(config.lat, config.lon, contact.lat, contact.lon);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getShortKey(publicKey: string | null | undefined): string {
|
||||||
|
if (!publicKey) return 'unknown';
|
||||||
|
return publicKey.slice(0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSNR(snr: number | null | undefined): string {
|
||||||
|
if (typeof snr !== 'number' || Number.isNaN(snr)) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
return `${snr >= 0 ? '+' : ''}${snr.toFixed(1)} dB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveHop(hops: TraceDraftHop[], index: number, direction: -1 | 1): TraceDraftHop[] {
|
||||||
|
const nextIndex = index + direction;
|
||||||
|
if (nextIndex < 0 || nextIndex >= hops.length) {
|
||||||
|
return hops;
|
||||||
|
}
|
||||||
|
const next = [...hops];
|
||||||
|
const [item] = next.splice(index, 1);
|
||||||
|
next.splice(nextIndex, 0, item);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCustomHopHex(value: string): string {
|
||||||
|
return value.replace(/[^a-fA-F0-9]/g, '').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextDraftHopId(prefix: string, currentLength: number): string {
|
||||||
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
|
return `${prefix}-${crypto.randomUUID()}`;
|
||||||
|
}
|
||||||
|
return `${prefix}-${Date.now()}-${currentLength}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TraceNodeRow({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
meta,
|
||||||
|
note,
|
||||||
|
fixed = false,
|
||||||
|
compact = false,
|
||||||
|
actions,
|
||||||
|
snr,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
meta?: string | null;
|
||||||
|
note?: string | null;
|
||||||
|
fixed?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
actions?: ReactNode;
|
||||||
|
snr?: string | null;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center rounded-md border border-border bg-background',
|
||||||
|
compact ? 'gap-2 px-2.5 py-2' : 'gap-3 px-3 py-3'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-9 w-9 items-center justify-center rounded-full border text-[11px] font-semibold uppercase tracking-wide',
|
||||||
|
fixed
|
||||||
|
? 'border-primary/30 bg-primary/10 text-primary'
|
||||||
|
: 'border-border bg-muted text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{fixed ? 'Self' : 'Hop'}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-sm font-medium">{title}</div>
|
||||||
|
<div className="truncate text-xs text-muted-foreground">{subtitle}</div>
|
||||||
|
{meta ? <div className="mt-1 text-[11px] text-muted-foreground">{meta}</div> : null}
|
||||||
|
{note ? <div className="mt-1 text-[11px] text-muted-foreground">{note}</div> : null}
|
||||||
|
</div>
|
||||||
|
{snr ? (
|
||||||
|
<div className="shrink-0 text-right">
|
||||||
|
<div className="text-[11px] text-muted-foreground">SNR</div>
|
||||||
|
<div className="font-mono text-sm">{snr}</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{actions ? <div className="ml-1 flex items-center gap-1">{actions}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps) {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [sortMode, setSortMode] = useState<TraceSortMode>('alpha');
|
||||||
|
const [draftHops, setDraftHops] = useState<TraceDraftHop[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [result, setResult] = useState<RadioTraceResponse | null>(null);
|
||||||
|
const [customDialogOpen, setCustomDialogOpen] = useState(false);
|
||||||
|
const [customHopBytesDraft, setCustomHopBytesDraft] = useState<CustomHopBytes>(1);
|
||||||
|
const [customHopHexDraft, setCustomHopHexDraft] = useState('');
|
||||||
|
const [customHopError, setCustomHopError] = useState<string | null>(null);
|
||||||
|
const activeRunTokenRef = useRef(0);
|
||||||
|
|
||||||
|
const repeaters = useMemo(() => {
|
||||||
|
const deduped = new Map<string, Contact>();
|
||||||
|
for (const contact of contacts) {
|
||||||
|
if (contact.type !== CONTACT_TYPE_REPEATER || contact.public_key.length !== 64) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!deduped.has(contact.public_key)) {
|
||||||
|
deduped.set(contact.public_key, contact);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...deduped.values()];
|
||||||
|
}, [contacts]);
|
||||||
|
|
||||||
|
const repeatersByKey = useMemo(
|
||||||
|
() => new Map(repeaters.map((contact) => [contact.public_key, contact])),
|
||||||
|
[repeaters]
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredRepeaters = useMemo(() => {
|
||||||
|
const query = searchQuery.trim().toLowerCase();
|
||||||
|
const matching = query
|
||||||
|
? repeaters.filter(
|
||||||
|
(contact) =>
|
||||||
|
contact.public_key.toLowerCase().includes(query) ||
|
||||||
|
(contact.name ?? '').toLowerCase().includes(query)
|
||||||
|
)
|
||||||
|
: repeaters;
|
||||||
|
|
||||||
|
return [...matching].sort((left, right) => {
|
||||||
|
if (sortMode === 'recent') {
|
||||||
|
const leftTs = getHeardTimestamp(left);
|
||||||
|
const rightTs = getHeardTimestamp(right);
|
||||||
|
if (leftTs !== rightTs) {
|
||||||
|
return rightTs - leftTs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sortMode === 'distance') {
|
||||||
|
const leftDistance = getDistanceKm(left, config);
|
||||||
|
const rightDistance = getDistanceKm(right, config);
|
||||||
|
if (leftDistance !== null && rightDistance !== null && leftDistance !== rightDistance) {
|
||||||
|
return leftDistance - rightDistance;
|
||||||
|
}
|
||||||
|
if (leftDistance !== null && rightDistance === null) return -1;
|
||||||
|
if (leftDistance === null && rightDistance !== null) return 1;
|
||||||
|
}
|
||||||
|
return getContactDisplayName(left.name, left.public_key, left.last_advert).localeCompare(
|
||||||
|
getContactDisplayName(right.name, right.public_key, right.last_advert)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [config, repeaters, searchQuery, sortMode]);
|
||||||
|
|
||||||
|
const localRadioName = config?.name || 'Local radio';
|
||||||
|
const localRadioKey = config?.public_key ?? null;
|
||||||
|
const canSortByDistance = !!config && isValidLocation(config.lat, config.lon);
|
||||||
|
const customHopBytesLocked = useMemo(
|
||||||
|
() => draftHops.find((hop) => hop.kind === 'custom')?.hopBytes ?? null,
|
||||||
|
[draftHops]
|
||||||
|
);
|
||||||
|
const effectiveHopHashBytes: CustomHopBytes = customHopBytesLocked ?? 4;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!customDialogOpen) return;
|
||||||
|
setCustomHopBytesDraft(customHopBytesLocked ?? 1);
|
||||||
|
setCustomHopHexDraft('');
|
||||||
|
setCustomHopError(null);
|
||||||
|
}, [customDialogOpen, customHopBytesLocked]);
|
||||||
|
|
||||||
|
const clearPendingResult = () => {
|
||||||
|
activeRunTokenRef.current += 1;
|
||||||
|
setLoading(false);
|
||||||
|
if (result) setResult(null);
|
||||||
|
if (error) setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddRepeater = (publicKey: string) => {
|
||||||
|
setDraftHops((current) => [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
id: nextDraftHopId('repeater', current.length),
|
||||||
|
kind: 'repeater',
|
||||||
|
publicKey,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
clearPendingResult();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddCustomHop = () => {
|
||||||
|
const hopBytes = customHopBytesLocked ?? customHopBytesDraft;
|
||||||
|
const hopHex = normalizeCustomHopHex(customHopHexDraft);
|
||||||
|
if (hopHex.length !== hopBytes * 2) {
|
||||||
|
setCustomHopError(`Custom hop must be exactly ${hopBytes * 2} hex characters.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDraftHops((current) => [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
id: nextDraftHopId('custom', current.length),
|
||||||
|
kind: 'custom',
|
||||||
|
hopHex,
|
||||||
|
hopBytes,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
clearPendingResult();
|
||||||
|
setCustomDialogOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveHop = (id: string) => {
|
||||||
|
setDraftHops((current) => current.filter((hop) => hop.id !== id));
|
||||||
|
clearPendingResult();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMoveHop = (index: number, direction: -1 | 1) => {
|
||||||
|
setDraftHops((current) => moveHop(current, index, direction));
|
||||||
|
clearPendingResult();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRunTrace = async () => {
|
||||||
|
if (draftHops.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const runToken = activeRunTokenRef.current + 1;
|
||||||
|
activeRunTokenRef.current = runToken;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setResult(null);
|
||||||
|
try {
|
||||||
|
const traceResult = await onRunTracePath(
|
||||||
|
effectiveHopHashBytes,
|
||||||
|
draftHops.map((hop) =>
|
||||||
|
hop.kind === 'repeater' ? { public_key: hop.publicKey } : { hop_hex: hop.hopHex }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (activeRunTokenRef.current !== runToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setResult(traceResult);
|
||||||
|
} catch (err) {
|
||||||
|
if (activeRunTokenRef.current !== runToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||||
|
} finally {
|
||||||
|
if (activeRunTokenRef.current === runToken) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resultNodes: RadioTraceNode[] = result
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
role: 'local',
|
||||||
|
public_key: localRadioKey,
|
||||||
|
name: localRadioName,
|
||||||
|
observed_hash: null,
|
||||||
|
snr: null,
|
||||||
|
},
|
||||||
|
...result.nodes,
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-col overflow-y-auto lg:overflow-hidden">
|
||||||
|
<div className="shrink-0 border-b border-border px-4 py-3">
|
||||||
|
<h2 className="text-base font-semibold">Trace</h2>
|
||||||
|
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
|
||||||
|
Build a repeater loop and trace it back to the local radio. The selectable hop list only
|
||||||
|
includes known full-key repeaters, but you can also add custom repeater prefixes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-4 p-4 lg:min-h-0 lg:flex-row lg:overflow-hidden">
|
||||||
|
<section className="flex w-full flex-col rounded-lg border border-border bg-card lg:min-h-0 lg:max-w-[24rem]">
|
||||||
|
<div className="shrink-0 border-b border-border p-4">
|
||||||
|
<h3 className="text-sm font-semibold">Repeater Hops</h3>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Search by name or key, then add repeaters in the order you want to traverse them.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="mt-3"
|
||||||
|
onClick={() => setCustomDialogOpen(true)}
|
||||||
|
>
|
||||||
|
Custom path
|
||||||
|
</Button>
|
||||||
|
<Input
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(event) => setSearchQuery(event.target.value)}
|
||||||
|
placeholder="Search name or public key"
|
||||||
|
aria-label="Search repeaters"
|
||||||
|
className="mt-3"
|
||||||
|
/>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['alpha', 'Alpha'],
|
||||||
|
['recent', 'Recent Heard'],
|
||||||
|
['distance', 'Distance'],
|
||||||
|
] as const
|
||||||
|
).map(([value, label]) => (
|
||||||
|
<Button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={sortMode === value ? 'default' : 'outline'}
|
||||||
|
onClick={() => setSortMode(value)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{sortMode === 'distance' && !canSortByDistance ? (
|
||||||
|
<p className="mt-2 text-[11px] text-muted-foreground">
|
||||||
|
Distance sorting is using known repeater coordinates, but the local radio does not
|
||||||
|
currently have a valid location.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[40vh] overflow-y-auto p-2 lg:min-h-0 lg:max-h-none lg:flex-1">
|
||||||
|
{filteredRepeaters.length === 0 ? (
|
||||||
|
<div className="rounded-md border border-dashed border-border px-3 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
No repeaters matched this search.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filteredRepeaters.map((contact) => {
|
||||||
|
const displayName = getContactDisplayName(
|
||||||
|
contact.name,
|
||||||
|
contact.public_key,
|
||||||
|
contact.last_advert
|
||||||
|
);
|
||||||
|
const distanceKm = getDistanceKm(contact, config);
|
||||||
|
const selectedCount = draftHops.filter(
|
||||||
|
(hop) => hop.kind === 'repeater' && hop.publicKey === contact.public_key
|
||||||
|
).length;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={contact.public_key}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`Add repeater ${displayName}`}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-3 rounded-md border px-3 py-3 text-left transition-colors',
|
||||||
|
selectedCount > 0
|
||||||
|
? 'border-primary/30 bg-primary/5'
|
||||||
|
: 'border-border bg-background hover:bg-accent'
|
||||||
|
)}
|
||||||
|
onClick={() => handleAddRepeater(contact.public_key)}
|
||||||
|
onKeyDown={handleKeyboardActivate}
|
||||||
|
>
|
||||||
|
<ContactAvatar
|
||||||
|
name={contact.name}
|
||||||
|
publicKey={contact.public_key}
|
||||||
|
size={28}
|
||||||
|
contactType={contact.type}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-sm font-medium">{displayName}</div>
|
||||||
|
<div className="truncate text-xs text-muted-foreground">
|
||||||
|
{getShortKey(contact.public_key)}
|
||||||
|
</div>
|
||||||
|
{sortMode === 'distance' && distanceKm !== null ? (
|
||||||
|
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||||
|
{distanceKm.toFixed(1)} km away
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{selectedCount > 0 ? (
|
||||||
|
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||||
|
Added {selectedCount} time{selectedCount === 1 ? '' : 's'}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-input bg-background text-muted-foreground"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-1 flex-col gap-4 lg:min-h-0 lg:overflow-hidden">
|
||||||
|
<div className="flex flex-col rounded-lg border border-border bg-card lg:min-h-0 lg:max-h-[50%]">
|
||||||
|
<div className="shrink-0 flex items-start justify-between gap-3 border-b border-border px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">Trace Path</h3>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
The first node is display-only. The terminal node is the local radio.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{draftHops.length > 0 ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="shrink-0 text-muted-foreground"
|
||||||
|
onClick={() => {
|
||||||
|
setDraftHops([]);
|
||||||
|
clearPendingResult();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 p-4 lg:min-h-0 lg:flex-1 lg:overflow-y-auto">
|
||||||
|
<TraceNodeRow
|
||||||
|
title={localRadioName}
|
||||||
|
subtitle={getShortKey(localRadioKey)}
|
||||||
|
meta="Origin"
|
||||||
|
fixed
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
{draftHops.length === 0 ? (
|
||||||
|
<div className="rounded-md border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
|
||||||
|
Add at least one hop to build a trace loop.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
draftHops.map((hop, index) => {
|
||||||
|
const contact =
|
||||||
|
hop.kind === 'repeater' ? (repeatersByKey.get(hop.publicKey) ?? null) : null;
|
||||||
|
const displayName =
|
||||||
|
hop.kind === 'repeater'
|
||||||
|
? getContactDisplayName(
|
||||||
|
contact?.name,
|
||||||
|
hop.publicKey,
|
||||||
|
contact?.last_advert ?? null
|
||||||
|
)
|
||||||
|
: 'Custom hop';
|
||||||
|
const subtitle =
|
||||||
|
hop.kind === 'repeater'
|
||||||
|
? getShortKey(hop.publicKey)
|
||||||
|
: `${hop.hopHex.toUpperCase()} (${hop.hopBytes}-byte)`;
|
||||||
|
return (
|
||||||
|
<div key={hop.id}>
|
||||||
|
<TraceNodeRow
|
||||||
|
title={displayName}
|
||||||
|
subtitle={subtitle}
|
||||||
|
meta={`Hop ${index + 1}`}
|
||||||
|
note={
|
||||||
|
index === draftHops.length - 1
|
||||||
|
? 'Note: you must be able to hear the final repeater in the trace for trace success.'
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
compact
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8"
|
||||||
|
aria-label={`Move ${displayName} up`}
|
||||||
|
onClick={() => handleMoveHop(index, -1)}
|
||||||
|
disabled={index === 0}
|
||||||
|
>
|
||||||
|
<ArrowUp className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8"
|
||||||
|
aria-label={`Move ${displayName} down`}
|
||||||
|
onClick={() => handleMoveHop(index, 1)}
|
||||||
|
disabled={index === draftHops.length - 1}
|
||||||
|
>
|
||||||
|
<ArrowDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8"
|
||||||
|
aria-label={`Remove ${displayName}`}
|
||||||
|
onClick={() => handleRemoveHop(hop.id)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
<TraceNodeRow
|
||||||
|
title={localRadioName}
|
||||||
|
subtitle={getShortKey(localRadioKey)}
|
||||||
|
meta="Terminal"
|
||||||
|
fixed
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 flex flex-wrap items-center justify-between gap-3 border-t border-border px-4 py-3">
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{draftHops.length === 0
|
||||||
|
? 'No hops selected'
|
||||||
|
: `${draftHops.length} hop${draftHops.length === 1 ? '' : 's'} selected · ${effectiveHopHashBytes}-byte trace`}
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleRunTrace} disabled={loading || draftHops.length === 0}>
|
||||||
|
{loading ? 'Tracing...' : 'Send trace'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col rounded-lg border border-border bg-card lg:min-h-0 lg:flex-1">
|
||||||
|
<div className="shrink-0 flex items-center justify-between gap-3 border-b border-border px-4 py-3">
|
||||||
|
<h3 className="text-sm font-semibold">
|
||||||
|
Results{result ? ` (${result.timeout_seconds.toFixed(1)}s)` : ''}
|
||||||
|
</h3>
|
||||||
|
{result || error ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="shrink-0 text-muted-foreground"
|
||||||
|
onClick={() => {
|
||||||
|
setResult(null);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 space-y-3 p-4 lg:overflow-y-auto">
|
||||||
|
{error ? (
|
||||||
|
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{!error && !result ? (
|
||||||
|
<div className="rounded-md border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
|
||||||
|
Send a trace to see the returned hop-by-hop SNR values.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{result
|
||||||
|
? resultNodes.map((node, index) => {
|
||||||
|
const title =
|
||||||
|
node.name ||
|
||||||
|
(node.role === 'custom'
|
||||||
|
? 'Custom hop'
|
||||||
|
: node.role === 'local'
|
||||||
|
? localRadioName
|
||||||
|
: getShortKey(node.public_key));
|
||||||
|
const subtitle =
|
||||||
|
node.role === 'custom'
|
||||||
|
? `Key prefix ${node.observed_hash?.toUpperCase() ?? 'unknown'}`
|
||||||
|
: node.observed_hash &&
|
||||||
|
node.public_key &&
|
||||||
|
node.observed_hash.toLowerCase() !==
|
||||||
|
getShortKey(node.public_key).toLowerCase()
|
||||||
|
? `${getShortKey(node.public_key)} · key prefix ${node.observed_hash.toUpperCase()}`
|
||||||
|
: getShortKey(node.public_key);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${node.role}-${node.public_key ?? node.observed_hash ?? 'local'}-${index}`}
|
||||||
|
>
|
||||||
|
<TraceNodeRow
|
||||||
|
title={title}
|
||||||
|
subtitle={subtitle}
|
||||||
|
meta={
|
||||||
|
index === 0
|
||||||
|
? 'Origin'
|
||||||
|
: node.role === 'local'
|
||||||
|
? 'Terminal'
|
||||||
|
: `Hop ${index}`
|
||||||
|
}
|
||||||
|
fixed={node.role === 'local'}
|
||||||
|
snr={index === 0 ? null : formatSNR(node.snr)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={customDialogOpen} onOpenChange={setCustomDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[440px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Custom path hop</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Add a raw repeater prefix as a 1-byte, 2-byte, or 4-byte hop. Once you add a custom
|
||||||
|
hop, all later custom hops must use the same byte width.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm font-medium">Hop width</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{([1, 2, 4] as const).map((value) => {
|
||||||
|
const locked = customHopBytesLocked !== null && customHopBytesLocked !== value;
|
||||||
|
const active = (customHopBytesLocked ?? customHopBytesDraft) === value;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={active ? 'default' : 'outline'}
|
||||||
|
disabled={locked}
|
||||||
|
onClick={() => setCustomHopBytesDraft(value)}
|
||||||
|
>
|
||||||
|
{value}-byte
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{customHopBytesLocked !== null ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Custom hops are locked to {customHopBytesLocked}-byte prefixes for this trace.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium" htmlFor="custom-hop-hex">
|
||||||
|
Repeater prefix
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="custom-hop-hex"
|
||||||
|
value={customHopHexDraft}
|
||||||
|
onChange={(event) =>
|
||||||
|
setCustomHopHexDraft(normalizeCustomHopHex(event.target.value))
|
||||||
|
}
|
||||||
|
placeholder={`${(customHopBytesLocked ?? customHopBytesDraft) * 2} hex chars`}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Enter exactly {(customHopBytesLocked ?? customHopBytesDraft) * 2} hex characters.
|
||||||
|
</p>
|
||||||
|
{customHopError ? (
|
||||||
|
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{customHopError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:justify-between">
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setCustomDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={handleAddCustomHop}>
|
||||||
|
Add custom hop
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useMemo, lazy, Suspense } from 'react';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { RepeaterPane, NotFetched, formatDuration } from './repeaterPaneShared';
|
import { RepeaterPane, NotFetched, formatDuration } from './repeaterPaneShared';
|
||||||
import { isValidLocation, calculateDistance, formatDistance } from '../../utils/pathUtils';
|
import { isValidLocation, calculateDistance, formatDistance } from '../../utils/pathUtils';
|
||||||
|
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
|
||||||
import type {
|
import type {
|
||||||
Contact,
|
Contact,
|
||||||
RepeaterNeighborsResponse,
|
RepeaterNeighborsResponse,
|
||||||
@@ -35,6 +36,7 @@ export function NeighborsPane({
|
|||||||
nodeInfoState: PaneState;
|
nodeInfoState: PaneState;
|
||||||
repeaterName: string | null;
|
repeaterName: string | null;
|
||||||
}) {
|
}) {
|
||||||
|
const { distanceUnit } = useDistanceUnit();
|
||||||
const advertLat = repeaterContact?.lat ?? null;
|
const advertLat = repeaterContact?.lat ?? null;
|
||||||
const advertLon = repeaterContact?.lon ?? null;
|
const advertLon = repeaterContact?.lon ?? null;
|
||||||
|
|
||||||
@@ -93,7 +95,7 @@ export function NeighborsPane({
|
|||||||
if (hasValidRepeaterGps && isValidLocation(nLat, nLon)) {
|
if (hasValidRepeaterGps && isValidLocation(nLat, nLon)) {
|
||||||
const distKm = calculateDistance(positionSource.lat, positionSource.lon, nLat, nLon);
|
const distKm = calculateDistance(positionSource.lat, positionSource.lon, nLat, nLon);
|
||||||
if (distKm != null) {
|
if (distKm != null) {
|
||||||
dist = formatDistance(distKm);
|
dist = formatDistance(distKm, distanceUnit);
|
||||||
anyDist = true;
|
anyDist = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,7 +113,7 @@ export function NeighborsPane({
|
|||||||
sorted: enriched,
|
sorted: enriched,
|
||||||
hasDistances: anyDist,
|
hasDistances: anyDist,
|
||||||
};
|
};
|
||||||
}, [contacts, data, hasValidRepeaterGps, positionSource.lat, positionSource.lon]);
|
}, [contacts, data, distanceUnit, hasValidRepeaterGps, positionSource.lat, positionSource.lon]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RepeaterPane
|
<RepeaterPane
|
||||||
|
|||||||
@@ -112,7 +112,10 @@ export function RadioSettingsPane({
|
|||||||
<NotFetched />
|
<NotFetched />
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<KvRow label="Local Advert" value={formatAdvertInterval(advertData.advert_interval)} />
|
<KvRow
|
||||||
|
label="Local Advert"
|
||||||
|
value={formatAdvertInterval(advertData.advert_interval, 'minutes')}
|
||||||
|
/>
|
||||||
<KvRow
|
<KvRow
|
||||||
label="Flood Advert"
|
label="Flood Advert"
|
||||||
value={formatAdvertInterval(advertData.flood_advert_interval)}
|
value={formatAdvertInterval(advertData.flood_advert_interval)}
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip as RechartsTooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { TelemetryHistoryEntry } from '../../types';
|
||||||
|
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TelemetryHistoryPane({ entries }: { entries: TelemetryHistoryEntry[] }) {
|
||||||
|
const [metric, setMetric] = useState<Metric>('battery_volts');
|
||||||
|
|
||||||
|
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];
|
||||||
|
|
||||||
|
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">
|
||||||
|
<h3 className="text-sm font-medium">Telemetry History</h3>
|
||||||
|
<span className="text-[10px] text-muted-foreground">{entries.length} samples</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-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-[11px] 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={false}
|
||||||
|
activeDot={{
|
||||||
|
r: 4,
|
||||||
|
fill: metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color,
|
||||||
|
strokeWidth: 2,
|
||||||
|
stroke: 'hsl(var(--popover))',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -76,11 +76,19 @@ export function formatClockDrift(
|
|||||||
return { text: parts.join(''), isLarge: false };
|
return { text: parts.join(''), isLarge: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatAdvertInterval(val: string | null): string {
|
export function formatAdvertInterval(
|
||||||
|
val: string | null,
|
||||||
|
unit: 'minutes' | 'hours' = 'hours'
|
||||||
|
): string {
|
||||||
if (val == null) return '—';
|
if (val == null) return '—';
|
||||||
const trimmed = val.trim();
|
const trimmed = val.trim();
|
||||||
if (trimmed === '0') return '<disabled>';
|
if (trimmed === '0') return '<disabled>';
|
||||||
return `${trimmed}h`;
|
if (unit === 'hours') return `${trimmed}h`;
|
||||||
|
const mins = parseInt(trimmed, 10);
|
||||||
|
if (isNaN(mins)) return trimmed;
|
||||||
|
if (mins >= 60 && mins % 60 === 0) return `${mins / 60}h`;
|
||||||
|
if (mins >= 60) return `${Math.floor(mins / 60)}h${mins % 60}m`;
|
||||||
|
return `${mins}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFetchedRelative(fetchedAt: number): string {
|
function formatFetchedRelative(fetchedAt: number): string {
|
||||||
|
|||||||
@@ -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,8 @@ export function SettingsDatabaseSection({
|
|||||||
blockedNames = [],
|
blockedNames = [],
|
||||||
onToggleBlockedKey,
|
onToggleBlockedKey,
|
||||||
onToggleBlockedName,
|
onToggleBlockedName,
|
||||||
|
contacts = [],
|
||||||
|
onBulkDeleteContacts,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
appSettings: AppSettings;
|
appSettings: AppSettings;
|
||||||
@@ -27,18 +30,23 @@ 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;
|
||||||
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 +100,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 +121,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 app functionality, nor impact your
|
|
||||||
ability to do historical decryption.
|
|
||||||
</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"
|
||||||
@@ -207,17 +223,87 @@ export function SettingsDatabaseSection({
|
|||||||
</p>
|
</p>
|
||||||
</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 />
|
<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 +354,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,20 @@ import {
|
|||||||
} from '../../utils/lastViewedConversation';
|
} from '../../utils/lastViewedConversation';
|
||||||
import { ThemeSelector } from './ThemeSelector';
|
import { ThemeSelector } from './ThemeSelector';
|
||||||
import { getLocalLabel, setLocalLabel, type LocalLabel } from '../../utils/localLabel';
|
import { getLocalLabel, setLocalLabel, type LocalLabel } from '../../utils/localLabel';
|
||||||
|
import {
|
||||||
|
DISTANCE_UNIT_LABELS,
|
||||||
|
DISTANCE_UNITS,
|
||||||
|
setSavedDistanceUnit,
|
||||||
|
} from '../../utils/distanceUnits';
|
||||||
|
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
|
||||||
|
import {
|
||||||
|
DEFAULT_FONT_SCALE,
|
||||||
|
FONT_SCALE_SLIDER_STEP,
|
||||||
|
MAX_FONT_SCALE,
|
||||||
|
MIN_FONT_SCALE,
|
||||||
|
getSavedFontScale,
|
||||||
|
setSavedFontScale,
|
||||||
|
} from '../../utils/fontScale';
|
||||||
|
|
||||||
export function SettingsLocalSection({
|
export function SettingsLocalSection({
|
||||||
onLocalLabelChange,
|
onLocalLabelChange,
|
||||||
@@ -19,11 +33,35 @@ export function SettingsLocalSection({
|
|||||||
onLocalLabelChange?: (label: LocalLabel) => void;
|
onLocalLabelChange?: (label: LocalLabel) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const { distanceUnit, setDistanceUnit } = useDistanceUnit();
|
||||||
const [reopenLastConversation, setReopenLastConversation] = useState(
|
const [reopenLastConversation, setReopenLastConversation] = useState(
|
||||||
getReopenLastConversationEnabled
|
getReopenLastConversationEnabled
|
||||||
);
|
);
|
||||||
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 [fontScaleSlider, setFontScaleSlider] = useState(getSavedFontScale);
|
||||||
|
const [fontScaleInput, setFontScaleInput] = useState(() => String(getSavedFontScale()));
|
||||||
|
|
||||||
|
const commitFontScale = (nextScale: number) => {
|
||||||
|
const normalized = setSavedFontScale(nextScale);
|
||||||
|
setFontScale(normalized);
|
||||||
|
setFontScaleSlider(normalized);
|
||||||
|
setFontScaleInput(String(normalized));
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoreFontScaleInput = () => {
|
||||||
|
setFontScaleInput(String(fontScale));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSliderChange = (nextScale: number) => {
|
||||||
|
setFontScaleSlider(nextScale);
|
||||||
|
setFontScaleInput(String(nextScale));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSliderCommit = (nextScale: number) => {
|
||||||
|
commitFontScale(nextScale);
|
||||||
|
};
|
||||||
|
|
||||||
const handleToggleReopenLastConversation = (enabled: boolean) => {
|
const handleToggleReopenLastConversation = (enabled: boolean) => {
|
||||||
setReopenLastConversation(enabled);
|
setReopenLastConversation(enabled);
|
||||||
@@ -82,6 +120,110 @@ export function SettingsLocalSection({
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label htmlFor="font-scale-input">Relative Font Size</Label>
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={MIN_FONT_SCALE}
|
||||||
|
max={MAX_FONT_SCALE}
|
||||||
|
step={FONT_SCALE_SLIDER_STEP}
|
||||||
|
value={fontScaleSlider}
|
||||||
|
onChange={(event) => handleSliderChange(Number(event.target.value))}
|
||||||
|
onMouseUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
|
||||||
|
onTouchEnd={(event) => handleSliderCommit(Number(event.currentTarget.value))}
|
||||||
|
onKeyUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
|
||||||
|
onBlur={(event) => handleSliderCommit(Number(event.currentTarget.value))}
|
||||||
|
aria-label="Relative font size slider"
|
||||||
|
className="w-full accent-primary sm:flex-1"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 sm:w-40">
|
||||||
|
<Input
|
||||||
|
id="font-scale-input"
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
min={MIN_FONT_SCALE}
|
||||||
|
max={MAX_FONT_SCALE}
|
||||||
|
step="any"
|
||||||
|
value={fontScaleInput}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextValue = event.target.value;
|
||||||
|
setFontScaleInput(nextValue);
|
||||||
|
|
||||||
|
if (nextValue === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.target.validity.valid && Number.isFinite(event.target.valueAsNumber)) {
|
||||||
|
commitFontScale(event.target.valueAsNumber);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
const parsed = Number.parseFloat(fontScaleInput);
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
restoreFontScaleInput();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
commitFontScale(parsed);
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== 'Enter') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
const parsed = Number.parseFloat(fontScaleInput);
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
restoreFontScaleInput();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
commitFontScale(parsed);
|
||||||
|
}}
|
||||||
|
aria-label="Relative font size percentage"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">%</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => commitFontScale(DEFAULT_FONT_SCALE)}
|
||||||
|
className="inline-flex h-9 items-center justify-center rounded-md border border-input px-3 text-sm font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={fontScale === DEFAULT_FONT_SCALE}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Scales the app's typography for this browser only. The slider moves in 5% steps; the
|
||||||
|
number field accepts any value from 25% to 400%.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label htmlFor="distance-units">Distance Units</Label>
|
||||||
|
<select
|
||||||
|
id="distance-units"
|
||||||
|
value={distanceUnit}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextUnit = event.target.value as (typeof DISTANCE_UNITS)[number];
|
||||||
|
setSavedDistanceUnit(nextUnit);
|
||||||
|
setDistanceUnit(nextUnit);
|
||||||
|
}}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
{DISTANCE_UNITS.map((unit) => (
|
||||||
|
<option key={unit} value={unit}>
|
||||||
|
{DISTANCE_UNIT_LABELS[unit]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Controls how distances are shown throughout the app.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
<label className="flex items-center gap-3 cursor-pointer">
|
<label className="flex items-center gap-3 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -205,17 +347,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-[13px] ${
|
||||||
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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user