Compare commits

...

82 Commits

Author SHA1 Message Date
Jack Kingsman 44f145b646 Updating changelog + build for 3.7.1 2026-04-02 18:01:22 -07:00
Jack Kingsman 55e2dc478d Redact Apprise URLs 2026-04-02 17:59:41 -07:00
Jack Kingsman 0932800e1f Fix lint 2026-04-02 17:38:35 -07:00
Jack Kingsman c333eb25e3 Updating changelog + build for 3.7.0 2026-04-02 17:30:19 -07:00
Jack Kingsman 580aa1cefd Correct TCP port 2026-04-02 13:55:05 -07:00
Jack Kingsman 30de09f71b Merge pull request #126 from maplemesh/gnomeadrift/repeater_telemetry_history
Logging battery voltage history from telemetry
2026-04-02 13:29:44 -07:00
Jack Kingsman 93d31adecd Don't change historical migrations (cruft from rebasing) and don't overwrite data 2026-04-02 13:21:21 -07:00
Jack Kingsman 5f969017f7 Add some tests, make it an actual endpoint (whoops said we didn't need that) and tidy things up a bit 2026-04-02 12:43:42 -07:00
Gnome Adrift 967dd05fad Prune telemetry entries, remove uplot comments, format code 2026-04-02 12:34:00 -07:00
Gnome Adrift c808f0930b Remove automatic telemetry querying, remove battery pane, add telemetry history pane 2026-04-02 12:31:51 -07:00
Gnome Adrift 87df4b4aa1 Fix for telemetry polling 2026-04-02 12:27:18 -07:00
Gnome Adrift 0511d6f69b Make battery history update when fetching telemetry 2026-04-02 12:27:18 -07:00
Gnome Adrift 78b5598f67 First draft of repeater telemetry feature 2026-04-02 12:27:06 -07:00
Jack Kingsman 5e1bdb2cc1 Fix terminals on hash room parsing 2026-04-02 00:23:07 -07:00
Jack Kingsman 4420d44838 Add bulk room add 2026-04-02 00:19:25 -07:00
Jack Kingsman ead1774cd3 Boost sidebar icon color 2026-04-01 22:17:04 -07:00
Jack Kingsman 0d45cbd849 Yolo to FK pragma
Move to fk pragma
2026-04-01 22:09:00 -07:00
Jack Kingsman 456f739f51 Emit correct events, update sender key, and don't let discovery path skip prefix promotion; other misc. fixes 2026-04-01 21:56:51 -07:00
Jack Kingsman 80c6cc44e5 Formatting and linting 2026-04-01 21:39:59 -07:00
Jack Kingsman 35265d8ae8 Back up all available files and remove dead else clause from contact prefix promotion 2026-04-01 21:34:35 -07:00
Jack Kingsman 4a2d7ed100 Move to FK pragma and prep other code points in light of that 2026-04-01 21:22:01 -07:00
Jack Kingsman 47c4f038fe Reorganize Database settings pane 2026-04-01 17:07:53 -07:00
Jack Kingsman 630ba67ef0 Patch up radio locking and frontend contact delete behavior for bulk contact delete 2026-04-01 16:52:25 -07:00
Jack Kingsman fd1188abcd Make our radio pane less miserable. Closes #145 2026-04-01 16:45:09 -07:00
Jack Kingsman 94513d7177 Move type label to top of bulk delete 2026-04-01 16:40:06 -07:00
Jack Kingsman fbff9821be Add bulk deletion interface 2026-04-01 16:33:05 -07:00
Jack Kingsman 1fd281121b Default auto-dm-decrypt to true 2026-04-01 15:58:15 -07:00
Jack Kingsman 5653a43941 Add new node ingest blocking 2026-04-01 15:57:22 -07:00
Jack Kingsman 7f07aedb8a Make repeaters blockable, and hide from the sidebar 2026-04-01 15:39:40 -07:00
Jack Kingsman e437ce74c6 Surface repeater info pane just like contacts 2026-04-01 14:21:00 -07:00
Jack Kingsman 4ff6d2018a Remove discontinuity on radio limit exceed for contacts 2026-04-01 12:27:10 -07:00
Jack Kingsman 1c634da687 Be more conservative around limits for radio contact adding and don't respect user value if it exceeds radio limits 2026-04-01 12:24:54 -07:00
Jack Kingsman 738c21dd66 Compact debug endpoint. Closes #143. 2026-04-01 12:18:03 -07:00
Jack Kingsman 7d72448ebf Make hop counts collapse to be neater. Closes #144. 2026-04-01 11:42:09 -07:00
Jack Kingsman b4f3d1f14c Add additional info to debug endpoint. Closes #142. 2026-04-01 11:31:20 -07:00
Jack Kingsman 416166b07c Add system arch data to debug output 2026-03-31 23:09:12 -07:00
Jack Kingsman 480798e117 Updating changelog + build for 3.6.7 2026-03-31 23:01:36 -07:00
Jack Kingsman 704a3d8a87 Updating changelog + build for 3.6.6 2026-03-31 22:52:14 -07:00
Jack Kingsman 96e108037c Updating changelog + build for 3.6.5 2026-03-31 22:21:06 -07:00
Jack Kingsman 97aade3632 Format changelog entries with bullets 2026-03-31 22:17:03 -07:00
Jack Kingsman e43584912b Updating changelog + build for 3.6.4 2026-03-31 22:14:58 -07:00
Jack Kingsman fccde36ecb Gentle emphasis on new contact/channel button 2026-03-31 21:57:35 -07:00
Jack Kingsman e631f9b0cc DHCP notes 2026-03-31 21:33:50 -07:00
Jack Kingsman b52431616e Be more sane with by-id aliases 2026-03-31 20:47:10 -07:00
Jack Kingsman 8446d99df1 Be more resistent for colons in the device ID 2026-03-31 20:23:07 -07:00
Jack Kingsman 8e1e913fcd Make all scripts executable 2026-03-31 18:26:04 -07:00
Jack Kingsman b74137dc72 Add trace clear and much better layout. Closes #139. 2026-03-31 16:55:21 -07:00
Jack Kingsman c83f9b0005 Rename Best RSSI to Strongest Neighbor. Closes #136. 2026-03-31 13:10:02 -07:00
Jack Kingsman 9f4737d350 Add hashtag link detection. Closes #134. 2026-03-31 12:55:52 -07:00
Jack Kingsman 29e9a5f701 Be more resilient in noise floor gathering 2026-03-31 12:35:35 -07:00
Jack Kingsman f0f06671cc Make new message button clearer 2026-03-31 12:28:47 -07:00
Jack Kingsman b1595e479c Use the image's full government name 2026-03-30 23:11:37 -07:00
Jack Kingsman 25df69bfbc Add snakeoil certs to docker setup 2026-03-30 22:13:09 -07:00
Jack Kingsman 88140081b9 Updating changelog + build for 3.6.3 2026-03-30 21:54:32 -07:00
Jack Kingsman 4326f57977 Lint fixes 2026-03-30 21:44:26 -07:00
Jack Kingsman 43abcd07b2 Improve DB streaming perf for cracking and statistics 2026-03-30 21:31:59 -07:00
Jack Kingsman 5c60559cb8 Fix memoization on cracker panel 2026-03-30 21:31:47 -07:00
Jack Kingsman 3c0d6a4466 Fix some misc. frontend correctness bugs 2026-03-30 21:29:01 -07:00
Jack Kingsman 7b9d8f6a23 Docs updates 2026-03-30 21:24:36 -07:00
Jack Kingsman 44d6fcac24 Add missing abort controller 2026-03-30 21:15:42 -07:00
Jack Kingsman 788d1cbdca Fix non-repeater traffic during repeater ops dropping messages 2026-03-30 21:13:25 -07:00
Jack Kingsman 26e8150092 Shorten quality script 2026-03-30 21:02:49 -07:00
Jack Kingsman 3a1c2d691b Misc. bug bash 2026-03-30 20:49:09 -07:00
Jack Kingsman 134e8d0d29 Add trace tool. Closes #130. 2026-03-30 19:26:12 -07:00
Jack Kingsman eb1f7ae638 Be more resilient about docker script input management 2026-03-30 17:54:07 -07:00
Jack Kingsman 14ba342160 Add docker install script 2026-03-30 17:09:25 -07:00
Jack Kingsman 7460c3ea9d Add font size slider. Closes #132. 2026-03-30 16:47:24 -07:00
Jack Kingsman 6534946bc7 Simplify installation instructions 2026-03-30 16:26:25 -07:00
Jack Kingsman 4847813ae1 Fix up the slow core query from the stats page. Closes #131. 2026-03-30 15:59:44 -07:00
Jack Kingsman 3f6efaae1d Overhaul script handling. Closes #125. 2026-03-30 15:51:43 -07:00
Jack Kingsman 60f3fa8e36 Add noise floor visualizer to statistics. Closes #129. 2026-03-30 15:31:39 -07:00
Jack Kingsman b42ca44ba7 Add noise floor plumbing 2026-03-30 14:23:01 -07:00
Jack Kingsman d4bbb8a542 Add multibyte trace output. Closes #127. 2026-03-30 12:52:01 -07:00
Jack Kingsman db248302e9 Show node name if we find it in the DB already. Closes #128. 2026-03-30 12:28:26 -07:00
Jack Kingsman 7aa4f76064 Fix clamping on value inputs to allow empty while focused 2026-03-29 22:38:06 -07:00
Jack Kingsman f01e91defc Clean up after release 2026-03-29 22:37:38 -07:00
Jack Kingsman 8ee08ff44a Updating changelog + build for 3.6.2 2026-03-29 19:55:49 -07:00
Jack Kingsman 6d9ea552bd Provide multi-platform docker builds. Closes #119. 2026-03-29 19:34:51 -07:00
Jack Kingsman 2cd71bf086 Fix linting 2026-03-29 19:09:36 -07:00
Jack Kingsman 08d55dec72 Show last error status on integrations. Closes #122. 2026-03-29 18:47:17 -07:00
Jack Kingsman 20532f70a3 Allow map uploader to follow redirects. Closes #123. 2026-03-29 18:15:10 -07:00
Jack Kingsman 659370e1eb Don't cast SNR/RSSI to string. Closes #121. 2026-03-29 18:02:59 -07:00
152 changed files with 9984 additions and 1944 deletions
+7
View File
@@ -2,6 +2,8 @@
__pycache__/
*.py[oc]
build/
!scripts/build/
!scripts/build/**
wheels/
*.egg-info
@@ -23,3 +25,8 @@ references/
# ancillary LLM files
.claude/
# local Docker compose files
docker-compose.yml
docker-compose.yaml
.docker-certs/
+37 -24
View File
@@ -7,7 +7,7 @@
If instructed to "run all tests" or "get ready for a commit" or other summative, work ending directives, run:
```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.
@@ -210,10 +210,16 @@ This message-layer echo/path handling is independent of raw-packet storage dedup
│ │ └── ...
│ └── vite.config.ts
├── scripts/ # Quality / release helpers (listing below is representative, not exhaustive)
│ ├── all_quality.sh # Repo-standard autofix + validate gate
│ ├── collect_licenses.sh # Gather third-party license attributions
├── e2e.sh # End-to-end test runner
── publish.sh # Version bump, changelog, docker build & push
│ ├── build/
│ ├── collect_licenses.sh # Gather third-party license attributions
│ └── 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
├── CONTRIBUTING.md # Contributor workflow and testing guidance
├── tests/ # Backend tests (pytest)
@@ -271,23 +277,23 @@ PYTHONPATH=. uv run pytest tests/ -v
```
Key test files:
- `tests/test_decoder.py` - Channel + direct message decryption, key exchange
- `tests/test_keystore.py` - Ephemeral key store
- `tests/test_event_handlers.py` - ACK tracking, repeat detection
- `tests/test_packet_pipeline.py` - End-to-end packet processing
- `tests/test_api.py` - API endpoints, read state tracking
- `tests/test_migrations.py` - Database migration system
- `tests/test_frontend_static.py` - Frontend static route registration (missing `dist`/`index.html` handling)
- `tests/test_messages_search.py` - Message search, around endpoint, forward pagination
- `tests/test_rx_log_data.py` - on_rx_log_data event handler integration
- `tests/test_ack_tracking_wiring.py` - DM ACK tracking extraction and wiring
- `tests/test_radio_lifecycle_service.py` - Radio reconnect/setup orchestration helpers
- `tests/test_radio_commands_service.py` - Radio config/private-key service workflows
- `tests/test_health_mqtt_status.py` - Health endpoint MQTT status field
- `tests/test_community_mqtt.py` - Community MQTT publisher (JWT, packet format, hash, broadcast)
- `tests/test_radio_sync.py` - Radio sync, periodic tasks, and contact offload back to the radio
- `tests/test_real_crypto.py` - Real cryptographic operations
- `tests/test_disable_bots.py` - MESHCORE_DISABLE_BOTS=true feature
- `tests/test_api.py` - Broad API integration coverage across routers and read-state flows
- `tests/test_packet_pipeline.py` - End-to-end packet processing, decrypt, dedup, and message creation
- `tests/test_event_handlers.py` - ACK tracking, fallback DM handling, and event subscription cleanup
- `tests/test_send_messages.py` - Outgoing DM/channel send workflows, retries, and bot-trigger wiring
- `tests/test_packets_router.py` - Historical decrypt, maintenance, and raw-packet detail endpoints
- `tests/test_repeater_routes.py` - Repeater command/telemetry/trace pane endpoints
- `tests/test_room_routes.py` - Room-server login/status/ACL/telemetry endpoints
- `tests/test_radio_router.py` - Radio config, advert, discovery, trace, and reconnect endpoints
- `tests/test_radio_sync.py` - Radio sync, periodic tasks, contact offload/reload, and pending-message flushes
- `tests/test_fanout.py` - Fanout config CRUD, scope matching, and manager dispatch
- `tests/test_fanout_integration.py` - Integration-module lifecycle and delivery behavior
- `tests/test_statistics.py` - Aggregated mesh/network statistics and noise-floor snapshots
- `tests/test_version_info.py` - Version/build metadata resolution
- `tests/test_websocket.py` - WS manager broadcast and cleanup behavior
- `tests/test_frontend_static.py` - Frontend static route registration and fallback behavior
For the fuller backend inventory, see `app/AGENTS.md`. For frontend-specific suites, see `frontend/AGENTS.md`.
### Frontend (Vitest)
@@ -298,7 +304,7 @@ npm run test:run
### Before Completing Major Changes
**Run `./scripts/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.
**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
@@ -313,6 +319,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| 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/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/disconnect` | Disconnect from radio and pause automatic reconnect attempts |
| POST | `/api/radio/reconnect` | Manual radio reconnection |
@@ -335,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/advert-intervals` | Fetch advert intervals |
| 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/{key}/detail` | Comprehensive channel profile (message stats, top senders) |
@@ -348,6 +359,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| 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) |
| 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/maintenance` | Delete old packets and vacuum |
| GET | `/api/read-state/unreads` | Server-computed unread counts, mentions, last message times, and `last_read_ats` boundaries |
@@ -362,6 +374,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| POST | `/api/fanout` | Create new fanout config |
| PATCH | `/api/fanout/{id}` | Update fanout config (triggers module reload) |
| 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 |
| WS | `/api/ws` | Real-time updates |
@@ -450,7 +463,7 @@ mc.subscribe(EventType.ACK, handler)
|----------|---------|-------------|
| `MESHCORE_SERIAL_PORT` | auto-detect | Serial port for radio |
| `MESHCORE_TCP_HOST` | *(none)* | TCP host for radio (mutually exclusive with serial/BLE) |
| `MESHCORE_TCP_PORT` | `4000` | TCP port (used with `MESHCORE_TCP_HOST`) |
| `MESHCORE_TCP_PORT` | `5000` | TCP port (used with `MESHCORE_TCP_HOST`) |
| `MESHCORE_BLE_ADDRESS` | *(none)* | BLE device address (mutually exclusive with serial/TCP) |
| `MESHCORE_BLE_PIN` | *(required with BLE)* | BLE PIN code |
| `MESHCORE_SERIAL_BAUDRATE` | `115200` | Serial baud rate |
+379 -317
View File
@@ -1,149 +1,211 @@
## [3.7.1] - 2026-04-02
* Feature: Redact Apprise URLs to prevent sensitive information disclosure
## [3.7.0] - 2026-04-02
* Feature: Repeater battery tracking
* Feature: Repeater info pane just like contacts
* Feature: Make repeaters blockable
* Feature: Add new-node advert blocking
* Feature: Add bulk deletion interface
* Feature: Bulk room add on alt+click of new channel button
* Feature: More info in debug endpoint
* Bugfix: Be more conservative around radio load limits and don't exceed radio-reported capacity
* Misc: Default auto-DM decrypt to true
* Misc: Reorganize some settings panes
* Misc: Enable FK pragma
* Misc: Various performance and correctness fixes
* Misc: Correct TCP default port
## [3.6.7] - 2026-03-31
* Misc: Remove armv7 (for now)
## [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
* 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
* 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
* 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
Bugfix: Improve handling of version information on prebuilt bundles
Bugfix: Improve frontend usability on disconnected radio
Misc: Docs and readme updates
Misc: Overhaul DM ingest and frontend state handling
* Bugfix: Improve handling of version information on prebuilt bundles
* Bugfix: Improve frontend usability on disconnected radio
* Misc: Docs and readme updates
* Misc: Overhaul DM ingest and frontend state handling
## [3.4.0] - 2026-03-16
Feature: Add radio model and stats display
Feature: Add prebuilt frontends, then deleted that and moved to prebuilt release artifacts
Bugfix: Misc. frontend performance and correctness fixes
Bugfix: Fix same-second same-content DM send collition
Bugfix: Discard clearly-wrong GPS data
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: Don't permit invalid fanout configs to be saved ever`
* Feature: Add radio model and stats display
* Feature: Add prebuilt frontends, then deleted that and moved to prebuilt release artifacts
* Bugfix: Misc. frontend performance and correctness fixes
* Bugfix: Fix same-second same-content DM send collition
* Bugfix: Discard clearly-wrong GPS data
* 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: Don't permit invalid fanout configs to be saved ever`
## [3.3.0] - 2026-03-13
Feature: Use dashed lines to show collapsed ambiguous router results
Feature: Jump to unred
Feature: Local channel management to prevent need to reload channel every time
Feature: Debug endpoint
Feature: Force-singleton channel management
Feature: Local node discovery
Feature: Node routing discovery
Bugfix: Don't tell users to us npm ci
Bugfix: Fallback polling dm message persistence
Bugfix: All native-JS inputs are now modals
Bugfix: Same-second send collision resolution
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 treat prefix-matching DM echoes as acks like we do for channel messages
Misc: Visualizer data layer overhaul for future map work
Misc: Parallelize docker tests
* Feature: Use dashed lines to show collapsed ambiguous router results
* Feature: Jump to unred
* Feature: Local channel management to prevent need to reload channel every time
* Feature: Debug endpoint
* Feature: Force-singleton channel management
* Feature: Local node discovery
* Feature: Node routing discovery
* Bugfix: Don't tell users to us npm ci
* Bugfix: Fallback polling dm message persistence
* Bugfix: All native-JS inputs are now modals
* Bugfix: Same-second send collision resolution
* 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 treat prefix-matching DM echoes as acks like we do for channel messages
* Misc: Visualizer data layer overhaul for future map work
* Misc: Parallelize docker tests
## [3.2.0] - 2026-03-12
Feature: Improve ambiguous-sender DM handling and visibility
Feature: Allow for toggling of node GPS broadcast
Feature: Add path width to bot and move example to full kwargs
Feature: Improve node map color contrast
Bugfix: More accurate tracking of contact data
Bugfix: Misc. frontend performance and bugfixes
Misc: Clearer warnings on user-key linkage
Misc: Documentation improvements
* Feature: Improve ambiguous-sender DM handling and visibility
* Feature: Allow for toggling of node GPS broadcast
* Feature: Add path width to bot and move example to full kwargs
* Feature: Improve node map color contrast
* Bugfix: More accurate tracking of contact data
* Bugfix: Misc. frontend performance and bugfixes
* Misc: Clearer warnings on user-key linkage
* Misc: Documentation improvements
## [3.1.1] - 2026-03-11
Feature: Add basic auth
Feature: SQS fanout
Feature: Enrich contact info pane
Feature: Search operators for node and channel
Feature: Pause radio connection attempts from Radio settings
Feature: New themes! What a great use of time!
Feature: Github workflows runs for validation
Bugfix: More consistent log format with times
Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses
* Feature: Add basic auth
* Feature: SQS fanout
* Feature: Enrich contact info pane
* Feature: Search operators for node and channel
* Feature: Pause radio connection attempts from Radio settings
* Feature: New themes! What a great use of time!
* Feature: Github workflows runs for validation
* Bugfix: More consistent log format with times
* Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses
## [3.1.0] - 2026-03-11
Feature: Add basic auth
Feature: SQS fanout
Feature: Enrich contact info pane
Feature: Search operators for node and channel
Feature: Pause radio connection attempts from Radio settings
Feature: New themes! What a great use of time!
Feature: Github workflows runs for validation
Bugfix: More consistent log format with times
Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses
* Feature: Add basic auth
* Feature: SQS fanout
* Feature: Enrich contact info pane
* Feature: Search operators for node and channel
* Feature: Pause radio connection attempts from Radio settings
* Feature: New themes! What a great use of time!
* Feature: Github workflows runs for validation
* Bugfix: More consistent log format with times
* Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses
## [3.0.0] - 2026-03-10
Feature: Custom regions per-channel
Feature: Add custom contact pathing
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: More consistent icons
Feature: Add per-channel local notifications
Feature: New themes
Feature: Massive codebase refactor and overhaul
Bugfix: Fix packet parsing for trace packets
Bugfix: Refetch channels on reconnect
Bugfix: Load All on repeater pane on mobile doesn't etend into lower text
Bugfix: Timestamps in logs
Bugfix: Correct wrong clock sync command
Misc: Improve bot error bubble up
Misc: Update to non-lib-included meshcore-decoder version
Misc: Revise refactors to be more LLM friendly
Misc: Fix script executability
Misc: Better logging format with timestamp
Misc: Repeater advert buttons separate flood and one-hop
Misc: Preserve repeater pane on navigation away
Misc: Clearer iconography and coloring for status bar buttons
Misc: Search bar to top bar
* Feature: Custom regions per-channel
* Feature: Add custom contact pathing
* 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: More consistent icons
* Feature: Add per-channel local notifications
* Feature: New themes
* Feature: Massive codebase refactor and overhaul
* Bugfix: Fix packet parsing for trace packets
* Bugfix: Refetch channels on reconnect
* Bugfix: Load All on repeater pane on mobile doesn't etend into lower text
* Bugfix: Timestamps in logs
* Bugfix: Correct wrong clock sync command
* Misc: Improve bot error bubble up
* Misc: Update to non-lib-included meshcore-decoder version
* Misc: Revise refactors to be more LLM friendly
* Misc: Fix script executability
* Misc: Better logging format with timestamp
* Misc: Repeater advert buttons separate flood and one-hop
* Misc: Preserve repeater pane on navigation away
* Misc: Clearer iconography and coloring for status bar buttons
* Misc: Search bar to top bar
## [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
@@ -151,287 +213,287 @@ Bugfix: Don't obscure new integration dropdown on session boundary
## [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
Bugfix: Fix historical DM packet length passing
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
* Bugfix: Fix historical DM packet length passing
* 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
## [2.7.0] - 2026-03-08
Feature: Multibyte path support
Feature: Add multibyte statistics to statistics pane
Feature: Add path bittage to contact info pane
Feature: Put tools in a collapsible
* Feature: Multibyte path support
* Feature: Add multibyte statistics to statistics pane
* Feature: Add path bittage to contact info pane
* Feature: Put tools in a collapsible
## [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
Feature: A11y improvements
Feature: New themes
Feature: Backfill channel sender identity when available
Feature: Modular fanout bus, including Webhooks, more customizable community MQTT, and Apprise
Bugfix: Unreads now respect blocklist
Bugfix: Unreads can't accumulate on an open thread
Bugfix: Channel name in broadcasts
Bugfix: Add missing httpx dependency
Bugfix: Improvements to radio startup frontend-blocking time and radio status reporting
Misc: Improved button signage for app movement
Misc: Test, performance, and documentation improvements
* Feature: A11y improvements
* Feature: New themes
* Feature: Backfill channel sender identity when available
* Feature: Modular fanout bus, including Webhooks, more customizable community MQTT, and Apprise
* Bugfix: Unreads now respect blocklist
* Bugfix: Unreads can't accumulate on an open thread
* Bugfix: Channel name in broadcasts
* Bugfix: Add missing httpx dependency
* Bugfix: Improvements to radio startup frontend-blocking time and radio status reporting
* Misc: Improved button signage for app movement
* Misc: Test, performance, and documentation improvements
## [2.5.0] - 2026-03-05
Feature: Far better accessibility across the app (with far to go)
Feature: Add community MQTT stats reporting, and improve over a few commits
Feature: Color schemes and misc. settings reorg
Feature: Add why-active to filtered nodes
Feature: Add channel and contact info box
Feature: Add contact blocking
Feature: Add potential repeater path map display
Feature: Add flood scoping/regions
Feature: Global message search
Feature: Fully safe bot disable
Feature: Add default #remoteterm channel (lol sorry I had to)
Feature: Custom recency pruning in visualizer
Bugfix: Be more cautious around null byte stripping
Bugfix: Clear channel-add interface on not-add-another
Bugfix: Add status/name/MQTT LWT
Bugfix: Channel deletion propagates over WS
Bugfix: Show map location for all nodes on link, not 7-day-limited
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: Doc, changelog, and test improvements
Misc: Add, and remove, package lock (sorry Windows users)
Misc: Don't show mark all as read if not necessary
Misc: Fix stale closures and misc. frontend perf/correctness improvements
Misc: Add Windows startup notes
Misc: E2E expansion + improvement
Misc: Move around visualizer settings
* Feature: Far better accessibility across the app (with far to go)
* Feature: Add community MQTT stats reporting, and improve over a few commits
* Feature: Color schemes and misc. settings reorg
* Feature: Add why-active to filtered nodes
* Feature: Add channel and contact info box
* Feature: Add contact blocking
* Feature: Add potential repeater path map display
* Feature: Add flood scoping/regions
* Feature: Global message search
* Feature: Fully safe bot disable
* Feature: Add default #remoteterm channel (lol sorry I had to)
* Feature: Custom recency pruning in visualizer
* Bugfix: Be more cautious around null byte stripping
* Bugfix: Clear channel-add interface on not-add-another
* Bugfix: Add status/name/MQTT LWT
* Bugfix: Channel deletion propagates over WS
* Bugfix: Show map location for all nodes on link, not 7-day-limited
* 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: Doc, changelog, and test improvements
* Misc: Add, and remove, package lock (sorry Windows users)
* Misc: Don't show mark all as read if not necessary
* Misc: Fix stale closures and misc. frontend perf/correctness improvements
* Misc: Add Windows startup notes
* Misc: E2E expansion + improvement
* Misc: Move around visualizer settings
## [2.4.0] - 2026-03-02
Feature: Add community MQTT reporting (e.g. LetsMesh.net)
Misc: Build scripts and library attribution
Misc: Add sign of life to E2E tests
* Feature: Add community MQTT reporting (e.g. LetsMesh.net)
* Misc: Build scripts and library attribution
* Misc: Add sign of life to E2E tests
## [2.3.0] - 2026-03-01
Feature: Click path description to reset to flood
Feature: Add MQTT publishing
Feature: Visualizer remembers settings
Bugfix: Fix prefetch usage
Bugfix: Fixed an issue where busy channels can result in double-display of incoming messages
Misc: Drop py3.12 requirement
Misc: Performance, documentation, test, and file structure optimizations
Misc: Add arrows between route nodes on contact info
Misc: Show repeater path/type in title bar
* Feature: Click path description to reset to flood
* Feature: Add MQTT publishing
* Feature: Visualizer remembers settings
* Bugfix: Fix prefetch usage
* Bugfix: Fixed an issue where busy channels can result in double-display of incoming messages
* Misc: Drop py3.12 requirement
* Misc: Performance, documentation, test, and file structure optimizations
* Misc: Add arrows between route nodes on contact info
* Misc: Show repeater path/type in title bar
## [2.2.0] - 2026-02-28
Feature: Track advert paths and use to disambiguate repeater identity in visualizer
Feature: Contact info pane
Feature: Overhaul repeater interface
Bugfix: Misc. frontend rendering + perf improvements
Bugfix: Better behavior around radio locking and autofetch/polling
Bugfix: Clear channel name field on new-channel modal tab change
Bugfix: Repeater inforbox can scroll
Bugfix: Better handling of historical DM encrypts
Bugfix: Handle errors if returned in prefetch phase
Misc: Radio event response failure is logged/surfaced better
Misc: Improve test coverage and remove dead code
Misc: Documentation and errata improvements
Misc: Database storage optimization
* Feature: Track advert paths and use to disambiguate repeater identity in visualizer
* Feature: Contact info pane
* Feature: Overhaul repeater interface
* Bugfix: Misc. frontend rendering + perf improvements
* Bugfix: Better behavior around radio locking and autofetch/polling
* Bugfix: Clear channel name field on new-channel modal tab change
* Bugfix: Repeater inforbox can scroll
* Bugfix: Better handling of historical DM encrypts
* Bugfix: Handle errors if returned in prefetch phase
* Misc: Radio event response failure is logged/surfaced better
* Misc: Improve test coverage and remove dead code
* Misc: Documentation and errata improvements
* Misc: Database storage optimization
## [2.1.0] - 2026-02-23
Feature: Add ability to remember last-used channel on load
Feature: Add `docker compose` support (thanks @suymur !)
Feature: Better-aligned favicon (lol)
Bugfix: Disable autocomplete on message field
Bugfix: Legacy hash restoration on page load
Bugfix: Align resend buttons in pathing modal
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: Improved repeater comms on busy meshes
Bugfix: Drain before autofetch from radio
Bugfix: Fix, or document exceptions to, sub-second resolution message failure
Bugfix: Improved handling of radio connection, disconnection, and connection-aliveness-status
Bugfix: Force server-side keystore update when radio key changes
Bugfix: Reduce WS churn for incoming message handling
Bugfix: Fix content type signalling for irrelevant endpoints
Bugfix: Handle stuck post-connect failure state
Misc: Documentation & version parsing improvements
Misc: Hide char counter on mobile for short messages
Misc: Typo fixes in docs and settings
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: Drop weird rounded bounding box for settings
Misc: Move resend buttons to pathing modal
Misc: Improved comments around database ownership on *nix systems
Misc: Move to SSoT for message dedupe on frontend
Misc: Move DM ack clearing to standard poll, and increase hold time between polling
Misc: Holistic testing overhaul
* Feature: Add ability to remember last-used channel on load
* Feature: Add `docker compose` support (thanks @suymur !)
* Feature: Better-aligned favicon (lol)
* Bugfix: Disable autocomplete on message field
* Bugfix: Legacy hash restoration on page load
* Bugfix: Align resend buttons in pathing modal
* 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: Improved repeater comms on busy meshes
* Bugfix: Drain before autofetch from radio
* Bugfix: Fix, or document exceptions to, sub-second resolution message failure
* Bugfix: Improved handling of radio connection, disconnection, and connection-aliveness-status
* Bugfix: Force server-side keystore update when radio key changes
* Bugfix: Reduce WS churn for incoming message handling
* Bugfix: Fix content type signalling for irrelevant endpoints
* Bugfix: Handle stuck post-connect failure state
* Misc: Documentation & version parsing improvements
* Misc: Hide char counter on mobile for short messages
* Misc: Typo fixes in docs and settings
* 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: Drop weird rounded bounding box for settings
* Misc: Move resend buttons to pathing modal
* Misc: Improved comments around database ownership on *nix systems
* Misc: Move to SSoT for message dedupe on frontend
* Misc: Move DM ack clearing to standard poll, and increase hold time between polling
* Misc: Holistic testing overhaul
## [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
Feature: Frontend UX + log overhaul
Bugfix: Use contact object from DB for broadcast rather than handrolling
Bugfix: Fix out of order path WS messages overwriting each other
Bugfix: Make broadcast timestamp match fallback logic used in storage code
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: Add missing radio operation locks in a few spots
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)
Misc: Visualizer layout refinement & option labels
* Feature: Frontend UX + log overhaul
* Bugfix: Use contact object from DB for broadcast rather than handrolling
* Bugfix: Fix out of order path WS messages overwriting each other
* Bugfix: Make broadcast timestamp match fallback logic used in storage code
* 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: Add missing radio operation locks in a few spots
* 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)
* Misc: Visualizer layout refinement & option labels
## [1.10.0] - 2026-02-16
Feature: Collapsible sidebar sections with per-section unread badge (thanks @rgregg !)
Feature: 3D mesh visualizer
Feature: Statistics pane
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!)
Bugfix: Fix top padding out outgoing message
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: 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: s/stopped/idle/ for room finder
* Feature: Collapsible sidebar sections with per-section unread badge (thanks @rgregg !)
* Feature: 3D mesh visualizer
* Feature: Statistics pane
* 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!)
* Bugfix: Fix top padding out outgoing message
* 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: 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: s/stopped/idle/ for room finder
## [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
Feature: Options dialog sucks less
Bugfix: Move tests to isolated memory DB
Bugfix: Mention case sensitivity
Bugfix: Stale header retention on settings page view
Bugfix: Non-isolated path writing
Bugfix: Nullable contact fields are now passed as real nulls
Bugfix: Look at all fields on message reconcile, not just text
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: Massive test and AGENTS.md overhauls and additions
* Feature: Options dialog sucks less
* Bugfix: Move tests to isolated memory DB
* Bugfix: Mention case sensitivity
* Bugfix: Stale header retention on settings page view
* Bugfix: Non-isolated path writing
* Bugfix: Nullable contact fields are now passed as real nulls
* Bugfix: Look at all fields on message reconcile, not just text
* 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: Massive test and AGENTS.md overhauls and additions
## [1.9.1] - 2026-02-10
Feature: Contacts and channels use keys, not names
Bugfix: Fix falsy casting of 0 in lat lon and timing data
Bugfix: Show message length in bytes, not chars
Bugfix: Fix phantom unread badges on focused convos
Misc: Bot invocation to async
Misc: Use full key, not prefix, where we can
* Feature: Contacts and channels use keys, not names
* Bugfix: Fix falsy casting of 0 in lat lon and timing data
* Bugfix: Show message length in bytes, not chars
* Bugfix: Fix phantom unread badges on focused convos
* Misc: Bot invocation to async
* Misc: Use full key, not prefix, where we can
## [1.9.0] - 2026-02-10
Feature: Favorited contacts are preferentially loaded onto the radio
Feature: Add recent-message caching for fast switching
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: Favorited contacts are preferentially loaded onto the radio
* Feature: Add recent-message caching for fast switching
* 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
Frontend: Better styling on echo + message path display
Bugfix: Prevent frontend static file serving path traversal vuln
Bugfix: Safer prefix-claiming for DMs we don't have the key for
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: App can boot and test without a frontend dir
Misc: Improve and consistent-ify (?) backend radio operation lock management
Misc: Frontend performance and safety enhancements
Misc: Move builds to non-bundled; usage requires building the Frontend
Misc: Update tests and agent docs
* Bugfix: Prevent frontend static file serving path traversal vuln
* Bugfix: Safer prefix-claiming for DMs we don't have the key for
* 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: App can boot and test without a frontend dir
* Misc: Improve and consistent-ify (?) backend radio operation lock management
* Misc: Frontend performance and safety enhancements
* Misc: Move builds to non-bundled; usage requires building the Frontend
* Misc: Update tests and agent docs
## [1.8.0] - 2026-02-07
Feature: Single hop ping
Feature: PWA viewport fixes(thanks @rgregg)
* Feature: Single hop ping
* Feature: PWA viewport fixes(thanks @rgregg)
Feature (?): No frontend distribution; build it yourself ;P
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: Better guarding around reconnection
Bugfix: Duplicate websocket connection fixes
Bugfix: Settings tab error cleanliness on tab swap
Bugfix: Fix path traversal vuln
* 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: Better guarding around reconnection
* Bugfix: Duplicate websocket connection fixes
* Bugfix: Settings tab error cleanliness on tab swap
* Bugfix: Fix path traversal vuln
UI: Swap visualizer legend ordering (yay prettier)
Misc: Perf and locking improvements
Misc: Always flood advertisements
Misc: Better packet dupe handling
Misc: Dead code cleanup, test improvements
* Misc: Perf and locking improvements
* Misc: Always flood advertisements
* Misc: Better packet dupe handling
* Misc: Dead code cleanup, test improvements
## [1.7.1] - 2026-02-03
Feature: Clickable hyperlinks
Bugfix: More consistent public key normalization
Bugfix: Use more reliable cursor paging
Bugfix: Fix null timestamp dedupe failure
Bugfix: More consistent prefix-based message claiming on key receipt
Misc: Bot can respond to its own messages
Misc: Additional tests
Misc: Remove unneeded message dedupe logic
Misc: Resync settings after radio settings mutation
* Feature: Clickable hyperlinks
* Bugfix: More consistent public key normalization
* Bugfix: Use more reliable cursor paging
* Bugfix: Fix null timestamp dedupe failure
* Bugfix: More consistent prefix-based message claiming on key receipt
* Misc: Bot can respond to its own messages
* Misc: Additional tests
* Misc: Remove unneeded message dedupe logic
* Misc: Resync settings after radio settings mutation
## [1.7.0] - 2026-01-27
Feature: Multi-bot functionality
Bugfix: Adjust bot code editor display and add line numbers
Bugfix: Fix clock filtering and contact lookup behavior bugs
Bugfix: Fix repeater message duplication issue
Bugfix: Correct outbound message timestamp assignment (affecting outgoing messages seen as incoming)
* Feature: Multi-bot functionality
* Bugfix: Adjust bot code editor display and add line numbers
* Bugfix: Fix clock filtering and contact lookup behavior bugs
* Bugfix: Fix repeater message duplication issue
* Bugfix: Correct outbound message timestamp assignment (affecting outgoing messages seen as incoming)
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
Feature: Visualizer: extract public key from AnonReq, add heuristic repeater disambiguation, add reset button, draggable nodes
Feature: Customizable advertising interval
Feature: In-app bot setup
Bugfix: Force contact onto radio before DM send
Misc: Remove unused code
* Feature: Visualizer: extract public key from AnonReq, add heuristic repeater disambiguation, add reset button, draggable nodes
* Feature: Customizable advertising interval
* Feature: In-app bot setup
* Bugfix: Force contact onto radio before DM send
* Misc: Remove unused code
## [1.5.0] - 2026-01-19
Feature: Network visualizer
* Feature: Network visualizer
## [1.4.1] - 2026-01-19
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: Add option to attempt historical DM decrypt on new-contact advertisement (disabled by default)
* Feature: Server-side preference management for favorites, read status, etc.
UI: More compact hop labelling
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: Misc. race conditions and websocket handling
* Bugfix: Reduce fetching cadence by loading all contact data at start to prevent fetches on advertise-driven update
## [1.4.0] - 2026-01-18
UI: Improve button layout for room searcher
UI: Improve favicon coloring
UI: Improve status bar button layout on small screen
Feature: Show multi-path hop display with distance estimates
Feature: Search rooms and contacts by key, not just name
Bugfix: Historical DM decryption now works as expected
Bugfix: Don't double-set active conversation after addition; wait for backend room name normalization
* Feature: Show multi-path hop display with distance estimates
* Feature: Search rooms and contacts by key, not just name
* Bugfix: Historical DM decryption now works as expected
* Bugfix: Don't double-set active conversation after addition; wait for backend room name normalization
## [1.3.1] - 2026-01-17
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
UI: Remove octothorpe from channel listing
## [1.3.0] - 2026-01-17
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: Drop repeater login wait time; vestigial from debugging a different issue
* 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: Drop repeater login wait time; vestigial from debugging a different issue
## [1.2.1] - 2026-01-17
@@ -439,27 +501,27 @@ Update: Update meshcore-hashtag-cracker to include sender-identification correct
## [1.2.0] - 2026-01-16
Feature: Add favorites
* Feature: Add favorites
## [1.1.0] - 2026-01-14
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: 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)
## [1.0.3] - 2026-01-13
Bugfix: Add missing test management packages
Improvement: Drop unnecessary repeater timeouts, and retain timeout for login only -- repeater ops are faster AND more reliable!
* Bugfix: Add missing test management packages
* Improvement: Drop unnecessary repeater timeouts, and retain timeout for login only -- repeater ops are faster AND more reliable!
## [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
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
Initial full release!
* Initial full release!
+2 -1
View File
@@ -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:
```bash
./scripts/all_quality.sh
./scripts/quality/all_quality.sh
```
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
cd tests/e2e
npm install
npx playwright test # headless
npx playwright test --headed # you can probably guess
```
+32 -1
View File
@@ -1,6 +1,6 @@
# 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
@@ -1625,6 +1625,37 @@ 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
<details>
+53 -35
View File
@@ -16,12 +16,6 @@ Backend server + browser interface for MeshCore mesh radio networks. Connect you
![Screenshot of the application's web interface](app_screenshot.png)
## 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
Most users should choose one of these paths:
@@ -95,65 +89,83 @@ Access the app at http://localhost:8000.
Source checkouts expect a normal frontend build in `frontend/dist`.
On Linux, if you want this installed as a persistent `systemd` service that starts on boot and restarts automatically on failure, run `bash scripts/install_service.sh` from the repo root.
> [!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`.
## Path 1.5: Use The Prebuilt Release Zip
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.
If you downloaded the release zip instead of cloning the repo, unpack it and run:
```bash
cd Remote-Terminal-for-MeshCore
uv sync
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
```
The release bundle includes `frontend/prebuilt`, so it does not require a local frontend build.
Alternatively, if you have already cloned the repo, you can fetch just the prebuilt frontend into your working tree without downloading the full release zip via `python3 scripts/fetch_prebuilt_frontend.py`.
> [!TIP]
> On Linux, you can also install RemoteTerm as a persistent `systemd` service that starts on boot and restarts automatically on failure:
>
> ```bash
> bash scripts/setup/install_service.sh
> ```
>
> For the full service workflow and post-install operations, see [README_ADVANCED.md](README_ADVANCED.md).
## Path 2: Docker
> **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
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
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
build: .
image: docker.io/jkingsman/remoteterm-meshcore:latest
```
with:
```yaml
image: jkingsman/remoteterm-meshcore:latest
build: .
```
Then run:
```bash
docker compose pull
docker compose up -d
sudo docker compose up -d --build
```
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:
```bash
docker compose down
sudo docker compose down
```
## Standard Environment Variables
@@ -165,7 +177,7 @@ Only one transport may be active at a time. If multiple are set, the server will
| `MESHCORE_SERIAL_PORT` | (auto-detect) | Serial port path |
| `MESHCORE_SERIAL_BAUDRATE` | 115200 | Serial baud rate |
| `MESHCORE_TCP_HOST` | | TCP host (mutually exclusive with serial/BLE) |
| `MESHCORE_TCP_PORT` | 4000 | TCP port |
| `MESHCORE_TCP_PORT` | 5000 | TCP port |
| `MESHCORE_BLE_ADDRESS` | | BLE device address (mutually exclusive with serial/TCP) |
| `MESHCORE_BLE_PIN` | | BLE PIN (required when BLE address is set) |
| `MESHCORE_LOG_LEVEL` | INFO | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
@@ -181,7 +193,7 @@ Common launch patterns:
MESHCORE_SERIAL_PORT=/dev/ttyUSB0 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
# TCP
MESHCORE_TCP_HOST=192.168.1.100 MESHCORE_TCP_PORT=4000 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
MESHCORE_TCP_HOST=192.168.1.100 MESHCORE_TCP_PORT=5000 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
# BLE
MESHCORE_BLE_ADDRESS=AA:BB:CC:DD:EE:FF MESHCORE_BLE_PIN=123456 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
@@ -201,3 +213,9 @@ If you enable Basic Auth, protect the app with HTTPS. HTTP Basic credentials are
- 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)
- 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`.
+1 -26
View File
@@ -46,39 +46,14 @@ Accept the browser warning, or use [mkcert](https://github.com/FiloSottile/mkcer
## Systemd Service
Two paths are available depending on your comfort level with Linux system administration.
### Simple install (recommended for most users)
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 scripts/install_service.sh
bash scripts/setup/install_service.sh
```
The script interactively asks which transport to use (serial auto-detect, serial with explicit port, TCP, or BLE), whether to build the frontend locally or download a prebuilt copy, whether to enable the bot system, and whether to set up HTTP Basic Auth. It handles dependency installation (`uv sync`), validates `node`/`npm` for local builds, adds your user to the `dialout` group if needed, writes the systemd unit file, and enables the service. After installation, normal operations work without any `sudo -u` gymnastics:
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.
```bash
# Update to latest and restart
cd /path/to/repo
git pull
uv sync
cd frontend && npm install && npm run build && cd ..
sudo systemctl restart remoteterm
# Refresh prebuilt frontend only (skips local build)
python3 scripts/fetch_prebuilt_frontend.py
sudo systemctl restart remoteterm
# View live logs
sudo journalctl -u remoteterm -f
# Service control
sudo systemctl start|stop|restart|status remoteterm
```
## Debug Logging And Bug Reports
If you're experiencing issues or opening a bug report, please start the backend with debug logging enabled. Debug mode provides a much more detailed breakdown of radio communication, packet processing, and other internal operations, which makes it significantly easier to diagnose problems.
+26 -10
View File
@@ -25,18 +25,22 @@ Keep it aligned with `app/` source files and router behavior.
app/
├── main.py # App startup/lifespan, router registration, static frontend mounting
├── 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
├── migrations.py # Schema migrations (SQLite user_version)
├── 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)
├── services/ # Shared orchestration/domain services
│ ├── messages.py # Shared message creation, dedup, ACK application
│ ├── message_send.py # Direct send, channel send, resend workflows
│ ├── 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
│ ├── contact_reconciliation.py # Prefix-claim, sender-key backfill, name-history wiring
│ ├── radio_lifecycle.py # Post-connect setup and reconnect/setup helpers
│ ├── 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.py # RadioManager transport/session state + lock management
├── radio_sync.py # Polling, sync, periodic advertisement loop
@@ -61,6 +65,8 @@ app/
├── messages.py
├── packets.py
├── read_state.py
├── rooms.py
├── server_control.py
├── settings.py
├── fanout.py
├── repeaters.py
@@ -174,6 +180,7 @@ app/
- `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/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/reboot`
- `POST /radio/reconnect`
@@ -198,6 +205,10 @@ app/
- `POST /contacts/{public_key}/repeater/radio-settings`
- `POST /contacts/{public_key}/repeater/advert-intervals`
- `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
- `GET /channels`
@@ -216,6 +227,7 @@ app/
### Packets
- `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/maintenance`
@@ -236,6 +248,7 @@ app/
- `POST /fanout` — create new fanout config
- `PATCH /fanout/{id}` — update fanout config (triggers module reload)
- `DELETE /fanout/{id}` — delete fanout config (stops module)
- `POST /fanout/bots/disable-until-restart` — stop bot modules and keep bots disabled until restart
### Statistics
- `GET /statistics` — aggregated mesh network stats (entity counts, message/packet splits, activity windows, busiest channels)
@@ -322,9 +335,11 @@ tests/
├── conftest.py # Shared fixtures
├── test_ack_tracking_wiring.py # DM ACK tracking extraction and wiring
├── 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_channels_router.py # Channels router endpoints
├── 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_contact_reconciliation_service.py # Prefix/contact reconciliation service helpers
├── test_contacts_router.py # Contacts router endpoints
@@ -332,40 +347,41 @@ tests/
├── test_disable_bots.py # MESHCORE_DISABLE_BOTS=true feature
├── test_echo_dedup.py # Echo/repeat deduplication (incl. concurrent)
├── 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_integration.py # Fanout integration tests
├── test_event_handlers.py # ACK tracking, event registration, cleanup
├── test_frontend_static.py # Frontend static file serving
├── test_health_mqtt_status.py # Health endpoint MQTT status field
├── test_http_quality.py # Cache-control / gzip / basic-auth HTTP quality checks
├── test_key_normalization.py # Public key normalization
├── 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_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_messages_search.py # Message search, around, forward pagination
├── test_migrations.py # Schema migration system
├── test_packet_pipeline.py # End-to-end packet processing
├── 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_commands_service.py # Radio config/private-key service workflows
├── 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_router.py # Radio router endpoints
├── test_radio_runtime_service.py # radio_runtime seam behavior and helpers
├── 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_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_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_send_messages.py # Outgoing messages, bot triggers, concurrent sends
├── test_settings_router.py # Settings endpoints, advert validation
├── test_statistics.py # Statistics aggregation
├── test_main_startup.py # App startup and lifespan
├── test_path_utils.py # Path hex rendering helpers
├── test_version_info.py # Version/build metadata resolution
├── test_websocket.py # WS manager broadcast/cleanup
└── test_websocket_route.py # WS endpoint lifecycle
```
+2 -1
View File
@@ -14,7 +14,7 @@ class Settings(BaseSettings):
serial_port: str = "" # Empty string triggers auto-detection
serial_baudrate: int = 115200
tcp_host: str = ""
tcp_port: int = 4000
tcp_port: int = 5000
ble_address: str = ""
ble_pin: str = ""
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
@@ -26,6 +26,7 @@ class Settings(BaseSettings):
default=False,
validation_alias="__CLOWNTOWN_DO_CLOCK_WRAPAROUND",
)
skip_post_connect_sync: bool = False
basic_auth_username: str = ""
basic_auth_password: str = ""
+17 -3
View File
@@ -66,7 +66,7 @@ CREATE TABLE IF NOT EXISTS raw_packets (
data BLOB NOT NULL,
message_id INTEGER,
payload_hash BLOB,
FOREIGN KEY (message_id) REFERENCES messages(id)
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS contact_advert_paths (
@@ -78,7 +78,7 @@ CREATE TABLE IF NOT EXISTS contact_advert_paths (
last_seen INTEGER NOT NULL,
heard_count INTEGER NOT NULL DEFAULT 1,
UNIQUE(public_key, path_hex, path_len),
FOREIGN KEY (public_key) REFERENCES contacts(public_key)
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS contact_name_history (
@@ -88,7 +88,7 @@ CREATE TABLE IF NOT EXISTS contact_name_history (
first_seen INTEGER NOT NULL,
last_seen INTEGER NOT NULL,
UNIQUE(public_key, name),
FOREIGN KEY (public_key) REFERENCES contacts(public_key)
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_messages_received ON messages(received_at);
@@ -96,8 +96,12 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_dedup_null_safe
ON messages(type, conversation_key, text, COALESCE(sender_timestamp, 0))
WHERE type = 'CHAN';
CREATE INDEX IF NOT EXISTS idx_raw_packets_message_id ON raw_packets(message_id);
CREATE INDEX IF NOT EXISTS idx_raw_packets_timestamp ON raw_packets(timestamp);
CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_packets_payload_hash ON raw_packets(payload_hash);
CREATE INDEX IF NOT EXISTS idx_contacts_on_radio ON contacts(on_radio);
CREATE INDEX IF NOT EXISTS idx_contacts_type_last_seen ON contacts(type, last_seen);
CREATE INDEX IF NOT EXISTS idx_messages_type_received_conversation
ON messages(type, received_at, conversation_key);
-- idx_messages_sender_key is created by migration 25 (after adding the sender_key column)
-- idx_messages_incoming_priv_dedup is created by migration 44 after legacy rows are reconciled
CREATE INDEX IF NOT EXISTS idx_contact_advert_paths_recent
@@ -128,6 +132,12 @@ class Database:
# migration 20 handles the one-time VACUUM to restructure the file.
await self._connection.execute("PRAGMA auto_vacuum = INCREMENTAL")
# Foreign key enforcement: must be set per-connection (not persisted).
# Disabled during schema init and migrations to avoid issues with
# historical table-rebuild migrations that may temporarily violate
# constraints, then re-enabled for all subsequent application queries.
await self._connection.execute("PRAGMA foreign_keys = OFF")
await self._connection.executescript(SCHEMA)
await self._connection.commit()
logger.debug("Database schema initialized")
@@ -137,6 +147,10 @@ class Database:
await run_migrations(self._connection)
# Enable FK enforcement for all application queries from this point on.
await self._connection.execute("PRAGMA foreign_keys = ON")
logger.debug("Foreign key enforcement enabled")
async def disconnect(self) -> None:
if self._connection:
await self._connection.close()
-2
View File
@@ -30,8 +30,6 @@ logger = logging.getLogger(__name__)
# Track active subscriptions so we can unsubscribe before re-registering
# This prevents handler duplication after reconnects
_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:
+3 -4
View File
@@ -95,7 +95,6 @@ class AppriseModule(FanoutModule):
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
super().__init__(config_id, config, name=name)
self._last_error: str | None = None
async def on_message(self, data: dict) -> None:
# Skip outgoing messages — only notify on incoming
@@ -114,17 +113,17 @@ class AppriseModule(FanoutModule):
success = await asyncio.to_thread(
_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:
logger.warning("Apprise notification failed for module %s", self.config_id)
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)
@property
def status(self) -> str:
if not self.config.get("urls", "").strip():
return "disconnected"
if self._last_error:
if self.last_error:
return "error"
return "connected"
+21
View File
@@ -3,6 +3,14 @@
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:
"""Base class for all fanout integrations.
@@ -16,6 +24,7 @@ class FanoutModule:
self.config_id = config_id
self.config = config
self.name = name
self._last_error: str | None = None
async def start(self) -> None:
"""Start the module (e.g. connect to broker). Override for persistent connections."""
@@ -34,6 +43,18 @@ class FanoutModule:
"""Return 'connected', 'disconnected', or 'error'."""
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.
+4 -3
View File
@@ -175,11 +175,12 @@ def _format_raw_packet(data: dict[str, Any], device_name: str, public_key_hex: s
current_time = datetime.now()
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")
rssi_val = data.get("rssi")
snr = str(snr_val) if snr_val is not None else "Unknown"
rssi = str(rssi_val) if rssi_val is not None else "Unknown"
snr: float | str = float(snr_val) if snr_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)
+63 -6
View File
@@ -15,6 +15,14 @@ _DISPATCH_TIMEOUT_SECONDS = 30.0
_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:
"""Lazily populate the type registry to avoid circular imports."""
if _MODULE_TYPES:
@@ -85,6 +93,23 @@ class FanoutManager:
self._modules: dict[str, tuple[FanoutModule, dict]] = {} # id -> (module, scope)
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."""
@@ -134,11 +159,13 @@ class FanoutManager:
module = cls(config_id, config_blob, name=cfg.get("name", ""))
await module.start()
self._modules[config_id] = (module, scope)
self._clear_module_error(config_id)
logger.info(
"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)
self._set_module_error(config_id, _format_error_detail(exc))
async def reload_config(self, config_id: str) -> None:
"""Stop old module (if any) and start updated config."""
@@ -162,6 +189,7 @@ class FanoutManager:
await module.stop()
except Exception:
logger.exception("Error stopping fanout module %s", config_id)
self._clear_module_error(config_id)
async def _dispatch_matching(
self,
@@ -191,7 +219,10 @@ class FanoutManager:
try:
handler = getattr(module, handler_name)
await asyncio.wait_for(handler(data), timeout=_DISPATCH_TIMEOUT_SECONDS)
self._clear_module_error(config_id)
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(
"Fanout %s %s timed out after %.1fs; restarting module",
config_id,
@@ -199,7 +230,8 @@ class FanoutManager:
_DISPATCH_TIMEOUT_SECONDS,
)
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)
async def _restart_module(self, config_id: str, module: FanoutModule) -> None:
@@ -215,6 +247,10 @@ class FanoutManager:
except Exception:
logger.exception("Failed to restart timed-out fanout module %s", config_id)
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:
"""Dispatch a decoded message to modules whose scope matches."""
@@ -243,18 +279,39 @@ class FanoutManager:
logger.exception("Error stopping fanout module %s", config_id)
self._modules.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."""
from app.repository.fanout import _configs_cache
result: dict[str, dict[str, str]] = {}
for config_id, (module, _) in self._modules.items():
result: dict[str, dict[str, str | None]] = {}
all_ids = set(_configs_cache) | set(self._modules) | set(self._module_errors)
for config_id in all_ids:
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] = {
"name": info.get("name", config_id),
"type": info.get("type", "unknown"),
"status": module.status,
"status": status,
"last_error": last_error,
}
return result
+8 -6
View File
@@ -106,12 +106,14 @@ class MapUploadModule(FanoutModule):
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
super().__init__(config_id, config, name=name)
self._client: httpx.AsyncClient | None = None
self._last_error: str | 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))
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(15.0),
follow_redirects=True,
)
self._last_error = None
self._seen.clear()
@@ -292,7 +294,7 @@ class MapUploadModule(FanoutModule):
)
resp.raise_for_status()
self._seen[pubkey] = advert_timestamp
self._last_error = None
self._set_last_error(None)
logger.info(
"MapUpload: uploaded %s (%s) → HTTP %d",
pubkey[:12],
@@ -300,7 +302,7 @@ class MapUploadModule(FanoutModule):
resp.status_code,
)
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(
"MapUpload: server returned %d for %s: %s",
exc.response.status_code,
@@ -308,13 +310,13 @@ class MapUploadModule(FanoutModule):
exc.response.text[:200],
)
except httpx.RequestError as exc:
self._last_error = str(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:
if self.last_error:
return "error"
return "connected"
+19
View File
@@ -23,6 +23,14 @@ logger = logging.getLogger(__name__)
_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:
"""Push updated health (including MQTT status) to all WS clients."""
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.connected: bool = False
self.integration_name: str = ""
self._last_error: str | None = None
def set_integration_name(self, name: str) -> None:
"""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 self._log_prefix
@property
def last_error(self) -> str | None:
"""Return the most recent retained connection/publish error."""
return self._last_error
# ── Lifecycle ──────────────────────────────────────────────────────
async def start(self, settings: object) -> None:
"""Start the background connection loop."""
self._settings = settings
self._last_error = None
self._settings_version += 1
self._version_event.set()
if self._task is None or self._task.done():
@@ -87,6 +102,7 @@ class BaseMqttPublisher(ABC):
self._task = None
self._client = None
self.connected = False
self._last_error = None
async def restart(self, settings: object) -> None:
"""Called when settings change — stop + start."""
@@ -109,6 +125,7 @@ class BaseMqttPublisher(ABC):
exc_info=True,
)
self.connected = False
self._last_error = _format_error_detail(e)
# Wake the connection loop so it exits the wait and reconnects
self._settings_version += 1
self._version_event.set()
@@ -198,6 +215,7 @@ class BaseMqttPublisher(ABC):
async with aiomqtt.Client(**client_kwargs) as client:
self._client = client
self.connected = True
self._last_error = None
backoff = _BACKOFF_MIN
title, detail = self._on_connected(settings)
@@ -232,6 +250,7 @@ class BaseMqttPublisher(ABC):
except Exception as e:
self.connected = False
self._client = None
self._last_error = _format_error_detail(e)
title, detail = self._on_error()
broadcast_error(title, detail)
+6
View File
@@ -98,9 +98,15 @@ class MqttCommunityModule(FanoutModule):
@property
def status(self) -> str:
if self._publisher._is_configured():
if self._publisher.last_error:
return "error"
return "connected" if self._publisher.connected else "disconnected"
return "disconnected"
@property
def last_error(self) -> str | None:
return self._publisher.last_error
async def _publish_community_packet(
publisher: CommunityMqttPublisher,
+6
View File
@@ -59,4 +59,10 @@ class MqttPrivateModule(FanoutModule):
def status(self) -> str:
if not self.config.get("broker_host"):
return "disconnected"
if self._publisher.last_error:
return "error"
return "connected" if self._publisher.connected else "disconnected"
@property
def last_error(self) -> str | None:
return self._publisher.last_error
+4 -5
View File
@@ -84,7 +84,6 @@ class SqsModule(FanoutModule):
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
super().__init__(config_id, config, name=name)
self._client = None
self._last_error: str | None = None
async def start(self) -> None:
kwargs: dict[str, str] = {}
@@ -147,18 +146,18 @@ class SqsModule(FanoutModule):
try:
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:
self._last_error = str(exc)
self._set_last_error(str(exc))
logger.warning("SQS %s send error: %s", self.config_id, 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)
@property
def status(self) -> str:
if not str(self.config.get("queue_url", "")).strip():
return "disconnected"
if self._last_error:
if self.last_error:
return "error"
return "connected"
+4 -5
View File
@@ -20,7 +20,6 @@ class WebhookModule(FanoutModule):
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
super().__init__(config_id, config, name=name)
self._client: httpx.AsyncClient | None = None
self._last_error: str | None = None
async def start(self) -> None:
self._client = httpx.AsyncClient(timeout=httpx.Timeout(10.0))
@@ -62,9 +61,9 @@ class WebhookModule(FanoutModule):
try:
resp = await self._client.request(method, url, content=body_bytes, headers=headers)
resp.raise_for_status()
self._last_error = None
self._set_last_error(None)
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(
"Webhook %s returned %s for %s",
self.config_id,
@@ -72,13 +71,13 @@ class WebhookModule(FanoutModule):
url,
)
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)
@property
def status(self) -> str:
if not self.config.get("url"):
return "disconnected"
if self._last_error:
if self.last_error:
return "error"
return "connected"
+3
View File
@@ -39,6 +39,7 @@ from app.routers import (
ws,
)
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.version_info import get_app_build_info
@@ -70,6 +71,7 @@ async def lifespan(app: FastAPI):
from app.radio_sync import ensure_default_channels
await ensure_default_channels()
await start_noise_floor_sampling()
# Always start connection monitor (even if initial connection failed)
await radio_manager.start_connection_monitor()
@@ -98,6 +100,7 @@ async def lifespan(app: FastAPI):
await radio_manager.stop_connection_monitor()
await stop_background_contact_reconciliation()
await stop_message_polling()
await stop_noise_floor_sampling()
await stop_periodic_advert()
await stop_periodic_sync()
if radio_manager.meshcore:
+262 -2
View File
@@ -360,6 +360,35 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
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 + tracking opt-in column
if version < 50:
logger.info("Applying migration 50: repeater telemetry history")
await _migrate_050_repeater_telemetry_history(conn)
await set_version(conn, 50)
applied += 1
if applied > 0:
logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
@@ -822,7 +851,7 @@ async def _migrate_009_create_app_settings_table(conn: aiosqlite.Connection) ->
id INTEGER PRIMARY KEY CHECK (id = 1),
max_radio_contacts INTEGER DEFAULT 200,
favorites TEXT DEFAULT '[]',
auto_decrypt_dm_on_advert INTEGER DEFAULT 0,
auto_decrypt_dm_on_advert INTEGER DEFAULT 1,
sidebar_sort_order TEXT DEFAULT 'recent',
last_message_times TEXT DEFAULT '{}',
preferences_migrated INTEGER DEFAULT 0
@@ -834,7 +863,7 @@ async def _migrate_009_create_app_settings_table(conn: aiosqlite.Connection) ->
await conn.execute(
"""
INSERT OR IGNORE INTO app_settings (id, max_radio_contacts, favorites, auto_decrypt_dm_on_advert, sidebar_sort_order, last_message_times, preferences_migrated)
VALUES (1, 200, '[]', 0, 'recent', '{}', 0)
VALUES (1, 200, '[]', 1, 'recent', '{}', 0)
"""
)
@@ -2868,3 +2897,234 @@ async def _migrate_046_cleanup_orphaned_contact_child_rows(conn: aiosqlite.Conne
)
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."""
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS repeater_telemetry_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
timestamp INTEGER NOT NULL,
data TEXT NOT NULL,
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
)
"""
)
await conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_repeater_telemetry_pk_ts
ON repeater_telemetry_history (public_key, timestamp)
"""
)
await conn.commit()
+95 -1
View File
@@ -530,6 +530,9 @@ class RepeaterStatusResponse(BaseModel):
flood_dups: int = Field(description="Duplicate flood packets")
direct_dups: int = Field(description="Duplicate direct packets")
full_events: int = Field(description="Full event queue count")
telemetry_history: list["TelemetryHistoryEntry"] = Field(
default_factory=list, description="Recent telemetry history snapshots"
)
class RepeaterNodeInfoResponse(BaseModel):
@@ -628,6 +631,59 @@ class TraceResponse(BaseModel):
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):
"""One resolved route returned by contact path discovery."""
@@ -681,6 +737,10 @@ class RadioDiscoveryResult(BaseModel):
"""One mesh node heard during a discovery sweep."""
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")
heard_count: int = Field(default=1, description="How many responses were heard from this node")
local_snr: float | None = Field(
@@ -748,7 +808,7 @@ class AppSettings(BaseModel):
default_factory=list, description="List of favorited conversations"
)
auto_decrypt_dm_on_advert: bool = Field(
default=False,
default=True,
description="Whether to attempt historical DM decryption on new contact advertisement",
)
sidebar_sort_order: Literal["recent", "alpha"] = Field(
@@ -783,6 +843,13 @@ class AppSettings(BaseModel):
default_factory=list,
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):
@@ -820,6 +887,27 @@ class PathHashWidthStats(BaseModel):
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):
busiest_channels_24h: list[BusyChannel]
contact_count: int
@@ -835,3 +923,9 @@ class StatisticsResponse(BaseModel):
repeaters_heard: ContactActivityCounts
known_channels_active: ContactActivityCounts
path_hash_width_24h: PathHashWidthStats
noise_floor_24h: NoiseFloorHistoryStats
class TelemetryHistoryEntry(BaseModel):
timestamp: int
data: dict
+40 -19
View File
@@ -122,20 +122,20 @@ async def run_historical_dm_decryption(
"""Background task to decrypt historical DM packets with contact's key."""
from app.websocket import broadcast_success
packets = await RawPacketRepository.get_undecrypted_text_messages()
total = len(packets)
total = 0
decrypted_count = 0
if total == 0:
logger.info("No undecrypted TEXT_MESSAGE packets to process")
return
logger.info("Starting historical DM decryption of %d TEXT_MESSAGE packets", total)
logger.info("Starting historical DM decryption scan for undecrypted TEXT_MESSAGE packets")
# Derive our public key from the private key
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
# 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,
@@ -187,6 +187,10 @@ async def run_historical_dm_decryption(
if msg_id is not None:
decrypted_count += 1
if total == 0:
logger.info("No undecrypted TEXT_MESSAGE packets to process")
return
logger.info(
"Historical DM decryption complete: %d/%d packets decrypted",
decrypted_count,
@@ -264,9 +268,10 @@ async def process_raw_packet(
This is the main entry point for all incoming RF packets.
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
the frontend (for the real-time packet feed) but skip decryption processing
since the original packet was already processed.
a duplicate payload (same payload, different path), we still broadcast it to
the frontend for realtime packet-feed fidelity. Some payload types are also
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())
observation_id = next(_raw_observation_counter)
@@ -457,14 +462,19 @@ async def _process_advertisement(
advert.device_role if advert.device_role > 0 else (existing.type if existing else 0)
)
# Keep recent unique advert paths for all contacts.
await ContactAdvertPathRepository.record_observation(
public_key=advert.public_key.lower(),
path_hex=new_path_hex,
timestamp=timestamp,
max_paths=10,
hop_count=new_path_len,
)
# Check discovery_blocked_types: skip new contacts whose type is blocked.
# Existing contacts are always updated (location, name, last_seen, etc.).
if existing is None and contact_type > 0:
from app.repository import AppSettingsRepository
settings = await AppSettingsRepository.get()
if contact_type in settings.discovery_blocked_types:
logger.debug(
"Skipping new contact %s: type %d is in discovery_blocked_types",
advert.public_key[:12],
contact_type,
)
return
contact_upsert = ContactUpsert(
public_key=advert.public_key.lower(),
@@ -477,7 +487,18 @@ async def _process_advertisement(
first_seen=timestamp, # COALESCE in upsert preserves existing value
)
# Upsert the contact BEFORE recording advert paths so the parent row
# exists when foreign key enforcement is enabled.
await ContactRepository.upsert(contact_upsert)
# Keep recent unique advert paths for all contacts.
await ContactAdvertPathRepository.record_observation(
public_key=advert.public_key.lower(),
path_hex=new_path_hex,
timestamp=timestamp,
max_paths=10,
hop_count=new_path_len,
)
promoted_keys = await promote_prefix_contacts_for_contact(
public_key=advert.public_key,
log=logger,
+53 -6
View File
@@ -20,7 +20,7 @@ from meshcore import EventType, MeshCore
from app.channel_constants import PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME
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.radio import RadioOperationBusyError
from app.repository import (
@@ -29,7 +29,10 @@ from app.repository import (
ChannelRepository,
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.radio_runtime import radio_runtime as radio_manager
from app.websocket import broadcast_error, broadcast_event
@@ -63,13 +66,25 @@ async def _reconcile_contact_messages_background(
public_key: str,
contact_name: str | None,
) -> None:
"""Run contact/message reconciliation outside the radio critical path."""
"""Run prefix promotion and contact/message reconciliation outside the radio critical path."""
try:
promoted_keys = await promote_prefix_contacts_for_contact(
public_key=public_key,
log=logger,
)
await reconcile_contact_messages(
public_key=public_key,
contact_name=contact_name,
log=logger,
)
if promoted_keys:
contact = await ContactRepository.get_by_key(public_key.lower())
if contact is not None:
for old_key in promoted_keys:
broadcast_event(
"contact_resolved",
{"previous_public_key": old_key, "contact": contact.model_dump()},
)
except Exception as exc:
logger.warning(
"Background contact reconciliation failed for %s: %s",
@@ -179,6 +194,22 @@ RADIO_CONTACT_REFILL_RATIO = 0.80
RADIO_CONTACT_FULL_SYNC_RATIO = 0.95
def _effective_radio_capacity(configured: int) -> int:
"""Return the effective radio contact capacity.
Uses the lower of the user-configured ``max_radio_contacts`` and the
hardware limit reported by the radio at connect time. The existing
80% refill ratio already reserves headroom for the radio to
organically add contacts it hears via adverts, so no additional
reduction is applied here.
"""
capacity = max(1, configured)
hw_limit = radio_manager.max_contacts
if hw_limit is not None:
capacity = min(capacity, hw_limit)
return max(1, capacity)
def _compute_radio_contact_limits(max_contacts: int) -> tuple[int, int]:
"""Return (refill_target, full_sync_trigger) for the configured capacity."""
capacity = max(1, max_contacts)
@@ -193,7 +224,7 @@ def _compute_radio_contact_limits(max_contacts: int) -> tuple[int, int]:
async def should_run_full_periodic_sync(mc: MeshCore) -> bool:
"""Check current radio occupancy and decide whether to offload/reload."""
app_settings = await AppSettingsRepository.get()
capacity = app_settings.max_radio_contacts
capacity = _effective_radio_capacity(app_settings.max_radio_contacts)
refill_target, full_sync_trigger = _compute_radio_contact_limits(capacity)
result = await mc.commands.get_contacts()
@@ -379,6 +410,14 @@ async def _resolve_channel_for_pending_message(
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:
"""Persist a CHANNEL_MSG_RECV event pulled via get_msg()."""
channel_idx = payload.get("channel_idx")
@@ -403,7 +442,8 @@ async def _store_pending_channel_message(mc: MeshCore, payload: dict) -> None:
return
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", ""))
await create_fallback_channel_message(
@@ -488,6 +528,8 @@ async def drain_pending_messages(mc: MeshCore) -> int:
elif result.type in (EventType.CONTACT_MSG_RECV, EventType.CHANNEL_MSG_RECV):
if result.type == EventType.CHANNEL_MSG_RECV:
await _store_pending_channel_message(mc, result.payload)
elif result.type == EventType.CONTACT_MSG_RECV:
await _store_pending_direct_message(result)
count += 1
# Small delay between fetches
@@ -525,6 +567,8 @@ async def poll_for_messages(mc: MeshCore) -> int:
elif result.type in (EventType.CONTACT_MSG_RECV, EventType.CHANNEL_MSG_RECV):
if result.type == EventType.CHANNEL_MSG_RECV:
await _store_pending_channel_message(mc, result.payload)
elif result.type == EventType.CONTACT_MSG_RECV:
await _store_pending_direct_message(result)
count += 1
# If we got a message, there might be more - drain them
count += await drain_pending_messages(mc)
@@ -1018,6 +1062,7 @@ _last_contact_sync: float = 0.0
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:
@@ -1227,6 +1272,8 @@ async def _reconcile_radio_contacts_in_background(
)
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:
@@ -1285,7 +1332,7 @@ async def stop_background_contact_reconciliation() -> None:
async def get_contacts_selected_for_radio_sync() -> list[Contact]:
"""Return the contacts that would be loaded onto the radio right now."""
app_settings = await AppSettingsRepository.get()
max_contacts = app_settings.max_radio_contacts
max_contacts = _effective_radio_capacity(app_settings.max_radio_contacts)
refill_target, _full_sync_trigger = _compute_radio_contact_limits(max_contacts)
selected_contacts: list[Contact] = []
selected_keys: set[str] = set()
+2
View File
@@ -8,6 +8,7 @@ from app.repository.contacts import (
from app.repository.fanout import FanoutConfigRepository
from app.repository.messages import MessageRepository
from app.repository.raw_packets import RawPacketRepository
from app.repository.repeater_telemetry import RepeaterTelemetryRepository
from app.repository.settings import AppSettingsRepository, StatisticsRepository
__all__ = [
@@ -20,5 +21,6 @@ __all__ = [
"FanoutConfigRepository",
"MessageRepository",
"RawPacketRepository",
"RepeaterTelemetryRepository",
"StatisticsRepository",
]
+63 -51
View File
@@ -1,3 +1,4 @@
import logging
import time
from collections.abc import Mapping
from typing import Any
@@ -12,6 +13,8 @@ from app.models import (
)
from app.path_utils import first_hop_hex, normalize_contact_route, normalize_route_override
logger = logging.getLogger(__name__)
class AmbiguousPublicKeyPrefixError(ValueError):
"""Raised when a public key prefix matches multiple contacts."""
@@ -484,7 +487,6 @@ class ContactRepository:
return []
promoted_keys: list[str] = []
full_exists = await ContactRepository.get_by_key(normalized_full_key) is not None
for row in rows:
old_key = row["public_key"]
@@ -501,60 +503,70 @@ class ContactRepository:
(old_key,),
)
match_row = await match_cursor.fetchone()
if (match_row["match_count"] if match_row is not None else 0) != 1:
match_count = match_row["match_count"] if match_row is not None else 0
if match_count != 1:
logger.warning(
"Skipping prefix promotion for %s: %d full-key contacts match (expected 1)",
old_key,
match_count,
)
continue
await migrate_child_rows(old_key, normalized_full_key)
if full_exists:
await db.conn.execute(
"""
UPDATE contacts
SET last_seen = CASE
WHEN contacts.last_seen IS NULL THEN ?
WHEN ? IS NULL THEN contacts.last_seen
WHEN ? > contacts.last_seen THEN ?
ELSE contacts.last_seen
END,
last_contacted = CASE
WHEN contacts.last_contacted IS NULL THEN ?
WHEN ? IS NULL THEN contacts.last_contacted
WHEN ? > contacts.last_contacted THEN ?
ELSE contacts.last_contacted
END,
first_seen = CASE
WHEN contacts.first_seen IS NULL THEN ?
WHEN ? IS NULL THEN contacts.first_seen
WHEN ? < contacts.first_seen THEN ?
ELSE contacts.first_seen
END,
last_read_at = COALESCE(contacts.last_read_at, ?)
WHERE public_key = ?
""",
(
row["last_seen"],
row["last_seen"],
row["last_seen"],
row["last_seen"],
row["last_contacted"],
row["last_contacted"],
row["last_contacted"],
row["last_contacted"],
row["first_seen"],
row["first_seen"],
row["first_seen"],
row["first_seen"],
row["last_read_at"],
normalized_full_key,
),
)
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (old_key,))
else:
await db.conn.execute(
"UPDATE contacts SET public_key = ? WHERE public_key = ?",
(normalized_full_key, old_key),
)
full_exists = True
# Merge timestamp metadata from the old prefix contact into the
# full-key contact (which all callers guarantee already exists),
# then delete the prefix placeholder.
await db.conn.execute(
"""
UPDATE contacts
SET last_seen = CASE
WHEN contacts.last_seen IS NULL THEN ?
WHEN ? IS NULL THEN contacts.last_seen
WHEN ? > contacts.last_seen THEN ?
ELSE contacts.last_seen
END,
last_contacted = CASE
WHEN contacts.last_contacted IS NULL THEN ?
WHEN ? IS NULL THEN contacts.last_contacted
WHEN ? > contacts.last_contacted THEN ?
ELSE contacts.last_contacted
END,
first_seen = CASE
WHEN contacts.first_seen IS NULL THEN ?
WHEN ? IS NULL THEN contacts.first_seen
WHEN ? < contacts.first_seen THEN ?
ELSE contacts.first_seen
END,
last_read_at = CASE
WHEN contacts.last_read_at IS NULL THEN ?
WHEN ? IS NULL THEN contacts.last_read_at
WHEN ? > contacts.last_read_at THEN ?
ELSE contacts.last_read_at
END
WHERE public_key = ?
""",
(
row["last_seen"],
row["last_seen"],
row["last_seen"],
row["last_seen"],
row["last_contacted"],
row["last_contacted"],
row["last_contacted"],
row["last_contacted"],
row["first_seen"],
row["first_seen"],
row["first_seen"],
row["first_seen"],
row["last_read_at"],
row["last_read_at"],
row["last_read_at"],
row["last_read_at"],
normalized_full_key,
),
)
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (old_key,))
promoted_keys.append(old_key)
+9 -2
View File
@@ -158,7 +158,11 @@ class MessageRepository:
"""
lower_key = full_key.lower()
cursor = await db.conn.execute(
"""UPDATE messages SET conversation_key = ?
"""UPDATE messages SET conversation_key = ?,
sender_key = CASE
WHEN sender_key IS NOT NULL AND length(sender_key) < 64
AND ? LIKE sender_key || '%'
THEN ? ELSE sender_key END
WHERE type = 'PRIV' AND length(conversation_key) < 64
AND ? LIKE conversation_key || '%'
AND (
@@ -166,7 +170,7 @@ class MessageRepository:
WHERE length(public_key) = 64
AND public_key LIKE messages.conversation_key || '%'
) = 1""",
(lower_key, lower_key),
(lower_key, lower_key, lower_key, lower_key),
)
await db.conn.commit()
return cursor.rowcount
@@ -572,6 +576,9 @@ class MessageRepository:
@staticmethod
async def delete_by_id(message_id: int) -> None:
"""Delete a message row by ID."""
await db.conn.execute(
"UPDATE raw_packets SET message_id = NULL WHERE message_id = ?", (message_id,)
)
await db.conn.execute("DELETE FROM messages WHERE id = ?", (message_id,))
await db.conn.commit()
+38 -14
View File
@@ -1,6 +1,7 @@
import logging
import sqlite3
import time
from collections.abc import AsyncIterator
from hashlib import sha256
from app.database import db
@@ -8,6 +9,8 @@ from app.decoder import PayloadType, extract_payload, get_packet_payload_type
logger = logging.getLogger(__name__)
UNDECRYPTED_PACKET_BATCH_SIZE = 500
class RawPacketRepository:
@staticmethod
@@ -100,6 +103,40 @@ class RawPacketRepository:
rows = await cursor.fetchall()
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
async def mark_decrypted(packet_id: int, message_id: int) -> None:
"""Link a raw packet to its decrypted message."""
@@ -158,17 +195,4 @@ class RawPacketRepository:
Filters raw packets to only include those with PayloadType.TEXT_MESSAGE (0x02).
These are direct messages that can be decrypted with contact ECDH keys.
"""
cursor = await db.conn.execute(
"SELECT id, data, timestamp FROM raw_packets WHERE message_id IS NULL ORDER BY timestamp ASC"
)
rows = await cursor.fetchall()
# 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
return [packet async for packet in RawPacketRepository.stream_undecrypted_text_messages()]
+75
View File
@@ -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
]
+71 -38
View File
@@ -12,6 +12,7 @@ logger = logging.getLogger(__name__)
SECONDS_1H = 3600
SECONDS_24H = 86400
SECONDS_7D = 604800
RAW_PACKET_STATS_BATCH_SIZE = 500
class AppSettingsRepository:
@@ -28,7 +29,7 @@ class AppSettingsRepository:
SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert,
sidebar_sort_order, last_message_times, preferences_migrated,
advert_interval, last_advert_time, flood_scope,
blocked_keys, blocked_names
blocked_keys, blocked_names, discovery_blocked_types
FROM app_settings WHERE id = 1
"""
)
@@ -80,6 +81,14 @@ class AppSettingsRepository:
except (json.JSONDecodeError, TypeError):
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)
sort_order = row["sidebar_sort_order"]
if sort_order not in ("recent", "alpha"):
@@ -97,6 +106,7 @@ class AppSettingsRepository:
flood_scope=row["flood_scope"] or "",
blocked_keys=blocked_keys,
blocked_names=blocked_names,
discovery_blocked_types=discovery_blocked_types,
)
@staticmethod
@@ -112,6 +122,7 @@ class AppSettingsRepository:
flood_scope: str | None = None,
blocked_keys: list[str] | None = None,
blocked_names: list[str] | None = None,
discovery_blocked_types: list[int] | None = None,
) -> AppSettings:
"""Update app settings. Only provided fields are updated."""
updates = []
@@ -162,6 +173,10 @@ class AppSettingsRepository:
updates.append("blocked_names = ?")
params.append(json.dumps(blocked_names))
if discovery_blocked_types is not None:
updates.append("discovery_blocked_types = ?")
params.append(json.dumps(discovery_blocked_types))
if updates:
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
await db.conn.execute(query, params)
@@ -246,6 +261,26 @@ class AppSettingsRepository:
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
async def _activity_counts(*, contact_type: int, exclude: bool = False) -> dict[str, int]:
"""Get time-windowed counts for contacts/repeaters heard."""
@@ -272,17 +307,26 @@ class StatisticsRepository:
@staticmethod
async def _known_channels_active() -> dict[str, int]:
"""Count distinct known channel keys with channel traffic in each time window."""
"""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
COUNT(DISTINCT CASE WHEN m.received_at >= ? THEN m.conversation_key END) AS last_hour,
COUNT(DISTINCT CASE WHEN m.received_at >= ? THEN m.conversation_key END) AS last_24_hours,
COUNT(DISTINCT CASE WHEN m.received_at >= ? THEN m.conversation_key END) AS last_week
FROM messages m
INNER JOIN channels c ON UPPER(m.conversation_key) = UPPER(c.key)
WHERE m.type = 'CHAN'
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),
)
@@ -302,22 +346,26 @@ class StatisticsRepository:
"SELECT data FROM raw_packets WHERE timestamp >= ?",
(now - SECONDS_24H,),
)
rows = await cursor.fetchall()
single_byte = 0
double_byte = 0
triple_byte = 0
for row in rows:
envelope = parse_packet_envelope(bytes(row["data"]))
if envelope is None:
continue
if envelope.hash_size == 1:
single_byte += 1
elif envelope.hash_size == 2:
double_byte += 1
elif envelope.hash_size == 3:
triple_byte += 1
while True:
rows = await cursor.fetchmany(RAW_PACKET_STATS_BATCH_SIZE)
if not rows:
break
for row in rows:
envelope = parse_packet_envelope(bytes(row["data"]))
if envelope is None:
continue
if envelope.hash_size == 1:
single_byte += 1
elif envelope.hash_size == 2:
double_byte += 1
elif envelope.hash_size == 3:
triple_byte += 1
total_packets = single_byte + double_byte + triple_byte
if total_packets == 0:
@@ -400,22 +448,7 @@ class StatisticsRepository:
decrypted_packets = pkt_row["decrypted"] or 0
undecrypted_packets = total_packets - decrypted_packets
# Message type counts
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"]
message_totals = await StatisticsRepository.get_database_message_totals()
# Activity windows
contacts_heard = await StatisticsRepository._activity_counts(contact_type=2, exclude=True)
@@ -431,9 +464,9 @@ class StatisticsRepository:
"total_packets": total_packets,
"decrypted_packets": decrypted_packets,
"undecrypted_packets": undecrypted_packets,
"total_dms": total_dms,
"total_channel_messages": total_channel_messages,
"total_outgoing": total_outgoing,
"total_dms": message_totals["total_dms"],
"total_channel_messages": message_totals["total_channel_messages"],
"total_outgoing": message_totals["total_outgoing"],
"contacts_heard": contacts_heard,
"repeaters_heard": repeaters_heard,
"known_channels_active": known_channels_active,
+224 -47
View File
@@ -1,7 +1,8 @@
import logging
import re
from hashlib import sha256
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, BackgroundTasks, HTTPException, Response, status
from pydantic import BaseModel, Field
from app.channel_constants import (
@@ -10,10 +11,12 @@ from app.channel_constants import (
is_public_channel_key,
is_public_channel_name,
)
from app.decoder import parse_packet, try_decrypt_packet_with_channel_key
from app.models import Channel, ChannelDetail, ChannelMessageCounts, ChannelTopSender
from app.packet_processor import create_message_from_decrypted
from app.region_scope import normalize_region_scope
from app.repository import ChannelRepository, MessageRepository
from app.websocket import broadcast_event
from app.repository import ChannelRepository, MessageRepository, RawPacketRepository
from app.websocket import broadcast_event, broadcast_success
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/channels", tags=["channels"])
@@ -31,12 +34,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):
flood_scope_override: str = Field(
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])
async def list_channels() -> list[Channel]:
"""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).
"""
requested_name = request.name
is_hashtag = requested_name.startswith("#")
# Reserve the canonical Public channel so it cannot drift to another key,
# and the well-known Public key cannot be renamed to something else.
if is_public_channel_name(requested_name):
if request.key:
try:
key_bytes = bytes.fromhex(request.key)
if len(key_bytes) != 16:
raise HTTPException(
status_code=400,
detail="Channel key must be exactly 16 bytes (32 hex chars)",
)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
if key_bytes.hex().upper() != PUBLIC_CHANNEL_KEY:
raise HTTPException(
status_code=400,
detail=f'"{PUBLIC_CHANNEL_NAME}" must use the canonical Public key',
)
key_hex = PUBLIC_CHANNEL_KEY
channel_name = PUBLIC_CHANNEL_NAME
is_hashtag = False
elif request.key and not is_hashtag:
try:
key_bytes = bytes.fromhex(request.key)
if len(key_bytes) != 16:
raise HTTPException(
status_code=400, detail="Channel key must be exactly 16 bytes (32 hex chars)"
)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
key_hex = key_bytes.hex().upper()
if is_public_channel_key(key_hex):
raise HTTPException(
status_code=400,
detail=f'The canonical Public key may only be used for "{PUBLIC_CHANNEL_NAME}"',
)
channel_name = requested_name
else:
# Derive key from name hash (same as meshcore library does)
key_bytes = sha256(requested_name.encode("utf-8")).digest()[:16]
key_hex = key_bytes.hex().upper()
channel_name = requested_name
key_hex, channel_name, is_hashtag = _derive_channel_identity(requested_name, request.key)
logger.info("Creating channel %s: %s (hashtag=%s)", key_hex, channel_name, is_hashtag)
@@ -132,6 +234,81 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
return stored
@router.post("/bulk-hashtag", response_model=BulkCreateHashtagChannelsResponse)
async def bulk_create_hashtag_channels(
request: BulkCreateHashtagChannelsRequest,
background_tasks: BackgroundTasks,
response: Response,
) -> BulkCreateHashtagChannelsResponse:
created_channels: list[Channel] = []
existing_count = 0
invalid_names: list[str] = []
decrypt_started = False
decrypt_total_packets = 0
decrypt_targets: list[tuple[bytes, str, str]] = []
for raw_name in request.channel_names:
normalized_name = _normalize_bulk_hashtag_name(raw_name)
if normalized_name is None:
invalid_names.append(raw_name)
continue
key_hex, channel_name, is_hashtag = _derive_channel_identity(normalized_name)
existing = await ChannelRepository.get_by_key(key_hex)
if existing is not None:
existing_count += 1
continue
await ChannelRepository.upsert(
key=key_hex,
name=channel_name,
is_hashtag=is_hashtag,
on_radio=False,
)
stored = await ChannelRepository.get_by_key(key_hex)
if stored is None:
raise HTTPException(
status_code=500,
detail="Channel was created but could not be reloaded",
)
created_channels.append(stored)
decrypt_targets.append((bytes.fromhex(stored.key), stored.key, stored.name))
_broadcast_channel_update(stored)
if request.try_historical and decrypt_targets:
decrypt_total_packets = await RawPacketRepository.get_undecrypted_count()
if decrypt_total_packets > 0:
background_tasks.add_task(
_run_historical_channel_decryption_for_channels, decrypt_targets
)
decrypt_started = True
response.status_code = status.HTTP_202_ACCEPTED
message = (
f"Created {len(created_channels)} room{'s' if len(created_channels) != 1 else ''}"
if created_channels
else "No new rooms were added"
)
if request.try_historical and decrypt_targets:
if decrypt_started:
message += (
f" and started background decrypt of {decrypt_total_packets} packet"
f"{'s' if decrypt_total_packets != 1 else ''}"
)
else:
message += "; no undecrypted packets were available"
return BulkCreateHashtagChannelsResponse(
created_channels=created_channels,
existing_count=existing_count,
invalid_names=invalid_names,
decrypt_started=decrypt_started,
decrypt_total_packets=decrypt_total_packets,
message=message,
)
@router.post("/{key}/mark-read")
async def mark_channel_read(key: str) -> dict:
"""Mark a channel as read (update last_read_at timestamp)."""
+64 -9
View File
@@ -1,10 +1,12 @@
import asyncio
import logging
import random
import time
from contextlib import suppress
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
from meshcore import EventType
from pydantic import BaseModel, Field
from app.dependencies import require_connected
from app.models import (
@@ -31,7 +33,7 @@ from app.repository import (
)
from app.services.contact_reconciliation import (
promote_prefix_contacts_for_contact,
reconcile_contact_messages,
record_contact_name_and_reconcile,
)
from app.services.radio_runtime import radio_runtime as radio_manager
@@ -40,6 +42,10 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/contacts", tags=["contacts"])
TRACE_HASH_BYTES = 4
TRACE_FLAGS_4BYTE = 2
def _ambiguous_contact_detail(err: AmbiguousPublicKeyPrefixError) -> str:
sample = ", ".join(key[:12] for key in err.matches[:2])
return (
@@ -273,12 +279,18 @@ async def create_contact(
# Check if contact already exists
existing = await ContactRepository.get_by_key(request.public_key)
if existing:
# Update name if provided
# Update name if provided and record name history
if request.name:
await ContactRepository.upsert(existing.to_upsert(name=request.name))
refreshed = await ContactRepository.get_by_key(request.public_key)
if refreshed is not None:
existing = refreshed
await record_contact_name_and_reconcile(
public_key=request.public_key,
contact_name=request.name,
timestamp=int(time.time()),
log=logger,
)
promoted_keys = await promote_prefix_contacts_for_contact(
public_key=request.public_key,
@@ -313,9 +325,10 @@ async def create_contact(
log=logger,
)
await reconcile_contact_messages(
await record_contact_name_and_reconcile(
public_key=lower_key,
contact_name=request.name,
timestamp=int(time.time()),
log=logger,
)
@@ -343,6 +356,44 @@ async def mark_contact_read(public_key: str) -> dict:
return {"status": "ok", "public_key": contact.public_key}
class BulkDeleteRequest(BaseModel):
public_keys: list[str] = Field(description="Public keys to delete")
@router.post("/bulk-delete")
async def bulk_delete_contacts(request: BulkDeleteRequest) -> dict:
"""Delete multiple contacts from the database (and radio if present)."""
from app.websocket import broadcast_event
# Resolve all contacts first
contacts_to_delete: list[Contact] = []
for key in request.public_keys:
contact = await ContactRepository.get_by_key(key.lower())
if contact:
contacts_to_delete.append(contact)
# Remove from radio in a single locked operation (blocks until radio is free)
if radio_manager.is_connected and contacts_to_delete:
try:
async with radio_manager.radio_operation("bulk_delete_contacts_from_radio") as mc:
for contact in contacts_to_delete:
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
if radio_contact:
await mc.commands.remove_contact(radio_contact)
except Exception as e:
logger.warning("Radio removal during bulk delete failed: %s", e)
# Delete from database and broadcast events
deleted = 0
for contact in contacts_to_delete:
await ContactRepository.delete(contact.public_key)
broadcast_event("contact_deleted", {"public_key": contact.public_key})
deleted += 1
logger.info("Bulk deleted %d/%d contacts", deleted, len(request.public_keys))
return {"deleted": deleted}
@router.delete("/{public_key}")
async def delete_contact(public_key: str) -> dict:
"""Delete a contact from the database (and radio if present)."""
@@ -373,17 +424,17 @@ async def delete_contact(public_key: str) -> dict:
async def request_trace(public_key: str) -> TraceResponse:
"""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
(no intermediate repeaters). The radio firmware requires at least one
node in the path.
The trace path contains the contact's 4-byte pubkey hash as the sole hop
(no intermediate repeaters). This uses TRACE's dedicated width flags rather
than the radio's normal path_hash_mode setting.
"""
require_connected()
contact = await _resolve_contact_or_404(public_key)
tag = random.randint(1, 0xFFFFFFFF)
# First 2 hex chars of pubkey = 1-byte hash used by the trace protocol
contact_hash = contact.public_key[:2]
# Use a 4-byte contact hash for low-collision direct trace targeting.
contact_hash = contact.public_key[: TRACE_HASH_BYTES * 2]
# Trace does not need auto-fetch suspension: response arrives as TRACE_DATA
# from the reader loop, not via get_msg().
@@ -394,7 +445,11 @@ async def request_trace(public_key: str) -> TraceResponse:
logger.info(
"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:
raise HTTPException(status_code=500, detail=f"Failed to send trace: {result.payload}")
+142 -23
View File
@@ -1,17 +1,21 @@
import hashlib
import logging
import os
import platform
import struct
import sys
from datetime import datetime, timezone
from typing import Any
from typing import Any, Literal
from fastapi import APIRouter
from meshcore import EventType
from pydantic import BaseModel, Field
from app.config import get_recent_log_lines, settings
from app.models import AppSettings
from app.radio_sync import get_contacts_selected_for_radio_sync, get_radio_channel_limit
from app.repository import MessageRepository, StatisticsRepository
from app.routers.health import HealthResponse, build_health_data
from app.repository import AppSettingsRepository, MessageRepository, StatisticsRepository
from app.routers.health import FanoutStatusResponse, build_health_data
from app.services.radio_runtime import radio_runtime
from app.version_info import get_app_build_info, git_output
@@ -34,6 +38,13 @@ LOG_COPY_BOUNDARY_PREFIX = [
]
class DebugSystemInfo(BaseModel):
os: str
arch: str
arch_bits: int
total_ram_mb: int
class DebugApplicationInfo(BaseModel):
version: str
version_source: str
@@ -50,8 +61,6 @@ class DebugRuntimeInfo(BaseModel):
setup_in_progress: bool
setup_complete: bool
channels_with_incoming_messages: int
max_channels: int
path_hash_mode: int
path_hash_mode_supported: bool
channel_slot_reuse_enabled: bool
channel_send_cache_capacity: int
@@ -78,7 +87,6 @@ class DebugChannelAudit(BaseModel):
class DebugRadioProbe(BaseModel):
performed: bool
errors: list[str] = Field(default_factory=list)
multi_acks_enabled: bool | None = None
self_info: dict[str, Any] | None = None
device_info: dict[str, Any] | None = None
stats_core: dict[str, Any] | None = None
@@ -93,16 +101,53 @@ class DebugDatabaseInfo(BaseModel):
total_outgoing: int
class DebugHealthSummary(BaseModel):
radio_state: str
database_size_mb: float
oldest_undecrypted_timestamp: int | None
fanouts_with_errors: dict[str, FanoutStatusResponse] = Field(default_factory=dict)
bots_disabled_source: Literal["env", "until_restart"] | None = None
basic_auth_enabled: bool = False
class DebugAppSettings(BaseModel):
max_radio_contacts: int
auto_decrypt_dm_on_advert: bool
advert_interval: int
flood_scope: str
blocked_keys_count: int
blocked_names_count: int
class DebugSnapshotResponse(BaseModel):
captured_at: str
system: DebugSystemInfo
application: DebugApplicationInfo
health: HealthResponse
health: DebugHealthSummary
settings: DebugAppSettings
runtime: DebugRuntimeInfo
database: DebugDatabaseInfo
radio_probe: DebugRadioProbe
logs: list[str]
def _build_system_info() -> DebugSystemInfo:
try:
# os.sysconf is available on Linux/macOS
page_size = os.sysconf("SC_PAGE_SIZE")
page_count = os.sysconf("SC_PHYS_PAGES")
total_ram_mb = (page_size * page_count) // (1024 * 1024)
except (AttributeError, ValueError, OSError):
total_ram_mb = 0
return DebugSystemInfo(
os=f"{platform.system()} {platform.release()}",
arch=platform.machine(),
arch_bits=struct.calcsize("P") * 8,
total_ram_mb=total_ram_mb,
)
def _build_application_info() -> DebugApplicationInfo:
build_info = get_app_build_info()
dirty_output = git_output("status", "--porcelain")
@@ -158,6 +203,68 @@ def _coerce_live_max_channels(device_info: dict[str, Any] | None) -> int | None:
return None
def _build_debug_app_settings(app_settings: AppSettings) -> DebugAppSettings:
return DebugAppSettings(
max_radio_contacts=app_settings.max_radio_contacts,
auto_decrypt_dm_on_advert=app_settings.auto_decrypt_dm_on_advert,
advert_interval=app_settings.advert_interval,
flood_scope=app_settings.flood_scope,
blocked_keys_count=len(app_settings.blocked_keys),
blocked_names_count=len(app_settings.blocked_names),
)
def _derive_debug_radio_state(
*,
radio_connected: bool,
connection_desired: bool,
setup_in_progress: bool,
setup_complete: bool,
is_reconnecting: bool,
) -> str:
if not connection_desired:
return "paused"
if radio_connected and (setup_in_progress or not setup_complete):
return "initializing"
if radio_connected:
return "connected"
if is_reconnecting:
return "connecting"
return "disconnected"
def _build_debug_health_summary(
health_data: dict[str, Any], *, radio_state: str
) -> DebugHealthSummary:
def _fanout_last_error(status: Any) -> str | None:
if isinstance(status, dict):
value = status.get("last_error")
else:
value = getattr(status, "last_error", None)
return value if isinstance(value, str) and value else None
fanouts_with_errors = {
config_id: status
for config_id, status in health_data["fanout_statuses"].items()
if _fanout_last_error(status)
}
return DebugHealthSummary(
radio_state=radio_state,
database_size_mb=health_data["database_size_mb"],
oldest_undecrypted_timestamp=health_data["oldest_undecrypted_timestamp"],
fanouts_with_errors=fanouts_with_errors,
bots_disabled_source=health_data["bots_disabled_source"],
basic_auth_enabled=health_data["basic_auth_enabled"],
)
def _sanitize_radio_probe_self_info(self_info: dict[str, Any] | None) -> dict[str, Any]:
sanitized = dict(self_info or {})
sanitized.pop("adv_lat", None)
sanitized.pop("adv_lon", None)
return sanitized
async def _build_contact_audit(
observed_contacts_payload: dict[str, dict[str, Any]],
) -> DebugContactAudit:
@@ -242,10 +349,7 @@ async def _probe_radio() -> DebugRadioProbe:
return DebugRadioProbe(
performed=True,
errors=errors,
multi_acks_enabled=bool(mc.self_info.get("multi_acks", 0))
if mc.self_info is not None
else None,
self_info=dict(mc.self_info or {}),
self_info=_sanitize_radio_probe_self_info(mc.self_info),
device_info=device_info,
stats_core=stats_core,
stats_radio=stats_radio,
@@ -264,24 +368,39 @@ async def _probe_radio() -> DebugRadioProbe:
@router.get("/debug", response_model=DebugSnapshotResponse)
async def debug_support_snapshot() -> DebugSnapshotResponse:
"""Return a support/debug snapshot with recent logs and live radio state."""
health_data = await build_health_data(radio_runtime.is_connected, radio_runtime.connection_info)
statistics = await StatisticsRepository.get_all()
connection_info = radio_runtime.connection_info
connection_desired = radio_runtime.connection_desired
setup_in_progress = radio_runtime.is_setup_in_progress
setup_complete = radio_runtime.is_setup_complete
radio_connected = radio_runtime.is_connected
is_reconnecting = getattr(radio_runtime, "is_reconnecting", False)
health_data = await build_health_data(radio_connected, connection_info)
app_settings = await AppSettingsRepository.get()
message_totals = await StatisticsRepository.get_database_message_totals()
radio_probe = await _probe_radio()
channels_with_incoming_messages = (
await MessageRepository.count_channels_with_incoming_messages()
)
radio_state = _derive_debug_radio_state(
radio_connected=radio_connected,
connection_desired=connection_desired,
setup_in_progress=setup_in_progress,
setup_complete=setup_complete,
is_reconnecting=is_reconnecting,
)
return DebugSnapshotResponse(
captured_at=datetime.now(timezone.utc).isoformat(),
system=_build_system_info(),
application=_build_application_info(),
health=HealthResponse(**health_data),
health=_build_debug_health_summary(health_data, radio_state=radio_state),
settings=_build_debug_app_settings(app_settings),
runtime=DebugRuntimeInfo(
connection_info=radio_runtime.connection_info,
connection_desired=radio_runtime.connection_desired,
setup_in_progress=radio_runtime.is_setup_in_progress,
setup_complete=radio_runtime.is_setup_complete,
connection_info=connection_info,
connection_desired=connection_desired,
setup_in_progress=setup_in_progress,
setup_complete=setup_complete,
channels_with_incoming_messages=channels_with_incoming_messages,
max_channels=radio_runtime.max_channels,
path_hash_mode=radio_runtime.path_hash_mode,
path_hash_mode_supported=radio_runtime.path_hash_mode_supported,
channel_slot_reuse_enabled=radio_runtime.channel_slot_reuse_enabled(),
channel_send_cache_capacity=radio_runtime.get_channel_send_cache_capacity(),
@@ -291,9 +410,9 @@ async def debug_support_snapshot() -> DebugSnapshotResponse:
},
),
database=DebugDatabaseInfo(
total_dms=statistics["total_dms"],
total_channel_messages=statistics["total_channel_messages"],
total_outgoing=statistics["total_outgoing"],
total_dms=message_totals["total_dms"],
total_channel_messages=message_totals["total_channel_messages"],
total_outgoing=message_totals["total_outgoing"],
),
radio_probe=radio_probe,
logs=[*LOG_COPY_BOUNDARY_PREFIX, *get_recent_log_lines(limit=1000)],
+9 -2
View File
@@ -2,7 +2,7 @@ import os
from typing import Any, Literal
from fastapi import APIRouter
from pydantic import BaseModel
from pydantic import BaseModel, Field
from app.config import settings
from app.repository import RawPacketRepository
@@ -25,6 +25,13 @@ class AppInfoResponse(BaseModel):
commit_hash: str | None = None
class FanoutStatusResponse(BaseModel):
name: str
type: str
status: str
last_error: str | None = None
class HealthResponse(BaseModel):
status: str
radio_connected: bool
@@ -35,7 +42,7 @@ class HealthResponse(BaseModel):
radio_device_info: RadioDeviceInfoResponse | None = None
database_size_mb: float
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_source: Literal["env", "until_restart"] | None = None
basic_auth_enabled: bool = False
+1 -2
View File
@@ -210,8 +210,7 @@ async def decrypt_historical_packets(
except ValueError:
raise _bad_request("Invalid hex string for contact public key") from None
packets = await RawPacketRepository.get_undecrypted_text_messages()
count = len(packets)
count = await RawPacketRepository.count_undecrypted_text_messages()
if count == 0:
return DecryptResult(
started=False,
+233
View File
@@ -2,6 +2,7 @@ import asyncio
import logging
import random
import time
from contextlib import suppress
from typing import Literal, TypeAlias
from fastapi import APIRouter, HTTPException
@@ -10,14 +11,23 @@ from pydantic import BaseModel, Field
from app.dependencies import require_connected
from app.models import (
CONTACT_TYPE_REPEATER,
ContactUpsert,
RadioDiscoveryRequest,
RadioDiscoveryResponse,
RadioDiscoveryResult,
RadioTraceHopRequest,
RadioTraceNode,
RadioTraceRequest,
RadioTraceResponse,
)
from app.radio_sync import send_advertisement as do_send_advertisement
from app.radio_sync import sync_radio_time
from app.repository import ContactRepository
from app.services.contact_reconciliation import (
promote_prefix_contacts_for_contact,
reconcile_contact_messages,
)
from app.services.radio_commands import (
KeystoreRefreshError,
PathHashModeUnsupportedError,
@@ -44,6 +54,12 @@ _DISCOVERY_NODE_TYPES: dict[int, DiscoveryNodeType] = {
2: "repeater",
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:
@@ -197,9 +213,126 @@ async def _persist_new_discovery_contacts(results: list[RadioDiscoveryResult]) -
on_radio=False,
)
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)
if created is not None:
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)
@@ -365,6 +498,7 @@ async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryRespons
),
)
await _persist_new_discovery_contacts(results)
await _attach_known_names(results)
return RadioDiscoveryResponse(
target=request.target,
duration_seconds=DISCOVERY_WINDOW_SECONDS,
@@ -372,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:
"""Shared reconnection logic for reboot and reconnect endpoints."""
radio_manager.resume_connection()
+40 -61
View File
@@ -1,9 +1,7 @@
import asyncio
import logging
from typing import TYPE_CHECKING
import time
from fastapi import APIRouter, HTTPException
from meshcore import EventType
from app.dependencies import require_connected
from app.models import (
@@ -24,11 +22,11 @@ from app.models import (
RepeaterOwnerInfoResponse,
RepeaterRadioSettingsResponse,
RepeaterStatusResponse,
TelemetryHistoryEntry,
)
from app.repository import ContactRepository
from app.repository import ContactRepository, RepeaterTelemetryRepository
from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404
from app.routers.server_control import (
_monotonic,
batch_cli_fetch,
extract_response_text,
prepare_authenticated_contact_connection,
@@ -37,9 +35,6 @@ from app.routers.server_control import (
)
from app.services.radio_runtime import radio_runtime as radio_manager
if TYPE_CHECKING:
from meshcore.events import Event
logger = logging.getLogger(__name__)
# ACL permission level names
@@ -57,58 +52,6 @@ def _extract_response_text(event) -> str:
return extract_response_text(event)
async def _fetch_repeater_response(
mc,
target_pubkey_prefix: str,
timeout: float = 20.0,
) -> "Event | None":
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(
"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:
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:
return await prepare_authenticated_contact_connection(
mc,
@@ -167,7 +110,7 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
if status is None:
raise HTTPException(status_code=504, detail="No status response from repeater")
return RepeaterStatusResponse(
response = RepeaterStatusResponse(
battery_volts=status.get("bat", 0) / 1000.0,
tx_queue_len=status.get("tx_queue_len", 0),
noise_floor_dbm=status.get("noise_floor", 0),
@@ -187,6 +130,42 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
full_events=status.get("full_evts", 0),
)
# Record to telemetry history as a JSON blob (best-effort)
now = int(time.time())
status_dict = response.model_dump(exclude={"telemetry_history"})
try:
await RepeaterTelemetryRepository.record(
public_key=contact.public_key,
timestamp=now,
data=status_dict,
)
except Exception as e:
logger.warning("Failed to record telemetry history: %s", e)
# Fetch recent history and embed in response
try:
since = now - 30 * 86400 # last 30 days
rows = await RepeaterTelemetryRepository.get_history(contact.public_key, since)
response.telemetry_history = [TelemetryHistoryEntry(**row) for row in rows]
except Exception as e:
logger.warning("Failed to fetch telemetry history: %s", e)
return response
@router.get(
"/{public_key}/repeater/telemetry-history",
response_model=list[TelemetryHistoryEntry],
)
async def repeater_telemetry_history(public_key: str) -> list[TelemetryHistoryEntry]:
"""Return stored telemetry history for a repeater (read-only, no radio access)."""
contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact)
since = int(time.time()) - 30 * 86400
rows = await RepeaterTelemetryRepository.get_history(contact.public_key, since)
return [TelemetryHistoryEntry(**row) for row in rows]
@router.post("/{public_key}/repeater/lpp-telemetry", response_model=RepeaterLppTelemetryResponse)
async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
+5 -2
View File
@@ -13,6 +13,7 @@ from app.models import (
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
@@ -115,18 +116,20 @@ async def fetch_contact_cli_response(
if msg_prefix == target_pubkey_prefix and txt_type == 1:
return result
logger.debug(
"Skipping non-target message (from=%s, txt_type=%d) while waiting for %s",
"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(
"Skipping channel message (channel_idx=%s) during CLI fetch",
"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)
+13
View File
@@ -48,6 +48,13 @@ class AppSettingsUpdate(BaseModel):
default=None,
description="Display names whose messages are hidden from the UI",
)
discovery_blocked_types: list[int] | None = Field(
default=None,
description=(
"Contact type codes (1=Client, 2=Repeater, 3=Room, 4=Sensor) whose "
"advertisements should not create new contacts"
),
)
class BlockKeyRequest(BaseModel):
@@ -122,6 +129,12 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
if update.blocked_names is not None:
kwargs["blocked_names"] = update.blocked_names
# Discovery blocked types
if update.discovery_blocked_types is not None:
# Only allow valid contact type codes (1-4)
valid = [t for t in update.discovery_blocked_types if t in (1, 2, 3, 4)]
kwargs["discovery_blocked_types"] = sorted(set(valid))
# Flood scope
flood_scope_changed = False
if update.flood_scope is not None:
+2
View File
@@ -2,6 +2,7 @@ from fastapi import APIRouter
from app.models import StatisticsResponse
from app.repository import StatisticsRepository
from app.services.radio_noise_floor import get_noise_floor_history
router = APIRouter(prefix="/statistics", tags=["statistics"])
@@ -9,4 +10,5 @@ router = APIRouter(prefix="/statistics", tags=["statistics"])
@router.get("", response_model=StatisticsResponse)
async def get_statistics() -> StatisticsResponse:
data = await StatisticsRepository.get_all()
data["noise_floor_24h"] = await get_noise_floor_history()
return StatisticsResponse(**data)
+28 -20
View File
@@ -204,35 +204,43 @@ async def run_post_connect_setup(radio_manager) -> None:
finally:
reader.handle_rx = _original_handle_rx
# Sync contacts/channels from radio to DB and clear radio
logger.info("Syncing and offloading radio data...")
result = await sync_and_offload_all(mc)
logger.info("Sync complete: %s", result)
from app.config import settings as app_settings_config
# Send advertisement to announce our presence (if enabled and not throttled)
if await send_advertisement(mc):
logger.info("Advertisement sent")
if app_settings_config.skip_post_connect_sync:
logger.info(
"Skipping sync/offload/advert/drain (MESHCORE_SKIP_POST_CONNECT_SYNC)"
)
else:
logger.debug("Advertisement skipped (disabled or throttled)")
# Sync contacts/channels from radio to DB and clear radio
logger.info("Syncing and offloading radio data...")
result = await sync_and_offload_all(mc)
logger.info("Sync complete: %s", result)
# Drain any messages that were queued before we connected.
# This must happen BEFORE starting auto-fetch, otherwise both
# compete on get_msg() with interleaved radio I/O.
drained = await drain_pending_messages(mc)
if drained > 0:
logger.info("Drained %d pending message(s)", drained)
radio_manager.clear_pending_message_channel_slots()
# Send advertisement to announce our presence (if enabled and not throttled)
if await send_advertisement(mc):
logger.info("Advertisement sent")
else:
logger.debug("Advertisement skipped (disabled or throttled)")
# Drain any messages that were queued before we connected.
# This must happen BEFORE starting auto-fetch, otherwise both
# compete on get_msg() with interleaved radio I/O.
drained = await drain_pending_messages(mc)
if drained > 0:
logger.info("Drained %d pending message(s)", drained)
radio_manager.clear_pending_message_channel_slots()
await mc.start_auto_message_fetching()
logger.info("Auto message fetching started")
finally:
radio_manager._release_operation_lock("post_connect_setup")
# Start background tasks AFTER releasing the operation lock.
# These tasks acquire their own locks when they need radio access.
start_periodic_sync()
start_periodic_advert()
start_message_polling()
if not app_settings_config.skip_post_connect_sync:
# Start background tasks AFTER releasing the operation lock.
# These tasks acquire their own locks when they need radio access.
start_periodic_sync()
start_periodic_advert()
start_message_polling()
radio_manager._setup_complete = True
finally:
+119
View File
@@ -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,
}
-3
View File
@@ -43,9 +43,6 @@ class WebSocketManager:
3. Send to all clients concurrently with timeout
4. Re-acquire lock to clean up disconnected clients
"""
if not self.active_connections:
return
message = dump_ws_event(event_type, data)
# Copy connection list under lock to avoid holding lock during I/O
+50
View File
@@ -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
-35
View File
@@ -1,35 +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
# Security
# MESHCORE_DISABLE_BOTS: "true"
# MESHCORE_BASIC_AUTH_USERNAME: changeme
# MESHCORE_BASIC_AUTH_PASSWORD: changeme
# Logging
# MESHCORE_LOG_LEVEL: INFO
restart: unless-stopped
+50 -7
View File
@@ -39,6 +39,8 @@ frontend/src/
├── index.css # Global styles/utilities
├── styles.css # Additional global app styles
├── themes.css # Color theme definitions
├── contexts/
│ └── DistanceUnitContext.tsx # Browser-local distance-unit context/provider
├── lib/
│ └── utils.ts # cn() — clsx + tailwind-merge helper
├── hooks/
@@ -53,10 +55,14 @@ frontend/src/
│ ├── useRadioControl.ts # Radio health/config state, reconnection, mesh discovery sweeps
│ ├── useAppSettings.ts # Settings, favorites, preferences migration
│ ├── 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/
│ ├── AppShell.tsx # App-shell layout: status, sidebar, search/settings panes, cracker, modals
│ ├── ConversationPane.tsx # Active conversation surface selection (map/raw/repeater/chat/empty)
│ ├── AppShell.tsx # App-shell layout: status, sidebar, search/settings panes, cracker, modals, security warning
│ ├── ConversationPane.tsx # Active conversation surface selection (map/raw/trace/repeater/room/chat/empty)
│ ├── visualizer/
│ │ ├── useVisualizerData3D.ts # Packet→graph data pipeline, repeat aggregation, simulation state
│ │ ├── useVisualizer3DScene.ts # Three.js scene lifecycle, buffers, hover/pin interaction
@@ -73,14 +79,18 @@ frontend/src/
│ ├── pubkey.ts # getContactDisplayName (12-char prefix fallback)
│ ├── contactAvatar.ts # Avatar color derivation from public key
│ ├── 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
│ ├── visualizerUtils.ts # 3D visualizer node types, colors, particles
│ ├── visualizerSettings.ts # LocalStorage persistence for visualizer options
│ ├── a11y.ts # Keyboard accessibility helper
│ ├── distanceUnits.ts # Browser-local distance unit persistence/helpers
│ ├── lastViewedConversation.ts # localStorage for last-viewed conversation
│ ├── contactMerge.ts # Merge WS contact updates into list
│ ├── localLabel.ts # Local label (text + color) in localStorage
│ ├── 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
├── components/
│ ├── StatusBar.tsx
@@ -91,8 +101,12 @@ frontend/src/
│ ├── NewMessageModal.tsx
│ ├── SearchView.tsx # Full-text message search pane
│ ├── SettingsModal.tsx # Layout shell — delegates to settings/ sections
│ ├── SecurityWarningModal.tsx # Startup warning for trusted-network / bot execution posture
│ ├── RawPacketList.tsx
│ ├── RawPacketFeedView.tsx # Live raw packet feed + session stats drawer
│ ├── RawPacketDetailModal.tsx # On-demand packet inspector dialog
│ ├── MapView.tsx
│ ├── TracePane.tsx # Multi-hop route trace builder/results view
│ ├── VisualizerView.tsx
│ ├── PacketVisualizer3D.tsx
│ ├── PathModal.tsx
@@ -102,15 +116,20 @@ frontend/src/
│ ├── ContactAvatar.tsx
│ ├── ContactInfoPane.tsx # Contact detail sheet (stats, name history, paths)
│ ├── 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
│ ├── 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)
│ ├── ChannelFloodScopeOverrideModal.tsx # Per-channel flood-scope override editor
│ ├── DirectTraceIcon.tsx # Shared direct-trace glyph used in header/dashboard
│ ├── NeighborsMiniMap.tsx # Leaflet mini-map for repeater neighbor locations
│ ├── settings/
│ │ ├── 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
│ │ ├── 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
│ │ ├── SettingsDatabaseSection.tsx # DB size, cleanup, auto-decrypt, local label
│ │ ├── SettingsStatisticsSection.tsx # Read-only mesh network stats
@@ -130,12 +149,13 @@ frontend/src/
│ └── ui/ # shadcn/ui primitives
├── types/
│ └── d3-force-3d.d.ts # Type declarations for d3-force-3d
└── test/
└── test/ # Representative frontend test suites (not an exhaustive listing)
├── setup.ts
├── fixtures/websocket_events.json
├── api.test.ts
├── appFavorites.test.tsx
├── appStartupHash.test.tsx
├── conversationPane.test.tsx
├── contactAvatar.test.ts
├── contactInfoPane.test.tsx
├── integration.test.ts
@@ -146,18 +166,23 @@ frontend/src/
├── rawPacketList.test.tsx
├── pathUtils.test.ts
├── prefetch.test.ts
├── rawPacketDetailModal.test.tsx
├── rawPacketFeedView.test.tsx
├── radioPresets.test.ts
├── rawPacketIdentity.test.ts
├── repeaterDashboard.test.tsx
├── repeaterFormatters.test.ts
├── repeaterLogin.test.tsx
├── repeaterMessageParsing.test.ts
├── roomServerPanel.test.tsx
├── securityWarningModal.test.tsx
├── localLabel.test.ts
├── messageInput.test.tsx
├── newMessageModal.test.tsx
├── settingsModal.test.tsx
├── sidebar.test.tsx
├── statusBar.test.tsx
├── tracePane.test.tsx
├── unreadCounts.test.ts
├── urlHash.test.ts
├── appSearchJump.test.tsx
@@ -169,12 +194,17 @@ frontend/src/
├── useConversationMessages.race.test.ts
├── useConversationNavigation.test.ts
├── useAppShell.test.ts
├── useBrowserNotifications.test.ts
├── useFaviconBadge.test.ts
├── useRepeaterDashboard.test.ts
├── useRememberedServerPassword.test.ts
├── useContactsAndChannels.test.ts
├── useRealtimeAppState.test.ts
├── useUnreadCounts.test.ts
├── useWebSocket.dispatch.test.ts
├── useWebSocket.lifecycle.test.ts
├── rawPacketStats.test.ts
├── fontScale.test.ts
└── wsEvents.test.ts
```
@@ -190,6 +220,7 @@ frontend/src/
- search/settings surface switching
- global cracker mount/focus behavior
- new-message modal and info panes
- trusted-network `SecurityWarningModal`
High-level state is delegated to hooks:
- `useAppShell`: app-shell view state (settings section, sidebar, cracker, new-message modal)
@@ -211,7 +242,9 @@ High-level state is delegated to hooks:
- map view
- visualizer
- raw packet feed
- trace view
- repeater dashboard
- room-server auth/status gate before room chat
- normal chat chrome (`ChatHeader` + `MessageList` + `MessageInput`)
### Initial load + realtime
@@ -272,12 +305,16 @@ Supported routes:
- `#map/focus/{pubkey_or_prefix}`
- `#visualizer`
- `#search`
- `#trace`
- `#settings/{section}`
- `#channel/{channelKey}`
- `#channel/{channelKey}/{label}`
- `#contact/{publicKey}`
- `#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`)
@@ -377,6 +414,12 @@ For repeater contacts (`type=2`), `ConversationPane.tsx` renders `RepeaterDashbo
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
The `SearchView` component (`components/SearchView.tsx`) provides full-text search across all DMs and channel messages. Key behaviors:
@@ -404,7 +447,7 @@ Do not rely on old class-only layout assumptions.
Run all quality checks (backend + frontend) from the repo root:
```bash
./scripts/all_quality.sh
./scripts/quality/all_quality.sh
```
Or run frontend checks individually:
+384 -3
View File
@@ -1,12 +1,12 @@
{
"name": "remoteterm-meshcore-frontend",
"version": "3.6.1",
"version": "3.6.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "remoteterm-meshcore-frontend",
"version": "3.6.1",
"version": "3.6.3",
"dependencies": {
"@codemirror/lang-python": "^6.2.1",
"@codemirror/theme-one-dark": "^6.1.3",
@@ -30,6 +30,7 @@
"react-dom": "^18.3.1",
"react-leaflet": "^4.2.1",
"react-swipeable": "^7.0.2",
"recharts": "^3.8.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
@@ -2057,6 +2058,42 @@
"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": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -2414,6 +2451,18 @@
"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": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@@ -2564,6 +2613,24 @@
"@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": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
@@ -2571,6 +2638,51 @@
"dev": true,
"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": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -2663,6 +2775,12 @@
"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": {
"version": "0.5.24",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
@@ -3712,12 +3830,33 @@
"devOptional": true,
"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": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz",
"integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==",
"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": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
@@ -3727,6 +3866,15 @@
"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": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
@@ -3757,12 +3905,42 @@
"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": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz",
"integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==",
"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": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
@@ -3772,6 +3950,58 @@
"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": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
@@ -3820,6 +4050,12 @@
"dev": true,
"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": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
@@ -3974,6 +4210,16 @@
"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": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -4216,6 +4462,12 @@
"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": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
@@ -4618,6 +4870,16 @@
"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": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -4655,6 +4917,15 @@
"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": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -5599,7 +5870,6 @@
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT",
"peer": true
},
@@ -5617,6 +5887,29 @@
"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": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -5726,6 +6019,36 @@
"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": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@@ -5740,6 +6063,27 @@
"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": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -6134,6 +6478,12 @@
"integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==",
"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": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -6448,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": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"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": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "remoteterm-meshcore-frontend",
"private": true,
"version": "3.6.1",
"version": "3.7.1",
"type": "module",
"scripts": {
"dev": "vite",
@@ -38,6 +38,7 @@
"react-dom": "^18.3.1",
"react-leaflet": "^4.2.1",
"react-swipeable": "^7.0.2",
"recharts": "^3.8.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
+81 -4
View File
@@ -1,4 +1,4 @@
import { useEffect, useCallback, useRef, useState, useMemo } from 'react';
import { useEffect, useCallback, useRef, useState, useMemo, type MouseEvent } from 'react';
import { api } from './api';
import { takePrefetchOrFetch } from './prefetch';
import { useWebSocket } from './useWebSocket';
@@ -23,7 +23,7 @@ import type { MessageInputHandle } from './components/MessageInput';
import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
import { messageContainsMention } from './utils/messageParser';
import { getStateKey } from './utils/conversationState';
import type { Conversation, Message, RawPacket } from './types';
import type { BulkCreateHashtagChannelsResult, Conversation, Message, RawPacket } from './types';
import { CONTACT_TYPE_ROOM } from './types';
interface ChannelUnreadMarker {
@@ -31,6 +31,12 @@ interface ChannelUnreadMarker {
lastReadAt: number | null;
}
interface NewMessagePrefillRequest {
tab: 'hashtag';
hashtagName: string;
nonce: number;
}
interface UnreadBoundaryBackfillParams {
activeConversation: Conversation | null;
unreadMarker: ChannelUnreadMarker | null;
@@ -77,6 +83,10 @@ export function App() {
const messageInputRef = useRef<MessageInputHandle>(null);
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
const [channelUnreadMarker, setChannelUnreadMarker] = useState<ChannelUnreadMarker | null>(null);
const [newMessagePrefillRequest, setNewMessagePrefillRequest] =
useState<NewMessagePrefillRequest | null>(null);
const [showBulkAddChannelTab, setShowBulkAddChannelTab] = useState(false);
const [bulkAddResult, setBulkAddResult] = useState<BulkCreateHashtagChannelsResult | null>(null);
const [visibilityVersion, setVisibilityVersion] = useState(0);
const lastUnreadBackfillAttemptRef = useRef<string | null>(null);
const {
@@ -103,8 +113,8 @@ export function App() {
setDistanceUnit,
handleCloseSettingsView,
handleToggleSettingsView,
handleOpenNewMessage,
handleCloseNewMessage,
handleOpenNewMessage: openNewMessageModal,
handleCloseNewMessage: closeNewMessageModal,
handleToggleCracker,
} = useAppShell();
@@ -182,6 +192,7 @@ export function App() {
handleCreateContact,
handleCreateChannel,
handleCreateHashtagChannel,
handleBulkCreateHashtagChannels,
handleDeleteChannel,
handleDeleteContact,
} = useContactsAndChannels({
@@ -274,6 +285,7 @@ export function App() {
unreadLastReadAts,
recordMessageEvent,
renameConversationState,
removeConversationState,
markAllRead,
refreshUnreads,
} = useUnreadCounts(channels, contacts, activeConversation);
@@ -349,6 +361,7 @@ export function App() {
observeMessage,
recordMessageEvent,
renameConversationState,
removeConversationState,
checkMention,
pendingDeleteFallbackRef,
setActiveConversation,
@@ -411,6 +424,52 @@ export function App() {
[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 = {
health,
config,
@@ -433,6 +492,11 @@ export function App() {
favorites,
legacySortOrder: appSettings?.sidebar_sort_order,
isConversationNotificationsEnabled,
blockedKeys: appSettings?.blocked_keys ?? [],
blockedNames: appSettings?.blocked_names ?? [],
};
const bulkAddChannelResultModalProps = {
result: bulkAddResult,
};
const conversationPaneProps = {
activeConversation,
@@ -457,6 +521,7 @@ export function App() {
loadingNewer,
messageInputRef,
onTrace: handleTrace,
onRunTracePath: api.requestRadioTrace,
onPathDiscovery: handlePathDiscovery,
onToggleFavorite: handleToggleFavorite,
onDeleteContact: handleDeleteContact,
@@ -465,6 +530,7 @@ export function App() {
onOpenContactInfo: handleOpenContactInfo,
onOpenChannelInfo: handleOpenChannelInfo,
onSenderClick: handleSenderClick,
onChannelReferenceClick: handleChannelReferenceClick,
onLoadOlder: fetchOlderMessages,
onResendChannelMessage: handleResendChannelMessage,
onTargetReached: () => setTargetMessageId(null),
@@ -515,6 +581,11 @@ export function App() {
blockedNames: appSettings?.blocked_names,
onToggleBlockedKey: handleBlockKey,
onToggleBlockedName: handleBlockName,
contacts,
onBulkDeleteContacts: (deletedKeys: string[]) => {
const keySet = new Set(deletedKeys.map((k) => k.toLowerCase()));
setContacts((prev) => prev.filter((c) => !keySet.has(c.public_key.toLowerCase())));
},
};
const crackerProps = {
packets: rawPackets,
@@ -523,9 +594,12 @@ export function App() {
};
const newMessageModalProps = {
undecryptedCount,
showBulkAddChannelTab,
prefillRequest: newMessagePrefillRequest,
onCreateContact: handleCreateContact,
onCreateChannel: handleCreateChannel,
onCreateHashtagChannel: handleCreateHashtagChannel,
onBulkAddHashtagChannels: handleBulkAddChannels,
};
const contactInfoPaneProps = {
contactKey: infoPaneContactKey,
@@ -589,6 +663,7 @@ export function App() {
<AppShell
localLabel={localLabel}
showNewMessage={showNewMessage}
showBulkAddResults={bulkAddResult !== null}
showSettings={showSettings}
settingsSection={settingsSection}
sidebarOpen={sidebarOpen}
@@ -599,6 +674,7 @@ export function App() {
onToggleSettingsView={handleToggleSettingsView}
onCloseSettingsView={handleCloseSettingsView}
onCloseNewMessage={handleCloseNewMessage}
onCloseBulkAddResults={handleCloseBulkAddResults}
onLocalLabelChange={setLocalLabel}
statusProps={statusProps}
sidebarProps={sidebarProps}
@@ -607,6 +683,7 @@ export function App() {
settingsProps={settingsProps}
crackerProps={crackerProps}
newMessageModalProps={newMessageModalProps}
bulkAddChannelResultModalProps={bulkAddChannelResultModalProps}
contactInfoPaneProps={contactInfoPaneProps}
channelInfoPaneProps={channelInfoPaneProps}
/>
+26 -2
View File
@@ -1,6 +1,7 @@
import type {
AppSettings,
AppSettingsUpdate,
BulkCreateHashtagChannelsResult,
Channel,
ChannelDetail,
CommandResponse,
@@ -20,6 +21,8 @@ import type {
RadioConfig,
RadioConfigUpdate,
RadioDiscoveryResponse,
RadioTraceHopRequest,
RadioTraceResponse,
RadioDiscoveryTarget,
PathDiscoveryResponse,
ResendChannelMessageResponse,
@@ -32,6 +35,7 @@ import type {
RepeaterOwnerInfoResponse,
RepeaterRadioSettingsResponse,
RepeaterStatusResponse,
TelemetryHistoryEntry,
StatisticsResponse,
TraceResponse,
UnreadCounts,
@@ -107,6 +111,11 @@ export const api = {
method: 'POST',
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: () =>
fetchJson<{ status: string; message: string }>('/radio/reboot', {
method: 'POST',
@@ -130,16 +139,24 @@ export const api = {
fetchJson<ContactAdvertPathSummary[]>(
`/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();
if (params.publicKey) searchParams.set('public_key', params.publicKey);
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) =>
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
method: 'DELETE',
}),
bulkDeleteContacts: (publicKeys: string[]) =>
fetchJson<{ deleted: number }>('/contacts/bulk-delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ public_keys: publicKeys }),
}),
createContact: (publicKey: string, name?: string, tryHistorical?: boolean) =>
fetchJson<Contact>('/contacts', {
method: 'POST',
@@ -175,6 +192,11 @@ export const api = {
method: 'POST',
body: JSON.stringify({ name, key }),
}),
bulkCreateHashtagChannels: (channelNames: string[], tryHistorical?: boolean) =>
fetchJson<BulkCreateHashtagChannelsResult>('/channels/bulk-hashtag', {
method: 'POST',
body: JSON.stringify({ channel_names: channelNames, try_historical: tryHistorical }),
}),
deleteChannel: (key: string) =>
fetchJson<{ status: string }>(`/channels/${key}`, { method: 'DELETE' }),
getChannelDetail: (key: string) => fetchJson<ChannelDetail>(`/channels/${key}/detail`),
@@ -393,6 +415,8 @@ export const api = {
fetchJson<RepeaterLppTelemetryResponse>(`/contacts/${publicKey}/repeater/lpp-telemetry`, {
method: 'POST',
}),
repeaterTelemetryHistory: (publicKey: string) =>
fetchJson<TelemetryHistoryEntry[]>(`/contacts/${publicKey}/repeater/telemetry-history`),
roomLogin: (publicKey: string, password: string) =>
fetchJson<RepeaterLoginResponse>(`/contacts/${publicKey}/room/login`, {
method: 'POST',
+16
View File
@@ -5,6 +5,7 @@ import { StatusBar } from './StatusBar';
import { Sidebar } from './Sidebar';
import { ConversationPane } from './ConversationPane';
import { NewMessageModal } from './NewMessageModal';
import { BulkAddChannelResultModal } from './BulkAddChannelResultModal';
import { ContactInfoPane } from './ContactInfoPane';
import { ChannelInfoPane } from './ChannelInfoPane';
import { SecurityWarningModal } from './SecurityWarningModal';
@@ -33,12 +34,17 @@ const SearchView = lazy(() => import('./SearchView').then((m) => ({ default: m.S
type SidebarProps = ComponentProps<typeof Sidebar>;
type ConversationPaneProps = ComponentProps<typeof ConversationPane>;
type NewMessageModalProps = Omit<ComponentProps<typeof NewMessageModal>, 'open' | 'onClose'>;
type BulkAddChannelResultModalProps = Omit<
ComponentProps<typeof BulkAddChannelResultModal>,
'open' | 'onClose'
>;
type ContactInfoPaneProps = ComponentProps<typeof ContactInfoPane>;
type ChannelInfoPaneProps = ComponentProps<typeof ChannelInfoPane>;
interface AppShellProps {
localLabel: LocalLabel;
showNewMessage: boolean;
showBulkAddResults: boolean;
showSettings: boolean;
settingsSection: SettingsSection;
sidebarOpen: boolean;
@@ -50,6 +56,7 @@ interface AppShellProps {
onToggleSettingsView: () => void;
onCloseSettingsView: () => void;
onCloseNewMessage: () => void;
onCloseBulkAddResults: () => void;
onLocalLabelChange: (label: LocalLabel) => void;
statusProps: Pick<ComponentProps<typeof StatusBar>, 'health' | 'config'>;
sidebarProps: SidebarProps;
@@ -61,6 +68,7 @@ interface AppShellProps {
>;
crackerProps: Omit<CrackerPanelProps, 'visible' | 'onRunningChange'>;
newMessageModalProps: NewMessageModalProps;
bulkAddChannelResultModalProps: BulkAddChannelResultModalProps;
contactInfoPaneProps: ContactInfoPaneProps;
channelInfoPaneProps: ChannelInfoPaneProps;
}
@@ -68,6 +76,7 @@ interface AppShellProps {
export function AppShell({
localLabel,
showNewMessage,
showBulkAddResults,
showSettings,
settingsSection,
sidebarOpen,
@@ -79,6 +88,7 @@ export function AppShell({
onToggleSettingsView,
onCloseSettingsView,
onCloseNewMessage,
onCloseBulkAddResults,
onLocalLabelChange,
statusProps,
sidebarProps,
@@ -87,6 +97,7 @@ export function AppShell({
settingsProps,
crackerProps,
newMessageModalProps,
bulkAddChannelResultModalProps,
contactInfoPaneProps,
channelInfoPaneProps,
}: AppShellProps) {
@@ -306,6 +317,11 @@ export function AppShell({
open={showNewMessage}
onClose={onCloseNewMessage}
/>
<BulkAddChannelResultModal
{...bulkAddChannelResultModalProps}
open={showBulkAddResults}
onClose={onCloseBulkAddResults}
/>
<SecurityWarningModal health={statusProps.health} />
<ContactInfoPane {...contactInfoPaneProps} />
@@ -0,0 +1,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>
);
}
+1 -1
View File
@@ -268,7 +268,7 @@ export function ChatHeader({
title={
activeContactIsPrefixOnly
? '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"
disabled={activeContactIsPrefixOnly}
+230 -160
View File
@@ -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 { 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 {
getContactDisplayName,
@@ -25,6 +35,7 @@ import { ContactAvatar } from './ContactAvatar';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
import { toast } from './ui/sonner';
import { useDistanceUnit } from '../contexts/DistanceUnitContext';
import { CONTACT_TYPE_REPEATER } from '../types';
import type {
Contact,
ContactActiveRoom,
@@ -100,29 +111,29 @@ export function ContactInfoPane({
return;
}
let cancelled = false;
const controller = new AbortController();
setAnalytics(null);
setLoading(true);
const request =
isNameOnly && nameOnlyValue
? api.getContactAnalytics({ name: nameOnlyValue })
: api.getContactAnalytics({ publicKey: contactKey });
? api.getContactAnalytics({ name: nameOnlyValue }, controller.signal)
: api.getContactAnalytics({ publicKey: contactKey }, controller.signal);
request
.then((data) => {
if (!cancelled) setAnalytics(data);
if (!controller.signal.aborted) setAnalytics(data);
})
.catch((err) => {
if (!cancelled) {
if (!isAbortError(err)) {
console.error('Failed to fetch contact analytics:', err);
toast.error('Failed to load contact info');
}
})
.finally(() => {
if (!cancelled) setLoading(false);
if (!controller.signal.aborted) setLoading(false);
});
return () => {
cancelled = true;
controller.abort();
};
}, [contactKey, isNameOnly, nameOnlyValue]);
@@ -148,6 +159,7 @@ export function ContactInfoPane({
contact !== null &&
!isPrefixOnlyResolvedContact &&
isUnknownFullKeyContact(contact.public_key, contact.last_advert);
const isRepeater = contact?.type === CONTACT_TYPE_REPEATER;
return (
<Sheet open={contactKey !== null} onOpenChange={(open) => !open && onClose()}>
@@ -430,7 +442,7 @@ export function ContactInfoPane({
</div>
)}
{onSearchMessagesByKey && (
{!isRepeater && onSearchMessagesByKey && (
<div className="px-5 py-3 border-b border-border">
<button
type="button"
@@ -443,40 +455,60 @@ export function ContactInfoPane({
</div>
)}
{/* Nearest Repeaters */}
{analytics && analytics.nearest_repeaters.length > 0 && (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Nearest Repeaters</SectionLabel>
<div className="space-y-1">
{analytics.nearest_repeaters.map((r) => (
<div key={r.public_key} className="flex justify-between items-center text-sm">
<span className="truncate">{r.name || r.public_key.slice(0, 12)}</span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{r.path_len === 0
? 'direct'
: `${r.path_len} hop${r.path_len > 1 ? 's' : ''}`}{' '}
· {r.heard_count}x
</span>
{/* Nearest Repeaters (Hops) — last 7 days only */}
{analytics &&
(() => {
const sevenDaysAgo = Math.floor(Date.now() / 1000) - 7 * 86400;
const recent = analytics.nearest_repeaters.filter(
(r) => r.last_seen >= sevenDaysAgo
);
if (recent.length === 0) return null;
return (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Nearest Repeaters Hops (last 7 days)</SectionLabel>
<div className="space-y-1">
{recent.map((r) => (
<div
key={r.public_key}
className="flex justify-between items-center text-sm"
>
<span className="truncate">{r.name || r.public_key.slice(0, 12)}</span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{r.path_len === 0
? 'direct'
: `${r.path_len} hop${r.path_len > 1 ? 's' : ''}`}{' '}
· {r.heard_count}x
</span>
</div>
))}
</div>
))}
</div>
</div>
</div>
);
})()}
{/* Geographically nearest repeaters (repeaters only) */}
{isRepeater && contact && isValidLocation(contact.lat, contact.lon) && (
<NearbyRepeatersSection
contact={contact}
contacts={contacts}
distanceUnit={distanceUnit}
/>
)}
{/* Advert Paths */}
{analytics && analytics.advert_paths.length > 0 && (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Recent Advert Paths</SectionLabel>
<div className="space-y-1">
<div className="space-y-1.5">
{analytics.advert_paths.map((p) => (
<div
key={p.path + p.first_seen}
className="flex justify-between items-center text-sm"
className="flex justify-between items-start gap-2 text-sm"
>
<span className="font-mono text-xs truncate">
<span className="font-mono text-xs break-all">
{p.path ? parsePathHops(p.path, p.path_len).join(' → ') : '(direct)'}
</span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
<span className="text-xs text-muted-foreground flex-shrink-0">
{p.heard_count}x · {formatTime(p.last_seen)}
</span>
</div>
@@ -508,17 +540,21 @@ export function ContactInfoPane({
</div>
)}
<MessageStatsSection
dmMessageCount={analytics?.dm_message_count ?? 0}
channelMessageCount={analytics?.channel_message_count ?? 0}
/>
{!isRepeater && (
<>
<MessageStatsSection
dmMessageCount={analytics?.dm_message_count ?? 0}
channelMessageCount={analytics?.channel_message_count ?? 0}
/>
<ActivityChartsSection analytics={analytics} />
<ActivityChartsSection analytics={analytics} />
<MostActiveChannelsSection
channels={analytics?.most_active_rooms ?? []}
onNavigateToChannel={onNavigateToChannel}
/>
<MostActiveChannelsSection
channels={analytics?.most_active_rooms ?? []}
onNavigateToChannel={onNavigateToChannel}
/>
</>
)}
</div>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
@@ -650,20 +686,18 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu
{hasHourlyActivity && (
<div>
<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
ariaLabel="Messages per hour"
points={analytics.hourly_activity}
series={[
{ key: 'last_24h_count', color: '#2563eb' },
{ key: 'last_week_average', color: '#ea580c' },
{ key: 'all_time_average', color: '#64748b' },
{ key: 'last_24h_count', color: '#2563eb', label: 'Last 24h' },
{ key: 'last_week_average', color: '#ea580c', label: '7-day avg' },
{ 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)}
tickFormatter={(bucket) =>
@@ -683,7 +717,7 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu
<ActivityLineChart
ariaLabel="Messages per week"
points={analytics.weekly_activity}
series={[{ key: 'message_count', color: '#16a34a' }]}
series={[{ key: 'message_count', color: '#16a34a', label: 'Messages' }]}
valueFormatter={(value) => value.toFixed(0)}
tickFormatter={(bucket) =>
new Date(bucket.bucket_start * 1000).toLocaleDateString([], {
@@ -705,133 +739,169 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu
);
}
function ChartLegend({ items }: { items: Array<{ label: string; color: string }> }) {
return (
<div className="flex flex-wrap gap-x-3 gap-y-1 mb-2 text-[11px] text-muted-foreground">
{items.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 }}
aria-hidden="true"
/>
{item.label}
</span>
))}
</div>
);
}
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 ActivityLineChart<T extends ContactAnalyticsHourlyBucket | ContactAnalyticsWeeklyBucket>({
ariaLabel,
points,
series,
legendItems,
tickFormatter,
valueFormatter,
}: {
ariaLabel: string;
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;
valueFormatter: (value: number) => string;
}) {
const width = 320;
const height = 132;
const padding = { top: 8, right: 8, bottom: 24, left: 32 };
const plotWidth = width - padding.left - padding.right;
const plotHeight = height - padding.top - padding.bottom;
const allValues = points.flatMap((point) =>
series.map((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 data = points.map((point, i) => {
const entry: Record<string, string | number> = { idx: i, tick: tickFormatter(point) };
for (const s of series) {
const raw = point[s.key];
entry[String(s.key)] = typeof raw === 'number' ? raw : 0;
}
return entry;
});
const buildPolyline = (key: keyof T) =>
points
.map((point, index) => {
const rawValue = point[key];
const value = typeof rawValue === 'number' ? rawValue : 0;
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(' ');
const tickCount = Math.min(5, points.length);
const tickIndices: number[] = [];
if (points.length > 1) {
for (let i = 0; i < tickCount; i++) {
tickIndices.push(Math.round((i / (tickCount - 1)) * (points.length - 1)));
}
}
return (
<div>
<svg
viewBox={`0 0 ${width} ${height}`}
className="w-full h-auto"
role="img"
aria-label={ariaLabel}
>
{[0, 0.5, 1].map((ratio) => {
const y = padding.top + plotHeight - ratio * plotHeight;
const value = maxValue * ratio;
return (
<g key={ratio}>
<line
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)}
<div role="img" aria-label={ariaLabel}>
<ResponsiveContainer width="100%" height={140}>
<LineChart data={data} margin={{ top: 4, right: 4, bottom: 0, left: -16 }}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
<XAxis
dataKey="idx"
type="number"
domain={[0, Math.max(1, points.length - 1)]}
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
ticks={tickIndices}
tickFormatter={(idx) => String(data[idx]?.tick ?? '')}
/>
))}
<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) => {
const point = points[index];
const x =
padding.left + (points.length === 1 ? 0 : (index / (points.length - 1)) * plotWidth);
return (
<text
key={`${ariaLabel}-${point.bucket_start}`}
x={x}
y={height - 6}
fontSize="10"
textAnchor={index === 0 ? 'start' : index === points.length - 1 ? 'end' : 'middle'}
fill="hsl(var(--muted-foreground))"
>
{tickFormatter(point)}
</text>
);
})}
</svg>
function NearbyRepeatersSection({
contact,
contacts,
distanceUnit,
}: {
contact: Contact;
contacts: Contact[];
distanceUnit: import('../utils/distanceUnits').DistanceUnit;
}) {
const nearby = useMemo(() => {
const sevenDaysAgo = Math.floor(Date.now() / 1000) - 7 * 86400;
const results: Array<{ name: string; publicKey: string; distance: number }> = [];
for (const other of contacts) {
const heardAt = Math.max(other.last_seen ?? 0, other.last_advert ?? 0);
if (
other.public_key === contact.public_key ||
other.type !== CONTACT_TYPE_REPEATER ||
!isValidLocation(other.lat, other.lon) ||
heardAt < sevenDaysAgo
) {
continue;
}
const dist = calculateDistance(contact.lat, contact.lon, other.lat, other.lon);
if (dist !== null) {
results.push({
name: getContactDisplayName(other.name, other.public_key, other.last_advert),
publicKey: other.public_key,
distance: dist,
});
}
}
results.sort((a, b) => a.distance - b.distance);
return results.slice(0, 5);
}, [contact.public_key, contact.lat, contact.lon, contacts]);
if (nearby.length === 0) return null;
return (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Nearest Repeaters Geo (last 7 days)</SectionLabel>
<div className="space-y-1">
{nearby.map((r) => (
<div key={r.publicKey} className="flex justify-between items-center text-sm">
<span className="truncate">{r.name}</span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{formatDistance(r.distance, distanceUnit)}
</span>
</div>
))}
</div>
</div>
);
}
@@ -5,6 +5,7 @@ import { MessageInput, type MessageInputHandle } from './MessageInput';
import { MessageList } from './MessageList';
import { RawPacketFeedView } from './RawPacketFeedView';
import { RoomServerPanel } from './RoomServerPanel';
import { TracePane } from './TracePane';
import type {
Channel,
Contact,
@@ -15,6 +16,8 @@ import type {
PathDiscoveryResponse,
RawPacket,
RadioConfig,
RadioTraceHopRequest,
RadioTraceResponse,
} from '../types';
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
@@ -50,6 +53,10 @@ interface ConversationPaneProps {
loadingNewer: boolean;
messageInputRef: Ref<MessageInputHandle>;
onTrace: () => Promise<void>;
onRunTracePath: (
hopHashBytes: 1 | 2 | 4,
hops: RadioTraceHopRequest[]
) => Promise<RadioTraceResponse>;
onPathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
onToggleFavorite: (type: 'channel' | 'contact', id: string) => Promise<void>;
onDeleteContact: (publicKey: string) => Promise<void>;
@@ -58,6 +65,7 @@ interface ConversationPaneProps {
onOpenContactInfo: (publicKey: string, fromChannel?: boolean) => void;
onOpenChannelInfo: (channelKey: string) => void;
onSenderClick: (sender: string) => void;
onChannelReferenceClick?: (channelName: string) => void;
onLoadOlder: () => Promise<void>;
onResendChannelMessage: (messageId: number, newTimestamp?: boolean) => Promise<void>;
onTargetReached: () => void;
@@ -115,6 +123,7 @@ export function ConversationPane({
loadingNewer,
messageInputRef,
onTrace,
onRunTracePath,
onPathDiscovery,
onToggleFavorite,
onDeleteContact,
@@ -123,6 +132,7 @@ export function ConversationPane({
onOpenContactInfo,
onOpenChannelInfo,
onSenderClick,
onChannelReferenceClick,
onLoadOlder,
onResendChannelMessage,
onTargetReached,
@@ -200,6 +210,10 @@ export function ConversationPane({
return null;
}
if (activeConversation.type === 'trace') {
return <TracePane contacts={contacts} config={config} onRunTracePath={onRunTracePath} />;
}
if (activeContactIsRepeater) {
return (
<Suspense fallback={<LoadingPane label="Loading dashboard..." />}>
@@ -219,6 +233,7 @@ export function ConversationPane({
onToggleNotifications={onToggleNotifications}
onToggleFavorite={onToggleFavorite}
onDeleteContact={onDeleteContact}
onOpenContactInfo={onOpenContactInfo}
/>
</Suspense>
);
@@ -272,6 +287,7 @@ export function ConversationPane({
activeConversation.type === 'channel' ? onDismissUnreadMarker : undefined
}
onSenderClick={activeConversation.type === 'channel' ? onSenderClick : undefined}
onChannelReferenceClick={onChannelReferenceClick}
onLoadOlder={onLoadOlder}
onResendChannelMessage={
activeConversation.type === 'channel' ? onResendChannelMessage : undefined
+27 -4
View File
@@ -39,6 +39,7 @@ export function CrackerPanel({
}: CrackerPanelProps) {
const [isRunning, setIsRunning] = useState(false);
const [maxLength, setMaxLength] = useState(6);
const [maxLengthInput, setMaxLengthInput] = useState('6');
const [retryFailedAtNextLength, setRetryFailedAtNextLength] = useState(false);
const [decryptHistorical, setDecryptHistorical] = useState(true);
const [turboMode, setTurboMode] = useState(false);
@@ -127,8 +128,9 @@ export function CrackerPanel({
}, [existingChannelKeys]);
// Filter packets to only undecrypted GROUP_TEXT
const undecryptedGroupText = packets.filter(
(p) => p.payload_type === 'GROUP_TEXT' && !p.decrypted
const undecryptedGroupText = useMemo(
() => packets.filter((p) => p.payload_type === 'GROUP_TEXT' && !p.decrypted),
[packets]
);
// Update queue when packets change (deduplicated by payload)
@@ -191,6 +193,10 @@ export function CrackerPanel({
maxLengthRef.current = maxLength;
}, [maxLength]);
useEffect(() => {
setMaxLengthInput(String(maxLength));
}, [maxLength]);
useEffect(() => {
decryptHistoricalRef.current = decryptHistorical;
}, [decryptHistorical]);
@@ -434,8 +440,25 @@ export function CrackerPanel({
type="number"
min={1}
max={10}
value={maxLength}
onChange={(e) => setMaxLength(Math.min(10, Math.max(1, parseInt(e.target.value) || 6)))}
value={maxLengthInput}
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"
/>
</div>
+14
View File
@@ -131,9 +131,23 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
// Store ref for a marker
const setMarkerRef = useCallback((key: string, ref: LeafletCircleMarker | null) => {
if (ref === null) {
delete markerRefs.current[key];
return;
}
markerRefs.current[key] = ref;
}, []);
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
useEffect(() => {
if (focusedContact && markerRefs.current[focusedContact.public_key]) {
+112 -11
View File
@@ -11,7 +11,11 @@ import {
import type { Channel, Contact, Message, MessagePath, RadioConfig, RawPacket } from '../types';
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
import { api } from '../api';
import { formatTime, parseSenderFromText } from '../utils/messageParser';
import {
findLinkedChannelReferences,
formatTime,
parseSenderFromText,
} from '../utils/messageParser';
import { formatHopCounts, type SenderInfo } from '../utils/pathUtils';
import { getDirectContactRoute } from '../utils/pathUtils';
import { ContactAvatar } from './ContactAvatar';
@@ -33,6 +37,7 @@ interface MessageListProps {
onSenderClick?: (sender: string) => void;
onLoadOlder?: () => void;
onResendChannelMessage?: (messageId: number, newTimestamp?: boolean) => void;
onChannelReferenceClick?: (channelName: string) => void;
radioName?: string;
config?: RadioConfig | null;
onOpenContactInfo?: (publicKey: string, fromChannel?: boolean) => void;
@@ -48,8 +53,64 @@ interface MessageListProps {
const URL_PATTERN =
/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 linkifyText(text: string, keyPrefix: string): ReactNode[] {
function renderChannelReferences(
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[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
@@ -58,7 +119,13 @@ function linkifyText(text: string, keyPrefix: string): ReactNode[] {
URL_PATTERN.lastIndex = 0;
while ((match = URL_PATTERN.exec(text)) !== null) {
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(
<a
@@ -74,15 +141,27 @@ function linkifyText(text: string, keyPrefix: string): ReactNode[] {
lastIndex = match.index + match[0].length;
}
if (lastIndex === 0) return [text];
if (lastIndex === 0) {
return renderChannelReferences(text, keyPrefix, onChannelReferenceClick);
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
parts.push(
...renderChannelReferences(
text.slice(lastIndex),
`${keyPrefix}-tail`,
onChannelReferenceClick
)
);
}
return parts;
}
// 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 parts: ReactNode[] = [];
let lastIndex = 0;
@@ -92,7 +171,13 @@ function renderTextWithMentions(text: string, radioName?: string): ReactNode {
while ((match = mentionPattern.exec(text)) !== null) {
// Add text before the match (with linkification)
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];
@@ -115,7 +200,7 @@ function renderTextWithMentions(text: string, radioName?: string): ReactNode {
// Add remaining text after last match (with linkification)
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;
@@ -188,6 +273,7 @@ export function MessageList({
onSenderClick,
onLoadOlder,
onResendChannelMessage,
onChannelReferenceClick,
radioName,
config,
onOpenContactInfo,
@@ -373,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 () => {
for (const timer of timers.values()) clearTimeout(timer);
@@ -896,7 +997,7 @@ export function MessageList({
<div className="break-words whitespace-pre-wrap">
{content.split('\n').map((line, i, arr) => (
<span key={i}>
{renderTextWithMentions(line, radioName)}
{renderTextWithMentions(line, radioName, onChannelReferenceClick)}
{i < arr.length - 1 && <br />}
</span>
))}
+178 -29
View File
@@ -1,58 +1,156 @@
import { useState, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Dice5 } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Checkbox } from './ui/checkbox';
import { Button } from './ui/button';
import { toast } from './ui/sonner';
type Tab = 'new-contact' | 'new-channel' | 'hashtag';
type Tab = 'new-contact' | 'new-channel' | 'hashtag' | 'bulk-hashtag';
interface BulkParseResult {
channelNames: string[];
invalidNames: string[];
}
interface NewMessageModalProps {
open: boolean;
undecryptedCount: number;
showBulkAddChannelTab?: boolean;
prefillRequest?: {
tab: 'hashtag';
hashtagName: string;
nonce: number;
} | null;
onClose: () => void;
onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>;
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
onCreateHashtagChannel: (name: string, tryHistorical: boolean) => Promise<void>;
onBulkAddHashtagChannels: (channelNames: string[], tryHistorical: boolean) => Promise<void>;
}
function validateHashtagName(channelName: string): string | null {
if (!channelName) {
return 'Channel name is required';
}
if (!/^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$/.test(channelName)) {
return 'Use letters, numbers, and single dashes (no leading/trailing dashes)';
}
return null;
}
function parseBulkHashtagNames(rawText: string, permitCapitals: boolean): BulkParseResult {
const tokens = rawText
.split(/[\s,]+/)
.map((token) => token.trim())
.filter(Boolean);
const invalidNames: string[] = [];
const channelNames: string[] = [];
const seen = new Set<string>();
for (const token of tokens) {
const stripped = token.replace(/^#+/, '');
const validationError = validateHashtagName(stripped);
if (validationError) {
invalidNames.push(token);
continue;
}
const normalized = permitCapitals ? stripped : stripped.toLowerCase();
const channelName = `#${normalized}`;
if (seen.has(channelName)) {
continue;
}
seen.add(channelName);
channelNames.push(channelName);
}
return { channelNames, invalidNames };
}
export function NewMessageModal({
open,
undecryptedCount,
showBulkAddChannelTab = false,
prefillRequest = null,
onClose,
onCreateContact,
onCreateChannel,
onCreateHashtagChannel,
onBulkAddHashtagChannels,
}: NewMessageModalProps) {
const [tab, setTab] = useState<Tab>('new-contact');
const [name, setName] = useState('');
const [contactKey, setContactKey] = useState('');
const [channelKey, setChannelKey] = useState('');
const [bulkChannelText, setBulkChannelText] = useState('');
const [tryHistorical, setTryHistorical] = useState(false);
const [permitCapitals, setPermitCapitals] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const hashtagInputRef = useRef<HTMLInputElement>(null);
const bulkTextareaRef = useRef<HTMLTextAreaElement>(null);
const resetForm = () => {
setName('');
setContactKey('');
setChannelKey('');
setBulkChannelText('');
setTryHistorical(false);
setPermitCapitals(false);
setError('');
};
useEffect(() => {
if (!open) {
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 () => {
setError('');
setLoading(true);
@@ -63,7 +161,6 @@ export function NewMessageModal({
setError('Name and public key are required');
return;
}
// handleCreateContact sets activeConversation with the backend-normalized key
await onCreateContact(name.trim(), contactKey.trim(), tryHistorical);
} else if (tab === 'new-channel') {
if (!name.trim() || !channelKey.trim()) {
@@ -78,10 +175,24 @@ export function NewMessageModal({
setError(validationError);
return;
}
// Normalize to lowercase unless user explicitly permits capitals
const normalizedName = permitCapitals ? channelName : channelName.toLowerCase();
await onCreateHashtagChannel(`#${normalizedName}`, tryHistorical);
} else {
const { channelNames, invalidNames } = parseBulkHashtagNames(
bulkChannelText,
permitCapitals
);
if (channelNames.length === 0) {
setError('Enter at least one valid room name');
return;
}
if (invalidNames.length > 0) {
setError(`Invalid room names: ${invalidNames.join(', ')}`);
return;
}
await onBulkAddHashtagChannels(channelNames, tryHistorical);
}
resetForm();
onClose();
} catch (err) {
@@ -94,16 +205,6 @@ export function NewMessageModal({
}
};
const validateHashtagName = (channelName: string): string | null => {
if (!channelName) {
return 'Channel name is required';
}
if (!/^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$/.test(channelName)) {
return 'Use letters, numbers, and single dashes (no leading/trailing dashes)';
}
return null;
};
const handleCreateAndAddAnother = async () => {
setError('');
const channelName = name.trim();
@@ -115,7 +216,6 @@ export function NewMessageModal({
setLoading(true);
try {
// Normalize to lowercase unless user explicitly permits capitals
const normalizedName = permitCapitals ? channelName : channelName.toLowerCase();
await onCreateHashtagChannel(`#${normalizedName}`, tryHistorical);
setName('');
@@ -142,28 +242,36 @@ export function NewMessageModal({
}
}}
>
<DialogContent className="sm:max-w-[500px]">
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>New Conversation</DialogTitle>
<DialogDescription className="sr-only">
{tab === 'new-contact' && 'Add a new contact by entering their name and public key'}
{tab === 'new-channel' && 'Create a private channel with a shared encryption key'}
{tab === 'hashtag' && 'Join a public hashtag channel'}
{tab === 'bulk-hashtag' && 'Paste multiple hashtag rooms to add them in one batch'}
</DialogDescription>
</DialogHeader>
<Tabs
value={tab}
onValueChange={(v) => {
setTab(v as Tab);
onValueChange={(value) => {
setTab(value as Tab);
resetForm();
}}
className="w-full"
>
<TabsList className="grid w-full grid-cols-3">
<TabsList
className={
showBulkAddChannelTab ? 'grid w-full grid-cols-4' : 'grid w-full grid-cols-3'
}
>
<TabsTrigger value="new-contact">Contact</TabsTrigger>
<TabsTrigger value="new-channel">Private Channel</TabsTrigger>
<TabsTrigger value="hashtag">Hashtag Channel</TabsTrigger>
{showBulkAddChannelTab && (
<TabsTrigger value="bulk-hashtag">Bulk Add Channel</TabsTrigger>
)}
</TabsList>
<TabsContent value="new-contact" className="mt-4 space-y-4">
@@ -215,7 +323,7 @@ export function NewMessageModal({
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
const hex = Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('');
setChannelKey(hex);
}}
@@ -244,20 +352,55 @@ export function NewMessageModal({
</div>
</div>
<div className="mt-3 space-y-1">
<label className="flex items-center gap-3 cursor-pointer">
<label className="flex cursor-pointer items-center gap-3">
<input
type="checkbox"
checked={permitCapitals}
onChange={(e) => setPermitCapitals(e.target.checked)}
className="w-4 h-4 rounded border-input accent-primary"
className="h-4 w-4 rounded border-input accent-primary"
/>
<span className="text-sm">Permit capitals in channel key derivation</span>
</label>
<p className="text-xs text-muted-foreground pl-7">
<p className="pl-7 text-xs text-muted-foreground">
Not recommended; most companions normalize to lowercase
</p>
</div>
</TabsContent>
{showBulkAddChannelTab && (
<TabsContent value="bulk-hashtag" className="mt-4 space-y-4">
<div className="space-y-2">
<Label htmlFor="bulk-hashtag-names">Bulk Add Channel</Label>
<textarea
ref={bulkTextareaRef}
id="bulk-hashtag-names"
aria-label="Bulk channel names"
value={bulkChannelText}
onChange={(e) => setBulkChannelText(e.target.value)}
placeholder={'#ops\nmesh-room\nanother-room'}
className="min-h-48 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
/>
<p className="text-xs text-muted-foreground">
Paste room names separated by lines, spaces, or commas. Leading # marks are
stripped automatically.
</p>
</div>
<div className="space-y-1">
<label className="flex cursor-pointer items-center gap-3">
<input
type="checkbox"
checked={permitCapitals}
onChange={(e) => setPermitCapitals(e.target.checked)}
className="h-4 w-4 rounded border-input accent-primary"
/>
<span className="text-sm">Permit capitals in channel key derivation</span>
</label>
<p className="pl-7 text-xs text-muted-foreground">
Not recommended; most companions normalize to lowercase
</p>
</div>
</TabsContent>
)}
</Tabs>
{showHistoricalOption && (
@@ -265,7 +408,7 @@ export function NewMessageModal({
<div className="flex items-center justify-end space-x-2">
<Label
htmlFor="try-historical"
className="text-sm text-muted-foreground cursor-pointer"
className="cursor-pointer text-sm text-muted-foreground"
>
Try decrypting {undecryptedCount.toLocaleString()} stored packet
{undecryptedCount !== 1 ? 's' : ''}
@@ -277,7 +420,7 @@ export function NewMessageModal({
/>
</div>
{tryHistorical && (
<p className="text-xs text-muted-foreground text-right">
<p className="text-right text-xs text-muted-foreground">
Messages will stream in as they decrypt in the background
</p>
)}
@@ -306,7 +449,13 @@ export function NewMessageModal({
</Button>
)}
<Button onClick={handleCreate} disabled={loading}>
{loading ? 'Creating...' : 'Create'}
{loading
? tab === 'bulk-hashtag'
? 'Adding...'
: 'Creating...'
: tab === 'bulk-hashtag'
? 'Add Channels'
: 'Create'}
</Button>
</DialogFooter>
</DialogContent>
+39 -24
View File
@@ -81,13 +81,14 @@ export function PathModal({
) : hasSinglePath ? (
<>
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
non-heard repeater advertisements.
Repeater identities are inferred from locally known advert and path data, so some
hops may be missing or misidentified when that data is incomplete.
</>
) : (
<>
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>
@@ -405,9 +406,12 @@ interface HopNodeProps {
distanceUnit: DistanceUnit;
}
const AMBIGUOUS_MATCH_PREVIEW_LIMIT = 3;
function HopNode({ hop, hopNumber, prevLocation, distanceUnit }: HopNodeProps) {
const isAmbiguous = hop.matches.length > 1;
const isUnknown = hop.matches.length === 0;
const [expanded, setExpanded] = useState(false);
// Calculate distance from previous location for a contact
// Returns null if prev location unknown/ambiguous or contact has no valid location
@@ -446,27 +450,38 @@ function HopNode({ hop, hopNumber, prevLocation, distanceUnit }: HopNodeProps) {
<div className="font-medium text-muted-foreground">&lt;UNKNOWN&gt;</div>
) : isAmbiguous ? (
<div>
{hop.matches.map((contact) => {
const dist = getDistanceForContact(contact);
const hasLocation = isValidLocation(contact.lat, contact.lon);
return (
<div key={contact.public_key} className="font-medium truncate">
{contact.name || contact.public_key.slice(0, 12)}
{dist !== null && (
<span className="text-xs text-muted-foreground ml-1">
- {formatDistance(dist, distanceUnit)}
</span>
)}
{hasLocation && (
<CoordinateLink
lat={contact.lat!}
lon={contact.lon!}
publicKey={contact.public_key}
/>
)}
</div>
);
})}
{(expanded ? hop.matches : hop.matches.slice(0, AMBIGUOUS_MATCH_PREVIEW_LIMIT)).map(
(contact) => {
const dist = getDistanceForContact(contact);
const hasLocation = isValidLocation(contact.lat, contact.lon);
return (
<div key={contact.public_key} className="font-medium truncate">
{contact.name || contact.public_key.slice(0, 12)}
{dist !== null && (
<span className="text-xs text-muted-foreground ml-1">
- {formatDistance(dist, distanceUnit)}
</span>
)}
{hasLocation && (
<CoordinateLink
lat={contact.lat!}
lon={contact.lon!}
publicKey={contact.public_key}
/>
)}
</div>
);
}
)}
{!expanded && hop.matches.length > AMBIGUOUS_MATCH_PREVIEW_LIMIT && (
<button
type="button"
className="text-xs text-primary hover:underline cursor-pointer"
onClick={() => setExpanded(true)}
>
(and {hop.matches.length - AMBIGUOUS_MATCH_PREVIEW_LIMIT} more)
</button>
)}
</div>
) : (
<div className="font-medium truncate">
+120 -76
View File
@@ -1,5 +1,15 @@
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';
@@ -24,6 +34,18 @@ interface RawPacketFeedViewProps {
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',
@@ -32,13 +54,7 @@ const WINDOW_LABELS: Record<RawPacketStatsWindow, string> = {
session: 'Session',
};
const TIMELINE_COLORS = [
'bg-sky-500/80',
'bg-emerald-500/80',
'bg-amber-500/80',
'bg-rose-500/80',
'bg-violet-500/80',
];
const TIMELINE_FILL_COLORS = ['#0ea5e9', '#10b981', '#f59e0b', '#f43f5e', '#8b5cf6'];
function formatTimestamp(timestampMs: number): string {
return new Date(timestampMs).toLocaleString([], {
@@ -155,24 +171,17 @@ function isNeighborIdentityResolvable(item: NeighborStat, contacts: Contact[]):
return resolveContact(item.key, contacts) !== null;
}
function formatStrongestPacketDetail(
function formatStrongestNeighborDetail(
stats: ReturnType<typeof buildRawPacketStatsSnapshot>,
contacts: Contact[]
): string | undefined {
if (!stats.strongestPacketPayloadType) {
const strongestNeighbor = stats.strongestNeighbors[0];
if (!strongestNeighbor || strongestNeighbor.bestRssi === null) {
return undefined;
}
const resolvedLabel =
resolveContactLabel(stats.strongestPacketSourceKey, contacts) ??
stats.strongestPacketSourceLabel;
if (resolvedLabel) {
return `${resolvedLabel} · ${stats.strongestPacketPayloadType}`;
}
if (stats.strongestPacketPayloadType === 'GroupText') {
return '<unknown sender> · GroupText';
}
return stats.strongestPacketPayloadType;
const resolvedNeighbor = resolveNeighbor(strongestNeighbor, contacts);
return `${formatRssi(resolvedNeighbor.bestRssi)} best heard`;
}
function getCoverageMessage(
@@ -220,7 +229,13 @@ function RankedBars({
emptyLabel: string;
formatter?: (item: RankedPacketStat) => string;
}) {
const maxCount = Math.max(...items.map((item) => item.count), 1);
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">
@@ -228,25 +243,36 @@ function RankedBars({
{items.length === 0 ? (
<p className="mt-3 text-sm text-muted-foreground">{emptyLabel}</p>
) : (
<div className="mt-3 space-y-2">
{items.map((item) => (
<div key={item.label}>
<div className="mb-1 flex items-center justify-between gap-3 text-xs">
<span className="truncate text-foreground">{item.label}</span>
<span className="shrink-0 tabular-nums text-muted-foreground">
{formatter
? formatter(item)
: `${item.count.toLocaleString()} · ${formatPercent(item.share)}`}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary/80"
style={{ width: `${(item.count / maxCount) * 100}%` }}
/>
</div>
</div>
))}
<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>
@@ -320,53 +346,66 @@ function NeighborList({
}
function TimelineChart({ bins }: { bins: PacketTimelineBin[] }) {
const maxTotal = Math.max(...bins.map((bin) => bin.total), 1);
const typeOrder = Array.from(new Set(bins.flatMap((bin) => Object.keys(bin.countsByType)))).slice(
0,
TIMELINE_COLORS.length
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, index) => (
{typeOrder.map((type, i) => (
<span key={type} className="inline-flex items-center gap-1">
<span className={cn('h-2 w-2 rounded-full', TIMELINE_COLORS[index])} />
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: TIMELINE_FILL_COLORS[i] }}
/>
<span>{type}</span>
</span>
))}
</div>
</div>
<div className="mt-3 flex items-start gap-1">
{bins.map((bin, index) => (
<div
key={`${bin.label}-${index}`}
className="flex min-w-0 flex-1 flex-col items-center gap-1"
>
<div className="flex h-24 w-full items-end overflow-hidden rounded-sm bg-muted/60">
<div className="flex h-full w-full flex-col justify-end">
{typeOrder.map((type, index) => {
const count = bin.countsByType[type] ?? 0;
if (count === 0) return null;
return (
<div
key={type}
className={cn('w-full', TIMELINE_COLORS[index])}
style={{
height: `${(count / maxTotal) * 100}%`,
}}
title={`${bin.label}: ${type} ${count.toLocaleString()}`}
/>
);
})}
</div>
</div>
<div className="text-[10px] text-muted-foreground">{bin.label}</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>
);
@@ -404,8 +443,13 @@ export function RawPacketFeedView({
[nowSec, rawPacketStatsSession, selectedWindow]
);
const coverageMessage = getCoverageMessage(stats, rawPacketStatsSession);
const strongestPacketDetail = useMemo(
() => formatStrongestPacketDetail(stats, contacts),
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(
@@ -532,9 +576,9 @@ export function RawPacketFeedView({
detail={`${formatPercent(stats.pathBearingRate)} path-bearing packets`}
/>
<StatTile
label="Best RSSI"
value={formatRssi(stats.bestRssi)}
detail={strongestPacketDetail ?? 'No signal sample in window'}
label="Strongest Neighbor"
value={strongestNeighbor?.label ?? '-'}
detail={strongestNeighborDetail ?? 'No neighbor RSSI sample in window'}
/>
<StatTile
label="Median RSSI"
+67 -6
View File
@@ -1,8 +1,9 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { api } from '../api';
import { toast } from './ui/sonner';
import { Button } from './ui/button';
import { Bell, Route, Star, Trash2 } from 'lucide-react';
import { Bell, Info, Route, Star, Trash2 } from 'lucide-react';
import { DirectTraceIcon } from './DirectTraceIcon';
import { RepeaterLogin } from './RepeaterLogin';
import { ServerLoginStatusBanner } from './ServerLoginStatusBanner';
@@ -12,7 +13,13 @@ import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
import { isValidLocation } from '../utils/pathUtils';
import { ContactStatusInfo } from './ContactStatusInfo';
import type { Contact, Conversation, Favorite, PathDiscoveryResponse } from '../types';
import type {
Contact,
Conversation,
Favorite,
PathDiscoveryResponse,
TelemetryHistoryEntry,
} from '../types';
import { cn } from '../lib/utils';
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
import { NeighborsPane } from './repeater/RepeaterNeighborsPane';
@@ -23,6 +30,7 @@ import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane';
import { OwnerInfoPane } from './repeater/RepeaterOwnerInfoPane';
import { ActionsPane } from './repeater/RepeaterActionsPane';
import { ConsolePane } from './repeater/RepeaterConsolePane';
import { TelemetryHistoryPane } from './repeater/RepeaterTelemetryHistoryPane';
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
// Re-export for backwards compatibility (used by repeaterFormatters.test.ts)
@@ -45,6 +53,7 @@ interface RepeaterDashboardProps {
onToggleNotifications: () => void;
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
onDeleteContact: (publicKey: string) => void;
onOpenContactInfo?: (publicKey: string) => void;
}
export function RepeaterDashboard({
@@ -62,6 +71,7 @@ export function RepeaterDashboard({
onToggleNotifications,
onToggleFavorite,
onDeleteContact,
onOpenContactInfo,
}: RepeaterDashboardProps) {
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
const contact = contacts.find((c) => c.public_key === conversation.id) ?? null;
@@ -88,7 +98,40 @@ export function RepeaterDashboard({
const { password, setPassword, rememberPassword, setRememberPassword, persistAfterLogin } =
useRememberedServerPassword('repeater', conversation.id);
// Telemetry history: preload from stored data, refresh from live status
const [telemetryHistory, setTelemetryHistory] = useState<TelemetryHistoryEntry[]>([]);
const telemetryHistorySourceRef = useRef<'none' | 'preload' | 'live'>('none');
const telemetryHistoryRequestRef = useRef(0);
useEffect(() => {
telemetryHistoryRequestRef.current += 1;
telemetryHistorySourceRef.current = 'none';
setTelemetryHistory([]);
if (!loggedIn) return;
const requestId = telemetryHistoryRequestRef.current;
api
.repeaterTelemetryHistory(conversation.id)
.then((history) => {
if (telemetryHistoryRequestRef.current !== requestId) return;
if (telemetryHistorySourceRef.current === 'live') return;
telemetryHistorySourceRef.current = 'preload';
setTelemetryHistory(history);
})
.catch(() => {});
}, [loggedIn, conversation.id]);
// When a live status fetch returns embedded telemetry_history, replace local state
useEffect(() => {
const liveHistory = paneData.status?.telemetry_history;
if (!liveHistory) return;
telemetryHistorySourceRef.current = 'live';
setTelemetryHistory(liveHistory);
}, [paneData.status?.telemetry_history]);
const isFav = isFavorite(favorites, 'contact', conversation.id);
const handleRepeaterLogin = async (nextPassword: string) => {
await login(nextPassword);
persistAfterLogin(nextPassword);
@@ -115,9 +158,24 @@ export function RepeaterDashboard({
<span className="flex min-w-0 flex-col">
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="min-w-0 flex-shrink truncate font-semibold text-base">
{conversation.name}
</span>
<h2 className="min-w-0 flex-shrink font-semibold text-base">
{onOpenContactInfo ? (
<button
type="button"
className="flex max-w-full min-w-0 items-center gap-1.5 overflow-hidden rounded-sm text-left transition-colors hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`View info for ${conversation.name}`}
onClick={() => onOpenContactInfo(conversation.id)}
>
<span className="truncate">{conversation.name}</span>
<Info
className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80"
aria-hidden="true"
/>
</button>
) : (
<span className="truncate">{conversation.name}</span>
)}
</h2>
<span
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary"
role="button"
@@ -336,6 +394,9 @@ export function RepeaterDashboard({
loading={consoleLoading}
onSend={sendConsoleCommand}
/>
{/* Telemetry history chart — full width, below console */}
<TelemetryHistoryPane entries={telemetryHistory} />
</div>
)}
</div>
+5 -1
View File
@@ -174,7 +174,11 @@ export function SearchView({
api
.getMessages({ q: debouncedQuery, limit: SEARCH_PAGE_SIZE, offset }, controller.signal)
.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);
setOffset((prev) => prev + data.length);
})
@@ -2,6 +2,7 @@ import { useState, useEffect, type ReactNode } from 'react';
import type {
AppSettings,
AppSettingsUpdate,
Contact,
HealthStatus,
RadioAdvertMode,
RadioConfig,
@@ -47,6 +48,8 @@ interface SettingsModalBaseProps {
blockedNames?: string[];
onToggleBlockedKey?: (key: string) => void;
onToggleBlockedName?: (name: string) => void;
contacts?: Contact[];
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
}
export type SettingsModalProps = SettingsModalBaseProps &
@@ -80,6 +83,8 @@ export function SettingsModal(props: SettingsModalProps) {
blockedNames,
onToggleBlockedKey,
onToggleBlockedName,
contacts,
onBulkDeleteContacts,
} = props;
const externalSidebarNav = props.externalSidebarNav === true;
const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined;
@@ -239,6 +244,8 @@ export function SettingsModal(props: SettingsModalProps) {
blockedNames={blockedNames}
onToggleBlockedKey={onToggleBlockedKey}
onToggleBlockedName={onToggleBlockedName}
contacts={contacts}
onBulkDeleteContacts={onBulkDeleteContacts}
className={sectionContentClass}
/>
) : (
+85 -62
View File
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Bell,
Cable,
ChartNetwork,
CheckCheck,
ChevronDown,
ChevronRight,
@@ -9,7 +11,6 @@ import {
Map,
Search as SearchIcon,
SquarePen,
Waypoints,
X,
} from 'lucide-react';
import {
@@ -96,7 +97,7 @@ interface SidebarProps {
channels: Channel[];
activeConversation: Conversation | null;
onSelectConversation: (conversation: Conversation) => void;
onNewMessage: () => void;
onNewMessage: (event?: React.MouseEvent<HTMLButtonElement>) => void;
lastMessageTimes: ConversationTimes;
unreadCounts: Record<string, number>;
/** Tracks which conversations have unread messages that mention the user */
@@ -109,6 +110,8 @@ interface SidebarProps {
/** Legacy global sort order, used only to seed per-section local preferences. */
legacySortOrder?: SortOrder;
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
blockedKeys?: string[];
blockedNames?: string[];
}
type InitialSectionSortState = {
@@ -152,7 +155,16 @@ export function Sidebar({
favorites,
legacySortOrder,
isConversationNotificationsEnabled,
blockedKeys = [],
blockedNames = [],
}: SidebarProps) {
const isContactBlocked = useCallback(
(c: Contact) =>
blockedKeys.includes(c.public_key.toLowerCase()) ||
(c.name != null && blockedNames.includes(c.name)),
[blockedKeys, blockedNames]
);
const [searchQuery, setSearchQuery] = useState('');
const initialSectionSortState = useMemo(loadInitialSectionSortOrders, []);
const [sectionSortOrders, setSectionSortOrders] = useState(initialSectionSortState.orders);
@@ -197,7 +209,7 @@ export function Sidebar({
};
const isActive = (
type: 'contact' | 'channel' | 'raw' | 'map' | 'visualizer' | 'search',
type: 'contact' | 'channel' | 'raw' | 'map' | 'visualizer' | 'search' | 'trace',
id: string
) => activeConversation?.type === type && activeConversation?.id === id;
@@ -397,38 +409,32 @@ export function Sidebar({
[sortedChannels, query]
);
const filteredNonRepeaterContacts = useMemo(
() =>
query
? sortedNonRepeaterContacts.filter(
(c) =>
c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
)
: sortedNonRepeaterContacts,
[sortedNonRepeaterContacts, query]
);
const filteredNonRepeaterContacts = useMemo(() => {
const visible = sortedNonRepeaterContacts.filter((c) => !isContactBlocked(c));
return query
? visible.filter(
(c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
)
: visible;
}, [sortedNonRepeaterContacts, query, isContactBlocked]);
const filteredRooms = useMemo(
() =>
query
? sortedRooms.filter(
(c) =>
c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
)
: sortedRooms,
[sortedRooms, query]
);
const filteredRooms = useMemo(() => {
const visible = sortedRooms.filter((c) => !isContactBlocked(c));
return query
? visible.filter(
(c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
)
: visible;
}, [sortedRooms, query, isContactBlocked]);
const filteredRepeaters = useMemo(
() =>
query
? sortedRepeaters.filter(
(c) =>
c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
)
: sortedRepeaters,
[sortedRepeaters, query]
);
const filteredRepeaters = useMemo(() => {
const visible = sortedRepeaters.filter((c) => !isContactBlocked(c));
return query
? visible.filter(
(c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
)
: visible;
}, [sortedRepeaters, query, isContactBlocked]);
// Expand sections while searching; restore prior collapse state when search ends.
useEffect(() => {
@@ -653,8 +659,9 @@ export function Sidebar({
}) => (
<div
key={key}
data-active={active ? 'true' : undefined}
className={cn(
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
'sidebar-action-row px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
active && 'bg-accent border-l-primary'
)}
role="button"
@@ -663,10 +670,10 @@ export function Sidebar({
onKeyDown={handleKeyboardActivate}
onClick={onClick}
>
<span className="sidebar-tool-icon text-muted-foreground" aria-hidden="true">
<span className="sidebar-tool-icon" aria-hidden="true">
{icon}
</span>
<span className="sidebar-tool-label flex-1 truncate text-muted-foreground">{label}</span>
<span className="sidebar-tool-label flex-1 truncate">{label}</span>
</div>
);
@@ -721,7 +728,7 @@ export function Sidebar({
renderSidebarActionRow({
key: 'tool-visualizer',
active: isActive('visualizer', 'visualizer'),
icon: <Waypoints className="h-4 w-4" />,
icon: <ChartNetwork className="h-4 w-4" />,
label: 'Mesh Visualizer',
onClick: () =>
handleSelectConversation({
@@ -730,6 +737,18 @@ export function Sidebar({
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({
key: 'tool-search',
active: isActive('search', 'search'),
@@ -840,41 +859,45 @@ export function Sidebar({
aria-label="Conversations"
>
{/* Header */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-border">
<div className="relative min-w-0 flex-1">
<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 className="px-3 py-2 border-b border-border">
<Button
variant="ghost"
variant="outline"
size="sm"
onClick={onNewMessage}
title="New Message"
aria-label="New message"
className="h-7 w-7 shrink-0 p-0 text-muted-foreground hover:text-foreground transition-colors"
title="Add channel or contact"
aria-label="Add channel or contact"
className="h-8 w-full justify-start gap-2 border-primary/20 bg-primary/5 px-3 text-[13px] text-primary hover:bg-primary/10 hover:text-primary"
>
<SquarePen className="h-4 w-4" />
<span>Add Channel/Contact</span>
</Button>
</div>
{/* List */}
<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 */}
{toolRows.length > 0 && (
<>
+721
View File
@@ -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>
);
}
@@ -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>
);
}
@@ -0,0 +1,352 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { api } from '../../api';
import { getContactDisplayName } from '../../utils/pubkey';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '../ui/dialog';
import { toast } from '../ui/sonner';
import type { Contact } from '../../types';
const CONTACT_TYPE_LABELS: Record<number, string> = {
0: 'Unknown',
1: 'Client',
2: 'Repeater',
3: 'Room',
4: 'Sensor',
};
function formatDate(ts: number): string {
return new Date(ts * 1000).toLocaleDateString([], {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
}
function formatDateISO(ts: number): string {
return new Date(ts * 1000).toISOString().slice(0, 10);
}
function datetimeToUnix(datetimeStr: string): number {
const d = new Date(datetimeStr);
return Math.floor(d.getTime() / 1000);
}
interface BulkDeleteContactsModalProps {
open: boolean;
onClose: () => void;
contacts: Contact[];
onDeleted: (deletedKeys: string[]) => void;
}
export function BulkDeleteContactsModal({
open,
onClose,
contacts,
onDeleted,
}: BulkDeleteContactsModalProps) {
const [step, setStep] = useState<'select' | 'confirm'>('select');
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [typeFilter, setTypeFilter] = useState<number | 'all'>('all');
const [deleting, setDeleting] = useState(false);
const lastClickedKeyRef = useRef<string | null>(null);
const resetAndClose = useCallback(() => {
setStep('select');
setSelectedKeys(new Set());
setStartDate('');
setEndDate('');
setTypeFilter('all');
lastClickedKeyRef.current = null;
onClose();
}, [onClose]);
const filteredContacts = useMemo(() => {
let list = [...contacts].sort((a, b) => (b.first_seen ?? 0) - (a.first_seen ?? 0));
if (typeFilter !== 'all') {
list = list.filter((c) => c.type === typeFilter);
}
if (startDate) {
const start = datetimeToUnix(startDate);
list = list.filter((c) => (c.first_seen ?? 0) >= start);
}
if (endDate) {
const end = datetimeToUnix(endDate);
list = list.filter((c) => (c.first_seen ?? 0) <= end);
}
return list;
}, [contacts, typeFilter, startDate, endDate]);
const handleToggle = (key: string, shiftKey: boolean) => {
if (shiftKey && lastClickedKeyRef.current && lastClickedKeyRef.current !== key) {
const keys = filteredContacts.map((c) => c.public_key);
const lastIdx = keys.indexOf(lastClickedKeyRef.current);
const curIdx = keys.indexOf(key);
if (lastIdx >= 0 && curIdx >= 0) {
const from = Math.min(lastIdx, curIdx);
const to = Math.max(lastIdx, curIdx);
const rangeKeys = keys.slice(from, to + 1);
setSelectedKeys((prev) => {
const next = new Set(prev);
for (const k of rangeKeys) next.add(k);
return next;
});
lastClickedKeyRef.current = key;
return;
}
}
setSelectedKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
lastClickedKeyRef.current = key;
};
const handleSelectAll = () => {
setSelectedKeys(new Set(filteredContacts.map((c) => c.public_key)));
};
const handleSelectNone = () => {
setSelectedKeys(new Set());
};
const selectedContacts = useMemo(
() => contacts.filter((c) => selectedKeys.has(c.public_key)),
[contacts, selectedKeys]
);
const contactCount = selectedContacts.filter((c) => c.type === 1 || c.type === 0).length;
const repeaterCount = selectedContacts.filter((c) => c.type === 2).length;
const roomCount = selectedContacts.filter((c) => c.type === 3).length;
const sensorCount = selectedContacts.filter((c) => c.type === 4).length;
const firstSeenDates = selectedContacts.map((c) => c.first_seen ?? 0).filter((t) => t > 0);
const minDate =
firstSeenDates.length > 0 ? formatDateISO(Math.min(...firstSeenDates)) : 'unknown';
const maxDate =
firstSeenDates.length > 0 ? formatDateISO(Math.max(...firstSeenDates)) : 'unknown';
const handleDelete = async () => {
setDeleting(true);
try {
const keysToDelete = [...selectedKeys];
const result = await api.bulkDeleteContacts(keysToDelete);
toast.success(`Deleted ${result.deleted} contact${result.deleted === 1 ? '' : 's'}`);
onDeleted(keysToDelete);
resetAndClose();
} catch (err) {
console.error('Bulk delete failed:', err);
toast.error('Bulk delete failed', {
description: err instanceof Error ? err.message : undefined,
});
} finally {
setDeleting(false);
}
};
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && resetAndClose()}>
<DialogContent className="sm:max-w-2xl max-h-[85dvh] flex flex-col">
<DialogHeader>
<DialogTitle>
{step === 'select' ? 'Bulk Delete Contacts' : 'Confirm Deletion'}
</DialogTitle>
<DialogDescription>
{step === 'select'
? 'Select contacts to delete. Message history will be preserved and accessible if a contact is re-added, but will no longer appear in the sidebar.'
: 'Review the contacts that will be permanently deleted.'}
</DialogDescription>
</DialogHeader>
{step === 'select' && (
<>
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1">
<label className="text-xs text-muted-foreground">Show</label>
<select
value={typeFilter === 'all' ? 'all' : String(typeFilter)}
onChange={(e) =>
setTypeFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))
}
className="block h-8 rounded-md border border-input bg-background px-2 text-sm"
>
<option value="all">All</option>
<option value="1">Clients</option>
<option value="2">Repeaters</option>
<option value="3">Room Servers</option>
<option value="4">Sensors</option>
</select>
</div>
<div className="space-y-1">
<label className="text-xs text-muted-foreground">Created after</label>
<Input
type="datetime-local"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-48 h-8 text-sm"
/>
</div>
<div className="space-y-1">
<label className="text-xs text-muted-foreground">Created before</label>
<Input
type="datetime-local"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-48 h-8 text-sm"
/>
</div>
<div className="flex gap-1.5">
<Button type="button" variant="outline" size="sm" onClick={handleSelectAll}>
Select all
</Button>
<Button type="button" variant="outline" size="sm" onClick={handleSelectNone}>
Select none
</Button>
</div>
</div>
<div className="text-xs text-muted-foreground">
{filteredContacts.length} contact{filteredContacts.length === 1 ? '' : 's'} shown
{(startDate || endDate) && ' (filtered)'}
{' · '}
{selectedKeys.size} selected
</div>
<div className="flex-1 overflow-y-auto min-h-0 border border-border rounded-md">
{filteredContacts.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground">
No contacts match the selected date range.
</div>
) : (
<table className="w-full text-sm">
<thead className="sticky top-0 bg-muted/90 backdrop-blur-sm">
<tr className="text-left text-xs text-muted-foreground">
<th className="px-3 py-1.5 w-8" />
<th className="px-3 py-1.5">Name</th>
<th className="px-3 py-1.5 hidden sm:table-cell">Type</th>
<th className="px-3 py-1.5">Key</th>
<th className="px-3 py-1.5 hidden sm:table-cell">Created</th>
</tr>
</thead>
<tbody>
{filteredContacts.map((c) => (
<tr
key={c.public_key}
className="border-t border-border hover:bg-accent/50 cursor-pointer"
onClick={(e) => handleToggle(c.public_key, e.shiftKey)}
>
<td className="px-3 py-1.5">
<input
type="checkbox"
checked={selectedKeys.has(c.public_key)}
onChange={(e) =>
handleToggle(
c.public_key,
e.nativeEvent instanceof MouseEvent && e.nativeEvent.shiftKey
)
}
onClick={(e) => e.stopPropagation()}
className="rounded border-input"
/>
</td>
<td className="px-3 py-1.5 truncate max-w-[10rem]">
{getContactDisplayName(c.name, c.public_key, c.last_advert)}
</td>
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
{CONTACT_TYPE_LABELS[c.type] ?? 'Unknown'}
</td>
<td className="px-3 py-1.5 font-mono text-xs text-muted-foreground truncate max-w-[8rem]">
{c.public_key.slice(0, 12)}
</td>
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
{c.first_seen ? formatDate(c.first_seen) : '—'}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={resetAndClose}>
Cancel
</Button>
<Button
variant="outline"
className="border-warning text-warning hover:bg-warning/10 hover:text-warning"
disabled={selectedKeys.size === 0}
onClick={() => setStep('confirm')}
>
Proceed to confirmation ({selectedKeys.size})
</Button>
</div>
</>
)}
{step === 'confirm' && (
<>
<div className="flex-1 overflow-y-auto min-h-0 border border-border rounded-md">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-muted/90 backdrop-blur-sm">
<tr className="text-left text-xs text-muted-foreground">
<th className="px-3 py-1.5">Name</th>
<th className="px-3 py-1.5">Type</th>
<th className="px-3 py-1.5">Key</th>
<th className="px-3 py-1.5 hidden sm:table-cell">Created</th>
</tr>
</thead>
<tbody>
{selectedContacts.map((c) => (
<tr key={c.public_key} className="border-t border-border">
<td className="px-3 py-1.5 truncate max-w-[12rem]">
{getContactDisplayName(c.name, c.public_key, c.last_advert)}
</td>
<td className="px-3 py-1.5 text-xs text-muted-foreground">
{CONTACT_TYPE_LABELS[c.type] ?? 'Unknown'}
</td>
<td className="px-3 py-1.5 font-mono text-xs text-muted-foreground truncate max-w-[8rem]">
{c.public_key.slice(0, 12)}
</td>
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
{c.first_seen ? formatDate(c.first_seen) : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex flex-col gap-3 pt-2">
<Button
variant="destructive"
className="w-full h-auto py-3 text-wrap"
disabled={deleting}
onClick={handleDelete}
>
{deleting
? 'Deleting...'
: `I confirm permanent, irrevocable deletion of all listed nodes above, totalling ${[
contactCount > 0 && `${contactCount} contact${contactCount === 1 ? '' : 's'}`,
repeaterCount > 0 &&
`${repeaterCount} repeater${repeaterCount === 1 ? '' : 's'}`,
roomCount > 0 && `${roomCount} room${roomCount === 1 ? '' : 's'}`,
sensorCount > 0 && `${sensorCount} sensor${sensorCount === 1 ? '' : 's'}`,
]
.filter(Boolean)
.join(', ')}, spanning creation dates from ${minDate} to ${maxDate}`}
</Button>
<Button variant="secondary" onClick={() => setStep('select')} disabled={deleting}>
Back
</Button>
</div>
</>
)}
</DialogContent>
</Dialog>
);
}
@@ -6,7 +6,8 @@ import { Separator } from '../ui/separator';
import { toast } from '../ui/sonner';
import { api } from '../../api';
import { formatTime } from '../../utils/messageParser';
import type { AppSettings, AppSettingsUpdate, HealthStatus } from '../../types';
import { BulkDeleteContactsModal } from './BulkDeleteContactsModal';
import type { AppSettings, AppSettingsUpdate, Contact, HealthStatus } from '../../types';
export function SettingsDatabaseSection({
appSettings,
@@ -17,6 +18,8 @@ export function SettingsDatabaseSection({
blockedNames = [],
onToggleBlockedKey,
onToggleBlockedName,
contacts = [],
onBulkDeleteContacts,
className,
}: {
appSettings: AppSettings;
@@ -27,18 +30,23 @@ export function SettingsDatabaseSection({
blockedNames?: string[];
onToggleBlockedKey?: (key: string) => void;
onToggleBlockedName?: (name: string) => void;
contacts?: Contact[];
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
className?: string;
}) {
const [retentionDays, setRetentionDays] = useState('14');
const [cleaning, setCleaning] = useState(false);
const [purgingDecryptedRaw, setPurgingDecryptedRaw] = useState(false);
const [autoDecryptOnAdvert, setAutoDecryptOnAdvert] = useState(false);
const [discoveryBlockedTypes, setDiscoveryBlockedTypes] = useState<number[]>([]);
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert);
setDiscoveryBlockedTypes(appSettings.discovery_blocked_types ?? []);
}, [appSettings]);
const handleCleanup = async () => {
@@ -92,7 +100,15 @@ export function SettingsDatabaseSection({
setError(null);
try {
await onSaveAppSettings({ auto_decrypt_dm_on_advert: autoDecryptOnAdvert });
const update: AppSettingsUpdate = { auto_decrypt_dm_on_advert: autoDecryptOnAdvert };
const currentBlocked = appSettings.discovery_blocked_types ?? [];
if (
discoveryBlockedTypes.length !== currentBlocked.length ||
discoveryBlockedTypes.some((t) => !currentBlocked.includes(t))
) {
update.discovery_blocked_types = discoveryBlockedTypes;
}
await onSaveAppSettings(update);
toast.success('Database settings saved');
} catch (err) {
console.error('Failed to save database settings:', err);
@@ -105,93 +121,93 @@ export function SettingsDatabaseSection({
return (
<div className={className}>
{/* ── Database Overview ── */}
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Database size</span>
<span className="font-medium">{health?.database_size_mb ?? '?'} MB</span>
</div>
{health?.oldest_undecrypted_timestamp ? (
<Label className="text-base">Database Overview</Label>
<div className="rounded-md border border-border bg-muted/30 p-3 space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Oldest undecrypted packet</span>
<span className="font-medium">
{formatTime(health.oldest_undecrypted_timestamp)}
<span className="text-muted-foreground ml-1">
({Math.floor((Date.now() / 1000 - health.oldest_undecrypted_timestamp) / 86400)}{' '}
days old)
<span className="text-sm">Database size</span>
<span className="text-sm font-semibold">{health?.database_size_mb ?? '?'} MB</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm">Oldest undecrypted packet</span>
{health?.oldest_undecrypted_timestamp ? (
<span className="text-sm font-semibold">
{formatTime(health.oldest_undecrypted_timestamp)}
<span className="font-normal text-muted-foreground ml-1">
({Math.floor((Date.now() / 1000 - health.oldest_undecrypted_timestamp) / 86400)}{' '}
days)
</span>
</span>
</span>
) : (
<span className="text-sm text-muted-foreground">None</span>
)}
</div>
) : (
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Oldest undecrypted packet</span>
<span className="text-muted-foreground">None</span>
</div>
)}
</div>
</div>
<Separator />
<div className="space-y-3">
<Label>Delete Undecrypted Packets</Label>
<p className="text-xs text-muted-foreground">
Permanently deletes stored raw packets containing DMs and channel messages that have not
yet been decrypted. These packets are retained in case you later obtain the correct key
once deleted, these messages can never be recovered or decrypted.
</p>
<div className="flex gap-2 items-end">
<div className="space-y-1">
<Label htmlFor="retention-days" className="text-xs">
Older than (days)
</Label>
<Input
id="retention-days"
type="number"
min="1"
max="365"
value={retentionDays}
onChange={(e) => setRetentionDays(e.target.value)}
className="w-24"
/>
{/* ── Storage Cleanup ── */}
<div className="space-y-4">
<Label className="text-base">Storage Cleanup</Label>
<div className="rounded-md border border-border p-3 space-y-2">
<Label className="text-sm">Delete Undecrypted Packets</Label>
<p className="text-xs text-muted-foreground">
Permanently deletes stored raw packets that have not yet been decrypted. These are
retained in case you later obtain the correct key once deleted, these messages can
never be recovered.
</p>
<div className="flex gap-2 items-end">
<div className="space-y-1">
<Label htmlFor="retention-days" className="text-xs text-muted-foreground">
Older than (days)
</Label>
<Input
id="retention-days"
type="number"
min="1"
max="365"
value={retentionDays}
onChange={(e) => setRetentionDays(e.target.value)}
className="w-24"
/>
</div>
<Button
variant="outline"
onClick={handleCleanup}
disabled={cleaning}
className="border-destructive/50 text-destructive hover:bg-destructive/10"
>
{cleaning ? 'Deleting...' : 'Delete'}
</Button>
</div>
</div>
<div className="rounded-md border border-border p-3 space-y-2">
<Label className="text-sm">Purge Archival Raw Packets</Label>
<p className="text-xs text-muted-foreground">
Deletes the raw packet bytes behind messages that are already decrypted and visible in
chat. This frees space but removes packet-analysis availability for those messages. It
does not affect displayed messages or future decryption.
</p>
<Button
variant="outline"
onClick={handleCleanup}
disabled={cleaning}
className="border-destructive/50 text-destructive hover:bg-destructive/10"
onClick={handlePurgeDecryptedRawPackets}
disabled={purgingDecryptedRaw}
className="w-full border-warning/50 text-warning hover:bg-warning/10"
>
{cleaning ? 'Deleting...' : 'Permanently Delete'}
{purgingDecryptedRaw ? 'Purging...' : 'Purge Archival Packets'}
</Button>
</div>
</div>
<Separator />
{/* ── DM Decryption ── */}
<div className="space-y-3">
<Label>Purge Archival Raw Packets</Label>
<p className="text-xs text-muted-foreground">
Deletes archival copies of raw packet bytes for messages that are already decrypted and
visible in your chat history.{' '}
<em className="text-muted-foreground/80">
This will not affect any displayed messages or your ability to do historical decryption,
but it will remove packet-analysis availability for those historical messages.
</em>{' '}
The raw bytes are only useful for manual packet analysis.
</p>
<Button
variant="outline"
onClick={handlePurgeDecryptedRawPackets}
disabled={purgingDecryptedRaw}
className="w-full border-warning/50 text-warning hover:bg-warning/10"
>
{purgingDecryptedRaw ? 'Purging Archival Raw Packets...' : 'Purge Archival Raw Packets'}
</Button>
</div>
<Separator />
<div className="space-y-3">
<Label>DM Decryption</Label>
<Label className="text-base">DM Decryption</Label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
@@ -207,17 +223,87 @@ export function SettingsDatabaseSection({
</p>
</div>
{error && (
<div className="text-sm text-destructive" role="alert">
{error}
</div>
)}
<Button onClick={handleSave} disabled={busy} className="w-full">
{busy ? 'Saving...' : 'Save Settings'}
</Button>
<Separator />
{/* ── Contact Management ── */}
<div className="space-y-2">
<Label className="text-base">Contact Management</Label>
</div>
{/* Block discovery of new node types */}
<div className="space-y-3">
<Label>Block Discovery of New Node Types</Label>
<p className="text-xs text-muted-foreground">
Checked types will be ignored when heard via advertisement. Existing contacts of these
types are still updated. This does not affect contacts added manually or via DM.
</p>
<div className="space-y-1.5">
{(
[
[1, 'Block clients'],
[2, 'Block repeaters'],
[3, 'Block room servers'],
[4, 'Block sensors'],
] as const
).map(([typeCode, label]) => {
const checked = discoveryBlockedTypes.includes(typeCode);
return (
<label key={typeCode} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={() =>
setDiscoveryBlockedTypes((prev) =>
checked ? prev.filter((t) => t !== typeCode) : [...prev, typeCode]
)
}
className="rounded border-input"
/>
{label}
</label>
);
})}
</div>
{discoveryBlockedTypes.length > 0 && (
<p className="text-xs text-warning">
New{' '}
{discoveryBlockedTypes
.map((t) =>
t === 1 ? 'clients' : t === 2 ? 'repeaters' : t === 3 ? 'room servers' : 'sensors'
)
.join(', ')}{' '}
heard via advertisement will not be added to your contact list.
</p>
)}
</div>
<Separator />
{/* Blocked contacts list */}
<div className="space-y-3">
<Label>Blocked Contacts</Label>
<p className="text-xs text-muted-foreground">
Blocking only hides messages from the UI. MQTT forwarding and bot responses are not
affected. Messages are still stored and will reappear if unblocked.
Blocked contacts are hidden from the sidebar. Blocking only hides messages from the UI
MQTT forwarding and bot responses are not affected. Messages are still stored and will
reappear if unblocked.
</p>
{blockedKeys.length === 0 && blockedNames.length === 0 ? (
<p className="text-sm text-muted-foreground italic">No blocked contacts</p>
<p className="text-sm text-muted-foreground italic">
No blocked contacts. Block contacts from their info pane, viewed by clicking their
avatar in any channel, or their name within the top status bar with the conversation
open.
</p>
) : (
<div className="space-y-2">
{blockedKeys.length > 0 && (
@@ -268,15 +354,25 @@ export function SettingsDatabaseSection({
)}
</div>
{error && (
<div className="text-sm text-destructive" role="alert">
{error}
</div>
)}
<Separator />
<Button onClick={handleSave} disabled={busy} className="w-full">
{busy ? 'Saving...' : 'Save Settings'}
</Button>
{/* Bulk delete */}
<div className="space-y-3">
<Label>Bulk Delete Contacts</Label>
<p className="text-xs text-muted-foreground">
Remove multiple contacts or repeaters at once. Useful for cleaning up spam or unwanted
nodes. Message history will be preserved.
</p>
<Button variant="outline" className="w-full" onClick={() => setBulkDeleteOpen(true)}>
Open Bulk Delete
</Button>
<BulkDeleteContactsModal
open={bulkDeleteOpen}
onClose={() => setBulkDeleteOpen(false)}
contacts={contacts}
onDeleted={(keys) => onBulkDeleteContacts?.(keys)}
/>
</div>
</div>
);
}
@@ -1,10 +1,17 @@
import { useState, useEffect, useCallback, useMemo, useRef, lazy, Suspense } from 'react';
import { ChevronDown } from 'lucide-react';
import { ChevronDown, Info } from 'lucide-react';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Button } from '../ui/button';
import { Separator } from '../ui/separator';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../ui/dialog';
import { toast } from '../ui/sonner';
import { cn } from '@/lib/utils';
import { api } from '../../api';
@@ -309,6 +316,80 @@ const CREATE_INTEGRATION_DEFINITIONS_BY_VALUE = Object.fromEntries(
CREATE_INTEGRATION_DEFINITIONS.map((definition) => [definition.value, definition])
) as Record<DraftType, CreateIntegrationDefinition>;
function getNumberInputValue(value: unknown, fallback: number): string | number {
if (value === '') return '';
if (typeof value === 'string') return value;
if (typeof value === 'number' && Number.isFinite(value)) return value;
return fallback;
}
function getOptionalNumberInputValue(value: unknown): string | number {
if (value === '') return '';
if (typeof value === 'string') return value;
if (typeof value === 'number' && Number.isFinite(value)) return value;
return '';
}
function parseIntegerInputValue(value: string): number | string {
if (value === '') return '';
const parsed = Number.parseInt(value, 10);
return Number.isNaN(parsed) ? value : parsed;
}
function parseFloatInputValue(value: string): number | string {
if (value === '') return '';
const parsed = Number.parseFloat(value);
return Number.isNaN(parsed) ? value : parsed;
}
function normalizeIntegrationConfigForSave(
configType: string,
config: Record<string, unknown>
): Record<string, unknown> {
const normalized = { ...config };
if (configType === 'mqtt_private') {
const port = normalized.broker_port;
if (port === '' || port === undefined || port === null) {
normalized.broker_port = 1883;
} else if (typeof port === 'string') {
const parsed = Number.parseInt(port, 10);
normalized.broker_port = Number.isNaN(parsed) ? 1883 : parsed;
}
const topicPrefix = String(normalized.topic_prefix ?? '').trim();
normalized.topic_prefix = topicPrefix || 'meshcore';
}
if (configType === 'mqtt_community') {
const brokerHost = String(normalized.broker_host ?? '').trim();
normalized.broker_host = brokerHost || DEFAULT_COMMUNITY_BROKER_HOST;
const port = normalized.broker_port;
if (port === '' || port === undefined || port === null) {
normalized.broker_port = DEFAULT_COMMUNITY_BROKER_PORT;
} else if (typeof port === 'string') {
const parsed = Number.parseInt(port, 10);
normalized.broker_port = Number.isNaN(parsed) ? DEFAULT_COMMUNITY_BROKER_PORT : parsed;
}
const topicTemplate = String(normalized.topic_template ?? '').trim();
normalized.topic_template = topicTemplate || DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE;
}
if (configType === 'map_upload') {
const radius = normalized.geofence_radius_km;
if (radius === '' || radius === undefined || radius === null) {
normalized.geofence_radius_km = 0;
} else if (typeof radius === 'string') {
const parsed = Number.parseFloat(radius);
normalized.geofence_radius_km = Number.isNaN(parsed) ? 0 : parsed;
}
}
return normalized;
}
function isDraftType(value: string): value is DraftType {
return value in CREATE_INTEGRATION_DEFINITIONS_BY_VALUE;
}
@@ -331,7 +412,7 @@ function normalizeDraftConfig(draftType: DraftType, config: Record<string, unkno
throw new Error('MeshRank packet topic is required');
}
return {
return normalizeIntegrationConfigForSave('mqtt_community', {
...config,
broker_host: DEFAULT_MESHRANK_BROKER_HOST,
broker_port: DEFAULT_MESHRANK_BROKER_PORT,
@@ -345,7 +426,7 @@ function normalizeDraftConfig(draftType: DraftType, config: Record<string, unkno
topic_template: topicTemplate,
username: '',
password: '',
};
});
}
if (draftType === 'mqtt_community_letsmesh_us' || draftType === 'mqtt_community_letsmesh_eu') {
@@ -353,7 +434,7 @@ function normalizeDraftConfig(draftType: DraftType, config: Record<string, unkno
draftType === 'mqtt_community_letsmesh_eu'
? DEFAULT_COMMUNITY_BROKER_HOST_EU
: DEFAULT_COMMUNITY_BROKER_HOST;
return {
return normalizeIntegrationConfigForSave('mqtt_community', {
...config,
broker_host: brokerHost,
broker_port: DEFAULT_COMMUNITY_BROKER_PORT,
@@ -365,10 +446,13 @@ function normalizeDraftConfig(draftType: DraftType, config: Record<string, unkno
topic_template: (config.topic_template as string) || DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE,
username: '',
password: '',
};
});
}
return config;
return normalizeIntegrationConfigForSave(
getCreateIntegrationDefinition(draftType).savedType,
config
);
}
function normalizeDraftScope(draftType: DraftType, scope: Record<string, unknown>) {
@@ -559,16 +643,20 @@ function formatPrivateTopicSummary(config: Record<string, unknown>) {
return `${prefix}/dm:<pubkey>, ${prefix}/gm:<channel>, ${prefix}/raw/...`;
}
function formatAppriseTargets(urls: string | undefined, maxLength = 80) {
function censorAppriseUrl(url: string): string {
const protoMatch = url.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//);
if (protoMatch) return `${protoMatch[0]}********`;
return '********';
}
function formatAppriseTargets(urls: string | undefined) {
const targets = (urls || '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
if (targets.length === 0) return 'No targets configured';
const joined = targets.join(', ');
if (joined.length <= maxLength) return joined;
return `${joined.slice(0, maxLength - 3)}...`;
return targets.map(censorAppriseUrl).join(', ');
}
function formatSqsQueueSummary(config: Record<string, unknown>) {
@@ -642,9 +730,9 @@ function MqttPrivateConfigEditor({
type="number"
min="1"
max="65535"
value={(config.broker_port as number) || 1883}
value={getNumberInputValue(config.broker_port, 1883)}
onChange={(e) =>
onChange({ ...config, broker_port: parseInt(e.target.value, 10) || 1883 })
onChange({ ...config, broker_port: parseIntegerInputValue(e.target.value) })
}
/>
</div>
@@ -702,7 +790,8 @@ function MqttPrivateConfigEditor({
<Input
id="fanout-mqtt-prefix"
type="text"
value={(config.topic_prefix as string) || 'meshcore'}
placeholder="meshcore"
value={(config.topic_prefix as string | undefined) ?? ''}
onChange={(e) => onChange({ ...config, topic_prefix: e.target.value })}
/>
</div>
@@ -738,7 +827,7 @@ function MqttCommunityConfigEditor({
id="fanout-comm-host"
type="text"
placeholder={DEFAULT_COMMUNITY_BROKER_HOST}
value={(config.broker_host as string) || DEFAULT_COMMUNITY_BROKER_HOST}
value={(config.broker_host as string | undefined) ?? ''}
onChange={(e) => onChange({ ...config, broker_host: e.target.value })}
/>
</div>
@@ -749,11 +838,11 @@ function MqttCommunityConfigEditor({
type="number"
min="1"
max="65535"
value={(config.broker_port as number) || DEFAULT_COMMUNITY_BROKER_PORT}
value={getNumberInputValue(config.broker_port, DEFAULT_COMMUNITY_BROKER_PORT)}
onChange={(e) =>
onChange({
...config,
broker_port: parseInt(e.target.value, 10) || DEFAULT_COMMUNITY_BROKER_PORT,
broker_port: parseIntegerInputValue(e.target.value),
})
}
/>
@@ -888,7 +977,8 @@ function MqttCommunityConfigEditor({
<Input
id="fanout-comm-topic-template"
type="text"
value={(config.topic_template as string) || DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE}
placeholder={DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE}
value={(config.topic_template as string | undefined) ?? ''}
onChange={(e) => onChange({ ...config, topic_template: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
@@ -1208,11 +1298,11 @@ function MapUploadConfigEditor({
min="0"
step="any"
placeholder="e.g. 100"
value={(config.geofence_radius_km as number | undefined) ?? ''}
value={getOptionalNumberInputValue(config.geofence_radius_km)}
onChange={(e) =>
onChange({
...config,
geofence_radius_km: e.target.value === '' ? 0 : parseFloat(e.target.value),
geofence_radius_km: parseFloatInputValue(e.target.value),
})
}
/>
@@ -1854,6 +1944,10 @@ export function SettingsFanoutSection({
const [inlineEditName, setInlineEditName] = useState('');
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [selectedCreateType, setSelectedCreateType] = useState<DraftType | null>(null);
const [errorDialogState, setErrorDialogState] = useState<{
integrationName: string;
error: string;
} | null>(null);
const [busy, setBusy] = useState(false);
const loadConfigs = useCallback(async () => {
@@ -1986,9 +2080,10 @@ export function SettingsFanoutSection({
if (!currentEditingId) {
throw new Error('Missing fanout config id for update');
}
const editingType = configs.find((cfg) => cfg.id === currentEditingId)?.type ?? '';
const update: Record<string, unknown> = {
name: editName,
config: editConfig,
config: normalizeIntegrationConfigForSave(editingType, editConfig),
scope: editScope,
};
if (enabled !== undefined) update.enabled = enabled;
@@ -2207,6 +2302,31 @@ export function SettingsFanoutSection({
}}
/>
<Dialog
open={errorDialogState !== null}
onOpenChange={(open) => {
if (!open) {
setErrorDialogState(null);
}
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader className="border-b border-border px-5 py-4">
<DialogTitle>
{errorDialogState ? `${errorDialogState.integrationName} Error` : 'Integration Error'}
</DialogTitle>
<DialogDescription>
Most recent backend error retained for this integration.
</DialogDescription>
</DialogHeader>
<div className="px-5 py-4 text-sm text-muted-foreground">
<p className="whitespace-pre-wrap break-words font-mono text-foreground">
{errorDialogState?.error}
</p>
</div>
</DialogContent>
</Dialog>
{configGroups.length > 0 && (
<div className="columns-1 gap-4 md:columns-2">
{configGroups.map((group) => (
@@ -2220,6 +2340,7 @@ export function SettingsFanoutSection({
{group.configs.map((cfg) => {
const statusEntry = health?.fanout_statuses?.[cfg.id];
const status = cfg.enabled ? statusEntry?.status : undefined;
const lastError = cfg.enabled ? statusEntry?.last_error : null;
const communityConfig = cfg.config as Record<string, unknown>;
return (
<div
@@ -2286,6 +2407,25 @@ export function SettingsFanoutSection({
{cfg.enabled ? getStatusLabel(status, cfg.type) : 'Disabled'}
</span>
{lastError && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 w-6 px-0"
onClick={() =>
setErrorDialogState({
integrationName: cfg.name,
error: lastError,
})
}
aria-label={`View error details for ${cfg.name}`}
title="View latest error"
>
<Info className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
)}
<Button
type="button"
variant="ghost"
@@ -17,6 +17,14 @@ import {
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({
onLocalLabelChange,
@@ -31,6 +39,29 @@ export function SettingsLocalSection({
);
const [localLabelText, setLocalLabelText] = useState(() => getLocalLabel().text);
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) => {
setReopenLastConversation(enabled);
@@ -89,6 +120,85 @@ export function SettingsLocalSection({
<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&apos;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
@@ -237,17 +347,20 @@ function PreviewSidebarRow({
}) {
return (
<div
className={`flex items-center gap-2 rounded-md border-l-2 px-3 py-2 text-[13px] ${
data-active={active ? 'true' : undefined}
className={`sidebar-action-row flex items-center gap-2 rounded-md border-l-2 px-3 py-2 text-[13px] ${
active ? 'border-l-primary bg-accent text-foreground' : 'border-l-transparent'
}`}
>
{leading}
<span className={`min-w-0 flex-1 truncate ${active ? 'font-medium' : 'text-foreground'}`}>
<span className="sidebar-tool-icon" aria-hidden="true">
{leading}
</span>
<span className={`sidebar-tool-label min-w-0 flex-1 truncate ${active ? 'font-medium' : ''}`}>
{label}
</span>
{badge}
{!badge && (
<span className="text-muted-foreground" aria-hidden="true">
<span className="sidebar-tool-icon" aria-hidden="true">
<MessageSquare className="h-3.5 w-3.5" />
</span>
)}
@@ -390,9 +390,9 @@ export function SettingsRadioSection({
return (
<div className={className}>
{/* Connection display */}
{/* ── Connection ── */}
<div className="space-y-3">
<Label>Connection</Label>
<Label className="text-base">Connection</Label>
<div className="flex items-center gap-2">
<div
className={`w-2 h-2 rounded-full ${
@@ -428,15 +428,58 @@ export function SettingsRadioSection({
</p>
</div>
{/* Radio Name */}
<Separator />
{/* ── Identity ── */}
<div className="space-y-2">
<Label className="text-base">Identity</Label>
</div>
<div className="space-y-2">
<Label htmlFor="name">Radio Name</Label>
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="public-key">Public Key</Label>
<Input id="public-key" value={config.public_key} disabled className="font-mono text-xs" />
</div>
<div className="space-y-2">
<Label htmlFor="private-key">Set Private Key (write-only)</Label>
<Input
id="private-key"
type="password"
autoComplete="off"
value={privateKey}
onChange={(e) => setPrivateKey(e.target.value)}
placeholder="64-character hex private key"
/>
<Button
onClick={handleSetPrivateKey}
disabled={identityBusy || identityRebooting || !privateKey.trim()}
className="w-full border-destructive/50 text-destructive hover:bg-destructive/10"
variant="outline"
>
{identityBusy || identityRebooting
? 'Setting & Rebooting...'
: 'Set Private Key & Reboot'}
</Button>
</div>
{identityError && (
<div className="text-sm text-destructive" role="alert">
{identityError}
</div>
)}
<Separator />
{/* Radio Config */}
{/* ── Radio Parameters ── */}
<div className="space-y-2">
<Label className="text-base">Radio Parameters</Label>
</div>
<div className="space-y-2">
<Label htmlFor="preset">Preset</Label>
<select
@@ -518,11 +561,36 @@ export function SettingsRadioSection({
</div>
</div>
{config.path_hash_mode_supported && (
<div className="space-y-2">
<Label htmlFor="path-hash-mode">Path Hash Mode</Label>
<select
id="path-hash-mode"
value={pathHashMode}
onChange={(e) => setPathHashMode(e.target.value)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<option value="0">1 byte (default)</option>
<option value="1">2 bytes</option>
<option value="2">3 bytes</option>
</select>
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-xs text-warning">
<p className="font-semibold mb-1">Compatibility Warning</p>
<p>
ALL nodes along a message&apos;s route &mdash; your radio, every repeater, and the
recipient &mdash; must be running firmware that supports the selected mode. Messages
sent with 2-byte or 3-byte hops will be dropped by any node on older firmware.
</p>
</div>
</div>
)}
<Separator />
{/* ── Location ── */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Location</Label>
<Label className="text-base">Location</Label>
<Button
type="button"
variant="outline"
@@ -585,53 +653,8 @@ export function SettingsRadioSection({
library.
</p>
</div>
<div className="space-y-2">
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3">
<Checkbox
id="multi-acks-enabled"
checked={multiAcksEnabled}
onCheckedChange={(checked) => setMultiAcksEnabled(checked === true)}
className="mt-0.5"
/>
<div className="space-y-1">
<Label htmlFor="multi-acks-enabled">Extra Direct ACK Transmission</Label>
<p className="text-xs text-muted-foreground">
When enabled, the radio sends one extra direct ACK transmission before the normal
ACK for received direct messages. This is a firmware-level receive behavior, not a
RemoteTerm retry setting.
</p>
</div>
</div>
</div>
</div>
{config.path_hash_mode_supported && (
<>
<Separator />
<div className="space-y-2">
<Label htmlFor="path-hash-mode">Path Hash Mode</Label>
<select
id="path-hash-mode"
value={pathHashMode}
onChange={(e) => setPathHashMode(e.target.value)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<option value="0">1 byte (default)</option>
<option value="1">2 bytes</option>
<option value="2">3 bytes</option>
</select>
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-xs text-warning">
<p className="font-semibold mb-1">Compatibility Warning</p>
<p>
ALL nodes along a message&apos;s route &mdash; your radio, every repeater, and the
recipient &mdash; must be running firmware that supports the selected mode. Messages
sent with 2-byte or 3-byte hops will be dropped by any node on older firmware.
</p>
</div>
</div>
</>
)}
{error && (
<div className="text-sm text-destructive" role="alert">
{error}
@@ -657,64 +680,28 @@ export function SettingsRadioSection({
<Separator />
{/* Keys */}
{/* ── Messaging ── */}
<div className="space-y-2">
<Label htmlFor="public-key">Public Key</Label>
<Input id="public-key" value={config.public_key} disabled className="font-mono text-xs" />
<Label className="text-base">Messaging</Label>
</div>
<div className="space-y-2">
<Label htmlFor="private-key">Set Private Key (write-only)</Label>
<Input
id="private-key"
type="password"
autoComplete="off"
value={privateKey}
onChange={(e) => setPrivateKey(e.target.value)}
placeholder="64-character hex private key"
/>
<Button
onClick={handleSetPrivateKey}
disabled={identityBusy || identityRebooting || !privateKey.trim()}
className="w-full border-destructive/50 text-destructive hover:bg-destructive/10"
variant="outline"
>
{identityBusy || identityRebooting
? 'Setting & Rebooting...'
: 'Set Private Key & Reboot'}
</Button>
</div>
{identityError && (
<div className="text-sm text-destructive" role="alert">
{identityError}
</div>
)}
<Separator />
{/* Flood & Advert Control */}
<div className="space-y-2">
<Label className="text-base">Flood & Advert Control</Label>
</div>
<div className="space-y-2">
<Label htmlFor="advert-interval">Periodic Advertising Interval</Label>
<div className="flex items-center gap-2">
<Input
id="advert-interval"
type="number"
min="0"
value={advertIntervalHours}
onChange={(e) => setAdvertIntervalHours(e.target.value)}
className="w-28"
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3">
<Checkbox
id="multi-acks-enabled"
checked={multiAcksEnabled}
onCheckedChange={(checked) => setMultiAcksEnabled(checked === true)}
className="mt-0.5"
/>
<span className="text-sm text-muted-foreground">hours (0 = off)</span>
<div className="space-y-1">
<Label htmlFor="multi-acks-enabled">Extra Direct ACK Transmission</Label>
<p className="text-xs text-muted-foreground">
When enabled, the radio sends one extra direct ACK transmission before the normal ACK
for received direct messages. This is a firmware-level receive behavior, not a
RemoteTerm retry setting.
</p>
</div>
</div>
<p className="text-xs text-muted-foreground">
How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour.
Recommended: 24 hours or higher.
</p>
</div>
<div className="space-y-2">
@@ -746,6 +733,13 @@ export function SettingsRadioSection({
Configured radio contact capacity. Favorites reload first, then background maintenance
refills to about 80% of this value and offloads once occupancy reaches about 95%.
</p>
{health?.radio_device_info?.max_contacts != null &&
Number(maxRadioContacts) > health.radio_device_info.max_contacts && (
<p className="text-xs text-warning">
Your radio reports a hardware limit of {health.radio_device_info.max_contacts}{' '}
contacts. The effective cap will be limited to what the radio supports.
</p>
)}
</div>
{floodError && (
@@ -760,8 +754,28 @@ export function SettingsRadioSection({
<Separator />
{/* ── Advertising & Discovery ── */}
<div className="space-y-2">
<Label className="text-base">Hear &amp; Be Heard</Label>
<Label className="text-base">Advertising &amp; Discovery</Label>
</div>
<div className="space-y-2">
<Label htmlFor="advert-interval">Periodic Advertising Interval</Label>
<div className="flex items-center gap-2">
<Input
id="advert-interval"
type="number"
min="0"
value={advertIntervalHours}
onChange={(e) => setAdvertIntervalHours(e.target.value)}
className="w-28"
/>
<span className="text-sm text-muted-foreground">hours (0 = off)</span>
</div>
<p className="text-xs text-muted-foreground">
How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour.
Recommended: 24 hours or higher.
</p>
</div>
<div className="space-y-2">
@@ -846,11 +860,16 @@ export function SettingsRadioSection({
className="rounded-md border border-input bg-background px-3 py-2"
>
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium capitalize">{result.node_type}</span>
<span className="text-sm font-medium">
{result.name ?? <span className="capitalize">{result.node_type}</span>}
</span>
<span className="text-xs text-muted-foreground">
heard {result.heard_count} time{result.heard_count === 1 ? '' : 's'}
</span>
</div>
{result.name && (
<p className="text-xs capitalize text-muted-foreground">{result.node_type}</p>
)}
<p className="mt-1 break-all font-mono text-xs text-muted-foreground">
{result.public_key}
</p>
@@ -1,4 +1,16 @@
import { useState, useEffect } from 'react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip as RechartsTooltip,
ResponsiveContainer,
AreaChart,
Area,
Cell,
} from 'recharts';
import { Separator } from '../ui/separator';
import { api } from '../../api';
import type { StatisticsResponse } from '../../types';
@@ -7,6 +19,94 @@ function formatPercent(value: number): string {
return `${value.toFixed(1)}%`;
}
const CHANNEL_BAR_COLORS = ['#0ea5e9', '#10b981', '#f59e0b', '#f43f5e', '#8b5cf6'];
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).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
}
function NoiseFloorChart({
samples,
}: {
samples: { timestamp: number; noise_floor_dbm: number }[];
}) {
const data = samples.map((s, i) => ({
idx: i,
time: formatTime(s.timestamp),
noise_floor: s.noise_floor_dbm,
}));
const tickCount = Math.min(6, samples.length);
const tickIndices: number[] = [];
if (samples.length > 1) {
for (let i = 0; i < tickCount; i++) {
tickIndices.push(Math.round((i / (tickCount - 1)) * (samples.length - 1)));
}
}
return (
<ResponsiveContainer width="100%" height={120}>
<AreaChart data={data} margin={{ top: 4, right: 4, bottom: 0, left: -8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
<XAxis
dataKey="idx"
type="number"
domain={[0, samples.length - 1]}
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
ticks={tickIndices}
tickFormatter={(idx) => data[idx]?.time ?? ''}
/>
<YAxis
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
domain={['dataMin - 5', 'dataMax + 5']}
tickFormatter={(v) => `${v}`}
/>
<RechartsTooltip
{...TOOLTIP_STYLE}
cursor={{
stroke: 'hsl(var(--muted-foreground))',
strokeWidth: 1,
strokeDasharray: '3 3',
}}
labelFormatter={(idx) => data[Number(idx)]?.time ?? ''}
formatter={(value) => [`${value} dBm`, 'Noise Floor']}
/>
<Area
type="linear"
dataKey="noise_floor"
stroke="#8b5cf6"
fill="#8b5cf6"
fillOpacity={0.15}
strokeWidth={1.5}
dot={false}
activeDot={{ r: 4, fill: '#8b5cf6', strokeWidth: 2, stroke: 'hsl(var(--popover))' }}
/>
</AreaChart>
</ResponsiveContainer>
);
}
export function SettingsStatisticsSection({ className }: { className?: string }) {
const [stats, setStats] = useState<StatisticsResponse | null>(null);
const [statsLoading, setStatsLoading] = useState(false);
@@ -85,60 +185,6 @@ export function SettingsStatisticsSection({ className }: { className?: string })
<Separator />
{/* Packets */}
<div>
<h4 className="text-sm font-medium mb-2">Packets</h4>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Total stored</span>
<span className="font-medium">{stats.total_packets}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-success">Decrypted</span>
<span className="font-medium text-success">{stats.decrypted_packets}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-warning">Undecrypted</span>
<span className="font-medium text-warning">{stats.undecrypted_packets}</span>
</div>
</div>
</div>
<Separator />
<div>
<h4 className="text-sm font-medium mb-2">Path Hash Width (24h)</h4>
<div className="mb-2 text-xs text-muted-foreground">
Parsed stored raw packets from the last 24 hours:{' '}
{stats.path_hash_width_24h.total_packets}
</div>
<div className="space-y-2">
<div className="flex justify-between items-center text-sm">
<span>1-byte hops</span>
<span className="text-muted-foreground">
{stats.path_hash_width_24h.single_byte} (
{formatPercent(stats.path_hash_width_24h.single_byte_pct)})
</span>
</div>
<div className="flex justify-between items-center text-sm">
<span>2-byte hops</span>
<span className="text-muted-foreground">
{stats.path_hash_width_24h.double_byte} (
{formatPercent(stats.path_hash_width_24h.double_byte_pct)})
</span>
</div>
<div className="flex justify-between items-center text-sm">
<span>3-byte hops</span>
<span className="text-muted-foreground">
{stats.path_hash_width_24h.triple_byte} (
{formatPercent(stats.path_hash_width_24h.triple_byte_pct)})
</span>
</div>
</div>
</div>
<Separator />
{/* Activity */}
<div>
<h4 className="text-sm font-medium mb-2">Activity</h4>
@@ -174,23 +220,172 @@ export function SettingsStatisticsSection({ className }: { className?: string })
</table>
</div>
<Separator />
{/* Packets */}
<div>
<h4 className="text-sm font-medium mb-2">Packets</h4>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Total stored</span>
<span className="font-medium">{stats.total_packets}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-success">Decrypted</span>
<span className="font-medium text-success">{stats.decrypted_packets}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-warning">Undecrypted</span>
<span className="font-medium text-warning">{stats.undecrypted_packets}</span>
</div>
</div>
</div>
<Separator />
{/* Path Hash Width */}
<div>
<h4 className="text-sm font-medium mb-2">Path Hash Width (24h)</h4>
<div className="mb-2 text-xs text-muted-foreground">
Parsed stored raw packets from the last 24 hours:{' '}
{stats.path_hash_width_24h.total_packets}
</div>
{stats.path_hash_width_24h.total_packets > 0 ? (
<ResponsiveContainer width="100%" height={120}>
<BarChart
data={[
{
name: '1-byte',
count: stats.path_hash_width_24h.single_byte,
pct: stats.path_hash_width_24h.single_byte_pct,
},
{
name: '2-byte',
count: stats.path_hash_width_24h.double_byte,
pct: stats.path_hash_width_24h.double_byte_pct,
},
{
name: '3-byte',
count: stats.path_hash_width_24h.triple_byte,
pct: stats.path_hash_width_24h.triple_byte_pct,
},
]}
margin={{ top: 4, right: 4, bottom: 0, left: -16 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(var(--border))"
vertical={false}
/>
<XAxis
dataKey="name"
tick={{ fontSize: 11, 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 }}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
formatter={(value: any, _: any, props: any) => [
`${Number(value).toLocaleString()} (${formatPercent(props.payload.pct)})`,
'Packets',
]}
/>
<Bar dataKey="count" radius={[4, 4, 0, 0]} maxBarSize={40}>
<Cell fill="#0ea5e9" />
<Cell fill="#10b981" />
<Cell fill="#f59e0b" />
</Bar>
</BarChart>
</ResponsiveContainer>
) : (
<p className="text-sm text-muted-foreground">No path data in the last 24 hours.</p>
)}
</div>
{/* Busiest Channels */}
{stats.busiest_channels_24h.length > 0 && (
<>
<Separator />
<div>
<h4 className="text-sm font-medium mb-2">Busiest Channels (24h)</h4>
<div className="space-y-1">
{stats.busiest_channels_24h.map((ch, i) => (
<div key={ch.channel_key} className="flex justify-between items-center text-sm">
<span>
<span className="text-muted-foreground mr-2">{i + 1}.</span>
{ch.channel_name}
</span>
<span className="text-muted-foreground">{ch.message_count} msgs</span>
</div>
))}
</div>
<ResponsiveContainer
width="100%"
height={stats.busiest_channels_24h.length * 28 + 8}
>
<BarChart
data={stats.busiest_channels_24h.map((ch) => ({
name: ch.channel_name,
messages: ch.message_count,
}))}
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={100}
/>
<RechartsTooltip
{...TOOLTIP_STYLE}
cursor={{ fill: 'hsl(var(--muted))', opacity: 0.5 }}
formatter={(value) => [`${Number(value).toLocaleString()} messages`, null]}
/>
<Bar dataKey="messages" radius={[0, 4, 4, 0]} maxBarSize={16}>
{stats.busiest_channels_24h.map((_, i) => (
<Cell key={i} fill={CHANNEL_BAR_COLORS[i % CHANNEL_BAR_COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</>
)}
{/* Noise Floor */}
{stats.noise_floor_24h.supported !== false && (
<>
<Separator />
<div>
<h4 className="text-sm font-medium mb-2">Noise Floor (24h)</h4>
{stats.noise_floor_24h.latest_noise_floor_dbm != null && (
<div className="mb-2 text-xs text-muted-foreground">
Latest reading: {stats.noise_floor_24h.latest_noise_floor_dbm} dBm
{stats.noise_floor_24h.latest_timestamp != null &&
` at ${new Date(
stats.noise_floor_24h.latest_timestamp * 1000
).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}`}
</div>
)}
{stats.noise_floor_24h.samples.length > 1 ? (
<NoiseFloorChart samples={stats.noise_floor_24h.samples} />
) : stats.noise_floor_24h.samples.length === 0 ? (
<p className="text-sm text-muted-foreground">
No noise floor samples collected yet. Samples are collected every five minutes,
and retained until server restart.
</p>
) : (
<p className="text-sm text-muted-foreground">
Only one sample so far ({stats.noise_floor_24h.samples[0].noise_floor_dbm} dBm).
More data needed for a chart. Samples are collected every five minutes, and
retained until server restart.
</p>
)}
</div>
</>
)}
@@ -1,3 +1,4 @@
import { useEffect, useState } from 'react';
import { Checkbox } from '../ui/checkbox';
import { PACKET_LEGEND_ITEMS } from '../../utils/visualizerUtils';
import { NODE_LEGEND_ITEMS } from './shared';
@@ -71,6 +72,19 @@ export function VisualizerControls({
onExpandContract,
onClearAndReset,
}: VisualizerControlsProps) {
const [observationWindowInput, setObservationWindowInput] = useState(
String(observationWindowSec)
);
const [pruneWindowInput, setPruneWindowInput] = useState(String(pruneStaleMinutes));
useEffect(() => {
setObservationWindowInput(String(observationWindowSec));
}, [observationWindowSec]);
useEffect(() => {
setPruneWindowInput(String(pruneStaleMinutes));
}, [pruneStaleMinutes]);
return (
<>
{showControls && (
@@ -212,12 +226,25 @@ export function VisualizerControls({
type="number"
min="1"
max="60"
value={observationWindowSec}
onChange={(e) =>
setObservationWindowSec(
Math.max(1, Math.min(60, parseInt(e.target.value, 10) || 1))
)
}
value={observationWindowInput}
onChange={(e) => {
const nextValue = e.target.value;
setObservationWindowInput(nextValue);
if (nextValue === '') return;
const parsed = Number.parseInt(nextValue, 10);
if (Number.isNaN(parsed)) return;
setObservationWindowSec(Math.max(1, Math.min(60, parsed)));
}}
onBlur={() => {
const parsed = Number.parseInt(observationWindowInput, 10);
const nextValue = Number.isNaN(parsed)
? observationWindowSec
: Math.max(1, Math.min(60, parsed));
setObservationWindowInput(String(nextValue));
if (nextValue !== observationWindowSec) {
setObservationWindowSec(nextValue);
}
}}
className="w-12 px-1 py-0.5 bg-background border border-border rounded text-xs text-center"
/>
<span className="text-muted-foreground">sec</span>
@@ -247,10 +274,25 @@ export function VisualizerControls({
type="number"
min={1}
max={60}
value={pruneStaleMinutes}
value={pruneWindowInput}
onChange={(e) => {
const v = parseInt(e.target.value, 10);
if (!isNaN(v) && v >= 1 && v <= 60) setPruneStaleMinutes(v);
const nextValue = e.target.value;
setPruneWindowInput(nextValue);
if (nextValue === '') return;
const parsed = Number.parseInt(nextValue, 10);
if (Number.isNaN(parsed)) return;
if (parsed >= 1 && parsed <= 60) setPruneStaleMinutes(parsed);
}}
onBlur={() => {
const parsed = Number.parseInt(pruneWindowInput, 10);
const nextValue =
Number.isNaN(parsed) || parsed < 1 || parsed > 60
? pruneStaleMinutes
: parsed;
setPruneWindowInput(String(nextValue));
if (nextValue !== pruneStaleMinutes) {
setPruneStaleMinutes(nextValue);
}
}}
className="w-14 rounded border border-border bg-background px-2 py-0.5 text-sm"
/>
+20 -1
View File
@@ -4,7 +4,7 @@ import { takePrefetchOrFetch } from '../prefetch';
import { toast } from '../components/ui/sonner';
import { getContactDisplayName } from '../utils/pubkey';
import { findPublicChannel, PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME } from '../utils/publicChannel';
import type { Channel, Contact, Conversation } from '../types';
import type { BulkCreateHashtagChannelsResult, Channel, Contact, Conversation } from '../types';
interface UseContactsAndChannelsArgs {
setActiveConversation: (conv: Conversation | null) => void;
@@ -112,6 +112,24 @@ export function useContactsAndChannels({
[fetchUndecryptedCountInternal, setActiveConversation]
);
const handleBulkCreateHashtagChannels = useCallback(
async (
channelNames: string[],
tryHistorical: boolean
): Promise<BulkCreateHashtagChannelsResult> => {
const result = await api.bulkCreateHashtagChannels(channelNames, tryHistorical);
const data = await api.getChannels();
setChannels(data);
if (tryHistorical && result.decrypt_started) {
fetchUndecryptedCountInternal();
}
return result;
},
[fetchUndecryptedCountInternal]
);
const handleDeleteChannel = useCallback(
async (key: string) => {
if (!confirm('Delete this channel? Message history will be preserved.')) return;
@@ -190,6 +208,7 @@ export function useContactsAndChannels({
handleCreateContact,
handleCreateChannel,
handleCreateHashtagChannel,
handleBulkCreateHashtagChannels,
handleDeleteChannel,
handleDeleteContact,
};
@@ -275,7 +275,9 @@ interface UseConversationMessagesResult {
}
function isMessageConversation(conversation: Conversation | null): conversation is Conversation {
return !!conversation && !['raw', 'map', 'visualizer', 'search'].includes(conversation.type);
return (
!!conversation && !['raw', 'map', 'visualizer', 'search', 'trace'].includes(conversation.type)
);
}
function isActiveConversationMessage(
+23 -9
View File
@@ -62,7 +62,6 @@ export function useConversationRouter({
// Only needs channels (fast path) - doesn't wait for contacts
useEffect(() => {
if (hasSetDefaultConversation.current || activeConversation) return;
if (channels.length === 0) return;
const hashConv = parseHashSettingsSection() ? null : parseHashConversation();
@@ -92,6 +91,29 @@ export function useConversationRouter({
hasSetDefaultConversation.current = true;
return;
}
if (hashConv?.type === 'trace') {
setActiveConversationState({ type: 'trace', id: 'trace', name: 'Trace' });
hasSetDefaultConversation.current = true;
return;
}
// No hash: optionally restore last-viewed non-data conversation if enabled on this device.
if (!hashConv && getReopenLastConversationEnabled()) {
const lastViewed = getLastViewedConversation();
if (
lastViewed &&
(lastViewed.type === 'raw' ||
lastViewed.type === 'map' ||
lastViewed.type === 'visualizer' ||
lastViewed.type === 'trace')
) {
setActiveConversationState(lastViewed);
hasSetDefaultConversation.current = true;
return;
}
}
if (channels.length === 0) return;
// Handle channel hash (ID-first with legacy-name fallback)
if (hashConv?.type === 'channel') {
@@ -109,14 +131,6 @@ export function useConversationRouter({
// No hash: optionally restore last-viewed conversation if enabled on this device.
if (!hashConv && getReopenLastConversationEnabled()) {
const lastViewed = getLastViewedConversation();
if (
lastViewed &&
(lastViewed.type === 'raw' || lastViewed.type === 'map' || lastViewed.type === 'visualizer')
) {
setActiveConversationState(lastViewed);
hasSetDefaultConversation.current = true;
return;
}
if (lastViewed?.type === 'channel') {
const channel =
channels.find((c) => c.key.toLowerCase() === lastViewed.id.toLowerCase()) ||
@@ -43,6 +43,7 @@ interface UseRealtimeAppStateArgs {
hasMention?: boolean;
}) => void;
renameConversationState: (oldStateKey: string, newStateKey: string) => void;
removeConversationState: (stateKey: string) => void;
checkMention: (text: string) => boolean;
pendingDeleteFallbackRef: MutableRefObject<boolean>;
setActiveConversation: (conv: Conversation | null) => void;
@@ -96,6 +97,7 @@ export function useRealtimeAppState({
observeMessage,
recordMessageEvent,
renameConversationState,
removeConversationState,
checkMention,
pendingDeleteFallbackRef,
setActiveConversation,
@@ -232,6 +234,7 @@ export function useRealtimeAppState({
onContactDeleted: (publicKey: string) => {
setContacts((prev) => prev.filter((c) => c.public_key !== publicKey));
removeConversationMessages(publicKey);
removeConversationState(getStateKey('contact', publicKey));
const active = activeConversationRef.current;
if (active?.type === 'contact' && active.id === publicKey) {
pendingDeleteFallbackRef.current = true;
@@ -241,6 +244,7 @@ export function useRealtimeAppState({
onChannelDeleted: (key: string) => {
setChannels((prev) => prev.filter((c) => c.key !== key));
removeConversationMessages(key);
removeConversationState(getStateKey('channel', key));
const active = activeConversationRef.current;
if (active?.type === 'channel' && active.id === key) {
pendingDeleteFallbackRef.current = true;
@@ -267,6 +271,7 @@ export function useRealtimeAppState({
checkMention,
fetchAllContacts,
fetchConfig,
removeConversationState,
renameConversationState,
renameConversationMessages,
maxRawPackets,
+34 -18
View File
@@ -10,6 +10,14 @@ import {
import type { Channel, Contact, Conversation, Message, UnreadCounts } from '../types';
import { takePrefetchOrFetch } from '../prefetch';
type UnreadTrackedConversation = Conversation & { type: 'channel' | 'contact' };
function isUnreadTrackedConversation(
conversation: Conversation | null
): conversation is UnreadTrackedConversation {
return conversation?.type === 'channel' || conversation?.type === 'contact';
}
interface UseUnreadCountsResult {
unreadCounts: Record<string, number>;
/** Tracks which conversations have unread messages that mention the user */
@@ -23,6 +31,7 @@ interface UseUnreadCountsResult {
hasMention?: boolean;
}) => void;
renameConversationState: (oldStateKey: string, newStateKey: string) => void;
removeConversationState: (stateKey: string) => void;
markAllRead: () => void;
refreshUnreads: () => Promise<void>;
}
@@ -47,14 +56,7 @@ export function useUnreadCounts(
// (the user is already viewing it, so its count should stay at 0).
const applyUnreads = useCallback((data: UnreadCounts) => {
const ac = activeConvRef.current;
const activeKey =
ac &&
ac.type !== 'raw' &&
ac.type !== 'map' &&
ac.type !== 'visualizer' &&
ac.type !== 'search'
? getStateKey(ac.type as 'channel' | 'contact', ac.id)
: null;
const activeKey = isUnreadTrackedConversation(ac) ? getStateKey(ac.type, ac.id) : null;
if (activeKey) {
const counts = { ...data.counts };
@@ -122,16 +124,8 @@ export function useUnreadCounts(
// Mark conversation as read when user views it
// Calls server API to persist read state across devices
useEffect(() => {
if (
activeConversation &&
activeConversation.type !== 'raw' &&
activeConversation.type !== 'map' &&
activeConversation.type !== 'visualizer'
) {
const key = getStateKey(
activeConversation.type as 'channel' | 'contact',
activeConversation.id
);
if (isUnreadTrackedConversation(activeConversation)) {
const key = getStateKey(activeConversation.type, activeConversation.id);
// Update local state immediately for responsive UI
setUnreadCounts((prev) => {
@@ -235,6 +229,27 @@ export function useUnreadCounts(
setLastMessageTimes(renameConversationTimeKey(oldStateKey, newStateKey));
}, []);
const removeConversationState = useCallback((stateKey: string) => {
setUnreadCounts((prev) => {
if (!(stateKey in prev)) return prev;
const next = { ...prev };
delete next[stateKey];
return next;
});
setMentions((prev) => {
if (!(stateKey in prev)) return prev;
const next = { ...prev };
delete next[stateKey];
return next;
});
setUnreadLastReadAts((prev) => {
if (!(stateKey in prev)) return prev;
const next = { ...prev };
delete next[stateKey];
return next;
});
}, []);
// Mark all conversations as read
// Calls single bulk API endpoint to persist read state
const markAllRead = useCallback(() => {
@@ -256,6 +271,7 @@ export function useUnreadCounts(
unreadLastReadAts,
recordMessageEvent,
renameConversationState,
removeConversationState,
markAllRead,
refreshUnreads: fetchUnreads,
};
+53 -1
View File
@@ -56,6 +56,14 @@
--badge-mention: var(--destructive);
--badge-mention-foreground: var(--destructive-foreground);
/* Sidebar navigation accents */
--sidebar-icon-color: hsl(var(--foreground));
--sidebar-icon-hover-color: hsl(var(--foreground));
--sidebar-icon-active-color: hsl(var(--foreground));
--sidebar-label-color: hsl(var(--muted-foreground));
--sidebar-label-hover-color: hsl(var(--foreground));
--sidebar-label-active-color: hsl(var(--foreground));
/* Error toast */
--toast-error: 0 30% 14%;
--toast-error-foreground: 0 56% 77%;
@@ -85,7 +93,7 @@
body {
@apply bg-background text-foreground;
font-family: var(--font-sans);
font-size: 14px;
font-size: 0.875rem;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@@ -126,6 +134,50 @@
animation: message-highlight 2s ease-out forwards;
}
.sidebar-tool-icon {
display: inline-flex;
height: 1.5rem;
width: 1.5rem;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 0.45rem;
color: var(--sidebar-icon-color);
opacity: 1;
transition:
color 150ms ease,
opacity 150ms ease;
}
.sidebar-tool-icon svg {
stroke-width: 2.35;
}
.sidebar-tool-label {
color: var(--sidebar-label-color);
transition: color 150ms ease;
}
.sidebar-action-row:hover .sidebar-tool-icon,
.sidebar-action-row:focus-visible .sidebar-tool-icon {
color: var(--sidebar-icon-hover-color);
opacity: 1;
}
.sidebar-action-row:hover .sidebar-tool-label,
.sidebar-action-row:focus-visible .sidebar-tool-label {
color: var(--sidebar-label-hover-color);
}
.sidebar-action-row[data-active='true'] .sidebar-tool-icon {
color: var(--sidebar-icon-active-color);
opacity: 1;
}
.sidebar-action-row[data-active='true'] .sidebar-tool-label {
color: var(--sidebar-label-active-color);
}
/* Constrain CodeMirror editor width */
.cm-editor {
max-width: 100% !important;
+2
View File
@@ -5,9 +5,11 @@ import './index.css';
import './themes.css';
import './styles.css';
import { getSavedTheme, applyTheme } from './utils/theme';
import { applyFontScale, getSavedFontScale } from './utils/fontScale';
// Apply saved theme before first render
applyTheme(getSavedTheme());
applyFontScale(getSavedFontScale());
createRoot(document.getElementById('root')!).render(
<StrictMode>
+47
View File
@@ -195,6 +195,53 @@ describe('App startup hash resolution', () => {
});
});
it('restores the trace tool from the URL hash', async () => {
window.location.hash = '#trace';
render(<App />);
await waitFor(() => {
for (const node of screen.getAllByTestId('active-conversation')) {
expect(node).toHaveTextContent('trace:trace:Trace');
}
});
});
it('restores the trace tool from the URL hash even when channels are unavailable', async () => {
window.location.hash = '#trace';
mocks.api.getChannels.mockResolvedValue([]);
render(<App />);
await waitFor(() => {
for (const node of screen.getAllByTestId('active-conversation')) {
expect(node).toHaveTextContent('trace:trace:Trace');
}
});
});
it('reopens the last viewed trace tool even when channels are unavailable', async () => {
window.location.hash = '';
localStorage.setItem(REOPEN_LAST_CONVERSATION_KEY, '1');
localStorage.setItem(
LAST_VIEWED_CONVERSATION_KEY,
JSON.stringify({
type: 'trace',
id: 'trace',
name: 'Trace',
})
);
mocks.api.getChannels.mockResolvedValue([]);
render(<App />);
await waitFor(() => {
for (const node of screen.getAllByTestId('active-conversation')) {
expect(node).toHaveTextContent('trace:trace:Trace');
}
});
});
it('restores last viewed channel when hash is empty and reopen preference is enabled', async () => {
const chatChannel = {
key: '11111111111111111111111111111111',
@@ -0,0 +1,46 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { BulkAddChannelResultModal } from '../components/BulkAddChannelResultModal';
describe('BulkAddChannelResultModal', () => {
it('renders links only for newly created rooms', () => {
render(
<BulkAddChannelResultModal
open
onClose={() => {}}
result={{
created_channels: [
{
key: 'AA'.repeat(16),
name: '#ops',
is_hashtag: true,
on_radio: false,
last_read_at: null,
},
{
key: 'BB'.repeat(16),
name: '#mesh-room',
is_hashtag: true,
on_radio: false,
last_read_at: null,
},
],
existing_count: 3,
invalid_names: ['bad_room'],
decrypt_started: true,
decrypt_total_packets: 8,
message: 'Created 2 rooms',
}}
/>
);
const opsLink = screen.getByRole('link', { name: '#ops' });
const meshLink = screen.getByRole('link', { name: '#mesh-room' });
expect(opsLink.getAttribute('href')).toContain('#channel/');
expect(meshLink.getAttribute('href')).toContain('#channel/');
expect(screen.queryByRole('link', { name: /bad_room/i })).toBeNull();
expect(screen.getByText(/Ignored invalid room names: bad_room/)).toBeTruthy();
});
});
+4 -1
View File
@@ -181,7 +181,10 @@ describe('ContactInfoPane', () => {
await screen.findByText('Mystery');
await waitFor(() => {
expect(getContactAnalytics).toHaveBeenCalledWith({ name: 'Mystery' });
expect(getContactAnalytics).toHaveBeenCalledWith(
{ name: 'Mystery' },
expect.any(AbortSignal)
);
expect(screen.getByText('Messages')).toBeInTheDocument();
expect(screen.getByText('Channel Messages')).toBeInTheDocument();
expect(screen.getByText('4', { selector: 'p' })).toBeInTheDocument();
@@ -64,6 +64,10 @@ vi.mock('../components/VisualizerView', () => ({
VisualizerView: () => <div data-testid="visualizer-view" />,
}));
vi.mock('../components/TracePane', () => ({
TracePane: () => <div data-testid="trace-pane" />,
}));
const config: RadioConfig = {
public_key: 'aa'.repeat(32),
name: 'Radio',
@@ -141,6 +145,7 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
loadingNewer: false,
messageInputRef: { current: null },
onTrace: vi.fn(async () => {}),
onRunTracePath: vi.fn(async () => ({ path_len: 0, timeout_seconds: 5, nodes: [] })),
onPathDiscovery: vi.fn(async () => {
throw new Error('unused');
}),
@@ -231,6 +236,23 @@ describe('ConversationPane', () => {
});
});
it('renders the trace tool pane for trace conversations', () => {
render(
<ConversationPane
{...createProps({
activeConversation: {
type: 'trace',
id: 'trace',
name: 'Trace',
},
})}
/>
);
expect(screen.getByTestId('trace-pane')).toBeInTheDocument();
expect(screen.queryByTestId('message-list')).not.toBeInTheDocument();
});
it('gates room chat behind room login controls until authenticated', async () => {
render(
<ConversationPane
+57
View File
@@ -0,0 +1,57 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CrackerPanel } from '../components/CrackerPanel';
vi.mock('meshcore-hashtag-cracker', () => ({
GroupTextCracker: class {
isGpuAvailable() {
return false;
}
destroy() {}
setWordlist() {}
abort() {}
},
}));
vi.mock('nosleep.js', () => ({
default: class {
enable() {}
disable() {}
},
}));
vi.mock('../api', () => ({
api: {
getUndecryptedPacketCount: vi.fn(),
},
}));
vi.mock('../components/ui/sonner', () => ({
toast: {
error: vi.fn(),
},
}));
import { api } from '../api';
const mockedApi = vi.mocked(api);
describe('CrackerPanel', () => {
beforeEach(() => {
vi.clearAllMocks();
mockedApi.getUndecryptedPacketCount.mockResolvedValue({ count: 0 });
});
it('allows clearing max length while editing', async () => {
render(<CrackerPanel packets={[]} channels={[]} onChannelCreate={vi.fn()} visible={false} />);
await waitFor(() => {
expect(mockedApi.getUndecryptedPacketCount).toHaveBeenCalled();
});
const maxLengthInput = screen.getByLabelText('Max Length:') as HTMLInputElement;
fireEvent.change(maxLengthInput, { target: { value: '' } });
expect(maxLengthInput.value).toBe('');
});
});
+229
View File
@@ -206,6 +206,56 @@ describe('SettingsFanoutSection', () => {
});
});
it('shows an error info button and dialog when the integration has a retained error', async () => {
mockedApi.getFanoutConfigs.mockResolvedValue([webhookConfig]);
renderSection({
health: {
...baseHealth,
fanout_statuses: {
'wh-1': {
name: 'Test Hook',
type: 'webhook',
status: 'error',
last_error: 'HTTP 500',
},
},
},
});
await waitFor(() => {
expect(screen.getByText('Test Hook')).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'View error details for Test Hook' }));
expect(screen.getByRole('dialog', { name: 'Test Hook Error' })).toBeInTheDocument();
expect(screen.getByText('HTTP 500')).toBeInTheDocument();
});
it('does not show an error info button when the integration has no retained error', async () => {
mockedApi.getFanoutConfigs.mockResolvedValue([webhookConfig]);
renderSection({
health: {
...baseHealth,
fanout_statuses: {
'wh-1': {
name: 'Test Hook',
type: 'webhook',
status: 'connected',
},
},
},
});
await waitFor(() => {
expect(screen.getByText('Test Hook')).toBeInTheDocument();
});
expect(
screen.queryByRole('button', { name: 'View error details for Test Hook' })
).not.toBeInTheDocument();
});
it('navigates to edit view when clicking edit', async () => {
mockedApi.getFanoutConfigs.mockResolvedValue([webhookConfig]);
renderSection();
@@ -654,6 +704,75 @@ describe('SettingsFanoutSection', () => {
expect(audienceInput).toHaveValue('');
});
it('existing community MQTT defaults can be cleared while editing and normalize on save', async () => {
const communityConfig: FanoutConfig = {
id: 'comm-1',
type: 'mqtt_community',
name: 'Community Feed',
enabled: false,
config: {
broker_host: 'mqtt-us-v1.letsmesh.net',
broker_port: 443,
transport: 'websockets',
use_tls: true,
tls_verify: true,
auth_mode: 'token',
iata: 'LAX',
email: '',
token_audience: '',
topic_template: 'meshcore/{IATA}/{PUBLIC_KEY}/packets',
},
scope: { messages: 'none', raw_packets: 'all' },
sort_order: 0,
created_at: 1000,
};
mockedApi.getFanoutConfigs.mockResolvedValue([communityConfig]);
mockedApi.updateFanoutConfig.mockResolvedValue({
...communityConfig,
enabled: true,
});
renderSection();
await waitFor(() => expect(screen.getByText('Community Feed')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
const hostInput = screen.getByLabelText('Broker Host') as HTMLInputElement;
const portInput = screen.getByLabelText('Broker Port') as HTMLInputElement;
const topicTemplateInput = screen.getByLabelText('Packet Topic Template') as HTMLInputElement;
fireEvent.change(hostInput, { target: { value: '' } });
fireEvent.change(portInput, { target: { value: '' } });
fireEvent.change(topicTemplateInput, { target: { value: '' } });
expect(hostInput.value).toBe('');
expect(portInput.value).toBe('');
expect(topicTemplateInput.value).toBe('');
fireEvent.click(screen.getByRole('button', { name: 'Save as Enabled' }));
await waitFor(() =>
expect(mockedApi.updateFanoutConfig).toHaveBeenCalledWith('comm-1', {
name: 'Community Feed',
config: {
broker_host: 'mqtt-us-v1.letsmesh.net',
broker_port: 443,
transport: 'websockets',
use_tls: true,
tls_verify: true,
auth_mode: 'token',
iata: 'LAX',
email: '',
token_audience: '',
topic_template: 'meshcore/{IATA}/{PUBLIC_KEY}/packets',
},
scope: { messages: 'none', raw_packets: 'all' },
enabled: true,
})
);
});
it('community MQTT can be configured for no auth', async () => {
const communityConfig: FanoutConfig = {
id: 'comm-1',
@@ -733,6 +852,65 @@ describe('SettingsFanoutSection', () => {
expect(screen.queryByLabelText('Broker Host')).not.toBeInTheDocument();
});
it('private MQTT fields can be cleared while editing and normalize defaults on create', async () => {
const createdConfig: FanoutConfig = {
id: 'mqtt-private-1',
type: 'mqtt_private',
name: 'Private MQTT 1',
enabled: true,
config: {
broker_host: 'broker.local',
broker_port: 1883,
username: '',
password: '',
use_tls: false,
tls_insecure: false,
topic_prefix: 'meshcore',
},
scope: { messages: 'all', raw_packets: 'all' },
sort_order: 0,
created_at: 2000,
};
mockedApi.createFanoutConfig.mockResolvedValue(createdConfig);
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdConfig]);
renderSection();
await openCreateIntegrationDialog();
selectCreateIntegration('Private MQTT');
confirmCreateIntegration();
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
fireEvent.change(screen.getByLabelText('Broker Host'), { target: { value: 'broker.local' } });
const portInput = screen.getByLabelText('Broker Port') as HTMLInputElement;
const prefixInput = screen.getByLabelText('Topic Prefix') as HTMLInputElement;
fireEvent.change(portInput, { target: { value: '' } });
fireEvent.change(prefixInput, { target: { value: '' } });
expect(portInput.value).toBe('');
expect(prefixInput.value).toBe('');
fireEvent.click(screen.getByRole('button', { name: 'Save as Enabled' }));
await waitFor(() =>
expect(mockedApi.createFanoutConfig).toHaveBeenCalledWith({
type: 'mqtt_private',
name: 'Private MQTT #1',
config: {
broker_host: 'broker.local',
broker_port: 1883,
username: '',
password: '',
use_tls: false,
tls_insecure: false,
topic_prefix: 'meshcore',
},
scope: { messages: 'all', raw_packets: 'all' },
enabled: true,
})
);
});
it('creates MeshRank preset as a regular mqtt_community config', async () => {
const createdConfig: FanoutConfig = {
id: 'comm-meshrank',
@@ -862,6 +1040,57 @@ describe('SettingsFanoutSection', () => {
);
});
it('map upload geofence radius can be cleared while editing and normalizes to zero', async () => {
const createdConfig: FanoutConfig = {
id: 'map-1',
type: 'map_upload',
name: 'Map Upload 1',
enabled: true,
config: {
api_url: '',
dry_run: true,
geofence_enabled: true,
geofence_radius_km: 0,
},
scope: { messages: 'none', raw_packets: 'all' },
sort_order: 0,
created_at: 2000,
};
mockedApi.createFanoutConfig.mockResolvedValue(createdConfig);
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdConfig]);
renderSection();
await openCreateIntegrationDialog();
selectCreateIntegration('Map Upload');
confirmCreateIntegration();
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
fireEvent.click(screen.getByText('Enable Geofence'));
const radiusInput = screen.getByLabelText('Radius (km)') as HTMLInputElement;
fireEvent.change(radiusInput, { target: { value: '100' } });
fireEvent.change(radiusInput, { target: { value: '' } });
expect(radiusInput.value).toBe('');
fireEvent.click(screen.getByRole('button', { name: 'Save as Enabled' }));
await waitFor(() =>
expect(mockedApi.createFanoutConfig).toHaveBeenCalledWith({
type: 'map_upload',
name: 'Map Upload #1',
config: {
api_url: '',
dry_run: true,
geofence_enabled: true,
geofence_radius_km: 0,
},
scope: { messages: 'none', raw_packets: 'all' },
enabled: true,
})
);
});
it('LetsMesh (EU) preset saves the EU broker defaults', async () => {
const createdConfig: FanoutConfig = {
id: 'comm-letsmesh-eu',
+66
View File
@@ -0,0 +1,66 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
applyFontScale,
DEFAULT_FONT_SCALE,
FONT_SCALE_KEY,
MAX_FONT_SCALE,
MIN_FONT_SCALE,
getSavedFontScale,
setSavedFontScale,
} from '../utils/fontScale';
describe('fontScale utilities', () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.style.fontSize = '';
});
afterEach(() => {
document.documentElement.style.fontSize = '';
});
it('defaults to 100% when nothing is saved', () => {
expect(getSavedFontScale()).toBe(DEFAULT_FONT_SCALE);
});
it('reads a saved scale from localStorage', () => {
localStorage.setItem(FONT_SCALE_KEY, '135');
expect(getSavedFontScale()).toBe(135);
});
it('falls back to the default when the saved value is invalid', () => {
localStorage.setItem(FONT_SCALE_KEY, 'giant');
expect(getSavedFontScale()).toBe(DEFAULT_FONT_SCALE);
});
it('applies the scale to the document root', () => {
expect(applyFontScale(150)).toBe(150);
expect(document.documentElement.style.fontSize).toBe('150%');
});
it('stores non-default values and applies them immediately', () => {
expect(setSavedFontScale(137.5)).toBe(137.5);
expect(localStorage.getItem(FONT_SCALE_KEY)).toBe('137.5');
expect(document.documentElement.style.fontSize).toBe('137.5%');
});
it('removes the saved value when returning to the default scale', () => {
localStorage.setItem(FONT_SCALE_KEY, '150');
expect(setSavedFontScale(DEFAULT_FONT_SCALE)).toBe(DEFAULT_FONT_SCALE);
expect(localStorage.getItem(FONT_SCALE_KEY)).toBeNull();
expect(document.documentElement.style.fontSize).toBe('100%');
});
it('clamps saved and applied values to the supported range', () => {
localStorage.setItem(FONT_SCALE_KEY, '900');
expect(getSavedFontScale()).toBe(MAX_FONT_SCALE);
expect(setSavedFontScale(5)).toBe(MIN_FONT_SCALE);
expect(localStorage.getItem(FONT_SCALE_KEY)).toBe(String(MIN_FONT_SCALE));
expect(document.documentElement.style.fontSize).toBe(`${MIN_FONT_SCALE}%`);
});
});
+79
View File
@@ -140,6 +140,85 @@ describe('MessageList channel sender rendering', () => {
expect(screen.getByRole('button', { name: 'View info for Alice' })).toBeInTheDocument();
});
it('renders valid channel references as clickable links and ignores invalid ones', async () => {
const user = userEvent.setup();
const onChannelReferenceClick = vi.fn();
render(
<MessageList
messages={[
createMessage({
text: 'Alice: Join #mesh-room now skip #bad--room and visit https://example.com/#also-skip',
}),
]}
contacts={[]}
loading={false}
onChannelReferenceClick={onChannelReferenceClick}
/>
);
const linkedChannel = screen.getByRole('button', { name: '#mesh-room' });
expect(linkedChannel).toBeInTheDocument();
expect(screen.queryByRole('button', { name: '#bad--room' })).not.toBeInTheDocument();
expect(
screen.getByRole('link', { name: 'https://example.com/#also-skip' })
).toBeInTheDocument();
await user.click(linkedChannel);
expect(onChannelReferenceClick).toHaveBeenCalledWith('#mesh-room');
});
it('links valid channel references when followed by clause punctuation', async () => {
const user = userEvent.setup();
const onChannelReferenceClick = vi.fn();
render(
<MessageList
messages={[
createMessage({
text: 'Alice: Check #mesh-room, then #ops-room; then #alpha-room.',
}),
]}
contacts={[]}
loading={false}
onChannelReferenceClick={onChannelReferenceClick}
/>
);
await user.click(screen.getByRole('button', { name: '#mesh-room' }));
await user.click(screen.getByRole('button', { name: '#ops-room' }));
await user.click(screen.getByRole('button', { name: '#alpha-room' }));
expect(onChannelReferenceClick).toHaveBeenNthCalledWith(1, '#mesh-room');
expect(onChannelReferenceClick).toHaveBeenNthCalledWith(2, '#ops-room');
expect(onChannelReferenceClick).toHaveBeenNthCalledWith(3, '#alpha-room');
});
it('links valid channel references in direct messages too', async () => {
const user = userEvent.setup();
const onChannelReferenceClick = vi.fn();
render(
<MessageList
messages={[
createMessage({
type: 'PRIV',
text: 'check #ops-room',
conversation_key: 'ab'.repeat(32),
}),
]}
contacts={[]}
loading={false}
onChannelReferenceClick={onChannelReferenceClick}
/>
);
await user.click(screen.getByRole('button', { name: '#ops-room' }));
expect(onChannelReferenceClick).toHaveBeenCalledWith('#ops-room');
});
it('renders and dismisses an unread marker at the first unread message boundary', async () => {
const user = userEvent.setup();
const messages = [
+46 -1
View File
@@ -6,7 +6,12 @@
*/
import { describe, it, expect } from 'vitest';
import { parseSenderFromText, formatTime } from '../utils/messageParser';
import {
findLinkedChannelReferences,
formatTime,
isValidLinkedChannelName,
parseSenderFromText,
} from '../utils/messageParser';
describe('parseSenderFromText', () => {
it('extracts sender and content from "sender: message" format', () => {
@@ -95,3 +100,43 @@ describe('formatTime', () => {
expect(result).toMatch(/\d{1,2}:\d{2}/); // time portion
});
});
describe('linked channel references', () => {
it('accepts lowercase alphanumeric names with single dashes', () => {
expect(isValidLinkedChannelName('ops')).toBe(true);
expect(isValidLinkedChannelName('ops-1')).toBe(true);
expect(isValidLinkedChannelName('1-2-3')).toBe(true);
});
it('rejects uppercase, leading or trailing dashes, and repeated dashes', () => {
expect(isValidLinkedChannelName('Ops')).toBe(false);
expect(isValidLinkedChannelName('-ops')).toBe(false);
expect(isValidLinkedChannelName('ops-')).toBe(false);
expect(isValidLinkedChannelName('ops--room')).toBe(false);
});
it('finds standalone linked channel references in message text', () => {
expect(findLinkedChannelReferences('Join #mesh-room then say hi in #ops2')).toEqual([
{ label: '#mesh-room', start: 5, end: 15 },
{ label: '#ops2', start: 31, end: 36 },
]);
});
it('finds linked channel references terminated by clause punctuation', () => {
expect(
findLinkedChannelReferences('Join #mesh-room, then #ops2; finally #alpha-room.')
).toEqual([
{ label: '#mesh-room', start: 5, end: 15 },
{ label: '#ops2', start: 22, end: 27 },
{ label: '#alpha-room', start: 37, end: 48 },
]);
});
it('ignores invalid or embedded channel-like text', () => {
const references = findLinkedChannelReferences(
'skip #Bad #bad--name abc#ops #ops- #opsRoom #ops_room #good-room,'
);
expect(references.map((reference) => reference.label)).toEqual(['#good-room']);
});
});
+74 -1
View File
@@ -27,12 +27,16 @@ describe('NewMessageModal form reset', () => {
const onCreateContact = vi.fn().mockResolvedValue(undefined);
const onCreateChannel = vi.fn().mockResolvedValue(undefined);
const onCreateHashtagChannel = vi.fn().mockResolvedValue(undefined);
const onBulkAddHashtagChannels = vi.fn().mockResolvedValue(undefined);
beforeEach(() => {
vi.clearAllMocks();
});
function renderModal(open = true) {
function renderModal(
open = true,
overrides: Partial<Parameters<typeof NewMessageModal>[0]> = {}
) {
return render(
<NewMessageModal
open={open}
@@ -41,6 +45,8 @@ describe('NewMessageModal form reset', () => {
onCreateContact={onCreateContact}
onCreateChannel={onCreateChannel}
onCreateHashtagChannel={onCreateHashtagChannel}
onBulkAddHashtagChannels={onBulkAddHashtagChannels}
{...overrides}
/>
);
}
@@ -50,6 +56,26 @@ describe('NewMessageModal form reset', () => {
}
describe('hashtag tab', () => {
it('prefills the hashtag tab from a linked channel request', async () => {
renderModal(true, {
prefillRequest: {
tab: 'hashtag',
hashtagName: 'mesh-room',
nonce: 1,
},
});
await waitFor(() => {
expect(screen.getByRole('tab', { name: 'Hashtag Channel' })).toHaveAttribute(
'data-state',
'active'
);
});
expect((screen.getByPlaceholderText('channel-name') as HTMLInputElement).value).toBe(
'mesh-room'
);
});
it('clears name after successful Create', async () => {
const user = userEvent.setup();
const { unmount } = renderModal();
@@ -87,6 +113,53 @@ describe('NewMessageModal form reset', () => {
});
});
describe('bulk hashtag tab', () => {
it('is only visible when enabled', () => {
renderModal();
expect(screen.queryByRole('tab', { name: 'Bulk Add Channel' })).toBeNull();
});
it('opens on the bulk tab when enabled and submits normalized room names', async () => {
const user = userEvent.setup();
renderModal(true, { showBulkAddChannelTab: true });
await waitFor(() => {
expect(screen.getByRole('tab', { name: 'Bulk Add Channel' })).toHaveAttribute(
'data-state',
'active'
);
});
await user.type(
screen.getByRole('textbox', { name: 'Bulk channel names' }),
'#Ops{enter}mesh-room another-room #Ops'
);
await user.click(screen.getByRole('button', { name: 'Add Channels' }));
await waitFor(() => {
expect(onBulkAddHashtagChannels).toHaveBeenCalledWith(
['#ops', '#mesh-room', '#another-room'],
false
);
});
expect(onClose).toHaveBeenCalled();
});
it('shows invalid bulk room names before submitting', async () => {
const user = userEvent.setup();
renderModal(true, { showBulkAddChannelTab: true });
await user.type(
screen.getByRole('textbox', { name: 'Bulk channel names' }),
'good-room bad_room'
);
await user.click(screen.getByRole('button', { name: 'Add Channels' }));
expect(onBulkAddHashtagChannels).not.toHaveBeenCalled();
expect(screen.getByText('Invalid room names: bad_room')).toBeTruthy();
});
});
describe('new-contact tab', () => {
it('clears name and key after successful Create', async () => {
const user = userEvent.setup();
@@ -283,6 +283,8 @@ describe('RawPacketFeedView', () => {
fireEvent.click(screen.getByRole('button', { name: /show stats/i }));
fireEvent.change(screen.getByLabelText('Stats window'), { target: { value: 'session' } });
expect(screen.getAllByText('Alpha').length).toBeGreaterThan(0);
expect(screen.getByText('Strongest Neighbor')).toBeInTheDocument();
expect(screen.getByText('-70 dBm best heard')).toBeInTheDocument();
});
it('marks unresolved neighbor identities explicitly', () => {
@@ -51,6 +51,14 @@ vi.mock('../hooks/useRepeaterDashboard', () => ({
useRepeaterDashboard: () => mockHook,
}));
// Mock api module (TelemetryHistoryPane fetches on mount)
vi.mock('../api', () => ({
api: {
repeaterTelemetryHistory: vi.fn().mockResolvedValue([]),
setContactRoutingOverride: vi.fn().mockResolvedValue({ status: 'ok' }),
},
}));
// Mock sonner toast
vi.mock('../components/ui/sonner', () => ({
toast: {
@@ -118,6 +126,16 @@ const defaultProps = {
onDeleteContact: vi.fn(),
};
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
describe('RepeaterDashboard', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -418,6 +436,7 @@ describe('RepeaterDashboard', () => {
flood_dups: 1,
direct_dups: 0,
full_events: 0,
telemetry_history: [],
};
render(<RepeaterDashboard {...defaultProps} />);
@@ -634,4 +653,106 @@ describe('RepeaterDashboard', () => {
overrideSpy.mockRestore();
});
});
describe('telemetry history', () => {
beforeEach(async () => {
const { api } = await import('../api');
vi.mocked(api.repeaterTelemetryHistory).mockResolvedValue([]);
});
it('loads telemetry history on mount when logged in', async () => {
const { api } = await import('../api');
mockHook.loggedIn = true;
render(<RepeaterDashboard {...defaultProps} />);
await waitFor(() => {
expect(api.repeaterTelemetryHistory).toHaveBeenCalledWith(REPEATER_KEY);
});
});
it('shows telemetry history pane in logged-in view even before status fetch', () => {
mockHook.loggedIn = true;
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText('Telemetry History')).toBeInTheDocument();
expect(screen.getByText('0 samples')).toBeInTheDocument();
});
it('updates history from live status fetch', async () => {
const { api } = await import('../api');
const historySpy = vi.mocked(api.repeaterTelemetryHistory);
const liveEntry = { timestamp: 1700000000, data: { battery_volts: 4.2 } };
historySpy.mockResolvedValue([]);
mockHook.loggedIn = true;
mockHook.paneData.status = {
battery_volts: 4.2,
tx_queue_len: 0,
noise_floor_dbm: -120,
last_rssi_dbm: -85,
last_snr_db: 7.5,
packets_received: 100,
packets_sent: 50,
airtime_seconds: 600,
rx_airtime_seconds: 1200,
uptime_seconds: 86400,
sent_flood: 10,
sent_direct: 40,
recv_flood: 30,
recv_direct: 70,
flood_dups: 1,
direct_dups: 0,
full_events: 0,
telemetry_history: [liveEntry],
};
render(<RepeaterDashboard {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('1 samples')).toBeInTheDocument();
});
});
it('does not let an older preload overwrite newer live status history', async () => {
const { api } = await import('../api');
const historySpy = vi.mocked(api.repeaterTelemetryHistory);
const deferred = createDeferred<{ timestamp: number; data: { battery_volts: number } }[]>();
historySpy.mockReturnValue(deferred.promise);
mockHook.loggedIn = true;
mockHook.paneData.status = {
battery_volts: 4.2,
tx_queue_len: 0,
noise_floor_dbm: -120,
last_rssi_dbm: -85,
last_snr_db: 7.5,
packets_received: 100,
packets_sent: 50,
airtime_seconds: 600,
rx_airtime_seconds: 1200,
uptime_seconds: 86400,
sent_flood: 10,
sent_direct: 40,
recv_flood: 30,
recv_direct: 70,
flood_dups: 1,
direct_dups: 0,
full_events: 0,
telemetry_history: [{ timestamp: 1700000000, data: { battery_volts: 4.2 } }],
};
render(<RepeaterDashboard {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('1 samples')).toBeInTheDocument();
});
deferred.resolve([{ timestamp: 1690000000, data: { battery_volts: 3.9 } }]);
await deferred.promise;
expect(screen.getByText('1 samples')).toBeInTheDocument();
});
});
});
+78 -10
View File
@@ -20,6 +20,12 @@ import {
} from '../utils/lastViewedConversation';
import { api } from '../api';
import { DISTANCE_UNIT_KEY } from '../utils/distanceUnits';
import {
DEFAULT_FONT_SCALE,
FONT_SCALE_KEY,
MAX_FONT_SCALE,
MIN_FONT_SCALE,
} from '../utils/fontScale';
const baseConfig: RadioConfig = {
public_key: 'aa'.repeat(32),
@@ -63,6 +69,7 @@ const baseSettings: AppSettings = {
flood_scope: '',
blocked_keys: [],
blocked_names: [],
discovery_blocked_types: [],
};
function renderModal(overrides?: {
@@ -186,6 +193,7 @@ describe('SettingsModal', () => {
vi.restoreAllMocks();
localStorage.clear();
window.location.hash = '';
document.documentElement.style.fontSize = '';
});
it('refreshes app settings when opened', async () => {
@@ -300,6 +308,7 @@ describe('SettingsModal', () => {
results: [
{
public_key: '11'.repeat(32),
name: null,
node_type: 'repeater',
heard_count: 2,
local_snr: 7.5,
@@ -548,6 +557,55 @@ describe('SettingsModal', () => {
expect(localStorage.getItem(DISTANCE_UNIT_KEY)).toBe('smoots');
});
it('defaults relative font size to 100% and exposes the expected input bounds', () => {
renderModal();
openLocalSection();
const slider = screen.getByLabelText('Relative font size slider');
const input = screen.getByLabelText('Relative font size percentage');
expect(slider).toHaveValue(String(DEFAULT_FONT_SCALE));
expect(slider).toHaveAttribute('step', '5');
expect(input).toHaveValue(DEFAULT_FONT_SCALE);
expect(input).toHaveAttribute('min', String(MIN_FONT_SCALE));
expect(input).toHaveAttribute('max', String(MAX_FONT_SCALE));
});
it('stores and applies relative font size changes locally', async () => {
renderModal();
openLocalSection();
const slider = screen.getByLabelText('Relative font size slider');
fireEvent.change(slider, { target: { value: '135' } });
expect(localStorage.getItem(FONT_SCALE_KEY)).toBeNull();
expect(document.documentElement.style.fontSize).toBe('');
fireEvent.mouseUp(slider);
await waitFor(() => {
expect(localStorage.getItem(FONT_SCALE_KEY)).toBe('135');
expect(document.documentElement.style.fontSize).toBe('135%');
});
fireEvent.change(screen.getByLabelText('Relative font size percentage'), {
target: { value: '137.5' },
});
await waitFor(() => {
expect(localStorage.getItem(FONT_SCALE_KEY)).toBe('137.5');
expect(document.documentElement.style.fontSize).toBe('137.5%');
});
fireEvent.click(screen.getByRole('button', { name: 'Reset' }));
await waitFor(() => {
expect(localStorage.getItem(FONT_SCALE_KEY)).toBeNull();
expect(document.documentElement.style.fontSize).toBe('100%');
});
});
it('purges decrypted raw packets via maintenance endpoint action', async () => {
const runMaintenanceSpy = vi.spyOn(api, 'runMaintenance').mockResolvedValue({
packets_deleted: 12,
@@ -558,10 +616,10 @@ describe('SettingsModal', () => {
openDatabaseSection();
expect(
screen.getByText(/remove packet-analysis availability for those historical messages/i)
screen.getByText(/removes packet-analysis availability for those messages/i)
).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Purge Archival Raw Packets' }));
fireEvent.click(screen.getByRole('button', { name: 'Purge Archival Packets' }));
await waitFor(() => {
expect(runMaintenanceSpy).toHaveBeenCalledWith({ purgeLinkedRawPackets: true });
@@ -594,6 +652,14 @@ describe('SettingsModal', () => {
double_byte_pct: 30,
triple_byte_pct: 20,
},
noise_floor_24h: {
sample_interval_seconds: 300,
coverage_seconds: 3600,
latest_noise_floor_dbm: -105,
latest_timestamp: 1711800000,
supported: true,
samples: [],
},
};
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
@@ -625,17 +691,11 @@ describe('SettingsModal', () => {
expect(
screen.getByText(/Parsed stored raw packets from the last 24 hours: 120/)
).toBeInTheDocument();
expect(screen.getByText('1-byte hops')).toBeInTheDocument();
expect(screen.getByText('60 (50.0%)')).toBeInTheDocument();
expect(screen.getByText('36 (30.0%)')).toBeInTheDocument();
expect(screen.getByText('24 (20.0%)')).toBeInTheDocument();
expect(screen.getByText('Contacts heard')).toBeInTheDocument();
expect(screen.getByText('Repeaters heard')).toBeInTheDocument();
expect(screen.getByText('Known-channels active')).toBeInTheDocument();
// Busiest channels
expect(screen.getByText('general')).toBeInTheDocument();
expect(screen.getByText('42 msgs')).toBeInTheDocument();
expect(screen.getByText('Busiest Channels (24h)')).toBeInTheDocument();
expect(screen.getByText('Noise Floor (24h)')).toBeInTheDocument();
});
it('fetches statistics when expanded in mobile external-nav mode', async () => {
@@ -662,6 +722,14 @@ describe('SettingsModal', () => {
double_byte_pct: 30,
triple_byte_pct: 20,
},
noise_floor_24h: {
sample_interval_seconds: 300,
coverage_seconds: 0,
latest_noise_floor_dbm: null,
latest_timestamp: null,
supported: null,
samples: [],
},
};
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
+16
View File
@@ -7,3 +7,19 @@ class ResizeObserver {
}
globalThis.ResizeObserver = ResizeObserver;
// Several components call matchMedia at import time for responsive detection
if (typeof globalThis.matchMedia === 'undefined') {
Object.defineProperty(globalThis, 'matchMedia', {
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
}
+55 -2
View File
@@ -75,13 +75,14 @@ function renderSidebar(overrides?: {
const favorites = overrides?.favorites ?? [{ type: 'channel', id: flightChannel.key }];
const channels = overrides?.channels ?? [publicChannel, flightChannel, opsChannel];
const onSelectConversation = vi.fn();
const view = render(
<Sidebar
contacts={[alice, board, relay]}
channels={channels}
activeConversation={null}
onSelectConversation={vi.fn()}
onSelectConversation={onSelectConversation}
onNewMessage={vi.fn()}
lastMessageTimes={overrides?.lastMessageTimes ?? {}}
unreadCounts={unreadCounts}
@@ -96,7 +97,7 @@ function renderSidebar(overrides?: {
/>
);
return { ...view, flightChannel, opsChannel, aliceName, roomName };
return { ...view, flightChannel, opsChannel, aliceName, roomName, onSelectConversation };
}
function getSectionHeaderContainer(title: string): HTMLElement {
@@ -121,6 +122,46 @@ describe('Sidebar section summaries', () => {
expect(within(getSectionHeaderContainer('Repeaters')).getByText('4')).toBeInTheDocument();
});
it('renders a full add channel/contact button above search and calls onNewMessage', () => {
const onNewMessage = vi.fn();
render(
<Sidebar
contacts={[]}
channels={[makeChannel(PUBLIC_CHANNEL_KEY, 'Public')]}
activeConversation={null}
onSelectConversation={vi.fn()}
onNewMessage={onNewMessage}
lastMessageTimes={{}}
unreadCounts={{}}
mentions={{}}
showCracker={false}
crackerRunning={false}
onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()}
favorites={[]}
legacySortOrder="recent"
/>
);
const addButton = screen.getByRole('button', { name: 'Add channel or contact' });
const search = screen.getByLabelText('Search conversations');
const nav = screen.getByRole('navigation', { name: 'Conversations' });
const toolsButton = screen.getByRole('button', { name: 'Tools' });
expect(addButton).toHaveTextContent('Add Channel/Contact');
expect(
addButton.compareDocumentPosition(search) & Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy();
expect(nav.compareDocumentPosition(search) & Node.DOCUMENT_POSITION_CONTAINED_BY).toBeTruthy();
expect(
search.compareDocumentPosition(toolsButton) & Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy();
fireEvent.click(addButton);
expect(onNewMessage).toHaveBeenCalledTimes(1);
});
it('turns favorites and channels rollups red when they contain a mention', () => {
renderSidebar({
mentions: {
@@ -306,6 +347,18 @@ describe('Sidebar section summaries', () => {
expect(bell.compareDocumentPosition(unread) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it('shows the trace tool row and selects it', () => {
const { onSelectConversation } = renderSidebar();
fireEvent.click(screen.getByText('Trace'));
expect(onSelectConversation).toHaveBeenCalledWith({
type: 'trace',
id: 'trace',
name: 'Trace',
});
});
it('sorts each section independently and persists per-section sort preferences', () => {
const publicChannel = makeChannel('AA'.repeat(16), 'Public');
const zebraChannel = makeChannel('BB'.repeat(16), '#zebra');
+262
View File
@@ -0,0 +1,262 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { TracePane } from '../components/TracePane';
import type { Contact, RadioConfig, RadioTraceResponse } from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
function makeContact(
publicKey: string,
name: string | null,
type = CONTACT_TYPE_REPEATER,
overrides: Partial<Contact> = {}
): Contact {
return {
public_key: publicKey,
name,
type,
flags: 0,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
...overrides,
};
}
const config: RadioConfig = {
public_key: 'ff'.repeat(32),
name: 'Base Radio',
lat: 10,
lon: 20,
tx_power: 17,
max_tx_power: 22,
radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 },
path_hash_mode: 0,
path_hash_mode_supported: true,
};
describe('TracePane', () => {
it('shows only full-key repeaters and filters by name or key', () => {
render(
<TracePane
config={config}
onRunTracePath={vi.fn()}
contacts={[
makeContact('11'.repeat(32), 'Relay Alpha'),
makeContact('22'.repeat(6), 'Prefix Relay'),
makeContact('33'.repeat(32), 'Client Node', 1),
makeContact('44'.repeat(32), 'Relay Beta'),
]}
/>
);
expect(screen.getByText('Relay Alpha')).toBeInTheDocument();
expect(screen.getByText('Relay Beta')).toBeInTheDocument();
expect(screen.queryByText('Prefix Relay')).not.toBeInTheDocument();
expect(screen.queryByText('Client Node')).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Search repeaters'), { target: { value: 'beta' } });
expect(screen.queryByText('Relay Alpha')).not.toBeInTheDocument();
expect(screen.getByText('Relay Beta')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Search repeaters'), { target: { value: '111111' } });
expect(screen.getByText('Relay Alpha')).toBeInTheDocument();
});
it('adds, reorders, removes, and sends a trace path with known repeaters', async () => {
const relayA = makeContact('11'.repeat(32), 'Relay Alpha');
const relayB = makeContact('22'.repeat(32), 'Relay Beta');
const onRunTracePath = vi.fn(
async (): Promise<RadioTraceResponse> => ({
path_len: 2,
timeout_seconds: 6,
nodes: [
{
role: 'repeater',
public_key: relayB.public_key,
name: relayB.name,
observed_hash: relayB.public_key.slice(0, 8),
snr: 7.5,
},
{
role: 'repeater',
public_key: relayA.public_key,
name: relayA.name,
observed_hash: relayA.public_key.slice(0, 8),
snr: 3.25,
},
{
role: 'local',
public_key: config.public_key,
name: config.name,
observed_hash: null,
snr: 5.0,
},
],
})
);
render(
<TracePane config={config} onRunTracePath={onRunTracePath} contacts={[relayA, relayB]} />
);
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i }));
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay beta/i }));
expect(screen.getByText('2 hops selected · 4-byte trace')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /move relay beta up/i }));
fireEvent.click(screen.getByRole('button', { name: /send trace/i }));
await waitFor(() => {
expect(onRunTracePath).toHaveBeenCalledWith(4, [
{ public_key: relayB.public_key },
{ public_key: relayA.public_key },
]);
});
expect(screen.getByRole('heading', { name: 'Results (6.0s)' })).toBeInTheDocument();
expect(screen.getByText('+7.5 dB')).toBeInTheDocument();
expect(screen.getByText('+5.0 dB')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /remove relay alpha/i }));
expect(screen.getByText('1 hop selected · 4-byte trace')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /remove relay beta/i }));
expect(screen.getByText('No hops selected')).toBeInTheDocument();
});
it('allows adding the same repeater multiple times from the picker row', () => {
const relayA = makeContact('11'.repeat(32), 'Relay Alpha');
render(<TracePane config={config} onRunTracePath={vi.fn()} contacts={[relayA]} />);
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i }));
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i }));
expect(screen.getByText('2 hops selected · 4-byte trace')).toBeInTheDocument();
expect(screen.getByText('Added 2 times')).toBeInTheDocument();
});
it('adds custom hops from the modal and locks later custom hops to the same byte width', async () => {
const relayA = makeContact('11'.repeat(32), 'Relay Alpha');
const onRunTracePath = vi.fn(
async (): Promise<RadioTraceResponse> => ({
path_len: 2,
timeout_seconds: 4.5,
nodes: [
{
role: 'custom',
public_key: null,
name: null,
observed_hash: 'ae',
snr: 4.0,
},
{
role: 'repeater',
public_key: relayA.public_key,
name: relayA.name,
observed_hash: '11',
snr: 2.0,
},
{
role: 'local',
public_key: config.public_key,
name: config.name,
observed_hash: null,
snr: 3.0,
},
],
})
);
render(<TracePane config={config} onRunTracePath={onRunTracePath} contacts={[relayA]} />);
fireEvent.click(screen.getByRole('button', { name: 'Custom path' }));
fireEvent.click(screen.getByRole('button', { name: '1-byte' }));
fireEvent.change(screen.getByLabelText('Repeater prefix'), { target: { value: 'ae' } });
fireEvent.click(screen.getByRole('button', { name: 'Add custom hop' }));
expect(screen.getByText('1 hop selected · 1-byte trace')).toBeInTheDocument();
expect(screen.getByText('AE (1-byte)')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i }));
fireEvent.click(screen.getByRole('button', { name: /send trace/i }));
await waitFor(() => {
expect(onRunTracePath).toHaveBeenCalledWith(1, [
{ hop_hex: 'ae' },
{ public_key: relayA.public_key },
]);
});
fireEvent.click(screen.getByRole('button', { name: 'Custom path' }));
expect(screen.getByRole('button', { name: '2-byte' })).toBeDisabled();
expect(screen.getByRole('button', { name: '4-byte' })).toBeDisabled();
expect(screen.getByText(/custom hops are locked to 1-byte prefixes/i)).toBeInTheDocument();
});
it('drops an in-flight result after the draft path changes', async () => {
const relayA = makeContact('11'.repeat(32), 'Relay Alpha');
const relayB = makeContact('22'.repeat(32), 'Relay Beta');
let resolveTrace: ((value: RadioTraceResponse) => void) | null = null;
const onRunTracePath = vi.fn(
() =>
new Promise<RadioTraceResponse>((resolve) => {
resolveTrace = resolve;
})
);
render(
<TracePane config={config} onRunTracePath={onRunTracePath} contacts={[relayA, relayB]} />
);
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i }));
fireEvent.click(screen.getByRole('button', { name: /send trace/i }));
await waitFor(() => {
expect(onRunTracePath).toHaveBeenCalledWith(4, [{ public_key: relayA.public_key }]);
});
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay beta/i }));
expect(screen.getByText('2 hops selected · 4-byte trace')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /send trace/i })).toBeEnabled();
await act(async () => {
resolveTrace?.({
path_len: 1,
timeout_seconds: 6,
nodes: [
{
role: 'repeater',
public_key: relayA.public_key,
name: relayA.name,
observed_hash: relayA.public_key.slice(0, 8),
snr: 7.5,
},
{
role: 'local',
public_key: config.public_key,
name: config.name,
observed_hash: null,
snr: 5.0,
},
],
});
});
expect(screen.queryByRole('heading', { name: 'Results (6.0s)' })).not.toBeInTheDocument();
expect(screen.queryByText('+7.5 dB')).not.toBeInTheDocument();
expect(
screen.getByText('Send a trace to see the returned hop-by-hop SNR values.')
).toBeInTheDocument();
});
});
+8
View File
@@ -52,6 +52,14 @@ describe('parseHashConversation', () => {
expect(result).toEqual({ type: 'map', name: 'map' });
});
it('parses #trace as trace type', () => {
window.location.hash = '#trace';
const result = parseHashConversation();
expect(result).toEqual({ type: 'trace', name: 'trace' });
});
it('parses #map/focus/PUBKEY with focus key', () => {
window.location.hash = '#map/focus/ABCD1234';

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