diff --git a/.gitignore b/.gitignore index 9ed39af..e3ad722 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/AGENTS.md b/AGENTS.md index f6debc9..5754ffe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 | diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e75979..a0dc948 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## [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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cb76d5e..256d09a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 ``` diff --git a/LICENSES.md b/LICENSES.md index 7fac22f..dac28f7 100644 --- a/LICENSES.md +++ b/LICENSES.md @@ -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. +### recharts (3.8.1) — MIT + +
+Full license text + +``` +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. +``` + +
+ ### sonner (2.0.7) — MIT
diff --git a/README.md b/README.md index a20e334..9fc8976 100644 --- a/README.md +++ b/README.md @@ -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,23 +89,17 @@ 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 @@ -119,47 +107,58 @@ Alternatively, if you have already cloned the repo, you can fetch just the prebu 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. -Edit `docker-compose.yaml` to set a serial device for passthrough, or uncomment your transport (serial or TCP). Then: +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. -```yaml -build: . +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 +docker compose up # -d for background once you validate it's working ``` -with: +The database is stored in `./data/` (bind-mounted), so the container shares the same database as the native app. -```yaml -image: jkingsman/remoteterm-meshcore:latest -``` - -Then run: +To rebuild after pulling updates: ```bash docker compose pull docker compose up -d ``` -Published Docker tags are intended to be multi-arch (`linux/amd64` and `linux/arm64`). If you are building and publishing manually, use Docker Buildx: +The example file and setup script default to the published Docker Hub image. To build locally from your checkout instead, replace: -```bash -docker buildx build \ - --platform linux/amd64,linux/arm64 \ - -t jkingsman/remoteterm-meshcore:latest \ - --push . +```yaml +image: docker.io/jkingsman/remoteterm-meshcore:latest ``` -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. +with: + +```yaml +build: . +``` + +Then run: + +```bash +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.yml` to keep ownership aligned with your host user. To stop: @@ -212,3 +211,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`. diff --git a/README_ADVANCED.md b/README_ADVANCED.md index 2b0684c..ff79af4 100644 --- a/README_ADVANCED.md +++ b/README_ADVANCED.md @@ -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. diff --git a/app/AGENTS.md b/app/AGENTS.md index 105fe7f..3c44261 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -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 ``` diff --git a/app/database.py b/app/database.py index e4cb912..77f8897 100644 --- a/app/database.py +++ b/app/database.py @@ -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 diff --git a/app/event_handlers.py b/app/event_handlers.py index 7a96f80..eaf5c43 100644 --- a/app/event_handlers.py +++ b/app/event_handlers.py @@ -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: diff --git a/app/main.py b/app/main.py index ddbe0bc..286cf95 100644 --- a/app/main.py +++ b/app/main.py @@ -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: diff --git a/app/migrations.py b/app/migrations.py index 1e62576..0802a06 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -360,13 +360,20 @@ async def run_migrations(conn: aiosqlite.Connection) -> int: await set_version(conn, 46) applied += 1 - # Migration 47: Repeater telemetry history table + tracking opt-in column + # Migration 47: Add statistics indexes for time-windowed scans if version < 47: - logger.info("Applying migration 47: repeater telemetry history") - await _migrate_047_repeater_telemetry_history(conn) + logger.info("Applying migration 47: add statistics indexes") + await _migrate_047_add_statistics_indexes(conn) await set_version(conn, 47) applied += 1 + # Migration 48: Repeater telemetry history table + tracking opt-in column + if version < 48: + logger.info("Applying migration 48: repeater telemetry history") + await _migrate_048_repeater_telemetry_history(conn) + await set_version(conn, 48) + applied += 1 + if applied > 0: logger.info( "Applied %d migration(s), schema now at version %d", applied, await get_version(conn) @@ -2877,7 +2884,41 @@ async def _migrate_046_cleanup_orphaned_contact_child_rows(conn: aiosqlite.Conne await conn.commit() -async def _migrate_047_repeater_telemetry_history(conn: aiosqlite.Connection) -> None: +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_repeater_telemetry_history(conn: aiosqlite.Connection) -> None: """Create repeater_telemetry_history table and add tracking opt-in column to app_settings.""" await conn.execute( """ diff --git a/app/models.py b/app/models.py index d0f0918..90bd112 100644 --- a/app/models.py +++ b/app/models.py @@ -628,6 +628,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 +734,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( @@ -824,6 +881,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 @@ -839,6 +917,7 @@ class StatisticsResponse(BaseModel): repeaters_heard: ContactActivityCounts known_channels_active: ContactActivityCounts path_hash_width_24h: PathHashWidthStats + noise_floor_24h: NoiseFloorHistoryStats class TelemetryHistoryEntry(BaseModel): diff --git a/app/packet_processor.py b/app/packet_processor.py index 12ac466..8a43618 100644 --- a/app/packet_processor.py +++ b/app/packet_processor.py @@ -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) diff --git a/app/radio_sync.py b/app/radio_sync.py index 2f908b0..290faeb 100644 --- a/app/radio_sync.py +++ b/app/radio_sync.py @@ -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 ( @@ -388,6 +388,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") @@ -412,7 +420,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( @@ -497,6 +506,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 @@ -534,6 +545,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) @@ -1119,6 +1132,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: @@ -1328,6 +1342,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: diff --git a/app/repository/raw_packets.py b/app/repository/raw_packets.py index c773a67..29ead2c 100644 --- a/app/repository/raw_packets.py +++ b/app/repository/raw_packets.py @@ -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()] diff --git a/app/repository/settings.py b/app/repository/settings.py index 0afc43a..daaf320 100644 --- a/app/repository/settings.py +++ b/app/repository/settings.py @@ -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: @@ -271,6 +272,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.""" @@ -297,17 +318,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), ) @@ -327,22 +357,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: @@ -425,22 +459,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) @@ -456,9 +475,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, diff --git a/app/routers/contacts.py b/app/routers/contacts.py index a4289df..3e628b8 100644 --- a/app/routers/contacts.py +++ b/app/routers/contacts.py @@ -40,6 +40,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 ( @@ -373,17 +377,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 +398,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}") diff --git a/app/routers/debug.py b/app/routers/debug.py index 55c1f65..f7573e9 100644 --- a/app/routers/debug.py +++ b/app/routers/debug.py @@ -265,7 +265,7 @@ async def _probe_radio() -> DebugRadioProbe: 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() + message_totals = await StatisticsRepository.get_database_message_totals() radio_probe = await _probe_radio() channels_with_incoming_messages = ( await MessageRepository.count_channels_with_incoming_messages() @@ -291,9 +291,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)], diff --git a/app/routers/packets.py b/app/routers/packets.py index 4c6374c..40475e4 100644 --- a/app/routers/packets.py +++ b/app/routers/packets.py @@ -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, diff --git a/app/routers/radio.py b/app/routers/radio.py index 795c106..920697a 100644 --- a/app/routers/radio.py +++ b/app/routers/radio.py @@ -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,20 @@ 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 from app.services.radio_commands import ( KeystoreRefreshError, PathHashModeUnsupportedError, @@ -44,6 +51,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 +210,118 @@ 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, + ) created = await ContactRepository.get_by_key(result.public_key) if created is not None: broadcast_event("contact", created.model_dump()) + for old_key in promoted_keys: + broadcast_event("contact_deleted", {"public_key": old_key}) + + +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 +487,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 +495,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() diff --git a/app/routers/repeaters.py b/app/routers/repeaters.py index 8ac3c89..2019f87 100644 --- a/app/routers/repeaters.py +++ b/app/routers/repeaters.py @@ -1,4 +1,3 @@ -import asyncio import logging import time from typing import TYPE_CHECKING @@ -31,7 +30,6 @@ from app.models import ( 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, @@ -40,9 +38,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 @@ -60,58 +55,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, diff --git a/app/routers/server_control.py b/app/routers/server_control.py index b3f9e9e..a13ffca 100644 --- a/app/routers/server_control.py +++ b/app/routers/server_control.py @@ -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) diff --git a/app/routers/statistics.py b/app/routers/statistics.py index 00dcbc8..a8050c8 100644 --- a/app/routers/statistics.py +++ b/app/routers/statistics.py @@ -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) diff --git a/app/services/radio_noise_floor.py b/app/services/radio_noise_floor.py new file mode 100644 index 0000000..d928821 --- /dev/null +++ b/app/services/radio_noise_floor.py @@ -0,0 +1,110 @@ +"""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: + await sample_noise_floor_once() + await asyncio.sleep(NOISE_FLOOR_SAMPLE_INTERVAL_SECONDS) + + +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, + } diff --git a/app/websocket.py b/app/websocket.py index 27ebdb0..81e67c8 100644 --- a/app/websocket.py +++ b/app/websocket.py @@ -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 diff --git a/docker-compose.yaml b/docker-compose.example.yml similarity index 51% rename from docker-compose.yaml rename to docker-compose.example.yml index 4ed71c0..2fbeeb3 100644 --- a/docker-compose.yaml +++ b/docker-compose.example.yml @@ -1,30 +1,42 @@ services: remoteterm: - build: . - # image: jkingsman/remoteterm-meshcore:latest + # 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 + ################################################ - # Set your serial device for passthrough here! # + # Map your radio by stable device ID if available. # ################################################ devices: - - /dev/ttyACM0:/dev/ttyUSB0 + - /dev/serial/by-id/your-meshcore-radio:/dev/meshcore-radio environment: MESHCORE_DATABASE_PATH: data/meshcore.db - # Radio connection -- optional if you map just a single serial device above, as the app will autodetect + + # Radio connection # Serial (USB) - # MESHCORE_SERIAL_PORT: /dev/ttyUSB0 + 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 diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index ac50444..5c44739 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -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: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3c1f138..12f6c50 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", @@ -2058,6 +2059,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", @@ -2415,6 +2452,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", @@ -2565,6 +2614,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", @@ -2572,6 +2639,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", @@ -2664,6 +2776,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", @@ -3713,12 +3831,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", @@ -3728,6 +3867,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", @@ -3758,12 +3906,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", @@ -3773,6 +3951,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", @@ -3821,6 +4051,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", @@ -3975,6 +4211,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", @@ -4217,6 +4463,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", @@ -4619,6 +4871,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", @@ -4656,6 +4918,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", @@ -5600,7 +5871,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 }, @@ -5618,6 +5888,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", @@ -5727,6 +6020,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", @@ -5741,6 +6064,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", @@ -6135,6 +6479,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", @@ -6455,12 +6805,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", diff --git a/frontend/package.json b/frontend/package.json index c320635..b5e50c8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "remoteterm-meshcore-frontend", "private": true, - "version": "3.6.2", + "version": "3.6.3", "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", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 601f458..6d50afb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -274,6 +274,7 @@ export function App() { unreadLastReadAts, recordMessageEvent, renameConversationState, + removeConversationState, markAllRead, refreshUnreads, } = useUnreadCounts(channels, contacts, activeConversation); @@ -349,6 +350,7 @@ export function App() { observeMessage, recordMessageEvent, renameConversationState, + removeConversationState, checkMention, pendingDeleteFallbackRef, setActiveConversation, @@ -457,6 +459,7 @@ export function App() { loadingNewer, messageInputRef, onTrace: handleTrace, + onRunTracePath: api.requestRadioTrace, onPathDiscovery: handlePathDiscovery, onToggleFavorite: handleToggleFavorite, onDeleteContact: handleDeleteContact, diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 8c7a5f5..2f6f2fb 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -20,6 +20,8 @@ import type { RadioConfig, RadioConfigUpdate, RadioDiscoveryResponse, + RadioTraceHopRequest, + RadioTraceResponse, RadioDiscoveryTarget, PathDiscoveryResponse, ResendChannelMessageResponse, @@ -108,6 +110,11 @@ export const api = { method: 'POST', body: JSON.stringify({ target }), }), + requestRadioTrace: (hopHashBytes: 1 | 2 | 4, hops: RadioTraceHopRequest[]) => + fetchJson('/radio/trace', { + method: 'POST', + body: JSON.stringify({ hop_hash_bytes: hopHashBytes, hops }), + }), rebootRadio: () => fetchJson<{ status: string; message: string }>('/radio/reboot', { method: 'POST', @@ -131,11 +138,13 @@ export const api = { fetchJson( `/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(`/contacts/analytics?${searchParams.toString()}`); + return fetchJson(`/contacts/analytics?${searchParams.toString()}`, { + signal, + }); }, deleteContact: (publicKey: string) => fetchJson<{ status: string }>(`/contacts/${publicKey}`, { diff --git a/frontend/src/components/ChatHeader.tsx b/frontend/src/components/ChatHeader.tsx index d92c4b3..aa6fba9 100644 --- a/frontend/src/components/ChatHeader.tsx +++ b/frontend/src/components/ChatHeader.tsx @@ -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} diff --git a/frontend/src/components/ContactInfoPane.tsx b/frontend/src/components/ContactInfoPane.tsx index 1daea1d..3f145fa 100644 --- a/frontend/src/components/ContactInfoPane.tsx +++ b/frontend/src/components/ContactInfoPane.tsx @@ -1,6 +1,16 @@ import { type ReactNode, useEffect, 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, @@ -100,29 +110,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]); @@ -650,20 +660,18 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu {hasHourlyActivity && (
Messages Per Hour - value.toFixed(value % 1 === 0 ? 0 : 1)} tickFormatter={(bucket) => @@ -683,7 +691,7 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu value.toFixed(0)} tickFormatter={(bucket) => new Date(bucket.bucket_start * 1000).toLocaleDateString([], { @@ -705,133 +713,115 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu ); } -function ChartLegend({ items }: { items: Array<{ label: string; color: string }> }) { - return ( -
- {items.map((item) => ( - - - ))} -
- ); -} +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({ 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 = { 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 ( -
- - {[0, 0.5, 1].map((ratio) => { - const y = padding.top + plotHeight - ratio * plotHeight; - const value = maxValue * ratio; - return ( - - - - {valueFormatter(value)} - - - ); - })} - - {series.map((entry) => ( - + + + + String(data[idx]?.tick ?? '')} /> - ))} - - {tickIndices.map((index) => { - const point = points[index]; - const x = - padding.left + (points.length === 1 ? 0 : (index / (points.length - 1)) * plotWidth); - return ( - - {tickFormatter(point)} - - ); - })} - + valueFormatter(v)} + width={40} + /> + 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 && ( + ( +
+ {legendItems.map((item) => ( + + + {item.label} + + ))} +
+ )} + /> + )} + {series.map((entry) => ( + + ))} + +
); } diff --git a/frontend/src/components/ConversationPane.tsx b/frontend/src/components/ConversationPane.tsx index 809fda3..aa97aeb 100644 --- a/frontend/src/components/ConversationPane.tsx +++ b/frontend/src/components/ConversationPane.tsx @@ -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; onTrace: () => Promise; + onRunTracePath: ( + hopHashBytes: 1 | 2 | 4, + hops: RadioTraceHopRequest[] + ) => Promise; onPathDiscovery: (publicKey: string) => Promise; onToggleFavorite: (type: 'channel' | 'contact', id: string) => Promise; onDeleteContact: (publicKey: string) => Promise; @@ -115,6 +122,7 @@ export function ConversationPane({ loadingNewer, messageInputRef, onTrace, + onRunTracePath, onPathDiscovery, onToggleFavorite, onDeleteContact, @@ -200,6 +208,10 @@ export function ConversationPane({ return null; } + if (activeConversation.type === 'trace') { + return ; + } + if (activeContactIsRepeater) { return ( }> diff --git a/frontend/src/components/CrackerPanel.tsx b/frontend/src/components/CrackerPanel.tsx index 8336fe1..58df592 100644 --- a/frontend/src/components/CrackerPanel.tsx +++ b/frontend/src/components/CrackerPanel.tsx @@ -128,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) diff --git a/frontend/src/components/MapView.tsx b/frontend/src/components/MapView.tsx index 7106594..8166e16 100644 --- a/frontend/src/components/MapView.tsx +++ b/frontend/src/components/MapView.tsx @@ -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]) { diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx index 06f46f8..ea39063 100644 --- a/frontend/src/components/MessageList.tsx +++ b/frontend/src/components/MessageList.tsx @@ -373,7 +373,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); diff --git a/frontend/src/components/PathModal.tsx b/frontend/src/components/PathModal.tsx index aef5ba1..61d30d3 100644 --- a/frontend/src/components/PathModal.tsx +++ b/frontend/src/components/PathModal.tsx @@ -81,13 +81,14 @@ export function PathModal({ ) : hasSinglePath ? ( <> This shows one route 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 {paths.length} different routes. - 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. )} diff --git a/frontend/src/components/RawPacketFeedView.tsx b/frontend/src/components/RawPacketFeedView.tsx index 50f4407..6b04df4 100644 --- a/frontend/src/components/RawPacketFeedView.tsx +++ b/frontend/src/components/RawPacketFeedView.tsx @@ -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 = { '1m': '1 min', '5m': '5 min', @@ -32,13 +54,7 @@ const WINDOW_LABELS: Record = { 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([], { @@ -220,7 +236,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 (
@@ -228,25 +250,36 @@ function RankedBars({ {items.length === 0 ? (

{emptyLabel}

) : ( -
- {items.map((item) => ( -
-
- {item.label} - - {formatter - ? formatter(item) - : `${item.count.toLocaleString()} · ${formatPercent(item.share)}`} - -
-
-
-
-
- ))} +
+ + + + + [props.payload.detail, null]} + /> + + {data.map((_, i) => ( + + ))} + + +
)}
@@ -320,53 +353,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 = { label: bin.label }; + for (const type of typeOrder) { + entry[type] = bin.countsByType[type] ?? 0; + } + return entry; + }); + return (

Traffic Timeline

- {typeOrder.map((type, index) => ( + {typeOrder.map((type, i) => ( - + {type} ))}
- -
- {bins.map((bin, index) => ( -
-
-
- {typeOrder.map((type, index) => { - const count = bin.countsByType[type] ?? 0; - if (count === 0) return null; - return ( -
- ); - })} -
-
-
{bin.label}
-
- ))} +
+ + + + + + + {typeOrder.map((type, i) => ( + + ))} + +
); diff --git a/frontend/src/components/SearchView.tsx b/frontend/src/components/SearchView.tsx index e297a51..7932996 100644 --- a/frontend/src/components/SearchView.tsx +++ b/frontend/src/components/SearchView.tsx @@ -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); }) diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 8a716de..088031b 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -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 { @@ -197,7 +198,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; @@ -721,7 +722,7 @@ export function Sidebar({ renderSidebarActionRow({ key: 'tool-visualizer', active: isActive('visualizer', 'visualizer'), - icon: , + icon: , label: 'Mesh Visualizer', onClick: () => handleSelectConversation({ @@ -730,6 +731,18 @@ export function Sidebar({ name: 'Mesh Visualizer', }), }), + renderSidebarActionRow({ + key: 'tool-trace', + active: isActive('trace', 'trace'), + icon: , + label: 'Trace', + onClick: () => + handleSelectConversation({ + type: 'trace', + id: 'trace', + name: 'Trace', + }), + }), renderSidebarActionRow({ key: 'tool-search', active: isActive('search', 'search'), diff --git a/frontend/src/components/TracePane.tsx b/frontend/src/components/TracePane.tsx new file mode 100644 index 0000000..54c2da1 --- /dev/null +++ b/frontend/src/components/TracePane.tsx @@ -0,0 +1,691 @@ +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; +} + +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 ( +
+
+ {fixed ? 'Self' : 'Hop'} +
+
+
{title}
+
{subtitle}
+ {meta ?
{meta}
: null} + {note ?
{note}
: null} +
+ {snr ? ( +
+
SNR
+
{snr}
+
+ ) : null} + {actions ?
{actions}
: null} +
+ ); +} + +export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps) { + const [searchQuery, setSearchQuery] = useState(''); + const [sortMode, setSortMode] = useState('alpha'); + const [draftHops, setDraftHops] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + const [customDialogOpen, setCustomDialogOpen] = useState(false); + const [customHopBytesDraft, setCustomHopBytesDraft] = useState(1); + const [customHopHexDraft, setCustomHopHexDraft] = useState(''); + const [customHopError, setCustomHopError] = useState(null); + const activeRunTokenRef = useRef(0); + + const repeaters = useMemo(() => { + const deduped = new Map(); + 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 ( +
+
+

Trace

+

+ 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. +

+
+ +
+
+
+

Repeater Hops

+

+ Search by name or key, then add repeaters in the order you want to traverse them. +

+ + setSearchQuery(event.target.value)} + placeholder="Search name or public key" + aria-label="Search repeaters" + className="mt-3" + /> +
+ {( + [ + ['alpha', 'Alpha'], + ['recent', 'Recent Heard'], + ['distance', 'Distance'], + ] as const + ).map(([value, label]) => ( + + ))} +
+ {sortMode === 'distance' && !canSortByDistance ? ( +

+ Distance sorting is using known repeater coordinates, but the local radio does not + currently have a valid location. +

+ ) : null} +
+ +
+ {filteredRepeaters.length === 0 ? ( +
+ No repeaters matched this search. +
+ ) : ( +
+ {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 ( +
0 + ? 'border-primary/30 bg-primary/5' + : 'border-border bg-background hover:bg-accent' + )} + onClick={() => handleAddRepeater(contact.public_key)} + onKeyDown={handleKeyboardActivate} + > + +
+
{displayName}
+
+ {getShortKey(contact.public_key)} +
+ {sortMode === 'distance' && distanceKm !== null ? ( +
+ {distanceKm.toFixed(1)} km away +
+ ) : null} + {selectedCount > 0 ? ( +
+ Added {selectedCount} time{selectedCount === 1 ? '' : 's'} +
+ ) : null} +
+ +
+ ); + })} +
+ )} +
+
+ +
+
+
+

Trace Path

+

+ The first node is display-only. The terminal node is the local radio. +

+
+
+ + {draftHops.length === 0 ? ( +
+ Add at least one hop to build a trace loop. +
+ ) : ( + 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 ( +
+ + + + + + } + /> +
+ ); + }) + )} + +
+
+
+ {draftHops.length === 0 + ? 'No hops selected' + : `${draftHops.length} hop${draftHops.length === 1 ? '' : 's'} selected · ${effectiveHopHashBytes}-byte trace`} +
+ +
+
+ +
+
+

+ Results{result ? ` (${result.timeout_seconds.toFixed(1)}s)` : ''} +

+
+
+ {error ? ( +
+ {error} +
+ ) : null} + {!error && !result ? ( +
+ Send a trace to see the returned hop-by-hop SNR values. +
+ ) : 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 ( +
+ +
+ ); + }) + : null} +
+
+
+
+ + + + + Custom path hop + + 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. + + + +
+
+
Hop width
+
+ {([1, 2, 4] as const).map((value) => { + const locked = customHopBytesLocked !== null && customHopBytesLocked !== value; + const active = (customHopBytesLocked ?? customHopBytesDraft) === value; + return ( + + ); + })} +
+ {customHopBytesLocked !== null ? ( +

+ Custom hops are locked to {customHopBytesLocked}-byte prefixes for this trace. +

+ ) : null} +
+ +
+ + + setCustomHopHexDraft(normalizeCustomHopHex(event.target.value)) + } + placeholder={`${(customHopBytesLocked ?? customHopBytesDraft) * 2} hex chars`} + /> +

+ Enter exactly {(customHopBytesLocked ?? customHopBytesDraft) * 2} hex characters. +

+ {customHopError ? ( +
+ {customHopError} +
+ ) : null} +
+
+ + + + + +
+
+
+ ); +} diff --git a/frontend/src/components/settings/SettingsLocalSection.tsx b/frontend/src/components/settings/SettingsLocalSection.tsx index 5b08952..9c886dd 100644 --- a/frontend/src/components/settings/SettingsLocalSection.tsx +++ b/frontend/src/components/settings/SettingsLocalSection.tsx @@ -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({ +
+ +
+ 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" + /> +
+ { + 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" + /> + % +
+ +
+

+ Scales the app's typography for this browser only. The slider moves in 5% steps; the + number field accepts any value from 25% to 400%. +

+
+ + +