mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-13 12:56:05 +02:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8843836bdf | |||
| e631f9b0cc | |||
| b52431616e | |||
| 8446d99df1 | |||
| 8e1e913fcd | |||
| b74137dc72 | |||
| c83f9b0005 | |||
| 9f4737d350 | |||
| 29e9a5f701 | |||
| f0f06671cc | |||
| b1595e479c | |||
| 25df69bfbc | |||
| 88140081b9 | |||
| 4326f57977 | |||
| 43abcd07b2 | |||
| 5c60559cb8 | |||
| 3c0d6a4466 | |||
| 7b9d8f6a23 | |||
| 44d6fcac24 | |||
| 788d1cbdca | |||
| 26e8150092 | |||
| 3a1c2d691b | |||
| 134e8d0d29 | |||
| eb1f7ae638 | |||
| 14ba342160 | |||
| 7460c3ea9d | |||
| 6534946bc7 | |||
| 4847813ae1 | |||
| 3f6efaae1d | |||
| 60f3fa8e36 | |||
| b42ca44ba7 | |||
| d4bbb8a542 | |||
| db248302e9 | |||
| 7aa4f76064 | |||
| f01e91defc |
@@ -2,6 +2,8 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[oc]
|
*.py[oc]
|
||||||
build/
|
build/
|
||||||
|
!scripts/build/
|
||||||
|
!scripts/build/**
|
||||||
wheels/
|
wheels/
|
||||||
*.egg-info
|
*.egg-info
|
||||||
|
|
||||||
@@ -23,3 +25,9 @@ references/
|
|||||||
|
|
||||||
# ancillary LLM files
|
# ancillary LLM files
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
# local Docker compose files
|
||||||
|
docker-compose.yml
|
||||||
|
docker-compose.yaml
|
||||||
|
.docker-certs/
|
||||||
|
.docker-nginx/
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
If instructed to "run all tests" or "get ready for a commit" or other summative, work ending directives, run:
|
If instructed to "run all tests" or "get ready for a commit" or other summative, work ending directives, run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/all_quality.sh
|
./scripts/quality/all_quality.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
This is the repo's end-to-end quality gate. It runs backend/frontend autofixers first, then type checking, tests, and the standard frontend build. All checks must pass green, and the script may leave formatting/lint edits behind.
|
This is the repo's end-to-end quality gate. It runs backend/frontend autofixers first, then type checking, tests, and the standard frontend build. All checks must pass green, and the script may leave formatting/lint edits behind.
|
||||||
@@ -210,10 +210,16 @@ This message-layer echo/path handling is independent of raw-packet storage dedup
|
|||||||
│ │ └── ...
|
│ │ └── ...
|
||||||
│ └── vite.config.ts
|
│ └── vite.config.ts
|
||||||
├── scripts/ # Quality / release helpers (listing below is representative, not exhaustive)
|
├── scripts/ # Quality / release helpers (listing below is representative, not exhaustive)
|
||||||
│ ├── all_quality.sh # Repo-standard autofix + validate gate
|
│ ├── build/
|
||||||
│ ├── collect_licenses.sh # Gather third-party license attributions
|
│ │ ├── collect_licenses.sh # Gather third-party license attributions
|
||||||
│ ├── e2e.sh # End-to-end test runner
|
│ │ └── publish.sh # Version bump, changelog, docker build & push
|
||||||
│ └── publish.sh # Version bump, changelog, docker build & push
|
│ ├── quality/
|
||||||
|
│ │ ├── all_quality.sh # Repo-standard autofix + validate gate
|
||||||
|
│ │ ├── e2e.sh # End-to-end test runner
|
||||||
|
│ │ └── extended_quality.sh # Quality gate plus e2e and Docker matrix
|
||||||
|
│ └── setup/
|
||||||
|
│ ├── fetch_prebuilt_frontend.py # Download release frontend fallback
|
||||||
|
│ └── install_service.sh # Install/configure Linux systemd service
|
||||||
├── README_ADVANCED.md # Advanced setup, troubleshooting, and service guidance
|
├── README_ADVANCED.md # Advanced setup, troubleshooting, and service guidance
|
||||||
├── CONTRIBUTING.md # Contributor workflow and testing guidance
|
├── CONTRIBUTING.md # Contributor workflow and testing guidance
|
||||||
├── tests/ # Backend tests (pytest)
|
├── tests/ # Backend tests (pytest)
|
||||||
@@ -271,23 +277,23 @@ PYTHONPATH=. uv run pytest tests/ -v
|
|||||||
```
|
```
|
||||||
|
|
||||||
Key test files:
|
Key test files:
|
||||||
- `tests/test_decoder.py` - Channel + direct message decryption, key exchange
|
- `tests/test_api.py` - Broad API integration coverage across routers and read-state flows
|
||||||
- `tests/test_keystore.py` - Ephemeral key store
|
- `tests/test_packet_pipeline.py` - End-to-end packet processing, decrypt, dedup, and message creation
|
||||||
- `tests/test_event_handlers.py` - ACK tracking, repeat detection
|
- `tests/test_event_handlers.py` - ACK tracking, fallback DM handling, and event subscription cleanup
|
||||||
- `tests/test_packet_pipeline.py` - End-to-end packet processing
|
- `tests/test_send_messages.py` - Outgoing DM/channel send workflows, retries, and bot-trigger wiring
|
||||||
- `tests/test_api.py` - API endpoints, read state tracking
|
- `tests/test_packets_router.py` - Historical decrypt, maintenance, and raw-packet detail endpoints
|
||||||
- `tests/test_migrations.py` - Database migration system
|
- `tests/test_repeater_routes.py` - Repeater command/telemetry/trace pane endpoints
|
||||||
- `tests/test_frontend_static.py` - Frontend static route registration (missing `dist`/`index.html` handling)
|
- `tests/test_room_routes.py` - Room-server login/status/ACL/telemetry endpoints
|
||||||
- `tests/test_messages_search.py` - Message search, around endpoint, forward pagination
|
- `tests/test_radio_router.py` - Radio config, advert, discovery, trace, and reconnect endpoints
|
||||||
- `tests/test_rx_log_data.py` - on_rx_log_data event handler integration
|
- `tests/test_radio_sync.py` - Radio sync, periodic tasks, contact offload/reload, and pending-message flushes
|
||||||
- `tests/test_ack_tracking_wiring.py` - DM ACK tracking extraction and wiring
|
- `tests/test_fanout.py` - Fanout config CRUD, scope matching, and manager dispatch
|
||||||
- `tests/test_radio_lifecycle_service.py` - Radio reconnect/setup orchestration helpers
|
- `tests/test_fanout_integration.py` - Integration-module lifecycle and delivery behavior
|
||||||
- `tests/test_radio_commands_service.py` - Radio config/private-key service workflows
|
- `tests/test_statistics.py` - Aggregated mesh/network statistics and noise-floor snapshots
|
||||||
- `tests/test_health_mqtt_status.py` - Health endpoint MQTT status field
|
- `tests/test_version_info.py` - Version/build metadata resolution
|
||||||
- `tests/test_community_mqtt.py` - Community MQTT publisher (JWT, packet format, hash, broadcast)
|
- `tests/test_websocket.py` - WS manager broadcast and cleanup behavior
|
||||||
- `tests/test_radio_sync.py` - Radio sync, periodic tasks, and contact offload back to the radio
|
- `tests/test_frontend_static.py` - Frontend static route registration and fallback behavior
|
||||||
- `tests/test_real_crypto.py` - Real cryptographic operations
|
|
||||||
- `tests/test_disable_bots.py` - MESHCORE_DISABLE_BOTS=true feature
|
For the fuller backend inventory, see `app/AGENTS.md`. For frontend-specific suites, see `frontend/AGENTS.md`.
|
||||||
|
|
||||||
### Frontend (Vitest)
|
### Frontend (Vitest)
|
||||||
|
|
||||||
@@ -298,7 +304,7 @@ npm run test:run
|
|||||||
|
|
||||||
### Before Completing Major Changes
|
### 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
|
## 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 |
|
| PUT | `/api/radio/private-key` | Import private key to radio |
|
||||||
| POST | `/api/radio/advertise` | Send advertisement (`mode`: `flood` or `zero_hop`, default `flood`) |
|
| POST | `/api/radio/advertise` | Send advertisement (`mode`: `flood` or `zero_hop`, default `flood`) |
|
||||||
| POST | `/api/radio/discover` | Run a short mesh discovery sweep for nearby repeaters/sensors |
|
| POST | `/api/radio/discover` | Run a short mesh discovery sweep for nearby repeaters/sensors |
|
||||||
|
| POST | `/api/radio/trace` | Send a multi-hop trace loop through known repeaters and back to the local radio |
|
||||||
| POST | `/api/radio/reboot` | Reboot radio or reconnect if disconnected |
|
| POST | `/api/radio/reboot` | Reboot radio or reconnect if disconnected |
|
||||||
| POST | `/api/radio/disconnect` | Disconnect from radio and pause automatic reconnect attempts |
|
| POST | `/api/radio/disconnect` | Disconnect from radio and pause automatic reconnect attempts |
|
||||||
| POST | `/api/radio/reconnect` | Manual radio reconnection |
|
| POST | `/api/radio/reconnect` | Manual radio reconnection |
|
||||||
@@ -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/radio-settings` | Fetch repeater radio config via CLI |
|
||||||
| POST | `/api/contacts/{public_key}/repeater/advert-intervals` | Fetch advert intervals |
|
| POST | `/api/contacts/{public_key}/repeater/advert-intervals` | Fetch advert intervals |
|
||||||
| POST | `/api/contacts/{public_key}/repeater/owner-info` | Fetch owner info |
|
| POST | `/api/contacts/{public_key}/repeater/owner-info` | Fetch owner info |
|
||||||
|
| POST | `/api/contacts/{public_key}/room/login` | Log in to a room server |
|
||||||
|
| POST | `/api/contacts/{public_key}/room/status` | Fetch room-server status telemetry |
|
||||||
|
| POST | `/api/contacts/{public_key}/room/lpp-telemetry` | Fetch room-server CayenneLPP sensor data |
|
||||||
|
| POST | `/api/contacts/{public_key}/room/acl` | Fetch room-server ACL entries |
|
||||||
|
|
||||||
| GET | `/api/channels` | List channels |
|
| GET | `/api/channels` | List channels |
|
||||||
| GET | `/api/channels/{key}/detail` | Comprehensive channel profile (message stats, top senders) |
|
| GET | `/api/channels/{key}/detail` | Comprehensive channel profile (message stats, top senders) |
|
||||||
@@ -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` | Send channel message |
|
||||||
| POST | `/api/messages/channel/{message_id}/resend` | Resend channel message (default: byte-perfect within 30s; `?new_timestamp=true`: fresh timestamp, no time limit, creates new message row) |
|
| POST | `/api/messages/channel/{message_id}/resend` | Resend channel message (default: byte-perfect within 30s; `?new_timestamp=true`: fresh timestamp, no time limit, creates new message row) |
|
||||||
| GET | `/api/packets/undecrypted/count` | Count of undecrypted packets |
|
| GET | `/api/packets/undecrypted/count` | Count of undecrypted packets |
|
||||||
|
| GET | `/api/packets/{packet_id}` | Fetch one stored raw packet by row ID for on-demand inspection |
|
||||||
| POST | `/api/packets/decrypt/historical` | Decrypt stored packets |
|
| POST | `/api/packets/decrypt/historical` | Decrypt stored packets |
|
||||||
| POST | `/api/packets/maintenance` | Delete old packets and vacuum |
|
| POST | `/api/packets/maintenance` | Delete old packets and vacuum |
|
||||||
| GET | `/api/read-state/unreads` | Server-computed unread counts, mentions, last message times, and `last_read_ats` boundaries |
|
| 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 |
|
| POST | `/api/fanout` | Create new fanout config |
|
||||||
| PATCH | `/api/fanout/{id}` | Update fanout config (triggers module reload) |
|
| PATCH | `/api/fanout/{id}` | Update fanout config (triggers module reload) |
|
||||||
| DELETE | `/api/fanout/{id}` | Delete fanout config (stops module) |
|
| DELETE | `/api/fanout/{id}` | Delete fanout config (stops module) |
|
||||||
|
| POST | `/api/fanout/bots/disable-until-restart` | Stop bot fanout modules and keep bots disabled until the process restarts |
|
||||||
| GET | `/api/statistics` | Aggregated mesh network statistics |
|
| GET | `/api/statistics` | Aggregated mesh network statistics |
|
||||||
| WS | `/api/ws` | Real-time updates |
|
| WS | `/api/ws` | Real-time updates |
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
## [3.6.2] - 2026-03-29
|
||||||
|
|
||||||
Feature: Be more flexible about timing and volume of full contact offload
|
Feature: Be more flexible about timing and volume of full contact offload
|
||||||
|
|||||||
+2
-1
@@ -48,7 +48,7 @@ Run both the backend and `npm run dev` for hot-reloading frontend development.
|
|||||||
Run the full quality suite before proposing or handing off code changes:
|
Run the full quality suite before proposing or handing off code changes:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/all_quality.sh
|
./scripts/quality/all_quality.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
That runs linting, formatting, type checking, tests, and builds for both backend and frontend.
|
That runs linting, formatting, type checking, tests, and builds for both backend and frontend.
|
||||||
@@ -78,6 +78,7 @@ These tests are only guaranteed to run correctly in a narrow subset of environme
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd tests/e2e
|
cd tests/e2e
|
||||||
|
npm install
|
||||||
npx playwright test # headless
|
npx playwright test # headless
|
||||||
npx playwright test --headed # you can probably guess
|
npx playwright test --headed # you can probably guess
|
||||||
```
|
```
|
||||||
|
|||||||
+32
-1
@@ -1,6 +1,6 @@
|
|||||||
# Third-Party Licenses
|
# Third-Party Licenses
|
||||||
|
|
||||||
Auto-generated by `scripts/collect_licenses.sh` — do not edit by hand.
|
Auto-generated by `scripts/build/collect_licenses.sh` — do not edit by hand.
|
||||||
|
|
||||||
## Backend (Python) Dependencies
|
## Backend (Python) Dependencies
|
||||||
|
|
||||||
@@ -1625,6 +1625,37 @@ THE SOFTWARE.
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
### recharts (3.8.1) — MIT
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Full license text</summary>
|
||||||
|
|
||||||
|
```
|
||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2015-present recharts
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
### sonner (2.0.7) — MIT
|
### sonner (2.0.7) — MIT
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
|||||||
@@ -16,12 +16,6 @@ Backend server + browser interface for MeshCore mesh radio networks. Connect you
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Disclaimer
|
|
||||||
|
|
||||||
This is developed with very heavy agentic assistance -- there is no warranty of fitness for any purpose. It's been lovingly guided by an engineer with a passion for clean code and good tests, but it's still mostly LLM output, so you may find some bugs.
|
|
||||||
|
|
||||||
If extending, have your LLM read the three `AGENTS.md` files: `./AGENTS.md`, `./frontend/AGENTS.md`, and `./app/AGENTS.md`.
|
|
||||||
|
|
||||||
## Start Here
|
## Start Here
|
||||||
|
|
||||||
Most users should choose one of these paths:
|
Most users should choose one of these paths:
|
||||||
@@ -95,23 +89,17 @@ Access the app at http://localhost:8000.
|
|||||||
|
|
||||||
Source checkouts expect a normal frontend build in `frontend/dist`.
|
Source checkouts expect a normal frontend build in `frontend/dist`.
|
||||||
|
|
||||||
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
|
> [!TIP]
|
||||||
|
> On Linux, you can also install RemoteTerm as a persistent `systemd` service that starts on boot and restarts automatically on failure:
|
||||||
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.
|
>
|
||||||
|
> ```bash
|
||||||
If you downloaded the release zip instead of cloning the repo, unpack it and run:
|
> bash scripts/setup/install_service.sh
|
||||||
|
> ```
|
||||||
```bash
|
>
|
||||||
cd Remote-Terminal-for-MeshCore
|
> For the full service workflow and post-install operations, see [README_ADVANCED.md](README_ADVANCED.md).
|
||||||
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`.
|
|
||||||
|
|
||||||
## Path 2: Docker
|
## 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.
|
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
|
```bash
|
||||||
docker compose up -d
|
cp docker-compose.example.yml docker-compose.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
The database is stored in `./data/` (bind-mounted), so the container shares the same database as the native app. To rebuild after pulling updates:
|
2. Or generate one interactively:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d --build
|
bash scripts/setup/install_docker.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
To use the prebuilt Docker Hub image instead of building locally, replace:
|
Your local `docker-compose.yml` is gitignored so future pulls do not overwrite your Docker settings.
|
||||||
|
|
||||||
|
The guided Docker flow can collect BLE settings, but BLE access from Docker still needs manual compose customization such as Bluetooth passthrough and possibly privileged mode or host networking. If you want the simpler path for BLE, use the regular Python launch flow instead.
|
||||||
|
|
||||||
|
Then customize the local compose file for your transport and launch:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo docker compose up # add -d for background once you validate it's working
|
||||||
|
```
|
||||||
|
|
||||||
|
The database is stored in `./data/` (bind-mounted), so the container shares the same database as the native app.
|
||||||
|
|
||||||
|
To rebuild after pulling updates:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo docker compose pull
|
||||||
|
sudo docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
The example file and setup script default to the published Docker Hub image. To build locally from your checkout instead, replace:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
build: .
|
image: docker.io/jkingsman/remoteterm-meshcore:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
with:
|
with:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
image: jkingsman/remoteterm-meshcore:latest
|
build: .
|
||||||
```
|
```
|
||||||
|
|
||||||
Then run:
|
Then run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose pull
|
sudo docker compose up -d --build
|
||||||
docker compose up -d
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Published Docker tags are intended to be multi-arch (`linux/amd64` and `linux/arm64`). If you are building and publishing manually, use Docker Buildx:
|
The 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.
|
||||||
|
|
||||||
```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.
|
|
||||||
|
|
||||||
To stop:
|
To stop:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose down
|
sudo docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
## Standard Environment Variables
|
## 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)
|
- Advanced setup, troubleshooting, HTTPS, systemd, remediation variables, and debug logging: [README_ADVANCED.md](README_ADVANCED.md)
|
||||||
- Contributing, tests, linting, E2E notes, and important AGENTS files: [CONTRIBUTING.md](CONTRIBUTING.md)
|
- Contributing, tests, linting, E2E notes, and important AGENTS files: [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||||
- Live API docs after the backend is running: http://localhost:8000/docs
|
- Live API docs after the backend is running: http://localhost:8000/docs
|
||||||
|
|
||||||
|
## Disclaimer
|
||||||
|
|
||||||
|
This is developed with very heavy agentic assistance -- there is no warranty of fitness for any purpose. It's been lovingly guided by an engineer with a passion for clean code and good tests, but it's still mostly LLM output, so you may find some bugs.
|
||||||
|
|
||||||
|
If extending, have your LLM read the three `AGENTS.md` files: `./AGENTS.md`, `./frontend/AGENTS.md`, and `./app/AGENTS.md`.
|
||||||
|
|||||||
+1
-26
@@ -46,39 +46,14 @@ Accept the browser warning, or use [mkcert](https://github.com/FiloSottile/mkcer
|
|||||||
|
|
||||||
## Systemd Service
|
## 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.
|
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
|
||||||
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.
|
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
|
## 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.
|
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
@@ -25,18 +25,22 @@ Keep it aligned with `app/` source files and router behavior.
|
|||||||
app/
|
app/
|
||||||
├── main.py # App startup/lifespan, router registration, static frontend mounting
|
├── main.py # App startup/lifespan, router registration, static frontend mounting
|
||||||
├── config.py # Env-driven runtime settings
|
├── config.py # Env-driven runtime settings
|
||||||
|
├── channel_constants.py # Public/default channel constants shared across sync/send logic
|
||||||
├── database.py # SQLite connection + base schema + migration runner
|
├── database.py # SQLite connection + base schema + migration runner
|
||||||
├── migrations.py # Schema migrations (SQLite user_version)
|
├── migrations.py # Schema migrations (SQLite user_version)
|
||||||
├── models.py # Pydantic request/response models and typed write contracts (for example ContactUpsert)
|
├── models.py # Pydantic request/response models and typed write contracts (for example ContactUpsert)
|
||||||
|
├── version_info.py # Unified version/build metadata resolution for debug + startup surfaces
|
||||||
├── repository/ # Data access layer (contacts, channels, messages, raw_packets, settings, fanout)
|
├── repository/ # Data access layer (contacts, channels, messages, raw_packets, settings, fanout)
|
||||||
├── services/ # Shared orchestration/domain services
|
├── services/ # Shared orchestration/domain services
|
||||||
│ ├── messages.py # Shared message creation, dedup, ACK application
|
│ ├── messages.py # Shared message creation, dedup, ACK application
|
||||||
│ ├── message_send.py # Direct send, channel send, resend workflows
|
│ ├── message_send.py # Direct send, channel send, resend workflows
|
||||||
│ ├── dm_ingest.py # Shared direct-message ingest / dedup seam for packet + fallback paths
|
│ ├── dm_ingest.py # Shared direct-message ingest / dedup seam for packet + fallback paths
|
||||||
|
│ ├── dm_ack_apply.py # Shared DM ACK application over pending/buffered ACK state
|
||||||
│ ├── dm_ack_tracker.py # Pending DM ACK state
|
│ ├── dm_ack_tracker.py # Pending DM ACK state
|
||||||
│ ├── contact_reconciliation.py # Prefix-claim, sender-key backfill, name-history wiring
|
│ ├── contact_reconciliation.py # Prefix-claim, sender-key backfill, name-history wiring
|
||||||
│ ├── radio_lifecycle.py # Post-connect setup and reconnect/setup helpers
|
│ ├── radio_lifecycle.py # Post-connect setup and reconnect/setup helpers
|
||||||
│ ├── radio_commands.py # Radio config/private-key command workflows
|
│ ├── radio_commands.py # Radio config/private-key command workflows
|
||||||
|
│ ├── radio_noise_floor.py # In-memory local radio noise-floor sampling/history
|
||||||
│ └── radio_runtime.py # Router/dependency seam over the global RadioManager
|
│ └── radio_runtime.py # Router/dependency seam over the global RadioManager
|
||||||
├── radio.py # RadioManager transport/session state + lock management
|
├── radio.py # RadioManager transport/session state + lock management
|
||||||
├── radio_sync.py # Polling, sync, periodic advertisement loop
|
├── radio_sync.py # Polling, sync, periodic advertisement loop
|
||||||
@@ -61,6 +65,8 @@ app/
|
|||||||
├── messages.py
|
├── messages.py
|
||||||
├── packets.py
|
├── packets.py
|
||||||
├── read_state.py
|
├── read_state.py
|
||||||
|
├── rooms.py
|
||||||
|
├── server_control.py
|
||||||
├── settings.py
|
├── settings.py
|
||||||
├── fanout.py
|
├── fanout.py
|
||||||
├── repeaters.py
|
├── repeaters.py
|
||||||
@@ -174,6 +180,7 @@ app/
|
|||||||
- `PUT /radio/private-key`
|
- `PUT /radio/private-key`
|
||||||
- `POST /radio/advertise` — manual advert send; request body may set `mode` to `flood` or `zero_hop` (defaults to `flood`)
|
- `POST /radio/advertise` — manual advert send; request body may set `mode` to `flood` or `zero_hop` (defaults to `flood`)
|
||||||
- `POST /radio/discover` — short mesh discovery sweep for nearby repeaters/sensors
|
- `POST /radio/discover` — short mesh discovery sweep for nearby repeaters/sensors
|
||||||
|
- `POST /radio/trace` — send a multi-hop trace loop through known repeaters and back to the local radio
|
||||||
- `POST /radio/disconnect`
|
- `POST /radio/disconnect`
|
||||||
- `POST /radio/reboot`
|
- `POST /radio/reboot`
|
||||||
- `POST /radio/reconnect`
|
- `POST /radio/reconnect`
|
||||||
@@ -198,6 +205,10 @@ app/
|
|||||||
- `POST /contacts/{public_key}/repeater/radio-settings`
|
- `POST /contacts/{public_key}/repeater/radio-settings`
|
||||||
- `POST /contacts/{public_key}/repeater/advert-intervals`
|
- `POST /contacts/{public_key}/repeater/advert-intervals`
|
||||||
- `POST /contacts/{public_key}/repeater/owner-info`
|
- `POST /contacts/{public_key}/repeater/owner-info`
|
||||||
|
- `POST /contacts/{public_key}/room/login`
|
||||||
|
- `POST /contacts/{public_key}/room/status`
|
||||||
|
- `POST /contacts/{public_key}/room/lpp-telemetry`
|
||||||
|
- `POST /contacts/{public_key}/room/acl`
|
||||||
|
|
||||||
### Channels
|
### Channels
|
||||||
- `GET /channels`
|
- `GET /channels`
|
||||||
@@ -216,6 +227,7 @@ app/
|
|||||||
|
|
||||||
### Packets
|
### Packets
|
||||||
- `GET /packets/undecrypted/count`
|
- `GET /packets/undecrypted/count`
|
||||||
|
- `GET /packets/{packet_id}` — fetch one stored raw packet by row ID for on-demand inspection
|
||||||
- `POST /packets/decrypt/historical`
|
- `POST /packets/decrypt/historical`
|
||||||
- `POST /packets/maintenance`
|
- `POST /packets/maintenance`
|
||||||
|
|
||||||
@@ -236,6 +248,7 @@ app/
|
|||||||
- `POST /fanout` — create new fanout config
|
- `POST /fanout` — create new fanout config
|
||||||
- `PATCH /fanout/{id}` — update fanout config (triggers module reload)
|
- `PATCH /fanout/{id}` — update fanout config (triggers module reload)
|
||||||
- `DELETE /fanout/{id}` — delete fanout config (stops module)
|
- `DELETE /fanout/{id}` — delete fanout config (stops module)
|
||||||
|
- `POST /fanout/bots/disable-until-restart` — stop bot modules and keep bots disabled until restart
|
||||||
|
|
||||||
### Statistics
|
### Statistics
|
||||||
- `GET /statistics` — aggregated mesh network stats (entity counts, message/packet splits, activity windows, busiest channels)
|
- `GET /statistics` — aggregated mesh network stats (entity counts, message/packet splits, activity windows, busiest channels)
|
||||||
@@ -322,9 +335,11 @@ tests/
|
|||||||
├── conftest.py # Shared fixtures
|
├── conftest.py # Shared fixtures
|
||||||
├── test_ack_tracking_wiring.py # DM ACK tracking extraction and wiring
|
├── test_ack_tracking_wiring.py # DM ACK tracking extraction and wiring
|
||||||
├── test_api.py # REST endpoint integration tests
|
├── test_api.py # REST endpoint integration tests
|
||||||
|
├── test_block_lists.py # Blocked keys/names filtering across list/search surfaces
|
||||||
├── test_bot.py # Bot execution and sandboxing
|
├── test_bot.py # Bot execution and sandboxing
|
||||||
├── test_channels_router.py # Channels router endpoints
|
|
||||||
├── test_channel_sender_backfill.py # Sender-key backfill uniqueness rules for channel messages
|
├── test_channel_sender_backfill.py # Sender-key backfill uniqueness rules for channel messages
|
||||||
|
├── test_channels_router.py # Channels router endpoints
|
||||||
|
├── test_community_mqtt.py # Community MQTT publisher (JWT, packet format, hash, broadcast)
|
||||||
├── test_config.py # Configuration validation
|
├── test_config.py # Configuration validation
|
||||||
├── test_contact_reconciliation_service.py # Prefix/contact reconciliation service helpers
|
├── test_contact_reconciliation_service.py # Prefix/contact reconciliation service helpers
|
||||||
├── test_contacts_router.py # Contacts router endpoints
|
├── test_contacts_router.py # Contacts router endpoints
|
||||||
@@ -332,40 +347,41 @@ tests/
|
|||||||
├── test_disable_bots.py # MESHCORE_DISABLE_BOTS=true feature
|
├── test_disable_bots.py # MESHCORE_DISABLE_BOTS=true feature
|
||||||
├── test_echo_dedup.py # Echo/repeat deduplication (incl. concurrent)
|
├── test_echo_dedup.py # Echo/repeat deduplication (incl. concurrent)
|
||||||
├── test_fanout.py # Fanout bus CRUD, scope matching, manager dispatch
|
├── test_fanout.py # Fanout bus CRUD, scope matching, manager dispatch
|
||||||
├── test_fanout_integration.py # Fanout integration tests
|
|
||||||
├── test_fanout_hitlist.py # Fanout-related hitlist regression tests
|
├── test_fanout_hitlist.py # Fanout-related hitlist regression tests
|
||||||
|
├── test_fanout_integration.py # Fanout integration tests
|
||||||
├── test_event_handlers.py # ACK tracking, event registration, cleanup
|
├── test_event_handlers.py # ACK tracking, event registration, cleanup
|
||||||
├── test_frontend_static.py # Frontend static file serving
|
├── test_frontend_static.py # Frontend static file serving
|
||||||
├── test_health_mqtt_status.py # Health endpoint MQTT status field
|
├── test_health_mqtt_status.py # Health endpoint MQTT status field
|
||||||
├── test_http_quality.py # Cache-control / gzip / basic-auth HTTP quality checks
|
├── test_http_quality.py # Cache-control / gzip / basic-auth HTTP quality checks
|
||||||
├── test_key_normalization.py # Public key normalization
|
├── test_key_normalization.py # Public key normalization
|
||||||
├── test_keystore.py # Ephemeral keystore
|
├── test_keystore.py # Ephemeral keystore
|
||||||
|
├── test_main_startup.py # App startup and lifespan
|
||||||
|
├── test_map_upload.py # Map upload fanout module
|
||||||
├── test_message_pagination.py # Cursor-based message pagination
|
├── test_message_pagination.py # Cursor-based message pagination
|
||||||
├── test_message_prefix_claim.py # Message prefix claim logic
|
├── test_message_prefix_claim.py # Message prefix claim logic
|
||||||
├── test_migrations.py # Schema migration system
|
|
||||||
├── test_community_mqtt.py # Community MQTT publisher (JWT, packet format, hash, broadcast)
|
|
||||||
├── test_mqtt.py # MQTT publisher topic routing and lifecycle
|
├── test_mqtt.py # MQTT publisher topic routing and lifecycle
|
||||||
|
├── test_messages_search.py # Message search, around, forward pagination
|
||||||
|
├── test_migrations.py # Schema migration system
|
||||||
├── test_packet_pipeline.py # End-to-end packet processing
|
├── test_packet_pipeline.py # End-to-end packet processing
|
||||||
├── test_packets_router.py # Packets router endpoints (decrypt, maintenance)
|
├── test_packets_router.py # Packets router endpoints (decrypt, maintenance)
|
||||||
|
├── test_path_utils.py # Path hex rendering helpers
|
||||||
├── test_radio.py # RadioManager, serial detection
|
├── test_radio.py # RadioManager, serial detection
|
||||||
├── test_radio_commands_service.py # Radio config/private-key service workflows
|
├── test_radio_commands_service.py # Radio config/private-key service workflows
|
||||||
├── test_radio_lifecycle_service.py # Reconnect/setup orchestration helpers
|
├── test_radio_lifecycle_service.py # Reconnect/setup orchestration helpers
|
||||||
├── test_radio_runtime_service.py # radio_runtime seam behavior and helpers
|
|
||||||
├── test_real_crypto.py # Real cryptographic operations
|
|
||||||
├── test_radio_operation.py # radio_operation() context manager
|
├── test_radio_operation.py # radio_operation() context manager
|
||||||
├── test_radio_router.py # Radio router endpoints
|
├── test_radio_router.py # Radio router endpoints
|
||||||
|
├── test_radio_runtime_service.py # radio_runtime seam behavior and helpers
|
||||||
├── test_radio_sync.py # Polling, sync, advertisement
|
├── test_radio_sync.py # Polling, sync, advertisement
|
||||||
|
├── test_real_crypto.py # Real cryptographic operations
|
||||||
├── test_repeater_routes.py # Repeater command/telemetry/trace + granular pane endpoints
|
├── test_repeater_routes.py # Repeater command/telemetry/trace + granular pane endpoints
|
||||||
├── test_repository.py # Data access layer
|
├── test_repository.py # Data access layer
|
||||||
|
├── test_room_routes.py # Room-server login/status/telemetry/ACL endpoints
|
||||||
├── test_rx_log_data.py # on_rx_log_data event handler integration
|
├── test_rx_log_data.py # on_rx_log_data event handler integration
|
||||||
├── test_messages_search.py # Message search, around, forward pagination
|
|
||||||
├── test_block_lists.py # Blocked keys/names filtering
|
|
||||||
├── test_security.py # Optional Basic Auth middleware / config behavior
|
├── test_security.py # Optional Basic Auth middleware / config behavior
|
||||||
├── test_send_messages.py # Outgoing messages, bot triggers, concurrent sends
|
├── test_send_messages.py # Outgoing messages, bot triggers, concurrent sends
|
||||||
├── test_settings_router.py # Settings endpoints, advert validation
|
├── test_settings_router.py # Settings endpoints, advert validation
|
||||||
├── test_statistics.py # Statistics aggregation
|
├── test_statistics.py # Statistics aggregation
|
||||||
├── test_main_startup.py # App startup and lifespan
|
├── test_version_info.py # Version/build metadata resolution
|
||||||
├── test_path_utils.py # Path hex rendering helpers
|
|
||||||
├── test_websocket.py # WS manager broadcast/cleanup
|
├── test_websocket.py # WS manager broadcast/cleanup
|
||||||
└── test_websocket_route.py # WS endpoint lifecycle
|
└── test_websocket_route.py # WS endpoint lifecycle
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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))
|
ON messages(type, conversation_key, text, COALESCE(sender_timestamp, 0))
|
||||||
WHERE type = 'CHAN';
|
WHERE type = 'CHAN';
|
||||||
CREATE INDEX IF NOT EXISTS idx_raw_packets_message_id ON raw_packets(message_id);
|
CREATE INDEX IF NOT EXISTS idx_raw_packets_message_id ON raw_packets(message_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_raw_packets_timestamp ON raw_packets(timestamp);
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_packets_payload_hash ON raw_packets(payload_hash);
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_packets_payload_hash ON raw_packets(payload_hash);
|
||||||
CREATE INDEX IF NOT EXISTS idx_contacts_on_radio ON contacts(on_radio);
|
CREATE INDEX IF NOT EXISTS idx_contacts_on_radio ON contacts(on_radio);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_contacts_type_last_seen ON contacts(type, last_seen);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_type_received_conversation
|
||||||
|
ON messages(type, received_at, conversation_key);
|
||||||
-- idx_messages_sender_key is created by migration 25 (after adding the sender_key column)
|
-- idx_messages_sender_key is created by migration 25 (after adding the sender_key column)
|
||||||
-- idx_messages_incoming_priv_dedup is created by migration 44 after legacy rows are reconciled
|
-- idx_messages_incoming_priv_dedup is created by migration 44 after legacy rows are reconciled
|
||||||
CREATE INDEX IF NOT EXISTS idx_contact_advert_paths_recent
|
CREATE INDEX IF NOT EXISTS idx_contact_advert_paths_recent
|
||||||
|
|||||||
@@ -30,8 +30,6 @@ logger = logging.getLogger(__name__)
|
|||||||
# Track active subscriptions so we can unsubscribe before re-registering
|
# Track active subscriptions so we can unsubscribe before re-registering
|
||||||
# This prevents handler duplication after reconnects
|
# This prevents handler duplication after reconnects
|
||||||
_active_subscriptions: list["Subscription"] = []
|
_active_subscriptions: list["Subscription"] = []
|
||||||
_pending_acks = dm_ack_tracker._pending_acks
|
|
||||||
_buffered_acks = dm_ack_tracker._buffered_acks
|
|
||||||
|
|
||||||
|
|
||||||
def track_pending_ack(expected_ack: str, message_id: int, timeout_ms: int) -> bool:
|
def track_pending_ack(expected_ack: str, message_id: int, timeout_ms: int) -> bool:
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ from app.routers import (
|
|||||||
ws,
|
ws,
|
||||||
)
|
)
|
||||||
from app.security import add_optional_basic_auth_middleware
|
from app.security import add_optional_basic_auth_middleware
|
||||||
|
from app.services.radio_noise_floor import start_noise_floor_sampling, stop_noise_floor_sampling
|
||||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||||
from app.version_info import get_app_build_info
|
from app.version_info import get_app_build_info
|
||||||
|
|
||||||
@@ -70,6 +71,7 @@ async def lifespan(app: FastAPI):
|
|||||||
from app.radio_sync import ensure_default_channels
|
from app.radio_sync import ensure_default_channels
|
||||||
|
|
||||||
await ensure_default_channels()
|
await ensure_default_channels()
|
||||||
|
await start_noise_floor_sampling()
|
||||||
|
|
||||||
# Always start connection monitor (even if initial connection failed)
|
# Always start connection monitor (even if initial connection failed)
|
||||||
await radio_manager.start_connection_monitor()
|
await radio_manager.start_connection_monitor()
|
||||||
@@ -98,6 +100,7 @@ async def lifespan(app: FastAPI):
|
|||||||
await radio_manager.stop_connection_monitor()
|
await radio_manager.stop_connection_monitor()
|
||||||
await stop_background_contact_reconciliation()
|
await stop_background_contact_reconciliation()
|
||||||
await stop_message_polling()
|
await stop_message_polling()
|
||||||
|
await stop_noise_floor_sampling()
|
||||||
await stop_periodic_advert()
|
await stop_periodic_advert()
|
||||||
await stop_periodic_sync()
|
await stop_periodic_sync()
|
||||||
if radio_manager.meshcore:
|
if radio_manager.meshcore:
|
||||||
|
|||||||
@@ -360,6 +360,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
|
|||||||
await set_version(conn, 46)
|
await set_version(conn, 46)
|
||||||
applied += 1
|
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:
|
if applied > 0:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
|
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
|
||||||
@@ -2868,3 +2875,37 @@ async def _migrate_046_cleanup_orphaned_contact_child_rows(conn: aiosqlite.Conne
|
|||||||
)
|
)
|
||||||
|
|
||||||
await conn.commit()
|
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()
|
||||||
|
|||||||
@@ -628,6 +628,59 @@ class TraceResponse(BaseModel):
|
|||||||
path_len: int = Field(description="Number of hops in the trace path")
|
path_len: int = Field(description="Number of hops in the trace path")
|
||||||
|
|
||||||
|
|
||||||
|
class RadioTraceHopRequest(BaseModel):
|
||||||
|
"""One requested hop in a radio trace path."""
|
||||||
|
|
||||||
|
public_key: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Full repeater public key when this hop maps to a known repeater",
|
||||||
|
)
|
||||||
|
hop_hex: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Raw hop hash hex when using a custom repeater prefix",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RadioTraceRequest(BaseModel):
|
||||||
|
"""Ordered trace path for a radio trace loop."""
|
||||||
|
|
||||||
|
hop_hash_bytes: Literal[1, 2, 4] = Field(
|
||||||
|
default=4,
|
||||||
|
description="Hash width in bytes for every hop in this trace path",
|
||||||
|
)
|
||||||
|
hops: list[RadioTraceHopRequest] = Field(
|
||||||
|
min_length=1,
|
||||||
|
description="Ordered repeater hops, using either known repeater keys or custom hop hex",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RadioTraceNode(BaseModel):
|
||||||
|
"""One resolved node in a radio trace result."""
|
||||||
|
|
||||||
|
role: Literal["repeater", "custom", "local"] = Field(description="Node role in the trace")
|
||||||
|
public_key: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Resolved full public key for this node when known",
|
||||||
|
)
|
||||||
|
name: str | None = Field(default=None, description="Display name for this node when known")
|
||||||
|
observed_hash: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Observed 4-byte trace hash for this node as hex",
|
||||||
|
)
|
||||||
|
snr: float | None = Field(default=None, description="Reported SNR for this node in dB")
|
||||||
|
|
||||||
|
|
||||||
|
class RadioTraceResponse(BaseModel):
|
||||||
|
"""Resolved multi-hop radio trace result."""
|
||||||
|
|
||||||
|
path_len: int = Field(description="Number of hashed nodes returned by the trace response")
|
||||||
|
timeout_seconds: float = Field(description="Timeout window used while waiting for the trace")
|
||||||
|
nodes: list[RadioTraceNode] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Ordered trace nodes: repeater hops followed by the terminal local radio",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PathDiscoveryRoute(BaseModel):
|
class PathDiscoveryRoute(BaseModel):
|
||||||
"""One resolved route returned by contact path discovery."""
|
"""One resolved route returned by contact path discovery."""
|
||||||
|
|
||||||
@@ -681,6 +734,10 @@ class RadioDiscoveryResult(BaseModel):
|
|||||||
"""One mesh node heard during a discovery sweep."""
|
"""One mesh node heard during a discovery sweep."""
|
||||||
|
|
||||||
public_key: str = Field(description="Discovered node public key as hex")
|
public_key: str = Field(description="Discovered node public key as hex")
|
||||||
|
name: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Known name for this node from contacts DB, if any",
|
||||||
|
)
|
||||||
node_type: Literal["repeater", "sensor"] = Field(description="Discovered node class")
|
node_type: Literal["repeater", "sensor"] = Field(description="Discovered node class")
|
||||||
heard_count: int = Field(default=1, description="How many responses were heard from this node")
|
heard_count: int = Field(default=1, description="How many responses were heard from this node")
|
||||||
local_snr: float | None = Field(
|
local_snr: float | None = Field(
|
||||||
@@ -820,6 +877,27 @@ class PathHashWidthStats(BaseModel):
|
|||||||
triple_byte_pct: float
|
triple_byte_pct: float
|
||||||
|
|
||||||
|
|
||||||
|
class NoiseFloorSample(BaseModel):
|
||||||
|
timestamp: int = Field(description="Unix timestamp of the sampled reading")
|
||||||
|
noise_floor_dbm: int = Field(description="Noise floor in dBm")
|
||||||
|
|
||||||
|
|
||||||
|
class NoiseFloorHistoryStats(BaseModel):
|
||||||
|
sample_interval_seconds: int = Field(description="Expected spacing between samples")
|
||||||
|
coverage_seconds: int = Field(description="How much of the last 24 hours is represented")
|
||||||
|
latest_noise_floor_dbm: int | None = Field(
|
||||||
|
default=None, description="Most recent sampled noise floor in dBm"
|
||||||
|
)
|
||||||
|
latest_timestamp: int | None = Field(
|
||||||
|
default=None, description="Unix timestamp of the most recent sample"
|
||||||
|
)
|
||||||
|
supported: bool | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Whether the connected radio appears to support radio stats sampling",
|
||||||
|
)
|
||||||
|
samples: list[NoiseFloorSample] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class StatisticsResponse(BaseModel):
|
class StatisticsResponse(BaseModel):
|
||||||
busiest_channels_24h: list[BusyChannel]
|
busiest_channels_24h: list[BusyChannel]
|
||||||
contact_count: int
|
contact_count: int
|
||||||
@@ -835,3 +913,4 @@ class StatisticsResponse(BaseModel):
|
|||||||
repeaters_heard: ContactActivityCounts
|
repeaters_heard: ContactActivityCounts
|
||||||
known_channels_active: ContactActivityCounts
|
known_channels_active: ContactActivityCounts
|
||||||
path_hash_width_24h: PathHashWidthStats
|
path_hash_width_24h: PathHashWidthStats
|
||||||
|
noise_floor_24h: NoiseFloorHistoryStats
|
||||||
|
|||||||
+16
-11
@@ -122,20 +122,20 @@ async def run_historical_dm_decryption(
|
|||||||
"""Background task to decrypt historical DM packets with contact's key."""
|
"""Background task to decrypt historical DM packets with contact's key."""
|
||||||
from app.websocket import broadcast_success
|
from app.websocket import broadcast_success
|
||||||
|
|
||||||
packets = await RawPacketRepository.get_undecrypted_text_messages()
|
total = 0
|
||||||
total = len(packets)
|
|
||||||
decrypted_count = 0
|
decrypted_count = 0
|
||||||
|
|
||||||
if total == 0:
|
logger.info("Starting historical DM decryption scan for undecrypted TEXT_MESSAGE packets")
|
||||||
logger.info("No undecrypted TEXT_MESSAGE packets to process")
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info("Starting historical DM decryption of %d TEXT_MESSAGE packets", total)
|
|
||||||
|
|
||||||
# Derive our public key from the private key
|
# Derive our public key from the private key
|
||||||
our_public_key_bytes = derive_public_key(private_key_bytes)
|
our_public_key_bytes = derive_public_key(private_key_bytes)
|
||||||
|
|
||||||
for packet_id, packet_data, packet_timestamp in packets:
|
async for (
|
||||||
|
packet_id,
|
||||||
|
packet_data,
|
||||||
|
packet_timestamp,
|
||||||
|
) in RawPacketRepository.stream_undecrypted_text_messages():
|
||||||
|
total += 1
|
||||||
# Note: passing our_public_key=None disables the outbound hash check in
|
# Note: passing our_public_key=None disables the outbound hash check in
|
||||||
# try_decrypt_dm (only the inbound check src_hash == their_first_byte runs).
|
# try_decrypt_dm (only the inbound check src_hash == their_first_byte runs).
|
||||||
# For the 255/256 case where our first byte differs from the contact's,
|
# For the 255/256 case where our first byte differs from the contact's,
|
||||||
@@ -187,6 +187,10 @@ async def run_historical_dm_decryption(
|
|||||||
if msg_id is not None:
|
if msg_id is not None:
|
||||||
decrypted_count += 1
|
decrypted_count += 1
|
||||||
|
|
||||||
|
if total == 0:
|
||||||
|
logger.info("No undecrypted TEXT_MESSAGE packets to process")
|
||||||
|
return
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Historical DM decryption complete: %d/%d packets decrypted",
|
"Historical DM decryption complete: %d/%d packets decrypted",
|
||||||
decrypted_count,
|
decrypted_count,
|
||||||
@@ -264,9 +268,10 @@ async def process_raw_packet(
|
|||||||
This is the main entry point for all incoming RF packets.
|
This is the main entry point for all incoming RF packets.
|
||||||
|
|
||||||
Note: Packets are deduplicated by payload hash in the database. If we receive
|
Note: Packets are deduplicated by payload hash in the database. If we receive
|
||||||
a duplicate packet (same payload, different path), we still broadcast it to
|
a duplicate payload (same payload, different path), we still broadcast it to
|
||||||
the frontend (for the real-time packet feed) but skip decryption processing
|
the frontend for realtime packet-feed fidelity. Some payload types are also
|
||||||
since the original packet was already processed.
|
intentionally reprocessed on duplicate arrival so message-level dedup/path
|
||||||
|
merge logic and advert/path-history tracking still see each observation.
|
||||||
"""
|
"""
|
||||||
ts = timestamp or int(time.time())
|
ts = timestamp or int(time.time())
|
||||||
observation_id = next(_raw_observation_counter)
|
observation_id = next(_raw_observation_counter)
|
||||||
|
|||||||
+18
-2
@@ -20,7 +20,7 @@ from meshcore import EventType, MeshCore
|
|||||||
|
|
||||||
from app.channel_constants import PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME
|
from app.channel_constants import PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.event_handlers import cleanup_expired_acks
|
from app.event_handlers import cleanup_expired_acks, on_contact_message
|
||||||
from app.models import Contact, ContactUpsert
|
from app.models import Contact, ContactUpsert
|
||||||
from app.radio import RadioOperationBusyError
|
from app.radio import RadioOperationBusyError
|
||||||
from app.repository import (
|
from app.repository import (
|
||||||
@@ -379,6 +379,14 @@ async def _resolve_channel_for_pending_message(
|
|||||||
return cached_key, channel.name if channel else None
|
return cached_key, channel.name if channel else None
|
||||||
|
|
||||||
|
|
||||||
|
async def _store_pending_direct_message(event) -> None:
|
||||||
|
"""Route a CONTACT_MSG_RECV event pulled via get_msg() through the DM ingest path."""
|
||||||
|
try:
|
||||||
|
await on_contact_message(event)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to store pending direct message", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
async def _store_pending_channel_message(mc: MeshCore, payload: dict) -> None:
|
async def _store_pending_channel_message(mc: MeshCore, payload: dict) -> None:
|
||||||
"""Persist a CHANNEL_MSG_RECV event pulled via get_msg()."""
|
"""Persist a CHANNEL_MSG_RECV event pulled via get_msg()."""
|
||||||
channel_idx = payload.get("channel_idx")
|
channel_idx = payload.get("channel_idx")
|
||||||
@@ -403,7 +411,8 @@ async def _store_pending_channel_message(mc: MeshCore, payload: dict) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
received_at = int(time.time())
|
received_at = int(time.time())
|
||||||
sender_timestamp = payload.get("sender_timestamp") or received_at
|
ts = payload.get("sender_timestamp")
|
||||||
|
sender_timestamp = ts if ts is not None else received_at
|
||||||
sender_name, message_text = _split_channel_sender_and_text(payload.get("text", ""))
|
sender_name, message_text = _split_channel_sender_and_text(payload.get("text", ""))
|
||||||
|
|
||||||
await create_fallback_channel_message(
|
await create_fallback_channel_message(
|
||||||
@@ -488,6 +497,8 @@ async def drain_pending_messages(mc: MeshCore) -> int:
|
|||||||
elif result.type in (EventType.CONTACT_MSG_RECV, EventType.CHANNEL_MSG_RECV):
|
elif result.type in (EventType.CONTACT_MSG_RECV, EventType.CHANNEL_MSG_RECV):
|
||||||
if result.type == EventType.CHANNEL_MSG_RECV:
|
if result.type == EventType.CHANNEL_MSG_RECV:
|
||||||
await _store_pending_channel_message(mc, result.payload)
|
await _store_pending_channel_message(mc, result.payload)
|
||||||
|
elif result.type == EventType.CONTACT_MSG_RECV:
|
||||||
|
await _store_pending_direct_message(result)
|
||||||
count += 1
|
count += 1
|
||||||
|
|
||||||
# Small delay between fetches
|
# Small delay between fetches
|
||||||
@@ -525,6 +536,8 @@ async def poll_for_messages(mc: MeshCore) -> int:
|
|||||||
elif result.type in (EventType.CONTACT_MSG_RECV, EventType.CHANNEL_MSG_RECV):
|
elif result.type in (EventType.CONTACT_MSG_RECV, EventType.CHANNEL_MSG_RECV):
|
||||||
if result.type == EventType.CHANNEL_MSG_RECV:
|
if result.type == EventType.CHANNEL_MSG_RECV:
|
||||||
await _store_pending_channel_message(mc, result.payload)
|
await _store_pending_channel_message(mc, result.payload)
|
||||||
|
elif result.type == EventType.CONTACT_MSG_RECV:
|
||||||
|
await _store_pending_direct_message(result)
|
||||||
count += 1
|
count += 1
|
||||||
# If we got a message, there might be more - drain them
|
# If we got a message, there might be more - drain them
|
||||||
count += await drain_pending_messages(mc)
|
count += await drain_pending_messages(mc)
|
||||||
@@ -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_SYNC_THROTTLE_SECONDS = 30 # Don't sync more than once per 30 seconds
|
||||||
CONTACT_RECONCILE_BATCH_SIZE = 2
|
CONTACT_RECONCILE_BATCH_SIZE = 2
|
||||||
CONTACT_RECONCILE_YIELD_SECONDS = 0.05
|
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:
|
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:
|
except RadioOperationBusyError:
|
||||||
logger.debug("Background contact reconcile yielding: radio busy")
|
logger.debug("Background contact reconcile yielding: radio busy")
|
||||||
|
await asyncio.sleep(CONTACT_RECONCILE_BUSY_BACKOFF_SECONDS)
|
||||||
|
continue
|
||||||
|
|
||||||
await asyncio.sleep(CONTACT_RECONCILE_YIELD_SECONDS)
|
await asyncio.sleep(CONTACT_RECONCILE_YIELD_SECONDS)
|
||||||
if not progressed:
|
if not progressed:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
|
|
||||||
from app.database import db
|
from app.database import db
|
||||||
@@ -8,6 +9,8 @@ from app.decoder import PayloadType, extract_payload, get_packet_payload_type
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
UNDECRYPTED_PACKET_BATCH_SIZE = 500
|
||||||
|
|
||||||
|
|
||||||
class RawPacketRepository:
|
class RawPacketRepository:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -100,6 +103,40 @@ class RawPacketRepository:
|
|||||||
rows = await cursor.fetchall()
|
rows = await cursor.fetchall()
|
||||||
return [(row["id"], bytes(row["data"]), row["timestamp"]) for row in rows]
|
return [(row["id"], bytes(row["data"]), row["timestamp"]) for row in rows]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def stream_undecrypted_text_messages(
|
||||||
|
batch_size: int = UNDECRYPTED_PACKET_BATCH_SIZE,
|
||||||
|
) -> AsyncIterator[tuple[int, bytes, int]]:
|
||||||
|
"""Yield undecrypted TEXT_MESSAGE packets in bounded-size batches."""
|
||||||
|
cursor = await db.conn.execute(
|
||||||
|
"SELECT id, data, timestamp FROM raw_packets WHERE message_id IS NULL ORDER BY timestamp ASC"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
rows = await cursor.fetchmany(batch_size)
|
||||||
|
if not rows:
|
||||||
|
break
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
data = bytes(row["data"])
|
||||||
|
payload_type = get_packet_payload_type(data)
|
||||||
|
if payload_type == PayloadType.TEXT_MESSAGE:
|
||||||
|
yield (row["id"], data, row["timestamp"])
|
||||||
|
finally:
|
||||||
|
await cursor.close()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def count_undecrypted_text_messages(
|
||||||
|
batch_size: int = UNDECRYPTED_PACKET_BATCH_SIZE,
|
||||||
|
) -> int:
|
||||||
|
"""Count undecrypted TEXT_MESSAGE packets without materializing them all."""
|
||||||
|
count = 0
|
||||||
|
async for _packet in RawPacketRepository.stream_undecrypted_text_messages(
|
||||||
|
batch_size=batch_size
|
||||||
|
):
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def mark_decrypted(packet_id: int, message_id: int) -> None:
|
async def mark_decrypted(packet_id: int, message_id: int) -> None:
|
||||||
"""Link a raw packet to its decrypted message."""
|
"""Link a raw packet to its decrypted message."""
|
||||||
@@ -158,17 +195,4 @@ class RawPacketRepository:
|
|||||||
Filters raw packets to only include those with PayloadType.TEXT_MESSAGE (0x02).
|
Filters raw packets to only include those with PayloadType.TEXT_MESSAGE (0x02).
|
||||||
These are direct messages that can be decrypted with contact ECDH keys.
|
These are direct messages that can be decrypted with contact ECDH keys.
|
||||||
"""
|
"""
|
||||||
cursor = await db.conn.execute(
|
return [packet async for packet in RawPacketRepository.stream_undecrypted_text_messages()]
|
||||||
"SELECT id, data, timestamp FROM raw_packets WHERE message_id IS NULL ORDER BY timestamp ASC"
|
|
||||||
)
|
|
||||||
rows = await cursor.fetchall()
|
|
||||||
|
|
||||||
# Filter for TEXT_MESSAGE packets
|
|
||||||
result = []
|
|
||||||
for row in rows:
|
|
||||||
data = bytes(row["data"])
|
|
||||||
payload_type = get_packet_payload_type(data)
|
|
||||||
if payload_type == PayloadType.TEXT_MESSAGE:
|
|
||||||
result.append((row["id"], data, row["timestamp"]))
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|||||||
+56
-37
@@ -12,6 +12,7 @@ logger = logging.getLogger(__name__)
|
|||||||
SECONDS_1H = 3600
|
SECONDS_1H = 3600
|
||||||
SECONDS_24H = 86400
|
SECONDS_24H = 86400
|
||||||
SECONDS_7D = 604800
|
SECONDS_7D = 604800
|
||||||
|
RAW_PACKET_STATS_BATCH_SIZE = 500
|
||||||
|
|
||||||
|
|
||||||
class AppSettingsRepository:
|
class AppSettingsRepository:
|
||||||
@@ -246,6 +247,26 @@ class AppSettingsRepository:
|
|||||||
|
|
||||||
|
|
||||||
class StatisticsRepository:
|
class StatisticsRepository:
|
||||||
|
@staticmethod
|
||||||
|
async def get_database_message_totals() -> dict[str, int]:
|
||||||
|
"""Return message totals needed by lightweight debug surfaces."""
|
||||||
|
cursor = await db.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
SUM(CASE WHEN type = 'PRIV' THEN 1 ELSE 0 END) AS total_dms,
|
||||||
|
SUM(CASE WHEN type = 'CHAN' THEN 1 ELSE 0 END) AS total_channel_messages,
|
||||||
|
SUM(CASE WHEN outgoing = 1 THEN 1 ELSE 0 END) AS total_outgoing
|
||||||
|
FROM messages
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
assert row is not None
|
||||||
|
return {
|
||||||
|
"total_dms": row["total_dms"] or 0,
|
||||||
|
"total_channel_messages": row["total_channel_messages"] or 0,
|
||||||
|
"total_outgoing": row["total_outgoing"] or 0,
|
||||||
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _activity_counts(*, contact_type: int, exclude: bool = False) -> dict[str, int]:
|
async def _activity_counts(*, contact_type: int, exclude: bool = False) -> dict[str, int]:
|
||||||
"""Get time-windowed counts for contacts/repeaters heard."""
|
"""Get time-windowed counts for contacts/repeaters heard."""
|
||||||
@@ -272,17 +293,26 @@ class StatisticsRepository:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _known_channels_active() -> dict[str, int]:
|
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())
|
now = int(time.time())
|
||||||
cursor = await db.conn.execute(
|
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
|
SELECT
|
||||||
COUNT(DISTINCT CASE WHEN m.received_at >= ? THEN m.conversation_key END) AS last_hour,
|
SUM(CASE WHEN last_received_at >= ? THEN 1 ELSE 0 END) AS last_hour,
|
||||||
COUNT(DISTINCT CASE WHEN m.received_at >= ? THEN m.conversation_key END) AS last_24_hours,
|
SUM(CASE WHEN last_received_at >= ? THEN 1 ELSE 0 END) AS last_24_hours,
|
||||||
COUNT(DISTINCT CASE WHEN m.received_at >= ? THEN m.conversation_key END) AS last_week
|
SUM(CASE WHEN last_received_at >= ? THEN 1 ELSE 0 END) AS last_week
|
||||||
FROM messages m
|
FROM known
|
||||||
INNER JOIN channels c ON UPPER(m.conversation_key) = UPPER(c.key)
|
|
||||||
WHERE m.type = 'CHAN'
|
|
||||||
""",
|
""",
|
||||||
(now - SECONDS_1H, now - SECONDS_24H, now - SECONDS_7D),
|
(now - SECONDS_1H, now - SECONDS_24H, now - SECONDS_7D),
|
||||||
)
|
)
|
||||||
@@ -302,22 +332,26 @@ class StatisticsRepository:
|
|||||||
"SELECT data FROM raw_packets WHERE timestamp >= ?",
|
"SELECT data FROM raw_packets WHERE timestamp >= ?",
|
||||||
(now - SECONDS_24H,),
|
(now - SECONDS_24H,),
|
||||||
)
|
)
|
||||||
rows = await cursor.fetchall()
|
|
||||||
|
|
||||||
single_byte = 0
|
single_byte = 0
|
||||||
double_byte = 0
|
double_byte = 0
|
||||||
triple_byte = 0
|
triple_byte = 0
|
||||||
|
|
||||||
for row in rows:
|
while True:
|
||||||
envelope = parse_packet_envelope(bytes(row["data"]))
|
rows = await cursor.fetchmany(RAW_PACKET_STATS_BATCH_SIZE)
|
||||||
if envelope is None:
|
if not rows:
|
||||||
continue
|
break
|
||||||
if envelope.hash_size == 1:
|
|
||||||
single_byte += 1
|
for row in rows:
|
||||||
elif envelope.hash_size == 2:
|
envelope = parse_packet_envelope(bytes(row["data"]))
|
||||||
double_byte += 1
|
if envelope is None:
|
||||||
elif envelope.hash_size == 3:
|
continue
|
||||||
triple_byte += 1
|
if envelope.hash_size == 1:
|
||||||
|
single_byte += 1
|
||||||
|
elif envelope.hash_size == 2:
|
||||||
|
double_byte += 1
|
||||||
|
elif envelope.hash_size == 3:
|
||||||
|
triple_byte += 1
|
||||||
|
|
||||||
total_packets = single_byte + double_byte + triple_byte
|
total_packets = single_byte + double_byte + triple_byte
|
||||||
if total_packets == 0:
|
if total_packets == 0:
|
||||||
@@ -400,22 +434,7 @@ class StatisticsRepository:
|
|||||||
decrypted_packets = pkt_row["decrypted"] or 0
|
decrypted_packets = pkt_row["decrypted"] or 0
|
||||||
undecrypted_packets = total_packets - decrypted_packets
|
undecrypted_packets = total_packets - decrypted_packets
|
||||||
|
|
||||||
# Message type counts
|
message_totals = await StatisticsRepository.get_database_message_totals()
|
||||||
cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM messages WHERE type = 'PRIV'")
|
|
||||||
row = await cursor.fetchone()
|
|
||||||
assert row is not None
|
|
||||||
total_dms: int = row["cnt"]
|
|
||||||
|
|
||||||
cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM messages WHERE type = 'CHAN'")
|
|
||||||
row = await cursor.fetchone()
|
|
||||||
assert row is not None
|
|
||||||
total_channel_messages: int = row["cnt"]
|
|
||||||
|
|
||||||
# Outgoing count
|
|
||||||
cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM messages WHERE outgoing = 1")
|
|
||||||
row = await cursor.fetchone()
|
|
||||||
assert row is not None
|
|
||||||
total_outgoing: int = row["cnt"]
|
|
||||||
|
|
||||||
# Activity windows
|
# Activity windows
|
||||||
contacts_heard = await StatisticsRepository._activity_counts(contact_type=2, exclude=True)
|
contacts_heard = await StatisticsRepository._activity_counts(contact_type=2, exclude=True)
|
||||||
@@ -431,9 +450,9 @@ class StatisticsRepository:
|
|||||||
"total_packets": total_packets,
|
"total_packets": total_packets,
|
||||||
"decrypted_packets": decrypted_packets,
|
"decrypted_packets": decrypted_packets,
|
||||||
"undecrypted_packets": undecrypted_packets,
|
"undecrypted_packets": undecrypted_packets,
|
||||||
"total_dms": total_dms,
|
"total_dms": message_totals["total_dms"],
|
||||||
"total_channel_messages": total_channel_messages,
|
"total_channel_messages": message_totals["total_channel_messages"],
|
||||||
"total_outgoing": total_outgoing,
|
"total_outgoing": message_totals["total_outgoing"],
|
||||||
"contacts_heard": contacts_heard,
|
"contacts_heard": contacts_heard,
|
||||||
"repeaters_heard": repeaters_heard,
|
"repeaters_heard": repeaters_heard,
|
||||||
"known_channels_active": known_channels_active,
|
"known_channels_active": known_channels_active,
|
||||||
|
|||||||
+14
-6
@@ -40,6 +40,10 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(prefix="/contacts", tags=["contacts"])
|
router = APIRouter(prefix="/contacts", tags=["contacts"])
|
||||||
|
|
||||||
|
|
||||||
|
TRACE_HASH_BYTES = 4
|
||||||
|
TRACE_FLAGS_4BYTE = 2
|
||||||
|
|
||||||
|
|
||||||
def _ambiguous_contact_detail(err: AmbiguousPublicKeyPrefixError) -> str:
|
def _ambiguous_contact_detail(err: AmbiguousPublicKeyPrefixError) -> str:
|
||||||
sample = ", ".join(key[:12] for key in err.matches[:2])
|
sample = ", ".join(key[:12] for key in err.matches[:2])
|
||||||
return (
|
return (
|
||||||
@@ -373,17 +377,17 @@ async def delete_contact(public_key: str) -> dict:
|
|||||||
async def request_trace(public_key: str) -> TraceResponse:
|
async def request_trace(public_key: str) -> TraceResponse:
|
||||||
"""Send a single-hop trace to a contact and wait for the result.
|
"""Send a single-hop trace to a contact and wait for the result.
|
||||||
|
|
||||||
The trace path contains the contact's 1-byte pubkey hash as the sole hop
|
The trace path contains the contact's 4-byte pubkey hash as the sole hop
|
||||||
(no intermediate repeaters). The radio firmware requires at least one
|
(no intermediate repeaters). This uses TRACE's dedicated width flags rather
|
||||||
node in the path.
|
than the radio's normal path_hash_mode setting.
|
||||||
"""
|
"""
|
||||||
require_connected()
|
require_connected()
|
||||||
|
|
||||||
contact = await _resolve_contact_or_404(public_key)
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
|
|
||||||
tag = random.randint(1, 0xFFFFFFFF)
|
tag = random.randint(1, 0xFFFFFFFF)
|
||||||
# First 2 hex chars of pubkey = 1-byte hash used by the trace protocol
|
# Use a 4-byte contact hash for low-collision direct trace targeting.
|
||||||
contact_hash = contact.public_key[:2]
|
contact_hash = contact.public_key[: TRACE_HASH_BYTES * 2]
|
||||||
|
|
||||||
# Trace does not need auto-fetch suspension: response arrives as TRACE_DATA
|
# Trace does not need auto-fetch suspension: response arrives as TRACE_DATA
|
||||||
# from the reader loop, not via get_msg().
|
# from the reader loop, not via get_msg().
|
||||||
@@ -394,7 +398,11 @@ async def request_trace(public_key: str) -> TraceResponse:
|
|||||||
logger.info(
|
logger.info(
|
||||||
"Sending trace to %s (tag=%d, hash=%s)", contact.public_key[:12], tag, contact_hash
|
"Sending trace to %s (tag=%d, hash=%s)", contact.public_key[:12], tag, contact_hash
|
||||||
)
|
)
|
||||||
result = await mc.commands.send_trace(path=contact_hash, tag=tag)
|
result = await mc.commands.send_trace(
|
||||||
|
path=contact_hash,
|
||||||
|
tag=tag,
|
||||||
|
flags=TRACE_FLAGS_4BYTE,
|
||||||
|
)
|
||||||
|
|
||||||
if result.type == EventType.ERROR:
|
if result.type == EventType.ERROR:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to send trace: {result.payload}")
|
raise HTTPException(status_code=500, detail=f"Failed to send trace: {result.payload}")
|
||||||
|
|||||||
@@ -265,7 +265,7 @@ async def _probe_radio() -> DebugRadioProbe:
|
|||||||
async def debug_support_snapshot() -> DebugSnapshotResponse:
|
async def debug_support_snapshot() -> DebugSnapshotResponse:
|
||||||
"""Return a support/debug snapshot with recent logs and live radio state."""
|
"""Return a support/debug snapshot with recent logs and live radio state."""
|
||||||
health_data = await build_health_data(radio_runtime.is_connected, radio_runtime.connection_info)
|
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()
|
radio_probe = await _probe_radio()
|
||||||
channels_with_incoming_messages = (
|
channels_with_incoming_messages = (
|
||||||
await MessageRepository.count_channels_with_incoming_messages()
|
await MessageRepository.count_channels_with_incoming_messages()
|
||||||
@@ -291,9 +291,9 @@ async def debug_support_snapshot() -> DebugSnapshotResponse:
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
database=DebugDatabaseInfo(
|
database=DebugDatabaseInfo(
|
||||||
total_dms=statistics["total_dms"],
|
total_dms=message_totals["total_dms"],
|
||||||
total_channel_messages=statistics["total_channel_messages"],
|
total_channel_messages=message_totals["total_channel_messages"],
|
||||||
total_outgoing=statistics["total_outgoing"],
|
total_outgoing=message_totals["total_outgoing"],
|
||||||
),
|
),
|
||||||
radio_probe=radio_probe,
|
radio_probe=radio_probe,
|
||||||
logs=[*LOG_COPY_BOUNDARY_PREFIX, *get_recent_log_lines(limit=1000)],
|
logs=[*LOG_COPY_BOUNDARY_PREFIX, *get_recent_log_lines(limit=1000)],
|
||||||
|
|||||||
@@ -210,8 +210,7 @@ async def decrypt_historical_packets(
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
raise _bad_request("Invalid hex string for contact public key") from None
|
raise _bad_request("Invalid hex string for contact public key") from None
|
||||||
|
|
||||||
packets = await RawPacketRepository.get_undecrypted_text_messages()
|
count = await RawPacketRepository.count_undecrypted_text_messages()
|
||||||
count = len(packets)
|
|
||||||
if count == 0:
|
if count == 0:
|
||||||
return DecryptResult(
|
return DecryptResult(
|
||||||
started=False,
|
started=False,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
|
from contextlib import suppress
|
||||||
from typing import Literal, TypeAlias
|
from typing import Literal, TypeAlias
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
@@ -10,14 +11,20 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
from app.dependencies import require_connected
|
from app.dependencies import require_connected
|
||||||
from app.models import (
|
from app.models import (
|
||||||
|
CONTACT_TYPE_REPEATER,
|
||||||
ContactUpsert,
|
ContactUpsert,
|
||||||
RadioDiscoveryRequest,
|
RadioDiscoveryRequest,
|
||||||
RadioDiscoveryResponse,
|
RadioDiscoveryResponse,
|
||||||
RadioDiscoveryResult,
|
RadioDiscoveryResult,
|
||||||
|
RadioTraceHopRequest,
|
||||||
|
RadioTraceNode,
|
||||||
|
RadioTraceRequest,
|
||||||
|
RadioTraceResponse,
|
||||||
)
|
)
|
||||||
from app.radio_sync import send_advertisement as do_send_advertisement
|
from app.radio_sync import send_advertisement as do_send_advertisement
|
||||||
from app.radio_sync import sync_radio_time
|
from app.radio_sync import sync_radio_time
|
||||||
from app.repository import ContactRepository
|
from app.repository import ContactRepository
|
||||||
|
from app.services.contact_reconciliation import promote_prefix_contacts_for_contact
|
||||||
from app.services.radio_commands import (
|
from app.services.radio_commands import (
|
||||||
KeystoreRefreshError,
|
KeystoreRefreshError,
|
||||||
PathHashModeUnsupportedError,
|
PathHashModeUnsupportedError,
|
||||||
@@ -44,6 +51,12 @@ _DISCOVERY_NODE_TYPES: dict[int, DiscoveryNodeType] = {
|
|||||||
2: "repeater",
|
2: "repeater",
|
||||||
4: "sensor",
|
4: "sensor",
|
||||||
}
|
}
|
||||||
|
TRACE_WAIT_TIMEOUT_SECONDS = 45.0
|
||||||
|
TRACE_DEFAULT_TIMEOUT_SECONDS = 15.0
|
||||||
|
TRACE_TIMEOUT_MIN_SECONDS = 5.0
|
||||||
|
TRACE_TIMEOUT_MAX_SECONDS = 30.0
|
||||||
|
TRACE_TIMEOUT_MARGIN = 1.2
|
||||||
|
TRACE_HASH_FLAGS = {1: 0, 2: 1, 4: 2}
|
||||||
|
|
||||||
|
|
||||||
async def _prepare_connected(*, broadcast_on_success: bool) -> bool:
|
async def _prepare_connected(*, broadcast_on_success: bool) -> bool:
|
||||||
@@ -197,9 +210,118 @@ async def _persist_new_discovery_contacts(results: list[RadioDiscoveryResult]) -
|
|||||||
on_radio=False,
|
on_radio=False,
|
||||||
)
|
)
|
||||||
await ContactRepository.upsert(contact)
|
await ContactRepository.upsert(contact)
|
||||||
|
promoted_keys = await promote_prefix_contacts_for_contact(
|
||||||
|
public_key=result.public_key,
|
||||||
|
log=logger,
|
||||||
|
)
|
||||||
created = await ContactRepository.get_by_key(result.public_key)
|
created = await ContactRepository.get_by_key(result.public_key)
|
||||||
if created is not None:
|
if created is not None:
|
||||||
broadcast_event("contact", created.model_dump())
|
broadcast_event("contact", created.model_dump())
|
||||||
|
for old_key in promoted_keys:
|
||||||
|
broadcast_event("contact_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)
|
@router.get("/config", response_model=RadioConfigResponse)
|
||||||
@@ -365,6 +487,7 @@ async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryRespons
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
await _persist_new_discovery_contacts(results)
|
await _persist_new_discovery_contacts(results)
|
||||||
|
await _attach_known_names(results)
|
||||||
return RadioDiscoveryResponse(
|
return RadioDiscoveryResponse(
|
||||||
target=request.target,
|
target=request.target,
|
||||||
duration_seconds=DISCOVERY_WINDOW_SECONDS,
|
duration_seconds=DISCOVERY_WINDOW_SECONDS,
|
||||||
@@ -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:
|
async def _attempt_reconnect() -> dict:
|
||||||
"""Shared reconnection logic for reboot and reconnect endpoints."""
|
"""Shared reconnection logic for reboot and reconnect endpoints."""
|
||||||
radio_manager.resume_connection()
|
radio_manager.resume_connection()
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from meshcore import EventType
|
|
||||||
|
|
||||||
from app.dependencies import require_connected
|
from app.dependencies import require_connected
|
||||||
from app.models import (
|
from app.models import (
|
||||||
@@ -28,7 +25,6 @@ from app.models import (
|
|||||||
from app.repository import ContactRepository
|
from app.repository import ContactRepository
|
||||||
from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404
|
from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404
|
||||||
from app.routers.server_control import (
|
from app.routers.server_control import (
|
||||||
_monotonic,
|
|
||||||
batch_cli_fetch,
|
batch_cli_fetch,
|
||||||
extract_response_text,
|
extract_response_text,
|
||||||
prepare_authenticated_contact_connection,
|
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
|
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from meshcore.events import Event
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ACL permission level names
|
# ACL permission level names
|
||||||
@@ -57,58 +50,6 @@ def _extract_response_text(event) -> str:
|
|||||||
return extract_response_text(event)
|
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:
|
async def prepare_repeater_connection(mc, contact: Contact, password: str) -> RepeaterLoginResponse:
|
||||||
return await prepare_authenticated_contact_connection(
|
return await prepare_authenticated_contact_connection(
|
||||||
mc,
|
mc,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from app.models import (
|
|||||||
Contact,
|
Contact,
|
||||||
RepeaterLoginResponse,
|
RepeaterLoginResponse,
|
||||||
)
|
)
|
||||||
|
from app.radio_sync import _store_pending_channel_message, _store_pending_direct_message
|
||||||
from app.routers.contacts import _ensure_on_radio
|
from app.routers.contacts import _ensure_on_radio
|
||||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
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:
|
if msg_prefix == target_pubkey_prefix and txt_type == 1:
|
||||||
return result
|
return result
|
||||||
logger.debug(
|
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,
|
msg_prefix,
|
||||||
txt_type,
|
txt_type,
|
||||||
target_pubkey_prefix,
|
target_pubkey_prefix,
|
||||||
)
|
)
|
||||||
|
await _store_pending_direct_message(result)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if result.type == EventType.CHANNEL_MSG_RECV:
|
if result.type == EventType.CHANNEL_MSG_RECV:
|
||||||
logger.debug(
|
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"),
|
result.payload.get("channel_idx"),
|
||||||
)
|
)
|
||||||
|
await _store_pending_channel_message(mc, result.payload)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
logger.debug("Unexpected event type %s during CLI fetch, skipping", result.type)
|
logger.debug("Unexpected event type %s during CLI fetch, skipping", result.type)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from fastapi import APIRouter
|
|||||||
|
|
||||||
from app.models import StatisticsResponse
|
from app.models import StatisticsResponse
|
||||||
from app.repository import StatisticsRepository
|
from app.repository import StatisticsRepository
|
||||||
|
from app.services.radio_noise_floor import get_noise_floor_history
|
||||||
|
|
||||||
router = APIRouter(prefix="/statistics", tags=["statistics"])
|
router = APIRouter(prefix="/statistics", tags=["statistics"])
|
||||||
|
|
||||||
@@ -9,4 +10,5 @@ router = APIRouter(prefix="/statistics", tags=["statistics"])
|
|||||||
@router.get("", response_model=StatisticsResponse)
|
@router.get("", response_model=StatisticsResponse)
|
||||||
async def get_statistics() -> StatisticsResponse:
|
async def get_statistics() -> StatisticsResponse:
|
||||||
data = await StatisticsRepository.get_all()
|
data = await StatisticsRepository.get_all()
|
||||||
|
data["noise_floor_24h"] = await get_noise_floor_history()
|
||||||
return StatisticsResponse(**data)
|
return StatisticsResponse(**data)
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""In-memory local-radio noise floor history sampling."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
from meshcore import EventType
|
||||||
|
|
||||||
|
from app.radio import RadioDisconnectedError, RadioOperationBusyError
|
||||||
|
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
NOISE_FLOOR_SAMPLE_INTERVAL_SECONDS = 300
|
||||||
|
NOISE_FLOOR_WINDOW_SECONDS = 24 * 60 * 60
|
||||||
|
MAX_NOISE_FLOOR_SAMPLES = 300
|
||||||
|
|
||||||
|
_noise_floor_task: asyncio.Task | None = None
|
||||||
|
_noise_floor_samples: deque[tuple[int, int]] = deque(maxlen=MAX_NOISE_FLOOR_SAMPLES)
|
||||||
|
_noise_floor_supported: bool | None = None
|
||||||
|
_samples_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
async def _append_sample(timestamp: int, noise_floor_dbm: int) -> None:
|
||||||
|
async with _samples_lock:
|
||||||
|
_noise_floor_samples.append((timestamp, noise_floor_dbm))
|
||||||
|
|
||||||
|
|
||||||
|
async def sample_noise_floor_once(*, blocking: bool = False) -> None:
|
||||||
|
"""Fetch the current radio noise floor once and record it when available."""
|
||||||
|
global _noise_floor_supported
|
||||||
|
|
||||||
|
if not radio_manager.is_connected:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with radio_manager.radio_operation("noise_floor_sample", blocking=blocking) as mc:
|
||||||
|
event = await mc.commands.get_stats_radio()
|
||||||
|
except (RadioDisconnectedError, RadioOperationBusyError):
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Noise floor sampling failed: %s", exc)
|
||||||
|
return
|
||||||
|
|
||||||
|
if event.type == EventType.ERROR:
|
||||||
|
_noise_floor_supported = False
|
||||||
|
return
|
||||||
|
|
||||||
|
if event.type != EventType.STATS_RADIO:
|
||||||
|
return
|
||||||
|
|
||||||
|
noise_floor = event.payload.get("noise_floor")
|
||||||
|
if not isinstance(noise_floor, int):
|
||||||
|
return
|
||||||
|
|
||||||
|
_noise_floor_supported = True
|
||||||
|
await _append_sample(int(time.time()), noise_floor)
|
||||||
|
|
||||||
|
|
||||||
|
async def _noise_floor_sampling_loop() -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await sample_noise_floor_once()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Noise floor sampling loop crashed during sample")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(NOISE_FLOOR_SAMPLE_INTERVAL_SECONDS)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def start_noise_floor_sampling() -> None:
|
||||||
|
global _noise_floor_task
|
||||||
|
if _noise_floor_task is not None and not _noise_floor_task.done():
|
||||||
|
return
|
||||||
|
_noise_floor_task = asyncio.create_task(_noise_floor_sampling_loop())
|
||||||
|
|
||||||
|
|
||||||
|
async def stop_noise_floor_sampling() -> None:
|
||||||
|
global _noise_floor_task
|
||||||
|
if _noise_floor_task is None:
|
||||||
|
return
|
||||||
|
if not _noise_floor_task.done():
|
||||||
|
_noise_floor_task.cancel()
|
||||||
|
try:
|
||||||
|
await _noise_floor_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
_noise_floor_task = None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_noise_floor_history() -> dict:
|
||||||
|
"""Return the current 24-hour in-memory noise floor history snapshot."""
|
||||||
|
now = int(time.time())
|
||||||
|
cutoff = now - NOISE_FLOOR_WINDOW_SECONDS
|
||||||
|
|
||||||
|
async with _samples_lock:
|
||||||
|
samples = [
|
||||||
|
{"timestamp": timestamp, "noise_floor_dbm": noise_floor_dbm}
|
||||||
|
for timestamp, noise_floor_dbm in _noise_floor_samples
|
||||||
|
if timestamp >= cutoff
|
||||||
|
]
|
||||||
|
|
||||||
|
latest = samples[-1] if samples else None
|
||||||
|
oldest_timestamp = samples[0]["timestamp"] if samples else None
|
||||||
|
coverage_seconds = 0 if oldest_timestamp is None else max(0, now - oldest_timestamp)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"sample_interval_seconds": NOISE_FLOOR_SAMPLE_INTERVAL_SECONDS,
|
||||||
|
"coverage_seconds": coverage_seconds,
|
||||||
|
"latest_noise_floor_dbm": latest["noise_floor_dbm"] if latest else None,
|
||||||
|
"latest_timestamp": latest["timestamp"] if latest else None,
|
||||||
|
"supported": _noise_floor_supported,
|
||||||
|
"samples": samples,
|
||||||
|
}
|
||||||
@@ -43,9 +43,6 @@ class WebSocketManager:
|
|||||||
3. Send to all clients concurrently with timeout
|
3. Send to all clients concurrently with timeout
|
||||||
4. Re-acquire lock to clean up disconnected clients
|
4. Re-acquire lock to clean up disconnected clients
|
||||||
"""
|
"""
|
||||||
if not self.active_connections:
|
|
||||||
return
|
|
||||||
|
|
||||||
message = dump_ws_event(event_type, data)
|
message = dump_ws_event(event_type, data)
|
||||||
|
|
||||||
# Copy connection list under lock to avoid holding lock during I/O
|
# Copy connection list under lock to avoid holding lock during I/O
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
services:
|
||||||
|
remoteterm:
|
||||||
|
# build: .
|
||||||
|
image: docker.io/jkingsman/remoteterm-meshcore:latest
|
||||||
|
|
||||||
|
# Optional on Linux: run container as your host user to avoid root-owned files in ./data
|
||||||
|
# This is less reliable for serial-device access than running as root and may require
|
||||||
|
# extra group setup (for example dialout) or other manual customization.
|
||||||
|
# user: "${UID:-1000}:${GID:-1000}"
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
|
||||||
|
#####################################################################
|
||||||
|
# Map your radio by stable device ID if available. #
|
||||||
|
# If your by-id path contains ':' characters, Docker Compose cannot #
|
||||||
|
# represent it here directly; use a colon-free host alias instead. #
|
||||||
|
# (e.g. /dev/ttyUSB0) #
|
||||||
|
#####################################################################
|
||||||
|
devices:
|
||||||
|
- /dev/serial/by-id/your-meshcore-radio:/dev/meshcore-radio
|
||||||
|
|
||||||
|
environment:
|
||||||
|
MESHCORE_DATABASE_PATH: data/meshcore.db
|
||||||
|
|
||||||
|
# Radio connection
|
||||||
|
# Serial (USB)
|
||||||
|
MESHCORE_SERIAL_PORT: /dev/meshcore-radio
|
||||||
|
# MESHCORE_SERIAL_BAUDRATE: 115200
|
||||||
|
|
||||||
|
# TCP
|
||||||
|
# MESHCORE_TCP_HOST: 192.168.1.100
|
||||||
|
# MESHCORE_TCP_PORT: 4000
|
||||||
|
|
||||||
|
# BLE
|
||||||
|
# BLE in Docker usually needs additional manual compose changes such as
|
||||||
|
# Bluetooth device passthrough, privileged mode, host networking, or
|
||||||
|
# other host-specific tweaks before it will actually work.
|
||||||
|
# MESHCORE_BLE_ADDRESS: AA:BB:CC:DD:EE:FF
|
||||||
|
# MESHCORE_BLE_PIN: 123456
|
||||||
|
|
||||||
|
# Security
|
||||||
|
# MESHCORE_DISABLE_BOTS: "true"
|
||||||
|
# MESHCORE_BASIC_AUTH_USERNAME: changeme
|
||||||
|
# MESHCORE_BASIC_AUTH_PASSWORD: changeme
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
# MESHCORE_LOG_LEVEL: INFO
|
||||||
|
restart: unless-stopped
|
||||||
@@ -1,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
@@ -39,6 +39,8 @@ frontend/src/
|
|||||||
├── index.css # Global styles/utilities
|
├── index.css # Global styles/utilities
|
||||||
├── styles.css # Additional global app styles
|
├── styles.css # Additional global app styles
|
||||||
├── themes.css # Color theme definitions
|
├── themes.css # Color theme definitions
|
||||||
|
├── contexts/
|
||||||
|
│ └── DistanceUnitContext.tsx # Browser-local distance-unit context/provider
|
||||||
├── lib/
|
├── lib/
|
||||||
│ └── utils.ts # cn() — clsx + tailwind-merge helper
|
│ └── utils.ts # cn() — clsx + tailwind-merge helper
|
||||||
├── hooks/
|
├── hooks/
|
||||||
@@ -53,10 +55,14 @@ frontend/src/
|
|||||||
│ ├── useRadioControl.ts # Radio health/config state, reconnection, mesh discovery sweeps
|
│ ├── useRadioControl.ts # Radio health/config state, reconnection, mesh discovery sweeps
|
||||||
│ ├── useAppSettings.ts # Settings, favorites, preferences migration
|
│ ├── useAppSettings.ts # Settings, favorites, preferences migration
|
||||||
│ ├── useConversationRouter.ts # URL hash → active conversation routing
|
│ ├── useConversationRouter.ts # URL hash → active conversation routing
|
||||||
│ └── useContactsAndChannels.ts # Contact/channel loading, creation, deletion
|
│ ├── useContactsAndChannels.ts # Contact/channel loading, creation, deletion
|
||||||
|
│ ├── useBrowserNotifications.ts # Per-conversation browser notification preferences + dispatch
|
||||||
|
│ ├── useFaviconBadge.ts # Browser tab unread badge state
|
||||||
|
│ ├── useRawPacketStatsSession.ts # Session-scoped packet-feed stats history
|
||||||
|
│ └── useRememberedServerPassword.ts # Browser-local repeater/room password persistence
|
||||||
├── components/
|
├── components/
|
||||||
│ ├── AppShell.tsx # App-shell layout: status, sidebar, search/settings panes, cracker, modals
|
│ ├── AppShell.tsx # App-shell layout: status, sidebar, search/settings panes, cracker, modals, security warning
|
||||||
│ ├── ConversationPane.tsx # Active conversation surface selection (map/raw/repeater/chat/empty)
|
│ ├── ConversationPane.tsx # Active conversation surface selection (map/raw/trace/repeater/room/chat/empty)
|
||||||
│ ├── visualizer/
|
│ ├── visualizer/
|
||||||
│ │ ├── useVisualizerData3D.ts # Packet→graph data pipeline, repeat aggregation, simulation state
|
│ │ ├── useVisualizerData3D.ts # Packet→graph data pipeline, repeat aggregation, simulation state
|
||||||
│ │ ├── useVisualizer3DScene.ts # Three.js scene lifecycle, buffers, hover/pin interaction
|
│ │ ├── useVisualizer3DScene.ts # Three.js scene lifecycle, buffers, hover/pin interaction
|
||||||
@@ -73,14 +79,18 @@ frontend/src/
|
|||||||
│ ├── pubkey.ts # getContactDisplayName (12-char prefix fallback)
|
│ ├── pubkey.ts # getContactDisplayName (12-char prefix fallback)
|
||||||
│ ├── contactAvatar.ts # Avatar color derivation from public key
|
│ ├── contactAvatar.ts # Avatar color derivation from public key
|
||||||
│ ├── rawPacketIdentity.ts # observation_id vs id dedup helpers
|
│ ├── rawPacketIdentity.ts # observation_id vs id dedup helpers
|
||||||
|
│ ├── rawPacketStats.ts # Session packet stats windows, rankings, and coverage helpers
|
||||||
│ ├── regionScope.ts # Regional flood-scope label/normalization helpers
|
│ ├── regionScope.ts # Regional flood-scope label/normalization helpers
|
||||||
│ ├── visualizerUtils.ts # 3D visualizer node types, colors, particles
|
│ ├── visualizerUtils.ts # 3D visualizer node types, colors, particles
|
||||||
│ ├── visualizerSettings.ts # LocalStorage persistence for visualizer options
|
│ ├── visualizerSettings.ts # LocalStorage persistence for visualizer options
|
||||||
│ ├── a11y.ts # Keyboard accessibility helper
|
│ ├── a11y.ts # Keyboard accessibility helper
|
||||||
|
│ ├── distanceUnits.ts # Browser-local distance unit persistence/helpers
|
||||||
│ ├── lastViewedConversation.ts # localStorage for last-viewed conversation
|
│ ├── lastViewedConversation.ts # localStorage for last-viewed conversation
|
||||||
│ ├── contactMerge.ts # Merge WS contact updates into list
|
│ ├── contactMerge.ts # Merge WS contact updates into list
|
||||||
│ ├── localLabel.ts # Local label (text + color) in localStorage
|
│ ├── localLabel.ts # Local label (text + color) in localStorage
|
||||||
│ ├── radioPresets.ts # LoRa radio preset configurations
|
│ ├── radioPresets.ts # LoRa radio preset configurations
|
||||||
|
│ ├── publicChannel.ts # Public-channel resolution helpers for routing/hash defaults
|
||||||
|
│ ├── fontScale.ts # Browser-local relative font scale persistence/application
|
||||||
│ └── theme.ts # Theme switching helpers
|
│ └── theme.ts # Theme switching helpers
|
||||||
├── components/
|
├── components/
|
||||||
│ ├── StatusBar.tsx
|
│ ├── StatusBar.tsx
|
||||||
@@ -91,8 +101,12 @@ frontend/src/
|
|||||||
│ ├── NewMessageModal.tsx
|
│ ├── NewMessageModal.tsx
|
||||||
│ ├── SearchView.tsx # Full-text message search pane
|
│ ├── SearchView.tsx # Full-text message search pane
|
||||||
│ ├── SettingsModal.tsx # Layout shell — delegates to settings/ sections
|
│ ├── SettingsModal.tsx # Layout shell — delegates to settings/ sections
|
||||||
|
│ ├── SecurityWarningModal.tsx # Startup warning for trusted-network / bot execution posture
|
||||||
│ ├── RawPacketList.tsx
|
│ ├── RawPacketList.tsx
|
||||||
|
│ ├── RawPacketFeedView.tsx # Live raw packet feed + session stats drawer
|
||||||
|
│ ├── RawPacketDetailModal.tsx # On-demand packet inspector dialog
|
||||||
│ ├── MapView.tsx
|
│ ├── MapView.tsx
|
||||||
|
│ ├── TracePane.tsx # Multi-hop route trace builder/results view
|
||||||
│ ├── VisualizerView.tsx
|
│ ├── VisualizerView.tsx
|
||||||
│ ├── PacketVisualizer3D.tsx
|
│ ├── PacketVisualizer3D.tsx
|
||||||
│ ├── PathModal.tsx
|
│ ├── PathModal.tsx
|
||||||
@@ -102,15 +116,20 @@ frontend/src/
|
|||||||
│ ├── ContactAvatar.tsx
|
│ ├── ContactAvatar.tsx
|
||||||
│ ├── ContactInfoPane.tsx # Contact detail sheet (stats, name history, paths)
|
│ ├── ContactInfoPane.tsx # Contact detail sheet (stats, name history, paths)
|
||||||
│ ├── ContactStatusInfo.tsx # Contact status info component
|
│ ├── ContactStatusInfo.tsx # Contact status info component
|
||||||
|
│ ├── ContactPathDiscoveryModal.tsx # Forward/return path discovery dialog
|
||||||
|
│ ├── ContactRoutingOverrideModal.tsx # Manual direct-route override editor
|
||||||
│ ├── RepeaterDashboard.tsx # Layout shell — delegates to repeater/ panes
|
│ ├── RepeaterDashboard.tsx # Layout shell — delegates to repeater/ panes
|
||||||
│ ├── RepeaterLogin.tsx # Repeater login form (password + guest)
|
│ ├── RepeaterLogin.tsx # Repeater login form (password + guest)
|
||||||
|
│ ├── RoomServerPanel.tsx # Room-server auth gate + status banner ahead of room chat
|
||||||
|
│ ├── ServerLoginStatusBanner.tsx # Shared repeater/room login state banner
|
||||||
│ ├── ChannelInfoPane.tsx # Channel detail sheet (stats, top senders)
|
│ ├── ChannelInfoPane.tsx # Channel detail sheet (stats, top senders)
|
||||||
|
│ ├── ChannelFloodScopeOverrideModal.tsx # Per-channel flood-scope override editor
|
||||||
│ ├── DirectTraceIcon.tsx # Shared direct-trace glyph used in header/dashboard
|
│ ├── DirectTraceIcon.tsx # Shared direct-trace glyph used in header/dashboard
|
||||||
│ ├── NeighborsMiniMap.tsx # Leaflet mini-map for repeater neighbor locations
|
│ ├── NeighborsMiniMap.tsx # Leaflet mini-map for repeater neighbor locations
|
||||||
│ ├── settings/
|
│ ├── settings/
|
||||||
│ │ ├── settingsConstants.ts # Settings section type, ordering, labels
|
│ │ ├── settingsConstants.ts # Settings section type, ordering, labels
|
||||||
│ │ ├── SettingsRadioSection.tsx # Name, keys, advert interval, max contacts, radio preset, freq/bw/sf/cr, txPower, lat/lon, reboot, mesh discovery
|
│ │ ├── SettingsRadioSection.tsx # Name, keys, advert interval, max contacts, radio preset, freq/bw/sf/cr, txPower, lat/lon, reboot, mesh discovery
|
||||||
│ │ ├── SettingsLocalSection.tsx # Browser-local settings: theme, local label, reopen last conversation
|
│ │ ├── SettingsLocalSection.tsx # Browser-local settings: theme, relative font scale, local label, reopen last conversation
|
||||||
│ │ ├── SettingsFanoutSection.tsx # Fanout integrations: MQTT, bots, config CRUD
|
│ │ ├── SettingsFanoutSection.tsx # Fanout integrations: MQTT, bots, config CRUD
|
||||||
│ │ ├── SettingsDatabaseSection.tsx # DB size, cleanup, auto-decrypt, local label
|
│ │ ├── SettingsDatabaseSection.tsx # DB size, cleanup, auto-decrypt, local label
|
||||||
│ │ ├── SettingsStatisticsSection.tsx # Read-only mesh network stats
|
│ │ ├── SettingsStatisticsSection.tsx # Read-only mesh network stats
|
||||||
@@ -130,12 +149,13 @@ frontend/src/
|
|||||||
│ └── ui/ # shadcn/ui primitives
|
│ └── ui/ # shadcn/ui primitives
|
||||||
├── types/
|
├── types/
|
||||||
│ └── d3-force-3d.d.ts # Type declarations for d3-force-3d
|
│ └── d3-force-3d.d.ts # Type declarations for d3-force-3d
|
||||||
└── test/
|
└── test/ # Representative frontend test suites (not an exhaustive listing)
|
||||||
├── setup.ts
|
├── setup.ts
|
||||||
├── fixtures/websocket_events.json
|
├── fixtures/websocket_events.json
|
||||||
├── api.test.ts
|
├── api.test.ts
|
||||||
├── appFavorites.test.tsx
|
├── appFavorites.test.tsx
|
||||||
├── appStartupHash.test.tsx
|
├── appStartupHash.test.tsx
|
||||||
|
├── conversationPane.test.tsx
|
||||||
├── contactAvatar.test.ts
|
├── contactAvatar.test.ts
|
||||||
├── contactInfoPane.test.tsx
|
├── contactInfoPane.test.tsx
|
||||||
├── integration.test.ts
|
├── integration.test.ts
|
||||||
@@ -146,18 +166,23 @@ frontend/src/
|
|||||||
├── rawPacketList.test.tsx
|
├── rawPacketList.test.tsx
|
||||||
├── pathUtils.test.ts
|
├── pathUtils.test.ts
|
||||||
├── prefetch.test.ts
|
├── prefetch.test.ts
|
||||||
|
├── rawPacketDetailModal.test.tsx
|
||||||
|
├── rawPacketFeedView.test.tsx
|
||||||
├── radioPresets.test.ts
|
├── radioPresets.test.ts
|
||||||
├── rawPacketIdentity.test.ts
|
├── rawPacketIdentity.test.ts
|
||||||
├── repeaterDashboard.test.tsx
|
├── repeaterDashboard.test.tsx
|
||||||
├── repeaterFormatters.test.ts
|
├── repeaterFormatters.test.ts
|
||||||
├── repeaterLogin.test.tsx
|
├── repeaterLogin.test.tsx
|
||||||
├── repeaterMessageParsing.test.ts
|
├── repeaterMessageParsing.test.ts
|
||||||
|
├── roomServerPanel.test.tsx
|
||||||
|
├── securityWarningModal.test.tsx
|
||||||
├── localLabel.test.ts
|
├── localLabel.test.ts
|
||||||
├── messageInput.test.tsx
|
├── messageInput.test.tsx
|
||||||
├── newMessageModal.test.tsx
|
├── newMessageModal.test.tsx
|
||||||
├── settingsModal.test.tsx
|
├── settingsModal.test.tsx
|
||||||
├── sidebar.test.tsx
|
├── sidebar.test.tsx
|
||||||
├── statusBar.test.tsx
|
├── statusBar.test.tsx
|
||||||
|
├── tracePane.test.tsx
|
||||||
├── unreadCounts.test.ts
|
├── unreadCounts.test.ts
|
||||||
├── urlHash.test.ts
|
├── urlHash.test.ts
|
||||||
├── appSearchJump.test.tsx
|
├── appSearchJump.test.tsx
|
||||||
@@ -169,12 +194,17 @@ frontend/src/
|
|||||||
├── useConversationMessages.race.test.ts
|
├── useConversationMessages.race.test.ts
|
||||||
├── useConversationNavigation.test.ts
|
├── useConversationNavigation.test.ts
|
||||||
├── useAppShell.test.ts
|
├── useAppShell.test.ts
|
||||||
|
├── useBrowserNotifications.test.ts
|
||||||
|
├── useFaviconBadge.test.ts
|
||||||
├── useRepeaterDashboard.test.ts
|
├── useRepeaterDashboard.test.ts
|
||||||
|
├── useRememberedServerPassword.test.ts
|
||||||
├── useContactsAndChannels.test.ts
|
├── useContactsAndChannels.test.ts
|
||||||
├── useRealtimeAppState.test.ts
|
├── useRealtimeAppState.test.ts
|
||||||
├── useUnreadCounts.test.ts
|
├── useUnreadCounts.test.ts
|
||||||
├── useWebSocket.dispatch.test.ts
|
├── useWebSocket.dispatch.test.ts
|
||||||
├── useWebSocket.lifecycle.test.ts
|
├── useWebSocket.lifecycle.test.ts
|
||||||
|
├── rawPacketStats.test.ts
|
||||||
|
├── fontScale.test.ts
|
||||||
└── wsEvents.test.ts
|
└── wsEvents.test.ts
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -190,6 +220,7 @@ frontend/src/
|
|||||||
- search/settings surface switching
|
- search/settings surface switching
|
||||||
- global cracker mount/focus behavior
|
- global cracker mount/focus behavior
|
||||||
- new-message modal and info panes
|
- new-message modal and info panes
|
||||||
|
- trusted-network `SecurityWarningModal`
|
||||||
|
|
||||||
High-level state is delegated to hooks:
|
High-level state is delegated to hooks:
|
||||||
- `useAppShell`: app-shell view state (settings section, sidebar, cracker, new-message modal)
|
- `useAppShell`: app-shell view state (settings section, sidebar, cracker, new-message modal)
|
||||||
@@ -211,7 +242,9 @@ High-level state is delegated to hooks:
|
|||||||
- map view
|
- map view
|
||||||
- visualizer
|
- visualizer
|
||||||
- raw packet feed
|
- raw packet feed
|
||||||
|
- trace view
|
||||||
- repeater dashboard
|
- repeater dashboard
|
||||||
|
- room-server auth/status gate before room chat
|
||||||
- normal chat chrome (`ChatHeader` + `MessageList` + `MessageInput`)
|
- normal chat chrome (`ChatHeader` + `MessageList` + `MessageInput`)
|
||||||
|
|
||||||
### Initial load + realtime
|
### Initial load + realtime
|
||||||
@@ -272,12 +305,16 @@ Supported routes:
|
|||||||
- `#map/focus/{pubkey_or_prefix}`
|
- `#map/focus/{pubkey_or_prefix}`
|
||||||
- `#visualizer`
|
- `#visualizer`
|
||||||
- `#search`
|
- `#search`
|
||||||
|
- `#trace`
|
||||||
|
- `#settings/{section}`
|
||||||
- `#channel/{channelKey}`
|
- `#channel/{channelKey}`
|
||||||
- `#channel/{channelKey}/{label}`
|
- `#channel/{channelKey}/{label}`
|
||||||
- `#contact/{publicKey}`
|
- `#contact/{publicKey}`
|
||||||
- `#contact/{publicKey}/{label}`
|
- `#contact/{publicKey}/{label}`
|
||||||
|
|
||||||
Legacy name-based hashes are still accepted for compatibility.
|
Where `{section}` is one of `radio`, `local`, `fanout`, `database`, `statistics`, or `about`.
|
||||||
|
|
||||||
|
Legacy name-based channel/contact hashes are still accepted for compatibility.
|
||||||
|
|
||||||
## Conversation State Keys (`utils/conversationState.ts`)
|
## Conversation State Keys (`utils/conversationState.ts`)
|
||||||
|
|
||||||
@@ -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.
|
All state is managed by `useRepeaterDashboard` hook. State resets on conversation change.
|
||||||
|
|
||||||
|
## Room Server Panel
|
||||||
|
|
||||||
|
For room contacts (`type=3`), `ConversationPane.tsx` keeps the normal chat surface but inserts `RoomServerPanel` above it. That panel handles room-server login/status messaging and gates room chat behind the room-authenticated state when required.
|
||||||
|
|
||||||
|
`ServerLoginStatusBanner` is shared between repeater and room login surfaces for inline status/error display.
|
||||||
|
|
||||||
## Message Search Pane
|
## Message Search Pane
|
||||||
|
|
||||||
The `SearchView` component (`components/SearchView.tsx`) provides full-text search across all DMs and channel messages. Key behaviors:
|
The `SearchView` component (`components/SearchView.tsx`) provides full-text search across all DMs and channel messages. Key behaviors:
|
||||||
@@ -404,7 +447,7 @@ Do not rely on old class-only layout assumptions.
|
|||||||
Run all quality checks (backend + frontend) from the repo root:
|
Run all quality checks (backend + frontend) from the repo root:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/all_quality.sh
|
./scripts/quality/all_quality.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Or run frontend checks individually:
|
Or run frontend checks individually:
|
||||||
|
|||||||
Generated
+384
-3
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "remoteterm-meshcore-frontend",
|
"name": "remoteterm-meshcore-frontend",
|
||||||
"version": "3.6.1",
|
"version": "3.6.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "remoteterm-meshcore-frontend",
|
"name": "remoteterm-meshcore-frontend",
|
||||||
"version": "3.6.1",
|
"version": "3.6.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-python": "^6.2.1",
|
"@codemirror/lang-python": "^6.2.1",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
"react-swipeable": "^7.0.2",
|
"react-swipeable": "^7.0.2",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
@@ -2057,6 +2058,42 @@
|
|||||||
"react-dom": "^18.0.0"
|
"react-dom": "^18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit": {
|
||||||
|
"version": "2.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||||
|
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"@standard-schema/utils": "^0.3.0",
|
||||||
|
"immer": "^11.0.0",
|
||||||
|
"redux": "^5.0.1",
|
||||||
|
"redux-thunk": "^3.1.0",
|
||||||
|
"reselect": "^5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||||
|
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||||
|
"version": "11.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
|
||||||
|
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
@@ -2414,6 +2451,18 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/spec": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@standard-schema/utils": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@testing-library/dom": {
|
"node_modules/@testing-library/dom": {
|
||||||
"version": "10.4.1",
|
"version": "10.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||||
@@ -2564,6 +2613,24 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@babel/types": "^7.28.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-array": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/d3-force": {
|
"node_modules/@types/d3-force": {
|
||||||
"version": "3.0.10",
|
"version": "3.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
|
||||||
@@ -2571,6 +2638,51 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-shape": {
|
||||||
|
"version": "3.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||||
|
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -2663,6 +2775,12 @@
|
|||||||
"meshoptimizer": "~0.22.0"
|
"meshoptimizer": "~0.22.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/webxr": {
|
"node_modules/@types/webxr": {
|
||||||
"version": "0.5.24",
|
"version": "0.5.24",
|
||||||
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||||
@@ -3712,12 +3830,33 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/d3-binarytree": {
|
"node_modules/d3-binarytree": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz",
|
||||||
"integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==",
|
"integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/d3-dispatch": {
|
"node_modules/d3-dispatch": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||||
@@ -3727,6 +3866,15 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/d3-force": {
|
"node_modules/d3-force": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
|
||||||
@@ -3757,12 +3905,42 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/d3-octree": {
|
"node_modules/d3-octree": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz",
|
||||||
"integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==",
|
"integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/d3-quadtree": {
|
"node_modules/d3-quadtree": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
|
||||||
@@ -3772,6 +3950,58 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/d3-timer": {
|
"node_modules/d3-timer": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
@@ -3820,6 +4050,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/decimal.js-light": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/deep-eql": {
|
"node_modules/deep-eql": {
|
||||||
"version": "5.0.2",
|
"version": "5.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
||||||
@@ -3974,6 +4210,16 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-toolkit": {
|
||||||
|
"version": "1.45.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
|
||||||
|
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"docs",
|
||||||
|
"benchmarks"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||||
@@ -4216,6 +4462,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/expect-type": {
|
"node_modules/expect-type": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||||
@@ -4618,6 +4870,16 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "10.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||||
|
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -4655,6 +4917,15 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-binary-path": {
|
"node_modules/is-binary-path": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||||
@@ -5599,7 +5870,6 @@
|
|||||||
"version": "17.0.2",
|
"version": "17.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
@@ -5617,6 +5887,29 @@
|
|||||||
"react-dom": "^18.0.0"
|
"react-dom": "^18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-redux": {
|
||||||
|
"version": "9.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^18.2.25 || ^19",
|
||||||
|
"react": "^18.0 || ^19",
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||||
@@ -5726,6 +6019,36 @@
|
|||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/recharts": {
|
||||||
|
"version": "3.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
|
||||||
|
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"www"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"decimal.js-light": "^2.5.1",
|
||||||
|
"es-toolkit": "^1.39.3",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"immer": "^10.1.1",
|
||||||
|
"react-redux": "8.x.x || 9.x.x",
|
||||||
|
"reselect": "5.1.1",
|
||||||
|
"tiny-invariant": "^1.3.3",
|
||||||
|
"use-sync-external-store": "^1.2.2",
|
||||||
|
"victory-vendor": "^37.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/redent": {
|
"node_modules/redent": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||||
@@ -5740,6 +6063,27 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/redux-thunk": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/reselect": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
@@ -6134,6 +6478,12 @@
|
|||||||
"integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==",
|
"integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-invariant": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinybench": {
|
"node_modules/tinybench": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
@@ -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": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/victory-vendor": {
|
||||||
|
"version": "37.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||||
|
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||||
|
"license": "MIT AND ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-array": "^3.0.3",
|
||||||
|
"@types/d3-ease": "^3.0.0",
|
||||||
|
"@types/d3-interpolate": "^3.0.1",
|
||||||
|
"@types/d3-scale": "^4.0.2",
|
||||||
|
"@types/d3-shape": "^3.1.0",
|
||||||
|
"@types/d3-time": "^3.0.0",
|
||||||
|
"@types/d3-timer": "^3.0.0",
|
||||||
|
"d3-array": "^3.1.6",
|
||||||
|
"d3-ease": "^3.0.1",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.1.0",
|
||||||
|
"d3-time": "^3.0.0",
|
||||||
|
"d3-timer": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.4.1",
|
"version": "6.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "remoteterm-meshcore-frontend",
|
"name": "remoteterm-meshcore-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "3.6.2",
|
"version": "3.6.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -38,6 +38,7 @@
|
|||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
"react-swipeable": "^7.0.2",
|
"react-swipeable": "^7.0.2",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
|||||||
+43
-2
@@ -31,6 +31,12 @@ interface ChannelUnreadMarker {
|
|||||||
lastReadAt: number | null;
|
lastReadAt: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NewMessagePrefillRequest {
|
||||||
|
tab: 'hashtag';
|
||||||
|
hashtagName: string;
|
||||||
|
nonce: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface UnreadBoundaryBackfillParams {
|
interface UnreadBoundaryBackfillParams {
|
||||||
activeConversation: Conversation | null;
|
activeConversation: Conversation | null;
|
||||||
unreadMarker: ChannelUnreadMarker | null;
|
unreadMarker: ChannelUnreadMarker | null;
|
||||||
@@ -77,6 +83,8 @@ export function App() {
|
|||||||
const messageInputRef = useRef<MessageInputHandle>(null);
|
const messageInputRef = useRef<MessageInputHandle>(null);
|
||||||
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
|
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
|
||||||
const [channelUnreadMarker, setChannelUnreadMarker] = useState<ChannelUnreadMarker | null>(null);
|
const [channelUnreadMarker, setChannelUnreadMarker] = useState<ChannelUnreadMarker | null>(null);
|
||||||
|
const [newMessagePrefillRequest, setNewMessagePrefillRequest] =
|
||||||
|
useState<NewMessagePrefillRequest | null>(null);
|
||||||
const [visibilityVersion, setVisibilityVersion] = useState(0);
|
const [visibilityVersion, setVisibilityVersion] = useState(0);
|
||||||
const lastUnreadBackfillAttemptRef = useRef<string | null>(null);
|
const lastUnreadBackfillAttemptRef = useRef<string | null>(null);
|
||||||
const {
|
const {
|
||||||
@@ -103,8 +111,8 @@ export function App() {
|
|||||||
setDistanceUnit,
|
setDistanceUnit,
|
||||||
handleCloseSettingsView,
|
handleCloseSettingsView,
|
||||||
handleToggleSettingsView,
|
handleToggleSettingsView,
|
||||||
handleOpenNewMessage,
|
handleOpenNewMessage: openNewMessageModal,
|
||||||
handleCloseNewMessage,
|
handleCloseNewMessage: closeNewMessageModal,
|
||||||
handleToggleCracker,
|
handleToggleCracker,
|
||||||
} = useAppShell();
|
} = useAppShell();
|
||||||
|
|
||||||
@@ -274,6 +282,7 @@ export function App() {
|
|||||||
unreadLastReadAts,
|
unreadLastReadAts,
|
||||||
recordMessageEvent,
|
recordMessageEvent,
|
||||||
renameConversationState,
|
renameConversationState,
|
||||||
|
removeConversationState,
|
||||||
markAllRead,
|
markAllRead,
|
||||||
refreshUnreads,
|
refreshUnreads,
|
||||||
} = useUnreadCounts(channels, contacts, activeConversation);
|
} = useUnreadCounts(channels, contacts, activeConversation);
|
||||||
@@ -349,6 +358,7 @@ export function App() {
|
|||||||
observeMessage,
|
observeMessage,
|
||||||
recordMessageEvent,
|
recordMessageEvent,
|
||||||
renameConversationState,
|
renameConversationState,
|
||||||
|
removeConversationState,
|
||||||
checkMention,
|
checkMention,
|
||||||
pendingDeleteFallbackRef,
|
pendingDeleteFallbackRef,
|
||||||
setActiveConversation,
|
setActiveConversation,
|
||||||
@@ -411,6 +421,34 @@ export function App() {
|
|||||||
[fetchUndecryptedCount, setChannels]
|
[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 = {
|
const statusProps = {
|
||||||
health,
|
health,
|
||||||
config,
|
config,
|
||||||
@@ -457,6 +495,7 @@ export function App() {
|
|||||||
loadingNewer,
|
loadingNewer,
|
||||||
messageInputRef,
|
messageInputRef,
|
||||||
onTrace: handleTrace,
|
onTrace: handleTrace,
|
||||||
|
onRunTracePath: api.requestRadioTrace,
|
||||||
onPathDiscovery: handlePathDiscovery,
|
onPathDiscovery: handlePathDiscovery,
|
||||||
onToggleFavorite: handleToggleFavorite,
|
onToggleFavorite: handleToggleFavorite,
|
||||||
onDeleteContact: handleDeleteContact,
|
onDeleteContact: handleDeleteContact,
|
||||||
@@ -465,6 +504,7 @@ export function App() {
|
|||||||
onOpenContactInfo: handleOpenContactInfo,
|
onOpenContactInfo: handleOpenContactInfo,
|
||||||
onOpenChannelInfo: handleOpenChannelInfo,
|
onOpenChannelInfo: handleOpenChannelInfo,
|
||||||
onSenderClick: handleSenderClick,
|
onSenderClick: handleSenderClick,
|
||||||
|
onChannelReferenceClick: handleChannelReferenceClick,
|
||||||
onLoadOlder: fetchOlderMessages,
|
onLoadOlder: fetchOlderMessages,
|
||||||
onResendChannelMessage: handleResendChannelMessage,
|
onResendChannelMessage: handleResendChannelMessage,
|
||||||
onTargetReached: () => setTargetMessageId(null),
|
onTargetReached: () => setTargetMessageId(null),
|
||||||
@@ -523,6 +563,7 @@ export function App() {
|
|||||||
};
|
};
|
||||||
const newMessageModalProps = {
|
const newMessageModalProps = {
|
||||||
undecryptedCount,
|
undecryptedCount,
|
||||||
|
prefillRequest: newMessagePrefillRequest,
|
||||||
onCreateContact: handleCreateContact,
|
onCreateContact: handleCreateContact,
|
||||||
onCreateChannel: handleCreateChannel,
|
onCreateChannel: handleCreateChannel,
|
||||||
onCreateHashtagChannel: handleCreateHashtagChannel,
|
onCreateHashtagChannel: handleCreateHashtagChannel,
|
||||||
|
|||||||
+11
-2
@@ -20,6 +20,8 @@ import type {
|
|||||||
RadioConfig,
|
RadioConfig,
|
||||||
RadioConfigUpdate,
|
RadioConfigUpdate,
|
||||||
RadioDiscoveryResponse,
|
RadioDiscoveryResponse,
|
||||||
|
RadioTraceHopRequest,
|
||||||
|
RadioTraceResponse,
|
||||||
RadioDiscoveryTarget,
|
RadioDiscoveryTarget,
|
||||||
PathDiscoveryResponse,
|
PathDiscoveryResponse,
|
||||||
ResendChannelMessageResponse,
|
ResendChannelMessageResponse,
|
||||||
@@ -107,6 +109,11 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ target }),
|
body: JSON.stringify({ target }),
|
||||||
}),
|
}),
|
||||||
|
requestRadioTrace: (hopHashBytes: 1 | 2 | 4, hops: RadioTraceHopRequest[]) =>
|
||||||
|
fetchJson<RadioTraceResponse>('/radio/trace', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ hop_hash_bytes: hopHashBytes, hops }),
|
||||||
|
}),
|
||||||
rebootRadio: () =>
|
rebootRadio: () =>
|
||||||
fetchJson<{ status: string; message: string }>('/radio/reboot', {
|
fetchJson<{ status: string; message: string }>('/radio/reboot', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -130,11 +137,13 @@ export const api = {
|
|||||||
fetchJson<ContactAdvertPathSummary[]>(
|
fetchJson<ContactAdvertPathSummary[]>(
|
||||||
`/contacts/repeaters/advert-paths?limit_per_repeater=${limitPerRepeater}`
|
`/contacts/repeaters/advert-paths?limit_per_repeater=${limitPerRepeater}`
|
||||||
),
|
),
|
||||||
getContactAnalytics: (params: { publicKey?: string; name?: string }) => {
|
getContactAnalytics: (params: { publicKey?: string; name?: string }, signal?: AbortSignal) => {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
if (params.publicKey) searchParams.set('public_key', params.publicKey);
|
if (params.publicKey) searchParams.set('public_key', params.publicKey);
|
||||||
if (params.name) searchParams.set('name', params.name);
|
if (params.name) searchParams.set('name', params.name);
|
||||||
return fetchJson<ContactAnalytics>(`/contacts/analytics?${searchParams.toString()}`);
|
return fetchJson<ContactAnalytics>(`/contacts/analytics?${searchParams.toString()}`, {
|
||||||
|
signal,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
deleteContact: (publicKey: string) =>
|
deleteContact: (publicKey: string) =>
|
||||||
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
|
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
|
||||||
|
|||||||
@@ -268,7 +268,7 @@ export function ChatHeader({
|
|||||||
title={
|
title={
|
||||||
activeContactIsPrefixOnly
|
activeContactIsPrefixOnly
|
||||||
? 'Direct Trace unavailable until the full contact key is known'
|
? 'Direct Trace unavailable until the full contact key is known'
|
||||||
: 'Direct Trace. Send a zero-hop packet to this contact and display out and back SNR'
|
: 'Direct Trace. Send a direct trace probe to this contact and display out and back SNR'
|
||||||
}
|
}
|
||||||
aria-label="Direct Trace"
|
aria-label="Direct Trace"
|
||||||
disabled={activeContactIsPrefixOnly}
|
disabled={activeContactIsPrefixOnly}
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import { type ReactNode, useEffect, useState } from 'react';
|
import { type ReactNode, useEffect, useState } from 'react';
|
||||||
import { Ban, Search, Star } from 'lucide-react';
|
import { Ban, Search, Star } from 'lucide-react';
|
||||||
import { api } from '../api';
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip as RechartsTooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Legend,
|
||||||
|
} from 'recharts';
|
||||||
|
import { api, isAbortError } from '../api';
|
||||||
import { formatTime } from '../utils/messageParser';
|
import { formatTime } from '../utils/messageParser';
|
||||||
import {
|
import {
|
||||||
getContactDisplayName,
|
getContactDisplayName,
|
||||||
@@ -100,29 +110,29 @@ export function ContactInfoPane({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
const controller = new AbortController();
|
||||||
setAnalytics(null);
|
setAnalytics(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const request =
|
const request =
|
||||||
isNameOnly && nameOnlyValue
|
isNameOnly && nameOnlyValue
|
||||||
? api.getContactAnalytics({ name: nameOnlyValue })
|
? api.getContactAnalytics({ name: nameOnlyValue }, controller.signal)
|
||||||
: api.getContactAnalytics({ publicKey: contactKey });
|
: api.getContactAnalytics({ publicKey: contactKey }, controller.signal);
|
||||||
|
|
||||||
request
|
request
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (!cancelled) setAnalytics(data);
|
if (!controller.signal.aborted) setAnalytics(data);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (!cancelled) {
|
if (!isAbortError(err)) {
|
||||||
console.error('Failed to fetch contact analytics:', err);
|
console.error('Failed to fetch contact analytics:', err);
|
||||||
toast.error('Failed to load contact info');
|
toast.error('Failed to load contact info');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setLoading(false);
|
if (!controller.signal.aborted) setLoading(false);
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
controller.abort();
|
||||||
};
|
};
|
||||||
}, [contactKey, isNameOnly, nameOnlyValue]);
|
}, [contactKey, isNameOnly, nameOnlyValue]);
|
||||||
|
|
||||||
@@ -650,20 +660,18 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu
|
|||||||
{hasHourlyActivity && (
|
{hasHourlyActivity && (
|
||||||
<div>
|
<div>
|
||||||
<SectionLabel>Messages Per Hour</SectionLabel>
|
<SectionLabel>Messages Per Hour</SectionLabel>
|
||||||
<ChartLegend
|
|
||||||
items={[
|
|
||||||
{ label: 'Last 24h', color: '#2563eb' },
|
|
||||||
{ label: '7-day avg', color: '#ea580c' },
|
|
||||||
{ label: 'All-time avg', color: '#64748b' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<ActivityLineChart
|
<ActivityLineChart
|
||||||
ariaLabel="Messages per hour"
|
ariaLabel="Messages per hour"
|
||||||
points={analytics.hourly_activity}
|
points={analytics.hourly_activity}
|
||||||
series={[
|
series={[
|
||||||
{ key: 'last_24h_count', color: '#2563eb' },
|
{ key: 'last_24h_count', color: '#2563eb', label: 'Last 24h' },
|
||||||
{ key: 'last_week_average', color: '#ea580c' },
|
{ key: 'last_week_average', color: '#ea580c', label: '7-day avg' },
|
||||||
{ key: 'all_time_average', color: '#64748b' },
|
{ key: 'all_time_average', color: '#64748b', label: 'All-time avg' },
|
||||||
|
]}
|
||||||
|
legendItems={[
|
||||||
|
{ label: 'Last 24h', color: '#2563eb' },
|
||||||
|
{ label: '7-day avg', color: '#ea580c' },
|
||||||
|
{ label: 'All-time avg', color: '#64748b' },
|
||||||
]}
|
]}
|
||||||
valueFormatter={(value) => value.toFixed(value % 1 === 0 ? 0 : 1)}
|
valueFormatter={(value) => value.toFixed(value % 1 === 0 ? 0 : 1)}
|
||||||
tickFormatter={(bucket) =>
|
tickFormatter={(bucket) =>
|
||||||
@@ -683,7 +691,7 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu
|
|||||||
<ActivityLineChart
|
<ActivityLineChart
|
||||||
ariaLabel="Messages per week"
|
ariaLabel="Messages per week"
|
||||||
points={analytics.weekly_activity}
|
points={analytics.weekly_activity}
|
||||||
series={[{ key: 'message_count', color: '#16a34a' }]}
|
series={[{ key: 'message_count', color: '#16a34a', label: 'Messages' }]}
|
||||||
valueFormatter={(value) => value.toFixed(0)}
|
valueFormatter={(value) => value.toFixed(0)}
|
||||||
tickFormatter={(bucket) =>
|
tickFormatter={(bucket) =>
|
||||||
new Date(bucket.bucket_start * 1000).toLocaleDateString([], {
|
new Date(bucket.bucket_start * 1000).toLocaleDateString([], {
|
||||||
@@ -705,133 +713,115 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChartLegend({ items }: { items: Array<{ label: string; color: string }> }) {
|
const TOOLTIP_STYLE = {
|
||||||
return (
|
contentStyle: {
|
||||||
<div className="flex flex-wrap gap-x-3 gap-y-1 mb-2 text-[11px] text-muted-foreground">
|
backgroundColor: 'hsl(var(--popover))',
|
||||||
{items.map((item) => (
|
border: '1px solid hsl(var(--border))',
|
||||||
<span key={item.label} className="inline-flex items-center gap-1.5">
|
borderRadius: '6px',
|
||||||
<span
|
fontSize: '11px',
|
||||||
className="inline-block h-2 w-2 rounded-full"
|
color: 'hsl(var(--popover-foreground))',
|
||||||
style={{ backgroundColor: item.color }}
|
},
|
||||||
aria-hidden="true"
|
itemStyle: { color: 'hsl(var(--popover-foreground))' },
|
||||||
/>
|
labelStyle: { color: 'hsl(var(--muted-foreground))' },
|
||||||
{item.label}
|
} as const;
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActivityLineChart<T extends ContactAnalyticsHourlyBucket | ContactAnalyticsWeeklyBucket>({
|
function ActivityLineChart<T extends ContactAnalyticsHourlyBucket | ContactAnalyticsWeeklyBucket>({
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
points,
|
points,
|
||||||
series,
|
series,
|
||||||
|
legendItems,
|
||||||
tickFormatter,
|
tickFormatter,
|
||||||
valueFormatter,
|
valueFormatter,
|
||||||
}: {
|
}: {
|
||||||
ariaLabel: string;
|
ariaLabel: string;
|
||||||
points: T[];
|
points: T[];
|
||||||
series: Array<{ key: keyof T; color: string }>;
|
series: Array<{ key: keyof T; color: string; label?: string }>;
|
||||||
|
legendItems?: Array<{ label: string; color: string }>;
|
||||||
tickFormatter: (point: T) => string;
|
tickFormatter: (point: T) => string;
|
||||||
valueFormatter: (value: number) => string;
|
valueFormatter: (value: number) => string;
|
||||||
}) {
|
}) {
|
||||||
const width = 320;
|
const data = points.map((point, i) => {
|
||||||
const height = 132;
|
const entry: Record<string, string | number> = { idx: i, tick: tickFormatter(point) };
|
||||||
const padding = { top: 8, right: 8, bottom: 24, left: 32 };
|
for (const s of series) {
|
||||||
const plotWidth = width - padding.left - padding.right;
|
const raw = point[s.key];
|
||||||
const plotHeight = height - padding.top - padding.bottom;
|
entry[String(s.key)] = typeof raw === 'number' ? raw : 0;
|
||||||
const allValues = points.flatMap((point) =>
|
}
|
||||||
series.map((entry) => {
|
return entry;
|
||||||
const value = point[entry.key];
|
});
|
||||||
return typeof value === 'number' ? value : 0;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const maxValue = Math.max(1, ...allValues);
|
|
||||||
const tickIndices = Array.from(
|
|
||||||
new Set([
|
|
||||||
0,
|
|
||||||
Math.floor((points.length - 1) / 3),
|
|
||||||
Math.floor(((points.length - 1) * 2) / 3),
|
|
||||||
points.length - 1,
|
|
||||||
])
|
|
||||||
);
|
|
||||||
|
|
||||||
const buildPolyline = (key: keyof T) =>
|
const tickCount = Math.min(5, points.length);
|
||||||
points
|
const tickIndices: number[] = [];
|
||||||
.map((point, index) => {
|
if (points.length > 1) {
|
||||||
const rawValue = point[key];
|
for (let i = 0; i < tickCount; i++) {
|
||||||
const value = typeof rawValue === 'number' ? rawValue : 0;
|
tickIndices.push(Math.round((i / (tickCount - 1)) * (points.length - 1)));
|
||||||
const x =
|
}
|
||||||
padding.left + (points.length === 1 ? 0 : (index / (points.length - 1)) * plotWidth);
|
}
|
||||||
const y = padding.top + plotHeight - (value / maxValue) * plotHeight;
|
|
||||||
return `${x},${y}`;
|
|
||||||
})
|
|
||||||
.join(' ');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div role="img" aria-label={ariaLabel}>
|
||||||
<svg
|
<ResponsiveContainer width="100%" height={140}>
|
||||||
viewBox={`0 0 ${width} ${height}`}
|
<LineChart data={data} margin={{ top: 4, right: 4, bottom: 0, left: -16 }}>
|
||||||
className="w-full h-auto"
|
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
||||||
role="img"
|
<XAxis
|
||||||
aria-label={ariaLabel}
|
dataKey="idx"
|
||||||
>
|
type="number"
|
||||||
{[0, 0.5, 1].map((ratio) => {
|
domain={[0, Math.max(1, points.length - 1)]}
|
||||||
const y = padding.top + plotHeight - ratio * plotHeight;
|
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
const value = maxValue * ratio;
|
tickLine={false}
|
||||||
return (
|
axisLine={false}
|
||||||
<g key={ratio}>
|
ticks={tickIndices}
|
||||||
<line
|
tickFormatter={(idx) => String(data[idx]?.tick ?? '')}
|
||||||
x1={padding.left}
|
|
||||||
x2={width - padding.right}
|
|
||||||
y1={y}
|
|
||||||
y2={y}
|
|
||||||
stroke="hsl(var(--border))"
|
|
||||||
strokeWidth="1"
|
|
||||||
/>
|
|
||||||
<text
|
|
||||||
x={padding.left - 6}
|
|
||||||
y={y + 4}
|
|
||||||
fontSize="10"
|
|
||||||
textAnchor="end"
|
|
||||||
fill="hsl(var(--muted-foreground))"
|
|
||||||
>
|
|
||||||
{valueFormatter(value)}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{series.map((entry) => (
|
|
||||||
<polyline
|
|
||||||
key={String(entry.key)}
|
|
||||||
fill="none"
|
|
||||||
stroke={entry.color}
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeLinecap="round"
|
|
||||||
points={buildPolyline(entry.key)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
<YAxis
|
||||||
|
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
{tickIndices.map((index) => {
|
tickLine={false}
|
||||||
const point = points[index];
|
axisLine={false}
|
||||||
const x =
|
tickFormatter={(v) => valueFormatter(v)}
|
||||||
padding.left + (points.length === 1 ? 0 : (index / (points.length - 1)) * plotWidth);
|
width={40}
|
||||||
return (
|
/>
|
||||||
<text
|
<RechartsTooltip
|
||||||
key={`${ariaLabel}-${point.bucket_start}`}
|
{...TOOLTIP_STYLE}
|
||||||
x={x}
|
cursor={{
|
||||||
y={height - 6}
|
stroke: 'hsl(var(--muted-foreground))',
|
||||||
fontSize="10"
|
strokeWidth: 1,
|
||||||
textAnchor={index === 0 ? 'start' : index === points.length - 1 ? 'end' : 'middle'}
|
strokeDasharray: '3 3',
|
||||||
fill="hsl(var(--muted-foreground))"
|
}}
|
||||||
>
|
labelFormatter={(idx) => String(data[Number(idx)]?.tick ?? '')}
|
||||||
{tickFormatter(point)}
|
formatter={(value, name) => {
|
||||||
</text>
|
const match = series.find((s) => String(s.key) === name);
|
||||||
);
|
return [valueFormatter(Number(value)), match?.label ?? String(name)];
|
||||||
})}
|
}}
|
||||||
</svg>
|
/>
|
||||||
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { MessageInput, type MessageInputHandle } from './MessageInput';
|
|||||||
import { MessageList } from './MessageList';
|
import { MessageList } from './MessageList';
|
||||||
import { RawPacketFeedView } from './RawPacketFeedView';
|
import { RawPacketFeedView } from './RawPacketFeedView';
|
||||||
import { RoomServerPanel } from './RoomServerPanel';
|
import { RoomServerPanel } from './RoomServerPanel';
|
||||||
|
import { TracePane } from './TracePane';
|
||||||
import type {
|
import type {
|
||||||
Channel,
|
Channel,
|
||||||
Contact,
|
Contact,
|
||||||
@@ -15,6 +16,8 @@ import type {
|
|||||||
PathDiscoveryResponse,
|
PathDiscoveryResponse,
|
||||||
RawPacket,
|
RawPacket,
|
||||||
RadioConfig,
|
RadioConfig,
|
||||||
|
RadioTraceHopRequest,
|
||||||
|
RadioTraceResponse,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
|
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
|
||||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
|
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
|
||||||
@@ -50,6 +53,10 @@ interface ConversationPaneProps {
|
|||||||
loadingNewer: boolean;
|
loadingNewer: boolean;
|
||||||
messageInputRef: Ref<MessageInputHandle>;
|
messageInputRef: Ref<MessageInputHandle>;
|
||||||
onTrace: () => Promise<void>;
|
onTrace: () => Promise<void>;
|
||||||
|
onRunTracePath: (
|
||||||
|
hopHashBytes: 1 | 2 | 4,
|
||||||
|
hops: RadioTraceHopRequest[]
|
||||||
|
) => Promise<RadioTraceResponse>;
|
||||||
onPathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
|
onPathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
|
||||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => Promise<void>;
|
onToggleFavorite: (type: 'channel' | 'contact', id: string) => Promise<void>;
|
||||||
onDeleteContact: (publicKey: string) => Promise<void>;
|
onDeleteContact: (publicKey: string) => Promise<void>;
|
||||||
@@ -58,6 +65,7 @@ interface ConversationPaneProps {
|
|||||||
onOpenContactInfo: (publicKey: string, fromChannel?: boolean) => void;
|
onOpenContactInfo: (publicKey: string, fromChannel?: boolean) => void;
|
||||||
onOpenChannelInfo: (channelKey: string) => void;
|
onOpenChannelInfo: (channelKey: string) => void;
|
||||||
onSenderClick: (sender: string) => void;
|
onSenderClick: (sender: string) => void;
|
||||||
|
onChannelReferenceClick?: (channelName: string) => void;
|
||||||
onLoadOlder: () => Promise<void>;
|
onLoadOlder: () => Promise<void>;
|
||||||
onResendChannelMessage: (messageId: number, newTimestamp?: boolean) => Promise<void>;
|
onResendChannelMessage: (messageId: number, newTimestamp?: boolean) => Promise<void>;
|
||||||
onTargetReached: () => void;
|
onTargetReached: () => void;
|
||||||
@@ -115,6 +123,7 @@ export function ConversationPane({
|
|||||||
loadingNewer,
|
loadingNewer,
|
||||||
messageInputRef,
|
messageInputRef,
|
||||||
onTrace,
|
onTrace,
|
||||||
|
onRunTracePath,
|
||||||
onPathDiscovery,
|
onPathDiscovery,
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
onDeleteContact,
|
onDeleteContact,
|
||||||
@@ -123,6 +132,7 @@ export function ConversationPane({
|
|||||||
onOpenContactInfo,
|
onOpenContactInfo,
|
||||||
onOpenChannelInfo,
|
onOpenChannelInfo,
|
||||||
onSenderClick,
|
onSenderClick,
|
||||||
|
onChannelReferenceClick,
|
||||||
onLoadOlder,
|
onLoadOlder,
|
||||||
onResendChannelMessage,
|
onResendChannelMessage,
|
||||||
onTargetReached,
|
onTargetReached,
|
||||||
@@ -200,6 +210,10 @@ export function ConversationPane({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (activeConversation.type === 'trace') {
|
||||||
|
return <TracePane contacts={contacts} config={config} onRunTracePath={onRunTracePath} />;
|
||||||
|
}
|
||||||
|
|
||||||
if (activeContactIsRepeater) {
|
if (activeContactIsRepeater) {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<LoadingPane label="Loading dashboard..." />}>
|
<Suspense fallback={<LoadingPane label="Loading dashboard..." />}>
|
||||||
@@ -272,6 +286,7 @@ export function ConversationPane({
|
|||||||
activeConversation.type === 'channel' ? onDismissUnreadMarker : undefined
|
activeConversation.type === 'channel' ? onDismissUnreadMarker : undefined
|
||||||
}
|
}
|
||||||
onSenderClick={activeConversation.type === 'channel' ? onSenderClick : undefined}
|
onSenderClick={activeConversation.type === 'channel' ? onSenderClick : undefined}
|
||||||
|
onChannelReferenceClick={onChannelReferenceClick}
|
||||||
onLoadOlder={onLoadOlder}
|
onLoadOlder={onLoadOlder}
|
||||||
onResendChannelMessage={
|
onResendChannelMessage={
|
||||||
activeConversation.type === 'channel' ? onResendChannelMessage : undefined
|
activeConversation.type === 'channel' ? onResendChannelMessage : undefined
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export function CrackerPanel({
|
|||||||
}: CrackerPanelProps) {
|
}: CrackerPanelProps) {
|
||||||
const [isRunning, setIsRunning] = useState(false);
|
const [isRunning, setIsRunning] = useState(false);
|
||||||
const [maxLength, setMaxLength] = useState(6);
|
const [maxLength, setMaxLength] = useState(6);
|
||||||
|
const [maxLengthInput, setMaxLengthInput] = useState('6');
|
||||||
const [retryFailedAtNextLength, setRetryFailedAtNextLength] = useState(false);
|
const [retryFailedAtNextLength, setRetryFailedAtNextLength] = useState(false);
|
||||||
const [decryptHistorical, setDecryptHistorical] = useState(true);
|
const [decryptHistorical, setDecryptHistorical] = useState(true);
|
||||||
const [turboMode, setTurboMode] = useState(false);
|
const [turboMode, setTurboMode] = useState(false);
|
||||||
@@ -127,8 +128,9 @@ export function CrackerPanel({
|
|||||||
}, [existingChannelKeys]);
|
}, [existingChannelKeys]);
|
||||||
|
|
||||||
// Filter packets to only undecrypted GROUP_TEXT
|
// Filter packets to only undecrypted GROUP_TEXT
|
||||||
const undecryptedGroupText = packets.filter(
|
const undecryptedGroupText = useMemo(
|
||||||
(p) => p.payload_type === 'GROUP_TEXT' && !p.decrypted
|
() => packets.filter((p) => p.payload_type === 'GROUP_TEXT' && !p.decrypted),
|
||||||
|
[packets]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update queue when packets change (deduplicated by payload)
|
// Update queue when packets change (deduplicated by payload)
|
||||||
@@ -191,6 +193,10 @@ export function CrackerPanel({
|
|||||||
maxLengthRef.current = maxLength;
|
maxLengthRef.current = maxLength;
|
||||||
}, [maxLength]);
|
}, [maxLength]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMaxLengthInput(String(maxLength));
|
||||||
|
}, [maxLength]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
decryptHistoricalRef.current = decryptHistorical;
|
decryptHistoricalRef.current = decryptHistorical;
|
||||||
}, [decryptHistorical]);
|
}, [decryptHistorical]);
|
||||||
@@ -434,8 +440,25 @@ export function CrackerPanel({
|
|||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
max={10}
|
max={10}
|
||||||
value={maxLength}
|
value={maxLengthInput}
|
||||||
onChange={(e) => setMaxLength(Math.min(10, Math.max(1, parseInt(e.target.value) || 6)))}
|
onChange={(e) => {
|
||||||
|
const nextValue = e.target.value;
|
||||||
|
setMaxLengthInput(nextValue);
|
||||||
|
if (nextValue === '') return;
|
||||||
|
const parsed = Number.parseInt(nextValue, 10);
|
||||||
|
if (Number.isNaN(parsed)) return;
|
||||||
|
setMaxLength(Math.min(10, Math.max(1, parsed)));
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
const parsed = Number.parseInt(maxLengthInput, 10);
|
||||||
|
const nextValue = Number.isNaN(parsed)
|
||||||
|
? maxLength
|
||||||
|
: Math.min(10, Math.max(1, parsed));
|
||||||
|
setMaxLengthInput(String(nextValue));
|
||||||
|
if (nextValue !== maxLength) {
|
||||||
|
setMaxLength(nextValue);
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="w-14 px-2 py-1 text-sm bg-muted border border-border rounded"
|
className="w-14 px-2 py-1 text-sm bg-muted border border-border rounded"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -131,9 +131,23 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
|||||||
|
|
||||||
// Store ref for a marker
|
// Store ref for a marker
|
||||||
const setMarkerRef = useCallback((key: string, ref: LeafletCircleMarker | null) => {
|
const setMarkerRef = useCallback((key: string, ref: LeafletCircleMarker | null) => {
|
||||||
|
if (ref === null) {
|
||||||
|
delete markerRefs.current[key];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
markerRefs.current[key] = ref;
|
markerRefs.current[key] = ref;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentKeys = new Set(mappableContacts.map((contact) => contact.public_key));
|
||||||
|
for (const key of Object.keys(markerRefs.current)) {
|
||||||
|
if (!currentKeys.has(key)) {
|
||||||
|
delete markerRefs.current[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [mappableContacts]);
|
||||||
|
|
||||||
// Open popup for focused contact after map is ready
|
// Open popup for focused contact after map is ready
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (focusedContact && markerRefs.current[focusedContact.public_key]) {
|
if (focusedContact && markerRefs.current[focusedContact.public_key]) {
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ import {
|
|||||||
import type { Channel, Contact, Message, MessagePath, RadioConfig, RawPacket } from '../types';
|
import type { Channel, Contact, Message, MessagePath, RadioConfig, RawPacket } from '../types';
|
||||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
|
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
|
||||||
import { api } from '../api';
|
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 { formatHopCounts, type SenderInfo } from '../utils/pathUtils';
|
||||||
import { getDirectContactRoute } from '../utils/pathUtils';
|
import { getDirectContactRoute } from '../utils/pathUtils';
|
||||||
import { ContactAvatar } from './ContactAvatar';
|
import { ContactAvatar } from './ContactAvatar';
|
||||||
@@ -33,6 +37,7 @@ interface MessageListProps {
|
|||||||
onSenderClick?: (sender: string) => void;
|
onSenderClick?: (sender: string) => void;
|
||||||
onLoadOlder?: () => void;
|
onLoadOlder?: () => void;
|
||||||
onResendChannelMessage?: (messageId: number, newTimestamp?: boolean) => void;
|
onResendChannelMessage?: (messageId: number, newTimestamp?: boolean) => void;
|
||||||
|
onChannelReferenceClick?: (channelName: string) => void;
|
||||||
radioName?: string;
|
radioName?: string;
|
||||||
config?: RadioConfig | null;
|
config?: RadioConfig | null;
|
||||||
onOpenContactInfo?: (publicKey: string, fromChannel?: boolean) => void;
|
onOpenContactInfo?: (publicKey: string, fromChannel?: boolean) => void;
|
||||||
@@ -48,8 +53,64 @@ interface MessageListProps {
|
|||||||
const URL_PATTERN =
|
const URL_PATTERN =
|
||||||
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/g;
|
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/g;
|
||||||
|
|
||||||
// Helper to convert URLs in a plain text string into clickable links
|
function renderChannelReferences(
|
||||||
function linkifyText(text: string, keyPrefix: string): ReactNode[] {
|
text: string,
|
||||||
|
keyPrefix: string,
|
||||||
|
onChannelReferenceClick?: (channelName: string) => void
|
||||||
|
): ReactNode[] {
|
||||||
|
const references = findLinkedChannelReferences(text);
|
||||||
|
if (references.length === 0) {
|
||||||
|
return [text];
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts: ReactNode[] = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
|
||||||
|
references.forEach((reference, index) => {
|
||||||
|
if (reference.start > lastIndex) {
|
||||||
|
parts.push(text.slice(lastIndex, reference.start));
|
||||||
|
}
|
||||||
|
|
||||||
|
const className =
|
||||||
|
'rounded px-0.5 font-medium text-primary underline underline-offset-2 transition-colors';
|
||||||
|
if (onChannelReferenceClick) {
|
||||||
|
parts.push(
|
||||||
|
<button
|
||||||
|
key={`${keyPrefix}-channel-${index}`}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
className,
|
||||||
|
'inline border-0 bg-transparent p-0 align-baseline hover:text-primary/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
||||||
|
)}
|
||||||
|
onClick={() => onChannelReferenceClick(reference.label)}
|
||||||
|
>
|
||||||
|
{reference.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
parts.push(
|
||||||
|
<span key={`${keyPrefix}-channel-${index}`} className={className}>
|
||||||
|
{reference.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastIndex = reference.end;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
parts.push(text.slice(lastIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to convert URLs and channel references in a plain text string into rich content
|
||||||
|
function linkifyText(
|
||||||
|
text: string,
|
||||||
|
keyPrefix: string,
|
||||||
|
onChannelReferenceClick?: (channelName: string) => void
|
||||||
|
): ReactNode[] {
|
||||||
const parts: ReactNode[] = [];
|
const parts: ReactNode[] = [];
|
||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
let match: RegExpExecArray | null;
|
let match: RegExpExecArray | null;
|
||||||
@@ -58,7 +119,13 @@ function linkifyText(text: string, keyPrefix: string): ReactNode[] {
|
|||||||
URL_PATTERN.lastIndex = 0;
|
URL_PATTERN.lastIndex = 0;
|
||||||
while ((match = URL_PATTERN.exec(text)) !== null) {
|
while ((match = URL_PATTERN.exec(text)) !== null) {
|
||||||
if (match.index > lastIndex) {
|
if (match.index > lastIndex) {
|
||||||
parts.push(text.slice(lastIndex, match.index));
|
parts.push(
|
||||||
|
...renderChannelReferences(
|
||||||
|
text.slice(lastIndex, match.index),
|
||||||
|
`${keyPrefix}-text-${keyIndex}`,
|
||||||
|
onChannelReferenceClick
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
parts.push(
|
parts.push(
|
||||||
<a
|
<a
|
||||||
@@ -74,15 +141,27 @@ function linkifyText(text: string, keyPrefix: string): ReactNode[] {
|
|||||||
lastIndex = match.index + match[0].length;
|
lastIndex = match.index + match[0].length;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastIndex === 0) return [text];
|
if (lastIndex === 0) {
|
||||||
|
return renderChannelReferences(text, keyPrefix, onChannelReferenceClick);
|
||||||
|
}
|
||||||
if (lastIndex < text.length) {
|
if (lastIndex < text.length) {
|
||||||
parts.push(text.slice(lastIndex));
|
parts.push(
|
||||||
|
...renderChannelReferences(
|
||||||
|
text.slice(lastIndex),
|
||||||
|
`${keyPrefix}-tail`,
|
||||||
|
onChannelReferenceClick
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return parts;
|
return parts;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to render text with highlighted @[Name] mentions and clickable URLs
|
// Helper to render text with highlighted @[Name] mentions and clickable URLs
|
||||||
function renderTextWithMentions(text: string, radioName?: string): ReactNode {
|
function renderTextWithMentions(
|
||||||
|
text: string,
|
||||||
|
radioName?: string,
|
||||||
|
onChannelReferenceClick?: (channelName: string) => void
|
||||||
|
): ReactNode {
|
||||||
const mentionPattern = /@\[([^\]]+)\]/g;
|
const mentionPattern = /@\[([^\]]+)\]/g;
|
||||||
const parts: ReactNode[] = [];
|
const parts: ReactNode[] = [];
|
||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
@@ -92,7 +171,13 @@ function renderTextWithMentions(text: string, radioName?: string): ReactNode {
|
|||||||
while ((match = mentionPattern.exec(text)) !== null) {
|
while ((match = mentionPattern.exec(text)) !== null) {
|
||||||
// Add text before the match (with linkification)
|
// Add text before the match (with linkification)
|
||||||
if (match.index > lastIndex) {
|
if (match.index > lastIndex) {
|
||||||
parts.push(...linkifyText(text.slice(lastIndex, match.index), `pre-${keyIndex}`));
|
parts.push(
|
||||||
|
...linkifyText(
|
||||||
|
text.slice(lastIndex, match.index),
|
||||||
|
`pre-${keyIndex}`,
|
||||||
|
onChannelReferenceClick
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mentionedName = match[1];
|
const mentionedName = match[1];
|
||||||
@@ -115,7 +200,7 @@ function renderTextWithMentions(text: string, radioName?: string): ReactNode {
|
|||||||
|
|
||||||
// Add remaining text after last match (with linkification)
|
// Add remaining text after last match (with linkification)
|
||||||
if (lastIndex < text.length) {
|
if (lastIndex < text.length) {
|
||||||
parts.push(...linkifyText(text.slice(lastIndex), `post-${keyIndex}`));
|
parts.push(...linkifyText(text.slice(lastIndex), `post-${keyIndex}`, onChannelReferenceClick));
|
||||||
}
|
}
|
||||||
|
|
||||||
return parts.length > 0 ? parts : text;
|
return parts.length > 0 ? parts : text;
|
||||||
@@ -188,6 +273,7 @@ export function MessageList({
|
|||||||
onSenderClick,
|
onSenderClick,
|
||||||
onLoadOlder,
|
onLoadOlder,
|
||||||
onResendChannelMessage,
|
onResendChannelMessage,
|
||||||
|
onChannelReferenceClick,
|
||||||
radioName,
|
radioName,
|
||||||
config,
|
config,
|
||||||
onOpenContactInfo,
|
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 () => {
|
return () => {
|
||||||
for (const timer of timers.values()) clearTimeout(timer);
|
for (const timer of timers.values()) clearTimeout(timer);
|
||||||
@@ -896,7 +997,7 @@ export function MessageList({
|
|||||||
<div className="break-words whitespace-pre-wrap">
|
<div className="break-words whitespace-pre-wrap">
|
||||||
{content.split('\n').map((line, i, arr) => (
|
{content.split('\n').map((line, i, arr) => (
|
||||||
<span key={i}>
|
<span key={i}>
|
||||||
{renderTextWithMentions(line, radioName)}
|
{renderTextWithMentions(line, radioName, onChannelReferenceClick)}
|
||||||
{i < arr.length - 1 && <br />}
|
{i < arr.length - 1 && <br />}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Dice5 } from 'lucide-react';
|
import { Dice5 } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -20,6 +20,11 @@ type Tab = 'new-contact' | 'new-channel' | 'hashtag';
|
|||||||
interface NewMessageModalProps {
|
interface NewMessageModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
undecryptedCount: number;
|
undecryptedCount: number;
|
||||||
|
prefillRequest?: {
|
||||||
|
tab: 'hashtag';
|
||||||
|
hashtagName: string;
|
||||||
|
nonce: number;
|
||||||
|
} | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>;
|
onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>;
|
||||||
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
|
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
|
||||||
@@ -29,6 +34,7 @@ interface NewMessageModalProps {
|
|||||||
export function NewMessageModal({
|
export function NewMessageModal({
|
||||||
open,
|
open,
|
||||||
undecryptedCount,
|
undecryptedCount,
|
||||||
|
prefillRequest = null,
|
||||||
onClose,
|
onClose,
|
||||||
onCreateContact,
|
onCreateContact,
|
||||||
onCreateChannel,
|
onCreateChannel,
|
||||||
@@ -53,6 +59,24 @@ export function NewMessageModal({
|
|||||||
setError('');
|
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 () => {
|
const handleCreate = async () => {
|
||||||
setError('');
|
setError('');
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|||||||
@@ -81,13 +81,14 @@ export function PathModal({
|
|||||||
) : hasSinglePath ? (
|
) : hasSinglePath ? (
|
||||||
<>
|
<>
|
||||||
This shows <em>one route</em> that this message traveled through the mesh network.
|
This shows <em>one route</em> that this message traveled through the mesh network.
|
||||||
Repeaters may be incorrectly identified due to prefix collisions between heard and
|
Repeater identities are inferred from locally known advert and path data, so some
|
||||||
non-heard repeater advertisements.
|
hops may be missing or misidentified when that data is incomplete.
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
This message was received via <strong>{paths.length} different routes</strong>.
|
This message was received via <strong>{paths.length} different routes</strong>.
|
||||||
Repeaters may be incorrectly identified due to prefix collisions.
|
Repeater identities are inferred from locally known advert and path data, so some
|
||||||
|
hops may be missing or misidentified when that data is incomplete.
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { ChevronLeft, ChevronRight } from 'lucide-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 { RawPacketList } from './RawPacketList';
|
||||||
import { RawPacketInspectorDialog } from './RawPacketDetailModal';
|
import { RawPacketInspectorDialog } from './RawPacketDetailModal';
|
||||||
@@ -24,6 +34,18 @@ interface RawPacketFeedViewProps {
|
|||||||
channels: Channel[];
|
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> = {
|
const WINDOW_LABELS: Record<RawPacketStatsWindow, string> = {
|
||||||
'1m': '1 min',
|
'1m': '1 min',
|
||||||
'5m': '5 min',
|
'5m': '5 min',
|
||||||
@@ -32,13 +54,7 @@ const WINDOW_LABELS: Record<RawPacketStatsWindow, string> = {
|
|||||||
session: 'Session',
|
session: 'Session',
|
||||||
};
|
};
|
||||||
|
|
||||||
const TIMELINE_COLORS = [
|
const TIMELINE_FILL_COLORS = ['#0ea5e9', '#10b981', '#f59e0b', '#f43f5e', '#8b5cf6'];
|
||||||
'bg-sky-500/80',
|
|
||||||
'bg-emerald-500/80',
|
|
||||||
'bg-amber-500/80',
|
|
||||||
'bg-rose-500/80',
|
|
||||||
'bg-violet-500/80',
|
|
||||||
];
|
|
||||||
|
|
||||||
function formatTimestamp(timestampMs: number): string {
|
function formatTimestamp(timestampMs: number): string {
|
||||||
return new Date(timestampMs).toLocaleString([], {
|
return new Date(timestampMs).toLocaleString([], {
|
||||||
@@ -155,24 +171,17 @@ function isNeighborIdentityResolvable(item: NeighborStat, contacts: Contact[]):
|
|||||||
return resolveContact(item.key, contacts) !== null;
|
return resolveContact(item.key, contacts) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatStrongestPacketDetail(
|
function formatStrongestNeighborDetail(
|
||||||
stats: ReturnType<typeof buildRawPacketStatsSnapshot>,
|
stats: ReturnType<typeof buildRawPacketStatsSnapshot>,
|
||||||
contacts: Contact[]
|
contacts: Contact[]
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
if (!stats.strongestPacketPayloadType) {
|
const strongestNeighbor = stats.strongestNeighbors[0];
|
||||||
|
if (!strongestNeighbor || strongestNeighbor.bestRssi === null) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedLabel =
|
const resolvedNeighbor = resolveNeighbor(strongestNeighbor, contacts);
|
||||||
resolveContactLabel(stats.strongestPacketSourceKey, contacts) ??
|
return `${formatRssi(resolvedNeighbor.bestRssi)} best heard`;
|
||||||
stats.strongestPacketSourceLabel;
|
|
||||||
if (resolvedLabel) {
|
|
||||||
return `${resolvedLabel} · ${stats.strongestPacketPayloadType}`;
|
|
||||||
}
|
|
||||||
if (stats.strongestPacketPayloadType === 'GroupText') {
|
|
||||||
return '<unknown sender> · GroupText';
|
|
||||||
}
|
|
||||||
return stats.strongestPacketPayloadType;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCoverageMessage(
|
function getCoverageMessage(
|
||||||
@@ -220,7 +229,13 @@ function RankedBars({
|
|||||||
emptyLabel: string;
|
emptyLabel: string;
|
||||||
formatter?: (item: RankedPacketStat) => 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 (
|
return (
|
||||||
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
|
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
|
||||||
@@ -228,25 +243,36 @@ function RankedBars({
|
|||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<p className="mt-3 text-sm text-muted-foreground">{emptyLabel}</p>
|
<p className="mt-3 text-sm text-muted-foreground">{emptyLabel}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-3 space-y-2">
|
<div className="mt-2">
|
||||||
{items.map((item) => (
|
<ResponsiveContainer width="100%" height={items.length * 28 + 8}>
|
||||||
<div key={item.label}>
|
<BarChart
|
||||||
<div className="mb-1 flex items-center justify-between gap-3 text-xs">
|
data={data}
|
||||||
<span className="truncate text-foreground">{item.label}</span>
|
layout="vertical"
|
||||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
margin={{ top: 0, right: 4, bottom: 0, left: 0 }}
|
||||||
{formatter
|
barCategoryGap="20%"
|
||||||
? formatter(item)
|
>
|
||||||
: `${item.count.toLocaleString()} · ${formatPercent(item.share)}`}
|
<XAxis type="number" hide />
|
||||||
</span>
|
<YAxis
|
||||||
</div>
|
type="category"
|
||||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
dataKey="name"
|
||||||
<div
|
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
className="h-full rounded-full bg-primary/80"
|
tickLine={false}
|
||||||
style={{ width: `${(item.count / maxCount) * 100}%` }}
|
axisLine={false}
|
||||||
/>
|
width={80}
|
||||||
</div>
|
/>
|
||||||
</div>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -320,53 +346,66 @@ function NeighborList({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TimelineChart({ bins }: { bins: PacketTimelineBin[] }) {
|
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(
|
const typeOrder = Array.from(new Set(bins.flatMap((bin) => Object.keys(bin.countsByType)))).slice(
|
||||||
0,
|
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 (
|
return (
|
||||||
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
|
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<h3 className="text-sm font-semibold text-foreground">Traffic Timeline</h3>
|
<h3 className="text-sm font-semibold text-foreground">Traffic Timeline</h3>
|
||||||
<div className="flex flex-wrap justify-end gap-2 text-[11px] text-muted-foreground">
|
<div className="flex flex-wrap justify-end gap-2 text-[11px] text-muted-foreground">
|
||||||
{typeOrder.map((type, index) => (
|
{typeOrder.map((type, i) => (
|
||||||
<span key={type} className="inline-flex items-center gap-1">
|
<span key={type} className="inline-flex items-center gap-1">
|
||||||
<span 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>{type}</span>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
<div className="mt-3 flex items-start gap-1">
|
<ResponsiveContainer width="100%" height={110}>
|
||||||
{bins.map((bin, index) => (
|
<BarChart data={data} margin={{ top: 4, right: 0, bottom: 0, left: -24 }}>
|
||||||
<div
|
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
||||||
key={`${bin.label}-${index}`}
|
<XAxis
|
||||||
className="flex min-w-0 flex-1 flex-col items-center gap-1"
|
dataKey="label"
|
||||||
>
|
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
<div className="flex h-24 w-full items-end overflow-hidden rounded-sm bg-muted/60">
|
tickLine={false}
|
||||||
<div className="flex h-full w-full flex-col justify-end">
|
axisLine={false}
|
||||||
{typeOrder.map((type, index) => {
|
/>
|
||||||
const count = bin.countsByType[type] ?? 0;
|
<YAxis
|
||||||
if (count === 0) return null;
|
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
return (
|
tickLine={false}
|
||||||
<div
|
axisLine={false}
|
||||||
key={type}
|
allowDecimals={false}
|
||||||
className={cn('w-full', TIMELINE_COLORS[index])}
|
/>
|
||||||
style={{
|
<RechartsTooltip
|
||||||
height: `${(count / maxTotal) * 100}%`,
|
{...TOOLTIP_STYLE}
|
||||||
}}
|
cursor={{ fill: 'hsl(var(--muted))', opacity: 0.5 }}
|
||||||
title={`${bin.label}: ${type} ${count.toLocaleString()}`}
|
/>
|
||||||
/>
|
{typeOrder.map((type, i) => (
|
||||||
);
|
<Bar
|
||||||
})}
|
key={type}
|
||||||
</div>
|
dataKey={type}
|
||||||
</div>
|
stackId="packets"
|
||||||
<div className="text-[10px] text-muted-foreground">{bin.label}</div>
|
fill={TIMELINE_FILL_COLORS[i]}
|
||||||
</div>
|
radius={i === typeOrder.length - 1 ? [2, 2, 0, 0] : undefined}
|
||||||
))}
|
/>
|
||||||
|
))}
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -404,8 +443,13 @@ export function RawPacketFeedView({
|
|||||||
[nowSec, rawPacketStatsSession, selectedWindow]
|
[nowSec, rawPacketStatsSession, selectedWindow]
|
||||||
);
|
);
|
||||||
const coverageMessage = getCoverageMessage(stats, rawPacketStatsSession);
|
const coverageMessage = getCoverageMessage(stats, rawPacketStatsSession);
|
||||||
const strongestPacketDetail = useMemo(
|
const strongestNeighbor = useMemo(() => {
|
||||||
() => formatStrongestPacketDetail(stats, contacts),
|
const topNeighbor = stats.strongestNeighbors[0];
|
||||||
|
return topNeighbor ? resolveNeighbor(topNeighbor, contacts) : null;
|
||||||
|
}, [contacts, stats]);
|
||||||
|
|
||||||
|
const strongestNeighborDetail = useMemo(
|
||||||
|
() => formatStrongestNeighborDetail(stats, contacts),
|
||||||
[contacts, stats]
|
[contacts, stats]
|
||||||
);
|
);
|
||||||
const strongestNeighbors = useMemo(
|
const strongestNeighbors = useMemo(
|
||||||
@@ -532,9 +576,9 @@ export function RawPacketFeedView({
|
|||||||
detail={`${formatPercent(stats.pathBearingRate)} path-bearing packets`}
|
detail={`${formatPercent(stats.pathBearingRate)} path-bearing packets`}
|
||||||
/>
|
/>
|
||||||
<StatTile
|
<StatTile
|
||||||
label="Best RSSI"
|
label="Strongest Neighbor"
|
||||||
value={formatRssi(stats.bestRssi)}
|
value={strongestNeighbor?.label ?? '-'}
|
||||||
detail={strongestPacketDetail ?? 'No signal sample in window'}
|
detail={strongestNeighborDetail ?? 'No neighbor RSSI sample in window'}
|
||||||
/>
|
/>
|
||||||
<StatTile
|
<StatTile
|
||||||
label="Median RSSI"
|
label="Median RSSI"
|
||||||
|
|||||||
@@ -174,7 +174,11 @@ export function SearchView({
|
|||||||
api
|
api
|
||||||
.getMessages({ q: debouncedQuery, limit: SEARCH_PAGE_SIZE, offset }, controller.signal)
|
.getMessages({ q: debouncedQuery, limit: SEARCH_PAGE_SIZE, offset }, controller.signal)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
setResults((prev) => [...prev, ...(data as SearchResult[])]);
|
setResults((prev) => {
|
||||||
|
const existingIds = new Set(prev.map((r) => r.id));
|
||||||
|
const unique = (data as SearchResult[]).filter((r) => !existingIds.has(r.id));
|
||||||
|
return [...prev, ...unique];
|
||||||
|
});
|
||||||
setHasMore(data.length >= SEARCH_PAGE_SIZE);
|
setHasMore(data.length >= SEARCH_PAGE_SIZE);
|
||||||
setOffset((prev) => prev + data.length);
|
setOffset((prev) => prev + data.length);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Bell,
|
Bell,
|
||||||
|
Cable,
|
||||||
|
ChartNetwork,
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@@ -9,7 +11,6 @@ import {
|
|||||||
Map,
|
Map,
|
||||||
Search as SearchIcon,
|
Search as SearchIcon,
|
||||||
SquarePen,
|
SquarePen,
|
||||||
Waypoints,
|
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
@@ -197,7 +198,7 @@ export function Sidebar({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isActive = (
|
const isActive = (
|
||||||
type: 'contact' | 'channel' | 'raw' | 'map' | 'visualizer' | 'search',
|
type: 'contact' | 'channel' | 'raw' | 'map' | 'visualizer' | 'search' | 'trace',
|
||||||
id: string
|
id: string
|
||||||
) => activeConversation?.type === type && activeConversation?.id === id;
|
) => activeConversation?.type === type && activeConversation?.id === id;
|
||||||
|
|
||||||
@@ -721,7 +722,7 @@ export function Sidebar({
|
|||||||
renderSidebarActionRow({
|
renderSidebarActionRow({
|
||||||
key: 'tool-visualizer',
|
key: 'tool-visualizer',
|
||||||
active: isActive('visualizer', 'visualizer'),
|
active: isActive('visualizer', 'visualizer'),
|
||||||
icon: <Waypoints className="h-4 w-4" />,
|
icon: <ChartNetwork className="h-4 w-4" />,
|
||||||
label: 'Mesh Visualizer',
|
label: 'Mesh Visualizer',
|
||||||
onClick: () =>
|
onClick: () =>
|
||||||
handleSelectConversation({
|
handleSelectConversation({
|
||||||
@@ -730,6 +731,18 @@ export function Sidebar({
|
|||||||
name: 'Mesh Visualizer',
|
name: 'Mesh Visualizer',
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
renderSidebarActionRow({
|
||||||
|
key: 'tool-trace',
|
||||||
|
active: isActive('trace', 'trace'),
|
||||||
|
icon: <Cable className="h-4 w-4" />,
|
||||||
|
label: 'Trace',
|
||||||
|
onClick: () =>
|
||||||
|
handleSelectConversation({
|
||||||
|
type: 'trace',
|
||||||
|
id: 'trace',
|
||||||
|
name: 'Trace',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
renderSidebarActionRow({
|
renderSidebarActionRow({
|
||||||
key: 'tool-search',
|
key: 'tool-search',
|
||||||
active: isActive('search', 'search'),
|
active: isActive('search', 'search'),
|
||||||
@@ -840,41 +853,45 @@ export function Sidebar({
|
|||||||
aria-label="Conversations"
|
aria-label="Conversations"
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border">
|
<div className="px-3 py-2 border-b border-border">
|
||||||
<div className="relative min-w-0 flex-1">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search 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>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onNewMessage}
|
onClick={onNewMessage}
|
||||||
title="New Message"
|
title="Add channel or contact"
|
||||||
aria-label="New message"
|
aria-label="Add channel or contact"
|
||||||
className="h-7 w-7 shrink-0 p-0 text-muted-foreground hover:text-foreground transition-colors"
|
className="h-8 w-full justify-start gap-2 px-3 text-[13px]"
|
||||||
>
|
>
|
||||||
<SquarePen className="h-4 w-4" />
|
<SquarePen className="h-4 w-4" />
|
||||||
|
<span>Add Channel/Contact</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* List */}
|
{/* List */}
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto [contain:layout_paint]">
|
<div className="flex-1 min-h-0 overflow-y-auto [contain:layout_paint]">
|
||||||
|
<div className="px-3 py-2 border-b border-border/60">
|
||||||
|
<div className="relative min-w-0">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search channels/contacts..."
|
||||||
|
aria-label="Search conversations"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className={cn('h-7 text-[13px] bg-background/50', searchQuery ? 'pr-8' : 'pr-3')}
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded"
|
||||||
|
onClick={() => setSearchQuery('')}
|
||||||
|
title="Clear search"
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Tools */}
|
{/* Tools */}
|
||||||
{toolRows.length > 0 && (
|
{toolRows.length > 0 && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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])
|
CREATE_INTEGRATION_DEFINITIONS.map((definition) => [definition.value, definition])
|
||||||
) as Record<DraftType, CreateIntegrationDefinition>;
|
) 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 {
|
function isDraftType(value: string): value is DraftType {
|
||||||
return value in CREATE_INTEGRATION_DEFINITIONS_BY_VALUE;
|
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');
|
throw new Error('MeshRank packet topic is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return normalizeIntegrationConfigForSave('mqtt_community', {
|
||||||
...config,
|
...config,
|
||||||
broker_host: DEFAULT_MESHRANK_BROKER_HOST,
|
broker_host: DEFAULT_MESHRANK_BROKER_HOST,
|
||||||
broker_port: DEFAULT_MESHRANK_BROKER_PORT,
|
broker_port: DEFAULT_MESHRANK_BROKER_PORT,
|
||||||
@@ -352,7 +426,7 @@ function normalizeDraftConfig(draftType: DraftType, config: Record<string, unkno
|
|||||||
topic_template: topicTemplate,
|
topic_template: topicTemplate,
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (draftType === 'mqtt_community_letsmesh_us' || draftType === 'mqtt_community_letsmesh_eu') {
|
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'
|
draftType === 'mqtt_community_letsmesh_eu'
|
||||||
? DEFAULT_COMMUNITY_BROKER_HOST_EU
|
? DEFAULT_COMMUNITY_BROKER_HOST_EU
|
||||||
: DEFAULT_COMMUNITY_BROKER_HOST;
|
: DEFAULT_COMMUNITY_BROKER_HOST;
|
||||||
return {
|
return normalizeIntegrationConfigForSave('mqtt_community', {
|
||||||
...config,
|
...config,
|
||||||
broker_host: brokerHost,
|
broker_host: brokerHost,
|
||||||
broker_port: DEFAULT_COMMUNITY_BROKER_PORT,
|
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,
|
topic_template: (config.topic_template as string) || DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE,
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return config;
|
return normalizeIntegrationConfigForSave(
|
||||||
|
getCreateIntegrationDefinition(draftType).savedType,
|
||||||
|
config
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDraftScope(draftType: DraftType, scope: Record<string, unknown>) {
|
function normalizeDraftScope(draftType: DraftType, scope: Record<string, unknown>) {
|
||||||
@@ -649,9 +726,9 @@ function MqttPrivateConfigEditor({
|
|||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="65535"
|
max="65535"
|
||||||
value={(config.broker_port as number) || 1883}
|
value={getNumberInputValue(config.broker_port, 1883)}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onChange({ ...config, broker_port: parseInt(e.target.value, 10) || 1883 })
|
onChange({ ...config, broker_port: parseIntegerInputValue(e.target.value) })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -709,7 +786,8 @@ function MqttPrivateConfigEditor({
|
|||||||
<Input
|
<Input
|
||||||
id="fanout-mqtt-prefix"
|
id="fanout-mqtt-prefix"
|
||||||
type="text"
|
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 })}
|
onChange={(e) => onChange({ ...config, topic_prefix: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -745,7 +823,7 @@ function MqttCommunityConfigEditor({
|
|||||||
id="fanout-comm-host"
|
id="fanout-comm-host"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={DEFAULT_COMMUNITY_BROKER_HOST}
|
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 })}
|
onChange={(e) => onChange({ ...config, broker_host: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -756,11 +834,11 @@ function MqttCommunityConfigEditor({
|
|||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="65535"
|
max="65535"
|
||||||
value={(config.broker_port as number) || DEFAULT_COMMUNITY_BROKER_PORT}
|
value={getNumberInputValue(config.broker_port, DEFAULT_COMMUNITY_BROKER_PORT)}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onChange({
|
onChange({
|
||||||
...config,
|
...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
|
<Input
|
||||||
id="fanout-comm-topic-template"
|
id="fanout-comm-topic-template"
|
||||||
type="text"
|
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 })}
|
onChange={(e) => onChange({ ...config, topic_template: e.target.value })}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -1215,11 +1294,11 @@ function MapUploadConfigEditor({
|
|||||||
min="0"
|
min="0"
|
||||||
step="any"
|
step="any"
|
||||||
placeholder="e.g. 100"
|
placeholder="e.g. 100"
|
||||||
value={(config.geofence_radius_km as number | undefined) ?? ''}
|
value={getOptionalNumberInputValue(config.geofence_radius_km)}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onChange({
|
onChange({
|
||||||
...config,
|
...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) {
|
if (!currentEditingId) {
|
||||||
throw new Error('Missing fanout config id for update');
|
throw new Error('Missing fanout config id for update');
|
||||||
}
|
}
|
||||||
|
const editingType = configs.find((cfg) => cfg.id === currentEditingId)?.type ?? '';
|
||||||
const update: Record<string, unknown> = {
|
const update: Record<string, unknown> = {
|
||||||
name: editName,
|
name: editName,
|
||||||
config: editConfig,
|
config: normalizeIntegrationConfigForSave(editingType, editConfig),
|
||||||
scope: editScope,
|
scope: editScope,
|
||||||
};
|
};
|
||||||
if (enabled !== undefined) update.enabled = enabled;
|
if (enabled !== undefined) update.enabled = enabled;
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ import {
|
|||||||
setSavedDistanceUnit,
|
setSavedDistanceUnit,
|
||||||
} from '../../utils/distanceUnits';
|
} from '../../utils/distanceUnits';
|
||||||
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
|
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
|
||||||
|
import {
|
||||||
|
DEFAULT_FONT_SCALE,
|
||||||
|
FONT_SCALE_SLIDER_STEP,
|
||||||
|
MAX_FONT_SCALE,
|
||||||
|
MIN_FONT_SCALE,
|
||||||
|
getSavedFontScale,
|
||||||
|
setSavedFontScale,
|
||||||
|
} from '../../utils/fontScale';
|
||||||
|
|
||||||
export function SettingsLocalSection({
|
export function SettingsLocalSection({
|
||||||
onLocalLabelChange,
|
onLocalLabelChange,
|
||||||
@@ -31,6 +39,29 @@ export function SettingsLocalSection({
|
|||||||
);
|
);
|
||||||
const [localLabelText, setLocalLabelText] = useState(() => getLocalLabel().text);
|
const [localLabelText, setLocalLabelText] = useState(() => getLocalLabel().text);
|
||||||
const [localLabelColor, setLocalLabelColor] = useState(() => getLocalLabel().color);
|
const [localLabelColor, setLocalLabelColor] = useState(() => getLocalLabel().color);
|
||||||
|
const [fontScale, setFontScale] = useState(getSavedFontScale);
|
||||||
|
const [fontScaleSlider, setFontScaleSlider] = useState(getSavedFontScale);
|
||||||
|
const [fontScaleInput, setFontScaleInput] = useState(() => String(getSavedFontScale()));
|
||||||
|
|
||||||
|
const commitFontScale = (nextScale: number) => {
|
||||||
|
const normalized = setSavedFontScale(nextScale);
|
||||||
|
setFontScale(normalized);
|
||||||
|
setFontScaleSlider(normalized);
|
||||||
|
setFontScaleInput(String(normalized));
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoreFontScaleInput = () => {
|
||||||
|
setFontScaleInput(String(fontScale));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSliderChange = (nextScale: number) => {
|
||||||
|
setFontScaleSlider(nextScale);
|
||||||
|
setFontScaleInput(String(nextScale));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSliderCommit = (nextScale: number) => {
|
||||||
|
commitFontScale(nextScale);
|
||||||
|
};
|
||||||
|
|
||||||
const handleToggleReopenLastConversation = (enabled: boolean) => {
|
const handleToggleReopenLastConversation = (enabled: boolean) => {
|
||||||
setReopenLastConversation(enabled);
|
setReopenLastConversation(enabled);
|
||||||
@@ -89,6 +120,85 @@ export function SettingsLocalSection({
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label htmlFor="font-scale-input">Relative Font Size</Label>
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={MIN_FONT_SCALE}
|
||||||
|
max={MAX_FONT_SCALE}
|
||||||
|
step={FONT_SCALE_SLIDER_STEP}
|
||||||
|
value={fontScaleSlider}
|
||||||
|
onChange={(event) => handleSliderChange(Number(event.target.value))}
|
||||||
|
onMouseUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
|
||||||
|
onTouchEnd={(event) => handleSliderCommit(Number(event.currentTarget.value))}
|
||||||
|
onKeyUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
|
||||||
|
onBlur={(event) => handleSliderCommit(Number(event.currentTarget.value))}
|
||||||
|
aria-label="Relative font size slider"
|
||||||
|
className="w-full accent-primary sm:flex-1"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 sm:w-40">
|
||||||
|
<Input
|
||||||
|
id="font-scale-input"
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
min={MIN_FONT_SCALE}
|
||||||
|
max={MAX_FONT_SCALE}
|
||||||
|
step="any"
|
||||||
|
value={fontScaleInput}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextValue = event.target.value;
|
||||||
|
setFontScaleInput(nextValue);
|
||||||
|
|
||||||
|
if (nextValue === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.target.validity.valid && Number.isFinite(event.target.valueAsNumber)) {
|
||||||
|
commitFontScale(event.target.valueAsNumber);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
const parsed = Number.parseFloat(fontScaleInput);
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
restoreFontScaleInput();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
commitFontScale(parsed);
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== 'Enter') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
const parsed = Number.parseFloat(fontScaleInput);
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
restoreFontScaleInput();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
commitFontScale(parsed);
|
||||||
|
}}
|
||||||
|
aria-label="Relative font size percentage"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">%</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => commitFontScale(DEFAULT_FONT_SCALE)}
|
||||||
|
className="inline-flex h-9 items-center justify-center rounded-md border border-input px-3 text-sm font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={fontScale === DEFAULT_FONT_SCALE}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Scales the app's typography for this browser only. The slider moves in 5% steps; the
|
||||||
|
number field accepts any value from 25% to 400%.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label htmlFor="distance-units">Distance Units</Label>
|
<Label htmlFor="distance-units">Distance Units</Label>
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -846,11 +846,16 @@ export function SettingsRadioSection({
|
|||||||
className="rounded-md border border-input bg-background px-3 py-2"
|
className="rounded-md border border-input bg-background px-3 py-2"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<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">
|
<span className="text-xs text-muted-foreground">
|
||||||
heard {result.heard_count} time{result.heard_count === 1 ? '' : 's'}
|
heard {result.heard_count} time{result.heard_count === 1 ? '' : 's'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<p className="mt-1 break-all font-mono text-xs text-muted-foreground">
|
||||||
{result.public_key}
|
{result.public_key}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,4 +1,16 @@
|
|||||||
import { useState, useEffect } from 'react';
|
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 { Separator } from '../ui/separator';
|
||||||
import { api } from '../../api';
|
import { api } from '../../api';
|
||||||
import type { StatisticsResponse } from '../../types';
|
import type { StatisticsResponse } from '../../types';
|
||||||
@@ -7,6 +19,94 @@ function formatPercent(value: number): string {
|
|||||||
return `${value.toFixed(1)}%`;
|
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 }) {
|
export function SettingsStatisticsSection({ className }: { className?: string }) {
|
||||||
const [stats, setStats] = useState<StatisticsResponse | null>(null);
|
const [stats, setStats] = useState<StatisticsResponse | null>(null);
|
||||||
const [statsLoading, setStatsLoading] = useState(false);
|
const [statsLoading, setStatsLoading] = useState(false);
|
||||||
@@ -85,60 +185,6 @@ export function SettingsStatisticsSection({ className }: { className?: string })
|
|||||||
|
|
||||||
<Separator />
|
<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 */}
|
{/* Activity */}
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-medium mb-2">Activity</h4>
|
<h4 className="text-sm font-medium mb-2">Activity</h4>
|
||||||
@@ -174,23 +220,172 @@ export function SettingsStatisticsSection({ className }: { className?: string })
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</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 */}
|
{/* Busiest Channels */}
|
||||||
{stats.busiest_channels_24h.length > 0 && (
|
{stats.busiest_channels_24h.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-medium mb-2">Busiest Channels (24h)</h4>
|
<h4 className="text-sm font-medium mb-2">Busiest Channels (24h)</h4>
|
||||||
<div className="space-y-1">
|
<ResponsiveContainer
|
||||||
{stats.busiest_channels_24h.map((ch, i) => (
|
width="100%"
|
||||||
<div key={ch.channel_key} className="flex justify-between items-center text-sm">
|
height={stats.busiest_channels_24h.length * 28 + 8}
|
||||||
<span>
|
>
|
||||||
<span className="text-muted-foreground mr-2">{i + 1}.</span>
|
<BarChart
|
||||||
{ch.channel_name}
|
data={stats.busiest_channels_24h.map((ch) => ({
|
||||||
</span>
|
name: ch.channel_name,
|
||||||
<span className="text-muted-foreground">{ch.message_count} msgs</span>
|
messages: ch.message_count,
|
||||||
</div>
|
}))}
|
||||||
))}
|
layout="vertical"
|
||||||
</div>
|
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>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { Checkbox } from '../ui/checkbox';
|
import { Checkbox } from '../ui/checkbox';
|
||||||
import { PACKET_LEGEND_ITEMS } from '../../utils/visualizerUtils';
|
import { PACKET_LEGEND_ITEMS } from '../../utils/visualizerUtils';
|
||||||
import { NODE_LEGEND_ITEMS } from './shared';
|
import { NODE_LEGEND_ITEMS } from './shared';
|
||||||
@@ -71,6 +72,19 @@ export function VisualizerControls({
|
|||||||
onExpandContract,
|
onExpandContract,
|
||||||
onClearAndReset,
|
onClearAndReset,
|
||||||
}: VisualizerControlsProps) {
|
}: VisualizerControlsProps) {
|
||||||
|
const [observationWindowInput, setObservationWindowInput] = useState(
|
||||||
|
String(observationWindowSec)
|
||||||
|
);
|
||||||
|
const [pruneWindowInput, setPruneWindowInput] = useState(String(pruneStaleMinutes));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setObservationWindowInput(String(observationWindowSec));
|
||||||
|
}, [observationWindowSec]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPruneWindowInput(String(pruneStaleMinutes));
|
||||||
|
}, [pruneStaleMinutes]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{showControls && (
|
{showControls && (
|
||||||
@@ -212,12 +226,25 @@ export function VisualizerControls({
|
|||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="60"
|
max="60"
|
||||||
value={observationWindowSec}
|
value={observationWindowInput}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
setObservationWindowSec(
|
const nextValue = e.target.value;
|
||||||
Math.max(1, Math.min(60, parseInt(e.target.value, 10) || 1))
|
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"
|
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>
|
<span className="text-muted-foreground">sec</span>
|
||||||
@@ -247,10 +274,25 @@ export function VisualizerControls({
|
|||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
max={60}
|
max={60}
|
||||||
value={pruneStaleMinutes}
|
value={pruneWindowInput}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const v = parseInt(e.target.value, 10);
|
const nextValue = e.target.value;
|
||||||
if (!isNaN(v) && v >= 1 && v <= 60) setPruneStaleMinutes(v);
|
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"
|
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 {
|
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(
|
function isActiveConversationMessage(
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ export function useConversationRouter({
|
|||||||
// Only needs channels (fast path) - doesn't wait for contacts
|
// Only needs channels (fast path) - doesn't wait for contacts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasSetDefaultConversation.current || activeConversation) return;
|
if (hasSetDefaultConversation.current || activeConversation) return;
|
||||||
if (channels.length === 0) return;
|
|
||||||
|
|
||||||
const hashConv = parseHashSettingsSection() ? null : parseHashConversation();
|
const hashConv = parseHashSettingsSection() ? null : parseHashConversation();
|
||||||
|
|
||||||
@@ -92,6 +91,29 @@ export function useConversationRouter({
|
|||||||
hasSetDefaultConversation.current = true;
|
hasSetDefaultConversation.current = true;
|
||||||
return;
|
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)
|
// Handle channel hash (ID-first with legacy-name fallback)
|
||||||
if (hashConv?.type === 'channel') {
|
if (hashConv?.type === 'channel') {
|
||||||
@@ -109,14 +131,6 @@ export function useConversationRouter({
|
|||||||
// No hash: optionally restore last-viewed conversation if enabled on this device.
|
// No hash: optionally restore last-viewed conversation if enabled on this device.
|
||||||
if (!hashConv && getReopenLastConversationEnabled()) {
|
if (!hashConv && getReopenLastConversationEnabled()) {
|
||||||
const lastViewed = getLastViewedConversation();
|
const lastViewed = getLastViewedConversation();
|
||||||
if (
|
|
||||||
lastViewed &&
|
|
||||||
(lastViewed.type === 'raw' || lastViewed.type === 'map' || lastViewed.type === 'visualizer')
|
|
||||||
) {
|
|
||||||
setActiveConversationState(lastViewed);
|
|
||||||
hasSetDefaultConversation.current = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (lastViewed?.type === 'channel') {
|
if (lastViewed?.type === 'channel') {
|
||||||
const channel =
|
const channel =
|
||||||
channels.find((c) => c.key.toLowerCase() === lastViewed.id.toLowerCase()) ||
|
channels.find((c) => c.key.toLowerCase() === lastViewed.id.toLowerCase()) ||
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ interface UseRealtimeAppStateArgs {
|
|||||||
hasMention?: boolean;
|
hasMention?: boolean;
|
||||||
}) => void;
|
}) => void;
|
||||||
renameConversationState: (oldStateKey: string, newStateKey: string) => void;
|
renameConversationState: (oldStateKey: string, newStateKey: string) => void;
|
||||||
|
removeConversationState: (stateKey: string) => void;
|
||||||
checkMention: (text: string) => boolean;
|
checkMention: (text: string) => boolean;
|
||||||
pendingDeleteFallbackRef: MutableRefObject<boolean>;
|
pendingDeleteFallbackRef: MutableRefObject<boolean>;
|
||||||
setActiveConversation: (conv: Conversation | null) => void;
|
setActiveConversation: (conv: Conversation | null) => void;
|
||||||
@@ -96,6 +97,7 @@ export function useRealtimeAppState({
|
|||||||
observeMessage,
|
observeMessage,
|
||||||
recordMessageEvent,
|
recordMessageEvent,
|
||||||
renameConversationState,
|
renameConversationState,
|
||||||
|
removeConversationState,
|
||||||
checkMention,
|
checkMention,
|
||||||
pendingDeleteFallbackRef,
|
pendingDeleteFallbackRef,
|
||||||
setActiveConversation,
|
setActiveConversation,
|
||||||
@@ -232,6 +234,7 @@ export function useRealtimeAppState({
|
|||||||
onContactDeleted: (publicKey: string) => {
|
onContactDeleted: (publicKey: string) => {
|
||||||
setContacts((prev) => prev.filter((c) => c.public_key !== publicKey));
|
setContacts((prev) => prev.filter((c) => c.public_key !== publicKey));
|
||||||
removeConversationMessages(publicKey);
|
removeConversationMessages(publicKey);
|
||||||
|
removeConversationState(getStateKey('contact', publicKey));
|
||||||
const active = activeConversationRef.current;
|
const active = activeConversationRef.current;
|
||||||
if (active?.type === 'contact' && active.id === publicKey) {
|
if (active?.type === 'contact' && active.id === publicKey) {
|
||||||
pendingDeleteFallbackRef.current = true;
|
pendingDeleteFallbackRef.current = true;
|
||||||
@@ -241,6 +244,7 @@ export function useRealtimeAppState({
|
|||||||
onChannelDeleted: (key: string) => {
|
onChannelDeleted: (key: string) => {
|
||||||
setChannels((prev) => prev.filter((c) => c.key !== key));
|
setChannels((prev) => prev.filter((c) => c.key !== key));
|
||||||
removeConversationMessages(key);
|
removeConversationMessages(key);
|
||||||
|
removeConversationState(getStateKey('channel', key));
|
||||||
const active = activeConversationRef.current;
|
const active = activeConversationRef.current;
|
||||||
if (active?.type === 'channel' && active.id === key) {
|
if (active?.type === 'channel' && active.id === key) {
|
||||||
pendingDeleteFallbackRef.current = true;
|
pendingDeleteFallbackRef.current = true;
|
||||||
@@ -267,6 +271,7 @@ export function useRealtimeAppState({
|
|||||||
checkMention,
|
checkMention,
|
||||||
fetchAllContacts,
|
fetchAllContacts,
|
||||||
fetchConfig,
|
fetchConfig,
|
||||||
|
removeConversationState,
|
||||||
renameConversationState,
|
renameConversationState,
|
||||||
renameConversationMessages,
|
renameConversationMessages,
|
||||||
maxRawPackets,
|
maxRawPackets,
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ import {
|
|||||||
import type { Channel, Contact, Conversation, Message, UnreadCounts } from '../types';
|
import type { Channel, Contact, Conversation, Message, UnreadCounts } from '../types';
|
||||||
import { takePrefetchOrFetch } from '../prefetch';
|
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 {
|
interface UseUnreadCountsResult {
|
||||||
unreadCounts: Record<string, number>;
|
unreadCounts: Record<string, number>;
|
||||||
/** Tracks which conversations have unread messages that mention the user */
|
/** Tracks which conversations have unread messages that mention the user */
|
||||||
@@ -23,6 +31,7 @@ interface UseUnreadCountsResult {
|
|||||||
hasMention?: boolean;
|
hasMention?: boolean;
|
||||||
}) => void;
|
}) => void;
|
||||||
renameConversationState: (oldStateKey: string, newStateKey: string) => void;
|
renameConversationState: (oldStateKey: string, newStateKey: string) => void;
|
||||||
|
removeConversationState: (stateKey: string) => void;
|
||||||
markAllRead: () => void;
|
markAllRead: () => void;
|
||||||
refreshUnreads: () => Promise<void>;
|
refreshUnreads: () => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -47,14 +56,7 @@ export function useUnreadCounts(
|
|||||||
// (the user is already viewing it, so its count should stay at 0).
|
// (the user is already viewing it, so its count should stay at 0).
|
||||||
const applyUnreads = useCallback((data: UnreadCounts) => {
|
const applyUnreads = useCallback((data: UnreadCounts) => {
|
||||||
const ac = activeConvRef.current;
|
const ac = activeConvRef.current;
|
||||||
const activeKey =
|
const activeKey = isUnreadTrackedConversation(ac) ? getStateKey(ac.type, ac.id) : null;
|
||||||
ac &&
|
|
||||||
ac.type !== 'raw' &&
|
|
||||||
ac.type !== 'map' &&
|
|
||||||
ac.type !== 'visualizer' &&
|
|
||||||
ac.type !== 'search'
|
|
||||||
? getStateKey(ac.type as 'channel' | 'contact', ac.id)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (activeKey) {
|
if (activeKey) {
|
||||||
const counts = { ...data.counts };
|
const counts = { ...data.counts };
|
||||||
@@ -122,16 +124,8 @@ export function useUnreadCounts(
|
|||||||
// Mark conversation as read when user views it
|
// Mark conversation as read when user views it
|
||||||
// Calls server API to persist read state across devices
|
// Calls server API to persist read state across devices
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (isUnreadTrackedConversation(activeConversation)) {
|
||||||
activeConversation &&
|
const key = getStateKey(activeConversation.type, activeConversation.id);
|
||||||
activeConversation.type !== 'raw' &&
|
|
||||||
activeConversation.type !== 'map' &&
|
|
||||||
activeConversation.type !== 'visualizer'
|
|
||||||
) {
|
|
||||||
const key = getStateKey(
|
|
||||||
activeConversation.type as 'channel' | 'contact',
|
|
||||||
activeConversation.id
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update local state immediately for responsive UI
|
// Update local state immediately for responsive UI
|
||||||
setUnreadCounts((prev) => {
|
setUnreadCounts((prev) => {
|
||||||
@@ -235,6 +229,27 @@ export function useUnreadCounts(
|
|||||||
setLastMessageTimes(renameConversationTimeKey(oldStateKey, newStateKey));
|
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
|
// Mark all conversations as read
|
||||||
// Calls single bulk API endpoint to persist read state
|
// Calls single bulk API endpoint to persist read state
|
||||||
const markAllRead = useCallback(() => {
|
const markAllRead = useCallback(() => {
|
||||||
@@ -256,6 +271,7 @@ export function useUnreadCounts(
|
|||||||
unreadLastReadAts,
|
unreadLastReadAts,
|
||||||
recordMessageEvent,
|
recordMessageEvent,
|
||||||
renameConversationState,
|
renameConversationState,
|
||||||
|
removeConversationState,
|
||||||
markAllRead,
|
markAllRead,
|
||||||
refreshUnreads: fetchUnreads,
|
refreshUnreads: fetchUnreads,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -85,7 +85,7 @@
|
|||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import './index.css';
|
|||||||
import './themes.css';
|
import './themes.css';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
import { getSavedTheme, applyTheme } from './utils/theme';
|
import { getSavedTheme, applyTheme } from './utils/theme';
|
||||||
|
import { applyFontScale, getSavedFontScale } from './utils/fontScale';
|
||||||
|
|
||||||
// Apply saved theme before first render
|
// Apply saved theme before first render
|
||||||
applyTheme(getSavedTheme());
|
applyTheme(getSavedTheme());
|
||||||
|
applyFontScale(getSavedFontScale());
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
|||||||
@@ -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 () => {
|
it('restores last viewed channel when hash is empty and reopen preference is enabled', async () => {
|
||||||
const chatChannel = {
|
const chatChannel = {
|
||||||
key: '11111111111111111111111111111111',
|
key: '11111111111111111111111111111111',
|
||||||
|
|||||||
@@ -181,7 +181,10 @@ describe('ContactInfoPane', () => {
|
|||||||
|
|
||||||
await screen.findByText('Mystery');
|
await screen.findByText('Mystery');
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(getContactAnalytics).toHaveBeenCalledWith({ name: 'Mystery' });
|
expect(getContactAnalytics).toHaveBeenCalledWith(
|
||||||
|
{ name: 'Mystery' },
|
||||||
|
expect.any(AbortSignal)
|
||||||
|
);
|
||||||
expect(screen.getByText('Messages')).toBeInTheDocument();
|
expect(screen.getByText('Messages')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Channel Messages')).toBeInTheDocument();
|
expect(screen.getByText('Channel Messages')).toBeInTheDocument();
|
||||||
expect(screen.getByText('4', { selector: 'p' })).toBeInTheDocument();
|
expect(screen.getByText('4', { selector: 'p' })).toBeInTheDocument();
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ vi.mock('../components/VisualizerView', () => ({
|
|||||||
VisualizerView: () => <div data-testid="visualizer-view" />,
|
VisualizerView: () => <div data-testid="visualizer-view" />,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../components/TracePane', () => ({
|
||||||
|
TracePane: () => <div data-testid="trace-pane" />,
|
||||||
|
}));
|
||||||
|
|
||||||
const config: RadioConfig = {
|
const config: RadioConfig = {
|
||||||
public_key: 'aa'.repeat(32),
|
public_key: 'aa'.repeat(32),
|
||||||
name: 'Radio',
|
name: 'Radio',
|
||||||
@@ -141,6 +145,7 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
|
|||||||
loadingNewer: false,
|
loadingNewer: false,
|
||||||
messageInputRef: { current: null },
|
messageInputRef: { current: null },
|
||||||
onTrace: vi.fn(async () => {}),
|
onTrace: vi.fn(async () => {}),
|
||||||
|
onRunTracePath: vi.fn(async () => ({ path_len: 0, timeout_seconds: 5, nodes: [] })),
|
||||||
onPathDiscovery: vi.fn(async () => {
|
onPathDiscovery: vi.fn(async () => {
|
||||||
throw new Error('unused');
|
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 () => {
|
it('gates room chat behind room login controls until authenticated', async () => {
|
||||||
render(
|
render(
|
||||||
<ConversationPane
|
<ConversationPane
|
||||||
|
|||||||
@@ -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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -704,6 +704,75 @@ describe('SettingsFanoutSection', () => {
|
|||||||
expect(audienceInput).toHaveValue('');
|
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 () => {
|
it('community MQTT can be configured for no auth', async () => {
|
||||||
const communityConfig: FanoutConfig = {
|
const communityConfig: FanoutConfig = {
|
||||||
id: 'comm-1',
|
id: 'comm-1',
|
||||||
@@ -783,6 +852,65 @@ describe('SettingsFanoutSection', () => {
|
|||||||
expect(screen.queryByLabelText('Broker Host')).not.toBeInTheDocument();
|
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 () => {
|
it('creates MeshRank preset as a regular mqtt_community config', async () => {
|
||||||
const createdConfig: FanoutConfig = {
|
const createdConfig: FanoutConfig = {
|
||||||
id: 'comm-meshrank',
|
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 () => {
|
it('LetsMesh (EU) preset saves the EU broker defaults', async () => {
|
||||||
const createdConfig: FanoutConfig = {
|
const createdConfig: FanoutConfig = {
|
||||||
id: 'comm-letsmesh-eu',
|
id: 'comm-letsmesh-eu',
|
||||||
|
|||||||
@@ -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}%`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -140,6 +140,59 @@ describe('MessageList channel sender rendering', () => {
|
|||||||
expect(screen.getByRole('button', { name: 'View info for Alice' })).toBeInTheDocument();
|
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 () => {
|
it('renders and dismisses an unread marker at the first unread message boundary', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const messages = [
|
const messages = [
|
||||||
|
|||||||
@@ -6,7 +6,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { parseSenderFromText, formatTime } from '../utils/messageParser';
|
import {
|
||||||
|
findLinkedChannelReferences,
|
||||||
|
formatTime,
|
||||||
|
isValidLinkedChannelName,
|
||||||
|
parseSenderFromText,
|
||||||
|
} from '../utils/messageParser';
|
||||||
|
|
||||||
describe('parseSenderFromText', () => {
|
describe('parseSenderFromText', () => {
|
||||||
it('extracts sender and content from "sender: message" format', () => {
|
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
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -32,7 +32,10 @@ describe('NewMessageModal form reset', () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
function renderModal(open = true) {
|
function renderModal(
|
||||||
|
open = true,
|
||||||
|
overrides: Partial<Parameters<typeof NewMessageModal>[0]> = {}
|
||||||
|
) {
|
||||||
return render(
|
return render(
|
||||||
<NewMessageModal
|
<NewMessageModal
|
||||||
open={open}
|
open={open}
|
||||||
@@ -41,6 +44,7 @@ describe('NewMessageModal form reset', () => {
|
|||||||
onCreateContact={onCreateContact}
|
onCreateContact={onCreateContact}
|
||||||
onCreateChannel={onCreateChannel}
|
onCreateChannel={onCreateChannel}
|
||||||
onCreateHashtagChannel={onCreateHashtagChannel}
|
onCreateHashtagChannel={onCreateHashtagChannel}
|
||||||
|
{...overrides}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -50,6 +54,26 @@ describe('NewMessageModal form reset', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('hashtag tab', () => {
|
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 () => {
|
it('clears name after successful Create', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { unmount } = renderModal();
|
const { unmount } = renderModal();
|
||||||
|
|||||||
@@ -283,6 +283,8 @@ describe('RawPacketFeedView', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: /show stats/i }));
|
fireEvent.click(screen.getByRole('button', { name: /show stats/i }));
|
||||||
fireEvent.change(screen.getByLabelText('Stats window'), { target: { value: 'session' } });
|
fireEvent.change(screen.getByLabelText('Stats window'), { target: { value: 'session' } });
|
||||||
expect(screen.getAllByText('Alpha').length).toBeGreaterThan(0);
|
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', () => {
|
it('marks unresolved neighbor identities explicitly', () => {
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ import {
|
|||||||
} from '../utils/lastViewedConversation';
|
} from '../utils/lastViewedConversation';
|
||||||
import { api } from '../api';
|
import { api } from '../api';
|
||||||
import { DISTANCE_UNIT_KEY } from '../utils/distanceUnits';
|
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 = {
|
const baseConfig: RadioConfig = {
|
||||||
public_key: 'aa'.repeat(32),
|
public_key: 'aa'.repeat(32),
|
||||||
@@ -186,6 +192,7 @@ describe('SettingsModal', () => {
|
|||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
window.location.hash = '';
|
window.location.hash = '';
|
||||||
|
document.documentElement.style.fontSize = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
it('refreshes app settings when opened', async () => {
|
it('refreshes app settings when opened', async () => {
|
||||||
@@ -300,6 +307,7 @@ describe('SettingsModal', () => {
|
|||||||
results: [
|
results: [
|
||||||
{
|
{
|
||||||
public_key: '11'.repeat(32),
|
public_key: '11'.repeat(32),
|
||||||
|
name: null,
|
||||||
node_type: 'repeater',
|
node_type: 'repeater',
|
||||||
heard_count: 2,
|
heard_count: 2,
|
||||||
local_snr: 7.5,
|
local_snr: 7.5,
|
||||||
@@ -548,6 +556,55 @@ describe('SettingsModal', () => {
|
|||||||
expect(localStorage.getItem(DISTANCE_UNIT_KEY)).toBe('smoots');
|
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 () => {
|
it('purges decrypted raw packets via maintenance endpoint action', async () => {
|
||||||
const runMaintenanceSpy = vi.spyOn(api, 'runMaintenance').mockResolvedValue({
|
const runMaintenanceSpy = vi.spyOn(api, 'runMaintenance').mockResolvedValue({
|
||||||
packets_deleted: 12,
|
packets_deleted: 12,
|
||||||
@@ -594,6 +651,14 @@ describe('SettingsModal', () => {
|
|||||||
double_byte_pct: 30,
|
double_byte_pct: 30,
|
||||||
triple_byte_pct: 20,
|
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(
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||||
@@ -625,17 +690,11 @@ describe('SettingsModal', () => {
|
|||||||
expect(
|
expect(
|
||||||
screen.getByText(/Parsed stored raw packets from the last 24 hours: 120/)
|
screen.getByText(/Parsed stored raw packets from the last 24 hours: 120/)
|
||||||
).toBeInTheDocument();
|
).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('Contacts heard')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Repeaters heard')).toBeInTheDocument();
|
expect(screen.getByText('Repeaters heard')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Known-channels active')).toBeInTheDocument();
|
expect(screen.getByText('Known-channels active')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Busiest Channels (24h)')).toBeInTheDocument();
|
||||||
// Busiest channels
|
expect(screen.getByText('Noise Floor (24h)')).toBeInTheDocument();
|
||||||
expect(screen.getByText('general')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('42 msgs')).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fetches statistics when expanded in mobile external-nav mode', async () => {
|
it('fetches statistics when expanded in mobile external-nav mode', async () => {
|
||||||
@@ -662,6 +721,14 @@ describe('SettingsModal', () => {
|
|||||||
double_byte_pct: 30,
|
double_byte_pct: 30,
|
||||||
triple_byte_pct: 20,
|
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(
|
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||||
|
|||||||
@@ -75,13 +75,14 @@ function renderSidebar(overrides?: {
|
|||||||
|
|
||||||
const favorites = overrides?.favorites ?? [{ type: 'channel', id: flightChannel.key }];
|
const favorites = overrides?.favorites ?? [{ type: 'channel', id: flightChannel.key }];
|
||||||
const channels = overrides?.channels ?? [publicChannel, flightChannel, opsChannel];
|
const channels = overrides?.channels ?? [publicChannel, flightChannel, opsChannel];
|
||||||
|
const onSelectConversation = vi.fn();
|
||||||
|
|
||||||
const view = render(
|
const view = render(
|
||||||
<Sidebar
|
<Sidebar
|
||||||
contacts={[alice, board, relay]}
|
contacts={[alice, board, relay]}
|
||||||
channels={channels}
|
channels={channels}
|
||||||
activeConversation={null}
|
activeConversation={null}
|
||||||
onSelectConversation={vi.fn()}
|
onSelectConversation={onSelectConversation}
|
||||||
onNewMessage={vi.fn()}
|
onNewMessage={vi.fn()}
|
||||||
lastMessageTimes={overrides?.lastMessageTimes ?? {}}
|
lastMessageTimes={overrides?.lastMessageTimes ?? {}}
|
||||||
unreadCounts={unreadCounts}
|
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 {
|
function getSectionHeaderContainer(title: string): HTMLElement {
|
||||||
@@ -121,6 +122,46 @@ describe('Sidebar section summaries', () => {
|
|||||||
expect(within(getSectionHeaderContainer('Repeaters')).getByText('4')).toBeInTheDocument();
|
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', () => {
|
it('turns favorites and channels rollups red when they contain a mention', () => {
|
||||||
renderSidebar({
|
renderSidebar({
|
||||||
mentions: {
|
mentions: {
|
||||||
@@ -306,6 +347,18 @@ describe('Sidebar section summaries', () => {
|
|||||||
expect(bell.compareDocumentPosition(unread) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
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', () => {
|
it('sorts each section independently and persists per-section sort preferences', () => {
|
||||||
const publicChannel = makeChannel('AA'.repeat(16), 'Public');
|
const publicChannel = makeChannel('AA'.repeat(16), 'Public');
|
||||||
const zebraChannel = makeChannel('BB'.repeat(16), '#zebra');
|
const zebraChannel = makeChannel('BB'.repeat(16), '#zebra');
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -52,6 +52,14 @@ describe('parseHashConversation', () => {
|
|||||||
expect(result).toEqual({ type: 'map', name: 'map' });
|
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', () => {
|
it('parses #map/focus/PUBKEY with focus key', () => {
|
||||||
window.location.hash = '#map/focus/ABCD1234';
|
window.location.hash = '#map/focus/ABCD1234';
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ function createRealtimeArgs(overrides: Partial<Parameters<typeof useRealtimeAppS
|
|||||||
observeMessage: vi.fn(() => ({ added: false, activeConversation: false })),
|
observeMessage: vi.fn(() => ({ added: false, activeConversation: false })),
|
||||||
recordMessageEvent: vi.fn(),
|
recordMessageEvent: vi.fn(),
|
||||||
renameConversationState: vi.fn(),
|
renameConversationState: vi.fn(),
|
||||||
|
removeConversationState: vi.fn(),
|
||||||
checkMention: vi.fn(() => false),
|
checkMention: vi.fn(() => false),
|
||||||
pendingDeleteFallbackRef: { current: false },
|
pendingDeleteFallbackRef: { current: false },
|
||||||
setActiveConversation: vi.fn(),
|
setActiveConversation: vi.fn(),
|
||||||
|
|||||||
@@ -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 () => {
|
it('re-fetches and filters when refreshUnreads is called (simulating WS reconnect)', async () => {
|
||||||
const mocks = await getMockedApi();
|
const mocks = await getMockedApi();
|
||||||
const channels = [makeChannel(CHANNEL_KEY, 'Test')];
|
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
@@ -34,6 +34,7 @@ export type RadioDiscoveryTarget = 'repeaters' | 'sensors' | 'all';
|
|||||||
|
|
||||||
export interface RadioDiscoveryResult {
|
export interface RadioDiscoveryResult {
|
||||||
public_key: string;
|
public_key: string;
|
||||||
|
name: string | null;
|
||||||
node_type: 'repeater' | 'sensor';
|
node_type: 'repeater' | 'sensor';
|
||||||
heard_count: number;
|
heard_count: number;
|
||||||
local_snr: number | null;
|
local_snr: number | null;
|
||||||
@@ -285,7 +286,7 @@ export interface ResendChannelMessageResponse {
|
|||||||
message?: Message;
|
message?: Message;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ConversationType = 'contact' | 'channel' | 'raw' | 'map' | 'visualizer' | 'search';
|
type ConversationType = 'contact' | 'channel' | 'raw' | 'map' | 'visualizer' | 'search' | 'trace';
|
||||||
|
|
||||||
export interface Conversation {
|
export interface Conversation {
|
||||||
type: ConversationType;
|
type: ConversationType;
|
||||||
@@ -473,6 +474,25 @@ export interface TraceResponse {
|
|||||||
path_len: number;
|
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 {
|
export interface PathDiscoveryRoute {
|
||||||
path: string;
|
path: string;
|
||||||
path_len: number;
|
path_len: number;
|
||||||
@@ -504,6 +524,20 @@ interface ContactActivityCounts {
|
|||||||
last_week: number;
|
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 {
|
export interface StatisticsResponse {
|
||||||
busiest_channels_24h: BusyChannel[];
|
busiest_channels_24h: BusyChannel[];
|
||||||
contact_count: number;
|
contact_count: number;
|
||||||
@@ -527,4 +561,5 @@ export interface StatisticsResponse {
|
|||||||
double_byte_pct: number;
|
double_byte_pct: number;
|
||||||
triple_byte_pct: number;
|
triple_byte_pct: number;
|
||||||
};
|
};
|
||||||
|
noise_floor_24h: NoiseFloorHistoryStats;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -4,7 +4,14 @@ import { parseHashConversation } from './urlHash';
|
|||||||
export const REOPEN_LAST_CONVERSATION_KEY = 'remoteterm-reopen-last-conversation';
|
export const REOPEN_LAST_CONVERSATION_KEY = 'remoteterm-reopen-last-conversation';
|
||||||
export const LAST_VIEWED_CONVERSATION_KEY = 'remoteterm-last-viewed-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'] {
|
function isSupportedType(value: unknown): value is Conversation['type'] {
|
||||||
return typeof value === 'string' && SUPPORTED_TYPES.includes(value as 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' });
|
saveLastViewedConversation({ type: 'visualizer', id: 'visualizer', name: 'Mesh Visualizer' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (hashConversation.type === 'trace') {
|
||||||
|
saveLastViewedConversation({ type: 'trace', id: 'trace', name: 'Trace' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
saveLastViewedConversation({
|
saveLastViewedConversation({
|
||||||
type: hashConversation.type,
|
type: hashConversation.type,
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
* Parse sender from channel message text.
|
* Parse sender from channel message text.
|
||||||
* Channel messages have format "sender: message".
|
* Channel messages have format "sender: message".
|
||||||
*/
|
*/
|
||||||
|
const HASHTAG_CHANNEL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||||
|
const HASHTAG_CHANNEL_REFERENCE_PATTERN = /(^|\s)(#[a-z0-9]+(?:-[a-z0-9]+)*)(?=$|\s)/g;
|
||||||
|
|
||||||
export function parseSenderFromText(text: string): { sender: string | null; content: string } {
|
export function parseSenderFromText(text: string): { sender: string | null; content: string } {
|
||||||
const colonIndex = text.indexOf(': ');
|
const colonIndex = text.indexOf(': ');
|
||||||
if (colonIndex > 0 && colonIndex < 50) {
|
if (colonIndex > 0 && colonIndex < 50) {
|
||||||
@@ -17,6 +20,35 @@ export function parseSenderFromText(text: string): { sender: string | null; cont
|
|||||||
return { sender: null, content: text };
|
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.
|
* Format a Unix timestamp to a time string.
|
||||||
* Shows date for messages not from today.
|
* Shows date for messages not from today.
|
||||||
|
|||||||
@@ -106,9 +106,6 @@ export interface RawPacketStatsSnapshot {
|
|||||||
medianRssi: number | null;
|
medianRssi: number | null;
|
||||||
bestRssi: number | null;
|
bestRssi: number | null;
|
||||||
rssiBuckets: RankedPacketStat[];
|
rssiBuckets: RankedPacketStat[];
|
||||||
strongestPacketSourceKey: string | null;
|
|
||||||
strongestPacketSourceLabel: string | null;
|
|
||||||
strongestPacketPayloadType: string | null;
|
|
||||||
coverageSeconds: number;
|
coverageSeconds: number;
|
||||||
windowFullyCovered: boolean;
|
windowFullyCovered: boolean;
|
||||||
oldestStoredTimestamp: number | null;
|
oldestStoredTimestamp: number | null;
|
||||||
@@ -377,8 +374,6 @@ export function buildRawPacketStatsSnapshot(
|
|||||||
['Weak (<-85 dBm)', 0],
|
['Weak (<-85 dBm)', 0],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let strongestPacket: RawPacketStatsObservation | null = null;
|
|
||||||
|
|
||||||
for (const packet of packets) {
|
for (const packet of packets) {
|
||||||
payloadCounts.set(packet.payloadType, (payloadCounts.get(packet.payloadType) ?? 0) + 1);
|
payloadCounts.set(packet.payloadType, (payloadCounts.get(packet.payloadType) ?? 0) + 1);
|
||||||
routeCounts.set(packet.routeType, (routeCounts.get(packet.routeType) ?? 0) + 1);
|
routeCounts.set(packet.routeType, (routeCounts.get(packet.routeType) ?? 0) + 1);
|
||||||
@@ -436,10 +431,6 @@ export function buildRawPacketStatsSnapshot(
|
|||||||
} else {
|
} else {
|
||||||
rssiBucketCounts.set('Weak (<-85 dBm)', (rssiBucketCounts.get('Weak (<-85 dBm)') ?? 0) + 1);
|
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,
|
medianRssi,
|
||||||
bestRssi,
|
bestRssi,
|
||||||
rssiBuckets: rankedBreakdown(rssiBucketCounts, rssiValues.length),
|
rssiBuckets: rankedBreakdown(rssiBucketCounts, rssiValues.length),
|
||||||
strongestPacketSourceKey: strongestPacket?.sourceKey ?? null,
|
|
||||||
strongestPacketSourceLabel: strongestPacket?.sourceLabel ?? null,
|
|
||||||
strongestPacketPayloadType: strongestPacket?.payloadType ?? null,
|
|
||||||
coverageSeconds,
|
coverageSeconds,
|
||||||
windowFullyCovered,
|
windowFullyCovered,
|
||||||
oldestStoredTimestamp,
|
oldestStoredTimestamp,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { getContactDisplayName } from './pubkey';
|
|||||||
import type { SettingsSection } from '../components/settings/settingsConstants';
|
import type { SettingsSection } from '../components/settings/settingsConstants';
|
||||||
|
|
||||||
interface ParsedHashConversation {
|
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) */
|
/** Conversation identity token (channel key or contact public key, or legacy name token) */
|
||||||
name: string;
|
name: string;
|
||||||
/** Optional human-readable label segment (ignored for identity resolution) */
|
/** Optional human-readable label segment (ignored for identity resolution) */
|
||||||
@@ -44,6 +44,10 @@ export function parseHashConversation(): ParsedHashConversation | null {
|
|||||||
return { type: 'search', name: 'search' };
|
return { type: 'search', name: 'search' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hash === 'trace') {
|
||||||
|
return { type: 'trace', name: 'trace' };
|
||||||
|
}
|
||||||
|
|
||||||
// Check for map with focus: #map/focus/{pubkey_prefix}
|
// Check for map with focus: #map/focus/{pubkey_prefix}
|
||||||
if (hash.startsWith('map/focus/')) {
|
if (hash.startsWith('map/focus/')) {
|
||||||
const focusKey = hash.slice('map/focus/'.length);
|
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 === 'map') return '#map';
|
||||||
if (conv.type === 'visualizer') return '#visualizer';
|
if (conv.type === 'visualizer') return '#visualizer';
|
||||||
if (conv.type === 'search') return '#search';
|
if (conv.type === 'search') return '#search';
|
||||||
|
if (conv.type === 'trace') return '#trace';
|
||||||
|
|
||||||
// Use immutable IDs for identity, append readable label for UX.
|
// Use immutable IDs for identity, append readable label for UX.
|
||||||
if (conv.type === 'channel') {
|
if (conv.type === 'channel') {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "remoteterm-meshcore"
|
name = "remoteterm-meshcore"
|
||||||
version = "3.6.2"
|
version = "3.6.3"
|
||||||
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
|
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|||||||
@@ -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
|
set -euo pipefail
|
||||||
|
|
||||||
# Collect third-party license texts into LICENSES.md
|
# 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
|
# 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}"
|
OUT="${1:-$REPO_ROOT/LICENSES.md}"
|
||||||
FRONTEND_LICENSE_IMAGE="${FRONTEND_LICENSE_IMAGE:-node:20-slim}"
|
FRONTEND_LICENSE_IMAGE="${FRONTEND_LICENSE_IMAGE:-node:20-slim}"
|
||||||
FRONTEND_LICENSE_NPM="${FRONTEND_LICENSE_NPM:-10.9.5}"
|
FRONTEND_LICENSE_NPM="${FRONTEND_LICENSE_NPM:-10.9.5}"
|
||||||
@@ -59,7 +59,7 @@ for d in data:
|
|||||||
# ── Frontend (npm) ───────────────────────────────────────────────────
|
# ── Frontend (npm) ───────────────────────────────────────────────────
|
||||||
frontend_licenses_local() {
|
frontend_licenses_local() {
|
||||||
cd "$REPO_ROOT/frontend"
|
cd "$REPO_ROOT/frontend"
|
||||||
node "$REPO_ROOT/scripts/print_frontend_licenses.cjs"
|
node "$REPO_ROOT/scripts/build/print_frontend_licenses.cjs"
|
||||||
}
|
}
|
||||||
|
|
||||||
frontend_licenses_docker() {
|
frontend_licenses_docker() {
|
||||||
@@ -73,7 +73,7 @@ frontend_licenses_docker() {
|
|||||||
cd frontend
|
cd frontend
|
||||||
npm i -g npm@$FRONTEND_LICENSE_NPM >/dev/null
|
npm i -g npm@$FRONTEND_LICENSE_NPM >/dev/null
|
||||||
npm ci --ignore-scripts >/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 "# Third-Party Licenses"
|
||||||
echo
|
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
|
||||||
echo "## Backend (Python) Dependencies"
|
echo "## Backend (Python) Dependencies"
|
||||||
echo
|
echo
|
||||||
@@ -7,21 +7,25 @@ GREEN='\033[0;32m'
|
|||||||
YELLOW='\033[1;33m'
|
YELLOW='\033[1;33m'
|
||||||
NC='\033[0m' # No Color
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
cd "$SCRIPT_DIR"
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
RELEASE_WORK_DIR=""
|
RELEASE_WORK_DIR=""
|
||||||
RELEASE_BUNDLE_DIR_NAME="Remote-Terminal-for-MeshCore"
|
RELEASE_BUNDLE_DIR_NAME="Remote-Terminal-for-MeshCore"
|
||||||
|
RELEASE_ASSET=""
|
||||||
DOCKER_IMAGE="jkingsman/remoteterm-meshcore"
|
DOCKER_IMAGE="jkingsman/remoteterm-meshcore"
|
||||||
DOCKER_PLATFORMS="linux/amd64,linux/arm64"
|
DOCKER_PLATFORMS="linux/amd64,linux/arm64"
|
||||||
|
|
||||||
cleanup_release_build_artifacts() {
|
cleanup_release_build_artifacts() {
|
||||||
if [ -d "$SCRIPT_DIR/frontend/prebuilt" ]; then
|
if [ -d "$REPO_ROOT/frontend/prebuilt" ]; then
|
||||||
rm -rf "$SCRIPT_DIR/frontend/prebuilt"
|
rm -rf "$REPO_ROOT/frontend/prebuilt"
|
||||||
fi
|
fi
|
||||||
if [ -n "$RELEASE_WORK_DIR" ] && [ -d "$RELEASE_WORK_DIR" ]; then
|
if [ -n "$RELEASE_WORK_DIR" ] && [ -d "$RELEASE_WORK_DIR" ]; then
|
||||||
rm -rf "$RELEASE_WORK_DIR"
|
rm -rf "$RELEASE_WORK_DIR"
|
||||||
fi
|
fi
|
||||||
|
if [ -n "$RELEASE_ASSET" ] && [ -f "$REPO_ROOT/$RELEASE_ASSET" ]; then
|
||||||
|
rm -f "$REPO_ROOT/$RELEASE_ASSET"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
trap cleanup_release_build_artifacts EXIT
|
trap cleanup_release_build_artifacts EXIT
|
||||||
@@ -74,7 +78,7 @@ echo
|
|||||||
|
|
||||||
# Run frontend linting and formatting check
|
# Run frontend linting and formatting check
|
||||||
echo -e "${YELLOW}Running frontend lint (ESLint)...${NC}"
|
echo -e "${YELLOW}Running frontend lint (ESLint)...${NC}"
|
||||||
cd "$SCRIPT_DIR/frontend"
|
cd "$REPO_ROOT/frontend"
|
||||||
npm run lint
|
npm run lint
|
||||||
echo -e "${GREEN}Frontend lint passed!${NC}"
|
echo -e "${GREEN}Frontend lint passed!${NC}"
|
||||||
echo
|
echo
|
||||||
@@ -93,11 +97,11 @@ echo
|
|||||||
echo -e "${YELLOW}Building frontend...${NC}"
|
echo -e "${YELLOW}Building frontend...${NC}"
|
||||||
npm run build
|
npm run build
|
||||||
echo -e "${GREEN}Frontend build complete!${NC}"
|
echo -e "${GREEN}Frontend build complete!${NC}"
|
||||||
cd "$SCRIPT_DIR"
|
cd "$REPO_ROOT"
|
||||||
echo
|
echo
|
||||||
|
|
||||||
echo -e "${YELLOW}Regenerating LICENSES.md...${NC}"
|
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 -e "${GREEN}LICENSES.md updated!${NC}"
|
||||||
echo
|
echo
|
||||||
|
|
||||||
@@ -198,16 +202,16 @@ FULL_GIT_HASH=$(git rev-parse HEAD)
|
|||||||
RELEASE_ASSET="remoteterm-prebuilt-frontend-v${VERSION}-${GIT_HASH}.zip"
|
RELEASE_ASSET="remoteterm-prebuilt-frontend-v${VERSION}-${GIT_HASH}.zip"
|
||||||
|
|
||||||
echo -e "${YELLOW}Building packaged frontend artifact...${NC}"
|
echo -e "${YELLOW}Building packaged frontend artifact...${NC}"
|
||||||
cd "$SCRIPT_DIR/frontend"
|
cd "$REPO_ROOT/frontend"
|
||||||
npm run packaged-build
|
npm run packaged-build
|
||||||
cd "$SCRIPT_DIR"
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
RELEASE_WORK_DIR=$(mktemp -d)
|
RELEASE_WORK_DIR=$(mktemp -d)
|
||||||
RELEASE_BUNDLE_DIR="$RELEASE_WORK_DIR/$RELEASE_BUNDLE_DIR_NAME"
|
RELEASE_BUNDLE_DIR="$RELEASE_WORK_DIR/$RELEASE_BUNDLE_DIR_NAME"
|
||||||
mkdir -p "$RELEASE_BUNDLE_DIR"
|
mkdir -p "$RELEASE_BUNDLE_DIR"
|
||||||
git archive "$FULL_GIT_HASH" | tar -x -C "$RELEASE_BUNDLE_DIR"
|
git archive "$FULL_GIT_HASH" | tar -x -C "$RELEASE_BUNDLE_DIR"
|
||||||
mkdir -p "$RELEASE_BUNDLE_DIR/frontend"
|
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
|
cat > "$RELEASE_BUNDLE_DIR/build_info.json" <<EOF
|
||||||
{
|
{
|
||||||
"version": "$VERSION",
|
"version": "$VERSION",
|
||||||
@@ -215,10 +219,10 @@ cat > "$RELEASE_BUNDLE_DIR/build_info.json" <<EOF
|
|||||||
"build_source": "prebuilt-release"
|
"build_source": "prebuilt-release"
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
rm -f "$SCRIPT_DIR/$RELEASE_ASSET"
|
rm -f "$REPO_ROOT/$RELEASE_ASSET"
|
||||||
(
|
(
|
||||||
cd "$RELEASE_WORK_DIR"
|
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 -e "${GREEN}Packaged release artifact created: $RELEASE_ASSET${NC}"
|
||||||
echo
|
echo
|
||||||
Executable
+73
@@ -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'
|
BLUE='\033[0;34m'
|
||||||
NC='\033[0m'
|
NC='\033[0m'
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
|
|
||||||
NODE_VERSIONS=("20" "22" "24")
|
NODE_VERSIONS=("20" "22" "24")
|
||||||
# Use explicit npm patch versions so resolver regressions are caught.
|
# Use explicit npm patch versions so resolver regressions are caught.
|
||||||
@@ -27,7 +27,7 @@ run_combo() {
|
|||||||
local image="node:${node_version}-slim"
|
local image="node:${node_version}-slim"
|
||||||
|
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
-v "$SCRIPT_DIR:/src:ro" \
|
-v "$REPO_ROOT:/src:ro" \
|
||||||
-w /tmp \
|
-w /tmp \
|
||||||
"$image" \
|
"$image" \
|
||||||
bash -lc "
|
bash -lc "
|
||||||
@@ -79,7 +79,7 @@ cleanup() {
|
|||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
echo -e "${YELLOW}=== Frontend Docker CI Matrix ===${NC}"
|
echo -e "${YELLOW}=== Frontend Docker CI Matrix ===${NC}"
|
||||||
echo -e "${BLUE}Repo:${NC} $SCRIPT_DIR"
|
echo -e "${BLUE}Repo:${NC} $REPO_ROOT"
|
||||||
echo
|
echo
|
||||||
|
|
||||||
for case_spec in "${TEST_CASES[@]}"; do
|
for case_spec in "${TEST_CASES[@]}"; do
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
|
|
||||||
echo "Starting E2E tests..."
|
echo "Starting E2E tests..."
|
||||||
cd "$SCRIPT_DIR/tests/e2e"
|
cd "$REPO_ROOT/tests/e2e"
|
||||||
npx playwright test "$@"
|
npx playwright test "$@"
|
||||||
@@ -6,23 +6,23 @@ GREEN='\033[0;32m'
|
|||||||
BLUE='\033[0;34m'
|
BLUE='\033[0;34m'
|
||||||
NC='\033[0m'
|
NC='\033[0m'
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
|
|
||||||
echo -e "${YELLOW}=== Extended Quality Checks ===${NC}"
|
echo -e "${YELLOW}=== Extended Quality Checks ===${NC}"
|
||||||
echo
|
echo
|
||||||
|
|
||||||
echo -e "${BLUE}[all_quality]${NC} Running full lint, typecheck, unit tests, and the standard frontend build..."
|
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 -e "${GREEN}[all_quality]${NC} Passed!"
|
||||||
echo
|
echo
|
||||||
|
|
||||||
echo -e "${BLUE}[e2e]${NC} Running end-to-end tests..."
|
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 -e "${GREEN}[e2e]${NC} Passed!"
|
||||||
echo
|
echo
|
||||||
|
|
||||||
echo -e "${BLUE}[docker_ci]${NC} Running Docker frontend install/build matrix..."
|
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 -e "${GREEN}[docker_ci]${NC} Passed!"
|
||||||
echo
|
echo
|
||||||
|
|
||||||
@@ -21,7 +21,8 @@ API_URL = f"https://api.github.com/repos/{REPO}/releases/latest"
|
|||||||
PREBUILT_PREFIX = "Remote-Terminal-for-MeshCore/frontend/prebuilt/"
|
PREBUILT_PREFIX = "Remote-Terminal-for-MeshCore/frontend/prebuilt/"
|
||||||
|
|
||||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
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:
|
def fetch_json(url: str) -> dict:
|
||||||
Executable
+582
@@ -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.
|
# gymnastics.
|
||||||
#
|
#
|
||||||
# Run from anywhere inside the repo:
|
# Run from anywhere inside the repo:
|
||||||
# bash scripts/install_service.sh
|
# bash scripts/setup/install_service.sh
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ BOLD='\033[1m'
|
|||||||
NC='\033[0m'
|
NC='\033[0m'
|
||||||
|
|
||||||
SERVICE_NAME="remoteterm"
|
SERVICE_NAME="remoteterm"
|
||||||
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
REPO_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
CURRENT_USER="$(id -un)"
|
CURRENT_USER="$(id -un)"
|
||||||
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||||
FRONTEND_MODE="build"
|
FRONTEND_MODE="build"
|
||||||
@@ -252,7 +252,7 @@ if [ "$FRONTEND_MODE" = "build" ]; then
|
|||||||
)
|
)
|
||||||
else
|
else
|
||||||
echo -e "${YELLOW}Fetching prebuilt frontend...${NC}"
|
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
|
fi
|
||||||
echo
|
echo
|
||||||
|
|
||||||
@@ -402,7 +402,7 @@ echo -e " cd frontend && npm install && npm run build && cd .."
|
|||||||
echo -e " sudo systemctl restart ${SERVICE_NAME}"
|
echo -e " sudo systemctl restart ${SERVICE_NAME}"
|
||||||
echo
|
echo
|
||||||
echo -e "${YELLOW}Refresh prebuilt frontend only (skips local build):${NC}"
|
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 -e " sudo systemctl restart ${SERVICE_NAME}"
|
||||||
echo
|
echo
|
||||||
echo -e "${YELLOW}View live logs (useful for troubleshooting):${NC}"
|
echo -e "${YELLOW}View live logs (useful for troubleshooting):${NC}"
|
||||||
@@ -190,6 +190,29 @@ class TestDebugEndpoint:
|
|||||||
assert payload["database"]["total_channel_messages"] == 1
|
assert payload["database"]["total_channel_messages"] == 1
|
||||||
assert payload["database"]["total_outgoing"] == 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:
|
class TestRadioDisconnectedHandler:
|
||||||
"""Test that RadioDisconnectedError maps to 503."""
|
"""Test that RadioDisconnectedError maps to 503."""
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ import pytest
|
|||||||
|
|
||||||
from app.event_handlers import (
|
from app.event_handlers import (
|
||||||
_active_subscriptions,
|
_active_subscriptions,
|
||||||
_buffered_acks,
|
|
||||||
_pending_acks,
|
|
||||||
cleanup_expired_acks,
|
cleanup_expired_acks,
|
||||||
register_event_handlers,
|
register_event_handlers,
|
||||||
track_pending_ack,
|
track_pending_ack,
|
||||||
@@ -23,6 +21,7 @@ from app.repository import (
|
|||||||
ContactRepository,
|
ContactRepository,
|
||||||
MessageRepository,
|
MessageRepository,
|
||||||
)
|
)
|
||||||
|
from app.services.dm_ack_tracker import _buffered_acks, _pending_acks
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
|
|||||||
+78
-14
@@ -1247,8 +1247,8 @@ class TestMigration039:
|
|||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
|
|
||||||
assert applied == 8
|
assert applied == 9
|
||||||
assert await get_version(conn) == 46
|
assert await get_version(conn) == 47
|
||||||
|
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -1319,8 +1319,8 @@ class TestMigration039:
|
|||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
|
|
||||||
assert applied == 8
|
assert applied == 9
|
||||||
assert await get_version(conn) == 46
|
assert await get_version(conn) == 47
|
||||||
|
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -1386,8 +1386,8 @@ class TestMigration039:
|
|||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
|
|
||||||
assert applied == 2
|
assert applied == 3
|
||||||
assert await get_version(conn) == 46
|
assert await get_version(conn) == 47
|
||||||
|
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -1439,8 +1439,8 @@ class TestMigration040:
|
|||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
|
|
||||||
assert applied == 7
|
assert applied == 8
|
||||||
assert await get_version(conn) == 46
|
assert await get_version(conn) == 47
|
||||||
|
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -1501,8 +1501,8 @@ class TestMigration041:
|
|||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
|
|
||||||
assert applied == 6
|
assert applied == 7
|
||||||
assert await get_version(conn) == 46
|
assert await get_version(conn) == 47
|
||||||
|
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -1554,8 +1554,8 @@ class TestMigration042:
|
|||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
|
|
||||||
assert applied == 5
|
assert applied == 6
|
||||||
assert await get_version(conn) == 46
|
assert await get_version(conn) == 47
|
||||||
|
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -1694,8 +1694,8 @@ class TestMigration046:
|
|||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
|
|
||||||
assert applied == 1
|
assert applied == 2
|
||||||
assert await get_version(conn) == 46
|
assert await get_version(conn) == 47
|
||||||
|
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -1750,6 +1750,70 @@ class TestMigration046:
|
|||||||
await conn.close()
|
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:
|
class TestMigrationPacketHelpers:
|
||||||
"""Test migration-local packet helpers against canonical path validation."""
|
"""Test migration-local packet helpers against canonical path validation."""
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ undecrypted count endpoint, and the maintenance endpoint.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from unittest.mock import patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -305,6 +305,43 @@ class TestDecryptHistoricalPackets:
|
|||||||
assert "key_type" in data["detail"].lower()
|
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:
|
class TestRunHistoricalChannelDecryption:
|
||||||
"""Test the _run_historical_channel_decryption background task."""
|
"""Test the _run_historical_channel_decryption background task."""
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import ANY, AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from meshcore import EventType
|
from meshcore import EventType
|
||||||
from pydantic import ValidationError
|
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.radio import RadioManager, radio_manager
|
||||||
from app.routers.radio import (
|
from app.routers.radio import (
|
||||||
PrivateKeyUpdate,
|
PrivateKeyUpdate,
|
||||||
@@ -25,6 +25,7 @@ from app.routers.radio import (
|
|||||||
reconnect_radio,
|
reconnect_radio,
|
||||||
send_advertisement,
|
send_advertisement,
|
||||||
set_private_key,
|
set_private_key,
|
||||||
|
trace_path,
|
||||||
update_radio_config,
|
update_radio_config,
|
||||||
)
|
)
|
||||||
from app.services.radio_runtime import RadioRuntime
|
from app.services.radio_runtime import RadioRuntime
|
||||||
@@ -375,6 +376,11 @@ class TestDiscoverMesh:
|
|||||||
return_value=None,
|
return_value=None,
|
||||||
),
|
),
|
||||||
patch("app.routers.radio.ContactRepository.upsert", new_callable=AsyncMock),
|
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"),
|
patch("app.routers.radio.broadcast_event"),
|
||||||
):
|
):
|
||||||
response = await discover_mesh(RadioDiscoveryRequest(target="repeaters"))
|
response = await discover_mesh(RadioDiscoveryRequest(target="repeaters"))
|
||||||
@@ -436,18 +442,27 @@ class TestDiscoverMesh:
|
|||||||
patch(
|
patch(
|
||||||
"app.routers.radio.ContactRepository.get_by_key",
|
"app.routers.radio.ContactRepository.get_by_key",
|
||||||
new_callable=AsyncMock,
|
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,
|
) as mock_get_by_key,
|
||||||
patch(
|
patch(
|
||||||
"app.routers.radio.ContactRepository.upsert", new_callable=AsyncMock
|
"app.routers.radio.ContactRepository.upsert", new_callable=AsyncMock
|
||||||
) as mock_upsert,
|
) 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,
|
patch("app.routers.radio.broadcast_event") as mock_broadcast,
|
||||||
):
|
):
|
||||||
response = await discover_mesh(RadioDiscoveryRequest(target="repeaters"))
|
response = await discover_mesh(RadioDiscoveryRequest(target="repeaters"))
|
||||||
|
|
||||||
assert len(response.results) == 1
|
assert len(response.results) == 1
|
||||||
|
assert response.results[0].name is None # created_contact has no name
|
||||||
mock_get_by_key.assert_awaited()
|
mock_get_by_key.assert_awaited()
|
||||||
mock_upsert.assert_awaited_once()
|
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]
|
upsert_arg = mock_upsert.await_args.args[0]
|
||||||
assert upsert_arg.public_key == "44" * 32
|
assert upsert_arg.public_key == "44" * 32
|
||||||
assert upsert_arg.type == 2
|
assert upsert_arg.type == 2
|
||||||
@@ -510,6 +525,223 @@ class TestDiscoverMesh:
|
|||||||
mock_upsert.assert_not_awaited()
|
mock_upsert.assert_not_awaited()
|
||||||
mock_broadcast.assert_not_called()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_discovers_all_supported_types(self):
|
async def test_discovers_all_supported_types(self):
|
||||||
mc = _mock_meshcore_with_info()
|
mc = _mock_meshcore_with_info()
|
||||||
@@ -542,6 +774,11 @@ class TestDiscoverMesh:
|
|||||||
return_value=None,
|
return_value=None,
|
||||||
),
|
),
|
||||||
patch("app.routers.radio.ContactRepository.upsert", new_callable=AsyncMock),
|
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"),
|
patch("app.routers.radio.broadcast_event"),
|
||||||
):
|
):
|
||||||
response = await discover_mesh(RadioDiscoveryRequest(target="all"))
|
response = await discover_mesh(RadioDiscoveryRequest(target="all"))
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ from app.repository import ContactRepository
|
|||||||
from app.routers.contacts import request_trace
|
from app.routers.contacts import request_trace
|
||||||
from app.routers.repeaters import (
|
from app.routers.repeaters import (
|
||||||
_batch_cli_fetch,
|
_batch_cli_fetch,
|
||||||
_fetch_repeater_response,
|
|
||||||
prepare_repeater_connection,
|
prepare_repeater_connection,
|
||||||
repeater_acl,
|
repeater_acl,
|
||||||
repeater_advert_intervals,
|
repeater_advert_intervals,
|
||||||
@@ -25,12 +24,17 @@ from app.routers.repeaters import (
|
|||||||
repeater_status,
|
repeater_status,
|
||||||
send_repeater_command,
|
send_repeater_command,
|
||||||
)
|
)
|
||||||
|
from app.routers.server_control import fetch_contact_cli_response
|
||||||
|
|
||||||
KEY_A = "aa" * 32
|
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.
|
# 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)
|
@pytest.fixture(autouse=True)
|
||||||
@@ -104,8 +108,8 @@ def _advancing_clock(start=0.0, step=0.1):
|
|||||||
return _tick
|
return _tick
|
||||||
|
|
||||||
|
|
||||||
class TestFetchRepeaterResponse:
|
class TestFetchContactCliResponse:
|
||||||
"""Tests for the _fetch_repeater_response helper."""
|
"""Tests for the fetch_contact_cli_response helper."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_returns_matching_cli_response(self):
|
async def test_returns_matching_cli_response(self):
|
||||||
@@ -118,7 +122,7 @@ class TestFetchRepeaterResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with patch(_MONOTONIC, side_effect=_advancing_clock()):
|
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 is not None
|
||||||
assert result.payload["text"] == "ok"
|
assert result.payload["text"] == "ok"
|
||||||
@@ -138,16 +142,20 @@ class TestFetchRepeaterResponse:
|
|||||||
)
|
)
|
||||||
mc.commands.get_msg = AsyncMock(side_effect=[non_cli, cli_response])
|
mc.commands.get_msg = AsyncMock(side_effect=[non_cli, cli_response])
|
||||||
|
|
||||||
with patch(_MONOTONIC, side_effect=_advancing_clock()):
|
with (
|
||||||
result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=5.0)
|
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 is not None
|
||||||
assert result.payload["text"] == "ver 1.0"
|
assert result.payload["text"] == "ver 1.0"
|
||||||
assert mc.commands.get_msg.await_count == 2
|
assert mc.commands.get_msg.await_count == 2
|
||||||
|
store_dm.assert_awaited_once_with(non_cli)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_unrelated_dm_is_skipped(self):
|
async def test_unrelated_dm_is_stored(self):
|
||||||
"""Unrelated DMs are skipped (dispatcher already handled them)."""
|
"""Unrelated DMs consumed during CLI fetch are stored, not discarded."""
|
||||||
mc = _mock_mc()
|
mc = _mock_mc()
|
||||||
unrelated = _radio_result(
|
unrelated = _radio_result(
|
||||||
EventType.CONTACT_MSG_RECV,
|
EventType.CONTACT_MSG_RECV,
|
||||||
@@ -159,14 +167,18 @@ class TestFetchRepeaterResponse:
|
|||||||
)
|
)
|
||||||
mc.commands.get_msg = AsyncMock(side_effect=[unrelated, expected])
|
mc.commands.get_msg = AsyncMock(side_effect=[unrelated, expected])
|
||||||
|
|
||||||
with patch(_MONOTONIC, side_effect=_advancing_clock()):
|
with (
|
||||||
result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=5.0)
|
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 is not None
|
||||||
assert result.payload["text"] == "ver 1.0"
|
assert result.payload["text"] == "ver 1.0"
|
||||||
|
store_dm.assert_awaited_once_with(unrelated)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_channel_message_is_skipped(self):
|
async def test_channel_message_is_stored(self):
|
||||||
mc = _mock_mc()
|
mc = _mock_mc()
|
||||||
channel_msg = _radio_result(
|
channel_msg = _radio_result(
|
||||||
EventType.CHANNEL_MSG_RECV,
|
EventType.CHANNEL_MSG_RECV,
|
||||||
@@ -178,11 +190,15 @@ class TestFetchRepeaterResponse:
|
|||||||
)
|
)
|
||||||
mc.commands.get_msg = AsyncMock(side_effect=[channel_msg, expected])
|
mc.commands.get_msg = AsyncMock(side_effect=[channel_msg, expected])
|
||||||
|
|
||||||
with patch(_MONOTONIC, side_effect=_advancing_clock()):
|
with (
|
||||||
result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=5.0)
|
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 is not None
|
||||||
assert result.payload["text"] == "ok"
|
assert result.payload["text"] == "ok"
|
||||||
|
store_chan.assert_awaited_once_with(mc, channel_msg.payload)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_no_more_msgs_retries_then_succeeds(self):
|
async def test_no_more_msgs_retries_then_succeeds(self):
|
||||||
@@ -196,9 +212,9 @@ class TestFetchRepeaterResponse:
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
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 is not None
|
||||||
assert result.payload["text"] == "ok"
|
assert result.payload["text"] == "ok"
|
||||||
@@ -215,9 +231,9 @@ class TestFetchRepeaterResponse:
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
patch(_MONOTONIC, side_effect=times),
|
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
|
assert result is None
|
||||||
|
|
||||||
@@ -233,16 +249,16 @@ class TestFetchRepeaterResponse:
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
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 is not None
|
||||||
assert result.payload["text"] == "ok"
|
assert result.payload["text"] == "ok"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_high_traffic_does_not_exhaust_budget(self):
|
async def test_high_traffic_stores_all_consumed_messages(self):
|
||||||
"""Many unrelated messages don't prevent eventual success (wall-clock deadline)."""
|
"""Many unrelated messages are stored and don't prevent eventual success."""
|
||||||
mc = _mock_mc()
|
mc = _mock_mc()
|
||||||
# 20 unrelated DMs followed by the expected CLI response
|
# 20 unrelated DMs followed by the expected CLI response
|
||||||
unrelated = [
|
unrelated = [
|
||||||
@@ -258,12 +274,16 @@ class TestFetchRepeaterResponse:
|
|||||||
)
|
)
|
||||||
mc.commands.get_msg = AsyncMock(side_effect=[*unrelated, expected])
|
mc.commands.get_msg = AsyncMock(side_effect=[*unrelated, expected])
|
||||||
|
|
||||||
with patch(_MONOTONIC, side_effect=_advancing_clock()):
|
with (
|
||||||
result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=30.0)
|
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 is not None
|
||||||
assert result.payload["text"] == "ver 1.0"
|
assert result.payload["text"] == "ver 1.0"
|
||||||
assert mc.commands.get_msg.await_count == 21
|
assert mc.commands.get_msg.await_count == 21
|
||||||
|
assert store_dm.await_count == 20
|
||||||
|
|
||||||
|
|
||||||
class TestRepeaterCommandRoute:
|
class TestRepeaterCommandRoute:
|
||||||
@@ -297,7 +317,7 @@ class TestRepeaterCommandRoute:
|
|||||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||||
patch.object(radio_manager, "_meshcore", mc),
|
patch.object(radio_manager, "_meshcore", mc),
|
||||||
patch(_MONOTONIC, side_effect=[0.0, 5.0, 25.0]),
|
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"))
|
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("app.routers.repeaters.require_connected", return_value=mc),
|
||||||
patch.object(radio_manager, "_meshcore", mc),
|
patch.object(radio_manager, "_meshcore", mc),
|
||||||
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
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"))
|
response = await send_repeater_command(KEY_A, CommandRequest(command="ver"))
|
||||||
|
|
||||||
@@ -483,6 +503,11 @@ class TestTraceRoute:
|
|||||||
await request_trace(KEY_A)
|
await request_trace(KEY_A)
|
||||||
|
|
||||||
assert exc.value.status_code == 500
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_wait_timeout_returns_504(self, test_db):
|
async def test_wait_timeout_returns_504(self, test_db):
|
||||||
@@ -500,6 +525,11 @@ class TestTraceRoute:
|
|||||||
await request_trace(KEY_A)
|
await request_trace(KEY_A)
|
||||||
|
|
||||||
assert exc.value.status_code == 504
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_success_returns_remote_and_local_snr(self, test_db):
|
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.remote_snr == 5.5
|
||||||
assert response.local_snr == 3.2
|
assert response.local_snr == 3.2
|
||||||
assert response.path_len == 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("app.routers.repeaters.require_connected", return_value=mc),
|
||||||
patch.object(radio_manager, "_meshcore", mc),
|
patch.object(radio_manager, "_meshcore", mc),
|
||||||
patch(_MONOTONIC, side_effect=clock_ticks),
|
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)
|
response = await repeater_radio_settings(KEY_A)
|
||||||
|
|
||||||
@@ -1058,7 +1093,7 @@ class TestRepeaterNodeInfo:
|
|||||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||||
patch.object(radio_manager, "_meshcore", mc),
|
patch.object(radio_manager, "_meshcore", mc),
|
||||||
patch(_MONOTONIC, side_effect=clock_ticks),
|
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)
|
response = await repeater_node_info(KEY_A)
|
||||||
|
|
||||||
@@ -1111,7 +1146,7 @@ class TestRepeaterAdvertIntervals:
|
|||||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||||
patch.object(radio_manager, "_meshcore", mc),
|
patch.object(radio_manager, "_meshcore", mc),
|
||||||
patch(_MONOTONIC, side_effect=clock_ticks),
|
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)
|
response = await repeater_advert_intervals(KEY_A)
|
||||||
|
|
||||||
@@ -1166,7 +1201,7 @@ class TestRepeaterOwnerInfo:
|
|||||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||||
patch.object(radio_manager, "_meshcore", mc),
|
patch.object(radio_manager, "_meshcore", mc),
|
||||||
patch(_MONOTONIC, side_effect=clock_ticks),
|
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)
|
response = await repeater_owner_info(KEY_A)
|
||||||
|
|
||||||
@@ -1224,7 +1259,7 @@ class TestBatchCliFetch:
|
|||||||
with (
|
with (
|
||||||
patch.object(radio_manager, "_meshcore", mc),
|
patch.object(radio_manager, "_meshcore", mc),
|
||||||
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
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(
|
results = await _batch_cli_fetch(
|
||||||
contact, "test_op", [("bad_cmd", "field_a"), ("good_cmd", "field_b")]
|
contact, "test_op", [("bad_cmd", "field_a"), ("good_cmd", "field_b")]
|
||||||
@@ -1245,7 +1280,7 @@ class TestBatchCliFetch:
|
|||||||
with (
|
with (
|
||||||
patch.object(radio_manager, "_meshcore", mc),
|
patch.object(radio_manager, "_meshcore", mc),
|
||||||
patch(_MONOTONIC, side_effect=[0.0, 5.0, 11.0]),
|
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")])
|
results = await _batch_cli_fetch(contact, "test_op", [("clock", "clock_output")])
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"""Tests for the statistics repository and endpoint."""
|
"""Tests for the statistics repository and endpoint."""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -347,3 +349,75 @@ class TestPathHashWidthStats:
|
|||||||
assert breakdown["single_byte_pct"] == pytest.approx(100 / 3, rel=1e-3)
|
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["double_byte_pct"] == pytest.approx(100 / 3, rel=1e-3)
|
||||||
assert breakdown["triple_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
|
||||||
|
|||||||
@@ -1098,7 +1098,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "remoteterm-meshcore"
|
name = "remoteterm-meshcore"
|
||||||
version = "3.6.2"
|
version = "3.6.3"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiomqtt" },
|
{ name = "aiomqtt" },
|
||||||
|
|||||||
Reference in New Issue
Block a user