Compare commits

..

35 Commits

Author SHA1 Message Date
Jack Kingsman 8843836bdf Use nginx docke 2026-03-31 21:37:44 -07:00
Jack Kingsman e631f9b0cc DHCP notes 2026-03-31 21:33:50 -07:00
Jack Kingsman b52431616e Be more sane with by-id aliases 2026-03-31 20:47:10 -07:00
Jack Kingsman 8446d99df1 Be more resistent for colons in the device ID 2026-03-31 20:23:07 -07:00
Jack Kingsman 8e1e913fcd Make all scripts executable 2026-03-31 18:26:04 -07:00
Jack Kingsman b74137dc72 Add trace clear and much better layout. Closes #139. 2026-03-31 16:55:21 -07:00
Jack Kingsman c83f9b0005 Rename Best RSSI to Strongest Neighbor. Closes #136. 2026-03-31 13:10:02 -07:00
Jack Kingsman 9f4737d350 Add hashtag link detection. Closes #134. 2026-03-31 12:55:52 -07:00
Jack Kingsman 29e9a5f701 Be more resilient in noise floor gathering 2026-03-31 12:35:35 -07:00
Jack Kingsman f0f06671cc Make new message button clearer 2026-03-31 12:28:47 -07:00
Jack Kingsman b1595e479c Use the image's full government name 2026-03-30 23:11:37 -07:00
Jack Kingsman 25df69bfbc Add snakeoil certs to docker setup 2026-03-30 22:13:09 -07:00
Jack Kingsman 88140081b9 Updating changelog + build for 3.6.3 2026-03-30 21:54:32 -07:00
Jack Kingsman 4326f57977 Lint fixes 2026-03-30 21:44:26 -07:00
Jack Kingsman 43abcd07b2 Improve DB streaming perf for cracking and statistics 2026-03-30 21:31:59 -07:00
Jack Kingsman 5c60559cb8 Fix memoization on cracker panel 2026-03-30 21:31:47 -07:00
Jack Kingsman 3c0d6a4466 Fix some misc. frontend correctness bugs 2026-03-30 21:29:01 -07:00
Jack Kingsman 7b9d8f6a23 Docs updates 2026-03-30 21:24:36 -07:00
Jack Kingsman 44d6fcac24 Add missing abort controller 2026-03-30 21:15:42 -07:00
Jack Kingsman 788d1cbdca Fix non-repeater traffic during repeater ops dropping messages 2026-03-30 21:13:25 -07:00
Jack Kingsman 26e8150092 Shorten quality script 2026-03-30 21:02:49 -07:00
Jack Kingsman 3a1c2d691b Misc. bug bash 2026-03-30 20:49:09 -07:00
Jack Kingsman 134e8d0d29 Add trace tool. Closes #130. 2026-03-30 19:26:12 -07:00
Jack Kingsman eb1f7ae638 Be more resilient about docker script input management 2026-03-30 17:54:07 -07:00
Jack Kingsman 14ba342160 Add docker install script 2026-03-30 17:09:25 -07:00
Jack Kingsman 7460c3ea9d Add font size slider. Closes #132. 2026-03-30 16:47:24 -07:00
Jack Kingsman 6534946bc7 Simplify installation instructions 2026-03-30 16:26:25 -07:00
Jack Kingsman 4847813ae1 Fix up the slow core query from the stats page. Closes #131. 2026-03-30 15:59:44 -07:00
Jack Kingsman 3f6efaae1d Overhaul script handling. Closes #125. 2026-03-30 15:51:43 -07:00
Jack Kingsman 60f3fa8e36 Add noise floor visualizer to statistics. Closes #129. 2026-03-30 15:31:39 -07:00
Jack Kingsman b42ca44ba7 Add noise floor plumbing 2026-03-30 14:23:01 -07:00
Jack Kingsman d4bbb8a542 Add multibyte trace output. Closes #127. 2026-03-30 12:52:01 -07:00
Jack Kingsman db248302e9 Show node name if we find it in the DB already. Closes #128. 2026-03-30 12:28:26 -07:00
Jack Kingsman 7aa4f76064 Fix clamping on value inputs to allow empty while focused 2026-03-29 22:38:06 -07:00
Jack Kingsman f01e91defc Clean up after release 2026-03-29 22:37:38 -07:00
100 changed files with 5502 additions and 846 deletions
+8
View File
@@ -2,6 +2,8 @@
__pycache__/
*.py[oc]
build/
!scripts/build/
!scripts/build/**
wheels/
*.egg-info
@@ -23,3 +25,9 @@ references/
# ancillary LLM files
.claude/
# local Docker compose files
docker-compose.yml
docker-compose.yaml
.docker-certs/
.docker-nginx/
+36 -23
View File
@@ -7,7 +7,7 @@
If instructed to "run all tests" or "get ready for a commit" or other summative, work ending directives, run:
```bash
./scripts/all_quality.sh
./scripts/quality/all_quality.sh
```
This is the repo's end-to-end quality gate. It runs backend/frontend autofixers first, then type checking, tests, and the standard frontend build. All checks must pass green, and the script may leave formatting/lint edits behind.
@@ -210,10 +210,16 @@ This message-layer echo/path handling is independent of raw-packet storage dedup
│ │ └── ...
│ └── vite.config.ts
├── scripts/ # Quality / release helpers (listing below is representative, not exhaustive)
│ ├── all_quality.sh # Repo-standard autofix + validate gate
│ ├── collect_licenses.sh # Gather third-party license attributions
├── e2e.sh # End-to-end test runner
── publish.sh # Version bump, changelog, docker build & push
│ ├── build/
│ ├── collect_licenses.sh # Gather third-party license attributions
│ └── publish.sh # Version bump, changelog, docker build & push
── quality/
│ │ ├── all_quality.sh # Repo-standard autofix + validate gate
│ │ ├── e2e.sh # End-to-end test runner
│ │ └── extended_quality.sh # Quality gate plus e2e and Docker matrix
│ └── setup/
│ ├── fetch_prebuilt_frontend.py # Download release frontend fallback
│ └── install_service.sh # Install/configure Linux systemd service
├── README_ADVANCED.md # Advanced setup, troubleshooting, and service guidance
├── CONTRIBUTING.md # Contributor workflow and testing guidance
├── tests/ # Backend tests (pytest)
@@ -271,23 +277,23 @@ PYTHONPATH=. uv run pytest tests/ -v
```
Key test files:
- `tests/test_decoder.py` - Channel + direct message decryption, key exchange
- `tests/test_keystore.py` - Ephemeral key store
- `tests/test_event_handlers.py` - ACK tracking, repeat detection
- `tests/test_packet_pipeline.py` - End-to-end packet processing
- `tests/test_api.py` - API endpoints, read state tracking
- `tests/test_migrations.py` - Database migration system
- `tests/test_frontend_static.py` - Frontend static route registration (missing `dist`/`index.html` handling)
- `tests/test_messages_search.py` - Message search, around endpoint, forward pagination
- `tests/test_rx_log_data.py` - on_rx_log_data event handler integration
- `tests/test_ack_tracking_wiring.py` - DM ACK tracking extraction and wiring
- `tests/test_radio_lifecycle_service.py` - Radio reconnect/setup orchestration helpers
- `tests/test_radio_commands_service.py` - Radio config/private-key service workflows
- `tests/test_health_mqtt_status.py` - Health endpoint MQTT status field
- `tests/test_community_mqtt.py` - Community MQTT publisher (JWT, packet format, hash, broadcast)
- `tests/test_radio_sync.py` - Radio sync, periodic tasks, and contact offload back to the radio
- `tests/test_real_crypto.py` - Real cryptographic operations
- `tests/test_disable_bots.py` - MESHCORE_DISABLE_BOTS=true feature
- `tests/test_api.py` - Broad API integration coverage across routers and read-state flows
- `tests/test_packet_pipeline.py` - End-to-end packet processing, decrypt, dedup, and message creation
- `tests/test_event_handlers.py` - ACK tracking, fallback DM handling, and event subscription cleanup
- `tests/test_send_messages.py` - Outgoing DM/channel send workflows, retries, and bot-trigger wiring
- `tests/test_packets_router.py` - Historical decrypt, maintenance, and raw-packet detail endpoints
- `tests/test_repeater_routes.py` - Repeater command/telemetry/trace pane endpoints
- `tests/test_room_routes.py` - Room-server login/status/ACL/telemetry endpoints
- `tests/test_radio_router.py` - Radio config, advert, discovery, trace, and reconnect endpoints
- `tests/test_radio_sync.py` - Radio sync, periodic tasks, contact offload/reload, and pending-message flushes
- `tests/test_fanout.py` - Fanout config CRUD, scope matching, and manager dispatch
- `tests/test_fanout_integration.py` - Integration-module lifecycle and delivery behavior
- `tests/test_statistics.py` - Aggregated mesh/network statistics and noise-floor snapshots
- `tests/test_version_info.py` - Version/build metadata resolution
- `tests/test_websocket.py` - WS manager broadcast and cleanup behavior
- `tests/test_frontend_static.py` - Frontend static route registration and fallback behavior
For the fuller backend inventory, see `app/AGENTS.md`. For frontend-specific suites, see `frontend/AGENTS.md`.
### Frontend (Vitest)
@@ -298,7 +304,7 @@ npm run test:run
### Before Completing Major Changes
**Run `./scripts/all_quality.sh` before finishing major changes that have modified code or tests.** It is the standard repo gate: autofix first, then type checks, tests, and the standard frontend build. This is not necessary for docs-only changes. For minor changes (like wording, color, spacing, etc.), wait until prompted to run the quality gate.
**Run `./scripts/quality/all_quality.sh` before finishing major changes that have modified code or tests.** It is the standard repo gate: autofix first, then type checks, tests, and the standard frontend build. This is not necessary for docs-only changes. For minor changes (like wording, color, spacing, etc.), wait until prompted to run the quality gate.
## API Summary
@@ -313,6 +319,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| PUT | `/api/radio/private-key` | Import private key to radio |
| POST | `/api/radio/advertise` | Send advertisement (`mode`: `flood` or `zero_hop`, default `flood`) |
| POST | `/api/radio/discover` | Run a short mesh discovery sweep for nearby repeaters/sensors |
| POST | `/api/radio/trace` | Send a multi-hop trace loop through known repeaters and back to the local radio |
| POST | `/api/radio/reboot` | Reboot radio or reconnect if disconnected |
| POST | `/api/radio/disconnect` | Disconnect from radio and pause automatic reconnect attempts |
| POST | `/api/radio/reconnect` | Manual radio reconnection |
@@ -335,6 +342,10 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| POST | `/api/contacts/{public_key}/repeater/radio-settings` | Fetch repeater radio config via CLI |
| POST | `/api/contacts/{public_key}/repeater/advert-intervals` | Fetch advert intervals |
| POST | `/api/contacts/{public_key}/repeater/owner-info` | Fetch owner info |
| POST | `/api/contacts/{public_key}/room/login` | Log in to a room server |
| POST | `/api/contacts/{public_key}/room/status` | Fetch room-server status telemetry |
| POST | `/api/contacts/{public_key}/room/lpp-telemetry` | Fetch room-server CayenneLPP sensor data |
| POST | `/api/contacts/{public_key}/room/acl` | Fetch room-server ACL entries |
| GET | `/api/channels` | List channels |
| GET | `/api/channels/{key}/detail` | Comprehensive channel profile (message stats, top senders) |
@@ -348,6 +359,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| POST | `/api/messages/channel` | Send channel message |
| POST | `/api/messages/channel/{message_id}/resend` | Resend channel message (default: byte-perfect within 30s; `?new_timestamp=true`: fresh timestamp, no time limit, creates new message row) |
| GET | `/api/packets/undecrypted/count` | Count of undecrypted packets |
| GET | `/api/packets/{packet_id}` | Fetch one stored raw packet by row ID for on-demand inspection |
| POST | `/api/packets/decrypt/historical` | Decrypt stored packets |
| POST | `/api/packets/maintenance` | Delete old packets and vacuum |
| GET | `/api/read-state/unreads` | Server-computed unread counts, mentions, last message times, and `last_read_ats` boundaries |
@@ -362,6 +374,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| POST | `/api/fanout` | Create new fanout config |
| PATCH | `/api/fanout/{id}` | Update fanout config (triggers module reload) |
| DELETE | `/api/fanout/{id}` | Delete fanout config (stops module) |
| POST | `/api/fanout/bots/disable-until-restart` | Stop bot fanout modules and keep bots disabled until the process restarts |
| GET | `/api/statistics` | Aggregated mesh network statistics |
| WS | `/api/ws` | Real-time updates |
+11
View File
@@ -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
+2 -1
View File
@@ -48,7 +48,7 @@ Run both the backend and `npm run dev` for hot-reloading frontend development.
Run the full quality suite before proposing or handing off code changes:
```bash
./scripts/all_quality.sh
./scripts/quality/all_quality.sh
```
That runs linting, formatting, type checking, tests, and builds for both backend and frontend.
@@ -78,6 +78,7 @@ These tests are only guaranteed to run correctly in a narrow subset of environme
```bash
cd tests/e2e
npm install
npx playwright test # headless
npx playwright test --headed # you can probably guess
```
+32 -1
View File
@@ -1,6 +1,6 @@
# Third-Party Licenses
Auto-generated by `scripts/collect_licenses.sh` — do not edit by hand.
Auto-generated by `scripts/build/collect_licenses.sh` — do not edit by hand.
## Backend (Python) Dependencies
@@ -1625,6 +1625,37 @@ THE SOFTWARE.
</details>
### recharts (3.8.1) — MIT
<details>
<summary>Full license text</summary>
```
The MIT License (MIT)
Copyright (c) 2015-present recharts
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
</details>
### sonner (2.0.7) — MIT
<details>
+49 -42
View File
@@ -16,12 +16,6 @@ Backend server + browser interface for MeshCore mesh radio networks. Connect you
![Screenshot of the application's web interface](app_screenshot.png)
## Disclaimer
This is developed with very heavy agentic assistance -- there is no warranty of fitness for any purpose. It's been lovingly guided by an engineer with a passion for clean code and good tests, but it's still mostly LLM output, so you may find some bugs.
If extending, have your LLM read the three `AGENTS.md` files: `./AGENTS.md`, `./frontend/AGENTS.md`, and `./app/AGENTS.md`.
## Start Here
Most users should choose one of these paths:
@@ -95,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,52 +107,65 @@ 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:
For serial-device passthrough, use rootful Docker. In practice that usually means starting the stack with `sudo docker compose ...` unless your Docker daemon is already configured for rootful access via your user/group. Rootless Docker has been observed to fail on serial-device mappings even when the compose file itself is correct.
Create a local `docker-compose.yml` in one of two ways:
1. Copy the example file and edit it by hand:
```bash
docker compose up -d
cp docker-compose.example.yml docker-compose.yml
```
The database is stored in `./data/` (bind-mounted), so the container shares the same database as the native app. To rebuild after pulling updates:
2. Or generate one interactively:
```bash
docker compose up -d --build
bash scripts/setup/install_docker.sh
```
To use the prebuilt Docker Hub image instead of building locally, replace:
Your local `docker-compose.yml` is gitignored so future pulls do not overwrite your Docker settings.
The guided Docker flow can collect BLE settings, but BLE access from Docker still needs manual compose customization such as Bluetooth passthrough and possibly privileged mode or host networking. If you want the simpler path for BLE, use the regular Python launch flow instead.
Then customize the local compose file for your transport and launch:
```bash
sudo docker compose up # add -d for background once you validate it's working
```
The database is stored in `./data/` (bind-mounted), so the container shares the same database as the native app.
To rebuild after pulling updates:
```bash
sudo docker compose pull
sudo docker compose up -d
```
The example file and setup script default to the published Docker Hub image. To build locally from your checkout instead, replace:
```yaml
build: .
image: docker.io/jkingsman/remoteterm-meshcore:latest
```
with:
```yaml
image: jkingsman/remoteterm-meshcore:latest
build: .
```
Then run:
```bash
docker compose pull
docker compose up -d
sudo docker compose up -d --build
```
Published Docker tags are intended to be multi-arch (`linux/amd64` and `linux/arm64`). If you are building and publishing manually, use Docker Buildx:
```bash
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t jkingsman/remoteterm-meshcore:latest \
--push .
```
The container runs as root by default for maximum serial passthrough compatibility across host setups. On Linux, if you switch between native and Docker runs, `./data` can end up root-owned. If you do not need that serial compatibility behavior, you can enable the optional `user: "${UID:-1000}:${GID:-1000}"` line in `docker-compose.yaml` to keep ownership aligned with your host user.
The container runs as root by default for maximum serial passthrough compatibility across host setups. On Linux, if you switch between native and Docker runs, `./data` can end up root-owned. If you do not need that serial compatibility behavior, you can enable the optional `user: "${UID:-1000}:${GID:-1000}"` line in `docker-compose.yml` to keep ownership aligned with your host user.
To stop:
```bash
docker compose down
sudo docker compose down
```
## Standard Environment Variables
@@ -212,3 +213,9 @@ If you enable Basic Auth, protect the app with HTTPS. HTTP Basic credentials are
- Advanced setup, troubleshooting, HTTPS, systemd, remediation variables, and debug logging: [README_ADVANCED.md](README_ADVANCED.md)
- Contributing, tests, linting, E2E notes, and important AGENTS files: [CONTRIBUTING.md](CONTRIBUTING.md)
- Live API docs after the backend is running: http://localhost:8000/docs
## Disclaimer
This is developed with very heavy agentic assistance -- there is no warranty of fitness for any purpose. It's been lovingly guided by an engineer with a passion for clean code and good tests, but it's still mostly LLM output, so you may find some bugs.
If extending, have your LLM read the three `AGENTS.md` files: `./AGENTS.md`, `./frontend/AGENTS.md`, and `./app/AGENTS.md`.
+1 -26
View File
@@ -46,39 +46,14 @@ Accept the browser warning, or use [mkcert](https://github.com/FiloSottile/mkcer
## Systemd Service
Two paths are available depending on your comfort level with Linux system administration.
### Simple install (recommended for most users)
On Linux systems, this is the recommended installation method if you want RemoteTerm set up as a persistent systemd service that starts automatically on boot and restarts automatically if it crashes. Run the installer script from the repo root. It runs as your current user, installs from wherever you cloned the repo, and prints a quick-reference cheatsheet when done — no separate service account or path juggling required.
```bash
bash scripts/install_service.sh
bash scripts/setup/install_service.sh
```
The script interactively asks which transport to use (serial auto-detect, serial with explicit port, TCP, or BLE), whether to build the frontend locally or download a prebuilt copy, whether to enable the bot system, and whether to set up HTTP Basic Auth. It handles dependency installation (`uv sync`), validates `node`/`npm` for local builds, adds your user to the `dialout` group if needed, writes the systemd unit file, and enables the service. After installation, normal operations work without any `sudo -u` gymnastics:
You can also rerun the script later to change transport, bot, or auth settings. If the service is already running, the installer stops it, rewrites the unit file, reloads systemd, and starts it again with the new configuration.
```bash
# Update to latest and restart
cd /path/to/repo
git pull
uv sync
cd frontend && npm install && npm run build && cd ..
sudo systemctl restart remoteterm
# Refresh prebuilt frontend only (skips local build)
python3 scripts/fetch_prebuilt_frontend.py
sudo systemctl restart remoteterm
# View live logs
sudo journalctl -u remoteterm -f
# Service control
sudo systemctl start|stop|restart|status remoteterm
```
## Debug Logging And Bug Reports
If you're experiencing issues or opening a bug report, please start the backend with debug logging enabled. Debug mode provides a much more detailed breakdown of radio communication, packet processing, and other internal operations, which makes it significantly easier to diagnose problems.
+26 -10
View File
@@ -25,18 +25,22 @@ Keep it aligned with `app/` source files and router behavior.
app/
├── main.py # App startup/lifespan, router registration, static frontend mounting
├── config.py # Env-driven runtime settings
├── channel_constants.py # Public/default channel constants shared across sync/send logic
├── database.py # SQLite connection + base schema + migration runner
├── migrations.py # Schema migrations (SQLite user_version)
├── models.py # Pydantic request/response models and typed write contracts (for example ContactUpsert)
├── version_info.py # Unified version/build metadata resolution for debug + startup surfaces
├── repository/ # Data access layer (contacts, channels, messages, raw_packets, settings, fanout)
├── services/ # Shared orchestration/domain services
│ ├── messages.py # Shared message creation, dedup, ACK application
│ ├── message_send.py # Direct send, channel send, resend workflows
│ ├── dm_ingest.py # Shared direct-message ingest / dedup seam for packet + fallback paths
│ ├── dm_ack_apply.py # Shared DM ACK application over pending/buffered ACK state
│ ├── dm_ack_tracker.py # Pending DM ACK state
│ ├── contact_reconciliation.py # Prefix-claim, sender-key backfill, name-history wiring
│ ├── radio_lifecycle.py # Post-connect setup and reconnect/setup helpers
│ ├── radio_commands.py # Radio config/private-key command workflows
│ ├── radio_noise_floor.py # In-memory local radio noise-floor sampling/history
│ └── radio_runtime.py # Router/dependency seam over the global RadioManager
├── radio.py # RadioManager transport/session state + lock management
├── radio_sync.py # Polling, sync, periodic advertisement loop
@@ -61,6 +65,8 @@ app/
├── messages.py
├── packets.py
├── read_state.py
├── rooms.py
├── server_control.py
├── settings.py
├── fanout.py
├── repeaters.py
@@ -174,6 +180,7 @@ app/
- `PUT /radio/private-key`
- `POST /radio/advertise` — manual advert send; request body may set `mode` to `flood` or `zero_hop` (defaults to `flood`)
- `POST /radio/discover` — short mesh discovery sweep for nearby repeaters/sensors
- `POST /radio/trace` — send a multi-hop trace loop through known repeaters and back to the local radio
- `POST /radio/disconnect`
- `POST /radio/reboot`
- `POST /radio/reconnect`
@@ -198,6 +205,10 @@ app/
- `POST /contacts/{public_key}/repeater/radio-settings`
- `POST /contacts/{public_key}/repeater/advert-intervals`
- `POST /contacts/{public_key}/repeater/owner-info`
- `POST /contacts/{public_key}/room/login`
- `POST /contacts/{public_key}/room/status`
- `POST /contacts/{public_key}/room/lpp-telemetry`
- `POST /contacts/{public_key}/room/acl`
### Channels
- `GET /channels`
@@ -216,6 +227,7 @@ app/
### Packets
- `GET /packets/undecrypted/count`
- `GET /packets/{packet_id}` — fetch one stored raw packet by row ID for on-demand inspection
- `POST /packets/decrypt/historical`
- `POST /packets/maintenance`
@@ -236,6 +248,7 @@ app/
- `POST /fanout` — create new fanout config
- `PATCH /fanout/{id}` — update fanout config (triggers module reload)
- `DELETE /fanout/{id}` — delete fanout config (stops module)
- `POST /fanout/bots/disable-until-restart` — stop bot modules and keep bots disabled until restart
### Statistics
- `GET /statistics` — aggregated mesh network stats (entity counts, message/packet splits, activity windows, busiest channels)
@@ -322,9 +335,11 @@ tests/
├── conftest.py # Shared fixtures
├── test_ack_tracking_wiring.py # DM ACK tracking extraction and wiring
├── test_api.py # REST endpoint integration tests
├── test_block_lists.py # Blocked keys/names filtering across list/search surfaces
├── test_bot.py # Bot execution and sandboxing
├── test_channels_router.py # Channels router endpoints
├── test_channel_sender_backfill.py # Sender-key backfill uniqueness rules for channel messages
├── test_channels_router.py # Channels router endpoints
├── test_community_mqtt.py # Community MQTT publisher (JWT, packet format, hash, broadcast)
├── test_config.py # Configuration validation
├── test_contact_reconciliation_service.py # Prefix/contact reconciliation service helpers
├── test_contacts_router.py # Contacts router endpoints
@@ -332,40 +347,41 @@ tests/
├── test_disable_bots.py # MESHCORE_DISABLE_BOTS=true feature
├── test_echo_dedup.py # Echo/repeat deduplication (incl. concurrent)
├── test_fanout.py # Fanout bus CRUD, scope matching, manager dispatch
├── test_fanout_integration.py # Fanout integration tests
├── test_fanout_hitlist.py # Fanout-related hitlist regression tests
├── test_fanout_integration.py # Fanout integration tests
├── test_event_handlers.py # ACK tracking, event registration, cleanup
├── test_frontend_static.py # Frontend static file serving
├── test_health_mqtt_status.py # Health endpoint MQTT status field
├── test_http_quality.py # Cache-control / gzip / basic-auth HTTP quality checks
├── test_key_normalization.py # Public key normalization
├── test_keystore.py # Ephemeral keystore
├── test_main_startup.py # App startup and lifespan
├── test_map_upload.py # Map upload fanout module
├── test_message_pagination.py # Cursor-based message pagination
├── test_message_prefix_claim.py # Message prefix claim logic
├── test_migrations.py # Schema migration system
├── test_community_mqtt.py # Community MQTT publisher (JWT, packet format, hash, broadcast)
├── test_mqtt.py # MQTT publisher topic routing and lifecycle
├── test_messages_search.py # Message search, around, forward pagination
├── test_migrations.py # Schema migration system
├── test_packet_pipeline.py # End-to-end packet processing
├── test_packets_router.py # Packets router endpoints (decrypt, maintenance)
├── test_path_utils.py # Path hex rendering helpers
├── test_radio.py # RadioManager, serial detection
├── test_radio_commands_service.py # Radio config/private-key service workflows
├── test_radio_lifecycle_service.py # Reconnect/setup orchestration helpers
├── test_radio_runtime_service.py # radio_runtime seam behavior and helpers
├── test_real_crypto.py # Real cryptographic operations
├── test_radio_operation.py # radio_operation() context manager
├── test_radio_router.py # Radio router endpoints
├── test_radio_runtime_service.py # radio_runtime seam behavior and helpers
├── test_radio_sync.py # Polling, sync, advertisement
├── test_real_crypto.py # Real cryptographic operations
├── test_repeater_routes.py # Repeater command/telemetry/trace + granular pane endpoints
├── test_repository.py # Data access layer
├── test_room_routes.py # Room-server login/status/telemetry/ACL endpoints
├── test_rx_log_data.py # on_rx_log_data event handler integration
├── test_messages_search.py # Message search, around, forward pagination
├── test_block_lists.py # Blocked keys/names filtering
├── test_security.py # Optional Basic Auth middleware / config behavior
├── test_send_messages.py # Outgoing messages, bot triggers, concurrent sends
├── test_settings_router.py # Settings endpoints, advert validation
├── test_statistics.py # Statistics aggregation
├── test_main_startup.py # App startup and lifespan
├── test_path_utils.py # Path hex rendering helpers
├── test_version_info.py # Version/build metadata resolution
├── test_websocket.py # WS manager broadcast/cleanup
└── test_websocket_route.py # WS endpoint lifecycle
```
+4
View File
@@ -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
-2
View File
@@ -30,8 +30,6 @@ logger = logging.getLogger(__name__)
# Track active subscriptions so we can unsubscribe before re-registering
# This prevents handler duplication after reconnects
_active_subscriptions: list["Subscription"] = []
_pending_acks = dm_ack_tracker._pending_acks
_buffered_acks = dm_ack_tracker._buffered_acks
def track_pending_ack(expected_ack: str, message_id: int, timeout_ms: int) -> bool:
+3
View File
@@ -39,6 +39,7 @@ from app.routers import (
ws,
)
from app.security import add_optional_basic_auth_middleware
from app.services.radio_noise_floor import start_noise_floor_sampling, stop_noise_floor_sampling
from app.services.radio_runtime import radio_runtime as radio_manager
from app.version_info import get_app_build_info
@@ -70,6 +71,7 @@ async def lifespan(app: FastAPI):
from app.radio_sync import ensure_default_channels
await ensure_default_channels()
await start_noise_floor_sampling()
# Always start connection monitor (even if initial connection failed)
await radio_manager.start_connection_monitor()
@@ -98,6 +100,7 @@ async def lifespan(app: FastAPI):
await radio_manager.stop_connection_monitor()
await stop_background_contact_reconciliation()
await stop_message_polling()
await stop_noise_floor_sampling()
await stop_periodic_advert()
await stop_periodic_sync()
if radio_manager.meshcore:
+41
View File
@@ -360,6 +360,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 46)
applied += 1
# Migration 47: Add statistics indexes for time-windowed scans
if version < 47:
logger.info("Applying migration 47: add statistics indexes")
await _migrate_047_add_statistics_indexes(conn)
await set_version(conn, 47)
applied += 1
if applied > 0:
logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
@@ -2868,3 +2875,37 @@ async def _migrate_046_cleanup_orphaned_contact_child_rows(conn: aiosqlite.Conne
)
await conn.commit()
async def _migrate_047_add_statistics_indexes(conn: aiosqlite.Connection) -> None:
"""Add indexes used by the statistics endpoint's time-windowed scans."""
cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables = {row[0] for row in await cursor.fetchall()}
if "raw_packets" in tables:
cursor = await conn.execute("PRAGMA table_info(raw_packets)")
raw_packet_columns = {row[1] for row in await cursor.fetchall()}
if "timestamp" in raw_packet_columns:
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_raw_packets_timestamp ON raw_packets(timestamp)"
)
if "contacts" in tables:
cursor = await conn.execute("PRAGMA table_info(contacts)")
contact_columns = {row[1] for row in await cursor.fetchall()}
if {"type", "last_seen"}.issubset(contact_columns):
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_contacts_type_last_seen ON contacts(type, last_seen)"
)
if "messages" in tables:
cursor = await conn.execute("PRAGMA table_info(messages)")
message_columns = {row[1] for row in await cursor.fetchall()}
if {"type", "received_at", "conversation_key"}.issubset(message_columns):
await conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_messages_type_received_conversation
ON messages(type, received_at, conversation_key)
"""
)
await conn.commit()
+79
View File
@@ -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(
@@ -820,6 +877,27 @@ class PathHashWidthStats(BaseModel):
triple_byte_pct: float
class NoiseFloorSample(BaseModel):
timestamp: int = Field(description="Unix timestamp of the sampled reading")
noise_floor_dbm: int = Field(description="Noise floor in dBm")
class NoiseFloorHistoryStats(BaseModel):
sample_interval_seconds: int = Field(description="Expected spacing between samples")
coverage_seconds: int = Field(description="How much of the last 24 hours is represented")
latest_noise_floor_dbm: int | None = Field(
default=None, description="Most recent sampled noise floor in dBm"
)
latest_timestamp: int | None = Field(
default=None, description="Unix timestamp of the most recent sample"
)
supported: bool | None = Field(
default=None,
description="Whether the connected radio appears to support radio stats sampling",
)
samples: list[NoiseFloorSample] = Field(default_factory=list)
class StatisticsResponse(BaseModel):
busiest_channels_24h: list[BusyChannel]
contact_count: int
@@ -835,3 +913,4 @@ class StatisticsResponse(BaseModel):
repeaters_heard: ContactActivityCounts
known_channels_active: ContactActivityCounts
path_hash_width_24h: PathHashWidthStats
noise_floor_24h: NoiseFloorHistoryStats
+16 -11
View File
@@ -122,20 +122,20 @@ async def run_historical_dm_decryption(
"""Background task to decrypt historical DM packets with contact's key."""
from app.websocket import broadcast_success
packets = await RawPacketRepository.get_undecrypted_text_messages()
total = len(packets)
total = 0
decrypted_count = 0
if total == 0:
logger.info("No undecrypted TEXT_MESSAGE packets to process")
return
logger.info("Starting historical DM decryption of %d TEXT_MESSAGE packets", total)
logger.info("Starting historical DM decryption scan for undecrypted TEXT_MESSAGE packets")
# Derive our public key from the private key
our_public_key_bytes = derive_public_key(private_key_bytes)
for packet_id, packet_data, packet_timestamp in packets:
async for (
packet_id,
packet_data,
packet_timestamp,
) in RawPacketRepository.stream_undecrypted_text_messages():
total += 1
# Note: passing our_public_key=None disables the outbound hash check in
# try_decrypt_dm (only the inbound check src_hash == their_first_byte runs).
# For the 255/256 case where our first byte differs from the contact's,
@@ -187,6 +187,10 @@ async def run_historical_dm_decryption(
if msg_id is not None:
decrypted_count += 1
if total == 0:
logger.info("No undecrypted TEXT_MESSAGE packets to process")
return
logger.info(
"Historical DM decryption complete: %d/%d packets decrypted",
decrypted_count,
@@ -264,9 +268,10 @@ async def process_raw_packet(
This is the main entry point for all incoming RF packets.
Note: Packets are deduplicated by payload hash in the database. If we receive
a duplicate packet (same payload, different path), we still broadcast it to
the frontend (for the real-time packet feed) but skip decryption processing
since the original packet was already processed.
a duplicate payload (same payload, different path), we still broadcast it to
the frontend for realtime packet-feed fidelity. Some payload types are also
intentionally reprocessed on duplicate arrival so message-level dedup/path
merge logic and advert/path-history tracking still see each observation.
"""
ts = timestamp or int(time.time())
observation_id = next(_raw_observation_counter)
+18 -2
View File
@@ -20,7 +20,7 @@ from meshcore import EventType, MeshCore
from app.channel_constants import PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME
from app.config import settings
from app.event_handlers import cleanup_expired_acks
from app.event_handlers import cleanup_expired_acks, on_contact_message
from app.models import Contact, ContactUpsert
from app.radio import RadioOperationBusyError
from app.repository import (
@@ -379,6 +379,14 @@ async def _resolve_channel_for_pending_message(
return cached_key, channel.name if channel else None
async def _store_pending_direct_message(event) -> None:
"""Route a CONTACT_MSG_RECV event pulled via get_msg() through the DM ingest path."""
try:
await on_contact_message(event)
except Exception:
logger.warning("Failed to store pending direct message", exc_info=True)
async def _store_pending_channel_message(mc: MeshCore, payload: dict) -> None:
"""Persist a CHANNEL_MSG_RECV event pulled via get_msg()."""
channel_idx = payload.get("channel_idx")
@@ -403,7 +411,8 @@ async def _store_pending_channel_message(mc: MeshCore, payload: dict) -> None:
return
received_at = int(time.time())
sender_timestamp = payload.get("sender_timestamp") or received_at
ts = payload.get("sender_timestamp")
sender_timestamp = ts if ts is not None else received_at
sender_name, message_text = _split_channel_sender_and_text(payload.get("text", ""))
await create_fallback_channel_message(
@@ -488,6 +497,8 @@ async def drain_pending_messages(mc: MeshCore) -> int:
elif result.type in (EventType.CONTACT_MSG_RECV, EventType.CHANNEL_MSG_RECV):
if result.type == EventType.CHANNEL_MSG_RECV:
await _store_pending_channel_message(mc, result.payload)
elif result.type == EventType.CONTACT_MSG_RECV:
await _store_pending_direct_message(result)
count += 1
# Small delay between fetches
@@ -525,6 +536,8 @@ async def poll_for_messages(mc: MeshCore) -> int:
elif result.type in (EventType.CONTACT_MSG_RECV, EventType.CHANNEL_MSG_RECV):
if result.type == EventType.CHANNEL_MSG_RECV:
await _store_pending_channel_message(mc, result.payload)
elif result.type == EventType.CONTACT_MSG_RECV:
await _store_pending_direct_message(result)
count += 1
# If we got a message, there might be more - drain them
count += await drain_pending_messages(mc)
@@ -1018,6 +1031,7 @@ _last_contact_sync: float = 0.0
CONTACT_SYNC_THROTTLE_SECONDS = 30 # Don't sync more than once per 30 seconds
CONTACT_RECONCILE_BATCH_SIZE = 2
CONTACT_RECONCILE_YIELD_SECONDS = 0.05
CONTACT_RECONCILE_BUSY_BACKOFF_SECONDS = 2.0
def _evict_removed_contact_from_library_cache(mc: MeshCore, public_key: str) -> None:
@@ -1227,6 +1241,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:
+38 -14
View File
@@ -1,6 +1,7 @@
import logging
import sqlite3
import time
from collections.abc import AsyncIterator
from hashlib import sha256
from app.database import db
@@ -8,6 +9,8 @@ from app.decoder import PayloadType, extract_payload, get_packet_payload_type
logger = logging.getLogger(__name__)
UNDECRYPTED_PACKET_BATCH_SIZE = 500
class RawPacketRepository:
@staticmethod
@@ -100,6 +103,40 @@ class RawPacketRepository:
rows = await cursor.fetchall()
return [(row["id"], bytes(row["data"]), row["timestamp"]) for row in rows]
@staticmethod
async def stream_undecrypted_text_messages(
batch_size: int = UNDECRYPTED_PACKET_BATCH_SIZE,
) -> AsyncIterator[tuple[int, bytes, int]]:
"""Yield undecrypted TEXT_MESSAGE packets in bounded-size batches."""
cursor = await db.conn.execute(
"SELECT id, data, timestamp FROM raw_packets WHERE message_id IS NULL ORDER BY timestamp ASC"
)
try:
while True:
rows = await cursor.fetchmany(batch_size)
if not rows:
break
for row in rows:
data = bytes(row["data"])
payload_type = get_packet_payload_type(data)
if payload_type == PayloadType.TEXT_MESSAGE:
yield (row["id"], data, row["timestamp"])
finally:
await cursor.close()
@staticmethod
async def count_undecrypted_text_messages(
batch_size: int = UNDECRYPTED_PACKET_BATCH_SIZE,
) -> int:
"""Count undecrypted TEXT_MESSAGE packets without materializing them all."""
count = 0
async for _packet in RawPacketRepository.stream_undecrypted_text_messages(
batch_size=batch_size
):
count += 1
return count
@staticmethod
async def mark_decrypted(packet_id: int, message_id: int) -> None:
"""Link a raw packet to its decrypted message."""
@@ -158,17 +195,4 @@ class RawPacketRepository:
Filters raw packets to only include those with PayloadType.TEXT_MESSAGE (0x02).
These are direct messages that can be decrypted with contact ECDH keys.
"""
cursor = await db.conn.execute(
"SELECT id, data, timestamp FROM raw_packets WHERE message_id IS NULL ORDER BY timestamp ASC"
)
rows = await cursor.fetchall()
# Filter for TEXT_MESSAGE packets
result = []
for row in rows:
data = bytes(row["data"])
payload_type = get_packet_payload_type(data)
if payload_type == PayloadType.TEXT_MESSAGE:
result.append((row["id"], data, row["timestamp"]))
return result
return [packet async for packet in RawPacketRepository.stream_undecrypted_text_messages()]
+56 -37
View File
@@ -12,6 +12,7 @@ logger = logging.getLogger(__name__)
SECONDS_1H = 3600
SECONDS_24H = 86400
SECONDS_7D = 604800
RAW_PACKET_STATS_BATCH_SIZE = 500
class AppSettingsRepository:
@@ -246,6 +247,26 @@ class AppSettingsRepository:
class StatisticsRepository:
@staticmethod
async def get_database_message_totals() -> dict[str, int]:
"""Return message totals needed by lightweight debug surfaces."""
cursor = await db.conn.execute(
"""
SELECT
SUM(CASE WHEN type = 'PRIV' THEN 1 ELSE 0 END) AS total_dms,
SUM(CASE WHEN type = 'CHAN' THEN 1 ELSE 0 END) AS total_channel_messages,
SUM(CASE WHEN outgoing = 1 THEN 1 ELSE 0 END) AS total_outgoing
FROM messages
"""
)
row = await cursor.fetchone()
assert row is not None
return {
"total_dms": row["total_dms"] or 0,
"total_channel_messages": row["total_channel_messages"] or 0,
"total_outgoing": row["total_outgoing"] or 0,
}
@staticmethod
async def _activity_counts(*, contact_type: int, exclude: bool = False) -> dict[str, int]:
"""Get time-windowed counts for contacts/repeaters heard."""
@@ -272,17 +293,26 @@ class StatisticsRepository:
@staticmethod
async def _known_channels_active() -> dict[str, int]:
"""Count distinct known channel keys with channel traffic in each time window."""
"""Count known channel keys with any traffic in each time window.
Channel keys are stored canonically as uppercase hex, so we can avoid
the old UPPER(...) join and aggregate per known channel directly.
"""
now = int(time.time())
cursor = await db.conn.execute(
"""
WITH known AS (
SELECT conversation_key, MAX(received_at) AS last_received_at
FROM messages
WHERE type = 'CHAN'
AND conversation_key IN (SELECT key FROM channels)
GROUP BY conversation_key
)
SELECT
COUNT(DISTINCT CASE WHEN m.received_at >= ? THEN m.conversation_key END) AS last_hour,
COUNT(DISTINCT CASE WHEN m.received_at >= ? THEN m.conversation_key END) AS last_24_hours,
COUNT(DISTINCT CASE WHEN m.received_at >= ? THEN m.conversation_key END) AS last_week
FROM messages m
INNER JOIN channels c ON UPPER(m.conversation_key) = UPPER(c.key)
WHERE m.type = 'CHAN'
SUM(CASE WHEN last_received_at >= ? THEN 1 ELSE 0 END) AS last_hour,
SUM(CASE WHEN last_received_at >= ? THEN 1 ELSE 0 END) AS last_24_hours,
SUM(CASE WHEN last_received_at >= ? THEN 1 ELSE 0 END) AS last_week
FROM known
""",
(now - SECONDS_1H, now - SECONDS_24H, now - SECONDS_7D),
)
@@ -302,22 +332,26 @@ class StatisticsRepository:
"SELECT data FROM raw_packets WHERE timestamp >= ?",
(now - SECONDS_24H,),
)
rows = await cursor.fetchall()
single_byte = 0
double_byte = 0
triple_byte = 0
for row in rows:
envelope = parse_packet_envelope(bytes(row["data"]))
if envelope is None:
continue
if envelope.hash_size == 1:
single_byte += 1
elif envelope.hash_size == 2:
double_byte += 1
elif envelope.hash_size == 3:
triple_byte += 1
while True:
rows = await cursor.fetchmany(RAW_PACKET_STATS_BATCH_SIZE)
if not rows:
break
for row in rows:
envelope = parse_packet_envelope(bytes(row["data"]))
if envelope is None:
continue
if envelope.hash_size == 1:
single_byte += 1
elif envelope.hash_size == 2:
double_byte += 1
elif envelope.hash_size == 3:
triple_byte += 1
total_packets = single_byte + double_byte + triple_byte
if total_packets == 0:
@@ -400,22 +434,7 @@ class StatisticsRepository:
decrypted_packets = pkt_row["decrypted"] or 0
undecrypted_packets = total_packets - decrypted_packets
# Message type counts
cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM messages WHERE type = 'PRIV'")
row = await cursor.fetchone()
assert row is not None
total_dms: int = row["cnt"]
cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM messages WHERE type = 'CHAN'")
row = await cursor.fetchone()
assert row is not None
total_channel_messages: int = row["cnt"]
# Outgoing count
cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM messages WHERE outgoing = 1")
row = await cursor.fetchone()
assert row is not None
total_outgoing: int = row["cnt"]
message_totals = await StatisticsRepository.get_database_message_totals()
# Activity windows
contacts_heard = await StatisticsRepository._activity_counts(contact_type=2, exclude=True)
@@ -431,9 +450,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,
+14 -6
View File
@@ -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}")
+4 -4
View File
@@ -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)],
+1 -2
View File
@@ -210,8 +210,7 @@ async def decrypt_historical_packets(
except ValueError:
raise _bad_request("Invalid hex string for contact public key") from None
packets = await RawPacketRepository.get_undecrypted_text_messages()
count = len(packets)
count = await RawPacketRepository.count_undecrypted_text_messages()
if count == 0:
return DecryptResult(
started=False,
+222
View File
@@ -2,6 +2,7 @@ import asyncio
import logging
import random
import time
from contextlib import suppress
from typing import Literal, TypeAlias
from fastapi import APIRouter, HTTPException
@@ -10,14 +11,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()
-59
View File
@@ -1,9 +1,6 @@
import asyncio
import logging
from typing import TYPE_CHECKING
from fastapi import APIRouter, HTTPException
from meshcore import EventType
from app.dependencies import require_connected
from app.models import (
@@ -28,7 +25,6 @@ from app.models import (
from app.repository import ContactRepository
from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404
from app.routers.server_control import (
_monotonic,
batch_cli_fetch,
extract_response_text,
prepare_authenticated_contact_connection,
@@ -37,9 +33,6 @@ from app.routers.server_control import (
)
from app.services.radio_runtime import radio_runtime as radio_manager
if TYPE_CHECKING:
from meshcore.events import Event
logger = logging.getLogger(__name__)
# ACL permission level names
@@ -57,58 +50,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,
+5 -2
View File
@@ -13,6 +13,7 @@ from app.models import (
Contact,
RepeaterLoginResponse,
)
from app.radio_sync import _store_pending_channel_message, _store_pending_direct_message
from app.routers.contacts import _ensure_on_radio
from app.services.radio_runtime import radio_runtime as radio_manager
@@ -115,18 +116,20 @@ async def fetch_contact_cli_response(
if msg_prefix == target_pubkey_prefix and txt_type == 1:
return result
logger.debug(
"Skipping non-target message (from=%s, txt_type=%d) while waiting for %s",
"Storing non-target DM (from=%s, txt_type=%d) consumed while waiting for %s",
msg_prefix,
txt_type,
target_pubkey_prefix,
)
await _store_pending_direct_message(result)
continue
if result.type == EventType.CHANNEL_MSG_RECV:
logger.debug(
"Skipping channel message (channel_idx=%s) during CLI fetch",
"Storing channel message (channel_idx=%s) consumed during CLI fetch",
result.payload.get("channel_idx"),
)
await _store_pending_channel_message(mc, result.payload)
continue
logger.debug("Unexpected event type %s during CLI fetch, skipping", result.type)
+2
View File
@@ -2,6 +2,7 @@ from fastapi import APIRouter
from app.models import StatisticsResponse
from app.repository import StatisticsRepository
from app.services.radio_noise_floor import get_noise_floor_history
router = APIRouter(prefix="/statistics", tags=["statistics"])
@@ -9,4 +10,5 @@ router = APIRouter(prefix="/statistics", tags=["statistics"])
@router.get("", response_model=StatisticsResponse)
async def get_statistics() -> StatisticsResponse:
data = await StatisticsRepository.get_all()
data["noise_floor_24h"] = await get_noise_floor_history()
return StatisticsResponse(**data)
+119
View File
@@ -0,0 +1,119 @@
"""In-memory local-radio noise floor history sampling."""
import asyncio
import logging
import time
from collections import deque
from meshcore import EventType
from app.radio import RadioDisconnectedError, RadioOperationBusyError
from app.services.radio_runtime import radio_runtime as radio_manager
logger = logging.getLogger(__name__)
NOISE_FLOOR_SAMPLE_INTERVAL_SECONDS = 300
NOISE_FLOOR_WINDOW_SECONDS = 24 * 60 * 60
MAX_NOISE_FLOOR_SAMPLES = 300
_noise_floor_task: asyncio.Task | None = None
_noise_floor_samples: deque[tuple[int, int]] = deque(maxlen=MAX_NOISE_FLOOR_SAMPLES)
_noise_floor_supported: bool | None = None
_samples_lock = asyncio.Lock()
async def _append_sample(timestamp: int, noise_floor_dbm: int) -> None:
async with _samples_lock:
_noise_floor_samples.append((timestamp, noise_floor_dbm))
async def sample_noise_floor_once(*, blocking: bool = False) -> None:
"""Fetch the current radio noise floor once and record it when available."""
global _noise_floor_supported
if not radio_manager.is_connected:
return
try:
async with radio_manager.radio_operation("noise_floor_sample", blocking=blocking) as mc:
event = await mc.commands.get_stats_radio()
except (RadioDisconnectedError, RadioOperationBusyError):
return
except Exception as exc:
logger.debug("Noise floor sampling failed: %s", exc)
return
if event.type == EventType.ERROR:
_noise_floor_supported = False
return
if event.type != EventType.STATS_RADIO:
return
noise_floor = event.payload.get("noise_floor")
if not isinstance(noise_floor, int):
return
_noise_floor_supported = True
await _append_sample(int(time.time()), noise_floor)
async def _noise_floor_sampling_loop() -> None:
while True:
try:
await sample_noise_floor_once()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Noise floor sampling loop crashed during sample")
try:
await asyncio.sleep(NOISE_FLOOR_SAMPLE_INTERVAL_SECONDS)
except asyncio.CancelledError:
raise
async def start_noise_floor_sampling() -> None:
global _noise_floor_task
if _noise_floor_task is not None and not _noise_floor_task.done():
return
_noise_floor_task = asyncio.create_task(_noise_floor_sampling_loop())
async def stop_noise_floor_sampling() -> None:
global _noise_floor_task
if _noise_floor_task is None:
return
if not _noise_floor_task.done():
_noise_floor_task.cancel()
try:
await _noise_floor_task
except asyncio.CancelledError:
pass
_noise_floor_task = None
async def get_noise_floor_history() -> dict:
"""Return the current 24-hour in-memory noise floor history snapshot."""
now = int(time.time())
cutoff = now - NOISE_FLOOR_WINDOW_SECONDS
async with _samples_lock:
samples = [
{"timestamp": timestamp, "noise_floor_dbm": noise_floor_dbm}
for timestamp, noise_floor_dbm in _noise_floor_samples
if timestamp >= cutoff
]
latest = samples[-1] if samples else None
oldest_timestamp = samples[0]["timestamp"] if samples else None
coverage_seconds = 0 if oldest_timestamp is None else max(0, now - oldest_timestamp)
return {
"sample_interval_seconds": NOISE_FLOOR_SAMPLE_INTERVAL_SECONDS,
"coverage_seconds": coverage_seconds,
"latest_noise_floor_dbm": latest["noise_floor_dbm"] if latest else None,
"latest_timestamp": latest["timestamp"] if latest else None,
"supported": _noise_floor_supported,
"samples": samples,
}
-3
View File
@@ -43,9 +43,6 @@ class WebSocketManager:
3. Send to all clients concurrently with timeout
4. Re-acquire lock to clean up disconnected clients
"""
if not self.active_connections:
return
message = dump_ws_event(event_type, data)
# Copy connection list under lock to avoid holding lock during I/O
+50
View File
@@ -0,0 +1,50 @@
services:
remoteterm:
# build: .
image: docker.io/jkingsman/remoteterm-meshcore:latest
# Optional on Linux: run container as your host user to avoid root-owned files in ./data
# This is less reliable for serial-device access than running as root and may require
# extra group setup (for example dialout) or other manual customization.
# user: "${UID:-1000}:${GID:-1000}"
ports:
- "8000:8000"
volumes:
- ./data:/app/data
#####################################################################
# Map your radio by stable device ID if available. #
# If your by-id path contains ':' characters, Docker Compose cannot #
# represent it here directly; use a colon-free host alias instead. #
# (e.g. /dev/ttyUSB0) #
#####################################################################
devices:
- /dev/serial/by-id/your-meshcore-radio:/dev/meshcore-radio
environment:
MESHCORE_DATABASE_PATH: data/meshcore.db
# Radio connection
# Serial (USB)
MESHCORE_SERIAL_PORT: /dev/meshcore-radio
# MESHCORE_SERIAL_BAUDRATE: 115200
# TCP
# MESHCORE_TCP_HOST: 192.168.1.100
# MESHCORE_TCP_PORT: 4000
# BLE
# BLE in Docker usually needs additional manual compose changes such as
# Bluetooth device passthrough, privileged mode, host networking, or
# other host-specific tweaks before it will actually work.
# MESHCORE_BLE_ADDRESS: AA:BB:CC:DD:EE:FF
# MESHCORE_BLE_PIN: 123456
# Security
# MESHCORE_DISABLE_BOTS: "true"
# MESHCORE_BASIC_AUTH_USERNAME: changeme
# MESHCORE_BASIC_AUTH_PASSWORD: changeme
# Logging
# MESHCORE_LOG_LEVEL: INFO
restart: unless-stopped
-35
View File
@@ -1,35 +0,0 @@
services:
remoteterm:
build: .
# image: jkingsman/remoteterm-meshcore:latest
# Optional on Linux: run container as your host user to avoid root-owned files in ./data
# user: "${UID:-1000}:${GID:-1000}"
ports:
- "8000:8000"
volumes:
- ./data:/app/data
################################################
# Set your serial device for passthrough here! #
################################################
devices:
- /dev/ttyACM0:/dev/ttyUSB0
environment:
MESHCORE_DATABASE_PATH: data/meshcore.db
# Radio connection -- optional if you map just a single serial device above, as the app will autodetect
# Serial (USB)
# MESHCORE_SERIAL_PORT: /dev/ttyUSB0
# MESHCORE_SERIAL_BAUDRATE: 115200
# TCP
# MESHCORE_TCP_HOST: 192.168.1.100
# MESHCORE_TCP_PORT: 4000
# Security
# MESHCORE_DISABLE_BOTS: "true"
# MESHCORE_BASIC_AUTH_USERNAME: changeme
# MESHCORE_BASIC_AUTH_PASSWORD: changeme
# Logging
# MESHCORE_LOG_LEVEL: INFO
restart: unless-stopped
+50 -7
View File
@@ -39,6 +39,8 @@ frontend/src/
├── index.css # Global styles/utilities
├── styles.css # Additional global app styles
├── themes.css # Color theme definitions
├── contexts/
│ └── DistanceUnitContext.tsx # Browser-local distance-unit context/provider
├── lib/
│ └── utils.ts # cn() — clsx + tailwind-merge helper
├── hooks/
@@ -53,10 +55,14 @@ frontend/src/
│ ├── useRadioControl.ts # Radio health/config state, reconnection, mesh discovery sweeps
│ ├── useAppSettings.ts # Settings, favorites, preferences migration
│ ├── useConversationRouter.ts # URL hash → active conversation routing
── useContactsAndChannels.ts # Contact/channel loading, creation, deletion
── useContactsAndChannels.ts # Contact/channel loading, creation, deletion
│ ├── useBrowserNotifications.ts # Per-conversation browser notification preferences + dispatch
│ ├── useFaviconBadge.ts # Browser tab unread badge state
│ ├── useRawPacketStatsSession.ts # Session-scoped packet-feed stats history
│ └── useRememberedServerPassword.ts # Browser-local repeater/room password persistence
├── components/
│ ├── AppShell.tsx # App-shell layout: status, sidebar, search/settings panes, cracker, modals
│ ├── ConversationPane.tsx # Active conversation surface selection (map/raw/repeater/chat/empty)
│ ├── AppShell.tsx # App-shell layout: status, sidebar, search/settings panes, cracker, modals, security warning
│ ├── ConversationPane.tsx # Active conversation surface selection (map/raw/trace/repeater/room/chat/empty)
│ ├── visualizer/
│ │ ├── useVisualizerData3D.ts # Packet→graph data pipeline, repeat aggregation, simulation state
│ │ ├── useVisualizer3DScene.ts # Three.js scene lifecycle, buffers, hover/pin interaction
@@ -73,14 +79,18 @@ frontend/src/
│ ├── pubkey.ts # getContactDisplayName (12-char prefix fallback)
│ ├── contactAvatar.ts # Avatar color derivation from public key
│ ├── rawPacketIdentity.ts # observation_id vs id dedup helpers
│ ├── rawPacketStats.ts # Session packet stats windows, rankings, and coverage helpers
│ ├── regionScope.ts # Regional flood-scope label/normalization helpers
│ ├── visualizerUtils.ts # 3D visualizer node types, colors, particles
│ ├── visualizerSettings.ts # LocalStorage persistence for visualizer options
│ ├── a11y.ts # Keyboard accessibility helper
│ ├── distanceUnits.ts # Browser-local distance unit persistence/helpers
│ ├── lastViewedConversation.ts # localStorage for last-viewed conversation
│ ├── contactMerge.ts # Merge WS contact updates into list
│ ├── localLabel.ts # Local label (text + color) in localStorage
│ ├── radioPresets.ts # LoRa radio preset configurations
│ ├── publicChannel.ts # Public-channel resolution helpers for routing/hash defaults
│ ├── fontScale.ts # Browser-local relative font scale persistence/application
│ └── theme.ts # Theme switching helpers
├── components/
│ ├── StatusBar.tsx
@@ -91,8 +101,12 @@ frontend/src/
│ ├── NewMessageModal.tsx
│ ├── SearchView.tsx # Full-text message search pane
│ ├── SettingsModal.tsx # Layout shell — delegates to settings/ sections
│ ├── SecurityWarningModal.tsx # Startup warning for trusted-network / bot execution posture
│ ├── RawPacketList.tsx
│ ├── RawPacketFeedView.tsx # Live raw packet feed + session stats drawer
│ ├── RawPacketDetailModal.tsx # On-demand packet inspector dialog
│ ├── MapView.tsx
│ ├── TracePane.tsx # Multi-hop route trace builder/results view
│ ├── VisualizerView.tsx
│ ├── PacketVisualizer3D.tsx
│ ├── PathModal.tsx
@@ -102,15 +116,20 @@ frontend/src/
│ ├── ContactAvatar.tsx
│ ├── ContactInfoPane.tsx # Contact detail sheet (stats, name history, paths)
│ ├── ContactStatusInfo.tsx # Contact status info component
│ ├── ContactPathDiscoveryModal.tsx # Forward/return path discovery dialog
│ ├── ContactRoutingOverrideModal.tsx # Manual direct-route override editor
│ ├── RepeaterDashboard.tsx # Layout shell — delegates to repeater/ panes
│ ├── RepeaterLogin.tsx # Repeater login form (password + guest)
│ ├── RoomServerPanel.tsx # Room-server auth gate + status banner ahead of room chat
│ ├── ServerLoginStatusBanner.tsx # Shared repeater/room login state banner
│ ├── ChannelInfoPane.tsx # Channel detail sheet (stats, top senders)
│ ├── ChannelFloodScopeOverrideModal.tsx # Per-channel flood-scope override editor
│ ├── DirectTraceIcon.tsx # Shared direct-trace glyph used in header/dashboard
│ ├── NeighborsMiniMap.tsx # Leaflet mini-map for repeater neighbor locations
│ ├── settings/
│ │ ├── settingsConstants.ts # Settings section type, ordering, labels
│ │ ├── SettingsRadioSection.tsx # Name, keys, advert interval, max contacts, radio preset, freq/bw/sf/cr, txPower, lat/lon, reboot, mesh discovery
│ │ ├── SettingsLocalSection.tsx # Browser-local settings: theme, local label, reopen last conversation
│ │ ├── SettingsLocalSection.tsx # Browser-local settings: theme, relative font scale, local label, reopen last conversation
│ │ ├── SettingsFanoutSection.tsx # Fanout integrations: MQTT, bots, config CRUD
│ │ ├── SettingsDatabaseSection.tsx # DB size, cleanup, auto-decrypt, local label
│ │ ├── SettingsStatisticsSection.tsx # Read-only mesh network stats
@@ -130,12 +149,13 @@ frontend/src/
│ └── ui/ # shadcn/ui primitives
├── types/
│ └── d3-force-3d.d.ts # Type declarations for d3-force-3d
└── test/
└── test/ # Representative frontend test suites (not an exhaustive listing)
├── setup.ts
├── fixtures/websocket_events.json
├── api.test.ts
├── appFavorites.test.tsx
├── appStartupHash.test.tsx
├── conversationPane.test.tsx
├── contactAvatar.test.ts
├── contactInfoPane.test.tsx
├── integration.test.ts
@@ -146,18 +166,23 @@ frontend/src/
├── rawPacketList.test.tsx
├── pathUtils.test.ts
├── prefetch.test.ts
├── rawPacketDetailModal.test.tsx
├── rawPacketFeedView.test.tsx
├── radioPresets.test.ts
├── rawPacketIdentity.test.ts
├── repeaterDashboard.test.tsx
├── repeaterFormatters.test.ts
├── repeaterLogin.test.tsx
├── repeaterMessageParsing.test.ts
├── roomServerPanel.test.tsx
├── securityWarningModal.test.tsx
├── localLabel.test.ts
├── messageInput.test.tsx
├── newMessageModal.test.tsx
├── settingsModal.test.tsx
├── sidebar.test.tsx
├── statusBar.test.tsx
├── tracePane.test.tsx
├── unreadCounts.test.ts
├── urlHash.test.ts
├── appSearchJump.test.tsx
@@ -169,12 +194,17 @@ frontend/src/
├── useConversationMessages.race.test.ts
├── useConversationNavigation.test.ts
├── useAppShell.test.ts
├── useBrowserNotifications.test.ts
├── useFaviconBadge.test.ts
├── useRepeaterDashboard.test.ts
├── useRememberedServerPassword.test.ts
├── useContactsAndChannels.test.ts
├── useRealtimeAppState.test.ts
├── useUnreadCounts.test.ts
├── useWebSocket.dispatch.test.ts
├── useWebSocket.lifecycle.test.ts
├── rawPacketStats.test.ts
├── fontScale.test.ts
└── wsEvents.test.ts
```
@@ -190,6 +220,7 @@ frontend/src/
- search/settings surface switching
- global cracker mount/focus behavior
- new-message modal and info panes
- trusted-network `SecurityWarningModal`
High-level state is delegated to hooks:
- `useAppShell`: app-shell view state (settings section, sidebar, cracker, new-message modal)
@@ -211,7 +242,9 @@ High-level state is delegated to hooks:
- map view
- visualizer
- raw packet feed
- trace view
- repeater dashboard
- room-server auth/status gate before room chat
- normal chat chrome (`ChatHeader` + `MessageList` + `MessageInput`)
### Initial load + realtime
@@ -272,12 +305,16 @@ Supported routes:
- `#map/focus/{pubkey_or_prefix}`
- `#visualizer`
- `#search`
- `#trace`
- `#settings/{section}`
- `#channel/{channelKey}`
- `#channel/{channelKey}/{label}`
- `#contact/{publicKey}`
- `#contact/{publicKey}/{label}`
Legacy name-based hashes are still accepted for compatibility.
Where `{section}` is one of `radio`, `local`, `fanout`, `database`, `statistics`, or `about`.
Legacy name-based channel/contact hashes are still accepted for compatibility.
## Conversation State Keys (`utils/conversationState.ts`)
@@ -377,6 +414,12 @@ For repeater contacts (`type=2`), `ConversationPane.tsx` renders `RepeaterDashbo
All state is managed by `useRepeaterDashboard` hook. State resets on conversation change.
## Room Server Panel
For room contacts (`type=3`), `ConversationPane.tsx` keeps the normal chat surface but inserts `RoomServerPanel` above it. That panel handles room-server login/status messaging and gates room chat behind the room-authenticated state when required.
`ServerLoginStatusBanner` is shared between repeater and room login surfaces for inline status/error display.
## Message Search Pane
The `SearchView` component (`components/SearchView.tsx`) provides full-text search across all DMs and channel messages. Key behaviors:
@@ -404,7 +447,7 @@ Do not rely on old class-only layout assumptions.
Run all quality checks (backend + frontend) from the repo root:
```bash
./scripts/all_quality.sh
./scripts/quality/all_quality.sh
```
Or run frontend checks individually:
+384 -3
View File
@@ -1,12 +1,12 @@
{
"name": "remoteterm-meshcore-frontend",
"version": "3.6.1",
"version": "3.6.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "remoteterm-meshcore-frontend",
"version": "3.6.1",
"version": "3.6.2",
"dependencies": {
"@codemirror/lang-python": "^6.2.1",
"@codemirror/theme-one-dark": "^6.1.3",
@@ -30,6 +30,7 @@
"react-dom": "^18.3.1",
"react-leaflet": "^4.2.1",
"react-swipeable": "^7.0.2",
"recharts": "^3.8.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
@@ -2057,6 +2058,42 @@
"react-dom": "^18.0.0"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -2414,6 +2451,18 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@@ -2564,6 +2613,24 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-force": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
@@ -2571,6 +2638,51 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -2663,6 +2775,12 @@
"meshoptimizer": "~0.22.0"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/webxr": {
"version": "0.5.24",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
@@ -3712,12 +3830,33 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-binarytree": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz",
"integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==",
"license": "MIT"
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
@@ -3727,6 +3866,15 @@
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-force": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
@@ -3757,12 +3905,42 @@
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-octree": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz",
"integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==",
"license": "MIT"
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-quadtree": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
@@ -3772,6 +3950,58 @@
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
@@ -3820,6 +4050,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
@@ -3974,6 +4210,16 @@
"node": ">= 0.4"
}
},
"node_modules/es-toolkit": {
"version": "1.45.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -4216,6 +4462,12 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
@@ -4618,6 +4870,16 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -4655,6 +4917,15 @@
"node": ">=8"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -5599,7 +5870,6 @@
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT",
"peer": true
},
@@ -5617,6 +5887,29 @@
"react-dom": "^18.0.0"
}
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -5726,6 +6019,36 @@
"node": ">=8.10.0"
}
},
"node_modules/recharts": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@@ -5740,6 +6063,27 @@
"node": ">=8"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -6134,6 +6478,12 @@
"integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==",
"license": "MIT"
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -6448,12 +6798,43 @@
}
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "remoteterm-meshcore-frontend",
"private": true,
"version": "3.6.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",
+43 -2
View File
@@ -31,6 +31,12 @@ interface ChannelUnreadMarker {
lastReadAt: number | null;
}
interface NewMessagePrefillRequest {
tab: 'hashtag';
hashtagName: string;
nonce: number;
}
interface UnreadBoundaryBackfillParams {
activeConversation: Conversation | null;
unreadMarker: ChannelUnreadMarker | null;
@@ -77,6 +83,8 @@ export function App() {
const messageInputRef = useRef<MessageInputHandle>(null);
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
const [channelUnreadMarker, setChannelUnreadMarker] = useState<ChannelUnreadMarker | null>(null);
const [newMessagePrefillRequest, setNewMessagePrefillRequest] =
useState<NewMessagePrefillRequest | null>(null);
const [visibilityVersion, setVisibilityVersion] = useState(0);
const lastUnreadBackfillAttemptRef = useRef<string | null>(null);
const {
@@ -103,8 +111,8 @@ export function App() {
setDistanceUnit,
handleCloseSettingsView,
handleToggleSettingsView,
handleOpenNewMessage,
handleCloseNewMessage,
handleOpenNewMessage: openNewMessageModal,
handleCloseNewMessage: closeNewMessageModal,
handleToggleCracker,
} = useAppShell();
@@ -274,6 +282,7 @@ export function App() {
unreadLastReadAts,
recordMessageEvent,
renameConversationState,
removeConversationState,
markAllRead,
refreshUnreads,
} = useUnreadCounts(channels, contacts, activeConversation);
@@ -349,6 +358,7 @@ export function App() {
observeMessage,
recordMessageEvent,
renameConversationState,
removeConversationState,
checkMention,
pendingDeleteFallbackRef,
setActiveConversation,
@@ -411,6 +421,34 @@ export function App() {
[fetchUndecryptedCount, setChannels]
);
const handleOpenNewMessage = useCallback(() => {
setNewMessagePrefillRequest(null);
openNewMessageModal();
}, [openNewMessageModal]);
const handleCloseNewMessage = useCallback(() => {
setNewMessagePrefillRequest(null);
closeNewMessageModal();
}, [closeNewMessageModal]);
const handleChannelReferenceClick = useCallback(
(channelName: string) => {
const existingChannel = channels.find((channel) => channel.name === channelName);
if (existingChannel) {
handleNavigateToChannel(existingChannel.key);
return;
}
setNewMessagePrefillRequest((previous) => ({
tab: 'hashtag',
hashtagName: channelName.slice(1),
nonce: (previous?.nonce ?? 0) + 1,
}));
openNewMessageModal();
},
[channels, handleNavigateToChannel, openNewMessageModal]
);
const statusProps = {
health,
config,
@@ -457,6 +495,7 @@ export function App() {
loadingNewer,
messageInputRef,
onTrace: handleTrace,
onRunTracePath: api.requestRadioTrace,
onPathDiscovery: handlePathDiscovery,
onToggleFavorite: handleToggleFavorite,
onDeleteContact: handleDeleteContact,
@@ -465,6 +504,7 @@ export function App() {
onOpenContactInfo: handleOpenContactInfo,
onOpenChannelInfo: handleOpenChannelInfo,
onSenderClick: handleSenderClick,
onChannelReferenceClick: handleChannelReferenceClick,
onLoadOlder: fetchOlderMessages,
onResendChannelMessage: handleResendChannelMessage,
onTargetReached: () => setTargetMessageId(null),
@@ -523,6 +563,7 @@ export function App() {
};
const newMessageModalProps = {
undecryptedCount,
prefillRequest: newMessagePrefillRequest,
onCreateContact: handleCreateContact,
onCreateChannel: handleCreateChannel,
onCreateHashtagChannel: handleCreateHashtagChannel,
+11 -2
View File
@@ -20,6 +20,8 @@ import type {
RadioConfig,
RadioConfigUpdate,
RadioDiscoveryResponse,
RadioTraceHopRequest,
RadioTraceResponse,
RadioDiscoveryTarget,
PathDiscoveryResponse,
ResendChannelMessageResponse,
@@ -107,6 +109,11 @@ export const api = {
method: 'POST',
body: JSON.stringify({ target }),
}),
requestRadioTrace: (hopHashBytes: 1 | 2 | 4, hops: RadioTraceHopRequest[]) =>
fetchJson<RadioTraceResponse>('/radio/trace', {
method: 'POST',
body: JSON.stringify({ hop_hash_bytes: hopHashBytes, hops }),
}),
rebootRadio: () =>
fetchJson<{ status: string; message: string }>('/radio/reboot', {
method: 'POST',
@@ -130,11 +137,13 @@ export const api = {
fetchJson<ContactAdvertPathSummary[]>(
`/contacts/repeaters/advert-paths?limit_per_repeater=${limitPerRepeater}`
),
getContactAnalytics: (params: { publicKey?: string; name?: string }) => {
getContactAnalytics: (params: { publicKey?: string; name?: string }, signal?: AbortSignal) => {
const searchParams = new URLSearchParams();
if (params.publicKey) searchParams.set('public_key', params.publicKey);
if (params.name) searchParams.set('name', params.name);
return fetchJson<ContactAnalytics>(`/contacts/analytics?${searchParams.toString()}`);
return fetchJson<ContactAnalytics>(`/contacts/analytics?${searchParams.toString()}`, {
signal,
});
},
deleteContact: (publicKey: string) =>
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
+1 -1
View File
@@ -268,7 +268,7 @@ export function ChatHeader({
title={
activeContactIsPrefixOnly
? 'Direct Trace unavailable until the full contact key is known'
: 'Direct Trace. Send a zero-hop packet to this contact and display out and back SNR'
: 'Direct Trace. Send a direct trace probe to this contact and display out and back SNR'
}
aria-label="Direct Trace"
disabled={activeContactIsPrefixOnly}
+119 -129
View File
@@ -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 && (
<div>
<SectionLabel>Messages Per Hour</SectionLabel>
<ChartLegend
items={[
{ label: 'Last 24h', color: '#2563eb' },
{ label: '7-day avg', color: '#ea580c' },
{ label: 'All-time avg', color: '#64748b' },
]}
/>
<ActivityLineChart
ariaLabel="Messages per hour"
points={analytics.hourly_activity}
series={[
{ key: 'last_24h_count', color: '#2563eb' },
{ key: 'last_week_average', color: '#ea580c' },
{ key: 'all_time_average', color: '#64748b' },
{ key: 'last_24h_count', color: '#2563eb', label: 'Last 24h' },
{ key: 'last_week_average', color: '#ea580c', label: '7-day avg' },
{ key: 'all_time_average', color: '#64748b', label: 'All-time avg' },
]}
legendItems={[
{ label: 'Last 24h', color: '#2563eb' },
{ label: '7-day avg', color: '#ea580c' },
{ label: 'All-time avg', color: '#64748b' },
]}
valueFormatter={(value) => value.toFixed(value % 1 === 0 ? 0 : 1)}
tickFormatter={(bucket) =>
@@ -683,7 +691,7 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu
<ActivityLineChart
ariaLabel="Messages per week"
points={analytics.weekly_activity}
series={[{ key: 'message_count', color: '#16a34a' }]}
series={[{ key: 'message_count', color: '#16a34a', label: 'Messages' }]}
valueFormatter={(value) => value.toFixed(0)}
tickFormatter={(bucket) =>
new Date(bucket.bucket_start * 1000).toLocaleDateString([], {
@@ -705,133 +713,115 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu
);
}
function ChartLegend({ items }: { items: Array<{ label: string; color: string }> }) {
return (
<div className="flex flex-wrap gap-x-3 gap-y-1 mb-2 text-[11px] text-muted-foreground">
{items.map((item) => (
<span key={item.label} className="inline-flex items-center gap-1.5">
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: item.color }}
aria-hidden="true"
/>
{item.label}
</span>
))}
</div>
);
}
const TOOLTIP_STYLE = {
contentStyle: {
backgroundColor: 'hsl(var(--popover))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
fontSize: '11px',
color: 'hsl(var(--popover-foreground))',
},
itemStyle: { color: 'hsl(var(--popover-foreground))' },
labelStyle: { color: 'hsl(var(--muted-foreground))' },
} as const;
function ActivityLineChart<T extends ContactAnalyticsHourlyBucket | ContactAnalyticsWeeklyBucket>({
ariaLabel,
points,
series,
legendItems,
tickFormatter,
valueFormatter,
}: {
ariaLabel: string;
points: T[];
series: Array<{ key: keyof T; color: string }>;
series: Array<{ key: keyof T; color: string; label?: string }>;
legendItems?: Array<{ label: string; color: string }>;
tickFormatter: (point: T) => string;
valueFormatter: (value: number) => string;
}) {
const width = 320;
const height = 132;
const padding = { top: 8, right: 8, bottom: 24, left: 32 };
const plotWidth = width - padding.left - padding.right;
const plotHeight = height - padding.top - padding.bottom;
const allValues = points.flatMap((point) =>
series.map((entry) => {
const value = point[entry.key];
return typeof value === 'number' ? value : 0;
})
);
const maxValue = Math.max(1, ...allValues);
const tickIndices = Array.from(
new Set([
0,
Math.floor((points.length - 1) / 3),
Math.floor(((points.length - 1) * 2) / 3),
points.length - 1,
])
);
const data = points.map((point, i) => {
const entry: Record<string, string | number> = { idx: i, tick: tickFormatter(point) };
for (const s of series) {
const raw = point[s.key];
entry[String(s.key)] = typeof raw === 'number' ? raw : 0;
}
return entry;
});
const buildPolyline = (key: keyof T) =>
points
.map((point, index) => {
const rawValue = point[key];
const value = typeof rawValue === 'number' ? rawValue : 0;
const x =
padding.left + (points.length === 1 ? 0 : (index / (points.length - 1)) * plotWidth);
const y = padding.top + plotHeight - (value / maxValue) * plotHeight;
return `${x},${y}`;
})
.join(' ');
const tickCount = Math.min(5, points.length);
const tickIndices: number[] = [];
if (points.length > 1) {
for (let i = 0; i < tickCount; i++) {
tickIndices.push(Math.round((i / (tickCount - 1)) * (points.length - 1)));
}
}
return (
<div>
<svg
viewBox={`0 0 ${width} ${height}`}
className="w-full h-auto"
role="img"
aria-label={ariaLabel}
>
{[0, 0.5, 1].map((ratio) => {
const y = padding.top + plotHeight - ratio * plotHeight;
const value = maxValue * ratio;
return (
<g key={ratio}>
<line
x1={padding.left}
x2={width - padding.right}
y1={y}
y2={y}
stroke="hsl(var(--border))"
strokeWidth="1"
/>
<text
x={padding.left - 6}
y={y + 4}
fontSize="10"
textAnchor="end"
fill="hsl(var(--muted-foreground))"
>
{valueFormatter(value)}
</text>
</g>
);
})}
{series.map((entry) => (
<polyline
key={String(entry.key)}
fill="none"
stroke={entry.color}
strokeWidth="2"
strokeLinejoin="round"
strokeLinecap="round"
points={buildPolyline(entry.key)}
<div role="img" aria-label={ariaLabel}>
<ResponsiveContainer width="100%" height={140}>
<LineChart data={data} margin={{ top: 4, right: 4, bottom: 0, left: -16 }}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
<XAxis
dataKey="idx"
type="number"
domain={[0, Math.max(1, points.length - 1)]}
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
ticks={tickIndices}
tickFormatter={(idx) => String(data[idx]?.tick ?? '')}
/>
))}
{tickIndices.map((index) => {
const point = points[index];
const x =
padding.left + (points.length === 1 ? 0 : (index / (points.length - 1)) * plotWidth);
return (
<text
key={`${ariaLabel}-${point.bucket_start}`}
x={x}
y={height - 6}
fontSize="10"
textAnchor={index === 0 ? 'start' : index === points.length - 1 ? 'end' : 'middle'}
fill="hsl(var(--muted-foreground))"
>
{tickFormatter(point)}
</text>
);
})}
</svg>
<YAxis
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
tickFormatter={(v) => valueFormatter(v)}
width={40}
/>
<RechartsTooltip
{...TOOLTIP_STYLE}
cursor={{
stroke: 'hsl(var(--muted-foreground))',
strokeWidth: 1,
strokeDasharray: '3 3',
}}
labelFormatter={(idx) => String(data[Number(idx)]?.tick ?? '')}
formatter={(value, name) => {
const match = series.find((s) => String(s.key) === name);
return [valueFormatter(Number(value)), match?.label ?? String(name)];
}}
/>
{legendItems && (
<Legend
content={() => (
<div className="flex flex-wrap justify-center gap-x-3 gap-y-1 mt-1 text-[11px] text-muted-foreground">
{legendItems.map((item) => (
<span key={item.label} className="inline-flex items-center gap-1.5">
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: item.color }}
/>
{item.label}
</span>
))}
</div>
)}
/>
)}
{series.map((entry) => (
<Line
key={String(entry.key)}
type="linear"
dataKey={String(entry.key)}
stroke={entry.color}
strokeWidth={1.5}
dot={false}
activeDot={{ r: 4, strokeWidth: 2, stroke: 'hsl(var(--popover))' }}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
);
}
@@ -5,6 +5,7 @@ import { MessageInput, type MessageInputHandle } from './MessageInput';
import { MessageList } from './MessageList';
import { RawPacketFeedView } from './RawPacketFeedView';
import { RoomServerPanel } from './RoomServerPanel';
import { TracePane } from './TracePane';
import type {
Channel,
Contact,
@@ -15,6 +16,8 @@ import type {
PathDiscoveryResponse,
RawPacket,
RadioConfig,
RadioTraceHopRequest,
RadioTraceResponse,
} from '../types';
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
@@ -50,6 +53,10 @@ interface ConversationPaneProps {
loadingNewer: boolean;
messageInputRef: Ref<MessageInputHandle>;
onTrace: () => Promise<void>;
onRunTracePath: (
hopHashBytes: 1 | 2 | 4,
hops: RadioTraceHopRequest[]
) => Promise<RadioTraceResponse>;
onPathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
onToggleFavorite: (type: 'channel' | 'contact', id: string) => Promise<void>;
onDeleteContact: (publicKey: string) => Promise<void>;
@@ -58,6 +65,7 @@ interface ConversationPaneProps {
onOpenContactInfo: (publicKey: string, fromChannel?: boolean) => void;
onOpenChannelInfo: (channelKey: string) => void;
onSenderClick: (sender: string) => void;
onChannelReferenceClick?: (channelName: string) => void;
onLoadOlder: () => Promise<void>;
onResendChannelMessage: (messageId: number, newTimestamp?: boolean) => Promise<void>;
onTargetReached: () => void;
@@ -115,6 +123,7 @@ export function ConversationPane({
loadingNewer,
messageInputRef,
onTrace,
onRunTracePath,
onPathDiscovery,
onToggleFavorite,
onDeleteContact,
@@ -123,6 +132,7 @@ export function ConversationPane({
onOpenContactInfo,
onOpenChannelInfo,
onSenderClick,
onChannelReferenceClick,
onLoadOlder,
onResendChannelMessage,
onTargetReached,
@@ -200,6 +210,10 @@ export function ConversationPane({
return null;
}
if (activeConversation.type === 'trace') {
return <TracePane contacts={contacts} config={config} onRunTracePath={onRunTracePath} />;
}
if (activeContactIsRepeater) {
return (
<Suspense fallback={<LoadingPane label="Loading dashboard..." />}>
@@ -272,6 +286,7 @@ export function ConversationPane({
activeConversation.type === 'channel' ? onDismissUnreadMarker : undefined
}
onSenderClick={activeConversation.type === 'channel' ? onSenderClick : undefined}
onChannelReferenceClick={onChannelReferenceClick}
onLoadOlder={onLoadOlder}
onResendChannelMessage={
activeConversation.type === 'channel' ? onResendChannelMessage : undefined
+27 -4
View File
@@ -39,6 +39,7 @@ export function CrackerPanel({
}: CrackerPanelProps) {
const [isRunning, setIsRunning] = useState(false);
const [maxLength, setMaxLength] = useState(6);
const [maxLengthInput, setMaxLengthInput] = useState('6');
const [retryFailedAtNextLength, setRetryFailedAtNextLength] = useState(false);
const [decryptHistorical, setDecryptHistorical] = useState(true);
const [turboMode, setTurboMode] = useState(false);
@@ -127,8 +128,9 @@ export function CrackerPanel({
}, [existingChannelKeys]);
// Filter packets to only undecrypted GROUP_TEXT
const undecryptedGroupText = packets.filter(
(p) => p.payload_type === 'GROUP_TEXT' && !p.decrypted
const undecryptedGroupText = useMemo(
() => packets.filter((p) => p.payload_type === 'GROUP_TEXT' && !p.decrypted),
[packets]
);
// Update queue when packets change (deduplicated by payload)
@@ -191,6 +193,10 @@ export function CrackerPanel({
maxLengthRef.current = maxLength;
}, [maxLength]);
useEffect(() => {
setMaxLengthInput(String(maxLength));
}, [maxLength]);
useEffect(() => {
decryptHistoricalRef.current = decryptHistorical;
}, [decryptHistorical]);
@@ -434,8 +440,25 @@ export function CrackerPanel({
type="number"
min={1}
max={10}
value={maxLength}
onChange={(e) => setMaxLength(Math.min(10, Math.max(1, parseInt(e.target.value) || 6)))}
value={maxLengthInput}
onChange={(e) => {
const nextValue = e.target.value;
setMaxLengthInput(nextValue);
if (nextValue === '') return;
const parsed = Number.parseInt(nextValue, 10);
if (Number.isNaN(parsed)) return;
setMaxLength(Math.min(10, Math.max(1, parsed)));
}}
onBlur={() => {
const parsed = Number.parseInt(maxLengthInput, 10);
const nextValue = Number.isNaN(parsed)
? maxLength
: Math.min(10, Math.max(1, parsed));
setMaxLengthInput(String(nextValue));
if (nextValue !== maxLength) {
setMaxLength(nextValue);
}
}}
className="w-14 px-2 py-1 text-sm bg-muted border border-border rounded"
/>
</div>
+14
View File
@@ -131,9 +131,23 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
// Store ref for a marker
const setMarkerRef = useCallback((key: string, ref: LeafletCircleMarker | null) => {
if (ref === null) {
delete markerRefs.current[key];
return;
}
markerRefs.current[key] = ref;
}, []);
useEffect(() => {
const currentKeys = new Set(mappableContacts.map((contact) => contact.public_key));
for (const key of Object.keys(markerRefs.current)) {
if (!currentKeys.has(key)) {
delete markerRefs.current[key];
}
}
}, [mappableContacts]);
// Open popup for focused contact after map is ready
useEffect(() => {
if (focusedContact && markerRefs.current[focusedContact.public_key]) {
+112 -11
View File
@@ -11,7 +11,11 @@ import {
import type { Channel, Contact, Message, MessagePath, RadioConfig, RawPacket } from '../types';
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
import { api } from '../api';
import { formatTime, parseSenderFromText } from '../utils/messageParser';
import {
findLinkedChannelReferences,
formatTime,
parseSenderFromText,
} from '../utils/messageParser';
import { formatHopCounts, type SenderInfo } from '../utils/pathUtils';
import { getDirectContactRoute } from '../utils/pathUtils';
import { ContactAvatar } from './ContactAvatar';
@@ -33,6 +37,7 @@ interface MessageListProps {
onSenderClick?: (sender: string) => void;
onLoadOlder?: () => void;
onResendChannelMessage?: (messageId: number, newTimestamp?: boolean) => void;
onChannelReferenceClick?: (channelName: string) => void;
radioName?: string;
config?: RadioConfig | null;
onOpenContactInfo?: (publicKey: string, fromChannel?: boolean) => void;
@@ -48,8 +53,64 @@ interface MessageListProps {
const URL_PATTERN =
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/g;
// Helper to convert URLs in a plain text string into clickable links
function linkifyText(text: string, keyPrefix: string): ReactNode[] {
function renderChannelReferences(
text: string,
keyPrefix: string,
onChannelReferenceClick?: (channelName: string) => void
): ReactNode[] {
const references = findLinkedChannelReferences(text);
if (references.length === 0) {
return [text];
}
const parts: ReactNode[] = [];
let lastIndex = 0;
references.forEach((reference, index) => {
if (reference.start > lastIndex) {
parts.push(text.slice(lastIndex, reference.start));
}
const className =
'rounded px-0.5 font-medium text-primary underline underline-offset-2 transition-colors';
if (onChannelReferenceClick) {
parts.push(
<button
key={`${keyPrefix}-channel-${index}`}
type="button"
className={cn(
className,
'inline border-0 bg-transparent p-0 align-baseline hover:text-primary/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
)}
onClick={() => onChannelReferenceClick(reference.label)}
>
{reference.label}
</button>
);
} else {
parts.push(
<span key={`${keyPrefix}-channel-${index}`} className={className}>
{reference.label}
</span>
);
}
lastIndex = reference.end;
});
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts;
}
// Helper to convert URLs and channel references in a plain text string into rich content
function linkifyText(
text: string,
keyPrefix: string,
onChannelReferenceClick?: (channelName: string) => void
): ReactNode[] {
const parts: ReactNode[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
@@ -58,7 +119,13 @@ function linkifyText(text: string, keyPrefix: string): ReactNode[] {
URL_PATTERN.lastIndex = 0;
while ((match = URL_PATTERN.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
parts.push(
...renderChannelReferences(
text.slice(lastIndex, match.index),
`${keyPrefix}-text-${keyIndex}`,
onChannelReferenceClick
)
);
}
parts.push(
<a
@@ -74,15 +141,27 @@ function linkifyText(text: string, keyPrefix: string): ReactNode[] {
lastIndex = match.index + match[0].length;
}
if (lastIndex === 0) return [text];
if (lastIndex === 0) {
return renderChannelReferences(text, keyPrefix, onChannelReferenceClick);
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
parts.push(
...renderChannelReferences(
text.slice(lastIndex),
`${keyPrefix}-tail`,
onChannelReferenceClick
)
);
}
return parts;
}
// Helper to render text with highlighted @[Name] mentions and clickable URLs
function renderTextWithMentions(text: string, radioName?: string): ReactNode {
function renderTextWithMentions(
text: string,
radioName?: string,
onChannelReferenceClick?: (channelName: string) => void
): ReactNode {
const mentionPattern = /@\[([^\]]+)\]/g;
const parts: ReactNode[] = [];
let lastIndex = 0;
@@ -92,7 +171,13 @@ function renderTextWithMentions(text: string, radioName?: string): ReactNode {
while ((match = mentionPattern.exec(text)) !== null) {
// Add text before the match (with linkification)
if (match.index > lastIndex) {
parts.push(...linkifyText(text.slice(lastIndex, match.index), `pre-${keyIndex}`));
parts.push(
...linkifyText(
text.slice(lastIndex, match.index),
`pre-${keyIndex}`,
onChannelReferenceClick
)
);
}
const mentionedName = match[1];
@@ -115,7 +200,7 @@ function renderTextWithMentions(text: string, radioName?: string): ReactNode {
// Add remaining text after last match (with linkification)
if (lastIndex < text.length) {
parts.push(...linkifyText(text.slice(lastIndex), `post-${keyIndex}`));
parts.push(...linkifyText(text.slice(lastIndex), `post-${keyIndex}`, onChannelReferenceClick));
}
return parts.length > 0 ? parts : text;
@@ -188,6 +273,7 @@ export function MessageList({
onSenderClick,
onLoadOlder,
onResendChannelMessage,
onChannelReferenceClick,
radioName,
config,
onOpenContactInfo,
@@ -373,7 +459,22 @@ export function MessageList({
}
}
setResendableIds(newResendable);
setResendableIds((prev) => {
if (prev.size === newResendable.size) {
let changed = false;
for (const id of newResendable) {
if (!prev.has(id)) {
changed = true;
break;
}
}
if (!changed) {
return prev;
}
}
return newResendable;
});
return () => {
for (const timer of timers.values()) clearTimeout(timer);
@@ -896,7 +997,7 @@ export function MessageList({
<div className="break-words whitespace-pre-wrap">
{content.split('\n').map((line, i, arr) => (
<span key={i}>
{renderTextWithMentions(line, radioName)}
{renderTextWithMentions(line, radioName, onChannelReferenceClick)}
{i < arr.length - 1 && <br />}
</span>
))}
+25 -1
View File
@@ -1,4 +1,4 @@
import { useState, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Dice5 } from 'lucide-react';
import {
Dialog,
@@ -20,6 +20,11 @@ type Tab = 'new-contact' | 'new-channel' | 'hashtag';
interface NewMessageModalProps {
open: boolean;
undecryptedCount: number;
prefillRequest?: {
tab: 'hashtag';
hashtagName: string;
nonce: number;
} | null;
onClose: () => void;
onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>;
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
@@ -29,6 +34,7 @@ interface NewMessageModalProps {
export function NewMessageModal({
open,
undecryptedCount,
prefillRequest = null,
onClose,
onCreateContact,
onCreateChannel,
@@ -53,6 +59,24 @@ export function NewMessageModal({
setError('');
};
useEffect(() => {
if (!open || !prefillRequest) {
return;
}
setTab(prefillRequest.tab);
setName(prefillRequest.hashtagName);
setContactKey('');
setChannelKey('');
setTryHistorical(false);
setPermitCapitals(false);
setError('');
setLoading(false);
requestAnimationFrame(() => {
hashtagInputRef.current?.focus();
});
}, [open, prefillRequest]);
const handleCreate = async () => {
setError('');
setLoading(true);
+4 -3
View File
@@ -81,13 +81,14 @@ export function PathModal({
) : hasSinglePath ? (
<>
This shows <em>one route</em> that this message traveled through the mesh network.
Repeaters may be incorrectly identified due to prefix collisions between heard and
non-heard repeater advertisements.
Repeater identities are inferred from locally known advert and path data, so some
hops may be missing or misidentified when that data is incomplete.
</>
) : (
<>
This message was received via <strong>{paths.length} different routes</strong>.
Repeaters may be incorrectly identified due to prefix collisions.
Repeater identities are inferred from locally known advert and path data, so some
hops may be missing or misidentified when that data is incomplete.
</>
)}
</DialogDescription>
+120 -76
View File
@@ -1,5 +1,15 @@
import { useEffect, useMemo, useState } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip as RechartsTooltip,
ResponsiveContainer,
Cell,
} from 'recharts';
import { RawPacketList } from './RawPacketList';
import { RawPacketInspectorDialog } from './RawPacketDetailModal';
@@ -24,6 +34,18 @@ interface RawPacketFeedViewProps {
channels: Channel[];
}
const TOOLTIP_STYLE = {
contentStyle: {
backgroundColor: 'hsl(var(--popover))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
fontSize: '11px',
color: 'hsl(var(--popover-foreground))',
},
itemStyle: { color: 'hsl(var(--popover-foreground))' },
labelStyle: { color: 'hsl(var(--muted-foreground))' },
} as const;
const WINDOW_LABELS: Record<RawPacketStatsWindow, string> = {
'1m': '1 min',
'5m': '5 min',
@@ -32,13 +54,7 @@ const WINDOW_LABELS: Record<RawPacketStatsWindow, string> = {
session: 'Session',
};
const TIMELINE_COLORS = [
'bg-sky-500/80',
'bg-emerald-500/80',
'bg-amber-500/80',
'bg-rose-500/80',
'bg-violet-500/80',
];
const TIMELINE_FILL_COLORS = ['#0ea5e9', '#10b981', '#f59e0b', '#f43f5e', '#8b5cf6'];
function formatTimestamp(timestampMs: number): string {
return new Date(timestampMs).toLocaleString([], {
@@ -155,24 +171,17 @@ function isNeighborIdentityResolvable(item: NeighborStat, contacts: Contact[]):
return resolveContact(item.key, contacts) !== null;
}
function formatStrongestPacketDetail(
function formatStrongestNeighborDetail(
stats: ReturnType<typeof buildRawPacketStatsSnapshot>,
contacts: Contact[]
): string | undefined {
if (!stats.strongestPacketPayloadType) {
const strongestNeighbor = stats.strongestNeighbors[0];
if (!strongestNeighbor || strongestNeighbor.bestRssi === null) {
return undefined;
}
const resolvedLabel =
resolveContactLabel(stats.strongestPacketSourceKey, contacts) ??
stats.strongestPacketSourceLabel;
if (resolvedLabel) {
return `${resolvedLabel} · ${stats.strongestPacketPayloadType}`;
}
if (stats.strongestPacketPayloadType === 'GroupText') {
return '<unknown sender> · GroupText';
}
return stats.strongestPacketPayloadType;
const resolvedNeighbor = resolveNeighbor(strongestNeighbor, contacts);
return `${formatRssi(resolvedNeighbor.bestRssi)} best heard`;
}
function getCoverageMessage(
@@ -220,7 +229,13 @@ function RankedBars({
emptyLabel: string;
formatter?: (item: RankedPacketStat) => string;
}) {
const maxCount = Math.max(...items.map((item) => item.count), 1);
const data = items.map((item) => ({
name: item.label,
value: item.count,
detail: formatter
? formatter(item)
: `${item.count.toLocaleString()} · ${formatPercent(item.share)}`,
}));
return (
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
@@ -228,25 +243,36 @@ function RankedBars({
{items.length === 0 ? (
<p className="mt-3 text-sm text-muted-foreground">{emptyLabel}</p>
) : (
<div className="mt-3 space-y-2">
{items.map((item) => (
<div key={item.label}>
<div className="mb-1 flex items-center justify-between gap-3 text-xs">
<span className="truncate text-foreground">{item.label}</span>
<span className="shrink-0 tabular-nums text-muted-foreground">
{formatter
? formatter(item)
: `${item.count.toLocaleString()} · ${formatPercent(item.share)}`}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary/80"
style={{ width: `${(item.count / maxCount) * 100}%` }}
/>
</div>
</div>
))}
<div className="mt-2">
<ResponsiveContainer width="100%" height={items.length * 28 + 8}>
<BarChart
data={data}
layout="vertical"
margin={{ top: 0, right: 4, bottom: 0, left: 0 }}
barCategoryGap="20%"
>
<XAxis type="number" hide />
<YAxis
type="category"
dataKey="name"
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
width={80}
/>
<RechartsTooltip
{...TOOLTIP_STYLE}
cursor={{ fill: 'hsl(var(--muted))', opacity: 0.5 }}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
formatter={(_v: any, _n: any, props: any) => [props.payload.detail, null]}
/>
<Bar dataKey="value" radius={[0, 4, 4, 0]} maxBarSize={16}>
{data.map((_, i) => (
<Cell key={i} fill={TIMELINE_FILL_COLORS[i % TIMELINE_FILL_COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)}
</section>
@@ -320,53 +346,66 @@ function NeighborList({
}
function TimelineChart({ bins }: { bins: PacketTimelineBin[] }) {
const maxTotal = Math.max(...bins.map((bin) => bin.total), 1);
const typeOrder = Array.from(new Set(bins.flatMap((bin) => Object.keys(bin.countsByType)))).slice(
0,
TIMELINE_COLORS.length
TIMELINE_FILL_COLORS.length
);
const data = bins.map((bin) => {
const entry: Record<string, string | number> = { label: bin.label };
for (const type of typeOrder) {
entry[type] = bin.countsByType[type] ?? 0;
}
return entry;
});
return (
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-foreground">Traffic Timeline</h3>
<div className="flex flex-wrap justify-end gap-2 text-[11px] text-muted-foreground">
{typeOrder.map((type, index) => (
{typeOrder.map((type, i) => (
<span key={type} className="inline-flex items-center gap-1">
<span className={cn('h-2 w-2 rounded-full', TIMELINE_COLORS[index])} />
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: TIMELINE_FILL_COLORS[i] }}
/>
<span>{type}</span>
</span>
))}
</div>
</div>
<div className="mt-3 flex items-start gap-1">
{bins.map((bin, index) => (
<div
key={`${bin.label}-${index}`}
className="flex min-w-0 flex-1 flex-col items-center gap-1"
>
<div className="flex h-24 w-full items-end overflow-hidden rounded-sm bg-muted/60">
<div className="flex h-full w-full flex-col justify-end">
{typeOrder.map((type, index) => {
const count = bin.countsByType[type] ?? 0;
if (count === 0) return null;
return (
<div
key={type}
className={cn('w-full', TIMELINE_COLORS[index])}
style={{
height: `${(count / maxTotal) * 100}%`,
}}
title={`${bin.label}: ${type} ${count.toLocaleString()}`}
/>
);
})}
</div>
</div>
<div className="text-[10px] text-muted-foreground">{bin.label}</div>
</div>
))}
<div className="mt-2">
<ResponsiveContainer width="100%" height={110}>
<BarChart data={data} margin={{ top: 4, right: 0, bottom: 0, left: -24 }}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
<XAxis
dataKey="label"
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
/>
<YAxis
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
allowDecimals={false}
/>
<RechartsTooltip
{...TOOLTIP_STYLE}
cursor={{ fill: 'hsl(var(--muted))', opacity: 0.5 }}
/>
{typeOrder.map((type, i) => (
<Bar
key={type}
dataKey={type}
stackId="packets"
fill={TIMELINE_FILL_COLORS[i]}
radius={i === typeOrder.length - 1 ? [2, 2, 0, 0] : undefined}
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
</section>
);
@@ -404,8 +443,13 @@ export function RawPacketFeedView({
[nowSec, rawPacketStatsSession, selectedWindow]
);
const coverageMessage = getCoverageMessage(stats, rawPacketStatsSession);
const strongestPacketDetail = useMemo(
() => formatStrongestPacketDetail(stats, contacts),
const strongestNeighbor = useMemo(() => {
const topNeighbor = stats.strongestNeighbors[0];
return topNeighbor ? resolveNeighbor(topNeighbor, contacts) : null;
}, [contacts, stats]);
const strongestNeighborDetail = useMemo(
() => formatStrongestNeighborDetail(stats, contacts),
[contacts, stats]
);
const strongestNeighbors = useMemo(
@@ -532,9 +576,9 @@ export function RawPacketFeedView({
detail={`${formatPercent(stats.pathBearingRate)} path-bearing packets`}
/>
<StatTile
label="Best RSSI"
value={formatRssi(stats.bestRssi)}
detail={strongestPacketDetail ?? 'No signal sample in window'}
label="Strongest Neighbor"
value={strongestNeighbor?.label ?? '-'}
detail={strongestNeighborDetail ?? 'No neighbor RSSI sample in window'}
/>
<StatTile
label="Median RSSI"
+5 -1
View File
@@ -174,7 +174,11 @@ export function SearchView({
api
.getMessages({ q: debouncedQuery, limit: SEARCH_PAGE_SIZE, offset }, controller.signal)
.then((data) => {
setResults((prev) => [...prev, ...(data as SearchResult[])]);
setResults((prev) => {
const existingIds = new Set(prev.map((r) => r.id));
const unique = (data as SearchResult[]).filter((r) => !existingIds.has(r.id));
return [...prev, ...unique];
});
setHasMore(data.length >= SEARCH_PAGE_SIZE);
setOffset((prev) => prev + data.length);
})
+45 -28
View File
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Bell,
Cable,
ChartNetwork,
CheckCheck,
ChevronDown,
ChevronRight,
@@ -9,7 +11,6 @@ import {
Map,
Search as SearchIcon,
SquarePen,
Waypoints,
X,
} from 'lucide-react';
import {
@@ -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: <Waypoints className="h-4 w-4" />,
icon: <ChartNetwork className="h-4 w-4" />,
label: 'Mesh Visualizer',
onClick: () =>
handleSelectConversation({
@@ -730,6 +731,18 @@ export function Sidebar({
name: 'Mesh Visualizer',
}),
}),
renderSidebarActionRow({
key: 'tool-trace',
active: isActive('trace', 'trace'),
icon: <Cable className="h-4 w-4" />,
label: 'Trace',
onClick: () =>
handleSelectConversation({
type: 'trace',
id: 'trace',
name: 'Trace',
}),
}),
renderSidebarActionRow({
key: 'tool-search',
active: isActive('search', 'search'),
@@ -840,41 +853,45 @@ export function Sidebar({
aria-label="Conversations"
>
{/* Header */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-border">
<div className="relative min-w-0 flex-1">
<Input
type="text"
placeholder="Search channels/contacts..."
aria-label="Search conversations"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className={cn('h-7 text-[13px] bg-background/50', searchQuery ? 'pr-8' : 'pr-3')}
/>
{searchQuery && (
<button
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded"
onClick={() => setSearchQuery('')}
title="Clear search"
aria-label="Clear search"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<div className="px-3 py-2 border-b border-border">
<Button
variant="ghost"
variant="outline"
size="sm"
onClick={onNewMessage}
title="New Message"
aria-label="New message"
className="h-7 w-7 shrink-0 p-0 text-muted-foreground hover:text-foreground transition-colors"
title="Add channel or contact"
aria-label="Add channel or contact"
className="h-8 w-full justify-start gap-2 px-3 text-[13px]"
>
<SquarePen className="h-4 w-4" />
<span>Add Channel/Contact</span>
</Button>
</div>
{/* List */}
<div className="flex-1 min-h-0 overflow-y-auto [contain:layout_paint]">
<div className="px-3 py-2 border-b border-border/60">
<div className="relative min-w-0">
<Input
type="text"
placeholder="Search channels/contacts..."
aria-label="Search conversations"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className={cn('h-7 text-[13px] bg-background/50', searchQuery ? 'pr-8' : 'pr-3')}
/>
{searchQuery && (
<button
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded"
onClick={() => setSearchQuery('')}
title="Clear search"
aria-label="Clear search"
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
{/* Tools */}
{toolRows.length > 0 && (
<>
+721
View File
@@ -0,0 +1,721 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { ArrowDown, ArrowUp, Plus, X } from 'lucide-react';
import type {
Contact,
RadioConfig,
RadioTraceHopRequest,
RadioTraceNode,
RadioTraceResponse,
} from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
import { calculateDistance, isValidLocation } from '../utils/pathUtils';
import { getContactDisplayName } from '../utils/pubkey';
import { handleKeyboardActivate } from '../utils/a11y';
import { ContactAvatar } from './ContactAvatar';
import { Button } from './ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Input } from './ui/input';
import { cn } from '@/lib/utils';
type TraceSortMode = 'alpha' | 'recent' | 'distance';
type CustomHopBytes = 1 | 2 | 4;
type TraceDraftHop =
| { id: string; kind: 'repeater'; publicKey: string }
| { id: string; kind: 'custom'; hopHex: string; hopBytes: CustomHopBytes };
interface TracePaneProps {
contacts: Contact[];
config: RadioConfig | null;
onRunTracePath: (
hopHashBytes: CustomHopBytes,
hops: RadioTraceHopRequest[]
) => Promise<RadioTraceResponse>;
}
function getHeardTimestamp(contact: Contact): number {
return Math.max(contact.last_seen ?? 0, contact.last_advert ?? 0);
}
function getDistanceKm(contact: Contact, config: RadioConfig | null): number | null {
if (
!config ||
!isValidLocation(config.lat, config.lon) ||
!isValidLocation(contact.lat, contact.lon)
) {
return null;
}
return calculateDistance(config.lat, config.lon, contact.lat, contact.lon);
}
function getShortKey(publicKey: string | null | undefined): string {
if (!publicKey) return 'unknown';
return publicKey.slice(0, 12);
}
function formatSNR(snr: number | null | undefined): string {
if (typeof snr !== 'number' || Number.isNaN(snr)) {
return '—';
}
return `${snr >= 0 ? '+' : ''}${snr.toFixed(1)} dB`;
}
function moveHop(hops: TraceDraftHop[], index: number, direction: -1 | 1): TraceDraftHop[] {
const nextIndex = index + direction;
if (nextIndex < 0 || nextIndex >= hops.length) {
return hops;
}
const next = [...hops];
const [item] = next.splice(index, 1);
next.splice(nextIndex, 0, item);
return next;
}
function normalizeCustomHopHex(value: string): string {
return value.replace(/[^a-fA-F0-9]/g, '').toLowerCase();
}
function nextDraftHopId(prefix: string, currentLength: number): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return `${prefix}-${crypto.randomUUID()}`;
}
return `${prefix}-${Date.now()}-${currentLength}`;
}
function TraceNodeRow({
title,
subtitle,
meta,
note,
fixed = false,
compact = false,
actions,
snr,
}: {
title: string;
subtitle: string;
meta?: string | null;
note?: string | null;
fixed?: boolean;
compact?: boolean;
actions?: ReactNode;
snr?: string | null;
}) {
return (
<div
className={cn(
'flex items-center rounded-md border border-border bg-background',
compact ? 'gap-2 px-2.5 py-2' : 'gap-3 px-3 py-3'
)}
>
<div
className={cn(
'flex h-9 w-9 items-center justify-center rounded-full border text-[11px] font-semibold uppercase tracking-wide',
fixed
? 'border-primary/30 bg-primary/10 text-primary'
: 'border-border bg-muted text-muted-foreground'
)}
>
{fixed ? 'Self' : 'Hop'}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{title}</div>
<div className="truncate text-xs text-muted-foreground">{subtitle}</div>
{meta ? <div className="mt-1 text-[11px] text-muted-foreground">{meta}</div> : null}
{note ? <div className="mt-1 text-[11px] text-muted-foreground">{note}</div> : null}
</div>
{snr ? (
<div className="shrink-0 text-right">
<div className="text-[11px] text-muted-foreground">SNR</div>
<div className="font-mono text-sm">{snr}</div>
</div>
) : null}
{actions ? <div className="ml-1 flex items-center gap-1">{actions}</div> : null}
</div>
);
}
export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps) {
const [searchQuery, setSearchQuery] = useState('');
const [sortMode, setSortMode] = useState<TraceSortMode>('alpha');
const [draftHops, setDraftHops] = useState<TraceDraftHop[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<RadioTraceResponse | null>(null);
const [customDialogOpen, setCustomDialogOpen] = useState(false);
const [customHopBytesDraft, setCustomHopBytesDraft] = useState<CustomHopBytes>(1);
const [customHopHexDraft, setCustomHopHexDraft] = useState('');
const [customHopError, setCustomHopError] = useState<string | null>(null);
const activeRunTokenRef = useRef(0);
const repeaters = useMemo(() => {
const deduped = new Map<string, Contact>();
for (const contact of contacts) {
if (contact.type !== CONTACT_TYPE_REPEATER || contact.public_key.length !== 64) {
continue;
}
if (!deduped.has(contact.public_key)) {
deduped.set(contact.public_key, contact);
}
}
return [...deduped.values()];
}, [contacts]);
const repeatersByKey = useMemo(
() => new Map(repeaters.map((contact) => [contact.public_key, contact])),
[repeaters]
);
const filteredRepeaters = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
const matching = query
? repeaters.filter(
(contact) =>
contact.public_key.toLowerCase().includes(query) ||
(contact.name ?? '').toLowerCase().includes(query)
)
: repeaters;
return [...matching].sort((left, right) => {
if (sortMode === 'recent') {
const leftTs = getHeardTimestamp(left);
const rightTs = getHeardTimestamp(right);
if (leftTs !== rightTs) {
return rightTs - leftTs;
}
}
if (sortMode === 'distance') {
const leftDistance = getDistanceKm(left, config);
const rightDistance = getDistanceKm(right, config);
if (leftDistance !== null && rightDistance !== null && leftDistance !== rightDistance) {
return leftDistance - rightDistance;
}
if (leftDistance !== null && rightDistance === null) return -1;
if (leftDistance === null && rightDistance !== null) return 1;
}
return getContactDisplayName(left.name, left.public_key, left.last_advert).localeCompare(
getContactDisplayName(right.name, right.public_key, right.last_advert)
);
});
}, [config, repeaters, searchQuery, sortMode]);
const localRadioName = config?.name || 'Local radio';
const localRadioKey = config?.public_key ?? null;
const canSortByDistance = !!config && isValidLocation(config.lat, config.lon);
const customHopBytesLocked = useMemo(
() => draftHops.find((hop) => hop.kind === 'custom')?.hopBytes ?? null,
[draftHops]
);
const effectiveHopHashBytes: CustomHopBytes = customHopBytesLocked ?? 4;
useEffect(() => {
if (!customDialogOpen) return;
setCustomHopBytesDraft(customHopBytesLocked ?? 1);
setCustomHopHexDraft('');
setCustomHopError(null);
}, [customDialogOpen, customHopBytesLocked]);
const clearPendingResult = () => {
activeRunTokenRef.current += 1;
setLoading(false);
if (result) setResult(null);
if (error) setError(null);
};
const handleAddRepeater = (publicKey: string) => {
setDraftHops((current) => [
...current,
{
id: nextDraftHopId('repeater', current.length),
kind: 'repeater',
publicKey,
},
]);
clearPendingResult();
};
const handleAddCustomHop = () => {
const hopBytes = customHopBytesLocked ?? customHopBytesDraft;
const hopHex = normalizeCustomHopHex(customHopHexDraft);
if (hopHex.length !== hopBytes * 2) {
setCustomHopError(`Custom hop must be exactly ${hopBytes * 2} hex characters.`);
return;
}
setDraftHops((current) => [
...current,
{
id: nextDraftHopId('custom', current.length),
kind: 'custom',
hopHex,
hopBytes,
},
]);
clearPendingResult();
setCustomDialogOpen(false);
};
const handleRemoveHop = (id: string) => {
setDraftHops((current) => current.filter((hop) => hop.id !== id));
clearPendingResult();
};
const handleMoveHop = (index: number, direction: -1 | 1) => {
setDraftHops((current) => moveHop(current, index, direction));
clearPendingResult();
};
const handleRunTrace = async () => {
if (draftHops.length === 0) {
return;
}
const runToken = activeRunTokenRef.current + 1;
activeRunTokenRef.current = runToken;
setLoading(true);
setError(null);
setResult(null);
try {
const traceResult = await onRunTracePath(
effectiveHopHashBytes,
draftHops.map((hop) =>
hop.kind === 'repeater' ? { public_key: hop.publicKey } : { hop_hex: hop.hopHex }
)
);
if (activeRunTokenRef.current !== runToken) {
return;
}
setResult(traceResult);
} catch (err) {
if (activeRunTokenRef.current !== runToken) {
return;
}
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
if (activeRunTokenRef.current === runToken) {
setLoading(false);
}
}
};
const resultNodes: RadioTraceNode[] = result
? [
{
role: 'local',
public_key: localRadioKey,
name: localRadioName,
observed_hash: null,
snr: null,
},
...result.nodes,
]
: [];
return (
<div className="flex h-full min-h-0 flex-col overflow-y-auto lg:overflow-hidden">
<div className="shrink-0 border-b border-border px-4 py-3">
<h2 className="text-base font-semibold">Trace</h2>
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
Build a repeater loop and trace it back to the local radio. The selectable hop list only
includes known full-key repeaters, but you can also add custom repeater prefixes.
</p>
</div>
<div className="flex flex-1 flex-col gap-4 p-4 lg:min-h-0 lg:flex-row lg:overflow-hidden">
<section className="flex w-full flex-col rounded-lg border border-border bg-card lg:min-h-0 lg:max-w-[24rem]">
<div className="shrink-0 border-b border-border p-4">
<h3 className="text-sm font-semibold">Repeater Hops</h3>
<p className="mt-1 text-xs text-muted-foreground">
Search by name or key, then add repeaters in the order you want to traverse them.
</p>
<Button
type="button"
size="sm"
variant="outline"
className="mt-3"
onClick={() => setCustomDialogOpen(true)}
>
Custom path
</Button>
<Input
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="Search name or public key"
aria-label="Search repeaters"
className="mt-3"
/>
<div className="mt-3 flex flex-wrap gap-2">
{(
[
['alpha', 'Alpha'],
['recent', 'Recent Heard'],
['distance', 'Distance'],
] as const
).map(([value, label]) => (
<Button
key={value}
type="button"
size="sm"
variant={sortMode === value ? 'default' : 'outline'}
onClick={() => setSortMode(value)}
>
{label}
</Button>
))}
</div>
{sortMode === 'distance' && !canSortByDistance ? (
<p className="mt-2 text-[11px] text-muted-foreground">
Distance sorting is using known repeater coordinates, but the local radio does not
currently have a valid location.
</p>
) : null}
</div>
<div className="max-h-[40vh] overflow-y-auto p-2 lg:min-h-0 lg:max-h-none lg:flex-1">
{filteredRepeaters.length === 0 ? (
<div className="rounded-md border border-dashed border-border px-3 py-6 text-center text-sm text-muted-foreground">
No repeaters matched this search.
</div>
) : (
<div className="space-y-2">
{filteredRepeaters.map((contact) => {
const displayName = getContactDisplayName(
contact.name,
contact.public_key,
contact.last_advert
);
const distanceKm = getDistanceKm(contact, config);
const selectedCount = draftHops.filter(
(hop) => hop.kind === 'repeater' && hop.publicKey === contact.public_key
).length;
return (
<div
key={contact.public_key}
role="button"
tabIndex={0}
aria-label={`Add repeater ${displayName}`}
className={cn(
'flex w-full items-center gap-3 rounded-md border px-3 py-3 text-left transition-colors',
selectedCount > 0
? 'border-primary/30 bg-primary/5'
: 'border-border bg-background hover:bg-accent'
)}
onClick={() => handleAddRepeater(contact.public_key)}
onKeyDown={handleKeyboardActivate}
>
<ContactAvatar
name={contact.name}
publicKey={contact.public_key}
size={28}
contactType={contact.type}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{displayName}</div>
<div className="truncate text-xs text-muted-foreground">
{getShortKey(contact.public_key)}
</div>
{sortMode === 'distance' && distanceKm !== null ? (
<div className="mt-1 text-[11px] text-muted-foreground">
{distanceKm.toFixed(1)} km away
</div>
) : null}
{selectedCount > 0 ? (
<div className="mt-1 text-[11px] text-muted-foreground">
Added {selectedCount} time{selectedCount === 1 ? '' : 's'}
</div>
) : null}
</div>
<span
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-input bg-background text-muted-foreground"
aria-hidden="true"
>
<Plus className="h-4 w-4" />
</span>
</div>
);
})}
</div>
)}
</div>
</section>
<section className="flex flex-1 flex-col gap-4 lg:min-h-0 lg:overflow-hidden">
<div className="flex flex-col rounded-lg border border-border bg-card lg:min-h-0 lg:max-h-[50%]">
<div className="shrink-0 flex items-start justify-between gap-3 border-b border-border px-4 py-3">
<div>
<h3 className="text-sm font-semibold">Trace Path</h3>
<p className="mt-1 text-xs text-muted-foreground">
The first node is display-only. The terminal node is the local radio.
</p>
</div>
{draftHops.length > 0 ? (
<Button
type="button"
size="sm"
variant="ghost"
className="shrink-0 text-muted-foreground"
onClick={() => {
setDraftHops([]);
clearPendingResult();
}}
>
Clear
</Button>
) : null}
</div>
<div className="space-y-2 p-4 lg:min-h-0 lg:flex-1 lg:overflow-y-auto">
<TraceNodeRow
title={localRadioName}
subtitle={getShortKey(localRadioKey)}
meta="Origin"
fixed
compact
/>
{draftHops.length === 0 ? (
<div className="rounded-md border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
Add at least one hop to build a trace loop.
</div>
) : (
draftHops.map((hop, index) => {
const contact =
hop.kind === 'repeater' ? (repeatersByKey.get(hop.publicKey) ?? null) : null;
const displayName =
hop.kind === 'repeater'
? getContactDisplayName(
contact?.name,
hop.publicKey,
contact?.last_advert ?? null
)
: 'Custom hop';
const subtitle =
hop.kind === 'repeater'
? getShortKey(hop.publicKey)
: `${hop.hopHex.toUpperCase()} (${hop.hopBytes}-byte)`;
return (
<div key={hop.id}>
<TraceNodeRow
title={displayName}
subtitle={subtitle}
meta={`Hop ${index + 1}`}
note={
index === draftHops.length - 1
? 'Note: you must be able to hear the final repeater in the trace for trace success.'
: null
}
compact
actions={
<>
<Button
type="button"
size="icon"
variant="outline"
className="h-8 w-8"
aria-label={`Move ${displayName} up`}
onClick={() => handleMoveHop(index, -1)}
disabled={index === 0}
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
type="button"
size="icon"
variant="outline"
className="h-8 w-8"
aria-label={`Move ${displayName} down`}
onClick={() => handleMoveHop(index, 1)}
disabled={index === draftHops.length - 1}
>
<ArrowDown className="h-4 w-4" />
</Button>
<Button
type="button"
size="icon"
variant="outline"
className="h-8 w-8"
aria-label={`Remove ${displayName}`}
onClick={() => handleRemoveHop(hop.id)}
>
<X className="h-4 w-4" />
</Button>
</>
}
/>
</div>
);
})
)}
<TraceNodeRow
title={localRadioName}
subtitle={getShortKey(localRadioKey)}
meta="Terminal"
fixed
compact
/>
</div>
<div className="shrink-0 flex flex-wrap items-center justify-between gap-3 border-t border-border px-4 py-3">
<div className="text-xs text-muted-foreground">
{draftHops.length === 0
? 'No hops selected'
: `${draftHops.length} hop${draftHops.length === 1 ? '' : 's'} selected · ${effectiveHopHashBytes}-byte trace`}
</div>
<Button onClick={handleRunTrace} disabled={loading || draftHops.length === 0}>
{loading ? 'Tracing...' : 'Send trace'}
</Button>
</div>
</div>
<div className="flex flex-col rounded-lg border border-border bg-card lg:min-h-0 lg:flex-1">
<div className="shrink-0 flex items-center justify-between gap-3 border-b border-border px-4 py-3">
<h3 className="text-sm font-semibold">
Results{result ? ` (${result.timeout_seconds.toFixed(1)}s)` : ''}
</h3>
{result || error ? (
<Button
type="button"
size="sm"
variant="ghost"
className="shrink-0 text-muted-foreground"
onClick={() => {
setResult(null);
setError(null);
}}
>
Clear
</Button>
) : null}
</div>
<div className="min-h-0 flex-1 space-y-3 p-4 lg:overflow-y-auto">
{error ? (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
) : null}
{!error && !result ? (
<div className="rounded-md border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
Send a trace to see the returned hop-by-hop SNR values.
</div>
) : null}
{result
? resultNodes.map((node, index) => {
const title =
node.name ||
(node.role === 'custom'
? 'Custom hop'
: node.role === 'local'
? localRadioName
: getShortKey(node.public_key));
const subtitle =
node.role === 'custom'
? `Key prefix ${node.observed_hash?.toUpperCase() ?? 'unknown'}`
: node.observed_hash &&
node.public_key &&
node.observed_hash.toLowerCase() !==
getShortKey(node.public_key).toLowerCase()
? `${getShortKey(node.public_key)} · key prefix ${node.observed_hash.toUpperCase()}`
: getShortKey(node.public_key);
return (
<div
key={`${node.role}-${node.public_key ?? node.observed_hash ?? 'local'}-${index}`}
>
<TraceNodeRow
title={title}
subtitle={subtitle}
meta={
index === 0
? 'Origin'
: node.role === 'local'
? 'Terminal'
: `Hop ${index}`
}
fixed={node.role === 'local'}
snr={index === 0 ? null : formatSNR(node.snr)}
/>
</div>
);
})
: null}
</div>
</div>
</section>
</div>
<Dialog open={customDialogOpen} onOpenChange={setCustomDialogOpen}>
<DialogContent className="sm:max-w-[440px]">
<DialogHeader>
<DialogTitle>Custom path hop</DialogTitle>
<DialogDescription>
Add a raw repeater prefix as a 1-byte, 2-byte, or 4-byte hop. Once you add a custom
hop, all later custom hops must use the same byte width.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Hop width</div>
<div className="flex flex-wrap gap-2">
{([1, 2, 4] as const).map((value) => {
const locked = customHopBytesLocked !== null && customHopBytesLocked !== value;
const active = (customHopBytesLocked ?? customHopBytesDraft) === value;
return (
<Button
key={value}
type="button"
size="sm"
variant={active ? 'default' : 'outline'}
disabled={locked}
onClick={() => setCustomHopBytesDraft(value)}
>
{value}-byte
</Button>
);
})}
</div>
{customHopBytesLocked !== null ? (
<p className="text-xs text-muted-foreground">
Custom hops are locked to {customHopBytesLocked}-byte prefixes for this trace.
</p>
) : null}
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="custom-hop-hex">
Repeater prefix
</label>
<Input
id="custom-hop-hex"
value={customHopHexDraft}
onChange={(event) =>
setCustomHopHexDraft(normalizeCustomHopHex(event.target.value))
}
placeholder={`${(customHopBytesLocked ?? customHopBytesDraft) * 2} hex chars`}
/>
<p className="text-xs text-muted-foreground">
Enter exactly {(customHopBytesLocked ?? customHopBytesDraft) * 2} hex characters.
</p>
{customHopError ? (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{customHopError}
</div>
) : null}
</div>
</div>
<DialogFooter className="gap-2 sm:justify-between">
<Button type="button" variant="secondary" onClick={() => setCustomDialogOpen(false)}>
Cancel
</Button>
<Button type="button" onClick={handleAddCustomHop}>
Add custom hop
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -316,6 +316,80 @@ const CREATE_INTEGRATION_DEFINITIONS_BY_VALUE = Object.fromEntries(
CREATE_INTEGRATION_DEFINITIONS.map((definition) => [definition.value, definition])
) as Record<DraftType, CreateIntegrationDefinition>;
function getNumberInputValue(value: unknown, fallback: number): string | number {
if (value === '') return '';
if (typeof value === 'string') return value;
if (typeof value === 'number' && Number.isFinite(value)) return value;
return fallback;
}
function getOptionalNumberInputValue(value: unknown): string | number {
if (value === '') return '';
if (typeof value === 'string') return value;
if (typeof value === 'number' && Number.isFinite(value)) return value;
return '';
}
function parseIntegerInputValue(value: string): number | string {
if (value === '') return '';
const parsed = Number.parseInt(value, 10);
return Number.isNaN(parsed) ? value : parsed;
}
function parseFloatInputValue(value: string): number | string {
if (value === '') return '';
const parsed = Number.parseFloat(value);
return Number.isNaN(parsed) ? value : parsed;
}
function normalizeIntegrationConfigForSave(
configType: string,
config: Record<string, unknown>
): Record<string, unknown> {
const normalized = { ...config };
if (configType === 'mqtt_private') {
const port = normalized.broker_port;
if (port === '' || port === undefined || port === null) {
normalized.broker_port = 1883;
} else if (typeof port === 'string') {
const parsed = Number.parseInt(port, 10);
normalized.broker_port = Number.isNaN(parsed) ? 1883 : parsed;
}
const topicPrefix = String(normalized.topic_prefix ?? '').trim();
normalized.topic_prefix = topicPrefix || 'meshcore';
}
if (configType === 'mqtt_community') {
const brokerHost = String(normalized.broker_host ?? '').trim();
normalized.broker_host = brokerHost || DEFAULT_COMMUNITY_BROKER_HOST;
const port = normalized.broker_port;
if (port === '' || port === undefined || port === null) {
normalized.broker_port = DEFAULT_COMMUNITY_BROKER_PORT;
} else if (typeof port === 'string') {
const parsed = Number.parseInt(port, 10);
normalized.broker_port = Number.isNaN(parsed) ? DEFAULT_COMMUNITY_BROKER_PORT : parsed;
}
const topicTemplate = String(normalized.topic_template ?? '').trim();
normalized.topic_template = topicTemplate || DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE;
}
if (configType === 'map_upload') {
const radius = normalized.geofence_radius_km;
if (radius === '' || radius === undefined || radius === null) {
normalized.geofence_radius_km = 0;
} else if (typeof radius === 'string') {
const parsed = Number.parseFloat(radius);
normalized.geofence_radius_km = Number.isNaN(parsed) ? 0 : parsed;
}
}
return normalized;
}
function isDraftType(value: string): value is DraftType {
return value in CREATE_INTEGRATION_DEFINITIONS_BY_VALUE;
}
@@ -338,7 +412,7 @@ function normalizeDraftConfig(draftType: DraftType, config: Record<string, unkno
throw new Error('MeshRank packet topic is required');
}
return {
return normalizeIntegrationConfigForSave('mqtt_community', {
...config,
broker_host: DEFAULT_MESHRANK_BROKER_HOST,
broker_port: DEFAULT_MESHRANK_BROKER_PORT,
@@ -352,7 +426,7 @@ function normalizeDraftConfig(draftType: DraftType, config: Record<string, unkno
topic_template: topicTemplate,
username: '',
password: '',
};
});
}
if (draftType === 'mqtt_community_letsmesh_us' || draftType === 'mqtt_community_letsmesh_eu') {
@@ -360,7 +434,7 @@ function normalizeDraftConfig(draftType: DraftType, config: Record<string, unkno
draftType === 'mqtt_community_letsmesh_eu'
? DEFAULT_COMMUNITY_BROKER_HOST_EU
: DEFAULT_COMMUNITY_BROKER_HOST;
return {
return normalizeIntegrationConfigForSave('mqtt_community', {
...config,
broker_host: brokerHost,
broker_port: DEFAULT_COMMUNITY_BROKER_PORT,
@@ -372,10 +446,13 @@ function normalizeDraftConfig(draftType: DraftType, config: Record<string, unkno
topic_template: (config.topic_template as string) || DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE,
username: '',
password: '',
};
});
}
return config;
return normalizeIntegrationConfigForSave(
getCreateIntegrationDefinition(draftType).savedType,
config
);
}
function normalizeDraftScope(draftType: DraftType, scope: Record<string, unknown>) {
@@ -649,9 +726,9 @@ function MqttPrivateConfigEditor({
type="number"
min="1"
max="65535"
value={(config.broker_port as number) || 1883}
value={getNumberInputValue(config.broker_port, 1883)}
onChange={(e) =>
onChange({ ...config, broker_port: parseInt(e.target.value, 10) || 1883 })
onChange({ ...config, broker_port: parseIntegerInputValue(e.target.value) })
}
/>
</div>
@@ -709,7 +786,8 @@ function MqttPrivateConfigEditor({
<Input
id="fanout-mqtt-prefix"
type="text"
value={(config.topic_prefix as string) || 'meshcore'}
placeholder="meshcore"
value={(config.topic_prefix as string | undefined) ?? ''}
onChange={(e) => onChange({ ...config, topic_prefix: e.target.value })}
/>
</div>
@@ -745,7 +823,7 @@ function MqttCommunityConfigEditor({
id="fanout-comm-host"
type="text"
placeholder={DEFAULT_COMMUNITY_BROKER_HOST}
value={(config.broker_host as string) || DEFAULT_COMMUNITY_BROKER_HOST}
value={(config.broker_host as string | undefined) ?? ''}
onChange={(e) => onChange({ ...config, broker_host: e.target.value })}
/>
</div>
@@ -756,11 +834,11 @@ function MqttCommunityConfigEditor({
type="number"
min="1"
max="65535"
value={(config.broker_port as number) || DEFAULT_COMMUNITY_BROKER_PORT}
value={getNumberInputValue(config.broker_port, DEFAULT_COMMUNITY_BROKER_PORT)}
onChange={(e) =>
onChange({
...config,
broker_port: parseInt(e.target.value, 10) || DEFAULT_COMMUNITY_BROKER_PORT,
broker_port: parseIntegerInputValue(e.target.value),
})
}
/>
@@ -895,7 +973,8 @@ function MqttCommunityConfigEditor({
<Input
id="fanout-comm-topic-template"
type="text"
value={(config.topic_template as string) || DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE}
placeholder={DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE}
value={(config.topic_template as string | undefined) ?? ''}
onChange={(e) => onChange({ ...config, topic_template: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
@@ -1215,11 +1294,11 @@ function MapUploadConfigEditor({
min="0"
step="any"
placeholder="e.g. 100"
value={(config.geofence_radius_km as number | undefined) ?? ''}
value={getOptionalNumberInputValue(config.geofence_radius_km)}
onChange={(e) =>
onChange({
...config,
geofence_radius_km: e.target.value === '' ? 0 : parseFloat(e.target.value),
geofence_radius_km: parseFloatInputValue(e.target.value),
})
}
/>
@@ -1997,9 +2076,10 @@ export function SettingsFanoutSection({
if (!currentEditingId) {
throw new Error('Missing fanout config id for update');
}
const editingType = configs.find((cfg) => cfg.id === currentEditingId)?.type ?? '';
const update: Record<string, unknown> = {
name: editName,
config: editConfig,
config: normalizeIntegrationConfigForSave(editingType, editConfig),
scope: editScope,
};
if (enabled !== undefined) update.enabled = enabled;
@@ -17,6 +17,14 @@ import {
setSavedDistanceUnit,
} from '../../utils/distanceUnits';
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
import {
DEFAULT_FONT_SCALE,
FONT_SCALE_SLIDER_STEP,
MAX_FONT_SCALE,
MIN_FONT_SCALE,
getSavedFontScale,
setSavedFontScale,
} from '../../utils/fontScale';
export function SettingsLocalSection({
onLocalLabelChange,
@@ -31,6 +39,29 @@ export function SettingsLocalSection({
);
const [localLabelText, setLocalLabelText] = useState(() => getLocalLabel().text);
const [localLabelColor, setLocalLabelColor] = useState(() => getLocalLabel().color);
const [fontScale, setFontScale] = useState(getSavedFontScale);
const [fontScaleSlider, setFontScaleSlider] = useState(getSavedFontScale);
const [fontScaleInput, setFontScaleInput] = useState(() => String(getSavedFontScale()));
const commitFontScale = (nextScale: number) => {
const normalized = setSavedFontScale(nextScale);
setFontScale(normalized);
setFontScaleSlider(normalized);
setFontScaleInput(String(normalized));
};
const restoreFontScaleInput = () => {
setFontScaleInput(String(fontScale));
};
const handleSliderChange = (nextScale: number) => {
setFontScaleSlider(nextScale);
setFontScaleInput(String(nextScale));
};
const handleSliderCommit = (nextScale: number) => {
commitFontScale(nextScale);
};
const handleToggleReopenLastConversation = (enabled: boolean) => {
setReopenLastConversation(enabled);
@@ -89,6 +120,85 @@ export function SettingsLocalSection({
<Separator />
<div className="space-y-3">
<Label htmlFor="font-scale-input">Relative Font Size</Label>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<input
type="range"
min={MIN_FONT_SCALE}
max={MAX_FONT_SCALE}
step={FONT_SCALE_SLIDER_STEP}
value={fontScaleSlider}
onChange={(event) => handleSliderChange(Number(event.target.value))}
onMouseUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
onTouchEnd={(event) => handleSliderCommit(Number(event.currentTarget.value))}
onKeyUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
onBlur={(event) => handleSliderCommit(Number(event.currentTarget.value))}
aria-label="Relative font size slider"
className="w-full accent-primary sm:flex-1"
/>
<div className="flex items-center gap-2 sm:w-40">
<Input
id="font-scale-input"
type="number"
inputMode="decimal"
min={MIN_FONT_SCALE}
max={MAX_FONT_SCALE}
step="any"
value={fontScaleInput}
onChange={(event) => {
const nextValue = event.target.value;
setFontScaleInput(nextValue);
if (nextValue === '') {
return;
}
if (event.target.validity.valid && Number.isFinite(event.target.valueAsNumber)) {
commitFontScale(event.target.valueAsNumber);
}
}}
onBlur={() => {
const parsed = Number.parseFloat(fontScaleInput);
if (!Number.isFinite(parsed)) {
restoreFontScaleInput();
return;
}
commitFontScale(parsed);
}}
onKeyDown={(event) => {
if (event.key !== 'Enter') {
return;
}
event.preventDefault();
const parsed = Number.parseFloat(fontScaleInput);
if (!Number.isFinite(parsed)) {
restoreFontScaleInput();
return;
}
commitFontScale(parsed);
}}
aria-label="Relative font size percentage"
/>
<span className="text-sm text-muted-foreground">%</span>
</div>
<button
type="button"
onClick={() => commitFontScale(DEFAULT_FONT_SCALE)}
className="inline-flex h-9 items-center justify-center rounded-md border border-input px-3 text-sm font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
disabled={fontScale === DEFAULT_FONT_SCALE}
>
Reset
</button>
</div>
<p className="text-xs text-muted-foreground">
Scales the app&apos;s typography for this browser only. The slider moves in 5% steps; the
number field accepts any value from 25% to 400%.
</p>
</div>
<Separator />
<div className="space-y-3">
<Label htmlFor="distance-units">Distance Units</Label>
<select
@@ -846,11 +846,16 @@ export function SettingsRadioSection({
className="rounded-md border border-input bg-background px-3 py-2"
>
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium capitalize">{result.node_type}</span>
<span className="text-sm font-medium">
{result.name ?? <span className="capitalize">{result.node_type}</span>}
</span>
<span className="text-xs text-muted-foreground">
heard {result.heard_count} time{result.heard_count === 1 ? '' : 's'}
</span>
</div>
{result.name && (
<p className="text-xs capitalize text-muted-foreground">{result.node_type}</p>
)}
<p className="mt-1 break-all font-mono text-xs text-muted-foreground">
{result.public_key}
</p>
@@ -1,4 +1,16 @@
import { useState, useEffect } from 'react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip as RechartsTooltip,
ResponsiveContainer,
AreaChart,
Area,
Cell,
} from 'recharts';
import { Separator } from '../ui/separator';
import { api } from '../../api';
import type { StatisticsResponse } from '../../types';
@@ -7,6 +19,94 @@ function formatPercent(value: number): string {
return `${value.toFixed(1)}%`;
}
const CHANNEL_BAR_COLORS = ['#0ea5e9', '#10b981', '#f59e0b', '#f43f5e', '#8b5cf6'];
const TOOLTIP_STYLE = {
contentStyle: {
backgroundColor: 'hsl(var(--popover))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
fontSize: '11px',
color: 'hsl(var(--popover-foreground))',
},
itemStyle: { color: 'hsl(var(--popover-foreground))' },
labelStyle: { color: 'hsl(var(--muted-foreground))' },
} as const;
function formatTime(ts: number): string {
return new Date(ts * 1000).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
}
function NoiseFloorChart({
samples,
}: {
samples: { timestamp: number; noise_floor_dbm: number }[];
}) {
const data = samples.map((s, i) => ({
idx: i,
time: formatTime(s.timestamp),
noise_floor: s.noise_floor_dbm,
}));
const tickCount = Math.min(6, samples.length);
const tickIndices: number[] = [];
if (samples.length > 1) {
for (let i = 0; i < tickCount; i++) {
tickIndices.push(Math.round((i / (tickCount - 1)) * (samples.length - 1)));
}
}
return (
<ResponsiveContainer width="100%" height={120}>
<AreaChart data={data} margin={{ top: 4, right: 4, bottom: 0, left: -8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
<XAxis
dataKey="idx"
type="number"
domain={[0, samples.length - 1]}
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
ticks={tickIndices}
tickFormatter={(idx) => data[idx]?.time ?? ''}
/>
<YAxis
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
domain={['dataMin - 5', 'dataMax + 5']}
tickFormatter={(v) => `${v}`}
/>
<RechartsTooltip
{...TOOLTIP_STYLE}
cursor={{
stroke: 'hsl(var(--muted-foreground))',
strokeWidth: 1,
strokeDasharray: '3 3',
}}
labelFormatter={(idx) => data[Number(idx)]?.time ?? ''}
formatter={(value) => [`${value} dBm`, 'Noise Floor']}
/>
<Area
type="linear"
dataKey="noise_floor"
stroke="#8b5cf6"
fill="#8b5cf6"
fillOpacity={0.15}
strokeWidth={1.5}
dot={false}
activeDot={{ r: 4, fill: '#8b5cf6', strokeWidth: 2, stroke: 'hsl(var(--popover))' }}
/>
</AreaChart>
</ResponsiveContainer>
);
}
export function SettingsStatisticsSection({ className }: { className?: string }) {
const [stats, setStats] = useState<StatisticsResponse | null>(null);
const [statsLoading, setStatsLoading] = useState(false);
@@ -85,60 +185,6 @@ export function SettingsStatisticsSection({ className }: { className?: string })
<Separator />
{/* Packets */}
<div>
<h4 className="text-sm font-medium mb-2">Packets</h4>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Total stored</span>
<span className="font-medium">{stats.total_packets}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-success">Decrypted</span>
<span className="font-medium text-success">{stats.decrypted_packets}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-warning">Undecrypted</span>
<span className="font-medium text-warning">{stats.undecrypted_packets}</span>
</div>
</div>
</div>
<Separator />
<div>
<h4 className="text-sm font-medium mb-2">Path Hash Width (24h)</h4>
<div className="mb-2 text-xs text-muted-foreground">
Parsed stored raw packets from the last 24 hours:{' '}
{stats.path_hash_width_24h.total_packets}
</div>
<div className="space-y-2">
<div className="flex justify-between items-center text-sm">
<span>1-byte hops</span>
<span className="text-muted-foreground">
{stats.path_hash_width_24h.single_byte} (
{formatPercent(stats.path_hash_width_24h.single_byte_pct)})
</span>
</div>
<div className="flex justify-between items-center text-sm">
<span>2-byte hops</span>
<span className="text-muted-foreground">
{stats.path_hash_width_24h.double_byte} (
{formatPercent(stats.path_hash_width_24h.double_byte_pct)})
</span>
</div>
<div className="flex justify-between items-center text-sm">
<span>3-byte hops</span>
<span className="text-muted-foreground">
{stats.path_hash_width_24h.triple_byte} (
{formatPercent(stats.path_hash_width_24h.triple_byte_pct)})
</span>
</div>
</div>
</div>
<Separator />
{/* Activity */}
<div>
<h4 className="text-sm font-medium mb-2">Activity</h4>
@@ -174,23 +220,172 @@ export function SettingsStatisticsSection({ className }: { className?: string })
</table>
</div>
<Separator />
{/* Packets */}
<div>
<h4 className="text-sm font-medium mb-2">Packets</h4>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Total stored</span>
<span className="font-medium">{stats.total_packets}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-success">Decrypted</span>
<span className="font-medium text-success">{stats.decrypted_packets}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-warning">Undecrypted</span>
<span className="font-medium text-warning">{stats.undecrypted_packets}</span>
</div>
</div>
</div>
<Separator />
{/* Path Hash Width */}
<div>
<h4 className="text-sm font-medium mb-2">Path Hash Width (24h)</h4>
<div className="mb-2 text-xs text-muted-foreground">
Parsed stored raw packets from the last 24 hours:{' '}
{stats.path_hash_width_24h.total_packets}
</div>
{stats.path_hash_width_24h.total_packets > 0 ? (
<ResponsiveContainer width="100%" height={120}>
<BarChart
data={[
{
name: '1-byte',
count: stats.path_hash_width_24h.single_byte,
pct: stats.path_hash_width_24h.single_byte_pct,
},
{
name: '2-byte',
count: stats.path_hash_width_24h.double_byte,
pct: stats.path_hash_width_24h.double_byte_pct,
},
{
name: '3-byte',
count: stats.path_hash_width_24h.triple_byte,
pct: stats.path_hash_width_24h.triple_byte_pct,
},
]}
margin={{ top: 4, right: 4, bottom: 0, left: -16 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(var(--border))"
vertical={false}
/>
<XAxis
dataKey="name"
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
/>
<YAxis
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
allowDecimals={false}
/>
<RechartsTooltip
{...TOOLTIP_STYLE}
cursor={{ fill: 'hsl(var(--muted))', opacity: 0.5 }}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
formatter={(value: any, _: any, props: any) => [
`${Number(value).toLocaleString()} (${formatPercent(props.payload.pct)})`,
'Packets',
]}
/>
<Bar dataKey="count" radius={[4, 4, 0, 0]} maxBarSize={40}>
<Cell fill="#0ea5e9" />
<Cell fill="#10b981" />
<Cell fill="#f59e0b" />
</Bar>
</BarChart>
</ResponsiveContainer>
) : (
<p className="text-sm text-muted-foreground">No path data in the last 24 hours.</p>
)}
</div>
{/* Busiest Channels */}
{stats.busiest_channels_24h.length > 0 && (
<>
<Separator />
<div>
<h4 className="text-sm font-medium mb-2">Busiest Channels (24h)</h4>
<div className="space-y-1">
{stats.busiest_channels_24h.map((ch, i) => (
<div key={ch.channel_key} className="flex justify-between items-center text-sm">
<span>
<span className="text-muted-foreground mr-2">{i + 1}.</span>
{ch.channel_name}
</span>
<span className="text-muted-foreground">{ch.message_count} msgs</span>
</div>
))}
</div>
<ResponsiveContainer
width="100%"
height={stats.busiest_channels_24h.length * 28 + 8}
>
<BarChart
data={stats.busiest_channels_24h.map((ch) => ({
name: ch.channel_name,
messages: ch.message_count,
}))}
layout="vertical"
margin={{ top: 0, right: 4, bottom: 0, left: 0 }}
barCategoryGap="20%"
>
<XAxis type="number" hide />
<YAxis
type="category"
dataKey="name"
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
width={100}
/>
<RechartsTooltip
{...TOOLTIP_STYLE}
cursor={{ fill: 'hsl(var(--muted))', opacity: 0.5 }}
formatter={(value) => [`${Number(value).toLocaleString()} messages`, null]}
/>
<Bar dataKey="messages" radius={[0, 4, 4, 0]} maxBarSize={16}>
{stats.busiest_channels_24h.map((_, i) => (
<Cell key={i} fill={CHANNEL_BAR_COLORS[i % CHANNEL_BAR_COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</>
)}
{/* Noise Floor */}
{stats.noise_floor_24h.supported !== false && (
<>
<Separator />
<div>
<h4 className="text-sm font-medium mb-2">Noise Floor (24h)</h4>
{stats.noise_floor_24h.latest_noise_floor_dbm != null && (
<div className="mb-2 text-xs text-muted-foreground">
Latest reading: {stats.noise_floor_24h.latest_noise_floor_dbm} dBm
{stats.noise_floor_24h.latest_timestamp != null &&
` at ${new Date(
stats.noise_floor_24h.latest_timestamp * 1000
).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}`}
</div>
)}
{stats.noise_floor_24h.samples.length > 1 ? (
<NoiseFloorChart samples={stats.noise_floor_24h.samples} />
) : stats.noise_floor_24h.samples.length === 0 ? (
<p className="text-sm text-muted-foreground">
No noise floor samples collected yet. Samples are collected every five minutes,
and retained until server restart.
</p>
) : (
<p className="text-sm text-muted-foreground">
Only one sample so far ({stats.noise_floor_24h.samples[0].noise_floor_dbm} dBm).
More data needed for a chart. Samples are collected every five minutes, and
retained until server restart.
</p>
)}
</div>
</>
)}
@@ -1,3 +1,4 @@
import { useEffect, useState } from 'react';
import { Checkbox } from '../ui/checkbox';
import { PACKET_LEGEND_ITEMS } from '../../utils/visualizerUtils';
import { NODE_LEGEND_ITEMS } from './shared';
@@ -71,6 +72,19 @@ export function VisualizerControls({
onExpandContract,
onClearAndReset,
}: VisualizerControlsProps) {
const [observationWindowInput, setObservationWindowInput] = useState(
String(observationWindowSec)
);
const [pruneWindowInput, setPruneWindowInput] = useState(String(pruneStaleMinutes));
useEffect(() => {
setObservationWindowInput(String(observationWindowSec));
}, [observationWindowSec]);
useEffect(() => {
setPruneWindowInput(String(pruneStaleMinutes));
}, [pruneStaleMinutes]);
return (
<>
{showControls && (
@@ -212,12 +226,25 @@ export function VisualizerControls({
type="number"
min="1"
max="60"
value={observationWindowSec}
onChange={(e) =>
setObservationWindowSec(
Math.max(1, Math.min(60, parseInt(e.target.value, 10) || 1))
)
}
value={observationWindowInput}
onChange={(e) => {
const nextValue = e.target.value;
setObservationWindowInput(nextValue);
if (nextValue === '') return;
const parsed = Number.parseInt(nextValue, 10);
if (Number.isNaN(parsed)) return;
setObservationWindowSec(Math.max(1, Math.min(60, parsed)));
}}
onBlur={() => {
const parsed = Number.parseInt(observationWindowInput, 10);
const nextValue = Number.isNaN(parsed)
? observationWindowSec
: Math.max(1, Math.min(60, parsed));
setObservationWindowInput(String(nextValue));
if (nextValue !== observationWindowSec) {
setObservationWindowSec(nextValue);
}
}}
className="w-12 px-1 py-0.5 bg-background border border-border rounded text-xs text-center"
/>
<span className="text-muted-foreground">sec</span>
@@ -247,10 +274,25 @@ export function VisualizerControls({
type="number"
min={1}
max={60}
value={pruneStaleMinutes}
value={pruneWindowInput}
onChange={(e) => {
const v = parseInt(e.target.value, 10);
if (!isNaN(v) && v >= 1 && v <= 60) setPruneStaleMinutes(v);
const nextValue = e.target.value;
setPruneWindowInput(nextValue);
if (nextValue === '') return;
const parsed = Number.parseInt(nextValue, 10);
if (Number.isNaN(parsed)) return;
if (parsed >= 1 && parsed <= 60) setPruneStaleMinutes(parsed);
}}
onBlur={() => {
const parsed = Number.parseInt(pruneWindowInput, 10);
const nextValue =
Number.isNaN(parsed) || parsed < 1 || parsed > 60
? pruneStaleMinutes
: parsed;
setPruneWindowInput(String(nextValue));
if (nextValue !== pruneStaleMinutes) {
setPruneStaleMinutes(nextValue);
}
}}
className="w-14 rounded border border-border bg-background px-2 py-0.5 text-sm"
/>
@@ -275,7 +275,9 @@ interface UseConversationMessagesResult {
}
function isMessageConversation(conversation: Conversation | null): conversation is Conversation {
return !!conversation && !['raw', 'map', 'visualizer', 'search'].includes(conversation.type);
return (
!!conversation && !['raw', 'map', 'visualizer', 'search', 'trace'].includes(conversation.type)
);
}
function isActiveConversationMessage(
+23 -9
View File
@@ -62,7 +62,6 @@ export function useConversationRouter({
// Only needs channels (fast path) - doesn't wait for contacts
useEffect(() => {
if (hasSetDefaultConversation.current || activeConversation) return;
if (channels.length === 0) return;
const hashConv = parseHashSettingsSection() ? null : parseHashConversation();
@@ -92,6 +91,29 @@ export function useConversationRouter({
hasSetDefaultConversation.current = true;
return;
}
if (hashConv?.type === 'trace') {
setActiveConversationState({ type: 'trace', id: 'trace', name: 'Trace' });
hasSetDefaultConversation.current = true;
return;
}
// No hash: optionally restore last-viewed non-data conversation if enabled on this device.
if (!hashConv && getReopenLastConversationEnabled()) {
const lastViewed = getLastViewedConversation();
if (
lastViewed &&
(lastViewed.type === 'raw' ||
lastViewed.type === 'map' ||
lastViewed.type === 'visualizer' ||
lastViewed.type === 'trace')
) {
setActiveConversationState(lastViewed);
hasSetDefaultConversation.current = true;
return;
}
}
if (channels.length === 0) return;
// Handle channel hash (ID-first with legacy-name fallback)
if (hashConv?.type === 'channel') {
@@ -109,14 +131,6 @@ export function useConversationRouter({
// No hash: optionally restore last-viewed conversation if enabled on this device.
if (!hashConv && getReopenLastConversationEnabled()) {
const lastViewed = getLastViewedConversation();
if (
lastViewed &&
(lastViewed.type === 'raw' || lastViewed.type === 'map' || lastViewed.type === 'visualizer')
) {
setActiveConversationState(lastViewed);
hasSetDefaultConversation.current = true;
return;
}
if (lastViewed?.type === 'channel') {
const channel =
channels.find((c) => c.key.toLowerCase() === lastViewed.id.toLowerCase()) ||
@@ -43,6 +43,7 @@ interface UseRealtimeAppStateArgs {
hasMention?: boolean;
}) => void;
renameConversationState: (oldStateKey: string, newStateKey: string) => void;
removeConversationState: (stateKey: string) => void;
checkMention: (text: string) => boolean;
pendingDeleteFallbackRef: MutableRefObject<boolean>;
setActiveConversation: (conv: Conversation | null) => void;
@@ -96,6 +97,7 @@ export function useRealtimeAppState({
observeMessage,
recordMessageEvent,
renameConversationState,
removeConversationState,
checkMention,
pendingDeleteFallbackRef,
setActiveConversation,
@@ -232,6 +234,7 @@ export function useRealtimeAppState({
onContactDeleted: (publicKey: string) => {
setContacts((prev) => prev.filter((c) => c.public_key !== publicKey));
removeConversationMessages(publicKey);
removeConversationState(getStateKey('contact', publicKey));
const active = activeConversationRef.current;
if (active?.type === 'contact' && active.id === publicKey) {
pendingDeleteFallbackRef.current = true;
@@ -241,6 +244,7 @@ export function useRealtimeAppState({
onChannelDeleted: (key: string) => {
setChannels((prev) => prev.filter((c) => c.key !== key));
removeConversationMessages(key);
removeConversationState(getStateKey('channel', key));
const active = activeConversationRef.current;
if (active?.type === 'channel' && active.id === key) {
pendingDeleteFallbackRef.current = true;
@@ -267,6 +271,7 @@ export function useRealtimeAppState({
checkMention,
fetchAllContacts,
fetchConfig,
removeConversationState,
renameConversationState,
renameConversationMessages,
maxRawPackets,
+34 -18
View File
@@ -10,6 +10,14 @@ import {
import type { Channel, Contact, Conversation, Message, UnreadCounts } from '../types';
import { takePrefetchOrFetch } from '../prefetch';
type UnreadTrackedConversation = Conversation & { type: 'channel' | 'contact' };
function isUnreadTrackedConversation(
conversation: Conversation | null
): conversation is UnreadTrackedConversation {
return conversation?.type === 'channel' || conversation?.type === 'contact';
}
interface UseUnreadCountsResult {
unreadCounts: Record<string, number>;
/** Tracks which conversations have unread messages that mention the user */
@@ -23,6 +31,7 @@ interface UseUnreadCountsResult {
hasMention?: boolean;
}) => void;
renameConversationState: (oldStateKey: string, newStateKey: string) => void;
removeConversationState: (stateKey: string) => void;
markAllRead: () => void;
refreshUnreads: () => Promise<void>;
}
@@ -47,14 +56,7 @@ export function useUnreadCounts(
// (the user is already viewing it, so its count should stay at 0).
const applyUnreads = useCallback((data: UnreadCounts) => {
const ac = activeConvRef.current;
const activeKey =
ac &&
ac.type !== 'raw' &&
ac.type !== 'map' &&
ac.type !== 'visualizer' &&
ac.type !== 'search'
? getStateKey(ac.type as 'channel' | 'contact', ac.id)
: null;
const activeKey = isUnreadTrackedConversation(ac) ? getStateKey(ac.type, ac.id) : null;
if (activeKey) {
const counts = { ...data.counts };
@@ -122,16 +124,8 @@ export function useUnreadCounts(
// Mark conversation as read when user views it
// Calls server API to persist read state across devices
useEffect(() => {
if (
activeConversation &&
activeConversation.type !== 'raw' &&
activeConversation.type !== 'map' &&
activeConversation.type !== 'visualizer'
) {
const key = getStateKey(
activeConversation.type as 'channel' | 'contact',
activeConversation.id
);
if (isUnreadTrackedConversation(activeConversation)) {
const key = getStateKey(activeConversation.type, activeConversation.id);
// Update local state immediately for responsive UI
setUnreadCounts((prev) => {
@@ -235,6 +229,27 @@ export function useUnreadCounts(
setLastMessageTimes(renameConversationTimeKey(oldStateKey, newStateKey));
}, []);
const removeConversationState = useCallback((stateKey: string) => {
setUnreadCounts((prev) => {
if (!(stateKey in prev)) return prev;
const next = { ...prev };
delete next[stateKey];
return next;
});
setMentions((prev) => {
if (!(stateKey in prev)) return prev;
const next = { ...prev };
delete next[stateKey];
return next;
});
setUnreadLastReadAts((prev) => {
if (!(stateKey in prev)) return prev;
const next = { ...prev };
delete next[stateKey];
return next;
});
}, []);
// Mark all conversations as read
// Calls single bulk API endpoint to persist read state
const markAllRead = useCallback(() => {
@@ -256,6 +271,7 @@ export function useUnreadCounts(
unreadLastReadAts,
recordMessageEvent,
renameConversationState,
removeConversationState,
markAllRead,
refreshUnreads: fetchUnreads,
};
+1 -1
View File
@@ -85,7 +85,7 @@
body {
@apply bg-background text-foreground;
font-family: var(--font-sans);
font-size: 14px;
font-size: 0.875rem;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
+2
View File
@@ -5,9 +5,11 @@ import './index.css';
import './themes.css';
import './styles.css';
import { getSavedTheme, applyTheme } from './utils/theme';
import { applyFontScale, getSavedFontScale } from './utils/fontScale';
// Apply saved theme before first render
applyTheme(getSavedTheme());
applyFontScale(getSavedFontScale());
createRoot(document.getElementById('root')!).render(
<StrictMode>
+47
View File
@@ -195,6 +195,53 @@ describe('App startup hash resolution', () => {
});
});
it('restores the trace tool from the URL hash', async () => {
window.location.hash = '#trace';
render(<App />);
await waitFor(() => {
for (const node of screen.getAllByTestId('active-conversation')) {
expect(node).toHaveTextContent('trace:trace:Trace');
}
});
});
it('restores the trace tool from the URL hash even when channels are unavailable', async () => {
window.location.hash = '#trace';
mocks.api.getChannels.mockResolvedValue([]);
render(<App />);
await waitFor(() => {
for (const node of screen.getAllByTestId('active-conversation')) {
expect(node).toHaveTextContent('trace:trace:Trace');
}
});
});
it('reopens the last viewed trace tool even when channels are unavailable', async () => {
window.location.hash = '';
localStorage.setItem(REOPEN_LAST_CONVERSATION_KEY, '1');
localStorage.setItem(
LAST_VIEWED_CONVERSATION_KEY,
JSON.stringify({
type: 'trace',
id: 'trace',
name: 'Trace',
})
);
mocks.api.getChannels.mockResolvedValue([]);
render(<App />);
await waitFor(() => {
for (const node of screen.getAllByTestId('active-conversation')) {
expect(node).toHaveTextContent('trace:trace:Trace');
}
});
});
it('restores last viewed channel when hash is empty and reopen preference is enabled', async () => {
const chatChannel = {
key: '11111111111111111111111111111111',
+4 -1
View File
@@ -181,7 +181,10 @@ describe('ContactInfoPane', () => {
await screen.findByText('Mystery');
await waitFor(() => {
expect(getContactAnalytics).toHaveBeenCalledWith({ name: 'Mystery' });
expect(getContactAnalytics).toHaveBeenCalledWith(
{ name: 'Mystery' },
expect.any(AbortSignal)
);
expect(screen.getByText('Messages')).toBeInTheDocument();
expect(screen.getByText('Channel Messages')).toBeInTheDocument();
expect(screen.getByText('4', { selector: 'p' })).toBeInTheDocument();
@@ -64,6 +64,10 @@ vi.mock('../components/VisualizerView', () => ({
VisualizerView: () => <div data-testid="visualizer-view" />,
}));
vi.mock('../components/TracePane', () => ({
TracePane: () => <div data-testid="trace-pane" />,
}));
const config: RadioConfig = {
public_key: 'aa'.repeat(32),
name: 'Radio',
@@ -141,6 +145,7 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
loadingNewer: false,
messageInputRef: { current: null },
onTrace: vi.fn(async () => {}),
onRunTracePath: vi.fn(async () => ({ path_len: 0, timeout_seconds: 5, nodes: [] })),
onPathDiscovery: vi.fn(async () => {
throw new Error('unused');
}),
@@ -231,6 +236,23 @@ describe('ConversationPane', () => {
});
});
it('renders the trace tool pane for trace conversations', () => {
render(
<ConversationPane
{...createProps({
activeConversation: {
type: 'trace',
id: 'trace',
name: 'Trace',
},
})}
/>
);
expect(screen.getByTestId('trace-pane')).toBeInTheDocument();
expect(screen.queryByTestId('message-list')).not.toBeInTheDocument();
});
it('gates room chat behind room login controls until authenticated', async () => {
render(
<ConversationPane
+57
View File
@@ -0,0 +1,57 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CrackerPanel } from '../components/CrackerPanel';
vi.mock('meshcore-hashtag-cracker', () => ({
GroupTextCracker: class {
isGpuAvailable() {
return false;
}
destroy() {}
setWordlist() {}
abort() {}
},
}));
vi.mock('nosleep.js', () => ({
default: class {
enable() {}
disable() {}
},
}));
vi.mock('../api', () => ({
api: {
getUndecryptedPacketCount: vi.fn(),
},
}));
vi.mock('../components/ui/sonner', () => ({
toast: {
error: vi.fn(),
},
}));
import { api } from '../api';
const mockedApi = vi.mocked(api);
describe('CrackerPanel', () => {
beforeEach(() => {
vi.clearAllMocks();
mockedApi.getUndecryptedPacketCount.mockResolvedValue({ count: 0 });
});
it('allows clearing max length while editing', async () => {
render(<CrackerPanel packets={[]} channels={[]} onChannelCreate={vi.fn()} visible={false} />);
await waitFor(() => {
expect(mockedApi.getUndecryptedPacketCount).toHaveBeenCalled();
});
const maxLengthInput = screen.getByLabelText('Max Length:') as HTMLInputElement;
fireEvent.change(maxLengthInput, { target: { value: '' } });
expect(maxLengthInput.value).toBe('');
});
});
+179
View File
@@ -704,6 +704,75 @@ describe('SettingsFanoutSection', () => {
expect(audienceInput).toHaveValue('');
});
it('existing community MQTT defaults can be cleared while editing and normalize on save', async () => {
const communityConfig: FanoutConfig = {
id: 'comm-1',
type: 'mqtt_community',
name: 'Community Feed',
enabled: false,
config: {
broker_host: 'mqtt-us-v1.letsmesh.net',
broker_port: 443,
transport: 'websockets',
use_tls: true,
tls_verify: true,
auth_mode: 'token',
iata: 'LAX',
email: '',
token_audience: '',
topic_template: 'meshcore/{IATA}/{PUBLIC_KEY}/packets',
},
scope: { messages: 'none', raw_packets: 'all' },
sort_order: 0,
created_at: 1000,
};
mockedApi.getFanoutConfigs.mockResolvedValue([communityConfig]);
mockedApi.updateFanoutConfig.mockResolvedValue({
...communityConfig,
enabled: true,
});
renderSection();
await waitFor(() => expect(screen.getByText('Community Feed')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
const hostInput = screen.getByLabelText('Broker Host') as HTMLInputElement;
const portInput = screen.getByLabelText('Broker Port') as HTMLInputElement;
const topicTemplateInput = screen.getByLabelText('Packet Topic Template') as HTMLInputElement;
fireEvent.change(hostInput, { target: { value: '' } });
fireEvent.change(portInput, { target: { value: '' } });
fireEvent.change(topicTemplateInput, { target: { value: '' } });
expect(hostInput.value).toBe('');
expect(portInput.value).toBe('');
expect(topicTemplateInput.value).toBe('');
fireEvent.click(screen.getByRole('button', { name: 'Save as Enabled' }));
await waitFor(() =>
expect(mockedApi.updateFanoutConfig).toHaveBeenCalledWith('comm-1', {
name: 'Community Feed',
config: {
broker_host: 'mqtt-us-v1.letsmesh.net',
broker_port: 443,
transport: 'websockets',
use_tls: true,
tls_verify: true,
auth_mode: 'token',
iata: 'LAX',
email: '',
token_audience: '',
topic_template: 'meshcore/{IATA}/{PUBLIC_KEY}/packets',
},
scope: { messages: 'none', raw_packets: 'all' },
enabled: true,
})
);
});
it('community MQTT can be configured for no auth', async () => {
const communityConfig: FanoutConfig = {
id: 'comm-1',
@@ -783,6 +852,65 @@ describe('SettingsFanoutSection', () => {
expect(screen.queryByLabelText('Broker Host')).not.toBeInTheDocument();
});
it('private MQTT fields can be cleared while editing and normalize defaults on create', async () => {
const createdConfig: FanoutConfig = {
id: 'mqtt-private-1',
type: 'mqtt_private',
name: 'Private MQTT 1',
enabled: true,
config: {
broker_host: 'broker.local',
broker_port: 1883,
username: '',
password: '',
use_tls: false,
tls_insecure: false,
topic_prefix: 'meshcore',
},
scope: { messages: 'all', raw_packets: 'all' },
sort_order: 0,
created_at: 2000,
};
mockedApi.createFanoutConfig.mockResolvedValue(createdConfig);
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdConfig]);
renderSection();
await openCreateIntegrationDialog();
selectCreateIntegration('Private MQTT');
confirmCreateIntegration();
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
fireEvent.change(screen.getByLabelText('Broker Host'), { target: { value: 'broker.local' } });
const portInput = screen.getByLabelText('Broker Port') as HTMLInputElement;
const prefixInput = screen.getByLabelText('Topic Prefix') as HTMLInputElement;
fireEvent.change(portInput, { target: { value: '' } });
fireEvent.change(prefixInput, { target: { value: '' } });
expect(portInput.value).toBe('');
expect(prefixInput.value).toBe('');
fireEvent.click(screen.getByRole('button', { name: 'Save as Enabled' }));
await waitFor(() =>
expect(mockedApi.createFanoutConfig).toHaveBeenCalledWith({
type: 'mqtt_private',
name: 'Private MQTT #1',
config: {
broker_host: 'broker.local',
broker_port: 1883,
username: '',
password: '',
use_tls: false,
tls_insecure: false,
topic_prefix: 'meshcore',
},
scope: { messages: 'all', raw_packets: 'all' },
enabled: true,
})
);
});
it('creates MeshRank preset as a regular mqtt_community config', async () => {
const createdConfig: FanoutConfig = {
id: 'comm-meshrank',
@@ -912,6 +1040,57 @@ describe('SettingsFanoutSection', () => {
);
});
it('map upload geofence radius can be cleared while editing and normalizes to zero', async () => {
const createdConfig: FanoutConfig = {
id: 'map-1',
type: 'map_upload',
name: 'Map Upload 1',
enabled: true,
config: {
api_url: '',
dry_run: true,
geofence_enabled: true,
geofence_radius_km: 0,
},
scope: { messages: 'none', raw_packets: 'all' },
sort_order: 0,
created_at: 2000,
};
mockedApi.createFanoutConfig.mockResolvedValue(createdConfig);
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdConfig]);
renderSection();
await openCreateIntegrationDialog();
selectCreateIntegration('Map Upload');
confirmCreateIntegration();
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
fireEvent.click(screen.getByText('Enable Geofence'));
const radiusInput = screen.getByLabelText('Radius (km)') as HTMLInputElement;
fireEvent.change(radiusInput, { target: { value: '100' } });
fireEvent.change(radiusInput, { target: { value: '' } });
expect(radiusInput.value).toBe('');
fireEvent.click(screen.getByRole('button', { name: 'Save as Enabled' }));
await waitFor(() =>
expect(mockedApi.createFanoutConfig).toHaveBeenCalledWith({
type: 'map_upload',
name: 'Map Upload #1',
config: {
api_url: '',
dry_run: true,
geofence_enabled: true,
geofence_radius_km: 0,
},
scope: { messages: 'none', raw_packets: 'all' },
enabled: true,
})
);
});
it('LetsMesh (EU) preset saves the EU broker defaults', async () => {
const createdConfig: FanoutConfig = {
id: 'comm-letsmesh-eu',
+66
View File
@@ -0,0 +1,66 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
applyFontScale,
DEFAULT_FONT_SCALE,
FONT_SCALE_KEY,
MAX_FONT_SCALE,
MIN_FONT_SCALE,
getSavedFontScale,
setSavedFontScale,
} from '../utils/fontScale';
describe('fontScale utilities', () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.style.fontSize = '';
});
afterEach(() => {
document.documentElement.style.fontSize = '';
});
it('defaults to 100% when nothing is saved', () => {
expect(getSavedFontScale()).toBe(DEFAULT_FONT_SCALE);
});
it('reads a saved scale from localStorage', () => {
localStorage.setItem(FONT_SCALE_KEY, '135');
expect(getSavedFontScale()).toBe(135);
});
it('falls back to the default when the saved value is invalid', () => {
localStorage.setItem(FONT_SCALE_KEY, 'giant');
expect(getSavedFontScale()).toBe(DEFAULT_FONT_SCALE);
});
it('applies the scale to the document root', () => {
expect(applyFontScale(150)).toBe(150);
expect(document.documentElement.style.fontSize).toBe('150%');
});
it('stores non-default values and applies them immediately', () => {
expect(setSavedFontScale(137.5)).toBe(137.5);
expect(localStorage.getItem(FONT_SCALE_KEY)).toBe('137.5');
expect(document.documentElement.style.fontSize).toBe('137.5%');
});
it('removes the saved value when returning to the default scale', () => {
localStorage.setItem(FONT_SCALE_KEY, '150');
expect(setSavedFontScale(DEFAULT_FONT_SCALE)).toBe(DEFAULT_FONT_SCALE);
expect(localStorage.getItem(FONT_SCALE_KEY)).toBeNull();
expect(document.documentElement.style.fontSize).toBe('100%');
});
it('clamps saved and applied values to the supported range', () => {
localStorage.setItem(FONT_SCALE_KEY, '900');
expect(getSavedFontScale()).toBe(MAX_FONT_SCALE);
expect(setSavedFontScale(5)).toBe(MIN_FONT_SCALE);
expect(localStorage.getItem(FONT_SCALE_KEY)).toBe(String(MIN_FONT_SCALE));
expect(document.documentElement.style.fontSize).toBe(`${MIN_FONT_SCALE}%`);
});
});
+53
View File
@@ -140,6 +140,59 @@ describe('MessageList channel sender rendering', () => {
expect(screen.getByRole('button', { name: 'View info for Alice' })).toBeInTheDocument();
});
it('renders valid channel references as clickable links and ignores invalid ones', async () => {
const user = userEvent.setup();
const onChannelReferenceClick = vi.fn();
render(
<MessageList
messages={[
createMessage({
text: 'Alice: Join #mesh-room now skip #bad--room and visit https://example.com/#also-skip',
}),
]}
contacts={[]}
loading={false}
onChannelReferenceClick={onChannelReferenceClick}
/>
);
const linkedChannel = screen.getByRole('button', { name: '#mesh-room' });
expect(linkedChannel).toBeInTheDocument();
expect(screen.queryByRole('button', { name: '#bad--room' })).not.toBeInTheDocument();
expect(
screen.getByRole('link', { name: 'https://example.com/#also-skip' })
).toBeInTheDocument();
await user.click(linkedChannel);
expect(onChannelReferenceClick).toHaveBeenCalledWith('#mesh-room');
});
it('links valid channel references in direct messages too', async () => {
const user = userEvent.setup();
const onChannelReferenceClick = vi.fn();
render(
<MessageList
messages={[
createMessage({
type: 'PRIV',
text: 'check #ops-room',
conversation_key: 'ab'.repeat(32),
}),
]}
contacts={[]}
loading={false}
onChannelReferenceClick={onChannelReferenceClick}
/>
);
await user.click(screen.getByRole('button', { name: '#ops-room' }));
expect(onChannelReferenceClick).toHaveBeenCalledWith('#ops-room');
});
it('renders and dismisses an unread marker at the first unread message boundary', async () => {
const user = userEvent.setup();
const messages = [
+36 -1
View File
@@ -6,7 +6,12 @@
*/
import { describe, it, expect } from 'vitest';
import { parseSenderFromText, formatTime } from '../utils/messageParser';
import {
findLinkedChannelReferences,
formatTime,
isValidLinkedChannelName,
parseSenderFromText,
} from '../utils/messageParser';
describe('parseSenderFromText', () => {
it('extracts sender and content from "sender: message" format', () => {
@@ -95,3 +100,33 @@ describe('formatTime', () => {
expect(result).toMatch(/\d{1,2}:\d{2}/); // time portion
});
});
describe('linked channel references', () => {
it('accepts lowercase alphanumeric names with single dashes', () => {
expect(isValidLinkedChannelName('ops')).toBe(true);
expect(isValidLinkedChannelName('ops-1')).toBe(true);
expect(isValidLinkedChannelName('1-2-3')).toBe(true);
});
it('rejects uppercase, leading or trailing dashes, and repeated dashes', () => {
expect(isValidLinkedChannelName('Ops')).toBe(false);
expect(isValidLinkedChannelName('-ops')).toBe(false);
expect(isValidLinkedChannelName('ops-')).toBe(false);
expect(isValidLinkedChannelName('ops--room')).toBe(false);
});
it('finds standalone linked channel references in message text', () => {
expect(findLinkedChannelReferences('Join #mesh-room then say hi in #ops2')).toEqual([
{ label: '#mesh-room', start: 5, end: 15 },
{ label: '#ops2', start: 31, end: 36 },
]);
});
it('ignores invalid or embedded channel-like text', () => {
expect(
findLinkedChannelReferences(
'skip #Bad #bad--name abc#ops #ops- #opsRoom #ops_room #good-room,'
)
).toEqual([]);
});
});
+25 -1
View File
@@ -32,7 +32,10 @@ describe('NewMessageModal form reset', () => {
vi.clearAllMocks();
});
function renderModal(open = true) {
function renderModal(
open = true,
overrides: Partial<Parameters<typeof NewMessageModal>[0]> = {}
) {
return render(
<NewMessageModal
open={open}
@@ -41,6 +44,7 @@ describe('NewMessageModal form reset', () => {
onCreateContact={onCreateContact}
onCreateChannel={onCreateChannel}
onCreateHashtagChannel={onCreateHashtagChannel}
{...overrides}
/>
);
}
@@ -50,6 +54,26 @@ describe('NewMessageModal form reset', () => {
}
describe('hashtag tab', () => {
it('prefills the hashtag tab from a linked channel request', async () => {
renderModal(true, {
prefillRequest: {
tab: 'hashtag',
hashtagName: 'mesh-room',
nonce: 1,
},
});
await waitFor(() => {
expect(screen.getByRole('tab', { name: 'Hashtag Channel' })).toHaveAttribute(
'data-state',
'active'
);
});
expect((screen.getByPlaceholderText('channel-name') as HTMLInputElement).value).toBe(
'mesh-room'
);
});
it('clears name after successful Create', async () => {
const user = userEvent.setup();
const { unmount } = renderModal();
@@ -283,6 +283,8 @@ describe('RawPacketFeedView', () => {
fireEvent.click(screen.getByRole('button', { name: /show stats/i }));
fireEvent.change(screen.getByLabelText('Stats window'), { target: { value: 'session' } });
expect(screen.getAllByText('Alpha').length).toBeGreaterThan(0);
expect(screen.getByText('Strongest Neighbor')).toBeInTheDocument();
expect(screen.getByText('-70 dBm best heard')).toBeInTheDocument();
});
it('marks unresolved neighbor identities explicitly', () => {
+75 -8
View File
@@ -20,6 +20,12 @@ import {
} from '../utils/lastViewedConversation';
import { api } from '../api';
import { DISTANCE_UNIT_KEY } from '../utils/distanceUnits';
import {
DEFAULT_FONT_SCALE,
FONT_SCALE_KEY,
MAX_FONT_SCALE,
MIN_FONT_SCALE,
} from '../utils/fontScale';
const baseConfig: RadioConfig = {
public_key: 'aa'.repeat(32),
@@ -186,6 +192,7 @@ describe('SettingsModal', () => {
vi.restoreAllMocks();
localStorage.clear();
window.location.hash = '';
document.documentElement.style.fontSize = '';
});
it('refreshes app settings when opened', async () => {
@@ -300,6 +307,7 @@ describe('SettingsModal', () => {
results: [
{
public_key: '11'.repeat(32),
name: null,
node_type: 'repeater',
heard_count: 2,
local_snr: 7.5,
@@ -548,6 +556,55 @@ describe('SettingsModal', () => {
expect(localStorage.getItem(DISTANCE_UNIT_KEY)).toBe('smoots');
});
it('defaults relative font size to 100% and exposes the expected input bounds', () => {
renderModal();
openLocalSection();
const slider = screen.getByLabelText('Relative font size slider');
const input = screen.getByLabelText('Relative font size percentage');
expect(slider).toHaveValue(String(DEFAULT_FONT_SCALE));
expect(slider).toHaveAttribute('step', '5');
expect(input).toHaveValue(DEFAULT_FONT_SCALE);
expect(input).toHaveAttribute('min', String(MIN_FONT_SCALE));
expect(input).toHaveAttribute('max', String(MAX_FONT_SCALE));
});
it('stores and applies relative font size changes locally', async () => {
renderModal();
openLocalSection();
const slider = screen.getByLabelText('Relative font size slider');
fireEvent.change(slider, { target: { value: '135' } });
expect(localStorage.getItem(FONT_SCALE_KEY)).toBeNull();
expect(document.documentElement.style.fontSize).toBe('');
fireEvent.mouseUp(slider);
await waitFor(() => {
expect(localStorage.getItem(FONT_SCALE_KEY)).toBe('135');
expect(document.documentElement.style.fontSize).toBe('135%');
});
fireEvent.change(screen.getByLabelText('Relative font size percentage'), {
target: { value: '137.5' },
});
await waitFor(() => {
expect(localStorage.getItem(FONT_SCALE_KEY)).toBe('137.5');
expect(document.documentElement.style.fontSize).toBe('137.5%');
});
fireEvent.click(screen.getByRole('button', { name: 'Reset' }));
await waitFor(() => {
expect(localStorage.getItem(FONT_SCALE_KEY)).toBeNull();
expect(document.documentElement.style.fontSize).toBe('100%');
});
});
it('purges decrypted raw packets via maintenance endpoint action', async () => {
const runMaintenanceSpy = vi.spyOn(api, 'runMaintenance').mockResolvedValue({
packets_deleted: 12,
@@ -594,6 +651,14 @@ describe('SettingsModal', () => {
double_byte_pct: 30,
triple_byte_pct: 20,
},
noise_floor_24h: {
sample_interval_seconds: 300,
coverage_seconds: 3600,
latest_noise_floor_dbm: -105,
latest_timestamp: 1711800000,
supported: true,
samples: [],
},
};
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
@@ -625,17 +690,11 @@ describe('SettingsModal', () => {
expect(
screen.getByText(/Parsed stored raw packets from the last 24 hours: 120/)
).toBeInTheDocument();
expect(screen.getByText('1-byte hops')).toBeInTheDocument();
expect(screen.getByText('60 (50.0%)')).toBeInTheDocument();
expect(screen.getByText('36 (30.0%)')).toBeInTheDocument();
expect(screen.getByText('24 (20.0%)')).toBeInTheDocument();
expect(screen.getByText('Contacts heard')).toBeInTheDocument();
expect(screen.getByText('Repeaters heard')).toBeInTheDocument();
expect(screen.getByText('Known-channels active')).toBeInTheDocument();
// Busiest channels
expect(screen.getByText('general')).toBeInTheDocument();
expect(screen.getByText('42 msgs')).toBeInTheDocument();
expect(screen.getByText('Busiest Channels (24h)')).toBeInTheDocument();
expect(screen.getByText('Noise Floor (24h)')).toBeInTheDocument();
});
it('fetches statistics when expanded in mobile external-nav mode', async () => {
@@ -662,6 +721,14 @@ describe('SettingsModal', () => {
double_byte_pct: 30,
triple_byte_pct: 20,
},
noise_floor_24h: {
sample_interval_seconds: 300,
coverage_seconds: 0,
latest_noise_floor_dbm: null,
latest_timestamp: null,
supported: null,
samples: [],
},
};
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
+55 -2
View File
@@ -75,13 +75,14 @@ function renderSidebar(overrides?: {
const favorites = overrides?.favorites ?? [{ type: 'channel', id: flightChannel.key }];
const channels = overrides?.channels ?? [publicChannel, flightChannel, opsChannel];
const onSelectConversation = vi.fn();
const view = render(
<Sidebar
contacts={[alice, board, relay]}
channels={channels}
activeConversation={null}
onSelectConversation={vi.fn()}
onSelectConversation={onSelectConversation}
onNewMessage={vi.fn()}
lastMessageTimes={overrides?.lastMessageTimes ?? {}}
unreadCounts={unreadCounts}
@@ -96,7 +97,7 @@ function renderSidebar(overrides?: {
/>
);
return { ...view, flightChannel, opsChannel, aliceName, roomName };
return { ...view, flightChannel, opsChannel, aliceName, roomName, onSelectConversation };
}
function getSectionHeaderContainer(title: string): HTMLElement {
@@ -121,6 +122,46 @@ describe('Sidebar section summaries', () => {
expect(within(getSectionHeaderContainer('Repeaters')).getByText('4')).toBeInTheDocument();
});
it('renders a full add channel/contact button above search and calls onNewMessage', () => {
const onNewMessage = vi.fn();
render(
<Sidebar
contacts={[]}
channels={[makeChannel(PUBLIC_CHANNEL_KEY, 'Public')]}
activeConversation={null}
onSelectConversation={vi.fn()}
onNewMessage={onNewMessage}
lastMessageTimes={{}}
unreadCounts={{}}
mentions={{}}
showCracker={false}
crackerRunning={false}
onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()}
favorites={[]}
legacySortOrder="recent"
/>
);
const addButton = screen.getByRole('button', { name: 'Add channel or contact' });
const search = screen.getByLabelText('Search conversations');
const nav = screen.getByRole('navigation', { name: 'Conversations' });
const toolsButton = screen.getByRole('button', { name: 'Tools' });
expect(addButton).toHaveTextContent('Add Channel/Contact');
expect(
addButton.compareDocumentPosition(search) & Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy();
expect(nav.compareDocumentPosition(search) & Node.DOCUMENT_POSITION_CONTAINED_BY).toBeTruthy();
expect(
search.compareDocumentPosition(toolsButton) & Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy();
fireEvent.click(addButton);
expect(onNewMessage).toHaveBeenCalledTimes(1);
});
it('turns favorites and channels rollups red when they contain a mention', () => {
renderSidebar({
mentions: {
@@ -306,6 +347,18 @@ describe('Sidebar section summaries', () => {
expect(bell.compareDocumentPosition(unread) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it('shows the trace tool row and selects it', () => {
const { onSelectConversation } = renderSidebar();
fireEvent.click(screen.getByText('Trace'));
expect(onSelectConversation).toHaveBeenCalledWith({
type: 'trace',
id: 'trace',
name: 'Trace',
});
});
it('sorts each section independently and persists per-section sort preferences', () => {
const publicChannel = makeChannel('AA'.repeat(16), 'Public');
const zebraChannel = makeChannel('BB'.repeat(16), '#zebra');
+262
View File
@@ -0,0 +1,262 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { TracePane } from '../components/TracePane';
import type { Contact, RadioConfig, RadioTraceResponse } from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
function makeContact(
publicKey: string,
name: string | null,
type = CONTACT_TYPE_REPEATER,
overrides: Partial<Contact> = {}
): Contact {
return {
public_key: publicKey,
name,
type,
flags: 0,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
...overrides,
};
}
const config: RadioConfig = {
public_key: 'ff'.repeat(32),
name: 'Base Radio',
lat: 10,
lon: 20,
tx_power: 17,
max_tx_power: 22,
radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 },
path_hash_mode: 0,
path_hash_mode_supported: true,
};
describe('TracePane', () => {
it('shows only full-key repeaters and filters by name or key', () => {
render(
<TracePane
config={config}
onRunTracePath={vi.fn()}
contacts={[
makeContact('11'.repeat(32), 'Relay Alpha'),
makeContact('22'.repeat(6), 'Prefix Relay'),
makeContact('33'.repeat(32), 'Client Node', 1),
makeContact('44'.repeat(32), 'Relay Beta'),
]}
/>
);
expect(screen.getByText('Relay Alpha')).toBeInTheDocument();
expect(screen.getByText('Relay Beta')).toBeInTheDocument();
expect(screen.queryByText('Prefix Relay')).not.toBeInTheDocument();
expect(screen.queryByText('Client Node')).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Search repeaters'), { target: { value: 'beta' } });
expect(screen.queryByText('Relay Alpha')).not.toBeInTheDocument();
expect(screen.getByText('Relay Beta')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Search repeaters'), { target: { value: '111111' } });
expect(screen.getByText('Relay Alpha')).toBeInTheDocument();
});
it('adds, reorders, removes, and sends a trace path with known repeaters', async () => {
const relayA = makeContact('11'.repeat(32), 'Relay Alpha');
const relayB = makeContact('22'.repeat(32), 'Relay Beta');
const onRunTracePath = vi.fn(
async (): Promise<RadioTraceResponse> => ({
path_len: 2,
timeout_seconds: 6,
nodes: [
{
role: 'repeater',
public_key: relayB.public_key,
name: relayB.name,
observed_hash: relayB.public_key.slice(0, 8),
snr: 7.5,
},
{
role: 'repeater',
public_key: relayA.public_key,
name: relayA.name,
observed_hash: relayA.public_key.slice(0, 8),
snr: 3.25,
},
{
role: 'local',
public_key: config.public_key,
name: config.name,
observed_hash: null,
snr: 5.0,
},
],
})
);
render(
<TracePane config={config} onRunTracePath={onRunTracePath} contacts={[relayA, relayB]} />
);
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i }));
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay beta/i }));
expect(screen.getByText('2 hops selected · 4-byte trace')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /move relay beta up/i }));
fireEvent.click(screen.getByRole('button', { name: /send trace/i }));
await waitFor(() => {
expect(onRunTracePath).toHaveBeenCalledWith(4, [
{ public_key: relayB.public_key },
{ public_key: relayA.public_key },
]);
});
expect(screen.getByRole('heading', { name: 'Results (6.0s)' })).toBeInTheDocument();
expect(screen.getByText('+7.5 dB')).toBeInTheDocument();
expect(screen.getByText('+5.0 dB')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /remove relay alpha/i }));
expect(screen.getByText('1 hop selected · 4-byte trace')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /remove relay beta/i }));
expect(screen.getByText('No hops selected')).toBeInTheDocument();
});
it('allows adding the same repeater multiple times from the picker row', () => {
const relayA = makeContact('11'.repeat(32), 'Relay Alpha');
render(<TracePane config={config} onRunTracePath={vi.fn()} contacts={[relayA]} />);
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i }));
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i }));
expect(screen.getByText('2 hops selected · 4-byte trace')).toBeInTheDocument();
expect(screen.getByText('Added 2 times')).toBeInTheDocument();
});
it('adds custom hops from the modal and locks later custom hops to the same byte width', async () => {
const relayA = makeContact('11'.repeat(32), 'Relay Alpha');
const onRunTracePath = vi.fn(
async (): Promise<RadioTraceResponse> => ({
path_len: 2,
timeout_seconds: 4.5,
nodes: [
{
role: 'custom',
public_key: null,
name: null,
observed_hash: 'ae',
snr: 4.0,
},
{
role: 'repeater',
public_key: relayA.public_key,
name: relayA.name,
observed_hash: '11',
snr: 2.0,
},
{
role: 'local',
public_key: config.public_key,
name: config.name,
observed_hash: null,
snr: 3.0,
},
],
})
);
render(<TracePane config={config} onRunTracePath={onRunTracePath} contacts={[relayA]} />);
fireEvent.click(screen.getByRole('button', { name: 'Custom path' }));
fireEvent.click(screen.getByRole('button', { name: '1-byte' }));
fireEvent.change(screen.getByLabelText('Repeater prefix'), { target: { value: 'ae' } });
fireEvent.click(screen.getByRole('button', { name: 'Add custom hop' }));
expect(screen.getByText('1 hop selected · 1-byte trace')).toBeInTheDocument();
expect(screen.getByText('AE (1-byte)')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i }));
fireEvent.click(screen.getByRole('button', { name: /send trace/i }));
await waitFor(() => {
expect(onRunTracePath).toHaveBeenCalledWith(1, [
{ hop_hex: 'ae' },
{ public_key: relayA.public_key },
]);
});
fireEvent.click(screen.getByRole('button', { name: 'Custom path' }));
expect(screen.getByRole('button', { name: '2-byte' })).toBeDisabled();
expect(screen.getByRole('button', { name: '4-byte' })).toBeDisabled();
expect(screen.getByText(/custom hops are locked to 1-byte prefixes/i)).toBeInTheDocument();
});
it('drops an in-flight result after the draft path changes', async () => {
const relayA = makeContact('11'.repeat(32), 'Relay Alpha');
const relayB = makeContact('22'.repeat(32), 'Relay Beta');
let resolveTrace: ((value: RadioTraceResponse) => void) | null = null;
const onRunTracePath = vi.fn(
() =>
new Promise<RadioTraceResponse>((resolve) => {
resolveTrace = resolve;
})
);
render(
<TracePane config={config} onRunTracePath={onRunTracePath} contacts={[relayA, relayB]} />
);
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i }));
fireEvent.click(screen.getByRole('button', { name: /send trace/i }));
await waitFor(() => {
expect(onRunTracePath).toHaveBeenCalledWith(4, [{ public_key: relayA.public_key }]);
});
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay beta/i }));
expect(screen.getByText('2 hops selected · 4-byte trace')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /send trace/i })).toBeEnabled();
await act(async () => {
resolveTrace?.({
path_len: 1,
timeout_seconds: 6,
nodes: [
{
role: 'repeater',
public_key: relayA.public_key,
name: relayA.name,
observed_hash: relayA.public_key.slice(0, 8),
snr: 7.5,
},
{
role: 'local',
public_key: config.public_key,
name: config.name,
observed_hash: null,
snr: 5.0,
},
],
});
});
expect(screen.queryByRole('heading', { name: 'Results (6.0s)' })).not.toBeInTheDocument();
expect(screen.queryByText('+7.5 dB')).not.toBeInTheDocument();
expect(
screen.getByText('Send a trace to see the returned hop-by-hop SNR values.')
).toBeInTheDocument();
});
});
+8
View File
@@ -52,6 +52,14 @@ describe('parseHashConversation', () => {
expect(result).toEqual({ type: 'map', name: 'map' });
});
it('parses #trace as trace type', () => {
window.location.hash = '#trace';
const result = parseHashConversation();
expect(result).toEqual({ type: 'trace', name: 'trace' });
});
it('parses #map/focus/PUBKEY with focus key', () => {
window.location.hash = '#map/focus/ABCD1234';
@@ -69,6 +69,7 @@ function createRealtimeArgs(overrides: Partial<Parameters<typeof useRealtimeAppS
observeMessage: vi.fn(() => ({ added: false, activeConversation: false })),
recordMessageEvent: vi.fn(),
renameConversationState: vi.fn(),
removeConversationState: vi.fn(),
checkMention: vi.fn(() => false),
pendingDeleteFallbackRef: { current: false },
setActiveConversation: vi.fn(),
+43
View File
@@ -221,6 +221,49 @@ describe('useUnreadCounts', () => {
});
});
it('does not treat search or trace views as readable conversations', async () => {
const mocks = await getMockedApi();
mocks.getUnreads.mockResolvedValue({
counts: {
[getStateKey('channel', CHANNEL_KEY)]: 4,
[getStateKey('contact', CONTACT_KEY)]: 2,
},
mentions: {
[getStateKey('channel', CHANNEL_KEY)]: true,
},
last_message_times: {},
last_read_ats: {},
});
const { result, rerender } = renderWith({
channels: [makeChannel(CHANNEL_KEY, 'Test')],
contacts: [makeContact(CONTACT_KEY)],
activeConversation: { type: 'search', id: 'search', name: 'Message Search' },
});
await act(async () => {
await vi.waitFor(() => expect(mocks.getUnreads).toHaveBeenCalled());
});
expect(result.current.unreadCounts[getStateKey('channel', CHANNEL_KEY)]).toBe(4);
expect(result.current.unreadCounts[getStateKey('contact', CONTACT_KEY)]).toBe(2);
expect(mocks.markChannelRead).not.toHaveBeenCalled();
expect(mocks.markContactRead).not.toHaveBeenCalled();
rerender({
channels: [makeChannel(CHANNEL_KEY, 'Test')],
contacts: [makeContact(CONTACT_KEY)],
activeConversation: { type: 'trace', id: 'trace', name: 'Trace' },
});
await act(async () => {
await Promise.resolve();
});
expect(mocks.markChannelRead).not.toHaveBeenCalled();
expect(mocks.markContactRead).not.toHaveBeenCalled();
});
it('re-fetches and filters when refreshUnreads is called (simulating WS reconnect)', async () => {
const mocks = await getMockedApi();
const channels = [makeChannel(CHANNEL_KEY, 'Test')];
@@ -0,0 +1,51 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { VisualizerControls } from '../components/visualizer/VisualizerControls';
describe('VisualizerControls', () => {
it('allows clearing numeric inputs while editing', () => {
render(
<VisualizerControls
showControls
setShowControls={vi.fn()}
showAmbiguousPaths={false}
setShowAmbiguousPaths={vi.fn()}
showAmbiguousNodes={false}
setShowAmbiguousNodes={vi.fn()}
useAdvertPathHints={false}
setUseAdvertPathHints={vi.fn()}
collapseLikelyKnownSiblingRepeaters={false}
setCollapseLikelyKnownSiblingRepeaters={vi.fn()}
splitAmbiguousByTraffic={false}
setSplitAmbiguousByTraffic={vi.fn()}
observationWindowSec={5}
setObservationWindowSec={vi.fn()}
pruneStaleNodes
setPruneStaleNodes={vi.fn()}
pruneStaleMinutes={10}
setPruneStaleMinutes={vi.fn()}
letEmDrift={false}
setLetEmDrift={vi.fn()}
autoOrbit={false}
setAutoOrbit={vi.fn()}
chargeStrength={-100}
setChargeStrength={vi.fn()}
particleSpeedMultiplier={1}
setParticleSpeedMultiplier={vi.fn()}
nodeCount={0}
linkCount={0}
onExpandContract={vi.fn()}
onClearAndReset={vi.fn()}
/>
);
const observationInput = screen.getByLabelText('Ack/echo listen window:') as HTMLInputElement;
const pruneInput = screen.getByLabelText('Window:') as HTMLInputElement;
fireEvent.change(observationInput, { target: { value: '' } });
fireEvent.change(pruneInput, { target: { value: '' } });
expect(observationInput.value).toBe('');
expect(pruneInput.value).toBe('');
});
});
+36 -1
View File
@@ -34,6 +34,7 @@ export type RadioDiscoveryTarget = 'repeaters' | 'sensors' | 'all';
export interface RadioDiscoveryResult {
public_key: string;
name: string | null;
node_type: 'repeater' | 'sensor';
heard_count: number;
local_snr: number | null;
@@ -285,7 +286,7 @@ export interface ResendChannelMessageResponse {
message?: Message;
}
type ConversationType = 'contact' | 'channel' | 'raw' | 'map' | 'visualizer' | 'search';
type ConversationType = 'contact' | 'channel' | 'raw' | 'map' | 'visualizer' | 'search' | 'trace';
export interface Conversation {
type: ConversationType;
@@ -473,6 +474,25 @@ export interface TraceResponse {
path_len: number;
}
export interface RadioTraceNode {
role: 'repeater' | 'custom' | 'local';
public_key: string | null;
name: string | null;
observed_hash: string | null;
snr: number | null;
}
export interface RadioTraceHopRequest {
public_key?: string | null;
hop_hex?: string | null;
}
export interface RadioTraceResponse {
path_len: number;
timeout_seconds: number;
nodes: RadioTraceNode[];
}
export interface PathDiscoveryRoute {
path: string;
path_len: number;
@@ -504,6 +524,20 @@ interface ContactActivityCounts {
last_week: number;
}
export interface NoiseFloorSample {
timestamp: number;
noise_floor_dbm: number;
}
export interface NoiseFloorHistoryStats {
sample_interval_seconds: number;
coverage_seconds: number;
latest_noise_floor_dbm: number | null;
latest_timestamp: number | null;
supported: boolean | null;
samples: NoiseFloorSample[];
}
export interface StatisticsResponse {
busiest_channels_24h: BusyChannel[];
contact_count: number;
@@ -527,4 +561,5 @@ export interface StatisticsResponse {
double_byte_pct: number;
triple_byte_pct: number;
};
noise_floor_24h: NoiseFloorHistoryStats;
}
+53
View File
@@ -0,0 +1,53 @@
export const FONT_SCALE_KEY = 'remoteterm-font-scale';
export const DEFAULT_FONT_SCALE = 100;
export const MIN_FONT_SCALE = 25;
export const MAX_FONT_SCALE = 400;
export const FONT_SCALE_SLIDER_STEP = 5;
function normalizeFontScale(scale: number): number {
if (!Number.isFinite(scale)) {
return DEFAULT_FONT_SCALE;
}
const clamped = Math.min(MAX_FONT_SCALE, Math.max(MIN_FONT_SCALE, scale));
return Number.parseFloat(clamped.toFixed(2));
}
export function getSavedFontScale(): number {
try {
const raw = localStorage.getItem(FONT_SCALE_KEY);
if (raw === null) {
return DEFAULT_FONT_SCALE;
}
return normalizeFontScale(Number.parseFloat(raw));
} catch {
return DEFAULT_FONT_SCALE;
}
}
export function applyFontScale(scale: number): number {
const normalized = normalizeFontScale(scale);
if (typeof document !== 'undefined') {
document.documentElement.style.fontSize = `${normalized}%`;
}
return normalized;
}
export function setSavedFontScale(scale: number): number {
const normalized = applyFontScale(scale);
try {
if (normalized === DEFAULT_FONT_SCALE) {
localStorage.removeItem(FONT_SCALE_KEY);
} else {
localStorage.setItem(FONT_SCALE_KEY, String(normalized));
}
} catch {
// localStorage may be unavailable
}
return normalized;
}
+12 -1
View File
@@ -4,7 +4,14 @@ import { parseHashConversation } from './urlHash';
export const REOPEN_LAST_CONVERSATION_KEY = 'remoteterm-reopen-last-conversation';
export const LAST_VIEWED_CONVERSATION_KEY = 'remoteterm-last-viewed-conversation';
const SUPPORTED_TYPES: Conversation['type'][] = ['contact', 'channel', 'raw', 'map', 'visualizer'];
const SUPPORTED_TYPES: Conversation['type'][] = [
'contact',
'channel',
'raw',
'map',
'visualizer',
'trace',
];
function isSupportedType(value: unknown): value is Conversation['type'] {
return typeof value === 'string' && SUPPORTED_TYPES.includes(value as Conversation['type']);
@@ -94,6 +101,10 @@ export function captureLastViewedConversationFromHash(): void {
saveLastViewedConversation({ type: 'visualizer', id: 'visualizer', name: 'Mesh Visualizer' });
return;
}
if (hashConversation.type === 'trace') {
saveLastViewedConversation({ type: 'trace', id: 'trace', name: 'Trace' });
return;
}
saveLastViewedConversation({
type: hashConversation.type,
+32
View File
@@ -2,6 +2,9 @@
* Parse sender from channel message text.
* Channel messages have format "sender: message".
*/
const HASHTAG_CHANNEL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const HASHTAG_CHANNEL_REFERENCE_PATTERN = /(^|\s)(#[a-z0-9]+(?:-[a-z0-9]+)*)(?=$|\s)/g;
export function parseSenderFromText(text: string): { sender: string | null; content: string } {
const colonIndex = text.indexOf(': ');
if (colonIndex > 0 && colonIndex < 50) {
@@ -17,6 +20,35 @@ export function parseSenderFromText(text: string): { sender: string | null; cont
return { sender: null, content: text };
}
export interface HashtagChannelReference {
label: string;
start: number;
end: number;
}
export function isValidLinkedChannelName(name: string): boolean {
return HASHTAG_CHANNEL_NAME_PATTERN.test(name);
}
export function findLinkedChannelReferences(text: string): HashtagChannelReference[] {
const references: HashtagChannelReference[] = [];
let match: RegExpExecArray | null;
HASHTAG_CHANNEL_REFERENCE_PATTERN.lastIndex = 0;
while ((match = HASHTAG_CHANNEL_REFERENCE_PATTERN.exec(text)) !== null) {
const prefix = match[1];
const label = match[2];
const start = match.index + prefix.length;
references.push({
label,
start,
end: start + label.length,
});
}
return references;
}
/**
* Format a Unix timestamp to a time string.
* Shows date for messages not from today.
-12
View File
@@ -106,9 +106,6 @@ export interface RawPacketStatsSnapshot {
medianRssi: number | null;
bestRssi: number | null;
rssiBuckets: RankedPacketStat[];
strongestPacketSourceKey: string | null;
strongestPacketSourceLabel: string | null;
strongestPacketPayloadType: string | null;
coverageSeconds: number;
windowFullyCovered: boolean;
oldestStoredTimestamp: number | null;
@@ -377,8 +374,6 @@ export function buildRawPacketStatsSnapshot(
['Weak (<-85 dBm)', 0],
]);
let strongestPacket: RawPacketStatsObservation | null = null;
for (const packet of packets) {
payloadCounts.set(packet.payloadType, (payloadCounts.get(packet.payloadType) ?? 0) + 1);
routeCounts.set(packet.routeType, (routeCounts.get(packet.routeType) ?? 0) + 1);
@@ -436,10 +431,6 @@ export function buildRawPacketStatsSnapshot(
} else {
rssiBucketCounts.set('Weak (<-85 dBm)', (rssiBucketCounts.get('Weak (<-85 dBm)') ?? 0) + 1);
}
if (!strongestPacket || strongestPacket.rssi === null || packet.rssi > strongestPacket.rssi) {
strongestPacket = packet;
}
}
}
@@ -527,9 +518,6 @@ export function buildRawPacketStatsSnapshot(
medianRssi,
bestRssi,
rssiBuckets: rankedBreakdown(rssiBucketCounts, rssiValues.length),
strongestPacketSourceKey: strongestPacket?.sourceKey ?? null,
strongestPacketSourceLabel: strongestPacket?.sourceLabel ?? null,
strongestPacketPayloadType: strongestPacket?.payloadType ?? null,
coverageSeconds,
windowFullyCovered,
oldestStoredTimestamp,
+6 -1
View File
@@ -4,7 +4,7 @@ import { getContactDisplayName } from './pubkey';
import type { SettingsSection } from '../components/settings/settingsConstants';
interface ParsedHashConversation {
type: 'channel' | 'contact' | 'raw' | 'map' | 'visualizer' | 'search';
type: 'channel' | 'contact' | 'raw' | 'map' | 'visualizer' | 'search' | 'trace';
/** Conversation identity token (channel key or contact public key, or legacy name token) */
name: string;
/** Optional human-readable label segment (ignored for identity resolution) */
@@ -44,6 +44,10 @@ export function parseHashConversation(): ParsedHashConversation | null {
return { type: 'search', name: 'search' };
}
if (hash === 'trace') {
return { type: 'trace', name: 'trace' };
}
// Check for map with focus: #map/focus/{pubkey_prefix}
if (hash.startsWith('map/focus/')) {
const focusKey = hash.slice('map/focus/'.length);
@@ -149,6 +153,7 @@ function getConversationHash(conv: Conversation | null): string {
if (conv.type === 'map') return '#map';
if (conv.type === 'visualizer') return '#visualizer';
if (conv.type === 'search') return '#search';
if (conv.type === 'trace') return '#trace';
// Use immutable IDs for identity, append readable label for UX.
if (conv.type === 'channel') {
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "remoteterm-meshcore"
version = "3.6.2"
version = "3.6.3"
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
readme = "README.md"
requires-python = ">=3.10"
-64
View File
@@ -1,64 +0,0 @@
#!/usr/bin/env bash
set -e
# developer perogative ;D
if command -v enablenvm >/dev/null 2>&1; then
enablenvm >/dev/null 2>&1 || true
fi
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
echo -e "${YELLOW}=== RemoteTerm Quality Checks ===${NC}"
echo
# --- Phase 1: Lint & Format ---
echo -e "${YELLOW}=== Phase 1: Lint & Format ===${NC}"
echo -e "${BLUE}[backend lint]${NC} Running ruff check + format..."
cd "$SCRIPT_DIR"
uv run ruff check app/ tests/ --fix
uv run ruff format app/ tests/
echo -e "${GREEN}[backend lint]${NC} Passed!"
echo -e "${BLUE}[frontend lint]${NC} Running eslint + prettier..."
cd "$SCRIPT_DIR/frontend"
npm run lint:fix
npm run format
echo -e "${GREEN}[frontend lint]${NC} Passed!"
echo -e "${GREEN}=== Phase 1 complete ===${NC}"
echo
# --- Phase 2: Typecheck, Tests & Build ---
echo -e "${YELLOW}=== Phase 2: Typecheck, Tests & Build ===${NC}"
echo -e "${BLUE}[pyright]${NC} Running type check..."
cd "$SCRIPT_DIR"
uv run pyright app/
echo -e "${GREEN}[pyright]${NC} Passed!"
echo -e "${BLUE}[pytest]${NC} Running backend tests..."
cd "$SCRIPT_DIR"
PYTHONPATH=. uv run pytest tests/ -v
echo -e "${GREEN}[pytest]${NC} Passed!"
echo -e "${BLUE}[frontend]${NC} Running tests + build..."
cd "$SCRIPT_DIR/frontend"
npm run test:run
npm run build
echo -e "${GREEN}[frontend]${NC} Passed!"
echo -e "${GREEN}=== Phase 2 complete ===${NC}"
echo
echo -e "${GREEN}=== All quality checks passed! ===${NC}"
@@ -2,10 +2,10 @@
set -euo pipefail
# Collect third-party license texts into LICENSES.md
# Usage: scripts/collect_licenses.sh [output-path]
# Usage: scripts/build/collect_licenses.sh [output-path]
# output-path defaults to LICENSES.md at the repo root
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
OUT="${1:-$REPO_ROOT/LICENSES.md}"
FRONTEND_LICENSE_IMAGE="${FRONTEND_LICENSE_IMAGE:-node:20-slim}"
FRONTEND_LICENSE_NPM="${FRONTEND_LICENSE_NPM:-10.9.5}"
@@ -59,7 +59,7 @@ for d in data:
# ── Frontend (npm) ───────────────────────────────────────────────────
frontend_licenses_local() {
cd "$REPO_ROOT/frontend"
node "$REPO_ROOT/scripts/print_frontend_licenses.cjs"
node "$REPO_ROOT/scripts/build/print_frontend_licenses.cjs"
}
frontend_licenses_docker() {
@@ -73,7 +73,7 @@ frontend_licenses_docker() {
cd frontend
npm i -g npm@$FRONTEND_LICENSE_NPM >/dev/null
npm ci --ignore-scripts >/dev/null
node /src/scripts/print_frontend_licenses.cjs
node /src/scripts/build/print_frontend_licenses.cjs
"
}
@@ -85,7 +85,7 @@ frontend_licenses() {
{
echo "# Third-Party Licenses"
echo
echo "Auto-generated by \`scripts/collect_licenses.sh\` — do not edit by hand."
echo "Auto-generated by \`scripts/build/collect_licenses.sh\` — do not edit by hand."
echo
echo "## Backend (Python) Dependencies"
echo
+16 -12
View File
@@ -7,21 +7,25 @@ GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$SCRIPT_DIR"
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$REPO_ROOT"
RELEASE_WORK_DIR=""
RELEASE_BUNDLE_DIR_NAME="Remote-Terminal-for-MeshCore"
RELEASE_ASSET=""
DOCKER_IMAGE="jkingsman/remoteterm-meshcore"
DOCKER_PLATFORMS="linux/amd64,linux/arm64"
cleanup_release_build_artifacts() {
if [ -d "$SCRIPT_DIR/frontend/prebuilt" ]; then
rm -rf "$SCRIPT_DIR/frontend/prebuilt"
if [ -d "$REPO_ROOT/frontend/prebuilt" ]; then
rm -rf "$REPO_ROOT/frontend/prebuilt"
fi
if [ -n "$RELEASE_WORK_DIR" ] && [ -d "$RELEASE_WORK_DIR" ]; then
rm -rf "$RELEASE_WORK_DIR"
fi
if [ -n "$RELEASE_ASSET" ] && [ -f "$REPO_ROOT/$RELEASE_ASSET" ]; then
rm -f "$REPO_ROOT/$RELEASE_ASSET"
fi
}
trap cleanup_release_build_artifacts EXIT
@@ -74,7 +78,7 @@ echo
# Run frontend linting and formatting check
echo -e "${YELLOW}Running frontend lint (ESLint)...${NC}"
cd "$SCRIPT_DIR/frontend"
cd "$REPO_ROOT/frontend"
npm run lint
echo -e "${GREEN}Frontend lint passed!${NC}"
echo
@@ -93,11 +97,11 @@ echo
echo -e "${YELLOW}Building frontend...${NC}"
npm run build
echo -e "${GREEN}Frontend build complete!${NC}"
cd "$SCRIPT_DIR"
cd "$REPO_ROOT"
echo
echo -e "${YELLOW}Regenerating LICENSES.md...${NC}"
bash scripts/collect_licenses.sh LICENSES.md
bash scripts/build/collect_licenses.sh LICENSES.md
echo -e "${GREEN}LICENSES.md updated!${NC}"
echo
@@ -198,16 +202,16 @@ FULL_GIT_HASH=$(git rev-parse HEAD)
RELEASE_ASSET="remoteterm-prebuilt-frontend-v${VERSION}-${GIT_HASH}.zip"
echo -e "${YELLOW}Building packaged frontend artifact...${NC}"
cd "$SCRIPT_DIR/frontend"
cd "$REPO_ROOT/frontend"
npm run packaged-build
cd "$SCRIPT_DIR"
cd "$REPO_ROOT"
RELEASE_WORK_DIR=$(mktemp -d)
RELEASE_BUNDLE_DIR="$RELEASE_WORK_DIR/$RELEASE_BUNDLE_DIR_NAME"
mkdir -p "$RELEASE_BUNDLE_DIR"
git archive "$FULL_GIT_HASH" | tar -x -C "$RELEASE_BUNDLE_DIR"
mkdir -p "$RELEASE_BUNDLE_DIR/frontend"
cp -R "$SCRIPT_DIR/frontend/prebuilt" "$RELEASE_BUNDLE_DIR/frontend/prebuilt"
cp -R "$REPO_ROOT/frontend/prebuilt" "$RELEASE_BUNDLE_DIR/frontend/prebuilt"
cat > "$RELEASE_BUNDLE_DIR/build_info.json" <<EOF
{
"version": "$VERSION",
@@ -215,10 +219,10 @@ cat > "$RELEASE_BUNDLE_DIR/build_info.json" <<EOF
"build_source": "prebuilt-release"
}
EOF
rm -f "$SCRIPT_DIR/$RELEASE_ASSET"
rm -f "$REPO_ROOT/$RELEASE_ASSET"
(
cd "$RELEASE_WORK_DIR"
zip -qr "$SCRIPT_DIR/$RELEASE_ASSET" "$(basename "$RELEASE_BUNDLE_DIR")"
zip -qr "$REPO_ROOT/$RELEASE_ASSET" "$(basename "$RELEASE_BUNDLE_DIR")"
)
echo -e "${GREEN}Packaged release artifact created: $RELEASE_ASSET${NC}"
echo
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
set -e
# developer perogative ;D
if command -v enablenvm >/dev/null 2>&1; then
enablenvm >/dev/null 2>&1 || true
fi
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
echo -e "${YELLOW}=== RemoteTerm Quality Checks ===${NC}"
echo
# --- Phase 1: Lint & Format ---
echo -e "${YELLOW}=== Phase 1: Lint & Format ===${NC}"
echo -ne "${BLUE}[backend lint]${NC} "
cd "$REPO_ROOT"
uv run ruff check app/ tests/ --fix --quiet
uv run ruff format app/ tests/ --check --quiet
echo -e "${GREEN}Passed!${NC}"
echo -ne "${BLUE}[frontend lint]${NC} "
cd "$REPO_ROOT/frontend"
npx --quiet eslint src/ --fix --cache --quiet
npx --quiet prettier --write --list-different src/ --log-level warn
echo -e "${GREEN}Passed!${NC}"
echo -e "${GREEN}=== Phase 1 complete ===${NC}"
echo
# --- Phase 2: Typecheck, Tests & Build ---
echo -e "${YELLOW}=== Phase 2: Typecheck, Tests & Build ===${NC}"
echo -ne "${BLUE}[pyright]${NC} "
cd "$REPO_ROOT"
uv run pyright app/ --outputjson 2>/dev/null | python3 -c "
import sys, json
d = json.load(sys.stdin)
s = d.get('summary', {})
print(f\"{s.get('filesAnalyzed',0)} files, 0 errors\")
" 2>/dev/null || { uv run pyright app/; exit 1; }
echo -e "${GREEN}Passed!${NC}"
echo -ne "${BLUE}[pytest]${NC} "
cd "$REPO_ROOT"
PYTHONPATH=. uv run pytest tests/ -q --no-header --tb=short
echo -e "${GREEN}Passed!${NC}"
echo -ne "${BLUE}[vitest]${NC} "
cd "$REPO_ROOT/frontend"
npx --quiet vitest run --reporter=dot 2>&1 | tail -5
echo -e "${GREEN}Passed!${NC}"
echo -ne "${BLUE}[build]${NC} "
cd "$REPO_ROOT/frontend"
npx --quiet tsc 2>&1 && npx --quiet vite build --logLevel error 2>&1
echo -e "${GREEN}Passed!${NC}"
echo -e "${GREEN}=== Phase 2 complete ===${NC}"
echo
echo -e "${GREEN}=== All quality checks passed! ===${NC}"
@@ -7,7 +7,7 @@ YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
NODE_VERSIONS=("20" "22" "24")
# Use explicit npm patch versions so resolver regressions are caught.
@@ -27,7 +27,7 @@ run_combo() {
local image="node:${node_version}-slim"
docker run --rm \
-v "$SCRIPT_DIR:/src:ro" \
-v "$REPO_ROOT:/src:ro" \
-w /tmp \
"$image" \
bash -lc "
@@ -79,7 +79,7 @@ cleanup() {
trap cleanup EXIT
echo -e "${YELLOW}=== Frontend Docker CI Matrix ===${NC}"
echo -e "${BLUE}Repo:${NC} $SCRIPT_DIR"
echo -e "${BLUE}Repo:${NC} $REPO_ROOT"
echo
for case_spec in "${TEST_CASES[@]}"; do
+2 -2
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env bash
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
echo "Starting E2E tests..."
cd "$SCRIPT_DIR/tests/e2e"
cd "$REPO_ROOT/tests/e2e"
npx playwright test "$@"
@@ -6,23 +6,23 @@ GREEN='\033[0;32m'
BLUE='\033[0;34m'
NC='\033[0m'
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
echo -e "${YELLOW}=== Extended Quality Checks ===${NC}"
echo
echo -e "${BLUE}[all_quality]${NC} Running full lint, typecheck, unit tests, and the standard frontend build..."
"$SCRIPT_DIR/scripts/all_quality.sh"
"$REPO_ROOT/scripts/quality/all_quality.sh"
echo -e "${GREEN}[all_quality]${NC} Passed!"
echo
echo -e "${BLUE}[e2e]${NC} Running end-to-end tests..."
"$SCRIPT_DIR/scripts/e2e.sh" "$@"
"$REPO_ROOT/scripts/quality/e2e.sh" "$@"
echo -e "${GREEN}[e2e]${NC} Passed!"
echo
echo -e "${BLUE}[docker_ci]${NC} Running Docker frontend install/build matrix..."
"$SCRIPT_DIR/scripts/docker_ci.sh"
"$REPO_ROOT/scripts/quality/docker_ci.sh"
echo -e "${GREEN}[docker_ci]${NC} Passed!"
echo
@@ -21,7 +21,8 @@ API_URL = f"https://api.github.com/repos/{REPO}/releases/latest"
PREBUILT_PREFIX = "Remote-Terminal-for-MeshCore/frontend/prebuilt/"
SCRIPT_DIR = Path(__file__).resolve().parent
PREBUILT_DIR = SCRIPT_DIR.parent / "frontend" / "prebuilt"
REPO_ROOT = SCRIPT_DIR.parent.parent
PREBUILT_DIR = REPO_ROOT / "frontend" / "prebuilt"
def fetch_json(url: str) -> dict:
+582
View File
@@ -0,0 +1,582 @@
#!/usr/bin/env bash
# install_docker.sh
#
# Generates a local docker-compose.yml for RemoteTerm from a guided prompt flow.
# The generated compose file is intentionally gitignored so local customization
# does not create merge churn on future pulls.
#
# Run from anywhere inside the repo:
# bash scripts/setup/install_docker.sh
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
PURPLE='\033[0;35m'
BOLD='\033[1m'
NC='\033[0m'
REPO_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
COMPOSE_FILE="$REPO_DIR/docker-compose.yml"
EXAMPLE_FILE="$REPO_DIR/docker-compose.example.yml"
SNAKEOIL_CERT_DIR="$REPO_DIR/.docker-certs"
NGINX_CONFIG_DIR="$REPO_DIR/.docker-nginx"
NGINX_CONFIG_BASENAME="remoteterm.conf"
NGINX_CONFIG_HOST_PATH="$NGINX_CONFIG_DIR/$NGINX_CONFIG_BASENAME"
SNAKEOIL_CERT_BASENAME="remoteterm-snakeoil.crt"
SNAKEOIL_KEY_BASENAME="remoteterm-snakeoil.key"
SNAKEOIL_CERT_HOST_PATH="$SNAKEOIL_CERT_DIR/$SNAKEOIL_CERT_BASENAME"
SNAKEOIL_KEY_HOST_PATH="$SNAKEOIL_CERT_DIR/$SNAKEOIL_KEY_BASENAME"
SNAKEOIL_CERT_CONTAINER_PATH="/etc/nginx/certs/$SNAKEOIL_CERT_BASENAME"
SNAKEOIL_KEY_CONTAINER_PATH="/etc/nginx/certs/$SNAKEOIL_KEY_BASENAME"
IMAGE_MODE="image"
TRANSPORT_MODE="serial"
SERIAL_HOST_PATH="/dev/ttyACM0"
SERIAL_COMPOSE_HOST_PATH="/dev/ttyACM0"
SERIAL_CONTAINER_PATH="/dev/meshcore-radio"
TCP_HOST=""
TCP_PORT="4000"
BLE_ADDRESS=""
BLE_PIN=""
ENABLE_BOTS="N"
ENABLE_AUTH="N"
AUTH_USERNAME=""
AUTH_PASSWORD=""
RUN_AS_HOST_USER="N"
ENABLE_SNAKEOIL_TLS="Y"
BLE_MANUAL_WARNING=false
LOCAL_ACCESS_IP=""
SERIAL_FOUND_HOST_PATHS=()
SERIAL_FOUND_LABELS=()
SERIAL_FOUND_DISPLAYS=()
find_serial_devices() {
local path
local resolved
local label
local existing
SERIAL_FOUND_HOST_PATHS=()
SERIAL_FOUND_LABELS=()
SERIAL_FOUND_DISPLAYS=()
if [ -d /dev/serial/by-id ]; then
while IFS= read -r path; do
[ -n "$path" ] || continue
resolved="$(readlink -f "$path" 2>/dev/null || true)"
[ -n "$resolved" ] || resolved="$path"
label="$(basename "$path")"
SERIAL_FOUND_HOST_PATHS+=("$path")
SERIAL_FOUND_LABELS+=("$label")
SERIAL_FOUND_DISPLAYS+=("$path -> $resolved")
done < <(find /dev/serial/by-id -maxdepth 1 -type l | sort)
fi
for path in /dev/ttyACM* /dev/ttyUSB* /dev/cu.usbmodem* /dev/cu.usbserial*; do
[ -e "$path" ] || continue
resolved="$(readlink -f "$path" 2>/dev/null || true)"
[ -n "$resolved" ] || resolved="$path"
if ((${#SERIAL_FOUND_HOST_PATHS[@]} > 0)); then
for existing in "${SERIAL_FOUND_DISPLAYS[@]}"; do
if [[ "$existing" = *"-> $resolved" ]]; then
resolved=""
break
fi
done
[ -n "$resolved" ] || continue
fi
SERIAL_FOUND_HOST_PATHS+=("$path")
SERIAL_FOUND_LABELS+=("$(basename "$path")")
SERIAL_FOUND_DISPLAYS+=("$path")
done
}
yaml_quote() {
local value="$1"
value=${value//\'/\'\'}
printf "'%s'" "$value"
}
normalize_serial_host_path_for_compose() {
local selected_path="$1"
local resolved_path=""
if [[ "$selected_path" != *:* ]]; then
SERIAL_COMPOSE_HOST_PATH="$selected_path"
return 0
fi
resolved_path="$(readlink -f "$selected_path" 2>/dev/null || true)"
if [ -z "$resolved_path" ]; then
echo -e "${RED}Error:${NC} the selected serial path contains ':' and could not be resolved to a raw /dev/tty-style device path."
echo "Selected path: $selected_path"
echo "Please enter the raw serial device path instead (for example /dev/ttyACM0)."
exit 1
fi
if [[ "$resolved_path" == *:* ]]; then
echo -e "${RED}Error:${NC} the selected serial path still resolves to a path containing ':', which Docker Compose cannot use here."
echo "Selected path: $selected_path"
echo "Resolved path: $resolved_path"
echo "Please enter the raw serial device path instead (for example /dev/ttyACM0)."
exit 1
fi
echo -e "${YELLOW}Note:${NC} the selected serial path contains ':', so Docker Compose will use the resolved raw device path instead: ${resolved_path}"
SERIAL_COMPOSE_HOST_PATH="$resolved_path"
}
detect_primary_local_ip() {
local ip=""
local iface=""
if command -v hostname &>/dev/null; then
ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
fi
if [ -z "$ip" ] && command -v ip &>/dev/null; then
ip="$(ip route get 1.1.1.1 2>/dev/null | awk '/src/ {for (i = 1; i <= NF; i++) if ($i == "src") {print $(i + 1); exit}}')"
fi
if [ -z "$ip" ] && command -v route &>/dev/null && command -v ipconfig &>/dev/null; then
iface="$(route -n get default 2>/dev/null | awk '/interface:/{print $2; exit}')"
if [ -n "$iface" ]; then
ip="$(ipconfig getifaddr "$iface" 2>/dev/null || true)"
fi
fi
if [ -z "$ip" ]; then
ip="127.0.0.1"
fi
printf '%s' "$ip"
}
ensure_snakeoil_requirements() {
local dep
for dep in openssl mktemp; do
if ! command -v "$dep" &>/dev/null; then
echo -e "${RED}Error: ${dep} is required to generate the snakeoil TLS certificate.${NC}"
exit 1
fi
done
}
generate_snakeoil_certificate() {
local san_ip="$1"
local tmp_config=""
mkdir -p "$SNAKEOIL_CERT_DIR"
tmp_config="$(mktemp)"
cat >"$tmp_config" <<EOF
[req]
default_bits = 2048
distinguished_name = req_distinguished_name
x509_extensions = v3_req
prompt = no
[req_distinguished_name]
CN = RemoteTerm Snakeoil
O = RemoteTerm for MeshCore
[v3_req]
subjectAltName = @alt_names
[alt_names]
DNS.1 = localhost
IP.1 = 127.0.0.1
EOF
if [ -n "$san_ip" ] && [ "$san_ip" != "127.0.0.1" ]; then
printf 'IP.2 = %s\n' "$san_ip" >>"$tmp_config"
fi
openssl req \
-x509 \
-nodes \
-newkey rsa:2048 \
-days 3650 \
-keyout "$SNAKEOIL_KEY_HOST_PATH" \
-out "$SNAKEOIL_CERT_HOST_PATH" \
-config "$tmp_config" \
-extensions v3_req >/dev/null 2>&1
rm -f "$tmp_config"
chmod 600 "$SNAKEOIL_KEY_HOST_PATH"
chmod 644 "$SNAKEOIL_CERT_HOST_PATH"
}
generate_nginx_tls_config() {
mkdir -p "$NGINX_CONFIG_DIR"
cat >"$NGINX_CONFIG_HOST_PATH" <<EOF
server {
listen 80;
server_name _;
return 308 https://\$host:8000\$request_uri;
}
server {
listen 443 ssl;
server_name _;
ssl_certificate $SNAKEOIL_CERT_CONTAINER_PATH;
ssl_certificate_key $SNAKEOIL_KEY_CONTAINER_PATH;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
location /api/ws {
proxy_pass http://remoteterm:8000/api/ws;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host \$host;
proxy_set_header X-Forwarded-Host \$host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 8000;
}
location / {
proxy_pass http://remoteterm:8000;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Forwarded-Host \$host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 8000;
proxy_read_timeout 300s;
}
}
EOF
}
echo -e "${BOLD}=== RemoteTerm for MeshCore — Docker Setup ===${NC}"
echo
echo -e " Repo directory : ${CYAN}${REPO_DIR}${NC}"
echo -e " Example compose : ${CYAN}${EXAMPLE_FILE}${NC}"
echo -e " Output compose : ${CYAN}${COMPOSE_FILE}${NC}"
echo
if ! command -v docker &>/dev/null; then
echo -e "${RED}Error: docker was not found in PATH.${NC}"
exit 1
fi
if ! docker compose version &>/dev/null; then
echo -e "${RED}Error: docker compose is required but was not available.${NC}"
exit 1
fi
if [ -f "$COMPOSE_FILE" ]; then
echo -e "${YELLOW}A local docker-compose.yml already exists.${NC}"
read -r -p "Overwrite it? [y/N]: " OVERWRITE
OVERWRITE="${OVERWRITE:-N}"
if ! [[ "$OVERWRITE" =~ ^[Yy]$ ]]; then
echo -e "${YELLOW}Leaving the existing compose file untouched.${NC}"
exit 0
fi
fi
echo -e "${BOLD}─── Image Source ────────────────────────────────────────────────────${NC}"
echo "How should Docker run RemoteTerm?"
echo " 1) Use the published Docker Hub image (default)"
echo " 2) Build locally from this checkout"
echo
read -r -p "Select image mode [1-2] (default: 1): " IMAGE_CHOICE
IMAGE_CHOICE="${IMAGE_CHOICE:-1}"
echo
case "$IMAGE_CHOICE" in
1)
IMAGE_MODE="image"
echo -e "${GREEN}Using published Docker image.${NC}"
;;
2)
IMAGE_MODE="build"
echo -e "${GREEN}Using local Docker build.${NC}"
;;
*)
IMAGE_MODE="image"
echo -e "${YELLOW}Invalid selection; defaulting to published Docker image.${NC}"
;;
esac
echo
echo -e "${BOLD}─── Transport ───────────────────────────────────────────────────────${NC}"
echo "How will the container reach your MeshCore radio?"
echo " 1) Serial device passthrough (default)"
echo " 2) TCP"
echo " 3) BLE"
echo
echo "BLE can be configured here, but Docker Bluetooth access still requires manual compose customization."
echo
read -r -p "Select transport [1-3] (default: 1): " TRANSPORT_CHOICE
TRANSPORT_CHOICE="${TRANSPORT_CHOICE:-1}"
echo
case "$TRANSPORT_CHOICE" in
1)
TRANSPORT_MODE="serial"
find_serial_devices
if ((${#SERIAL_FOUND_HOST_PATHS[@]} == 0)); then
echo -e "${YELLOW}No serial devices were auto-detected.${NC}"
read -r -p "Serial device path on the host (default: /dev/ttyACM0): " SERIAL_HOST_PATH
SERIAL_HOST_PATH="${SERIAL_HOST_PATH:-/dev/ttyACM0}"
else
echo "Detected serial devices:"
for i in "${!SERIAL_FOUND_HOST_PATHS[@]}"; do
printf ' %d) %s (%s)\n' "$((i + 1))" "${SERIAL_FOUND_LABELS[$i]}" "${SERIAL_FOUND_DISPLAYS[$i]}"
done
echo " m) Enter a path manually"
echo
read -r -p "Select serial device [1-${#SERIAL_FOUND_HOST_PATHS[@]} or m] (default: 1): " SERIAL_CHOICE
SERIAL_CHOICE="${SERIAL_CHOICE:-1}"
if [[ "$SERIAL_CHOICE" =~ ^[Mm]$ ]]; then
read -r -p "Serial device path on the host (default: ${SERIAL_FOUND_HOST_PATHS[0]}): " SERIAL_HOST_PATH
SERIAL_HOST_PATH="${SERIAL_HOST_PATH:-${SERIAL_FOUND_HOST_PATHS[0]}}"
elif [[ "$SERIAL_CHOICE" =~ ^[0-9]+$ ]] && [ "$SERIAL_CHOICE" -ge 1 ] && [ "$SERIAL_CHOICE" -le "${#SERIAL_FOUND_HOST_PATHS[@]}" ]; then
SERIAL_HOST_PATH="${SERIAL_FOUND_HOST_PATHS[$((SERIAL_CHOICE - 1))]}"
else
SERIAL_HOST_PATH="${SERIAL_FOUND_HOST_PATHS[0]}"
echo -e "${YELLOW}Invalid selection; defaulting to ${SERIAL_HOST_PATH}.${NC}"
fi
fi
normalize_serial_host_path_for_compose "$SERIAL_HOST_PATH"
echo -e "${GREEN}Serial passthrough: ${SERIAL_COMPOSE_HOST_PATH} -> ${SERIAL_CONTAINER_PATH}${NC}"
;;
2)
TRANSPORT_MODE="tcp"
read -r -p "TCP host (IP address or hostname): " TCP_HOST
while [ -z "$TCP_HOST" ]; do
echo -e "${RED}TCP host is required.${NC}"
read -r -p "TCP host: " TCP_HOST
done
read -r -p "TCP port (default: 4000): " TCP_PORT
TCP_PORT="${TCP_PORT:-4000}"
echo -e "${GREEN}TCP: ${TCP_HOST}:${TCP_PORT}${NC}"
;;
3)
TRANSPORT_MODE="ble"
read -r -p "BLE device address (e.g. AA:BB:CC:DD:EE:FF): " BLE_ADDRESS
while [ -z "$BLE_ADDRESS" ]; do
echo -e "${RED}BLE address is required.${NC}"
read -r -p "BLE device address: " BLE_ADDRESS
done
read -r -s -p "BLE PIN: " BLE_PIN
echo
while [ -z "$BLE_PIN" ]; do
echo -e "${RED}BLE PIN is required.${NC}"
read -r -s -p "BLE PIN: " BLE_PIN
echo
done
echo -e "${GREEN}BLE: ${BLE_ADDRESS}${NC}"
echo
echo -e "${RED}BLE Docker warning:${NC} Bluetooth access is not fully automated here."
echo -e "${RED}You will need to customize docker-compose.yml manually before BLE works.${NC}"
echo "That may include passing through Bluetooth devices, enabling privileged mode,"
echo "using host networking, or other host-specific Docker changes."
echo "If you want the easier path, use the regular Python launch flow for BLE instead."
BLE_MANUAL_WARNING=true
;;
*)
TRANSPORT_MODE="serial"
SERIAL_HOST_PATH="/dev/ttyACM0"
echo -e "${YELLOW}Invalid selection; defaulting to serial passthrough at ${SERIAL_HOST_PATH}.${NC}"
;;
esac
echo
echo -e "${BOLD}─── Bot System ──────────────────────────────────────────────────────${NC}"
echo -e "${YELLOW}Warning:${NC} The bot system executes arbitrary Python code on the server."
echo "It is not recommended on untrusted networks."
echo
read -r -p "Enable bots? [y/N]: " ENABLE_BOTS
ENABLE_BOTS="${ENABLE_BOTS:-N}"
echo
if [[ "$ENABLE_BOTS" =~ ^[Yy]$ ]]; then
echo -e "${GREEN}Bots enabled.${NC}"
echo
echo -e "${BOLD}─── HTTP Basic Auth ─────────────────────────────────────────────────${NC}"
echo "With bots enabled, HTTP Basic Auth is strongly recommended if this"
echo "service will be reachable beyond your local machine."
echo
read -r -p "Set up HTTP Basic Auth? [Y/n]: " ENABLE_AUTH
ENABLE_AUTH="${ENABLE_AUTH:-Y}"
echo
if [[ "$ENABLE_AUTH" =~ ^[Yy]$ ]]; then
read -r -p "Username: " AUTH_USERNAME
while [ -z "$AUTH_USERNAME" ]; do
echo -e "${RED}Username cannot be empty.${NC}"
read -r -p "Username: " AUTH_USERNAME
done
read -r -s -p "Password: " AUTH_PASSWORD
echo
while [ -z "$AUTH_PASSWORD" ]; do
echo -e "${RED}Password cannot be empty.${NC}"
read -r -s -p "Password: " AUTH_PASSWORD
echo
done
echo -e "${GREEN}Basic Auth configured for user '${AUTH_USERNAME}'.${NC}"
fi
else
echo -e "${GREEN}Bots disabled.${NC}"
fi
echo
echo -e "${BOLD}─── HTTPS / Snakeoil TLS ────────────────────────────────────────────${NC}"
echo "Generating a local self-signed certificate enables HTTPS-only browser features"
echo "such as the channel key finder and, in some browsers, notifications."
echo "Browsers will still warn that the certificate is untrusted."
echo
read -r -p "Generate and enable a snakeoil TLS certificate? [Y/n]: " ENABLE_SNAKEOIL_TLS
ENABLE_SNAKEOIL_TLS="${ENABLE_SNAKEOIL_TLS:-Y}"
LOCAL_ACCESS_IP="$(detect_primary_local_ip)"
if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then
ensure_snakeoil_requirements
generate_snakeoil_certificate "$LOCAL_ACCESS_IP"
generate_nginx_tls_config
echo -e "${GREEN}Generated snakeoil TLS certificate in ${SNAKEOIL_CERT_DIR}.${NC}"
echo -e "${GREEN}Generated nginx TLS proxy config in ${NGINX_CONFIG_DIR}.${NC}"
echo -e "${YELLOW}Browsers will show an untrusted/self-signed certificate warning.${NC}"
else
echo -e "${GREEN}Skipping snakeoil TLS generation. The container will serve plain HTTP.${NC}"
fi
echo
if [ "$(uname -s)" = "Linux" ]; then
echo -e "${BOLD}─── Container User ──────────────────────────────────────────────────${NC}"
echo "The container runs as root by default for maximum serial compatibility."
echo "You can override that and run as your host UID/GID instead to avoid"
echo "root-owned files in ./data."
echo
read -r -p "Run as your current UID/GID instead of the default root user? [y/N]: " RUN_AS_HOST_USER
RUN_AS_HOST_USER="${RUN_AS_HOST_USER:-N}"
if [[ "$RUN_AS_HOST_USER" =~ ^[Yy]$ ]] && [ "$TRANSPORT_MODE" = "serial" ]; then
echo
echo -e "${YELLOW}Note:${NC} host-user mode can be less reliable for serial device access than running as root."
echo "It may require extra group setup such as dialout, or other manual"
echo "container customization, depending on your host."
echo "If serial access becomes unreliable, rerun this setup and keep the"
echo "default root user instead."
fi
echo
fi
mkdir -p "$REPO_DIR/data"
{
echo "# Generated by scripts/setup/install_docker.sh"
echo "# This file is gitignored. Re-run the setup script to regenerate it."
echo "services:"
echo " remoteterm:"
if [ "$IMAGE_MODE" = "build" ]; then
echo " build: ."
else
echo " image: docker.io/jkingsman/remoteterm-meshcore:latest"
fi
if [[ "$RUN_AS_HOST_USER" =~ ^[Yy]$ ]]; then
echo " user: \"$(id -u):$(id -g)\""
fi
if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then
echo " expose:"
echo " - \"8000\""
else
echo " ports:"
echo " - \"8000:8000\""
fi
echo " volumes:"
echo " - ./data:/app/data"
if [ "$TRANSPORT_MODE" = "serial" ]; then
echo " devices:"
echo " - ${SERIAL_COMPOSE_HOST_PATH}:${SERIAL_CONTAINER_PATH}"
fi
echo " environment:"
echo " MESHCORE_DATABASE_PATH: $(yaml_quote "data/meshcore.db")"
if [ "$TRANSPORT_MODE" = "serial" ]; then
echo " MESHCORE_SERIAL_PORT: $(yaml_quote "$SERIAL_CONTAINER_PATH")"
elif [ "$TRANSPORT_MODE" = "tcp" ]; then
echo " MESHCORE_TCP_HOST: $(yaml_quote "$TCP_HOST")"
echo " MESHCORE_TCP_PORT: $(yaml_quote "$TCP_PORT")"
else
echo " MESHCORE_BLE_ADDRESS: $(yaml_quote "$BLE_ADDRESS")"
echo " MESHCORE_BLE_PIN: $(yaml_quote "$BLE_PIN")"
fi
if ! [[ "$ENABLE_BOTS" =~ ^[Yy]$ ]]; then
echo " MESHCORE_DISABLE_BOTS: $(yaml_quote "true")"
fi
if [[ "$ENABLE_AUTH" =~ ^[Yy]$ ]]; then
echo " MESHCORE_BASIC_AUTH_USERNAME: $(yaml_quote "$AUTH_USERNAME")"
echo " MESHCORE_BASIC_AUTH_PASSWORD: $(yaml_quote "$AUTH_PASSWORD")"
fi
echo " restart: unless-stopped"
if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then
echo " nginx:"
echo " image: nginx:alpine"
echo " depends_on:"
echo " - remoteterm"
echo " ports:"
echo " - \"80:80\""
echo " - \"8000:443\""
echo " volumes:"
echo " - ./.docker-certs:/etc/nginx/certs:ro"
echo " - ./.docker-nginx/$NGINX_CONFIG_BASENAME:/etc/nginx/conf.d/default.conf:ro"
echo " restart: unless-stopped"
fi
} >"$COMPOSE_FILE"
echo -e "${GREEN}Generated ${COMPOSE_FILE}.${NC}"
echo
echo -e "${BOLD}Docker commands${NC}"
if [ "$IMAGE_MODE" = "build" ]; then
echo " sudo docker compose up -d --build # build the local image and start RemoteTerm in the background"
else
echo " sudo docker compose up -d # start RemoteTerm in the background"
fi
echo " sudo docker compose logs -f # follow the container logs live"
echo
echo " sudo docker compose down # stop and remove the running container"
echo " sudo docker compose restart # restart the container without changing the image"
echo " sudo docker compose pull && sudo docker compose up -d # upgrade to the latest published image and restart"
echo
echo -e "${YELLOW}Note:${NC} serial passthrough generally needs ${BOLD}rootful Docker${NC}."
echo "If Docker is running rootless on this host, serial-device mappings may fail even with a valid compose file."
if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then
echo
echo -e "${GREEN}HTTPS will be handled by an nginx sidecar.${NC}"
echo "Host port 80 will redirect to HTTPS on port 8000."
fi
if [ "$TRANSPORT_MODE" = "ble" ] || [ "$BLE_MANUAL_WARNING" = true ]; then
echo
echo -e "${RED}BLE requires more than the generated env vars.${NC}"
echo -e "${RED}Before starting, edit docker-compose.yml for Bluetooth passthrough and any privileged/network settings your host requires.${NC}"
fi
echo
echo -e "${GREEN}Your new docker file is ready at ${COMPOSE_FILE}.${NC}"
echo -e "${GREEN}Feel free to edit it by hand as desired, or:${NC}"
echo
echo -e "${PURPLE}┌───────────────────────────────────────────────┐${NC}"
echo -e "${PURPLE}│ Run ${GREEN}${BOLD}sudo docker compose up -d${NC}${PURPLE} to get started. │${NC}"
echo -e "${PURPLE}└───────────────────────────────────────────────┘${NC}"
if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then
echo
echo -e "After the container starts, open ${CYAN}https://${LOCAL_ACCESS_IP}:8000${NC}. Note that this address may change if you use DHCP/have not configured a static IP for your host via your router."
echo -e "Plain HTTP on ${CYAN}http://${LOCAL_ACCESS_IP}${NC} will redirect there automatically."
echo -e "${YELLOW}Expect an untrusted/self-signed certificate warning the first time you connect.${NC}"
else
echo
echo -e "After the container starts, open ${CYAN}http://${LOCAL_ACCESS_IP}:8000${NC}. Note that this address may change if you use DHCP/have not configured a static IP for your host via your router."
fi
echo "If the interface does not appear, follow the logs to view errors with:"
echo " sudo docker compose logs -f"
@@ -7,7 +7,7 @@
# gymnastics.
#
# Run from anywhere inside the repo:
# bash scripts/install_service.sh
# bash scripts/setup/install_service.sh
set -e
@@ -19,7 +19,7 @@ BOLD='\033[1m'
NC='\033[0m'
SERVICE_NAME="remoteterm"
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
REPO_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
CURRENT_USER="$(id -un)"
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
FRONTEND_MODE="build"
@@ -252,7 +252,7 @@ if [ "$FRONTEND_MODE" = "build" ]; then
)
else
echo -e "${YELLOW}Fetching prebuilt frontend...${NC}"
python3 "$REPO_DIR/scripts/fetch_prebuilt_frontend.py"
python3 "$REPO_DIR/scripts/setup/fetch_prebuilt_frontend.py"
fi
echo
@@ -402,7 +402,7 @@ echo -e " cd frontend && npm install && npm run build && cd .."
echo -e " sudo systemctl restart ${SERVICE_NAME}"
echo
echo -e "${YELLOW}Refresh prebuilt frontend only (skips local build):${NC}"
echo -e " python3 ${REPO_DIR}/scripts/fetch_prebuilt_frontend.py"
echo -e " python3 ${REPO_DIR}/scripts/setup/fetch_prebuilt_frontend.py"
echo -e " sudo systemctl restart ${SERVICE_NAME}"
echo
echo -e "${YELLOW}View live logs (useful for troubleshooting):${NC}"
+23
View File
@@ -190,6 +190,29 @@ class TestDebugEndpoint:
assert payload["database"]["total_channel_messages"] == 1
assert payload["database"]["total_outgoing"] == 1
@pytest.mark.asyncio
async def test_support_snapshot_uses_lightweight_message_totals(self, test_db, client):
"""Debug snapshot should not call the full statistics aggregation."""
with (
patch(
"app.routers.debug.StatisticsRepository.get_all",
new=AsyncMock(side_effect=AssertionError("get_all should not be called")),
),
patch(
"app.routers.debug.StatisticsRepository.get_database_message_totals",
new=AsyncMock(
return_value={
"total_dms": 0,
"total_channel_messages": 0,
"total_outgoing": 0,
}
),
),
):
response = await client.get("/api/debug")
assert response.status_code == 200
class TestRadioDisconnectedHandler:
"""Test that RadioDisconnectedError maps to 503."""
+1 -2
View File
@@ -11,8 +11,6 @@ import pytest
from app.event_handlers import (
_active_subscriptions,
_buffered_acks,
_pending_acks,
cleanup_expired_acks,
register_event_handlers,
track_pending_ack,
@@ -23,6 +21,7 @@ from app.repository import (
ContactRepository,
MessageRepository,
)
from app.services.dm_ack_tracker import _buffered_acks, _pending_acks
@pytest.fixture(autouse=True)
+78 -14
View File
@@ -1247,8 +1247,8 @@ class TestMigration039:
applied = await run_migrations(conn)
assert applied == 8
assert await get_version(conn) == 46
assert applied == 9
assert await get_version(conn) == 47
cursor = await conn.execute(
"""
@@ -1319,8 +1319,8 @@ class TestMigration039:
applied = await run_migrations(conn)
assert applied == 8
assert await get_version(conn) == 46
assert applied == 9
assert await get_version(conn) == 47
cursor = await conn.execute(
"""
@@ -1386,8 +1386,8 @@ class TestMigration039:
applied = await run_migrations(conn)
assert applied == 2
assert await get_version(conn) == 46
assert applied == 3
assert await get_version(conn) == 47
cursor = await conn.execute(
"""
@@ -1439,8 +1439,8 @@ class TestMigration040:
applied = await run_migrations(conn)
assert applied == 7
assert await get_version(conn) == 46
assert applied == 8
assert await get_version(conn) == 47
await conn.execute(
"""
@@ -1501,8 +1501,8 @@ class TestMigration041:
applied = await run_migrations(conn)
assert applied == 6
assert await get_version(conn) == 46
assert applied == 7
assert await get_version(conn) == 47
await conn.execute(
"""
@@ -1554,8 +1554,8 @@ class TestMigration042:
applied = await run_migrations(conn)
assert applied == 5
assert await get_version(conn) == 46
assert applied == 6
assert await get_version(conn) == 47
await conn.execute(
"""
@@ -1694,8 +1694,8 @@ class TestMigration046:
applied = await run_migrations(conn)
assert applied == 1
assert await get_version(conn) == 46
assert applied == 2
assert await get_version(conn) == 47
cursor = await conn.execute(
"""
@@ -1750,6 +1750,70 @@ class TestMigration046:
await conn.close()
class TestMigration047:
"""Test migration 047: add statistics indexes."""
@pytest.mark.asyncio
async def test_adds_statistics_indexes(self):
conn = await aiosqlite.connect(":memory:")
conn.row_factory = aiosqlite.Row
try:
await set_version(conn, 46)
await conn.execute("""
CREATE TABLE contacts (
public_key TEXT PRIMARY KEY,
name TEXT,
type INTEGER DEFAULT 0,
last_seen INTEGER
)
""")
await conn.execute("""
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
conversation_key TEXT NOT NULL,
received_at INTEGER NOT NULL
)
""")
await conn.execute("""
CREATE TABLE raw_packets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
data BLOB NOT NULL,
message_id INTEGER,
payload_hash BLOB
)
""")
await conn.commit()
applied = await run_migrations(conn)
assert applied == 1
assert await get_version(conn) == 47
cursor = await conn.execute(
"""
SELECT name
FROM sqlite_master
WHERE type = 'index'
AND name IN (
'idx_raw_packets_timestamp',
'idx_contacts_type_last_seen',
'idx_messages_type_received_conversation'
)
ORDER BY name
"""
)
rows = await cursor.fetchall()
assert [row["name"] for row in rows] == [
"idx_contacts_type_last_seen",
"idx_messages_type_received_conversation",
"idx_raw_packets_timestamp",
]
finally:
await conn.close()
class TestMigrationPacketHelpers:
"""Test migration-local packet helpers against canonical path validation."""
+38 -1
View File
@@ -5,7 +5,7 @@ undecrypted count endpoint, and the maintenance endpoint.
"""
import time
from unittest.mock import patch
from unittest.mock import AsyncMock, patch
import pytest
@@ -305,6 +305,43 @@ class TestDecryptHistoricalPackets:
assert "key_type" in data["detail"].lower()
class TestUndecryptedTextPacketStreaming:
@pytest.mark.asyncio
async def test_count_undecrypted_text_messages_uses_batched_streaming(self, test_db):
"""Counting undecrypted DM packets should stream batches and filter by payload type."""
class FakeCursor:
def __init__(self):
self._batches = [
[
{"id": 1, "data": b"\x09\x00dm", "timestamp": 1000},
{"id": 2, "data": b"\x15\x00chan", "timestamp": 1001},
],
[{"id": 3, "data": b"\x09\x00dm2", "timestamp": 1002}],
[],
]
self.fetchall_called = False
async def fetchmany(self, size):
assert size > 0
return self._batches.pop(0)
async def close(self):
return None
async def fetchall(self):
self.fetchall_called = True
raise AssertionError("fetchall() should not be used")
fake_cursor = FakeCursor()
with patch.object(test_db.conn, "execute", new=AsyncMock(return_value=fake_cursor)):
count = await RawPacketRepository.count_undecrypted_text_messages(batch_size=2)
assert fake_cursor.fetchall_called is False
assert count == 2
class TestRunHistoricalChannelDecryption:
"""Test the _run_historical_channel_decryption background task."""
+37
View File
@@ -0,0 +1,37 @@
import asyncio
from unittest.mock import patch
import pytest
from app.services import radio_noise_floor
class TestNoiseFloorSamplingLoop:
@pytest.mark.asyncio
async def test_logs_and_continues_after_unexpected_sample_exception(self):
sample_calls = 0
sleep_calls = 0
async def fake_sample() -> None:
nonlocal sample_calls
sample_calls += 1
if sample_calls == 1:
raise RuntimeError("boom")
async def fake_sleep(_seconds: int) -> None:
nonlocal sleep_calls
sleep_calls += 1
if sleep_calls >= 2:
raise asyncio.CancelledError()
with (
patch.object(radio_noise_floor, "sample_noise_floor_once", side_effect=fake_sample),
patch.object(radio_noise_floor.asyncio, "sleep", side_effect=fake_sleep),
patch.object(radio_noise_floor.logger, "exception") as mock_exception,
):
with pytest.raises(asyncio.CancelledError):
await radio_noise_floor._noise_floor_sampling_loop()
assert sample_calls == 2
assert sleep_calls == 2
mock_exception.assert_called_once()
+240 -3
View File
@@ -2,14 +2,14 @@
import asyncio
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import ANY, AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException
from meshcore import EventType
from pydantic import ValidationError
from app.models import Contact
from app.models import CONTACT_TYPE_REPEATER, Contact, RadioTraceHopRequest, RadioTraceRequest
from app.radio import RadioManager, radio_manager
from app.routers.radio import (
PrivateKeyUpdate,
@@ -25,6 +25,7 @@ from app.routers.radio import (
reconnect_radio,
send_advertisement,
set_private_key,
trace_path,
update_radio_config,
)
from app.services.radio_runtime import RadioRuntime
@@ -375,6 +376,11 @@ class TestDiscoverMesh:
return_value=None,
),
patch("app.routers.radio.ContactRepository.upsert", new_callable=AsyncMock),
patch(
"app.routers.radio.promote_prefix_contacts_for_contact",
new_callable=AsyncMock,
return_value=[],
),
patch("app.routers.radio.broadcast_event"),
):
response = await discover_mesh(RadioDiscoveryRequest(target="repeaters"))
@@ -436,18 +442,27 @@ class TestDiscoverMesh:
patch(
"app.routers.radio.ContactRepository.get_by_key",
new_callable=AsyncMock,
side_effect=[None, created_contact],
# 1st: _persist check (not found), 2nd: _persist re-fetch (created),
# 3rd: _attach_known_names lookup
side_effect=[None, created_contact, created_contact],
) as mock_get_by_key,
patch(
"app.routers.radio.ContactRepository.upsert", new_callable=AsyncMock
) as mock_upsert,
patch(
"app.routers.radio.promote_prefix_contacts_for_contact",
new_callable=AsyncMock,
return_value=[],
) as mock_promote,
patch("app.routers.radio.broadcast_event") as mock_broadcast,
):
response = await discover_mesh(RadioDiscoveryRequest(target="repeaters"))
assert len(response.results) == 1
assert response.results[0].name is None # created_contact has no name
mock_get_by_key.assert_awaited()
mock_upsert.assert_awaited_once()
mock_promote.assert_awaited_once_with(public_key="44" * 32, log=ANY)
upsert_arg = mock_upsert.await_args.args[0]
assert upsert_arg.public_key == "44" * 32
assert upsert_arg.type == 2
@@ -510,6 +525,223 @@ class TestDiscoverMesh:
mock_upsert.assert_not_awaited()
mock_broadcast.assert_not_called()
class TestTracePath:
@pytest.mark.asyncio
async def test_returns_resolved_nodes_for_multi_hop_trace(self):
mc = _mock_meshcore_with_info()
repeater_a = Contact(
public_key="11" * 32,
name="Relay Alpha",
type=CONTACT_TYPE_REPEATER,
flags=0,
direct_path=None,
direct_path_len=-1,
direct_path_hash_mode=-1,
last_advert=None,
lat=None,
lon=None,
last_seen=None,
on_radio=False,
last_contacted=None,
last_read_at=None,
first_seen=None,
)
repeater_b = Contact(
public_key="22" * 32,
name="Relay Beta",
type=CONTACT_TYPE_REPEATER,
flags=0,
direct_path=None,
direct_path_len=-1,
direct_path_hash_mode=-1,
last_advert=None,
lat=None,
lon=None,
last_seen=None,
on_radio=False,
last_contacted=None,
last_read_at=None,
first_seen=None,
)
mc.commands.send_trace = AsyncMock(
return_value=_radio_result(EventType.MSG_SENT, {"suggested_timeout": 4000})
)
mc.wait_for_event = AsyncMock(
return_value=MagicMock(
payload={
"path_len": 2,
"path": [
{"hash": "11111111", "snr": 7.5},
{"hash": "22222222", "snr": 3.25},
{"snr": 5.0},
],
}
)
)
with (
patch("app.routers.radio.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch(
"app.routers.radio.ContactRepository.get_by_key", new_callable=AsyncMock
) as mock_get,
patch("app.routers.radio.radio_manager") as mock_rm,
):
mock_get.side_effect = [repeater_a, repeater_b]
mock_rm.radio_operation = _noop_radio_operation(mc)
response = await trace_path(
RadioTraceRequest(
hop_hash_bytes=4,
hops=[
RadioTraceHopRequest(public_key=repeater_a.public_key),
RadioTraceHopRequest(public_key=repeater_b.public_key),
],
)
)
mc.commands.send_trace.assert_awaited_once_with(
path="11111111,22222222",
tag=ANY,
flags=2,
)
mc.wait_for_event.assert_awaited_once()
assert response.path_len == 2
assert response.nodes[0].name == "Relay Alpha"
assert response.nodes[0].snr == 7.5
assert response.nodes[1].name == "Relay Beta"
assert response.nodes[1].observed_hash == "22222222"
assert response.nodes[2].role == "local"
assert response.nodes[2].public_key == "aa" * 32
assert response.nodes[2].observed_hash is None
assert response.nodes[2].snr == 5.0
@pytest.mark.asyncio
async def test_rejects_non_repeater_nodes(self):
mc = _mock_meshcore_with_info()
non_repeater = Contact(
public_key="33" * 32,
name="Client",
type=1,
flags=0,
direct_path=None,
direct_path_len=-1,
direct_path_hash_mode=-1,
last_advert=None,
lat=None,
lon=None,
last_seen=None,
on_radio=False,
last_contacted=None,
last_read_at=None,
first_seen=None,
)
with (
patch("app.routers.radio.require_connected", return_value=mc),
patch(
"app.routers.radio.ContactRepository.get_by_key", new_callable=AsyncMock
) as mock_get,
):
mock_get.return_value = non_repeater
with pytest.raises(HTTPException) as exc:
await trace_path(
RadioTraceRequest(
hop_hash_bytes=4,
hops=[RadioTraceHopRequest(public_key=non_repeater.public_key)],
)
)
assert exc.value.status_code == 400
assert "not a repeater" in exc.value.detail
@pytest.mark.asyncio
async def test_returns_504_when_no_trace_response_is_heard(self):
mc = _mock_meshcore_with_info()
repeater = Contact(
public_key="44" * 32,
name="Relay",
type=CONTACT_TYPE_REPEATER,
flags=0,
direct_path=None,
direct_path_len=-1,
direct_path_hash_mode=-1,
last_advert=None,
lat=None,
lon=None,
last_seen=None,
on_radio=False,
last_contacted=None,
last_read_at=None,
first_seen=None,
)
mc.commands.send_trace = AsyncMock(
return_value=_radio_result(EventType.MSG_SENT, {"suggested_timeout": 1000})
)
mc.wait_for_event = AsyncMock(return_value=None)
with (
patch("app.routers.radio.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch(
"app.routers.radio.ContactRepository.get_by_key", new_callable=AsyncMock
) as mock_get,
patch("app.routers.radio.radio_manager") as mock_rm,
):
mock_get.return_value = repeater
mock_rm.radio_operation = _noop_radio_operation(mc)
with pytest.raises(HTTPException) as exc:
await trace_path(
RadioTraceRequest(
hop_hash_bytes=4,
hops=[RadioTraceHopRequest(public_key=repeater.public_key)],
)
)
assert exc.value.status_code == 504
assert "No trace response heard" in exc.value.detail
@pytest.mark.asyncio
async def test_supports_custom_hops_with_shorter_hash_width(self):
mc = _mock_meshcore_with_info()
mc.commands.send_trace = AsyncMock(
return_value=_radio_result(EventType.MSG_SENT, {"suggested_timeout": 2500})
)
mc.wait_for_event = AsyncMock(
return_value=MagicMock(
payload={
"path_len": 2,
"path": [
{"hash": "ae", "snr": 4.0},
{"hash": "bf", "snr": 2.5},
{"snr": 3.0},
],
}
)
)
with (
patch("app.routers.radio.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch("app.routers.radio.radio_manager") as mock_rm,
):
mock_rm.radio_operation = _noop_radio_operation(mc)
response = await trace_path(
RadioTraceRequest(
hop_hash_bytes=1,
hops=[
RadioTraceHopRequest(hop_hex="ae"),
RadioTraceHopRequest(hop_hex="bf"),
],
)
)
mc.commands.send_trace.assert_awaited_once_with(path="ae,bf", tag=ANY, flags=0)
assert response.nodes[0].role == "custom"
assert response.nodes[0].observed_hash == "ae"
assert response.nodes[1].role == "custom"
assert response.nodes[1].observed_hash == "bf"
@pytest.mark.asyncio
async def test_discovers_all_supported_types(self):
mc = _mock_meshcore_with_info()
@@ -542,6 +774,11 @@ class TestDiscoverMesh:
return_value=None,
),
patch("app.routers.radio.ContactRepository.upsert", new_callable=AsyncMock),
patch(
"app.routers.radio.promote_prefix_contacts_for_contact",
new_callable=AsyncMock,
return_value=[],
),
patch("app.routers.radio.broadcast_event"),
):
response = await discover_mesh(RadioDiscoveryRequest(target="all"))
+68 -33
View File
@@ -12,7 +12,6 @@ from app.repository import ContactRepository
from app.routers.contacts import request_trace
from app.routers.repeaters import (
_batch_cli_fetch,
_fetch_repeater_response,
prepare_repeater_connection,
repeater_acl,
repeater_advert_intervals,
@@ -25,12 +24,17 @@ from app.routers.repeaters import (
repeater_status,
send_repeater_command,
)
from app.routers.server_control import fetch_contact_cli_response
KEY_A = "aa" * 32
# Patch target for the wall-clock wrapper used by _fetch_repeater_response.
# Patch target for the wall-clock wrapper used by fetch_contact_cli_response.
# We patch _monotonic (not time.monotonic) to avoid breaking the asyncio event loop.
_MONOTONIC = "app.routers.repeaters._monotonic"
_MONOTONIC = "app.routers.server_control._monotonic"
# Patch targets for the store helpers called on consumed non-target messages.
_STORE_DM = "app.routers.server_control._store_pending_direct_message"
_STORE_CHAN = "app.routers.server_control._store_pending_channel_message"
@pytest.fixture(autouse=True)
@@ -104,8 +108,8 @@ def _advancing_clock(start=0.0, step=0.1):
return _tick
class TestFetchRepeaterResponse:
"""Tests for the _fetch_repeater_response helper."""
class TestFetchContactCliResponse:
"""Tests for the fetch_contact_cli_response helper."""
@pytest.mark.asyncio
async def test_returns_matching_cli_response(self):
@@ -118,7 +122,7 @@ class TestFetchRepeaterResponse:
)
with patch(_MONOTONIC, side_effect=_advancing_clock()):
result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=5.0)
result = await fetch_contact_cli_response(mc, "aaaaaaaaaaaa", timeout=5.0)
assert result is not None
assert result.payload["text"] == "ok"
@@ -138,16 +142,20 @@ class TestFetchRepeaterResponse:
)
mc.commands.get_msg = AsyncMock(side_effect=[non_cli, cli_response])
with patch(_MONOTONIC, side_effect=_advancing_clock()):
result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=5.0)
with (
patch(_MONOTONIC, side_effect=_advancing_clock()),
patch(_STORE_DM, new_callable=AsyncMock) as store_dm,
):
result = await fetch_contact_cli_response(mc, "aaaaaaaaaaaa", timeout=5.0)
assert result is not None
assert result.payload["text"] == "ver 1.0"
assert mc.commands.get_msg.await_count == 2
store_dm.assert_awaited_once_with(non_cli)
@pytest.mark.asyncio
async def test_unrelated_dm_is_skipped(self):
"""Unrelated DMs are skipped (dispatcher already handled them)."""
async def test_unrelated_dm_is_stored(self):
"""Unrelated DMs consumed during CLI fetch are stored, not discarded."""
mc = _mock_mc()
unrelated = _radio_result(
EventType.CONTACT_MSG_RECV,
@@ -159,14 +167,18 @@ class TestFetchRepeaterResponse:
)
mc.commands.get_msg = AsyncMock(side_effect=[unrelated, expected])
with patch(_MONOTONIC, side_effect=_advancing_clock()):
result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=5.0)
with (
patch(_MONOTONIC, side_effect=_advancing_clock()),
patch(_STORE_DM, new_callable=AsyncMock) as store_dm,
):
result = await fetch_contact_cli_response(mc, "aaaaaaaaaaaa", timeout=5.0)
assert result is not None
assert result.payload["text"] == "ver 1.0"
store_dm.assert_awaited_once_with(unrelated)
@pytest.mark.asyncio
async def test_channel_message_is_skipped(self):
async def test_channel_message_is_stored(self):
mc = _mock_mc()
channel_msg = _radio_result(
EventType.CHANNEL_MSG_RECV,
@@ -178,11 +190,15 @@ class TestFetchRepeaterResponse:
)
mc.commands.get_msg = AsyncMock(side_effect=[channel_msg, expected])
with patch(_MONOTONIC, side_effect=_advancing_clock()):
result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=5.0)
with (
patch(_MONOTONIC, side_effect=_advancing_clock()),
patch(_STORE_CHAN, new_callable=AsyncMock) as store_chan,
):
result = await fetch_contact_cli_response(mc, "aaaaaaaaaaaa", timeout=5.0)
assert result is not None
assert result.payload["text"] == "ok"
store_chan.assert_awaited_once_with(mc, channel_msg.payload)
@pytest.mark.asyncio
async def test_no_more_msgs_retries_then_succeeds(self):
@@ -196,9 +212,9 @@ class TestFetchRepeaterResponse:
with (
patch(_MONOTONIC, side_effect=_advancing_clock()),
patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock),
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
):
result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=5.0)
result = await fetch_contact_cli_response(mc, "aaaaaaaaaaaa", timeout=5.0)
assert result is not None
assert result.payload["text"] == "ok"
@@ -215,9 +231,9 @@ class TestFetchRepeaterResponse:
with (
patch(_MONOTONIC, side_effect=times),
patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock),
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
):
result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=2.0)
result = await fetch_contact_cli_response(mc, "aaaaaaaaaaaa", timeout=2.0)
assert result is None
@@ -233,16 +249,16 @@ class TestFetchRepeaterResponse:
with (
patch(_MONOTONIC, side_effect=_advancing_clock()),
patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock),
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
):
result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=5.0)
result = await fetch_contact_cli_response(mc, "aaaaaaaaaaaa", timeout=5.0)
assert result is not None
assert result.payload["text"] == "ok"
@pytest.mark.asyncio
async def test_high_traffic_does_not_exhaust_budget(self):
"""Many unrelated messages don't prevent eventual success (wall-clock deadline)."""
async def test_high_traffic_stores_all_consumed_messages(self):
"""Many unrelated messages are stored and don't prevent eventual success."""
mc = _mock_mc()
# 20 unrelated DMs followed by the expected CLI response
unrelated = [
@@ -258,12 +274,16 @@ class TestFetchRepeaterResponse:
)
mc.commands.get_msg = AsyncMock(side_effect=[*unrelated, expected])
with patch(_MONOTONIC, side_effect=_advancing_clock()):
result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=30.0)
with (
patch(_MONOTONIC, side_effect=_advancing_clock()),
patch(_STORE_DM, new_callable=AsyncMock) as store_dm,
):
result = await fetch_contact_cli_response(mc, "aaaaaaaaaaaa", timeout=30.0)
assert result is not None
assert result.payload["text"] == "ver 1.0"
assert mc.commands.get_msg.await_count == 21
assert store_dm.await_count == 20
class TestRepeaterCommandRoute:
@@ -297,7 +317,7 @@ class TestRepeaterCommandRoute:
patch("app.routers.repeaters.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch(_MONOTONIC, side_effect=[0.0, 5.0, 25.0]),
patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock),
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
):
response = await send_repeater_command(KEY_A, CommandRequest(command="ver"))
@@ -457,7 +477,7 @@ class TestRepeaterCommandRoute:
patch("app.routers.repeaters.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch(_MONOTONIC, side_effect=_advancing_clock()),
patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock),
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
):
response = await send_repeater_command(KEY_A, CommandRequest(command="ver"))
@@ -483,6 +503,11 @@ class TestTraceRoute:
await request_trace(KEY_A)
assert exc.value.status_code == 500
mc.commands.send_trace.assert_awaited_once_with(
path=KEY_A[:8],
tag=1234,
flags=2,
)
@pytest.mark.asyncio
async def test_wait_timeout_returns_504(self, test_db):
@@ -500,6 +525,11 @@ class TestTraceRoute:
await request_trace(KEY_A)
assert exc.value.status_code == 504
mc.commands.send_trace.assert_awaited_once_with(
path=KEY_A[:8],
tag=1234,
flags=2,
)
@pytest.mark.asyncio
async def test_success_returns_remote_and_local_snr(self, test_db):
@@ -520,6 +550,11 @@ class TestTraceRoute:
assert response.remote_snr == 5.5
assert response.local_snr == 3.2
assert response.path_len == 2
mc.commands.send_trace.assert_awaited_once_with(
path=KEY_A[:8],
tag=1234,
flags=2,
)
# ---------------------------------------------------------------------------
@@ -983,7 +1018,7 @@ class TestRepeaterRadioSettings:
patch("app.routers.repeaters.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch(_MONOTONIC, side_effect=clock_ticks),
patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock),
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
):
response = await repeater_radio_settings(KEY_A)
@@ -1058,7 +1093,7 @@ class TestRepeaterNodeInfo:
patch("app.routers.repeaters.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch(_MONOTONIC, side_effect=clock_ticks),
patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock),
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
):
response = await repeater_node_info(KEY_A)
@@ -1111,7 +1146,7 @@ class TestRepeaterAdvertIntervals:
patch("app.routers.repeaters.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch(_MONOTONIC, side_effect=clock_ticks),
patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock),
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
):
response = await repeater_advert_intervals(KEY_A)
@@ -1166,7 +1201,7 @@ class TestRepeaterOwnerInfo:
patch("app.routers.repeaters.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch(_MONOTONIC, side_effect=clock_ticks),
patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock),
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
):
response = await repeater_owner_info(KEY_A)
@@ -1224,7 +1259,7 @@ class TestBatchCliFetch:
with (
patch.object(radio_manager, "_meshcore", mc),
patch(_MONOTONIC, side_effect=_advancing_clock()),
patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock),
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
):
results = await _batch_cli_fetch(
contact, "test_op", [("bad_cmd", "field_a"), ("good_cmd", "field_b")]
@@ -1245,7 +1280,7 @@ class TestBatchCliFetch:
with (
patch.object(radio_manager, "_meshcore", mc),
patch(_MONOTONIC, side_effect=[0.0, 5.0, 11.0]),
patch("app.routers.repeaters.asyncio.sleep", new_callable=AsyncMock),
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
):
results = await _batch_cli_fetch(contact, "test_op", [("clock", "clock_output")])
+74
View File
@@ -1,6 +1,8 @@
"""Tests for the statistics repository and endpoint."""
import time
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
@@ -347,3 +349,75 @@ class TestPathHashWidthStats:
assert breakdown["single_byte_pct"] == pytest.approx(100 / 3, rel=1e-3)
assert breakdown["double_byte_pct"] == pytest.approx(100 / 3, rel=1e-3)
assert breakdown["triple_byte_pct"] == pytest.approx(100 / 3, rel=1e-3)
@pytest.mark.asyncio
async def test_path_hash_width_scan_uses_batched_fetchmany(self, test_db):
"""Hash-width stats should stream batches instead of calling fetchall()."""
class FakeCursor:
def __init__(self):
self._batches = [
[{"data": b"a"}, {"data": b"b"}],
[{"data": b"c"}],
[],
]
self.fetchall_called = False
async def fetchmany(self, size):
assert size > 0
return self._batches.pop(0)
async def fetchall(self):
self.fetchall_called = True
raise AssertionError("fetchall() should not be used")
fake_cursor = FakeCursor()
def fake_parse(raw_packet: bytes):
hash_sizes = {
b"a": 1,
b"b": 2,
b"c": 3,
}
hash_size = hash_sizes.get(raw_packet)
if hash_size is None:
return None
return SimpleNamespace(hash_size=hash_size)
with (
patch.object(test_db.conn, "execute", new=AsyncMock(return_value=fake_cursor)),
patch("app.repository.settings.parse_packet_envelope", side_effect=fake_parse),
):
breakdown = await StatisticsRepository._path_hash_width_24h()
assert fake_cursor.fetchall_called is False
assert breakdown["total_packets"] == 3
assert breakdown["single_byte"] == 1
assert breakdown["double_byte"] == 1
assert breakdown["triple_byte"] == 1
class TestStatisticsEndpoint:
@pytest.mark.asyncio
async def test_statistics_endpoint_includes_noise_floor_history(self, test_db, client):
noise_floor_history = {
"sample_interval_seconds": 300,
"coverage_seconds": 1800,
"latest_noise_floor_dbm": -119,
"latest_timestamp": 1_700_000_000,
"supported": True,
"samples": [
{"timestamp": 1_699_998_200, "noise_floor_dbm": -121},
{"timestamp": 1_700_000_000, "noise_floor_dbm": -119},
],
}
with patch(
"app.routers.statistics.get_noise_floor_history",
new=AsyncMock(return_value=noise_floor_history),
):
response = await client.get("/api/statistics")
assert response.status_code == 200
payload = response.json()
assert payload["noise_floor_24h"] == noise_floor_history
Generated
+1 -1
View File
@@ -1098,7 +1098,7 @@ wheels = [
[[package]]
name = "remoteterm-meshcore"
version = "3.6.2"
version = "3.6.3"
source = { virtual = "." }
dependencies = [
{ name = "aiomqtt" },