mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-07 22:05:14 +02:00
Compare commits
41 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 | |||
| 8ee08ff44a | |||
| 6d9ea552bd | |||
| 2cd71bf086 | |||
| 08d55dec72 | |||
| 20532f70a3 | |||
| 659370e1eb |
@@ -2,6 +2,8 @@
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
!scripts/build/
|
||||
!scripts/build/**
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
@@ -23,3 +25,9 @@ references/
|
||||
|
||||
# ancillary LLM files
|
||||
.claude/
|
||||
|
||||
# local Docker compose files
|
||||
docker-compose.yml
|
||||
docker-compose.yaml
|
||||
.docker-certs/
|
||||
.docker-nginx/
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
If instructed to "run all tests" or "get ready for a commit" or other summative, work ending directives, run:
|
||||
|
||||
```bash
|
||||
./scripts/all_quality.sh
|
||||
./scripts/quality/all_quality.sh
|
||||
```
|
||||
|
||||
This is the repo's end-to-end quality gate. It runs backend/frontend autofixers first, then type checking, tests, and the standard frontend build. All checks must pass green, and the script may leave formatting/lint edits behind.
|
||||
@@ -210,10 +210,16 @@ This message-layer echo/path handling is independent of raw-packet storage dedup
|
||||
│ │ └── ...
|
||||
│ └── vite.config.ts
|
||||
├── scripts/ # Quality / release helpers (listing below is representative, not exhaustive)
|
||||
│ ├── all_quality.sh # Repo-standard autofix + validate gate
|
||||
│ ├── collect_licenses.sh # Gather third-party license attributions
|
||||
│ ├── e2e.sh # End-to-end test runner
|
||||
│ └── publish.sh # Version bump, changelog, docker build & push
|
||||
│ ├── build/
|
||||
│ │ ├── collect_licenses.sh # Gather third-party license attributions
|
||||
│ │ └── publish.sh # Version bump, changelog, docker build & push
|
||||
│ ├── quality/
|
||||
│ │ ├── all_quality.sh # Repo-standard autofix + validate gate
|
||||
│ │ ├── e2e.sh # End-to-end test runner
|
||||
│ │ └── extended_quality.sh # Quality gate plus e2e and Docker matrix
|
||||
│ └── setup/
|
||||
│ ├── fetch_prebuilt_frontend.py # Download release frontend fallback
|
||||
│ └── install_service.sh # Install/configure Linux systemd service
|
||||
├── README_ADVANCED.md # Advanced setup, troubleshooting, and service guidance
|
||||
├── CONTRIBUTING.md # Contributor workflow and testing guidance
|
||||
├── tests/ # Backend tests (pytest)
|
||||
@@ -271,23 +277,23 @@ PYTHONPATH=. uv run pytest tests/ -v
|
||||
```
|
||||
|
||||
Key test files:
|
||||
- `tests/test_decoder.py` - Channel + direct message decryption, key exchange
|
||||
- `tests/test_keystore.py` - Ephemeral key store
|
||||
- `tests/test_event_handlers.py` - ACK tracking, repeat detection
|
||||
- `tests/test_packet_pipeline.py` - End-to-end packet processing
|
||||
- `tests/test_api.py` - API endpoints, read state tracking
|
||||
- `tests/test_migrations.py` - Database migration system
|
||||
- `tests/test_frontend_static.py` - Frontend static route registration (missing `dist`/`index.html` handling)
|
||||
- `tests/test_messages_search.py` - Message search, around endpoint, forward pagination
|
||||
- `tests/test_rx_log_data.py` - on_rx_log_data event handler integration
|
||||
- `tests/test_ack_tracking_wiring.py` - DM ACK tracking extraction and wiring
|
||||
- `tests/test_radio_lifecycle_service.py` - Radio reconnect/setup orchestration helpers
|
||||
- `tests/test_radio_commands_service.py` - Radio config/private-key service workflows
|
||||
- `tests/test_health_mqtt_status.py` - Health endpoint MQTT status field
|
||||
- `tests/test_community_mqtt.py` - Community MQTT publisher (JWT, packet format, hash, broadcast)
|
||||
- `tests/test_radio_sync.py` - Radio sync, periodic tasks, and contact offload back to the radio
|
||||
- `tests/test_real_crypto.py` - Real cryptographic operations
|
||||
- `tests/test_disable_bots.py` - MESHCORE_DISABLE_BOTS=true feature
|
||||
- `tests/test_api.py` - Broad API integration coverage across routers and read-state flows
|
||||
- `tests/test_packet_pipeline.py` - End-to-end packet processing, decrypt, dedup, and message creation
|
||||
- `tests/test_event_handlers.py` - ACK tracking, fallback DM handling, and event subscription cleanup
|
||||
- `tests/test_send_messages.py` - Outgoing DM/channel send workflows, retries, and bot-trigger wiring
|
||||
- `tests/test_packets_router.py` - Historical decrypt, maintenance, and raw-packet detail endpoints
|
||||
- `tests/test_repeater_routes.py` - Repeater command/telemetry/trace pane endpoints
|
||||
- `tests/test_room_routes.py` - Room-server login/status/ACL/telemetry endpoints
|
||||
- `tests/test_radio_router.py` - Radio config, advert, discovery, trace, and reconnect endpoints
|
||||
- `tests/test_radio_sync.py` - Radio sync, periodic tasks, contact offload/reload, and pending-message flushes
|
||||
- `tests/test_fanout.py` - Fanout config CRUD, scope matching, and manager dispatch
|
||||
- `tests/test_fanout_integration.py` - Integration-module lifecycle and delivery behavior
|
||||
- `tests/test_statistics.py` - Aggregated mesh/network statistics and noise-floor snapshots
|
||||
- `tests/test_version_info.py` - Version/build metadata resolution
|
||||
- `tests/test_websocket.py` - WS manager broadcast and cleanup behavior
|
||||
- `tests/test_frontend_static.py` - Frontend static route registration and fallback behavior
|
||||
|
||||
For the fuller backend inventory, see `app/AGENTS.md`. For frontend-specific suites, see `frontend/AGENTS.md`.
|
||||
|
||||
### Frontend (Vitest)
|
||||
|
||||
@@ -298,7 +304,7 @@ npm run test:run
|
||||
|
||||
### Before Completing Major Changes
|
||||
|
||||
**Run `./scripts/all_quality.sh` before finishing major changes that have modified code or tests.** It is the standard repo gate: autofix first, then type checks, tests, and the standard frontend build. This is not necessary for docs-only changes. For minor changes (like wording, color, spacing, etc.), wait until prompted to run the quality gate.
|
||||
**Run `./scripts/quality/all_quality.sh` before finishing major changes that have modified code or tests.** It is the standard repo gate: autofix first, then type checks, tests, and the standard frontend build. This is not necessary for docs-only changes. For minor changes (like wording, color, spacing, etc.), wait until prompted to run the quality gate.
|
||||
|
||||
## API Summary
|
||||
|
||||
@@ -313,6 +319,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
| PUT | `/api/radio/private-key` | Import private key to radio |
|
||||
| POST | `/api/radio/advertise` | Send advertisement (`mode`: `flood` or `zero_hop`, default `flood`) |
|
||||
| POST | `/api/radio/discover` | Run a short mesh discovery sweep for nearby repeaters/sensors |
|
||||
| POST | `/api/radio/trace` | Send a multi-hop trace loop through known repeaters and back to the local radio |
|
||||
| POST | `/api/radio/reboot` | Reboot radio or reconnect if disconnected |
|
||||
| POST | `/api/radio/disconnect` | Disconnect from radio and pause automatic reconnect attempts |
|
||||
| POST | `/api/radio/reconnect` | Manual radio reconnection |
|
||||
@@ -335,6 +342,10 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
| POST | `/api/contacts/{public_key}/repeater/radio-settings` | Fetch repeater radio config via CLI |
|
||||
| POST | `/api/contacts/{public_key}/repeater/advert-intervals` | Fetch advert intervals |
|
||||
| POST | `/api/contacts/{public_key}/repeater/owner-info` | Fetch owner info |
|
||||
| POST | `/api/contacts/{public_key}/room/login` | Log in to a room server |
|
||||
| POST | `/api/contacts/{public_key}/room/status` | Fetch room-server status telemetry |
|
||||
| POST | `/api/contacts/{public_key}/room/lpp-telemetry` | Fetch room-server CayenneLPP sensor data |
|
||||
| POST | `/api/contacts/{public_key}/room/acl` | Fetch room-server ACL entries |
|
||||
|
||||
| GET | `/api/channels` | List channels |
|
||||
| GET | `/api/channels/{key}/detail` | Comprehensive channel profile (message stats, top senders) |
|
||||
@@ -348,6 +359,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
| POST | `/api/messages/channel` | Send channel message |
|
||||
| POST | `/api/messages/channel/{message_id}/resend` | Resend channel message (default: byte-perfect within 30s; `?new_timestamp=true`: fresh timestamp, no time limit, creates new message row) |
|
||||
| GET | `/api/packets/undecrypted/count` | Count of undecrypted packets |
|
||||
| GET | `/api/packets/{packet_id}` | Fetch one stored raw packet by row ID for on-demand inspection |
|
||||
| POST | `/api/packets/decrypt/historical` | Decrypt stored packets |
|
||||
| POST | `/api/packets/maintenance` | Delete old packets and vacuum |
|
||||
| GET | `/api/read-state/unreads` | Server-computed unread counts, mentions, last message times, and `last_read_ats` boundaries |
|
||||
@@ -362,6 +374,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
| POST | `/api/fanout` | Create new fanout config |
|
||||
| PATCH | `/api/fanout/{id}` | Update fanout config (triggers module reload) |
|
||||
| DELETE | `/api/fanout/{id}` | Delete fanout config (stops module) |
|
||||
| POST | `/api/fanout/bots/disable-until-restart` | Stop bot fanout modules and keep bots disabled until the process restarts |
|
||||
| GET | `/api/statistics` | Aggregated mesh network statistics |
|
||||
| WS | `/api/ws` | Real-time updates |
|
||||
|
||||
|
||||
@@ -1,3 +1,26 @@
|
||||
## [3.6.3] - 2026-03-30
|
||||
|
||||
Feature: Add multi-byte trace
|
||||
Feature: Show node name on discovered node if we know it
|
||||
Feature: Add docker installation script
|
||||
Feature: Add historical noise floor to stats
|
||||
Feature: Add trace tool
|
||||
Bugfix: 100x performance on statistics endpoint with indices and better queries
|
||||
Misc: Performance and correctness improvements for backend-of-the-frontend
|
||||
Misc: Reorganize scripts
|
||||
|
||||
## [3.6.2] - 2026-03-29
|
||||
|
||||
Feature: Be more flexible about timing and volume of full contact offload
|
||||
Feature: Improve room server and repeater ops to be much more clearer about auth status
|
||||
Feature: Show last error status on integrations
|
||||
Feature: Push multi-platform docker builds
|
||||
Bugfix: Fix advert interval time unit display
|
||||
Bugfix: Don't cast RSSI/SNR to string for community MQTT
|
||||
Bugfix: Map uploader follows redirect
|
||||
Misc: Thin out unnecessary cruft in unreads endpoint
|
||||
Misc: Fall back gracefully if linked to an unknown contact
|
||||
|
||||
## [3.6.1] - 2026-03-26
|
||||
|
||||
Feature: MeshCore Map integration
|
||||
|
||||
+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:
|
||||
|
||||
```bash
|
||||
./scripts/all_quality.sh
|
||||
./scripts/quality/all_quality.sh
|
||||
```
|
||||
|
||||
That runs linting, formatting, type checking, tests, and builds for both backend and frontend.
|
||||
@@ -78,6 +78,7 @@ These tests are only guaranteed to run correctly in a narrow subset of environme
|
||||
|
||||
```bash
|
||||
cd tests/e2e
|
||||
npm install
|
||||
npx playwright test # headless
|
||||
npx playwright test --headed # you can probably guess
|
||||
```
|
||||
|
||||
+32
-1
@@ -1,6 +1,6 @@
|
||||
# Third-Party Licenses
|
||||
|
||||
Auto-generated by `scripts/collect_licenses.sh` — do not edit by hand.
|
||||
Auto-generated by `scripts/build/collect_licenses.sh` — do not edit by hand.
|
||||
|
||||
## Backend (Python) Dependencies
|
||||
|
||||
@@ -1625,6 +1625,37 @@ THE SOFTWARE.
|
||||
|
||||
</details>
|
||||
|
||||
### recharts (3.8.1) — MIT
|
||||
|
||||
<details>
|
||||
<summary>Full license text</summary>
|
||||
|
||||
```
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015-present recharts
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### sonner (2.0.7) — MIT
|
||||
|
||||
<details>
|
||||
|
||||
@@ -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
|
||||
|
||||
Most users should choose one of these paths:
|
||||
@@ -95,65 +89,83 @@ Access the app at http://localhost:8000.
|
||||
|
||||
Source checkouts expect a normal frontend build in `frontend/dist`.
|
||||
|
||||
On Linux, if you want this installed as a persistent `systemd` service that starts on boot and restarts automatically on failure, run `bash scripts/install_service.sh` from the repo root.
|
||||
> [!NOTE]
|
||||
> Running on lightweight hardware, or just do not want to build the frontend locally? From a cloned checkout, run `python3 scripts/setup/fetch_prebuilt_frontend.py` to fetch and unpack a prebuilt frontend into `frontend/prebuilt`, then start the app normally with `uv run uvicorn app.main:app --host 0.0.0.0 --port 8000`.
|
||||
|
||||
## Path 1.5: Use The Prebuilt Release Zip
|
||||
|
||||
Release zips can be found as an asset within the [releases listed here](https://github.com/jkingsman/Remote-Terminal-for-MeshCore/releases). This can be beneficial on resource constrained systems that cannot cope with the RAM-hungry frontend build process.
|
||||
|
||||
If you downloaded the release zip instead of cloning the repo, unpack it and run:
|
||||
|
||||
```bash
|
||||
cd Remote-Terminal-for-MeshCore
|
||||
uv sync
|
||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
The release bundle includes `frontend/prebuilt`, so it does not require a local frontend build.
|
||||
|
||||
Alternatively, if you have already cloned the repo, you can fetch just the prebuilt frontend into your working tree without downloading the full release zip via `python3 scripts/fetch_prebuilt_frontend.py`.
|
||||
> [!TIP]
|
||||
> On Linux, you can also install RemoteTerm as a persistent `systemd` service that starts on boot and restarts automatically on failure:
|
||||
>
|
||||
> ```bash
|
||||
> bash scripts/setup/install_service.sh
|
||||
> ```
|
||||
>
|
||||
> For the full service workflow and post-install operations, see [README_ADVANCED.md](README_ADVANCED.md).
|
||||
|
||||
## Path 2: Docker
|
||||
|
||||
> **Warning:** Docker has had reports intermittent issues with serial event subscriptions. The native method above is more reliable.
|
||||
|
||||
Edit `docker-compose.yaml` to set a serial device for passthrough, or uncomment your transport (serial or TCP). Then:
|
||||
Local Docker builds are architecture-native by default. On Apple Silicon Macs and ARM64 Linux hosts such as Raspberry Pi, `docker compose build` / `docker compose up --build` will produce an ARM64 image unless you override the platform.
|
||||
|
||||
For serial-device passthrough, use rootful Docker. In practice that usually means starting the stack with `sudo docker compose ...` unless your Docker daemon is already configured for rootful access via your user/group. Rootless Docker has been observed to fail on serial-device mappings even when the compose file itself is correct.
|
||||
|
||||
Create a local `docker-compose.yml` in one of two ways:
|
||||
|
||||
1. Copy the example file and edit it by hand:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
cp docker-compose.example.yml docker-compose.yml
|
||||
```
|
||||
|
||||
The database is stored in `./data/` (bind-mounted), so the container shares the same database as the native app. To rebuild after pulling updates:
|
||||
2. Or generate one interactively:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
bash scripts/setup/install_docker.sh
|
||||
```
|
||||
|
||||
To use the prebuilt Docker Hub image instead of building locally, replace:
|
||||
Your local `docker-compose.yml` is gitignored so future pulls do not overwrite your Docker settings.
|
||||
|
||||
The guided Docker flow can collect BLE settings, but BLE access from Docker still needs manual compose customization such as Bluetooth passthrough and possibly privileged mode or host networking. If you want the simpler path for BLE, use the regular Python launch flow instead.
|
||||
|
||||
Then customize the local compose file for your transport and launch:
|
||||
|
||||
```bash
|
||||
sudo docker compose up # add -d for background once you validate it's working
|
||||
```
|
||||
|
||||
The database is stored in `./data/` (bind-mounted), so the container shares the same database as the native app.
|
||||
|
||||
To rebuild after pulling updates:
|
||||
|
||||
```bash
|
||||
sudo docker compose pull
|
||||
sudo docker compose up -d
|
||||
```
|
||||
|
||||
The example file and setup script default to the published Docker Hub image. To build locally from your checkout instead, replace:
|
||||
|
||||
```yaml
|
||||
build: .
|
||||
image: docker.io/jkingsman/remoteterm-meshcore:latest
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```yaml
|
||||
image: jkingsman/remoteterm-meshcore:latest
|
||||
build: .
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
sudo docker compose up -d --build
|
||||
```
|
||||
|
||||
The container runs as root by default for maximum serial passthrough compatibility across host setups. On Linux, if you switch between native and Docker runs, `./data` can end up root-owned. If you do not need that serial compatibility behavior, you can enable the optional `user: "${UID:-1000}:${GID:-1000}"` line in `docker-compose.yaml` to keep ownership aligned with your host user.
|
||||
The container runs as root by default for maximum serial passthrough compatibility across host setups. On Linux, if you switch between native and Docker runs, `./data` can end up root-owned. If you do not need that serial compatibility behavior, you can enable the optional `user: "${UID:-1000}:${GID:-1000}"` line in `docker-compose.yml` to keep ownership aligned with your host user.
|
||||
|
||||
To stop:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
sudo docker compose down
|
||||
```
|
||||
|
||||
## Standard Environment Variables
|
||||
@@ -201,3 +213,9 @@ If you enable Basic Auth, protect the app with HTTPS. HTTP Basic credentials are
|
||||
- Advanced setup, troubleshooting, HTTPS, systemd, remediation variables, and debug logging: [README_ADVANCED.md](README_ADVANCED.md)
|
||||
- Contributing, tests, linting, E2E notes, and important AGENTS files: [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
- Live API docs after the backend is running: http://localhost:8000/docs
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This is developed with very heavy agentic assistance -- there is no warranty of fitness for any purpose. It's been lovingly guided by an engineer with a passion for clean code and good tests, but it's still mostly LLM output, so you may find some bugs.
|
||||
|
||||
If extending, have your LLM read the three `AGENTS.md` files: `./AGENTS.md`, `./frontend/AGENTS.md`, and `./app/AGENTS.md`.
|
||||
|
||||
+1
-26
@@ -46,39 +46,14 @@ Accept the browser warning, or use [mkcert](https://github.com/FiloSottile/mkcer
|
||||
|
||||
## Systemd Service
|
||||
|
||||
Two paths are available depending on your comfort level with Linux system administration.
|
||||
|
||||
### Simple install (recommended for most users)
|
||||
|
||||
On Linux systems, this is the recommended installation method if you want RemoteTerm set up as a persistent systemd service that starts automatically on boot and restarts automatically if it crashes. Run the installer script from the repo root. It runs as your current user, installs from wherever you cloned the repo, and prints a quick-reference cheatsheet when done — no separate service account or path juggling required.
|
||||
|
||||
```bash
|
||||
bash scripts/install_service.sh
|
||||
bash scripts/setup/install_service.sh
|
||||
```
|
||||
|
||||
The script interactively asks which transport to use (serial auto-detect, serial with explicit port, TCP, or BLE), whether to build the frontend locally or download a prebuilt copy, whether to enable the bot system, and whether to set up HTTP Basic Auth. It handles dependency installation (`uv sync`), validates `node`/`npm` for local builds, adds your user to the `dialout` group if needed, writes the systemd unit file, and enables the service. After installation, normal operations work without any `sudo -u` gymnastics:
|
||||
|
||||
You can also rerun the script later to change transport, bot, or auth settings. If the service is already running, the installer stops it, rewrites the unit file, reloads systemd, and starts it again with the new configuration.
|
||||
|
||||
```bash
|
||||
# Update to latest and restart
|
||||
cd /path/to/repo
|
||||
git pull
|
||||
uv sync
|
||||
cd frontend && npm install && npm run build && cd ..
|
||||
sudo systemctl restart remoteterm
|
||||
|
||||
# Refresh prebuilt frontend only (skips local build)
|
||||
python3 scripts/fetch_prebuilt_frontend.py
|
||||
sudo systemctl restart remoteterm
|
||||
|
||||
# View live logs
|
||||
sudo journalctl -u remoteterm -f
|
||||
|
||||
# Service control
|
||||
sudo systemctl start|stop|restart|status remoteterm
|
||||
```
|
||||
|
||||
## Debug Logging And Bug Reports
|
||||
|
||||
If you're experiencing issues or opening a bug report, please start the backend with debug logging enabled. Debug mode provides a much more detailed breakdown of radio communication, packet processing, and other internal operations, which makes it significantly easier to diagnose problems.
|
||||
|
||||
+26
-10
@@ -25,18 +25,22 @@ Keep it aligned with `app/` source files and router behavior.
|
||||
app/
|
||||
├── main.py # App startup/lifespan, router registration, static frontend mounting
|
||||
├── config.py # Env-driven runtime settings
|
||||
├── channel_constants.py # Public/default channel constants shared across sync/send logic
|
||||
├── database.py # SQLite connection + base schema + migration runner
|
||||
├── migrations.py # Schema migrations (SQLite user_version)
|
||||
├── models.py # Pydantic request/response models and typed write contracts (for example ContactUpsert)
|
||||
├── version_info.py # Unified version/build metadata resolution for debug + startup surfaces
|
||||
├── repository/ # Data access layer (contacts, channels, messages, raw_packets, settings, fanout)
|
||||
├── services/ # Shared orchestration/domain services
|
||||
│ ├── messages.py # Shared message creation, dedup, ACK application
|
||||
│ ├── message_send.py # Direct send, channel send, resend workflows
|
||||
│ ├── dm_ingest.py # Shared direct-message ingest / dedup seam for packet + fallback paths
|
||||
│ ├── dm_ack_apply.py # Shared DM ACK application over pending/buffered ACK state
|
||||
│ ├── dm_ack_tracker.py # Pending DM ACK state
|
||||
│ ├── contact_reconciliation.py # Prefix-claim, sender-key backfill, name-history wiring
|
||||
│ ├── radio_lifecycle.py # Post-connect setup and reconnect/setup helpers
|
||||
│ ├── radio_commands.py # Radio config/private-key command workflows
|
||||
│ ├── radio_noise_floor.py # In-memory local radio noise-floor sampling/history
|
||||
│ └── radio_runtime.py # Router/dependency seam over the global RadioManager
|
||||
├── radio.py # RadioManager transport/session state + lock management
|
||||
├── radio_sync.py # Polling, sync, periodic advertisement loop
|
||||
@@ -61,6 +65,8 @@ app/
|
||||
├── messages.py
|
||||
├── packets.py
|
||||
├── read_state.py
|
||||
├── rooms.py
|
||||
├── server_control.py
|
||||
├── settings.py
|
||||
├── fanout.py
|
||||
├── repeaters.py
|
||||
@@ -174,6 +180,7 @@ app/
|
||||
- `PUT /radio/private-key`
|
||||
- `POST /radio/advertise` — manual advert send; request body may set `mode` to `flood` or `zero_hop` (defaults to `flood`)
|
||||
- `POST /radio/discover` — short mesh discovery sweep for nearby repeaters/sensors
|
||||
- `POST /radio/trace` — send a multi-hop trace loop through known repeaters and back to the local radio
|
||||
- `POST /radio/disconnect`
|
||||
- `POST /radio/reboot`
|
||||
- `POST /radio/reconnect`
|
||||
@@ -198,6 +205,10 @@ app/
|
||||
- `POST /contacts/{public_key}/repeater/radio-settings`
|
||||
- `POST /contacts/{public_key}/repeater/advert-intervals`
|
||||
- `POST /contacts/{public_key}/repeater/owner-info`
|
||||
- `POST /contacts/{public_key}/room/login`
|
||||
- `POST /contacts/{public_key}/room/status`
|
||||
- `POST /contacts/{public_key}/room/lpp-telemetry`
|
||||
- `POST /contacts/{public_key}/room/acl`
|
||||
|
||||
### Channels
|
||||
- `GET /channels`
|
||||
@@ -216,6 +227,7 @@ app/
|
||||
|
||||
### Packets
|
||||
- `GET /packets/undecrypted/count`
|
||||
- `GET /packets/{packet_id}` — fetch one stored raw packet by row ID for on-demand inspection
|
||||
- `POST /packets/decrypt/historical`
|
||||
- `POST /packets/maintenance`
|
||||
|
||||
@@ -236,6 +248,7 @@ app/
|
||||
- `POST /fanout` — create new fanout config
|
||||
- `PATCH /fanout/{id}` — update fanout config (triggers module reload)
|
||||
- `DELETE /fanout/{id}` — delete fanout config (stops module)
|
||||
- `POST /fanout/bots/disable-until-restart` — stop bot modules and keep bots disabled until restart
|
||||
|
||||
### Statistics
|
||||
- `GET /statistics` — aggregated mesh network stats (entity counts, message/packet splits, activity windows, busiest channels)
|
||||
@@ -322,9 +335,11 @@ tests/
|
||||
├── conftest.py # Shared fixtures
|
||||
├── test_ack_tracking_wiring.py # DM ACK tracking extraction and wiring
|
||||
├── test_api.py # REST endpoint integration tests
|
||||
├── test_block_lists.py # Blocked keys/names filtering across list/search surfaces
|
||||
├── test_bot.py # Bot execution and sandboxing
|
||||
├── test_channels_router.py # Channels router endpoints
|
||||
├── test_channel_sender_backfill.py # Sender-key backfill uniqueness rules for channel messages
|
||||
├── test_channels_router.py # Channels router endpoints
|
||||
├── test_community_mqtt.py # Community MQTT publisher (JWT, packet format, hash, broadcast)
|
||||
├── test_config.py # Configuration validation
|
||||
├── test_contact_reconciliation_service.py # Prefix/contact reconciliation service helpers
|
||||
├── test_contacts_router.py # Contacts router endpoints
|
||||
@@ -332,40 +347,41 @@ tests/
|
||||
├── test_disable_bots.py # MESHCORE_DISABLE_BOTS=true feature
|
||||
├── test_echo_dedup.py # Echo/repeat deduplication (incl. concurrent)
|
||||
├── test_fanout.py # Fanout bus CRUD, scope matching, manager dispatch
|
||||
├── test_fanout_integration.py # Fanout integration tests
|
||||
├── test_fanout_hitlist.py # Fanout-related hitlist regression tests
|
||||
├── test_fanout_integration.py # Fanout integration tests
|
||||
├── test_event_handlers.py # ACK tracking, event registration, cleanup
|
||||
├── test_frontend_static.py # Frontend static file serving
|
||||
├── test_health_mqtt_status.py # Health endpoint MQTT status field
|
||||
├── test_http_quality.py # Cache-control / gzip / basic-auth HTTP quality checks
|
||||
├── test_key_normalization.py # Public key normalization
|
||||
├── test_keystore.py # Ephemeral keystore
|
||||
├── test_main_startup.py # App startup and lifespan
|
||||
├── test_map_upload.py # Map upload fanout module
|
||||
├── test_message_pagination.py # Cursor-based message pagination
|
||||
├── test_message_prefix_claim.py # Message prefix claim logic
|
||||
├── test_migrations.py # Schema migration system
|
||||
├── test_community_mqtt.py # Community MQTT publisher (JWT, packet format, hash, broadcast)
|
||||
├── test_mqtt.py # MQTT publisher topic routing and lifecycle
|
||||
├── test_messages_search.py # Message search, around, forward pagination
|
||||
├── test_migrations.py # Schema migration system
|
||||
├── test_packet_pipeline.py # End-to-end packet processing
|
||||
├── test_packets_router.py # Packets router endpoints (decrypt, maintenance)
|
||||
├── test_path_utils.py # Path hex rendering helpers
|
||||
├── test_radio.py # RadioManager, serial detection
|
||||
├── test_radio_commands_service.py # Radio config/private-key service workflows
|
||||
├── test_radio_lifecycle_service.py # Reconnect/setup orchestration helpers
|
||||
├── test_radio_runtime_service.py # radio_runtime seam behavior and helpers
|
||||
├── test_real_crypto.py # Real cryptographic operations
|
||||
├── test_radio_operation.py # radio_operation() context manager
|
||||
├── test_radio_router.py # Radio router endpoints
|
||||
├── test_radio_runtime_service.py # radio_runtime seam behavior and helpers
|
||||
├── test_radio_sync.py # Polling, sync, advertisement
|
||||
├── test_real_crypto.py # Real cryptographic operations
|
||||
├── test_repeater_routes.py # Repeater command/telemetry/trace + granular pane endpoints
|
||||
├── test_repository.py # Data access layer
|
||||
├── test_room_routes.py # Room-server login/status/telemetry/ACL endpoints
|
||||
├── test_rx_log_data.py # on_rx_log_data event handler integration
|
||||
├── test_messages_search.py # Message search, around, forward pagination
|
||||
├── test_block_lists.py # Blocked keys/names filtering
|
||||
├── test_security.py # Optional Basic Auth middleware / config behavior
|
||||
├── test_send_messages.py # Outgoing messages, bot triggers, concurrent sends
|
||||
├── test_settings_router.py # Settings endpoints, advert validation
|
||||
├── test_statistics.py # Statistics aggregation
|
||||
├── test_main_startup.py # App startup and lifespan
|
||||
├── test_path_utils.py # Path hex rendering helpers
|
||||
├── test_version_info.py # Version/build metadata resolution
|
||||
├── test_websocket.py # WS manager broadcast/cleanup
|
||||
└── test_websocket_route.py # WS endpoint lifecycle
|
||||
```
|
||||
|
||||
@@ -96,8 +96,12 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_dedup_null_safe
|
||||
ON messages(type, conversation_key, text, COALESCE(sender_timestamp, 0))
|
||||
WHERE type = 'CHAN';
|
||||
CREATE INDEX IF NOT EXISTS idx_raw_packets_message_id ON raw_packets(message_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_raw_packets_timestamp ON raw_packets(timestamp);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_packets_payload_hash ON raw_packets(payload_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_contacts_on_radio ON contacts(on_radio);
|
||||
CREATE INDEX IF NOT EXISTS idx_contacts_type_last_seen ON contacts(type, last_seen);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_type_received_conversation
|
||||
ON messages(type, received_at, conversation_key);
|
||||
-- idx_messages_sender_key is created by migration 25 (after adding the sender_key column)
|
||||
-- idx_messages_incoming_priv_dedup is created by migration 44 after legacy rows are reconciled
|
||||
CREATE INDEX IF NOT EXISTS idx_contact_advert_paths_recent
|
||||
|
||||
@@ -30,8 +30,6 @@ logger = logging.getLogger(__name__)
|
||||
# Track active subscriptions so we can unsubscribe before re-registering
|
||||
# This prevents handler duplication after reconnects
|
||||
_active_subscriptions: list["Subscription"] = []
|
||||
_pending_acks = dm_ack_tracker._pending_acks
|
||||
_buffered_acks = dm_ack_tracker._buffered_acks
|
||||
|
||||
|
||||
def track_pending_ack(expected_ack: str, message_id: int, timeout_ms: int) -> bool:
|
||||
|
||||
@@ -95,7 +95,6 @@ class AppriseModule(FanoutModule):
|
||||
|
||||
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
|
||||
super().__init__(config_id, config, name=name)
|
||||
self._last_error: str | None = None
|
||||
|
||||
async def on_message(self, data: dict) -> None:
|
||||
# Skip outgoing messages — only notify on incoming
|
||||
@@ -114,17 +113,17 @@ class AppriseModule(FanoutModule):
|
||||
success = await asyncio.to_thread(
|
||||
_send_sync, urls, body, preserve_identity=preserve_identity
|
||||
)
|
||||
self._last_error = None if success else "Apprise notify returned failure"
|
||||
self._set_last_error(None if success else "Apprise notify returned failure")
|
||||
if not success:
|
||||
logger.warning("Apprise notification failed for module %s", self.config_id)
|
||||
except Exception as exc:
|
||||
self._last_error = str(exc)
|
||||
self._set_last_error(str(exc))
|
||||
logger.exception("Apprise send error for module %s", self.config_id)
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
if not self.config.get("urls", "").strip():
|
||||
return "disconnected"
|
||||
if self._last_error:
|
||||
if self.last_error:
|
||||
return "error"
|
||||
return "connected"
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def _broadcast_fanout_health() -> None:
|
||||
"""Push updated fanout status to connected frontend clients."""
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
from app.websocket import broadcast_health
|
||||
|
||||
broadcast_health(radio_manager.is_connected, radio_manager.connection_info)
|
||||
|
||||
|
||||
class FanoutModule:
|
||||
"""Base class for all fanout integrations.
|
||||
|
||||
@@ -16,6 +24,7 @@ class FanoutModule:
|
||||
self.config_id = config_id
|
||||
self.config = config
|
||||
self.name = name
|
||||
self._last_error: str | None = None
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the module (e.g. connect to broker). Override for persistent connections."""
|
||||
@@ -34,6 +43,18 @@ class FanoutModule:
|
||||
"""Return 'connected', 'disconnected', or 'error'."""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def last_error(self) -> str | None:
|
||||
"""Return the most recent retained operator-facing error, if any."""
|
||||
return self._last_error
|
||||
|
||||
def _set_last_error(self, value: str | None) -> None:
|
||||
"""Update the retained error and broadcast health when it changes."""
|
||||
if self._last_error == value:
|
||||
return
|
||||
self._last_error = value
|
||||
_broadcast_fanout_health()
|
||||
|
||||
|
||||
def get_fanout_message_text(data: dict) -> str:
|
||||
"""Return the best human-readable message body for fanout consumers.
|
||||
|
||||
@@ -175,11 +175,12 @@ def _format_raw_packet(data: dict[str, Any], device_name: str, public_key_hex: s
|
||||
current_time = datetime.now()
|
||||
ts_str = current_time.isoformat()
|
||||
|
||||
# SNR/RSSI are always strings in reference output.
|
||||
# Keep numeric telemetry numeric so downstream analyzers can ingest it.
|
||||
# Preserve the existing "Unknown" fallback for missing values.
|
||||
snr_val = data.get("snr")
|
||||
rssi_val = data.get("rssi")
|
||||
snr = str(snr_val) if snr_val is not None else "Unknown"
|
||||
rssi = str(rssi_val) if rssi_val is not None else "Unknown"
|
||||
snr: float | str = float(snr_val) if snr_val is not None else "Unknown"
|
||||
rssi: int | str = int(rssi_val) if rssi_val is not None else "Unknown"
|
||||
|
||||
packet_hash = _calculate_packet_hash(raw_bytes)
|
||||
|
||||
|
||||
+63
-6
@@ -15,6 +15,14 @@ _DISPATCH_TIMEOUT_SECONDS = 30.0
|
||||
_MODULE_TYPES: dict[str, type] = {}
|
||||
|
||||
|
||||
def _format_error_detail(exc: Exception) -> str:
|
||||
"""Return a short operator-facing error string."""
|
||||
message = str(exc).strip()
|
||||
if message:
|
||||
return f"{type(exc).__name__}: {message}"
|
||||
return type(exc).__name__
|
||||
|
||||
|
||||
def _register_module_types() -> None:
|
||||
"""Lazily populate the type registry to avoid circular imports."""
|
||||
if _MODULE_TYPES:
|
||||
@@ -85,6 +93,23 @@ class FanoutManager:
|
||||
self._modules: dict[str, tuple[FanoutModule, dict]] = {} # id -> (module, scope)
|
||||
self._restart_locks: dict[str, asyncio.Lock] = {}
|
||||
self._bots_disabled_until_restart = False
|
||||
self._module_errors: dict[str, str] = {}
|
||||
|
||||
def _broadcast_health_update(self) -> None:
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
from app.websocket import broadcast_health
|
||||
|
||||
broadcast_health(radio_manager.is_connected, radio_manager.connection_info)
|
||||
|
||||
def _set_module_error(self, config_id: str, error: str) -> None:
|
||||
if self._module_errors.get(config_id) == error:
|
||||
return
|
||||
self._module_errors[config_id] = error
|
||||
self._broadcast_health_update()
|
||||
|
||||
def _clear_module_error(self, config_id: str) -> None:
|
||||
if self._module_errors.pop(config_id, None) is not None:
|
||||
self._broadcast_health_update()
|
||||
|
||||
def get_bots_disabled_source(self) -> str | None:
|
||||
"""Return why bot modules are unavailable, if at all."""
|
||||
@@ -134,11 +159,13 @@ class FanoutManager:
|
||||
module = cls(config_id, config_blob, name=cfg.get("name", ""))
|
||||
await module.start()
|
||||
self._modules[config_id] = (module, scope)
|
||||
self._clear_module_error(config_id)
|
||||
logger.info(
|
||||
"Started fanout module %s (type=%s)", cfg.get("name", config_id), config_type
|
||||
)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to start fanout module %s", config_id)
|
||||
self._set_module_error(config_id, _format_error_detail(exc))
|
||||
|
||||
async def reload_config(self, config_id: str) -> None:
|
||||
"""Stop old module (if any) and start updated config."""
|
||||
@@ -162,6 +189,7 @@ class FanoutManager:
|
||||
await module.stop()
|
||||
except Exception:
|
||||
logger.exception("Error stopping fanout module %s", config_id)
|
||||
self._clear_module_error(config_id)
|
||||
|
||||
async def _dispatch_matching(
|
||||
self,
|
||||
@@ -191,7 +219,10 @@ class FanoutManager:
|
||||
try:
|
||||
handler = getattr(module, handler_name)
|
||||
await asyncio.wait_for(handler(data), timeout=_DISPATCH_TIMEOUT_SECONDS)
|
||||
self._clear_module_error(config_id)
|
||||
except asyncio.TimeoutError:
|
||||
timeout_error = f"{handler_name} timed out after {_DISPATCH_TIMEOUT_SECONDS:.1f}s"
|
||||
self._set_module_error(config_id, timeout_error)
|
||||
logger.error(
|
||||
"Fanout %s %s timed out after %.1fs; restarting module",
|
||||
config_id,
|
||||
@@ -199,7 +230,8 @@ class FanoutManager:
|
||||
_DISPATCH_TIMEOUT_SECONDS,
|
||||
)
|
||||
await self._restart_module(config_id, module)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
self._set_module_error(config_id, _format_error_detail(exc))
|
||||
logger.exception("Fanout %s %s error", config_id, log_label)
|
||||
|
||||
async def _restart_module(self, config_id: str, module: FanoutModule) -> None:
|
||||
@@ -215,6 +247,10 @@ class FanoutManager:
|
||||
except Exception:
|
||||
logger.exception("Failed to restart timed-out fanout module %s", config_id)
|
||||
self._modules.pop(config_id, None)
|
||||
self._set_module_error(
|
||||
config_id,
|
||||
"Module restart failed after timeout",
|
||||
)
|
||||
|
||||
async def broadcast_message(self, data: dict) -> None:
|
||||
"""Dispatch a decoded message to modules whose scope matches."""
|
||||
@@ -243,18 +279,39 @@ class FanoutManager:
|
||||
logger.exception("Error stopping fanout module %s", config_id)
|
||||
self._modules.clear()
|
||||
self._restart_locks.clear()
|
||||
self._module_errors.clear()
|
||||
|
||||
def get_statuses(self) -> dict[str, dict[str, str]]:
|
||||
def get_statuses(self) -> dict[str, dict[str, str | None]]:
|
||||
"""Return status info for each active module."""
|
||||
from app.repository.fanout import _configs_cache
|
||||
|
||||
result: dict[str, dict[str, str]] = {}
|
||||
for config_id, (module, _) in self._modules.items():
|
||||
result: dict[str, dict[str, str | None]] = {}
|
||||
all_ids = set(_configs_cache) | set(self._modules) | set(self._module_errors)
|
||||
for config_id in all_ids:
|
||||
info = _configs_cache.get(config_id, {})
|
||||
if info.get("enabled") is False:
|
||||
continue
|
||||
|
||||
module_entry = self._modules.get(config_id)
|
||||
module = module_entry[0] if module_entry is not None else None
|
||||
last_error = module.last_error if module is not None else None
|
||||
status = module.status if module is not None else "error"
|
||||
|
||||
manager_error = self._module_errors.get(config_id)
|
||||
if manager_error is not None:
|
||||
status = "error"
|
||||
last_error = manager_error
|
||||
elif last_error is not None and status != "error":
|
||||
status = "error"
|
||||
|
||||
if module is None and last_error is None:
|
||||
continue
|
||||
|
||||
result[config_id] = {
|
||||
"name": info.get("name", config_id),
|
||||
"type": info.get("type", "unknown"),
|
||||
"status": module.status,
|
||||
"status": status,
|
||||
"last_error": last_error,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
@@ -106,12 +106,14 @@ class MapUploadModule(FanoutModule):
|
||||
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
|
||||
super().__init__(config_id, config, name=name)
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
self._last_error: str | None = None
|
||||
# Per-pubkey rate limiting: pubkey_hex -> last_uploaded_advert_timestamp
|
||||
self._seen: dict[str, int] = {}
|
||||
|
||||
async def start(self) -> None:
|
||||
self._client = httpx.AsyncClient(timeout=httpx.Timeout(15.0))
|
||||
self._client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(15.0),
|
||||
follow_redirects=True,
|
||||
)
|
||||
self._last_error = None
|
||||
self._seen.clear()
|
||||
|
||||
@@ -292,7 +294,7 @@ class MapUploadModule(FanoutModule):
|
||||
)
|
||||
resp.raise_for_status()
|
||||
self._seen[pubkey] = advert_timestamp
|
||||
self._last_error = None
|
||||
self._set_last_error(None)
|
||||
logger.info(
|
||||
"MapUpload: uploaded %s (%s) → HTTP %d",
|
||||
pubkey[:12],
|
||||
@@ -300,7 +302,7 @@ class MapUploadModule(FanoutModule):
|
||||
resp.status_code,
|
||||
)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
self._last_error = f"HTTP {exc.response.status_code}"
|
||||
self._set_last_error(f"HTTP {exc.response.status_code}")
|
||||
logger.warning(
|
||||
"MapUpload: server returned %d for %s: %s",
|
||||
exc.response.status_code,
|
||||
@@ -308,13 +310,13 @@ class MapUploadModule(FanoutModule):
|
||||
exc.response.text[:200],
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
self._last_error = str(exc)
|
||||
self._set_last_error(str(exc))
|
||||
logger.warning("MapUpload: request error for %s: %s", pubkey[:12], exc)
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
if self._client is None:
|
||||
return "disconnected"
|
||||
if self._last_error:
|
||||
if self.last_error:
|
||||
return "error"
|
||||
return "connected"
|
||||
|
||||
@@ -23,6 +23,14 @@ logger = logging.getLogger(__name__)
|
||||
_BACKOFF_MIN = 5
|
||||
|
||||
|
||||
def _format_error_detail(exc: Exception) -> str:
|
||||
"""Return a short operator-facing error string."""
|
||||
message = str(exc).strip()
|
||||
if message:
|
||||
return message
|
||||
return type(exc).__name__
|
||||
|
||||
|
||||
def _broadcast_health() -> None:
|
||||
"""Push updated health (including MQTT status) to all WS clients."""
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
@@ -55,6 +63,7 @@ class BaseMqttPublisher(ABC):
|
||||
self._version_event: asyncio.Event = asyncio.Event()
|
||||
self.connected: bool = False
|
||||
self.integration_name: str = ""
|
||||
self._last_error: str | None = None
|
||||
|
||||
def set_integration_name(self, name: str) -> None:
|
||||
"""Attach the configured fanout-module name for operator-facing logs."""
|
||||
@@ -66,11 +75,17 @@ class BaseMqttPublisher(ABC):
|
||||
return f"{self._log_prefix} [{self.integration_name}]"
|
||||
return self._log_prefix
|
||||
|
||||
@property
|
||||
def last_error(self) -> str | None:
|
||||
"""Return the most recent retained connection/publish error."""
|
||||
return self._last_error
|
||||
|
||||
# ── Lifecycle ──────────────────────────────────────────────────────
|
||||
|
||||
async def start(self, settings: object) -> None:
|
||||
"""Start the background connection loop."""
|
||||
self._settings = settings
|
||||
self._last_error = None
|
||||
self._settings_version += 1
|
||||
self._version_event.set()
|
||||
if self._task is None or self._task.done():
|
||||
@@ -87,6 +102,7 @@ class BaseMqttPublisher(ABC):
|
||||
self._task = None
|
||||
self._client = None
|
||||
self.connected = False
|
||||
self._last_error = None
|
||||
|
||||
async def restart(self, settings: object) -> None:
|
||||
"""Called when settings change — stop + start."""
|
||||
@@ -109,6 +125,7 @@ class BaseMqttPublisher(ABC):
|
||||
exc_info=True,
|
||||
)
|
||||
self.connected = False
|
||||
self._last_error = _format_error_detail(e)
|
||||
# Wake the connection loop so it exits the wait and reconnects
|
||||
self._settings_version += 1
|
||||
self._version_event.set()
|
||||
@@ -198,6 +215,7 @@ class BaseMqttPublisher(ABC):
|
||||
async with aiomqtt.Client(**client_kwargs) as client:
|
||||
self._client = client
|
||||
self.connected = True
|
||||
self._last_error = None
|
||||
backoff = _BACKOFF_MIN
|
||||
|
||||
title, detail = self._on_connected(settings)
|
||||
@@ -232,6 +250,7 @@ class BaseMqttPublisher(ABC):
|
||||
except Exception as e:
|
||||
self.connected = False
|
||||
self._client = None
|
||||
self._last_error = _format_error_detail(e)
|
||||
|
||||
title, detail = self._on_error()
|
||||
broadcast_error(title, detail)
|
||||
|
||||
@@ -98,9 +98,15 @@ class MqttCommunityModule(FanoutModule):
|
||||
@property
|
||||
def status(self) -> str:
|
||||
if self._publisher._is_configured():
|
||||
if self._publisher.last_error:
|
||||
return "error"
|
||||
return "connected" if self._publisher.connected else "disconnected"
|
||||
return "disconnected"
|
||||
|
||||
@property
|
||||
def last_error(self) -> str | None:
|
||||
return self._publisher.last_error
|
||||
|
||||
|
||||
async def _publish_community_packet(
|
||||
publisher: CommunityMqttPublisher,
|
||||
|
||||
@@ -59,4 +59,10 @@ class MqttPrivateModule(FanoutModule):
|
||||
def status(self) -> str:
|
||||
if not self.config.get("broker_host"):
|
||||
return "disconnected"
|
||||
if self._publisher.last_error:
|
||||
return "error"
|
||||
return "connected" if self._publisher.connected else "disconnected"
|
||||
|
||||
@property
|
||||
def last_error(self) -> str | None:
|
||||
return self._publisher.last_error
|
||||
|
||||
+4
-5
@@ -84,7 +84,6 @@ class SqsModule(FanoutModule):
|
||||
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
|
||||
super().__init__(config_id, config, name=name)
|
||||
self._client = None
|
||||
self._last_error: str | None = None
|
||||
|
||||
async def start(self) -> None:
|
||||
kwargs: dict[str, str] = {}
|
||||
@@ -147,18 +146,18 @@ class SqsModule(FanoutModule):
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(partial(self._client.send_message, **request_kwargs))
|
||||
self._last_error = None
|
||||
self._set_last_error(None)
|
||||
except (ClientError, BotoCoreError) as exc:
|
||||
self._last_error = str(exc)
|
||||
self._set_last_error(str(exc))
|
||||
logger.warning("SQS %s send error: %s", self.config_id, exc)
|
||||
except Exception as exc:
|
||||
self._last_error = str(exc)
|
||||
self._set_last_error(str(exc))
|
||||
logger.exception("Unexpected SQS send error for %s", self.config_id)
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
if not str(self.config.get("queue_url", "")).strip():
|
||||
return "disconnected"
|
||||
if self._last_error:
|
||||
if self.last_error:
|
||||
return "error"
|
||||
return "connected"
|
||||
|
||||
@@ -20,7 +20,6 @@ class WebhookModule(FanoutModule):
|
||||
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
|
||||
super().__init__(config_id, config, name=name)
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
self._last_error: str | None = None
|
||||
|
||||
async def start(self) -> None:
|
||||
self._client = httpx.AsyncClient(timeout=httpx.Timeout(10.0))
|
||||
@@ -62,9 +61,9 @@ class WebhookModule(FanoutModule):
|
||||
try:
|
||||
resp = await self._client.request(method, url, content=body_bytes, headers=headers)
|
||||
resp.raise_for_status()
|
||||
self._last_error = None
|
||||
self._set_last_error(None)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
self._last_error = f"HTTP {exc.response.status_code}"
|
||||
self._set_last_error(f"HTTP {exc.response.status_code}")
|
||||
logger.warning(
|
||||
"Webhook %s returned %s for %s",
|
||||
self.config_id,
|
||||
@@ -72,13 +71,13 @@ class WebhookModule(FanoutModule):
|
||||
url,
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
self._last_error = str(exc)
|
||||
self._set_last_error(str(exc))
|
||||
logger.warning("Webhook %s request error: %s", self.config_id, exc)
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
if not self.config.get("url"):
|
||||
return "disconnected"
|
||||
if self._last_error:
|
||||
if self.last_error:
|
||||
return "error"
|
||||
return "connected"
|
||||
|
||||
@@ -39,6 +39,7 @@ from app.routers import (
|
||||
ws,
|
||||
)
|
||||
from app.security import add_optional_basic_auth_middleware
|
||||
from app.services.radio_noise_floor import start_noise_floor_sampling, stop_noise_floor_sampling
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
from app.version_info import get_app_build_info
|
||||
|
||||
@@ -70,6 +71,7 @@ async def lifespan(app: FastAPI):
|
||||
from app.radio_sync import ensure_default_channels
|
||||
|
||||
await ensure_default_channels()
|
||||
await start_noise_floor_sampling()
|
||||
|
||||
# Always start connection monitor (even if initial connection failed)
|
||||
await radio_manager.start_connection_monitor()
|
||||
@@ -98,6 +100,7 @@ async def lifespan(app: FastAPI):
|
||||
await radio_manager.stop_connection_monitor()
|
||||
await stop_background_contact_reconciliation()
|
||||
await stop_message_polling()
|
||||
await stop_noise_floor_sampling()
|
||||
await stop_periodic_advert()
|
||||
await stop_periodic_sync()
|
||||
if radio_manager.meshcore:
|
||||
|
||||
@@ -360,6 +360,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
|
||||
await set_version(conn, 46)
|
||||
applied += 1
|
||||
|
||||
# Migration 47: Add statistics indexes for time-windowed scans
|
||||
if version < 47:
|
||||
logger.info("Applying migration 47: add statistics indexes")
|
||||
await _migrate_047_add_statistics_indexes(conn)
|
||||
await set_version(conn, 47)
|
||||
applied += 1
|
||||
|
||||
if applied > 0:
|
||||
logger.info(
|
||||
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
|
||||
@@ -2868,3 +2875,37 @@ async def _migrate_046_cleanup_orphaned_contact_child_rows(conn: aiosqlite.Conne
|
||||
)
|
||||
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def _migrate_047_add_statistics_indexes(conn: aiosqlite.Connection) -> None:
|
||||
"""Add indexes used by the statistics endpoint's time-windowed scans."""
|
||||
cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
tables = {row[0] for row in await cursor.fetchall()}
|
||||
|
||||
if "raw_packets" in tables:
|
||||
cursor = await conn.execute("PRAGMA table_info(raw_packets)")
|
||||
raw_packet_columns = {row[1] for row in await cursor.fetchall()}
|
||||
if "timestamp" in raw_packet_columns:
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_raw_packets_timestamp ON raw_packets(timestamp)"
|
||||
)
|
||||
|
||||
if "contacts" in tables:
|
||||
cursor = await conn.execute("PRAGMA table_info(contacts)")
|
||||
contact_columns = {row[1] for row in await cursor.fetchall()}
|
||||
if {"type", "last_seen"}.issubset(contact_columns):
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_contacts_type_last_seen ON contacts(type, last_seen)"
|
||||
)
|
||||
|
||||
if "messages" in tables:
|
||||
cursor = await conn.execute("PRAGMA table_info(messages)")
|
||||
message_columns = {row[1] for row in await cursor.fetchall()}
|
||||
if {"type", "received_at", "conversation_key"}.issubset(message_columns):
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_type_received_conversation
|
||||
ON messages(type, received_at, conversation_key)
|
||||
"""
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
@@ -628,6 +628,59 @@ class TraceResponse(BaseModel):
|
||||
path_len: int = Field(description="Number of hops in the trace path")
|
||||
|
||||
|
||||
class RadioTraceHopRequest(BaseModel):
|
||||
"""One requested hop in a radio trace path."""
|
||||
|
||||
public_key: str | None = Field(
|
||||
default=None,
|
||||
description="Full repeater public key when this hop maps to a known repeater",
|
||||
)
|
||||
hop_hex: str | None = Field(
|
||||
default=None,
|
||||
description="Raw hop hash hex when using a custom repeater prefix",
|
||||
)
|
||||
|
||||
|
||||
class RadioTraceRequest(BaseModel):
|
||||
"""Ordered trace path for a radio trace loop."""
|
||||
|
||||
hop_hash_bytes: Literal[1, 2, 4] = Field(
|
||||
default=4,
|
||||
description="Hash width in bytes for every hop in this trace path",
|
||||
)
|
||||
hops: list[RadioTraceHopRequest] = Field(
|
||||
min_length=1,
|
||||
description="Ordered repeater hops, using either known repeater keys or custom hop hex",
|
||||
)
|
||||
|
||||
|
||||
class RadioTraceNode(BaseModel):
|
||||
"""One resolved node in a radio trace result."""
|
||||
|
||||
role: Literal["repeater", "custom", "local"] = Field(description="Node role in the trace")
|
||||
public_key: str | None = Field(
|
||||
default=None,
|
||||
description="Resolved full public key for this node when known",
|
||||
)
|
||||
name: str | None = Field(default=None, description="Display name for this node when known")
|
||||
observed_hash: str | None = Field(
|
||||
default=None,
|
||||
description="Observed 4-byte trace hash for this node as hex",
|
||||
)
|
||||
snr: float | None = Field(default=None, description="Reported SNR for this node in dB")
|
||||
|
||||
|
||||
class RadioTraceResponse(BaseModel):
|
||||
"""Resolved multi-hop radio trace result."""
|
||||
|
||||
path_len: int = Field(description="Number of hashed nodes returned by the trace response")
|
||||
timeout_seconds: float = Field(description="Timeout window used while waiting for the trace")
|
||||
nodes: list[RadioTraceNode] = Field(
|
||||
default_factory=list,
|
||||
description="Ordered trace nodes: repeater hops followed by the terminal local radio",
|
||||
)
|
||||
|
||||
|
||||
class PathDiscoveryRoute(BaseModel):
|
||||
"""One resolved route returned by contact path discovery."""
|
||||
|
||||
@@ -681,6 +734,10 @@ class RadioDiscoveryResult(BaseModel):
|
||||
"""One mesh node heard during a discovery sweep."""
|
||||
|
||||
public_key: str = Field(description="Discovered node public key as hex")
|
||||
name: str | None = Field(
|
||||
default=None,
|
||||
description="Known name for this node from contacts DB, if any",
|
||||
)
|
||||
node_type: Literal["repeater", "sensor"] = Field(description="Discovered node class")
|
||||
heard_count: int = Field(default=1, description="How many responses were heard from this node")
|
||||
local_snr: float | None = Field(
|
||||
@@ -820,6 +877,27 @@ class PathHashWidthStats(BaseModel):
|
||||
triple_byte_pct: float
|
||||
|
||||
|
||||
class NoiseFloorSample(BaseModel):
|
||||
timestamp: int = Field(description="Unix timestamp of the sampled reading")
|
||||
noise_floor_dbm: int = Field(description="Noise floor in dBm")
|
||||
|
||||
|
||||
class NoiseFloorHistoryStats(BaseModel):
|
||||
sample_interval_seconds: int = Field(description="Expected spacing between samples")
|
||||
coverage_seconds: int = Field(description="How much of the last 24 hours is represented")
|
||||
latest_noise_floor_dbm: int | None = Field(
|
||||
default=None, description="Most recent sampled noise floor in dBm"
|
||||
)
|
||||
latest_timestamp: int | None = Field(
|
||||
default=None, description="Unix timestamp of the most recent sample"
|
||||
)
|
||||
supported: bool | None = Field(
|
||||
default=None,
|
||||
description="Whether the connected radio appears to support radio stats sampling",
|
||||
)
|
||||
samples: list[NoiseFloorSample] = Field(default_factory=list)
|
||||
|
||||
|
||||
class StatisticsResponse(BaseModel):
|
||||
busiest_channels_24h: list[BusyChannel]
|
||||
contact_count: int
|
||||
@@ -835,3 +913,4 @@ class StatisticsResponse(BaseModel):
|
||||
repeaters_heard: ContactActivityCounts
|
||||
known_channels_active: ContactActivityCounts
|
||||
path_hash_width_24h: PathHashWidthStats
|
||||
noise_floor_24h: NoiseFloorHistoryStats
|
||||
|
||||
+16
-11
@@ -122,20 +122,20 @@ async def run_historical_dm_decryption(
|
||||
"""Background task to decrypt historical DM packets with contact's key."""
|
||||
from app.websocket import broadcast_success
|
||||
|
||||
packets = await RawPacketRepository.get_undecrypted_text_messages()
|
||||
total = len(packets)
|
||||
total = 0
|
||||
decrypted_count = 0
|
||||
|
||||
if total == 0:
|
||||
logger.info("No undecrypted TEXT_MESSAGE packets to process")
|
||||
return
|
||||
|
||||
logger.info("Starting historical DM decryption of %d TEXT_MESSAGE packets", total)
|
||||
logger.info("Starting historical DM decryption scan for undecrypted TEXT_MESSAGE packets")
|
||||
|
||||
# Derive our public key from the private key
|
||||
our_public_key_bytes = derive_public_key(private_key_bytes)
|
||||
|
||||
for packet_id, packet_data, packet_timestamp in packets:
|
||||
async for (
|
||||
packet_id,
|
||||
packet_data,
|
||||
packet_timestamp,
|
||||
) in RawPacketRepository.stream_undecrypted_text_messages():
|
||||
total += 1
|
||||
# Note: passing our_public_key=None disables the outbound hash check in
|
||||
# try_decrypt_dm (only the inbound check src_hash == their_first_byte runs).
|
||||
# For the 255/256 case where our first byte differs from the contact's,
|
||||
@@ -187,6 +187,10 @@ async def run_historical_dm_decryption(
|
||||
if msg_id is not None:
|
||||
decrypted_count += 1
|
||||
|
||||
if total == 0:
|
||||
logger.info("No undecrypted TEXT_MESSAGE packets to process")
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Historical DM decryption complete: %d/%d packets decrypted",
|
||||
decrypted_count,
|
||||
@@ -264,9 +268,10 @@ async def process_raw_packet(
|
||||
This is the main entry point for all incoming RF packets.
|
||||
|
||||
Note: Packets are deduplicated by payload hash in the database. If we receive
|
||||
a duplicate packet (same payload, different path), we still broadcast it to
|
||||
the frontend (for the real-time packet feed) but skip decryption processing
|
||||
since the original packet was already processed.
|
||||
a duplicate payload (same payload, different path), we still broadcast it to
|
||||
the frontend for realtime packet-feed fidelity. Some payload types are also
|
||||
intentionally reprocessed on duplicate arrival so message-level dedup/path
|
||||
merge logic and advert/path-history tracking still see each observation.
|
||||
"""
|
||||
ts = timestamp or int(time.time())
|
||||
observation_id = next(_raw_observation_counter)
|
||||
|
||||
+18
-2
@@ -20,7 +20,7 @@ from meshcore import EventType, MeshCore
|
||||
|
||||
from app.channel_constants import PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME
|
||||
from app.config import settings
|
||||
from app.event_handlers import cleanup_expired_acks
|
||||
from app.event_handlers import cleanup_expired_acks, on_contact_message
|
||||
from app.models import Contact, ContactUpsert
|
||||
from app.radio import RadioOperationBusyError
|
||||
from app.repository import (
|
||||
@@ -379,6 +379,14 @@ async def _resolve_channel_for_pending_message(
|
||||
return cached_key, channel.name if channel else None
|
||||
|
||||
|
||||
async def _store_pending_direct_message(event) -> None:
|
||||
"""Route a CONTACT_MSG_RECV event pulled via get_msg() through the DM ingest path."""
|
||||
try:
|
||||
await on_contact_message(event)
|
||||
except Exception:
|
||||
logger.warning("Failed to store pending direct message", exc_info=True)
|
||||
|
||||
|
||||
async def _store_pending_channel_message(mc: MeshCore, payload: dict) -> None:
|
||||
"""Persist a CHANNEL_MSG_RECV event pulled via get_msg()."""
|
||||
channel_idx = payload.get("channel_idx")
|
||||
@@ -403,7 +411,8 @@ async def _store_pending_channel_message(mc: MeshCore, payload: dict) -> None:
|
||||
return
|
||||
|
||||
received_at = int(time.time())
|
||||
sender_timestamp = payload.get("sender_timestamp") or received_at
|
||||
ts = payload.get("sender_timestamp")
|
||||
sender_timestamp = ts if ts is not None else received_at
|
||||
sender_name, message_text = _split_channel_sender_and_text(payload.get("text", ""))
|
||||
|
||||
await create_fallback_channel_message(
|
||||
@@ -488,6 +497,8 @@ async def drain_pending_messages(mc: MeshCore) -> int:
|
||||
elif result.type in (EventType.CONTACT_MSG_RECV, EventType.CHANNEL_MSG_RECV):
|
||||
if result.type == EventType.CHANNEL_MSG_RECV:
|
||||
await _store_pending_channel_message(mc, result.payload)
|
||||
elif result.type == EventType.CONTACT_MSG_RECV:
|
||||
await _store_pending_direct_message(result)
|
||||
count += 1
|
||||
|
||||
# Small delay between fetches
|
||||
@@ -525,6 +536,8 @@ async def poll_for_messages(mc: MeshCore) -> int:
|
||||
elif result.type in (EventType.CONTACT_MSG_RECV, EventType.CHANNEL_MSG_RECV):
|
||||
if result.type == EventType.CHANNEL_MSG_RECV:
|
||||
await _store_pending_channel_message(mc, result.payload)
|
||||
elif result.type == EventType.CONTACT_MSG_RECV:
|
||||
await _store_pending_direct_message(result)
|
||||
count += 1
|
||||
# If we got a message, there might be more - drain them
|
||||
count += await drain_pending_messages(mc)
|
||||
@@ -1018,6 +1031,7 @@ _last_contact_sync: float = 0.0
|
||||
CONTACT_SYNC_THROTTLE_SECONDS = 30 # Don't sync more than once per 30 seconds
|
||||
CONTACT_RECONCILE_BATCH_SIZE = 2
|
||||
CONTACT_RECONCILE_YIELD_SECONDS = 0.05
|
||||
CONTACT_RECONCILE_BUSY_BACKOFF_SECONDS = 2.0
|
||||
|
||||
|
||||
def _evict_removed_contact_from_library_cache(mc: MeshCore, public_key: str) -> None:
|
||||
@@ -1227,6 +1241,8 @@ async def _reconcile_radio_contacts_in_background(
|
||||
)
|
||||
except RadioOperationBusyError:
|
||||
logger.debug("Background contact reconcile yielding: radio busy")
|
||||
await asyncio.sleep(CONTACT_RECONCILE_BUSY_BACKOFF_SECONDS)
|
||||
continue
|
||||
|
||||
await asyncio.sleep(CONTACT_RECONCILE_YIELD_SECONDS)
|
||||
if not progressed:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
import sqlite3
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
from hashlib import sha256
|
||||
|
||||
from app.database import db
|
||||
@@ -8,6 +9,8 @@ from app.decoder import PayloadType, extract_payload, get_packet_payload_type
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
UNDECRYPTED_PACKET_BATCH_SIZE = 500
|
||||
|
||||
|
||||
class RawPacketRepository:
|
||||
@staticmethod
|
||||
@@ -100,6 +103,40 @@ class RawPacketRepository:
|
||||
rows = await cursor.fetchall()
|
||||
return [(row["id"], bytes(row["data"]), row["timestamp"]) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def stream_undecrypted_text_messages(
|
||||
batch_size: int = UNDECRYPTED_PACKET_BATCH_SIZE,
|
||||
) -> AsyncIterator[tuple[int, bytes, int]]:
|
||||
"""Yield undecrypted TEXT_MESSAGE packets in bounded-size batches."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT id, data, timestamp FROM raw_packets WHERE message_id IS NULL ORDER BY timestamp ASC"
|
||||
)
|
||||
try:
|
||||
while True:
|
||||
rows = await cursor.fetchmany(batch_size)
|
||||
if not rows:
|
||||
break
|
||||
|
||||
for row in rows:
|
||||
data = bytes(row["data"])
|
||||
payload_type = get_packet_payload_type(data)
|
||||
if payload_type == PayloadType.TEXT_MESSAGE:
|
||||
yield (row["id"], data, row["timestamp"])
|
||||
finally:
|
||||
await cursor.close()
|
||||
|
||||
@staticmethod
|
||||
async def count_undecrypted_text_messages(
|
||||
batch_size: int = UNDECRYPTED_PACKET_BATCH_SIZE,
|
||||
) -> int:
|
||||
"""Count undecrypted TEXT_MESSAGE packets without materializing them all."""
|
||||
count = 0
|
||||
async for _packet in RawPacketRepository.stream_undecrypted_text_messages(
|
||||
batch_size=batch_size
|
||||
):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def mark_decrypted(packet_id: int, message_id: int) -> None:
|
||||
"""Link a raw packet to its decrypted message."""
|
||||
@@ -158,17 +195,4 @@ class RawPacketRepository:
|
||||
Filters raw packets to only include those with PayloadType.TEXT_MESSAGE (0x02).
|
||||
These are direct messages that can be decrypted with contact ECDH keys.
|
||||
"""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT id, data, timestamp FROM raw_packets WHERE message_id IS NULL ORDER BY timestamp ASC"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
# Filter for TEXT_MESSAGE packets
|
||||
result = []
|
||||
for row in rows:
|
||||
data = bytes(row["data"])
|
||||
payload_type = get_packet_payload_type(data)
|
||||
if payload_type == PayloadType.TEXT_MESSAGE:
|
||||
result.append((row["id"], data, row["timestamp"]))
|
||||
|
||||
return result
|
||||
return [packet async for packet in RawPacketRepository.stream_undecrypted_text_messages()]
|
||||
|
||||
+56
-37
@@ -12,6 +12,7 @@ logger = logging.getLogger(__name__)
|
||||
SECONDS_1H = 3600
|
||||
SECONDS_24H = 86400
|
||||
SECONDS_7D = 604800
|
||||
RAW_PACKET_STATS_BATCH_SIZE = 500
|
||||
|
||||
|
||||
class AppSettingsRepository:
|
||||
@@ -246,6 +247,26 @@ class AppSettingsRepository:
|
||||
|
||||
|
||||
class StatisticsRepository:
|
||||
@staticmethod
|
||||
async def get_database_message_totals() -> dict[str, int]:
|
||||
"""Return message totals needed by lightweight debug surfaces."""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
SUM(CASE WHEN type = 'PRIV' THEN 1 ELSE 0 END) AS total_dms,
|
||||
SUM(CASE WHEN type = 'CHAN' THEN 1 ELSE 0 END) AS total_channel_messages,
|
||||
SUM(CASE WHEN outgoing = 1 THEN 1 ELSE 0 END) AS total_outgoing
|
||||
FROM messages
|
||||
"""
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
assert row is not None
|
||||
return {
|
||||
"total_dms": row["total_dms"] or 0,
|
||||
"total_channel_messages": row["total_channel_messages"] or 0,
|
||||
"total_outgoing": row["total_outgoing"] or 0,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def _activity_counts(*, contact_type: int, exclude: bool = False) -> dict[str, int]:
|
||||
"""Get time-windowed counts for contacts/repeaters heard."""
|
||||
@@ -272,17 +293,26 @@ class StatisticsRepository:
|
||||
|
||||
@staticmethod
|
||||
async def _known_channels_active() -> dict[str, int]:
|
||||
"""Count distinct known channel keys with channel traffic in each time window."""
|
||||
"""Count known channel keys with any traffic in each time window.
|
||||
|
||||
Channel keys are stored canonically as uppercase hex, so we can avoid
|
||||
the old UPPER(...) join and aggregate per known channel directly.
|
||||
"""
|
||||
now = int(time.time())
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
WITH known AS (
|
||||
SELECT conversation_key, MAX(received_at) AS last_received_at
|
||||
FROM messages
|
||||
WHERE type = 'CHAN'
|
||||
AND conversation_key IN (SELECT key FROM channels)
|
||||
GROUP BY conversation_key
|
||||
)
|
||||
SELECT
|
||||
COUNT(DISTINCT CASE WHEN m.received_at >= ? THEN m.conversation_key END) AS last_hour,
|
||||
COUNT(DISTINCT CASE WHEN m.received_at >= ? THEN m.conversation_key END) AS last_24_hours,
|
||||
COUNT(DISTINCT CASE WHEN m.received_at >= ? THEN m.conversation_key END) AS last_week
|
||||
FROM messages m
|
||||
INNER JOIN channels c ON UPPER(m.conversation_key) = UPPER(c.key)
|
||||
WHERE m.type = 'CHAN'
|
||||
SUM(CASE WHEN last_received_at >= ? THEN 1 ELSE 0 END) AS last_hour,
|
||||
SUM(CASE WHEN last_received_at >= ? THEN 1 ELSE 0 END) AS last_24_hours,
|
||||
SUM(CASE WHEN last_received_at >= ? THEN 1 ELSE 0 END) AS last_week
|
||||
FROM known
|
||||
""",
|
||||
(now - SECONDS_1H, now - SECONDS_24H, now - SECONDS_7D),
|
||||
)
|
||||
@@ -302,22 +332,26 @@ class StatisticsRepository:
|
||||
"SELECT data FROM raw_packets WHERE timestamp >= ?",
|
||||
(now - SECONDS_24H,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
single_byte = 0
|
||||
double_byte = 0
|
||||
triple_byte = 0
|
||||
|
||||
for row in rows:
|
||||
envelope = parse_packet_envelope(bytes(row["data"]))
|
||||
if envelope is None:
|
||||
continue
|
||||
if envelope.hash_size == 1:
|
||||
single_byte += 1
|
||||
elif envelope.hash_size == 2:
|
||||
double_byte += 1
|
||||
elif envelope.hash_size == 3:
|
||||
triple_byte += 1
|
||||
while True:
|
||||
rows = await cursor.fetchmany(RAW_PACKET_STATS_BATCH_SIZE)
|
||||
if not rows:
|
||||
break
|
||||
|
||||
for row in rows:
|
||||
envelope = parse_packet_envelope(bytes(row["data"]))
|
||||
if envelope is None:
|
||||
continue
|
||||
if envelope.hash_size == 1:
|
||||
single_byte += 1
|
||||
elif envelope.hash_size == 2:
|
||||
double_byte += 1
|
||||
elif envelope.hash_size == 3:
|
||||
triple_byte += 1
|
||||
|
||||
total_packets = single_byte + double_byte + triple_byte
|
||||
if total_packets == 0:
|
||||
@@ -400,22 +434,7 @@ class StatisticsRepository:
|
||||
decrypted_packets = pkt_row["decrypted"] or 0
|
||||
undecrypted_packets = total_packets - decrypted_packets
|
||||
|
||||
# Message type counts
|
||||
cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM messages WHERE type = 'PRIV'")
|
||||
row = await cursor.fetchone()
|
||||
assert row is not None
|
||||
total_dms: int = row["cnt"]
|
||||
|
||||
cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM messages WHERE type = 'CHAN'")
|
||||
row = await cursor.fetchone()
|
||||
assert row is not None
|
||||
total_channel_messages: int = row["cnt"]
|
||||
|
||||
# Outgoing count
|
||||
cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM messages WHERE outgoing = 1")
|
||||
row = await cursor.fetchone()
|
||||
assert row is not None
|
||||
total_outgoing: int = row["cnt"]
|
||||
message_totals = await StatisticsRepository.get_database_message_totals()
|
||||
|
||||
# Activity windows
|
||||
contacts_heard = await StatisticsRepository._activity_counts(contact_type=2, exclude=True)
|
||||
@@ -431,9 +450,9 @@ class StatisticsRepository:
|
||||
"total_packets": total_packets,
|
||||
"decrypted_packets": decrypted_packets,
|
||||
"undecrypted_packets": undecrypted_packets,
|
||||
"total_dms": total_dms,
|
||||
"total_channel_messages": total_channel_messages,
|
||||
"total_outgoing": total_outgoing,
|
||||
"total_dms": message_totals["total_dms"],
|
||||
"total_channel_messages": message_totals["total_channel_messages"],
|
||||
"total_outgoing": message_totals["total_outgoing"],
|
||||
"contacts_heard": contacts_heard,
|
||||
"repeaters_heard": repeaters_heard,
|
||||
"known_channels_active": known_channels_active,
|
||||
|
||||
+14
-6
@@ -40,6 +40,10 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/contacts", tags=["contacts"])
|
||||
|
||||
|
||||
TRACE_HASH_BYTES = 4
|
||||
TRACE_FLAGS_4BYTE = 2
|
||||
|
||||
|
||||
def _ambiguous_contact_detail(err: AmbiguousPublicKeyPrefixError) -> str:
|
||||
sample = ", ".join(key[:12] for key in err.matches[:2])
|
||||
return (
|
||||
@@ -373,17 +377,17 @@ async def delete_contact(public_key: str) -> dict:
|
||||
async def request_trace(public_key: str) -> TraceResponse:
|
||||
"""Send a single-hop trace to a contact and wait for the result.
|
||||
|
||||
The trace path contains the contact's 1-byte pubkey hash as the sole hop
|
||||
(no intermediate repeaters). The radio firmware requires at least one
|
||||
node in the path.
|
||||
The trace path contains the contact's 4-byte pubkey hash as the sole hop
|
||||
(no intermediate repeaters). This uses TRACE's dedicated width flags rather
|
||||
than the radio's normal path_hash_mode setting.
|
||||
"""
|
||||
require_connected()
|
||||
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
|
||||
tag = random.randint(1, 0xFFFFFFFF)
|
||||
# First 2 hex chars of pubkey = 1-byte hash used by the trace protocol
|
||||
contact_hash = contact.public_key[:2]
|
||||
# Use a 4-byte contact hash for low-collision direct trace targeting.
|
||||
contact_hash = contact.public_key[: TRACE_HASH_BYTES * 2]
|
||||
|
||||
# Trace does not need auto-fetch suspension: response arrives as TRACE_DATA
|
||||
# from the reader loop, not via get_msg().
|
||||
@@ -394,7 +398,11 @@ async def request_trace(public_key: str) -> TraceResponse:
|
||||
logger.info(
|
||||
"Sending trace to %s (tag=%d, hash=%s)", contact.public_key[:12], tag, contact_hash
|
||||
)
|
||||
result = await mc.commands.send_trace(path=contact_hash, tag=tag)
|
||||
result = await mc.commands.send_trace(
|
||||
path=contact_hash,
|
||||
tag=tag,
|
||||
flags=TRACE_FLAGS_4BYTE,
|
||||
)
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to send trace: {result.payload}")
|
||||
|
||||
@@ -265,7 +265,7 @@ async def _probe_radio() -> DebugRadioProbe:
|
||||
async def debug_support_snapshot() -> DebugSnapshotResponse:
|
||||
"""Return a support/debug snapshot with recent logs and live radio state."""
|
||||
health_data = await build_health_data(radio_runtime.is_connected, radio_runtime.connection_info)
|
||||
statistics = await StatisticsRepository.get_all()
|
||||
message_totals = await StatisticsRepository.get_database_message_totals()
|
||||
radio_probe = await _probe_radio()
|
||||
channels_with_incoming_messages = (
|
||||
await MessageRepository.count_channels_with_incoming_messages()
|
||||
@@ -291,9 +291,9 @@ async def debug_support_snapshot() -> DebugSnapshotResponse:
|
||||
},
|
||||
),
|
||||
database=DebugDatabaseInfo(
|
||||
total_dms=statistics["total_dms"],
|
||||
total_channel_messages=statistics["total_channel_messages"],
|
||||
total_outgoing=statistics["total_outgoing"],
|
||||
total_dms=message_totals["total_dms"],
|
||||
total_channel_messages=message_totals["total_channel_messages"],
|
||||
total_outgoing=message_totals["total_outgoing"],
|
||||
),
|
||||
radio_probe=radio_probe,
|
||||
logs=[*LOG_COPY_BOUNDARY_PREFIX, *get_recent_log_lines(limit=1000)],
|
||||
|
||||
@@ -2,7 +2,7 @@ import os
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.config import settings
|
||||
from app.repository import RawPacketRepository
|
||||
@@ -25,6 +25,13 @@ class AppInfoResponse(BaseModel):
|
||||
commit_hash: str | None = None
|
||||
|
||||
|
||||
class FanoutStatusResponse(BaseModel):
|
||||
name: str
|
||||
type: str
|
||||
status: str
|
||||
last_error: str | None = None
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
radio_connected: bool
|
||||
@@ -35,7 +42,7 @@ class HealthResponse(BaseModel):
|
||||
radio_device_info: RadioDeviceInfoResponse | None = None
|
||||
database_size_mb: float
|
||||
oldest_undecrypted_timestamp: int | None
|
||||
fanout_statuses: dict[str, dict[str, str]] = {}
|
||||
fanout_statuses: dict[str, FanoutStatusResponse] = Field(default_factory=dict)
|
||||
bots_disabled: bool = False
|
||||
bots_disabled_source: Literal["env", "until_restart"] | None = None
|
||||
basic_auth_enabled: bool = False
|
||||
|
||||
@@ -210,8 +210,7 @@ async def decrypt_historical_packets(
|
||||
except ValueError:
|
||||
raise _bad_request("Invalid hex string for contact public key") from None
|
||||
|
||||
packets = await RawPacketRepository.get_undecrypted_text_messages()
|
||||
count = len(packets)
|
||||
count = await RawPacketRepository.count_undecrypted_text_messages()
|
||||
if count == 0:
|
||||
return DecryptResult(
|
||||
started=False,
|
||||
|
||||
@@ -2,6 +2,7 @@ import asyncio
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from typing import Literal, TypeAlias
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
@@ -10,14 +11,20 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.models import (
|
||||
CONTACT_TYPE_REPEATER,
|
||||
ContactUpsert,
|
||||
RadioDiscoveryRequest,
|
||||
RadioDiscoveryResponse,
|
||||
RadioDiscoveryResult,
|
||||
RadioTraceHopRequest,
|
||||
RadioTraceNode,
|
||||
RadioTraceRequest,
|
||||
RadioTraceResponse,
|
||||
)
|
||||
from app.radio_sync import send_advertisement as do_send_advertisement
|
||||
from app.radio_sync import sync_radio_time
|
||||
from app.repository import ContactRepository
|
||||
from app.services.contact_reconciliation import promote_prefix_contacts_for_contact
|
||||
from app.services.radio_commands import (
|
||||
KeystoreRefreshError,
|
||||
PathHashModeUnsupportedError,
|
||||
@@ -44,6 +51,12 @@ _DISCOVERY_NODE_TYPES: dict[int, DiscoveryNodeType] = {
|
||||
2: "repeater",
|
||||
4: "sensor",
|
||||
}
|
||||
TRACE_WAIT_TIMEOUT_SECONDS = 45.0
|
||||
TRACE_DEFAULT_TIMEOUT_SECONDS = 15.0
|
||||
TRACE_TIMEOUT_MIN_SECONDS = 5.0
|
||||
TRACE_TIMEOUT_MAX_SECONDS = 30.0
|
||||
TRACE_TIMEOUT_MARGIN = 1.2
|
||||
TRACE_HASH_FLAGS = {1: 0, 2: 1, 4: 2}
|
||||
|
||||
|
||||
async def _prepare_connected(*, broadcast_on_success: bool) -> bool:
|
||||
@@ -197,9 +210,118 @@ async def _persist_new_discovery_contacts(results: list[RadioDiscoveryResult]) -
|
||||
on_radio=False,
|
||||
)
|
||||
await ContactRepository.upsert(contact)
|
||||
promoted_keys = await promote_prefix_contacts_for_contact(
|
||||
public_key=result.public_key,
|
||||
log=logger,
|
||||
)
|
||||
created = await ContactRepository.get_by_key(result.public_key)
|
||||
if created is not None:
|
||||
broadcast_event("contact", created.model_dump())
|
||||
for old_key in promoted_keys:
|
||||
broadcast_event("contact_deleted", {"public_key": old_key})
|
||||
|
||||
|
||||
async def _attach_known_names(results: list[RadioDiscoveryResult]) -> None:
|
||||
"""Resolve known contact names for discovery results from the DB."""
|
||||
for result in results:
|
||||
contact = await ContactRepository.get_by_key(result.public_key)
|
||||
if contact is not None and contact.name:
|
||||
result.name = contact.name
|
||||
|
||||
|
||||
def _trace_hash_for_key(public_key: str, hop_hash_bytes: int) -> str:
|
||||
return public_key[: hop_hash_bytes * 2].lower()
|
||||
|
||||
|
||||
def _trace_timeout_seconds(send_result: object) -> float:
|
||||
payload = getattr(send_result, "payload", None) or {}
|
||||
suggested_timeout = payload.get("suggested_timeout")
|
||||
try:
|
||||
if suggested_timeout is None:
|
||||
raise TypeError
|
||||
timeout_seconds = float(suggested_timeout) / 1000.0 * TRACE_TIMEOUT_MARGIN
|
||||
except (TypeError, ValueError):
|
||||
timeout_seconds = TRACE_DEFAULT_TIMEOUT_SECONDS
|
||||
return max(TRACE_TIMEOUT_MIN_SECONDS, min(TRACE_TIMEOUT_MAX_SECONDS, timeout_seconds))
|
||||
|
||||
|
||||
async def _resolve_trace_hops(
|
||||
hops: list[RadioTraceHopRequest], hop_hash_bytes: int
|
||||
) -> tuple[list[RadioTraceNode], list[str]]:
|
||||
trace_nodes: list[RadioTraceNode] = []
|
||||
requested_hashes: list[str] = []
|
||||
expected_hex_len = hop_hash_bytes * 2
|
||||
|
||||
for hop in hops:
|
||||
public_key = hop.public_key.strip().lower() if isinstance(hop.public_key, str) else None
|
||||
hop_hex = hop.hop_hex.strip().lower() if isinstance(hop.hop_hex, str) else None
|
||||
if bool(public_key) == bool(hop_hex):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Each trace hop must provide exactly one of public_key or hop_hex",
|
||||
)
|
||||
|
||||
if public_key:
|
||||
if len(public_key) != 64:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Trace repeater keys must be full 64-character public keys",
|
||||
)
|
||||
try:
|
||||
bytes.fromhex(public_key)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Trace repeater keys must be valid hex public keys",
|
||||
) from exc
|
||||
|
||||
contact = await ContactRepository.get_by_key(public_key)
|
||||
if contact is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Trace repeater not found: {public_key}"
|
||||
)
|
||||
if contact.type != CONTACT_TYPE_REPEATER:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Trace node is not a repeater: {public_key[:12]}",
|
||||
)
|
||||
requested_hashes.append(_trace_hash_for_key(contact.public_key, hop_hash_bytes))
|
||||
trace_nodes.append(
|
||||
RadioTraceNode(
|
||||
role="repeater",
|
||||
public_key=contact.public_key,
|
||||
name=contact.name,
|
||||
observed_hash=None,
|
||||
snr=None,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
assert hop_hex is not None
|
||||
if len(hop_hex) != expected_hex_len:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Custom trace hops must be exactly {expected_hex_len} hex characters",
|
||||
)
|
||||
try:
|
||||
bytes.fromhex(hop_hex)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Custom trace hops must be valid hex",
|
||||
) from exc
|
||||
requested_hashes.append(hop_hex)
|
||||
trace_nodes.append(
|
||||
RadioTraceNode(
|
||||
role="custom",
|
||||
public_key=None,
|
||||
name=None,
|
||||
observed_hash=hop_hex,
|
||||
snr=None,
|
||||
)
|
||||
)
|
||||
|
||||
return trace_nodes, requested_hashes
|
||||
|
||||
|
||||
@router.get("/config", response_model=RadioConfigResponse)
|
||||
@@ -365,6 +487,7 @@ async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryRespons
|
||||
),
|
||||
)
|
||||
await _persist_new_discovery_contacts(results)
|
||||
await _attach_known_names(results)
|
||||
return RadioDiscoveryResponse(
|
||||
target=request.target,
|
||||
duration_seconds=DISCOVERY_WINDOW_SECONDS,
|
||||
@@ -372,6 +495,105 @@ async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryRespons
|
||||
)
|
||||
|
||||
|
||||
@router.post("/trace", response_model=RadioTraceResponse)
|
||||
async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse:
|
||||
"""Send a multi-hop trace loop through known repeaters and back to the local radio."""
|
||||
require_connected()
|
||||
trace_nodes, requested_hashes = await _resolve_trace_hops(request.hops, request.hop_hash_bytes)
|
||||
|
||||
tag = random.randint(1, 0xFFFFFFFF)
|
||||
trace_flags = TRACE_HASH_FLAGS[request.hop_hash_bytes]
|
||||
|
||||
async with radio_manager.radio_operation("radio_trace", pause_polling=True) as mc:
|
||||
local_public_key = str((mc.self_info or {}).get("public_key") or "").lower()
|
||||
if len(local_public_key) != 64:
|
||||
raise HTTPException(status_code=503, detail="Local radio public key is unavailable")
|
||||
local_name = (mc.self_info or {}).get("name")
|
||||
|
||||
response_task = asyncio.create_task(
|
||||
mc.wait_for_event(
|
||||
EventType.TRACE_DATA,
|
||||
attribute_filters={"tag": tag},
|
||||
timeout=TRACE_WAIT_TIMEOUT_SECONDS,
|
||||
)
|
||||
)
|
||||
try:
|
||||
send_result = await mc.commands.send_trace(
|
||||
path=",".join(requested_hashes),
|
||||
tag=tag,
|
||||
flags=trace_flags,
|
||||
)
|
||||
if send_result is None or send_result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=500, detail="Failed to send trace")
|
||||
|
||||
timeout_seconds = _trace_timeout_seconds(send_result)
|
||||
try:
|
||||
event = await asyncio.wait_for(response_task, timeout=timeout_seconds)
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise HTTPException(status_code=504, detail="No trace response heard") from exc
|
||||
finally:
|
||||
if not response_task.done():
|
||||
response_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await response_task
|
||||
|
||||
if event is None:
|
||||
raise HTTPException(status_code=504, detail="No trace response heard")
|
||||
|
||||
payload = event.payload if isinstance(event.payload, dict) else {}
|
||||
path_len = payload.get("path_len")
|
||||
if not isinstance(path_len, int):
|
||||
raise HTTPException(status_code=500, detail="Trace response was malformed")
|
||||
|
||||
raw_path = payload.get("path")
|
||||
path_nodes = raw_path if isinstance(raw_path, list) else []
|
||||
final_local_node = (
|
||||
path_nodes[-1]
|
||||
if path_nodes
|
||||
and isinstance(path_nodes[-1], dict)
|
||||
and not isinstance(path_nodes[-1].get("hash"), str)
|
||||
else None
|
||||
)
|
||||
hashed_nodes = path_nodes[:-1] if final_local_node is not None else path_nodes
|
||||
|
||||
if len(hashed_nodes) < len(trace_nodes):
|
||||
raise HTTPException(status_code=500, detail="Trace response was incomplete")
|
||||
|
||||
nodes: list[RadioTraceNode] = []
|
||||
for index, trace_node in enumerate(trace_nodes):
|
||||
observed = hashed_nodes[index] if index < len(hashed_nodes) else {}
|
||||
observed_hash = observed.get("hash") if isinstance(observed, dict) else None
|
||||
observed_snr = observed.get("snr") if isinstance(observed, dict) else None
|
||||
nodes.append(
|
||||
RadioTraceNode(
|
||||
role=trace_node.role,
|
||||
public_key=trace_node.public_key,
|
||||
name=trace_node.name,
|
||||
observed_hash=(
|
||||
observed_hash if isinstance(observed_hash, str) else trace_node.observed_hash
|
||||
),
|
||||
snr=float(observed_snr) if isinstance(observed_snr, (int, float)) else None,
|
||||
)
|
||||
)
|
||||
|
||||
terminal_snr_value = final_local_node.get("snr") if isinstance(final_local_node, dict) else None
|
||||
nodes.append(
|
||||
RadioTraceNode(
|
||||
role="local",
|
||||
public_key=local_public_key,
|
||||
name=local_name if isinstance(local_name, str) and local_name else None,
|
||||
observed_hash=None,
|
||||
snr=float(terminal_snr_value) if isinstance(terminal_snr_value, (int, float)) else None,
|
||||
)
|
||||
)
|
||||
|
||||
return RadioTraceResponse(
|
||||
path_len=path_len,
|
||||
timeout_seconds=timeout_seconds,
|
||||
nodes=nodes,
|
||||
)
|
||||
|
||||
|
||||
async def _attempt_reconnect() -> dict:
|
||||
"""Shared reconnection logic for reboot and reconnect endpoints."""
|
||||
radio_manager.resume_connection()
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from meshcore import EventType
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.models import (
|
||||
@@ -28,7 +25,6 @@ from app.models import (
|
||||
from app.repository import ContactRepository
|
||||
from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404
|
||||
from app.routers.server_control import (
|
||||
_monotonic,
|
||||
batch_cli_fetch,
|
||||
extract_response_text,
|
||||
prepare_authenticated_contact_connection,
|
||||
@@ -37,9 +33,6 @@ from app.routers.server_control import (
|
||||
)
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from meshcore.events import Event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ACL permission level names
|
||||
@@ -57,58 +50,6 @@ def _extract_response_text(event) -> str:
|
||||
return extract_response_text(event)
|
||||
|
||||
|
||||
async def _fetch_repeater_response(
|
||||
mc,
|
||||
target_pubkey_prefix: str,
|
||||
timeout: float = 20.0,
|
||||
) -> "Event | None":
|
||||
deadline = _monotonic() + timeout
|
||||
|
||||
while _monotonic() < deadline:
|
||||
try:
|
||||
result = await mc.commands.get_msg(timeout=2.0)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
except Exception as exc:
|
||||
logger.debug("get_msg() exception: %s", exc)
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
|
||||
if result.type == EventType.NO_MORE_MSGS:
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
logger.debug("get_msg() error: %s", result.payload)
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
|
||||
if result.type == EventType.CONTACT_MSG_RECV:
|
||||
msg_prefix = result.payload.get("pubkey_prefix", "")
|
||||
txt_type = result.payload.get("txt_type", 0)
|
||||
if msg_prefix == target_pubkey_prefix and txt_type == 1:
|
||||
return result
|
||||
logger.debug(
|
||||
"Skipping non-target message (from=%s, txt_type=%d) while waiting for %s",
|
||||
msg_prefix,
|
||||
txt_type,
|
||||
target_pubkey_prefix,
|
||||
)
|
||||
continue
|
||||
|
||||
if result.type == EventType.CHANNEL_MSG_RECV:
|
||||
logger.debug(
|
||||
"Skipping channel message (channel_idx=%s) during repeater fetch",
|
||||
result.payload.get("channel_idx"),
|
||||
)
|
||||
continue
|
||||
|
||||
logger.debug("Unexpected event type %s during repeater fetch, skipping", result.type)
|
||||
|
||||
logger.warning("No CLI response from repeater %s within %.1fs", target_pubkey_prefix, timeout)
|
||||
return None
|
||||
|
||||
|
||||
async def prepare_repeater_connection(mc, contact: Contact, password: str) -> RepeaterLoginResponse:
|
||||
return await prepare_authenticated_contact_connection(
|
||||
mc,
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.models import (
|
||||
Contact,
|
||||
RepeaterLoginResponse,
|
||||
)
|
||||
from app.radio_sync import _store_pending_channel_message, _store_pending_direct_message
|
||||
from app.routers.contacts import _ensure_on_radio
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
@@ -115,18 +116,20 @@ async def fetch_contact_cli_response(
|
||||
if msg_prefix == target_pubkey_prefix and txt_type == 1:
|
||||
return result
|
||||
logger.debug(
|
||||
"Skipping non-target message (from=%s, txt_type=%d) while waiting for %s",
|
||||
"Storing non-target DM (from=%s, txt_type=%d) consumed while waiting for %s",
|
||||
msg_prefix,
|
||||
txt_type,
|
||||
target_pubkey_prefix,
|
||||
)
|
||||
await _store_pending_direct_message(result)
|
||||
continue
|
||||
|
||||
if result.type == EventType.CHANNEL_MSG_RECV:
|
||||
logger.debug(
|
||||
"Skipping channel message (channel_idx=%s) during CLI fetch",
|
||||
"Storing channel message (channel_idx=%s) consumed during CLI fetch",
|
||||
result.payload.get("channel_idx"),
|
||||
)
|
||||
await _store_pending_channel_message(mc, result.payload)
|
||||
continue
|
||||
|
||||
logger.debug("Unexpected event type %s during CLI fetch, skipping", result.type)
|
||||
|
||||
@@ -2,6 +2,7 @@ from fastapi import APIRouter
|
||||
|
||||
from app.models import StatisticsResponse
|
||||
from app.repository import StatisticsRepository
|
||||
from app.services.radio_noise_floor import get_noise_floor_history
|
||||
|
||||
router = APIRouter(prefix="/statistics", tags=["statistics"])
|
||||
|
||||
@@ -9,4 +10,5 @@ router = APIRouter(prefix="/statistics", tags=["statistics"])
|
||||
@router.get("", response_model=StatisticsResponse)
|
||||
async def get_statistics() -> StatisticsResponse:
|
||||
data = await StatisticsRepository.get_all()
|
||||
data["noise_floor_24h"] = await get_noise_floor_history()
|
||||
return StatisticsResponse(**data)
|
||||
|
||||
@@ -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
|
||||
4. Re-acquire lock to clean up disconnected clients
|
||||
"""
|
||||
if not self.active_connections:
|
||||
return
|
||||
|
||||
message = dump_ws_event(event_type, data)
|
||||
|
||||
# Copy connection list under lock to avoid holding lock during I/O
|
||||
|
||||
@@ -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
|
||||
├── styles.css # Additional global app styles
|
||||
├── themes.css # Color theme definitions
|
||||
├── contexts/
|
||||
│ └── DistanceUnitContext.tsx # Browser-local distance-unit context/provider
|
||||
├── lib/
|
||||
│ └── utils.ts # cn() — clsx + tailwind-merge helper
|
||||
├── hooks/
|
||||
@@ -53,10 +55,14 @@ frontend/src/
|
||||
│ ├── useRadioControl.ts # Radio health/config state, reconnection, mesh discovery sweeps
|
||||
│ ├── useAppSettings.ts # Settings, favorites, preferences migration
|
||||
│ ├── useConversationRouter.ts # URL hash → active conversation routing
|
||||
│ └── useContactsAndChannels.ts # Contact/channel loading, creation, deletion
|
||||
│ ├── useContactsAndChannels.ts # Contact/channel loading, creation, deletion
|
||||
│ ├── useBrowserNotifications.ts # Per-conversation browser notification preferences + dispatch
|
||||
│ ├── useFaviconBadge.ts # Browser tab unread badge state
|
||||
│ ├── useRawPacketStatsSession.ts # Session-scoped packet-feed stats history
|
||||
│ └── useRememberedServerPassword.ts # Browser-local repeater/room password persistence
|
||||
├── components/
|
||||
│ ├── AppShell.tsx # App-shell layout: status, sidebar, search/settings panes, cracker, modals
|
||||
│ ├── ConversationPane.tsx # Active conversation surface selection (map/raw/repeater/chat/empty)
|
||||
│ ├── AppShell.tsx # App-shell layout: status, sidebar, search/settings panes, cracker, modals, security warning
|
||||
│ ├── ConversationPane.tsx # Active conversation surface selection (map/raw/trace/repeater/room/chat/empty)
|
||||
│ ├── visualizer/
|
||||
│ │ ├── useVisualizerData3D.ts # Packet→graph data pipeline, repeat aggregation, simulation state
|
||||
│ │ ├── useVisualizer3DScene.ts # Three.js scene lifecycle, buffers, hover/pin interaction
|
||||
@@ -73,14 +79,18 @@ frontend/src/
|
||||
│ ├── pubkey.ts # getContactDisplayName (12-char prefix fallback)
|
||||
│ ├── contactAvatar.ts # Avatar color derivation from public key
|
||||
│ ├── rawPacketIdentity.ts # observation_id vs id dedup helpers
|
||||
│ ├── rawPacketStats.ts # Session packet stats windows, rankings, and coverage helpers
|
||||
│ ├── regionScope.ts # Regional flood-scope label/normalization helpers
|
||||
│ ├── visualizerUtils.ts # 3D visualizer node types, colors, particles
|
||||
│ ├── visualizerSettings.ts # LocalStorage persistence for visualizer options
|
||||
│ ├── a11y.ts # Keyboard accessibility helper
|
||||
│ ├── distanceUnits.ts # Browser-local distance unit persistence/helpers
|
||||
│ ├── lastViewedConversation.ts # localStorage for last-viewed conversation
|
||||
│ ├── contactMerge.ts # Merge WS contact updates into list
|
||||
│ ├── localLabel.ts # Local label (text + color) in localStorage
|
||||
│ ├── radioPresets.ts # LoRa radio preset configurations
|
||||
│ ├── publicChannel.ts # Public-channel resolution helpers for routing/hash defaults
|
||||
│ ├── fontScale.ts # Browser-local relative font scale persistence/application
|
||||
│ └── theme.ts # Theme switching helpers
|
||||
├── components/
|
||||
│ ├── StatusBar.tsx
|
||||
@@ -91,8 +101,12 @@ frontend/src/
|
||||
│ ├── NewMessageModal.tsx
|
||||
│ ├── SearchView.tsx # Full-text message search pane
|
||||
│ ├── SettingsModal.tsx # Layout shell — delegates to settings/ sections
|
||||
│ ├── SecurityWarningModal.tsx # Startup warning for trusted-network / bot execution posture
|
||||
│ ├── RawPacketList.tsx
|
||||
│ ├── RawPacketFeedView.tsx # Live raw packet feed + session stats drawer
|
||||
│ ├── RawPacketDetailModal.tsx # On-demand packet inspector dialog
|
||||
│ ├── MapView.tsx
|
||||
│ ├── TracePane.tsx # Multi-hop route trace builder/results view
|
||||
│ ├── VisualizerView.tsx
|
||||
│ ├── PacketVisualizer3D.tsx
|
||||
│ ├── PathModal.tsx
|
||||
@@ -102,15 +116,20 @@ frontend/src/
|
||||
│ ├── ContactAvatar.tsx
|
||||
│ ├── ContactInfoPane.tsx # Contact detail sheet (stats, name history, paths)
|
||||
│ ├── ContactStatusInfo.tsx # Contact status info component
|
||||
│ ├── ContactPathDiscoveryModal.tsx # Forward/return path discovery dialog
|
||||
│ ├── ContactRoutingOverrideModal.tsx # Manual direct-route override editor
|
||||
│ ├── RepeaterDashboard.tsx # Layout shell — delegates to repeater/ panes
|
||||
│ ├── RepeaterLogin.tsx # Repeater login form (password + guest)
|
||||
│ ├── RoomServerPanel.tsx # Room-server auth gate + status banner ahead of room chat
|
||||
│ ├── ServerLoginStatusBanner.tsx # Shared repeater/room login state banner
|
||||
│ ├── ChannelInfoPane.tsx # Channel detail sheet (stats, top senders)
|
||||
│ ├── ChannelFloodScopeOverrideModal.tsx # Per-channel flood-scope override editor
|
||||
│ ├── DirectTraceIcon.tsx # Shared direct-trace glyph used in header/dashboard
|
||||
│ ├── NeighborsMiniMap.tsx # Leaflet mini-map for repeater neighbor locations
|
||||
│ ├── settings/
|
||||
│ │ ├── settingsConstants.ts # Settings section type, ordering, labels
|
||||
│ │ ├── SettingsRadioSection.tsx # Name, keys, advert interval, max contacts, radio preset, freq/bw/sf/cr, txPower, lat/lon, reboot, mesh discovery
|
||||
│ │ ├── SettingsLocalSection.tsx # Browser-local settings: theme, local label, reopen last conversation
|
||||
│ │ ├── SettingsLocalSection.tsx # Browser-local settings: theme, relative font scale, local label, reopen last conversation
|
||||
│ │ ├── SettingsFanoutSection.tsx # Fanout integrations: MQTT, bots, config CRUD
|
||||
│ │ ├── SettingsDatabaseSection.tsx # DB size, cleanup, auto-decrypt, local label
|
||||
│ │ ├── SettingsStatisticsSection.tsx # Read-only mesh network stats
|
||||
@@ -130,12 +149,13 @@ frontend/src/
|
||||
│ └── ui/ # shadcn/ui primitives
|
||||
├── types/
|
||||
│ └── d3-force-3d.d.ts # Type declarations for d3-force-3d
|
||||
└── test/
|
||||
└── test/ # Representative frontend test suites (not an exhaustive listing)
|
||||
├── setup.ts
|
||||
├── fixtures/websocket_events.json
|
||||
├── api.test.ts
|
||||
├── appFavorites.test.tsx
|
||||
├── appStartupHash.test.tsx
|
||||
├── conversationPane.test.tsx
|
||||
├── contactAvatar.test.ts
|
||||
├── contactInfoPane.test.tsx
|
||||
├── integration.test.ts
|
||||
@@ -146,18 +166,23 @@ frontend/src/
|
||||
├── rawPacketList.test.tsx
|
||||
├── pathUtils.test.ts
|
||||
├── prefetch.test.ts
|
||||
├── rawPacketDetailModal.test.tsx
|
||||
├── rawPacketFeedView.test.tsx
|
||||
├── radioPresets.test.ts
|
||||
├── rawPacketIdentity.test.ts
|
||||
├── repeaterDashboard.test.tsx
|
||||
├── repeaterFormatters.test.ts
|
||||
├── repeaterLogin.test.tsx
|
||||
├── repeaterMessageParsing.test.ts
|
||||
├── roomServerPanel.test.tsx
|
||||
├── securityWarningModal.test.tsx
|
||||
├── localLabel.test.ts
|
||||
├── messageInput.test.tsx
|
||||
├── newMessageModal.test.tsx
|
||||
├── settingsModal.test.tsx
|
||||
├── sidebar.test.tsx
|
||||
├── statusBar.test.tsx
|
||||
├── tracePane.test.tsx
|
||||
├── unreadCounts.test.ts
|
||||
├── urlHash.test.ts
|
||||
├── appSearchJump.test.tsx
|
||||
@@ -169,12 +194,17 @@ frontend/src/
|
||||
├── useConversationMessages.race.test.ts
|
||||
├── useConversationNavigation.test.ts
|
||||
├── useAppShell.test.ts
|
||||
├── useBrowserNotifications.test.ts
|
||||
├── useFaviconBadge.test.ts
|
||||
├── useRepeaterDashboard.test.ts
|
||||
├── useRememberedServerPassword.test.ts
|
||||
├── useContactsAndChannels.test.ts
|
||||
├── useRealtimeAppState.test.ts
|
||||
├── useUnreadCounts.test.ts
|
||||
├── useWebSocket.dispatch.test.ts
|
||||
├── useWebSocket.lifecycle.test.ts
|
||||
├── rawPacketStats.test.ts
|
||||
├── fontScale.test.ts
|
||||
└── wsEvents.test.ts
|
||||
|
||||
```
|
||||
@@ -190,6 +220,7 @@ frontend/src/
|
||||
- search/settings surface switching
|
||||
- global cracker mount/focus behavior
|
||||
- new-message modal and info panes
|
||||
- trusted-network `SecurityWarningModal`
|
||||
|
||||
High-level state is delegated to hooks:
|
||||
- `useAppShell`: app-shell view state (settings section, sidebar, cracker, new-message modal)
|
||||
@@ -211,7 +242,9 @@ High-level state is delegated to hooks:
|
||||
- map view
|
||||
- visualizer
|
||||
- raw packet feed
|
||||
- trace view
|
||||
- repeater dashboard
|
||||
- room-server auth/status gate before room chat
|
||||
- normal chat chrome (`ChatHeader` + `MessageList` + `MessageInput`)
|
||||
|
||||
### Initial load + realtime
|
||||
@@ -272,12 +305,16 @@ Supported routes:
|
||||
- `#map/focus/{pubkey_or_prefix}`
|
||||
- `#visualizer`
|
||||
- `#search`
|
||||
- `#trace`
|
||||
- `#settings/{section}`
|
||||
- `#channel/{channelKey}`
|
||||
- `#channel/{channelKey}/{label}`
|
||||
- `#contact/{publicKey}`
|
||||
- `#contact/{publicKey}/{label}`
|
||||
|
||||
Legacy name-based hashes are still accepted for compatibility.
|
||||
Where `{section}` is one of `radio`, `local`, `fanout`, `database`, `statistics`, or `about`.
|
||||
|
||||
Legacy name-based channel/contact hashes are still accepted for compatibility.
|
||||
|
||||
## Conversation State Keys (`utils/conversationState.ts`)
|
||||
|
||||
@@ -377,6 +414,12 @@ For repeater contacts (`type=2`), `ConversationPane.tsx` renders `RepeaterDashbo
|
||||
|
||||
All state is managed by `useRepeaterDashboard` hook. State resets on conversation change.
|
||||
|
||||
## Room Server Panel
|
||||
|
||||
For room contacts (`type=3`), `ConversationPane.tsx` keeps the normal chat surface but inserts `RoomServerPanel` above it. That panel handles room-server login/status messaging and gates room chat behind the room-authenticated state when required.
|
||||
|
||||
`ServerLoginStatusBanner` is shared between repeater and room login surfaces for inline status/error display.
|
||||
|
||||
## Message Search Pane
|
||||
|
||||
The `SearchView` component (`components/SearchView.tsx`) provides full-text search across all DMs and channel messages. Key behaviors:
|
||||
@@ -404,7 +447,7 @@ Do not rely on old class-only layout assumptions.
|
||||
Run all quality checks (backend + frontend) from the repo root:
|
||||
|
||||
```bash
|
||||
./scripts/all_quality.sh
|
||||
./scripts/quality/all_quality.sh
|
||||
```
|
||||
|
||||
Or run frontend checks individually:
|
||||
|
||||
Generated
+384
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"version": "3.6.1",
|
||||
"version": "3.6.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"version": "3.6.1",
|
||||
"version": "3.6.2",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
@@ -30,6 +30,7 @@
|
||||
"react-dom": "^18.3.1",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-swipeable": "^7.0.2",
|
||||
"recharts": "^3.8.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
@@ -2057,6 +2058,42 @@
|
||||
"react-dom": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.0.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||
"version": "11.1.4",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
|
||||
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -2414,6 +2451,18 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
@@ -2564,6 +2613,24 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-force": {
|
||||
"version": "3.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
|
||||
@@ -2571,6 +2638,51 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -2663,6 +2775,12 @@
|
||||
"meshoptimizer": "~0.22.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/webxr": {
|
||||
"version": "0.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||
@@ -3712,12 +3830,33 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-binarytree": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz",
|
||||
"integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dispatch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||
@@ -3727,6 +3866,15 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-force": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
|
||||
@@ -3757,12 +3905,42 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-octree": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz",
|
||||
"integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-quadtree": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
|
||||
@@ -3772,6 +3950,58 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
@@ -3820,6 +4050,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deep-eql": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
||||
@@ -3974,6 +4210,16 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.45.1",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
|
||||
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||
@@ -4216,6 +4462,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||
@@ -4618,6 +4870,16 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -4655,6 +4917,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
@@ -5599,7 +5870,6 @@
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
@@ -5617,6 +5887,29 @@
|
||||
"react-dom": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
@@ -5726,6 +6019,36 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.8.1",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
|
||||
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"www"
|
||||
],
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^10.1.1",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.1.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||
@@ -5740,6 +6063,27 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
@@ -6134,6 +6478,12 @@
|
||||
"integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
@@ -6448,12 +6798,43 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"private": true,
|
||||
"version": "3.6.1",
|
||||
"version": "3.6.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -38,6 +38,7 @@
|
||||
"react-dom": "^18.3.1",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-swipeable": "^7.0.2",
|
||||
"recharts": "^3.8.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
|
||||
+43
-2
@@ -31,6 +31,12 @@ interface ChannelUnreadMarker {
|
||||
lastReadAt: number | null;
|
||||
}
|
||||
|
||||
interface NewMessagePrefillRequest {
|
||||
tab: 'hashtag';
|
||||
hashtagName: string;
|
||||
nonce: number;
|
||||
}
|
||||
|
||||
interface UnreadBoundaryBackfillParams {
|
||||
activeConversation: Conversation | null;
|
||||
unreadMarker: ChannelUnreadMarker | null;
|
||||
@@ -77,6 +83,8 @@ export function App() {
|
||||
const messageInputRef = useRef<MessageInputHandle>(null);
|
||||
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
|
||||
const [channelUnreadMarker, setChannelUnreadMarker] = useState<ChannelUnreadMarker | null>(null);
|
||||
const [newMessagePrefillRequest, setNewMessagePrefillRequest] =
|
||||
useState<NewMessagePrefillRequest | null>(null);
|
||||
const [visibilityVersion, setVisibilityVersion] = useState(0);
|
||||
const lastUnreadBackfillAttemptRef = useRef<string | null>(null);
|
||||
const {
|
||||
@@ -103,8 +111,8 @@ export function App() {
|
||||
setDistanceUnit,
|
||||
handleCloseSettingsView,
|
||||
handleToggleSettingsView,
|
||||
handleOpenNewMessage,
|
||||
handleCloseNewMessage,
|
||||
handleOpenNewMessage: openNewMessageModal,
|
||||
handleCloseNewMessage: closeNewMessageModal,
|
||||
handleToggleCracker,
|
||||
} = useAppShell();
|
||||
|
||||
@@ -274,6 +282,7 @@ export function App() {
|
||||
unreadLastReadAts,
|
||||
recordMessageEvent,
|
||||
renameConversationState,
|
||||
removeConversationState,
|
||||
markAllRead,
|
||||
refreshUnreads,
|
||||
} = useUnreadCounts(channels, contacts, activeConversation);
|
||||
@@ -349,6 +358,7 @@ export function App() {
|
||||
observeMessage,
|
||||
recordMessageEvent,
|
||||
renameConversationState,
|
||||
removeConversationState,
|
||||
checkMention,
|
||||
pendingDeleteFallbackRef,
|
||||
setActiveConversation,
|
||||
@@ -411,6 +421,34 @@ export function App() {
|
||||
[fetchUndecryptedCount, setChannels]
|
||||
);
|
||||
|
||||
const handleOpenNewMessage = useCallback(() => {
|
||||
setNewMessagePrefillRequest(null);
|
||||
openNewMessageModal();
|
||||
}, [openNewMessageModal]);
|
||||
|
||||
const handleCloseNewMessage = useCallback(() => {
|
||||
setNewMessagePrefillRequest(null);
|
||||
closeNewMessageModal();
|
||||
}, [closeNewMessageModal]);
|
||||
|
||||
const handleChannelReferenceClick = useCallback(
|
||||
(channelName: string) => {
|
||||
const existingChannel = channels.find((channel) => channel.name === channelName);
|
||||
if (existingChannel) {
|
||||
handleNavigateToChannel(existingChannel.key);
|
||||
return;
|
||||
}
|
||||
|
||||
setNewMessagePrefillRequest((previous) => ({
|
||||
tab: 'hashtag',
|
||||
hashtagName: channelName.slice(1),
|
||||
nonce: (previous?.nonce ?? 0) + 1,
|
||||
}));
|
||||
openNewMessageModal();
|
||||
},
|
||||
[channels, handleNavigateToChannel, openNewMessageModal]
|
||||
);
|
||||
|
||||
const statusProps = {
|
||||
health,
|
||||
config,
|
||||
@@ -457,6 +495,7 @@ export function App() {
|
||||
loadingNewer,
|
||||
messageInputRef,
|
||||
onTrace: handleTrace,
|
||||
onRunTracePath: api.requestRadioTrace,
|
||||
onPathDiscovery: handlePathDiscovery,
|
||||
onToggleFavorite: handleToggleFavorite,
|
||||
onDeleteContact: handleDeleteContact,
|
||||
@@ -465,6 +504,7 @@ export function App() {
|
||||
onOpenContactInfo: handleOpenContactInfo,
|
||||
onOpenChannelInfo: handleOpenChannelInfo,
|
||||
onSenderClick: handleSenderClick,
|
||||
onChannelReferenceClick: handleChannelReferenceClick,
|
||||
onLoadOlder: fetchOlderMessages,
|
||||
onResendChannelMessage: handleResendChannelMessage,
|
||||
onTargetReached: () => setTargetMessageId(null),
|
||||
@@ -523,6 +563,7 @@ export function App() {
|
||||
};
|
||||
const newMessageModalProps = {
|
||||
undecryptedCount,
|
||||
prefillRequest: newMessagePrefillRequest,
|
||||
onCreateContact: handleCreateContact,
|
||||
onCreateChannel: handleCreateChannel,
|
||||
onCreateHashtagChannel: handleCreateHashtagChannel,
|
||||
|
||||
+11
-2
@@ -20,6 +20,8 @@ import type {
|
||||
RadioConfig,
|
||||
RadioConfigUpdate,
|
||||
RadioDiscoveryResponse,
|
||||
RadioTraceHopRequest,
|
||||
RadioTraceResponse,
|
||||
RadioDiscoveryTarget,
|
||||
PathDiscoveryResponse,
|
||||
ResendChannelMessageResponse,
|
||||
@@ -107,6 +109,11 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ target }),
|
||||
}),
|
||||
requestRadioTrace: (hopHashBytes: 1 | 2 | 4, hops: RadioTraceHopRequest[]) =>
|
||||
fetchJson<RadioTraceResponse>('/radio/trace', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ hop_hash_bytes: hopHashBytes, hops }),
|
||||
}),
|
||||
rebootRadio: () =>
|
||||
fetchJson<{ status: string; message: string }>('/radio/reboot', {
|
||||
method: 'POST',
|
||||
@@ -130,11 +137,13 @@ export const api = {
|
||||
fetchJson<ContactAdvertPathSummary[]>(
|
||||
`/contacts/repeaters/advert-paths?limit_per_repeater=${limitPerRepeater}`
|
||||
),
|
||||
getContactAnalytics: (params: { publicKey?: string; name?: string }) => {
|
||||
getContactAnalytics: (params: { publicKey?: string; name?: string }, signal?: AbortSignal) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params.publicKey) searchParams.set('public_key', params.publicKey);
|
||||
if (params.name) searchParams.set('name', params.name);
|
||||
return fetchJson<ContactAnalytics>(`/contacts/analytics?${searchParams.toString()}`);
|
||||
return fetchJson<ContactAnalytics>(`/contacts/analytics?${searchParams.toString()}`, {
|
||||
signal,
|
||||
});
|
||||
},
|
||||
deleteContact: (publicKey: string) =>
|
||||
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
|
||||
|
||||
@@ -268,7 +268,7 @@ export function ChatHeader({
|
||||
title={
|
||||
activeContactIsPrefixOnly
|
||||
? 'Direct Trace unavailable until the full contact key is known'
|
||||
: 'Direct Trace. Send a zero-hop packet to this contact and display out and back SNR'
|
||||
: 'Direct Trace. Send a direct trace probe to this contact and display out and back SNR'
|
||||
}
|
||||
aria-label="Direct Trace"
|
||||
disabled={activeContactIsPrefixOnly}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
import { Ban, Search, Star } from 'lucide-react';
|
||||
import { api } from '../api';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip as RechartsTooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
import { api, isAbortError } from '../api';
|
||||
import { formatTime } from '../utils/messageParser';
|
||||
import {
|
||||
getContactDisplayName,
|
||||
@@ -100,29 +110,29 @@ export function ContactInfoPane({
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
setAnalytics(null);
|
||||
setLoading(true);
|
||||
const request =
|
||||
isNameOnly && nameOnlyValue
|
||||
? api.getContactAnalytics({ name: nameOnlyValue })
|
||||
: api.getContactAnalytics({ publicKey: contactKey });
|
||||
? api.getContactAnalytics({ name: nameOnlyValue }, controller.signal)
|
||||
: api.getContactAnalytics({ publicKey: contactKey }, controller.signal);
|
||||
|
||||
request
|
||||
.then((data) => {
|
||||
if (!cancelled) setAnalytics(data);
|
||||
if (!controller.signal.aborted) setAnalytics(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
if (!isAbortError(err)) {
|
||||
console.error('Failed to fetch contact analytics:', err);
|
||||
toast.error('Failed to load contact info');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [contactKey, isNameOnly, nameOnlyValue]);
|
||||
|
||||
@@ -650,20 +660,18 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu
|
||||
{hasHourlyActivity && (
|
||||
<div>
|
||||
<SectionLabel>Messages Per Hour</SectionLabel>
|
||||
<ChartLegend
|
||||
items={[
|
||||
{ label: 'Last 24h', color: '#2563eb' },
|
||||
{ label: '7-day avg', color: '#ea580c' },
|
||||
{ label: 'All-time avg', color: '#64748b' },
|
||||
]}
|
||||
/>
|
||||
<ActivityLineChart
|
||||
ariaLabel="Messages per hour"
|
||||
points={analytics.hourly_activity}
|
||||
series={[
|
||||
{ key: 'last_24h_count', color: '#2563eb' },
|
||||
{ key: 'last_week_average', color: '#ea580c' },
|
||||
{ key: 'all_time_average', color: '#64748b' },
|
||||
{ key: 'last_24h_count', color: '#2563eb', label: 'Last 24h' },
|
||||
{ key: 'last_week_average', color: '#ea580c', label: '7-day avg' },
|
||||
{ key: 'all_time_average', color: '#64748b', label: 'All-time avg' },
|
||||
]}
|
||||
legendItems={[
|
||||
{ label: 'Last 24h', color: '#2563eb' },
|
||||
{ label: '7-day avg', color: '#ea580c' },
|
||||
{ label: 'All-time avg', color: '#64748b' },
|
||||
]}
|
||||
valueFormatter={(value) => value.toFixed(value % 1 === 0 ? 0 : 1)}
|
||||
tickFormatter={(bucket) =>
|
||||
@@ -683,7 +691,7 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu
|
||||
<ActivityLineChart
|
||||
ariaLabel="Messages per week"
|
||||
points={analytics.weekly_activity}
|
||||
series={[{ key: 'message_count', color: '#16a34a' }]}
|
||||
series={[{ key: 'message_count', color: '#16a34a', label: 'Messages' }]}
|
||||
valueFormatter={(value) => value.toFixed(0)}
|
||||
tickFormatter={(bucket) =>
|
||||
new Date(bucket.bucket_start * 1000).toLocaleDateString([], {
|
||||
@@ -705,133 +713,115 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu
|
||||
);
|
||||
}
|
||||
|
||||
function ChartLegend({ items }: { items: Array<{ label: string; color: string }> }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 mb-2 text-[11px] text-muted-foreground">
|
||||
{items.map((item) => (
|
||||
<span key={item.label} className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: item.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{item.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const TOOLTIP_STYLE = {
|
||||
contentStyle: {
|
||||
backgroundColor: 'hsl(var(--popover))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
fontSize: '11px',
|
||||
color: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
itemStyle: { color: 'hsl(var(--popover-foreground))' },
|
||||
labelStyle: { color: 'hsl(var(--muted-foreground))' },
|
||||
} as const;
|
||||
|
||||
function ActivityLineChart<T extends ContactAnalyticsHourlyBucket | ContactAnalyticsWeeklyBucket>({
|
||||
ariaLabel,
|
||||
points,
|
||||
series,
|
||||
legendItems,
|
||||
tickFormatter,
|
||||
valueFormatter,
|
||||
}: {
|
||||
ariaLabel: string;
|
||||
points: T[];
|
||||
series: Array<{ key: keyof T; color: string }>;
|
||||
series: Array<{ key: keyof T; color: string; label?: string }>;
|
||||
legendItems?: Array<{ label: string; color: string }>;
|
||||
tickFormatter: (point: T) => string;
|
||||
valueFormatter: (value: number) => string;
|
||||
}) {
|
||||
const width = 320;
|
||||
const height = 132;
|
||||
const padding = { top: 8, right: 8, bottom: 24, left: 32 };
|
||||
const plotWidth = width - padding.left - padding.right;
|
||||
const plotHeight = height - padding.top - padding.bottom;
|
||||
const allValues = points.flatMap((point) =>
|
||||
series.map((entry) => {
|
||||
const value = point[entry.key];
|
||||
return typeof value === 'number' ? value : 0;
|
||||
})
|
||||
);
|
||||
const maxValue = Math.max(1, ...allValues);
|
||||
const tickIndices = Array.from(
|
||||
new Set([
|
||||
0,
|
||||
Math.floor((points.length - 1) / 3),
|
||||
Math.floor(((points.length - 1) * 2) / 3),
|
||||
points.length - 1,
|
||||
])
|
||||
);
|
||||
const data = points.map((point, i) => {
|
||||
const entry: Record<string, string | number> = { idx: i, tick: tickFormatter(point) };
|
||||
for (const s of series) {
|
||||
const raw = point[s.key];
|
||||
entry[String(s.key)] = typeof raw === 'number' ? raw : 0;
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
|
||||
const buildPolyline = (key: keyof T) =>
|
||||
points
|
||||
.map((point, index) => {
|
||||
const rawValue = point[key];
|
||||
const value = typeof rawValue === 'number' ? rawValue : 0;
|
||||
const x =
|
||||
padding.left + (points.length === 1 ? 0 : (index / (points.length - 1)) * plotWidth);
|
||||
const y = padding.top + plotHeight - (value / maxValue) * plotHeight;
|
||||
return `${x},${y}`;
|
||||
})
|
||||
.join(' ');
|
||||
const tickCount = Math.min(5, points.length);
|
||||
const tickIndices: number[] = [];
|
||||
if (points.length > 1) {
|
||||
for (let i = 0; i < tickCount; i++) {
|
||||
tickIndices.push(Math.round((i / (tickCount - 1)) * (points.length - 1)));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="w-full h-auto"
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{[0, 0.5, 1].map((ratio) => {
|
||||
const y = padding.top + plotHeight - ratio * plotHeight;
|
||||
const value = maxValue * ratio;
|
||||
return (
|
||||
<g key={ratio}>
|
||||
<line
|
||||
x1={padding.left}
|
||||
x2={width - padding.right}
|
||||
y1={y}
|
||||
y2={y}
|
||||
stroke="hsl(var(--border))"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<text
|
||||
x={padding.left - 6}
|
||||
y={y + 4}
|
||||
fontSize="10"
|
||||
textAnchor="end"
|
||||
fill="hsl(var(--muted-foreground))"
|
||||
>
|
||||
{valueFormatter(value)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{series.map((entry) => (
|
||||
<polyline
|
||||
key={String(entry.key)}
|
||||
fill="none"
|
||||
stroke={entry.color}
|
||||
strokeWidth="2"
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
points={buildPolyline(entry.key)}
|
||||
<div role="img" aria-label={ariaLabel}>
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<LineChart data={data} margin={{ top: 4, right: 4, bottom: 0, left: -16 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="idx"
|
||||
type="number"
|
||||
domain={[0, Math.max(1, points.length - 1)]}
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
ticks={tickIndices}
|
||||
tickFormatter={(idx) => String(data[idx]?.tick ?? '')}
|
||||
/>
|
||||
))}
|
||||
|
||||
{tickIndices.map((index) => {
|
||||
const point = points[index];
|
||||
const x =
|
||||
padding.left + (points.length === 1 ? 0 : (index / (points.length - 1)) * plotWidth);
|
||||
return (
|
||||
<text
|
||||
key={`${ariaLabel}-${point.bucket_start}`}
|
||||
x={x}
|
||||
y={height - 6}
|
||||
fontSize="10"
|
||||
textAnchor={index === 0 ? 'start' : index === points.length - 1 ? 'end' : 'middle'}
|
||||
fill="hsl(var(--muted-foreground))"
|
||||
>
|
||||
{tickFormatter(point)}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(v) => valueFormatter(v)}
|
||||
width={40}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
{...TOOLTIP_STYLE}
|
||||
cursor={{
|
||||
stroke: 'hsl(var(--muted-foreground))',
|
||||
strokeWidth: 1,
|
||||
strokeDasharray: '3 3',
|
||||
}}
|
||||
labelFormatter={(idx) => String(data[Number(idx)]?.tick ?? '')}
|
||||
formatter={(value, name) => {
|
||||
const match = series.find((s) => String(s.key) === name);
|
||||
return [valueFormatter(Number(value)), match?.label ?? String(name)];
|
||||
}}
|
||||
/>
|
||||
{legendItems && (
|
||||
<Legend
|
||||
content={() => (
|
||||
<div className="flex flex-wrap justify-center gap-x-3 gap-y-1 mt-1 text-[11px] text-muted-foreground">
|
||||
{legendItems.map((item) => (
|
||||
<span key={item.label} className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
{item.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{series.map((entry) => (
|
||||
<Line
|
||||
key={String(entry.key)}
|
||||
type="linear"
|
||||
dataKey={String(entry.key)}
|
||||
stroke={entry.color}
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
activeDot={{ r: 4, strokeWidth: 2, stroke: 'hsl(var(--popover))' }}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { MessageInput, type MessageInputHandle } from './MessageInput';
|
||||
import { MessageList } from './MessageList';
|
||||
import { RawPacketFeedView } from './RawPacketFeedView';
|
||||
import { RoomServerPanel } from './RoomServerPanel';
|
||||
import { TracePane } from './TracePane';
|
||||
import type {
|
||||
Channel,
|
||||
Contact,
|
||||
@@ -15,6 +16,8 @@ import type {
|
||||
PathDiscoveryResponse,
|
||||
RawPacket,
|
||||
RadioConfig,
|
||||
RadioTraceHopRequest,
|
||||
RadioTraceResponse,
|
||||
} from '../types';
|
||||
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
|
||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
|
||||
@@ -50,6 +53,10 @@ interface ConversationPaneProps {
|
||||
loadingNewer: boolean;
|
||||
messageInputRef: Ref<MessageInputHandle>;
|
||||
onTrace: () => Promise<void>;
|
||||
onRunTracePath: (
|
||||
hopHashBytes: 1 | 2 | 4,
|
||||
hops: RadioTraceHopRequest[]
|
||||
) => Promise<RadioTraceResponse>;
|
||||
onPathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
|
||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => Promise<void>;
|
||||
onDeleteContact: (publicKey: string) => Promise<void>;
|
||||
@@ -58,6 +65,7 @@ interface ConversationPaneProps {
|
||||
onOpenContactInfo: (publicKey: string, fromChannel?: boolean) => void;
|
||||
onOpenChannelInfo: (channelKey: string) => void;
|
||||
onSenderClick: (sender: string) => void;
|
||||
onChannelReferenceClick?: (channelName: string) => void;
|
||||
onLoadOlder: () => Promise<void>;
|
||||
onResendChannelMessage: (messageId: number, newTimestamp?: boolean) => Promise<void>;
|
||||
onTargetReached: () => void;
|
||||
@@ -115,6 +123,7 @@ export function ConversationPane({
|
||||
loadingNewer,
|
||||
messageInputRef,
|
||||
onTrace,
|
||||
onRunTracePath,
|
||||
onPathDiscovery,
|
||||
onToggleFavorite,
|
||||
onDeleteContact,
|
||||
@@ -123,6 +132,7 @@ export function ConversationPane({
|
||||
onOpenContactInfo,
|
||||
onOpenChannelInfo,
|
||||
onSenderClick,
|
||||
onChannelReferenceClick,
|
||||
onLoadOlder,
|
||||
onResendChannelMessage,
|
||||
onTargetReached,
|
||||
@@ -200,6 +210,10 @@ export function ConversationPane({
|
||||
return null;
|
||||
}
|
||||
|
||||
if (activeConversation.type === 'trace') {
|
||||
return <TracePane contacts={contacts} config={config} onRunTracePath={onRunTracePath} />;
|
||||
}
|
||||
|
||||
if (activeContactIsRepeater) {
|
||||
return (
|
||||
<Suspense fallback={<LoadingPane label="Loading dashboard..." />}>
|
||||
@@ -272,6 +286,7 @@ export function ConversationPane({
|
||||
activeConversation.type === 'channel' ? onDismissUnreadMarker : undefined
|
||||
}
|
||||
onSenderClick={activeConversation.type === 'channel' ? onSenderClick : undefined}
|
||||
onChannelReferenceClick={onChannelReferenceClick}
|
||||
onLoadOlder={onLoadOlder}
|
||||
onResendChannelMessage={
|
||||
activeConversation.type === 'channel' ? onResendChannelMessage : undefined
|
||||
|
||||
@@ -39,6 +39,7 @@ export function CrackerPanel({
|
||||
}: CrackerPanelProps) {
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [maxLength, setMaxLength] = useState(6);
|
||||
const [maxLengthInput, setMaxLengthInput] = useState('6');
|
||||
const [retryFailedAtNextLength, setRetryFailedAtNextLength] = useState(false);
|
||||
const [decryptHistorical, setDecryptHistorical] = useState(true);
|
||||
const [turboMode, setTurboMode] = useState(false);
|
||||
@@ -127,8 +128,9 @@ export function CrackerPanel({
|
||||
}, [existingChannelKeys]);
|
||||
|
||||
// Filter packets to only undecrypted GROUP_TEXT
|
||||
const undecryptedGroupText = packets.filter(
|
||||
(p) => p.payload_type === 'GROUP_TEXT' && !p.decrypted
|
||||
const undecryptedGroupText = useMemo(
|
||||
() => packets.filter((p) => p.payload_type === 'GROUP_TEXT' && !p.decrypted),
|
||||
[packets]
|
||||
);
|
||||
|
||||
// Update queue when packets change (deduplicated by payload)
|
||||
@@ -191,6 +193,10 @@ export function CrackerPanel({
|
||||
maxLengthRef.current = maxLength;
|
||||
}, [maxLength]);
|
||||
|
||||
useEffect(() => {
|
||||
setMaxLengthInput(String(maxLength));
|
||||
}, [maxLength]);
|
||||
|
||||
useEffect(() => {
|
||||
decryptHistoricalRef.current = decryptHistorical;
|
||||
}, [decryptHistorical]);
|
||||
@@ -434,8 +440,25 @@ export function CrackerPanel({
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={maxLength}
|
||||
onChange={(e) => setMaxLength(Math.min(10, Math.max(1, parseInt(e.target.value) || 6)))}
|
||||
value={maxLengthInput}
|
||||
onChange={(e) => {
|
||||
const nextValue = e.target.value;
|
||||
setMaxLengthInput(nextValue);
|
||||
if (nextValue === '') return;
|
||||
const parsed = Number.parseInt(nextValue, 10);
|
||||
if (Number.isNaN(parsed)) return;
|
||||
setMaxLength(Math.min(10, Math.max(1, parsed)));
|
||||
}}
|
||||
onBlur={() => {
|
||||
const parsed = Number.parseInt(maxLengthInput, 10);
|
||||
const nextValue = Number.isNaN(parsed)
|
||||
? maxLength
|
||||
: Math.min(10, Math.max(1, parsed));
|
||||
setMaxLengthInput(String(nextValue));
|
||||
if (nextValue !== maxLength) {
|
||||
setMaxLength(nextValue);
|
||||
}
|
||||
}}
|
||||
className="w-14 px-2 py-1 text-sm bg-muted border border-border rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -131,9 +131,23 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
||||
|
||||
// Store ref for a marker
|
||||
const setMarkerRef = useCallback((key: string, ref: LeafletCircleMarker | null) => {
|
||||
if (ref === null) {
|
||||
delete markerRefs.current[key];
|
||||
return;
|
||||
}
|
||||
|
||||
markerRefs.current[key] = ref;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const currentKeys = new Set(mappableContacts.map((contact) => contact.public_key));
|
||||
for (const key of Object.keys(markerRefs.current)) {
|
||||
if (!currentKeys.has(key)) {
|
||||
delete markerRefs.current[key];
|
||||
}
|
||||
}
|
||||
}, [mappableContacts]);
|
||||
|
||||
// Open popup for focused contact after map is ready
|
||||
useEffect(() => {
|
||||
if (focusedContact && markerRefs.current[focusedContact.public_key]) {
|
||||
|
||||
@@ -11,7 +11,11 @@ import {
|
||||
import type { Channel, Contact, Message, MessagePath, RadioConfig, RawPacket } from '../types';
|
||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
|
||||
import { api } from '../api';
|
||||
import { formatTime, parseSenderFromText } from '../utils/messageParser';
|
||||
import {
|
||||
findLinkedChannelReferences,
|
||||
formatTime,
|
||||
parseSenderFromText,
|
||||
} from '../utils/messageParser';
|
||||
import { formatHopCounts, type SenderInfo } from '../utils/pathUtils';
|
||||
import { getDirectContactRoute } from '../utils/pathUtils';
|
||||
import { ContactAvatar } from './ContactAvatar';
|
||||
@@ -33,6 +37,7 @@ interface MessageListProps {
|
||||
onSenderClick?: (sender: string) => void;
|
||||
onLoadOlder?: () => void;
|
||||
onResendChannelMessage?: (messageId: number, newTimestamp?: boolean) => void;
|
||||
onChannelReferenceClick?: (channelName: string) => void;
|
||||
radioName?: string;
|
||||
config?: RadioConfig | null;
|
||||
onOpenContactInfo?: (publicKey: string, fromChannel?: boolean) => void;
|
||||
@@ -48,8 +53,64 @@ interface MessageListProps {
|
||||
const URL_PATTERN =
|
||||
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/g;
|
||||
|
||||
// Helper to convert URLs in a plain text string into clickable links
|
||||
function linkifyText(text: string, keyPrefix: string): ReactNode[] {
|
||||
function renderChannelReferences(
|
||||
text: string,
|
||||
keyPrefix: string,
|
||||
onChannelReferenceClick?: (channelName: string) => void
|
||||
): ReactNode[] {
|
||||
const references = findLinkedChannelReferences(text);
|
||||
if (references.length === 0) {
|
||||
return [text];
|
||||
}
|
||||
|
||||
const parts: ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
references.forEach((reference, index) => {
|
||||
if (reference.start > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, reference.start));
|
||||
}
|
||||
|
||||
const className =
|
||||
'rounded px-0.5 font-medium text-primary underline underline-offset-2 transition-colors';
|
||||
if (onChannelReferenceClick) {
|
||||
parts.push(
|
||||
<button
|
||||
key={`${keyPrefix}-channel-${index}`}
|
||||
type="button"
|
||||
className={cn(
|
||||
className,
|
||||
'inline border-0 bg-transparent p-0 align-baseline hover:text-primary/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
||||
)}
|
||||
onClick={() => onChannelReferenceClick(reference.label)}
|
||||
>
|
||||
{reference.label}
|
||||
</button>
|
||||
);
|
||||
} else {
|
||||
parts.push(
|
||||
<span key={`${keyPrefix}-channel-${index}`} className={className}>
|
||||
{reference.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
lastIndex = reference.end;
|
||||
});
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
// Helper to convert URLs and channel references in a plain text string into rich content
|
||||
function linkifyText(
|
||||
text: string,
|
||||
keyPrefix: string,
|
||||
onChannelReferenceClick?: (channelName: string) => void
|
||||
): ReactNode[] {
|
||||
const parts: ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
@@ -58,7 +119,13 @@ function linkifyText(text: string, keyPrefix: string): ReactNode[] {
|
||||
URL_PATTERN.lastIndex = 0;
|
||||
while ((match = URL_PATTERN.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
parts.push(
|
||||
...renderChannelReferences(
|
||||
text.slice(lastIndex, match.index),
|
||||
`${keyPrefix}-text-${keyIndex}`,
|
||||
onChannelReferenceClick
|
||||
)
|
||||
);
|
||||
}
|
||||
parts.push(
|
||||
<a
|
||||
@@ -74,15 +141,27 @@ function linkifyText(text: string, keyPrefix: string): ReactNode[] {
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (lastIndex === 0) return [text];
|
||||
if (lastIndex === 0) {
|
||||
return renderChannelReferences(text, keyPrefix, onChannelReferenceClick);
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
parts.push(
|
||||
...renderChannelReferences(
|
||||
text.slice(lastIndex),
|
||||
`${keyPrefix}-tail`,
|
||||
onChannelReferenceClick
|
||||
)
|
||||
);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
// Helper to render text with highlighted @[Name] mentions and clickable URLs
|
||||
function renderTextWithMentions(text: string, radioName?: string): ReactNode {
|
||||
function renderTextWithMentions(
|
||||
text: string,
|
||||
radioName?: string,
|
||||
onChannelReferenceClick?: (channelName: string) => void
|
||||
): ReactNode {
|
||||
const mentionPattern = /@\[([^\]]+)\]/g;
|
||||
const parts: ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
@@ -92,7 +171,13 @@ function renderTextWithMentions(text: string, radioName?: string): ReactNode {
|
||||
while ((match = mentionPattern.exec(text)) !== null) {
|
||||
// Add text before the match (with linkification)
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(...linkifyText(text.slice(lastIndex, match.index), `pre-${keyIndex}`));
|
||||
parts.push(
|
||||
...linkifyText(
|
||||
text.slice(lastIndex, match.index),
|
||||
`pre-${keyIndex}`,
|
||||
onChannelReferenceClick
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const mentionedName = match[1];
|
||||
@@ -115,7 +200,7 @@ function renderTextWithMentions(text: string, radioName?: string): ReactNode {
|
||||
|
||||
// Add remaining text after last match (with linkification)
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(...linkifyText(text.slice(lastIndex), `post-${keyIndex}`));
|
||||
parts.push(...linkifyText(text.slice(lastIndex), `post-${keyIndex}`, onChannelReferenceClick));
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts : text;
|
||||
@@ -188,6 +273,7 @@ export function MessageList({
|
||||
onSenderClick,
|
||||
onLoadOlder,
|
||||
onResendChannelMessage,
|
||||
onChannelReferenceClick,
|
||||
radioName,
|
||||
config,
|
||||
onOpenContactInfo,
|
||||
@@ -373,7 +459,22 @@ export function MessageList({
|
||||
}
|
||||
}
|
||||
|
||||
setResendableIds(newResendable);
|
||||
setResendableIds((prev) => {
|
||||
if (prev.size === newResendable.size) {
|
||||
let changed = false;
|
||||
for (const id of newResendable) {
|
||||
if (!prev.has(id)) {
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!changed) {
|
||||
return prev;
|
||||
}
|
||||
}
|
||||
|
||||
return newResendable;
|
||||
});
|
||||
|
||||
return () => {
|
||||
for (const timer of timers.values()) clearTimeout(timer);
|
||||
@@ -896,7 +997,7 @@ export function MessageList({
|
||||
<div className="break-words whitespace-pre-wrap">
|
||||
{content.split('\n').map((line, i, arr) => (
|
||||
<span key={i}>
|
||||
{renderTextWithMentions(line, radioName)}
|
||||
{renderTextWithMentions(line, radioName, onChannelReferenceClick)}
|
||||
{i < arr.length - 1 && <br />}
|
||||
</span>
|
||||
))}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Dice5 } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -20,6 +20,11 @@ type Tab = 'new-contact' | 'new-channel' | 'hashtag';
|
||||
interface NewMessageModalProps {
|
||||
open: boolean;
|
||||
undecryptedCount: number;
|
||||
prefillRequest?: {
|
||||
tab: 'hashtag';
|
||||
hashtagName: string;
|
||||
nonce: number;
|
||||
} | null;
|
||||
onClose: () => void;
|
||||
onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>;
|
||||
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
|
||||
@@ -29,6 +34,7 @@ interface NewMessageModalProps {
|
||||
export function NewMessageModal({
|
||||
open,
|
||||
undecryptedCount,
|
||||
prefillRequest = null,
|
||||
onClose,
|
||||
onCreateContact,
|
||||
onCreateChannel,
|
||||
@@ -53,6 +59,24 @@ export function NewMessageModal({
|
||||
setError('');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !prefillRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTab(prefillRequest.tab);
|
||||
setName(prefillRequest.hashtagName);
|
||||
setContactKey('');
|
||||
setChannelKey('');
|
||||
setTryHistorical(false);
|
||||
setPermitCapitals(false);
|
||||
setError('');
|
||||
setLoading(false);
|
||||
requestAnimationFrame(() => {
|
||||
hashtagInputRef.current?.focus();
|
||||
});
|
||||
}, [open, prefillRequest]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
@@ -81,13 +81,14 @@ export function PathModal({
|
||||
) : hasSinglePath ? (
|
||||
<>
|
||||
This shows <em>one route</em> that this message traveled through the mesh network.
|
||||
Repeaters may be incorrectly identified due to prefix collisions between heard and
|
||||
non-heard repeater advertisements.
|
||||
Repeater identities are inferred from locally known advert and path data, so some
|
||||
hops may be missing or misidentified when that data is incomplete.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
This message was received via <strong>{paths.length} different routes</strong>.
|
||||
Repeaters may be incorrectly identified due to prefix collisions.
|
||||
Repeater identities are inferred from locally known advert and path data, so some
|
||||
hops may be missing or misidentified when that data is incomplete.
|
||||
</>
|
||||
)}
|
||||
</DialogDescription>
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip as RechartsTooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
} from 'recharts';
|
||||
|
||||
import { RawPacketList } from './RawPacketList';
|
||||
import { RawPacketInspectorDialog } from './RawPacketDetailModal';
|
||||
@@ -24,6 +34,18 @@ interface RawPacketFeedViewProps {
|
||||
channels: Channel[];
|
||||
}
|
||||
|
||||
const TOOLTIP_STYLE = {
|
||||
contentStyle: {
|
||||
backgroundColor: 'hsl(var(--popover))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
fontSize: '11px',
|
||||
color: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
itemStyle: { color: 'hsl(var(--popover-foreground))' },
|
||||
labelStyle: { color: 'hsl(var(--muted-foreground))' },
|
||||
} as const;
|
||||
|
||||
const WINDOW_LABELS: Record<RawPacketStatsWindow, string> = {
|
||||
'1m': '1 min',
|
||||
'5m': '5 min',
|
||||
@@ -32,13 +54,7 @@ const WINDOW_LABELS: Record<RawPacketStatsWindow, string> = {
|
||||
session: 'Session',
|
||||
};
|
||||
|
||||
const TIMELINE_COLORS = [
|
||||
'bg-sky-500/80',
|
||||
'bg-emerald-500/80',
|
||||
'bg-amber-500/80',
|
||||
'bg-rose-500/80',
|
||||
'bg-violet-500/80',
|
||||
];
|
||||
const TIMELINE_FILL_COLORS = ['#0ea5e9', '#10b981', '#f59e0b', '#f43f5e', '#8b5cf6'];
|
||||
|
||||
function formatTimestamp(timestampMs: number): string {
|
||||
return new Date(timestampMs).toLocaleString([], {
|
||||
@@ -155,24 +171,17 @@ function isNeighborIdentityResolvable(item: NeighborStat, contacts: Contact[]):
|
||||
return resolveContact(item.key, contacts) !== null;
|
||||
}
|
||||
|
||||
function formatStrongestPacketDetail(
|
||||
function formatStrongestNeighborDetail(
|
||||
stats: ReturnType<typeof buildRawPacketStatsSnapshot>,
|
||||
contacts: Contact[]
|
||||
): string | undefined {
|
||||
if (!stats.strongestPacketPayloadType) {
|
||||
const strongestNeighbor = stats.strongestNeighbors[0];
|
||||
if (!strongestNeighbor || strongestNeighbor.bestRssi === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const resolvedLabel =
|
||||
resolveContactLabel(stats.strongestPacketSourceKey, contacts) ??
|
||||
stats.strongestPacketSourceLabel;
|
||||
if (resolvedLabel) {
|
||||
return `${resolvedLabel} · ${stats.strongestPacketPayloadType}`;
|
||||
}
|
||||
if (stats.strongestPacketPayloadType === 'GroupText') {
|
||||
return '<unknown sender> · GroupText';
|
||||
}
|
||||
return stats.strongestPacketPayloadType;
|
||||
const resolvedNeighbor = resolveNeighbor(strongestNeighbor, contacts);
|
||||
return `${formatRssi(resolvedNeighbor.bestRssi)} best heard`;
|
||||
}
|
||||
|
||||
function getCoverageMessage(
|
||||
@@ -220,7 +229,13 @@ function RankedBars({
|
||||
emptyLabel: string;
|
||||
formatter?: (item: RankedPacketStat) => string;
|
||||
}) {
|
||||
const maxCount = Math.max(...items.map((item) => item.count), 1);
|
||||
const data = items.map((item) => ({
|
||||
name: item.label,
|
||||
value: item.count,
|
||||
detail: formatter
|
||||
? formatter(item)
|
||||
: `${item.count.toLocaleString()} · ${formatPercent(item.share)}`,
|
||||
}));
|
||||
|
||||
return (
|
||||
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
|
||||
@@ -228,25 +243,36 @@ function RankedBars({
|
||||
{items.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-muted-foreground">{emptyLabel}</p>
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{items.map((item) => (
|
||||
<div key={item.label}>
|
||||
<div className="mb-1 flex items-center justify-between gap-3 text-xs">
|
||||
<span className="truncate text-foreground">{item.label}</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||
{formatter
|
||||
? formatter(item)
|
||||
: `${item.count.toLocaleString()} · ${formatPercent(item.share)}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary/80"
|
||||
style={{ width: `${(item.count / maxCount) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-2">
|
||||
<ResponsiveContainer width="100%" height={items.length * 28 + 8}>
|
||||
<BarChart
|
||||
data={data}
|
||||
layout="vertical"
|
||||
margin={{ top: 0, right: 4, bottom: 0, left: 0 }}
|
||||
barCategoryGap="20%"
|
||||
>
|
||||
<XAxis type="number" hide />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={80}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
{...TOOLTIP_STYLE}
|
||||
cursor={{ fill: 'hsl(var(--muted))', opacity: 0.5 }}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
formatter={(_v: any, _n: any, props: any) => [props.payload.detail, null]}
|
||||
/>
|
||||
<Bar dataKey="value" radius={[0, 4, 4, 0]} maxBarSize={16}>
|
||||
{data.map((_, i) => (
|
||||
<Cell key={i} fill={TIMELINE_FILL_COLORS[i % TIMELINE_FILL_COLORS.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@@ -320,53 +346,66 @@ function NeighborList({
|
||||
}
|
||||
|
||||
function TimelineChart({ bins }: { bins: PacketTimelineBin[] }) {
|
||||
const maxTotal = Math.max(...bins.map((bin) => bin.total), 1);
|
||||
const typeOrder = Array.from(new Set(bins.flatMap((bin) => Object.keys(bin.countsByType)))).slice(
|
||||
0,
|
||||
TIMELINE_COLORS.length
|
||||
TIMELINE_FILL_COLORS.length
|
||||
);
|
||||
|
||||
const data = bins.map((bin) => {
|
||||
const entry: Record<string, string | number> = { label: bin.label };
|
||||
for (const type of typeOrder) {
|
||||
entry[type] = bin.countsByType[type] ?? 0;
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold text-foreground">Traffic Timeline</h3>
|
||||
<div className="flex flex-wrap justify-end gap-2 text-[11px] text-muted-foreground">
|
||||
{typeOrder.map((type, index) => (
|
||||
{typeOrder.map((type, i) => (
|
||||
<span key={type} className="inline-flex items-center gap-1">
|
||||
<span className={cn('h-2 w-2 rounded-full', TIMELINE_COLORS[index])} />
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: TIMELINE_FILL_COLORS[i] }}
|
||||
/>
|
||||
<span>{type}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-start gap-1">
|
||||
{bins.map((bin, index) => (
|
||||
<div
|
||||
key={`${bin.label}-${index}`}
|
||||
className="flex min-w-0 flex-1 flex-col items-center gap-1"
|
||||
>
|
||||
<div className="flex h-24 w-full items-end overflow-hidden rounded-sm bg-muted/60">
|
||||
<div className="flex h-full w-full flex-col justify-end">
|
||||
{typeOrder.map((type, index) => {
|
||||
const count = bin.countsByType[type] ?? 0;
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<div
|
||||
key={type}
|
||||
className={cn('w-full', TIMELINE_COLORS[index])}
|
||||
style={{
|
||||
height: `${(count / maxTotal) * 100}%`,
|
||||
}}
|
||||
title={`${bin.label}: ${type} ${count.toLocaleString()}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">{bin.label}</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-2">
|
||||
<ResponsiveContainer width="100%" height={110}>
|
||||
<BarChart data={data} margin={{ top: 4, right: 0, bottom: 0, left: -24 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
{...TOOLTIP_STYLE}
|
||||
cursor={{ fill: 'hsl(var(--muted))', opacity: 0.5 }}
|
||||
/>
|
||||
{typeOrder.map((type, i) => (
|
||||
<Bar
|
||||
key={type}
|
||||
dataKey={type}
|
||||
stackId="packets"
|
||||
fill={TIMELINE_FILL_COLORS[i]}
|
||||
radius={i === typeOrder.length - 1 ? [2, 2, 0, 0] : undefined}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -404,8 +443,13 @@ export function RawPacketFeedView({
|
||||
[nowSec, rawPacketStatsSession, selectedWindow]
|
||||
);
|
||||
const coverageMessage = getCoverageMessage(stats, rawPacketStatsSession);
|
||||
const strongestPacketDetail = useMemo(
|
||||
() => formatStrongestPacketDetail(stats, contacts),
|
||||
const strongestNeighbor = useMemo(() => {
|
||||
const topNeighbor = stats.strongestNeighbors[0];
|
||||
return topNeighbor ? resolveNeighbor(topNeighbor, contacts) : null;
|
||||
}, [contacts, stats]);
|
||||
|
||||
const strongestNeighborDetail = useMemo(
|
||||
() => formatStrongestNeighborDetail(stats, contacts),
|
||||
[contacts, stats]
|
||||
);
|
||||
const strongestNeighbors = useMemo(
|
||||
@@ -532,9 +576,9 @@ export function RawPacketFeedView({
|
||||
detail={`${formatPercent(stats.pathBearingRate)} path-bearing packets`}
|
||||
/>
|
||||
<StatTile
|
||||
label="Best RSSI"
|
||||
value={formatRssi(stats.bestRssi)}
|
||||
detail={strongestPacketDetail ?? 'No signal sample in window'}
|
||||
label="Strongest Neighbor"
|
||||
value={strongestNeighbor?.label ?? '-'}
|
||||
detail={strongestNeighborDetail ?? 'No neighbor RSSI sample in window'}
|
||||
/>
|
||||
<StatTile
|
||||
label="Median RSSI"
|
||||
|
||||
@@ -174,7 +174,11 @@ export function SearchView({
|
||||
api
|
||||
.getMessages({ q: debouncedQuery, limit: SEARCH_PAGE_SIZE, offset }, controller.signal)
|
||||
.then((data) => {
|
||||
setResults((prev) => [...prev, ...(data as SearchResult[])]);
|
||||
setResults((prev) => {
|
||||
const existingIds = new Set(prev.map((r) => r.id));
|
||||
const unique = (data as SearchResult[]).filter((r) => !existingIds.has(r.id));
|
||||
return [...prev, ...unique];
|
||||
});
|
||||
setHasMore(data.length >= SEARCH_PAGE_SIZE);
|
||||
setOffset((prev) => prev + data.length);
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Bell,
|
||||
Cable,
|
||||
ChartNetwork,
|
||||
CheckCheck,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
@@ -9,7 +11,6 @@ import {
|
||||
Map,
|
||||
Search as SearchIcon,
|
||||
SquarePen,
|
||||
Waypoints,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
@@ -197,7 +198,7 @@ export function Sidebar({
|
||||
};
|
||||
|
||||
const isActive = (
|
||||
type: 'contact' | 'channel' | 'raw' | 'map' | 'visualizer' | 'search',
|
||||
type: 'contact' | 'channel' | 'raw' | 'map' | 'visualizer' | 'search' | 'trace',
|
||||
id: string
|
||||
) => activeConversation?.type === type && activeConversation?.id === id;
|
||||
|
||||
@@ -721,7 +722,7 @@ export function Sidebar({
|
||||
renderSidebarActionRow({
|
||||
key: 'tool-visualizer',
|
||||
active: isActive('visualizer', 'visualizer'),
|
||||
icon: <Waypoints className="h-4 w-4" />,
|
||||
icon: <ChartNetwork className="h-4 w-4" />,
|
||||
label: 'Mesh Visualizer',
|
||||
onClick: () =>
|
||||
handleSelectConversation({
|
||||
@@ -730,6 +731,18 @@ export function Sidebar({
|
||||
name: 'Mesh Visualizer',
|
||||
}),
|
||||
}),
|
||||
renderSidebarActionRow({
|
||||
key: 'tool-trace',
|
||||
active: isActive('trace', 'trace'),
|
||||
icon: <Cable className="h-4 w-4" />,
|
||||
label: 'Trace',
|
||||
onClick: () =>
|
||||
handleSelectConversation({
|
||||
type: 'trace',
|
||||
id: 'trace',
|
||||
name: 'Trace',
|
||||
}),
|
||||
}),
|
||||
renderSidebarActionRow({
|
||||
key: 'tool-search',
|
||||
active: isActive('search', 'search'),
|
||||
@@ -840,41 +853,45 @@ export function Sidebar({
|
||||
aria-label="Conversations"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search channels/contacts..."
|
||||
aria-label="Search conversations"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className={cn('h-7 text-[13px] bg-background/50', searchQuery ? 'pr-8' : 'pr-3')}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded"
|
||||
onClick={() => setSearchQuery('')}
|
||||
title="Clear search"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-3 py-2 border-b border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onNewMessage}
|
||||
title="New Message"
|
||||
aria-label="New message"
|
||||
className="h-7 w-7 shrink-0 p-0 text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Add channel or contact"
|
||||
aria-label="Add channel or contact"
|
||||
className="h-8 w-full justify-start gap-2 px-3 text-[13px]"
|
||||
>
|
||||
<SquarePen className="h-4 w-4" />
|
||||
<span>Add Channel/Contact</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto [contain:layout_paint]">
|
||||
<div className="px-3 py-2 border-b border-border/60">
|
||||
<div className="relative min-w-0">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search channels/contacts..."
|
||||
aria-label="Search conversations"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className={cn('h-7 text-[13px] bg-background/50', searchQuery ? 'pr-8' : 'pr-3')}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded"
|
||||
onClick={() => setSearchQuery('')}
|
||||
title="Clear search"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tools */}
|
||||
{toolRows.length > 0 && (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,17 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef, lazy, Suspense } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { ChevronDown, Info } from 'lucide-react';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Button } from '../ui/button';
|
||||
import { Separator } from '../ui/separator';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../ui/dialog';
|
||||
import { toast } from '../ui/sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { api } from '../../api';
|
||||
@@ -309,6 +316,80 @@ const CREATE_INTEGRATION_DEFINITIONS_BY_VALUE = Object.fromEntries(
|
||||
CREATE_INTEGRATION_DEFINITIONS.map((definition) => [definition.value, definition])
|
||||
) as Record<DraftType, CreateIntegrationDefinition>;
|
||||
|
||||
function getNumberInputValue(value: unknown, fallback: number): string | number {
|
||||
if (value === '') return '';
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function getOptionalNumberInputValue(value: unknown): string | number {
|
||||
if (value === '') return '';
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
return '';
|
||||
}
|
||||
|
||||
function parseIntegerInputValue(value: string): number | string {
|
||||
if (value === '') return '';
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isNaN(parsed) ? value : parsed;
|
||||
}
|
||||
|
||||
function parseFloatInputValue(value: string): number | string {
|
||||
if (value === '') return '';
|
||||
const parsed = Number.parseFloat(value);
|
||||
return Number.isNaN(parsed) ? value : parsed;
|
||||
}
|
||||
|
||||
function normalizeIntegrationConfigForSave(
|
||||
configType: string,
|
||||
config: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const normalized = { ...config };
|
||||
|
||||
if (configType === 'mqtt_private') {
|
||||
const port = normalized.broker_port;
|
||||
if (port === '' || port === undefined || port === null) {
|
||||
normalized.broker_port = 1883;
|
||||
} else if (typeof port === 'string') {
|
||||
const parsed = Number.parseInt(port, 10);
|
||||
normalized.broker_port = Number.isNaN(parsed) ? 1883 : parsed;
|
||||
}
|
||||
|
||||
const topicPrefix = String(normalized.topic_prefix ?? '').trim();
|
||||
normalized.topic_prefix = topicPrefix || 'meshcore';
|
||||
}
|
||||
|
||||
if (configType === 'mqtt_community') {
|
||||
const brokerHost = String(normalized.broker_host ?? '').trim();
|
||||
normalized.broker_host = brokerHost || DEFAULT_COMMUNITY_BROKER_HOST;
|
||||
|
||||
const port = normalized.broker_port;
|
||||
if (port === '' || port === undefined || port === null) {
|
||||
normalized.broker_port = DEFAULT_COMMUNITY_BROKER_PORT;
|
||||
} else if (typeof port === 'string') {
|
||||
const parsed = Number.parseInt(port, 10);
|
||||
normalized.broker_port = Number.isNaN(parsed) ? DEFAULT_COMMUNITY_BROKER_PORT : parsed;
|
||||
}
|
||||
|
||||
const topicTemplate = String(normalized.topic_template ?? '').trim();
|
||||
normalized.topic_template = topicTemplate || DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE;
|
||||
}
|
||||
|
||||
if (configType === 'map_upload') {
|
||||
const radius = normalized.geofence_radius_km;
|
||||
if (radius === '' || radius === undefined || radius === null) {
|
||||
normalized.geofence_radius_km = 0;
|
||||
} else if (typeof radius === 'string') {
|
||||
const parsed = Number.parseFloat(radius);
|
||||
normalized.geofence_radius_km = Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isDraftType(value: string): value is DraftType {
|
||||
return value in CREATE_INTEGRATION_DEFINITIONS_BY_VALUE;
|
||||
}
|
||||
@@ -331,7 +412,7 @@ function normalizeDraftConfig(draftType: DraftType, config: Record<string, unkno
|
||||
throw new Error('MeshRank packet topic is required');
|
||||
}
|
||||
|
||||
return {
|
||||
return normalizeIntegrationConfigForSave('mqtt_community', {
|
||||
...config,
|
||||
broker_host: DEFAULT_MESHRANK_BROKER_HOST,
|
||||
broker_port: DEFAULT_MESHRANK_BROKER_PORT,
|
||||
@@ -345,7 +426,7 @@ function normalizeDraftConfig(draftType: DraftType, config: Record<string, unkno
|
||||
topic_template: topicTemplate,
|
||||
username: '',
|
||||
password: '',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (draftType === 'mqtt_community_letsmesh_us' || draftType === 'mqtt_community_letsmesh_eu') {
|
||||
@@ -353,7 +434,7 @@ function normalizeDraftConfig(draftType: DraftType, config: Record<string, unkno
|
||||
draftType === 'mqtt_community_letsmesh_eu'
|
||||
? DEFAULT_COMMUNITY_BROKER_HOST_EU
|
||||
: DEFAULT_COMMUNITY_BROKER_HOST;
|
||||
return {
|
||||
return normalizeIntegrationConfigForSave('mqtt_community', {
|
||||
...config,
|
||||
broker_host: brokerHost,
|
||||
broker_port: DEFAULT_COMMUNITY_BROKER_PORT,
|
||||
@@ -365,10 +446,13 @@ function normalizeDraftConfig(draftType: DraftType, config: Record<string, unkno
|
||||
topic_template: (config.topic_template as string) || DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE,
|
||||
username: '',
|
||||
password: '',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return config;
|
||||
return normalizeIntegrationConfigForSave(
|
||||
getCreateIntegrationDefinition(draftType).savedType,
|
||||
config
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDraftScope(draftType: DraftType, scope: Record<string, unknown>) {
|
||||
@@ -642,9 +726,9 @@ function MqttPrivateConfigEditor({
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
value={(config.broker_port as number) || 1883}
|
||||
value={getNumberInputValue(config.broker_port, 1883)}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, broker_port: parseInt(e.target.value, 10) || 1883 })
|
||||
onChange({ ...config, broker_port: parseIntegerInputValue(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -702,7 +786,8 @@ function MqttPrivateConfigEditor({
|
||||
<Input
|
||||
id="fanout-mqtt-prefix"
|
||||
type="text"
|
||||
value={(config.topic_prefix as string) || 'meshcore'}
|
||||
placeholder="meshcore"
|
||||
value={(config.topic_prefix as string | undefined) ?? ''}
|
||||
onChange={(e) => onChange({ ...config, topic_prefix: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
@@ -738,7 +823,7 @@ function MqttCommunityConfigEditor({
|
||||
id="fanout-comm-host"
|
||||
type="text"
|
||||
placeholder={DEFAULT_COMMUNITY_BROKER_HOST}
|
||||
value={(config.broker_host as string) || DEFAULT_COMMUNITY_BROKER_HOST}
|
||||
value={(config.broker_host as string | undefined) ?? ''}
|
||||
onChange={(e) => onChange({ ...config, broker_host: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
@@ -749,11 +834,11 @@ function MqttCommunityConfigEditor({
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
value={(config.broker_port as number) || DEFAULT_COMMUNITY_BROKER_PORT}
|
||||
value={getNumberInputValue(config.broker_port, DEFAULT_COMMUNITY_BROKER_PORT)}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
broker_port: parseInt(e.target.value, 10) || DEFAULT_COMMUNITY_BROKER_PORT,
|
||||
broker_port: parseIntegerInputValue(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
@@ -888,7 +973,8 @@ function MqttCommunityConfigEditor({
|
||||
<Input
|
||||
id="fanout-comm-topic-template"
|
||||
type="text"
|
||||
value={(config.topic_template as string) || DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE}
|
||||
placeholder={DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE}
|
||||
value={(config.topic_template as string | undefined) ?? ''}
|
||||
onChange={(e) => onChange({ ...config, topic_template: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -1208,11 +1294,11 @@ function MapUploadConfigEditor({
|
||||
min="0"
|
||||
step="any"
|
||||
placeholder="e.g. 100"
|
||||
value={(config.geofence_radius_km as number | undefined) ?? ''}
|
||||
value={getOptionalNumberInputValue(config.geofence_radius_km)}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
geofence_radius_km: e.target.value === '' ? 0 : parseFloat(e.target.value),
|
||||
geofence_radius_km: parseFloatInputValue(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
@@ -1854,6 +1940,10 @@ export function SettingsFanoutSection({
|
||||
const [inlineEditName, setInlineEditName] = useState('');
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [selectedCreateType, setSelectedCreateType] = useState<DraftType | null>(null);
|
||||
const [errorDialogState, setErrorDialogState] = useState<{
|
||||
integrationName: string;
|
||||
error: string;
|
||||
} | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const loadConfigs = useCallback(async () => {
|
||||
@@ -1986,9 +2076,10 @@ export function SettingsFanoutSection({
|
||||
if (!currentEditingId) {
|
||||
throw new Error('Missing fanout config id for update');
|
||||
}
|
||||
const editingType = configs.find((cfg) => cfg.id === currentEditingId)?.type ?? '';
|
||||
const update: Record<string, unknown> = {
|
||||
name: editName,
|
||||
config: editConfig,
|
||||
config: normalizeIntegrationConfigForSave(editingType, editConfig),
|
||||
scope: editScope,
|
||||
};
|
||||
if (enabled !== undefined) update.enabled = enabled;
|
||||
@@ -2207,6 +2298,31 @@ export function SettingsFanoutSection({
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={errorDialogState !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setErrorDialogState(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader className="border-b border-border px-5 py-4">
|
||||
<DialogTitle>
|
||||
{errorDialogState ? `${errorDialogState.integrationName} Error` : 'Integration Error'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Most recent backend error retained for this integration.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="px-5 py-4 text-sm text-muted-foreground">
|
||||
<p className="whitespace-pre-wrap break-words font-mono text-foreground">
|
||||
{errorDialogState?.error}
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{configGroups.length > 0 && (
|
||||
<div className="columns-1 gap-4 md:columns-2">
|
||||
{configGroups.map((group) => (
|
||||
@@ -2220,6 +2336,7 @@ export function SettingsFanoutSection({
|
||||
{group.configs.map((cfg) => {
|
||||
const statusEntry = health?.fanout_statuses?.[cfg.id];
|
||||
const status = cfg.enabled ? statusEntry?.status : undefined;
|
||||
const lastError = cfg.enabled ? statusEntry?.last_error : null;
|
||||
const communityConfig = cfg.config as Record<string, unknown>;
|
||||
return (
|
||||
<div
|
||||
@@ -2286,6 +2403,25 @@ export function SettingsFanoutSection({
|
||||
{cfg.enabled ? getStatusLabel(status, cfg.type) : 'Disabled'}
|
||||
</span>
|
||||
|
||||
{lastError && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 px-0"
|
||||
onClick={() =>
|
||||
setErrorDialogState({
|
||||
integrationName: cfg.name,
|
||||
error: lastError,
|
||||
})
|
||||
}
|
||||
aria-label={`View error details for ${cfg.name}`}
|
||||
title="View latest error"
|
||||
>
|
||||
<Info className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
||||
@@ -17,6 +17,14 @@ import {
|
||||
setSavedDistanceUnit,
|
||||
} from '../../utils/distanceUnits';
|
||||
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
|
||||
import {
|
||||
DEFAULT_FONT_SCALE,
|
||||
FONT_SCALE_SLIDER_STEP,
|
||||
MAX_FONT_SCALE,
|
||||
MIN_FONT_SCALE,
|
||||
getSavedFontScale,
|
||||
setSavedFontScale,
|
||||
} from '../../utils/fontScale';
|
||||
|
||||
export function SettingsLocalSection({
|
||||
onLocalLabelChange,
|
||||
@@ -31,6 +39,29 @@ export function SettingsLocalSection({
|
||||
);
|
||||
const [localLabelText, setLocalLabelText] = useState(() => getLocalLabel().text);
|
||||
const [localLabelColor, setLocalLabelColor] = useState(() => getLocalLabel().color);
|
||||
const [fontScale, setFontScale] = useState(getSavedFontScale);
|
||||
const [fontScaleSlider, setFontScaleSlider] = useState(getSavedFontScale);
|
||||
const [fontScaleInput, setFontScaleInput] = useState(() => String(getSavedFontScale()));
|
||||
|
||||
const commitFontScale = (nextScale: number) => {
|
||||
const normalized = setSavedFontScale(nextScale);
|
||||
setFontScale(normalized);
|
||||
setFontScaleSlider(normalized);
|
||||
setFontScaleInput(String(normalized));
|
||||
};
|
||||
|
||||
const restoreFontScaleInput = () => {
|
||||
setFontScaleInput(String(fontScale));
|
||||
};
|
||||
|
||||
const handleSliderChange = (nextScale: number) => {
|
||||
setFontScaleSlider(nextScale);
|
||||
setFontScaleInput(String(nextScale));
|
||||
};
|
||||
|
||||
const handleSliderCommit = (nextScale: number) => {
|
||||
commitFontScale(nextScale);
|
||||
};
|
||||
|
||||
const handleToggleReopenLastConversation = (enabled: boolean) => {
|
||||
setReopenLastConversation(enabled);
|
||||
@@ -89,6 +120,85 @@ export function SettingsLocalSection({
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="font-scale-input">Relative Font Size</Label>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<input
|
||||
type="range"
|
||||
min={MIN_FONT_SCALE}
|
||||
max={MAX_FONT_SCALE}
|
||||
step={FONT_SCALE_SLIDER_STEP}
|
||||
value={fontScaleSlider}
|
||||
onChange={(event) => handleSliderChange(Number(event.target.value))}
|
||||
onMouseUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
|
||||
onTouchEnd={(event) => handleSliderCommit(Number(event.currentTarget.value))}
|
||||
onKeyUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
|
||||
onBlur={(event) => handleSliderCommit(Number(event.currentTarget.value))}
|
||||
aria-label="Relative font size slider"
|
||||
className="w-full accent-primary sm:flex-1"
|
||||
/>
|
||||
<div className="flex items-center gap-2 sm:w-40">
|
||||
<Input
|
||||
id="font-scale-input"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={MIN_FONT_SCALE}
|
||||
max={MAX_FONT_SCALE}
|
||||
step="any"
|
||||
value={fontScaleInput}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setFontScaleInput(nextValue);
|
||||
|
||||
if (nextValue === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.validity.valid && Number.isFinite(event.target.valueAsNumber)) {
|
||||
commitFontScale(event.target.valueAsNumber);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
const parsed = Number.parseFloat(fontScaleInput);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
restoreFontScaleInput();
|
||||
return;
|
||||
}
|
||||
commitFontScale(parsed);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter') {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const parsed = Number.parseFloat(fontScaleInput);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
restoreFontScaleInput();
|
||||
return;
|
||||
}
|
||||
commitFontScale(parsed);
|
||||
}}
|
||||
aria-label="Relative font size percentage"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">%</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => commitFontScale(DEFAULT_FONT_SCALE)}
|
||||
className="inline-flex h-9 items-center justify-center rounded-md border border-input px-3 text-sm font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={fontScale === DEFAULT_FONT_SCALE}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Scales the app's typography for this browser only. The slider moves in 5% steps; the
|
||||
number field accepts any value from 25% to 400%.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="distance-units">Distance Units</Label>
|
||||
<select
|
||||
|
||||
@@ -846,11 +846,16 @@ export function SettingsRadioSection({
|
||||
className="rounded-md border border-input bg-background px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-medium capitalize">{result.node_type}</span>
|
||||
<span className="text-sm font-medium">
|
||||
{result.name ?? <span className="capitalize">{result.node_type}</span>}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
heard {result.heard_count} time{result.heard_count === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
{result.name && (
|
||||
<p className="text-xs capitalize text-muted-foreground">{result.node_type}</p>
|
||||
)}
|
||||
<p className="mt-1 break-all font-mono text-xs text-muted-foreground">
|
||||
{result.public_key}
|
||||
</p>
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip as RechartsTooltip,
|
||||
ResponsiveContainer,
|
||||
AreaChart,
|
||||
Area,
|
||||
Cell,
|
||||
} from 'recharts';
|
||||
import { Separator } from '../ui/separator';
|
||||
import { api } from '../../api';
|
||||
import type { StatisticsResponse } from '../../types';
|
||||
@@ -7,6 +19,94 @@ function formatPercent(value: number): string {
|
||||
return `${value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
const CHANNEL_BAR_COLORS = ['#0ea5e9', '#10b981', '#f59e0b', '#f43f5e', '#8b5cf6'];
|
||||
|
||||
const TOOLTIP_STYLE = {
|
||||
contentStyle: {
|
||||
backgroundColor: 'hsl(var(--popover))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
fontSize: '11px',
|
||||
color: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
itemStyle: { color: 'hsl(var(--popover-foreground))' },
|
||||
labelStyle: { color: 'hsl(var(--muted-foreground))' },
|
||||
} as const;
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
return new Date(ts * 1000).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
function NoiseFloorChart({
|
||||
samples,
|
||||
}: {
|
||||
samples: { timestamp: number; noise_floor_dbm: number }[];
|
||||
}) {
|
||||
const data = samples.map((s, i) => ({
|
||||
idx: i,
|
||||
time: formatTime(s.timestamp),
|
||||
noise_floor: s.noise_floor_dbm,
|
||||
}));
|
||||
|
||||
const tickCount = Math.min(6, samples.length);
|
||||
const tickIndices: number[] = [];
|
||||
if (samples.length > 1) {
|
||||
for (let i = 0; i < tickCount; i++) {
|
||||
tickIndices.push(Math.round((i / (tickCount - 1)) * (samples.length - 1)));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={120}>
|
||||
<AreaChart data={data} margin={{ top: 4, right: 4, bottom: 0, left: -8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="idx"
|
||||
type="number"
|
||||
domain={[0, samples.length - 1]}
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
ticks={tickIndices}
|
||||
tickFormatter={(idx) => data[idx]?.time ?? ''}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
domain={['dataMin - 5', 'dataMax + 5']}
|
||||
tickFormatter={(v) => `${v}`}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
{...TOOLTIP_STYLE}
|
||||
cursor={{
|
||||
stroke: 'hsl(var(--muted-foreground))',
|
||||
strokeWidth: 1,
|
||||
strokeDasharray: '3 3',
|
||||
}}
|
||||
labelFormatter={(idx) => data[Number(idx)]?.time ?? ''}
|
||||
formatter={(value) => [`${value} dBm`, 'Noise Floor']}
|
||||
/>
|
||||
<Area
|
||||
type="linear"
|
||||
dataKey="noise_floor"
|
||||
stroke="#8b5cf6"
|
||||
fill="#8b5cf6"
|
||||
fillOpacity={0.15}
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
activeDot={{ r: 4, fill: '#8b5cf6', strokeWidth: 2, stroke: 'hsl(var(--popover))' }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsStatisticsSection({ className }: { className?: string }) {
|
||||
const [stats, setStats] = useState<StatisticsResponse | null>(null);
|
||||
const [statsLoading, setStatsLoading] = useState(false);
|
||||
@@ -85,60 +185,6 @@ export function SettingsStatisticsSection({ className }: { className?: string })
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Packets */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Packets</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Total stored</span>
|
||||
<span className="font-medium">{stats.total_packets}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-success">Decrypted</span>
|
||||
<span className="font-medium text-success">{stats.decrypted_packets}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-warning">Undecrypted</span>
|
||||
<span className="font-medium text-warning">{stats.undecrypted_packets}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Path Hash Width (24h)</h4>
|
||||
<div className="mb-2 text-xs text-muted-foreground">
|
||||
Parsed stored raw packets from the last 24 hours:{' '}
|
||||
{stats.path_hash_width_24h.total_packets}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span>1-byte hops</span>
|
||||
<span className="text-muted-foreground">
|
||||
{stats.path_hash_width_24h.single_byte} (
|
||||
{formatPercent(stats.path_hash_width_24h.single_byte_pct)})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span>2-byte hops</span>
|
||||
<span className="text-muted-foreground">
|
||||
{stats.path_hash_width_24h.double_byte} (
|
||||
{formatPercent(stats.path_hash_width_24h.double_byte_pct)})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span>3-byte hops</span>
|
||||
<span className="text-muted-foreground">
|
||||
{stats.path_hash_width_24h.triple_byte} (
|
||||
{formatPercent(stats.path_hash_width_24h.triple_byte_pct)})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Activity */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Activity</h4>
|
||||
@@ -174,23 +220,172 @@ export function SettingsStatisticsSection({ className }: { className?: string })
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Packets */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Packets</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Total stored</span>
|
||||
<span className="font-medium">{stats.total_packets}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-success">Decrypted</span>
|
||||
<span className="font-medium text-success">{stats.decrypted_packets}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-warning">Undecrypted</span>
|
||||
<span className="font-medium text-warning">{stats.undecrypted_packets}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Path Hash Width */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Path Hash Width (24h)</h4>
|
||||
<div className="mb-2 text-xs text-muted-foreground">
|
||||
Parsed stored raw packets from the last 24 hours:{' '}
|
||||
{stats.path_hash_width_24h.total_packets}
|
||||
</div>
|
||||
{stats.path_hash_width_24h.total_packets > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={120}>
|
||||
<BarChart
|
||||
data={[
|
||||
{
|
||||
name: '1-byte',
|
||||
count: stats.path_hash_width_24h.single_byte,
|
||||
pct: stats.path_hash_width_24h.single_byte_pct,
|
||||
},
|
||||
{
|
||||
name: '2-byte',
|
||||
count: stats.path_hash_width_24h.double_byte,
|
||||
pct: stats.path_hash_width_24h.double_byte_pct,
|
||||
},
|
||||
{
|
||||
name: '3-byte',
|
||||
count: stats.path_hash_width_24h.triple_byte,
|
||||
pct: stats.path_hash_width_24h.triple_byte_pct,
|
||||
},
|
||||
]}
|
||||
margin={{ top: 4, right: 4, bottom: 0, left: -16 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="hsl(var(--border))"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
{...TOOLTIP_STYLE}
|
||||
cursor={{ fill: 'hsl(var(--muted))', opacity: 0.5 }}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
formatter={(value: any, _: any, props: any) => [
|
||||
`${Number(value).toLocaleString()} (${formatPercent(props.payload.pct)})`,
|
||||
'Packets',
|
||||
]}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[4, 4, 0, 0]} maxBarSize={40}>
|
||||
<Cell fill="#0ea5e9" />
|
||||
<Cell fill="#10b981" />
|
||||
<Cell fill="#f59e0b" />
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No path data in the last 24 hours.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Busiest Channels */}
|
||||
{stats.busiest_channels_24h.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Busiest Channels (24h)</h4>
|
||||
<div className="space-y-1">
|
||||
{stats.busiest_channels_24h.map((ch, i) => (
|
||||
<div key={ch.channel_key} className="flex justify-between items-center text-sm">
|
||||
<span>
|
||||
<span className="text-muted-foreground mr-2">{i + 1}.</span>
|
||||
{ch.channel_name}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{ch.message_count} msgs</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height={stats.busiest_channels_24h.length * 28 + 8}
|
||||
>
|
||||
<BarChart
|
||||
data={stats.busiest_channels_24h.map((ch) => ({
|
||||
name: ch.channel_name,
|
||||
messages: ch.message_count,
|
||||
}))}
|
||||
layout="vertical"
|
||||
margin={{ top: 0, right: 4, bottom: 0, left: 0 }}
|
||||
barCategoryGap="20%"
|
||||
>
|
||||
<XAxis type="number" hide />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={100}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
{...TOOLTIP_STYLE}
|
||||
cursor={{ fill: 'hsl(var(--muted))', opacity: 0.5 }}
|
||||
formatter={(value) => [`${Number(value).toLocaleString()} messages`, null]}
|
||||
/>
|
||||
<Bar dataKey="messages" radius={[0, 4, 4, 0]} maxBarSize={16}>
|
||||
{stats.busiest_channels_24h.map((_, i) => (
|
||||
<Cell key={i} fill={CHANNEL_BAR_COLORS[i % CHANNEL_BAR_COLORS.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Noise Floor */}
|
||||
{stats.noise_floor_24h.supported !== false && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Noise Floor (24h)</h4>
|
||||
{stats.noise_floor_24h.latest_noise_floor_dbm != null && (
|
||||
<div className="mb-2 text-xs text-muted-foreground">
|
||||
Latest reading: {stats.noise_floor_24h.latest_noise_floor_dbm} dBm
|
||||
{stats.noise_floor_24h.latest_timestamp != null &&
|
||||
` at ${new Date(
|
||||
stats.noise_floor_24h.latest_timestamp * 1000
|
||||
).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}`}
|
||||
</div>
|
||||
)}
|
||||
{stats.noise_floor_24h.samples.length > 1 ? (
|
||||
<NoiseFloorChart samples={stats.noise_floor_24h.samples} />
|
||||
) : stats.noise_floor_24h.samples.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No noise floor samples collected yet. Samples are collected every five minutes,
|
||||
and retained until server restart.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Only one sample so far ({stats.noise_floor_24h.samples[0].noise_floor_dbm} dBm).
|
||||
More data needed for a chart. Samples are collected every five minutes, and
|
||||
retained until server restart.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Checkbox } from '../ui/checkbox';
|
||||
import { PACKET_LEGEND_ITEMS } from '../../utils/visualizerUtils';
|
||||
import { NODE_LEGEND_ITEMS } from './shared';
|
||||
@@ -71,6 +72,19 @@ export function VisualizerControls({
|
||||
onExpandContract,
|
||||
onClearAndReset,
|
||||
}: VisualizerControlsProps) {
|
||||
const [observationWindowInput, setObservationWindowInput] = useState(
|
||||
String(observationWindowSec)
|
||||
);
|
||||
const [pruneWindowInput, setPruneWindowInput] = useState(String(pruneStaleMinutes));
|
||||
|
||||
useEffect(() => {
|
||||
setObservationWindowInput(String(observationWindowSec));
|
||||
}, [observationWindowSec]);
|
||||
|
||||
useEffect(() => {
|
||||
setPruneWindowInput(String(pruneStaleMinutes));
|
||||
}, [pruneStaleMinutes]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showControls && (
|
||||
@@ -212,12 +226,25 @@ export function VisualizerControls({
|
||||
type="number"
|
||||
min="1"
|
||||
max="60"
|
||||
value={observationWindowSec}
|
||||
onChange={(e) =>
|
||||
setObservationWindowSec(
|
||||
Math.max(1, Math.min(60, parseInt(e.target.value, 10) || 1))
|
||||
)
|
||||
}
|
||||
value={observationWindowInput}
|
||||
onChange={(e) => {
|
||||
const nextValue = e.target.value;
|
||||
setObservationWindowInput(nextValue);
|
||||
if (nextValue === '') return;
|
||||
const parsed = Number.parseInt(nextValue, 10);
|
||||
if (Number.isNaN(parsed)) return;
|
||||
setObservationWindowSec(Math.max(1, Math.min(60, parsed)));
|
||||
}}
|
||||
onBlur={() => {
|
||||
const parsed = Number.parseInt(observationWindowInput, 10);
|
||||
const nextValue = Number.isNaN(parsed)
|
||||
? observationWindowSec
|
||||
: Math.max(1, Math.min(60, parsed));
|
||||
setObservationWindowInput(String(nextValue));
|
||||
if (nextValue !== observationWindowSec) {
|
||||
setObservationWindowSec(nextValue);
|
||||
}
|
||||
}}
|
||||
className="w-12 px-1 py-0.5 bg-background border border-border rounded text-xs text-center"
|
||||
/>
|
||||
<span className="text-muted-foreground">sec</span>
|
||||
@@ -247,10 +274,25 @@ export function VisualizerControls({
|
||||
type="number"
|
||||
min={1}
|
||||
max={60}
|
||||
value={pruneStaleMinutes}
|
||||
value={pruneWindowInput}
|
||||
onChange={(e) => {
|
||||
const v = parseInt(e.target.value, 10);
|
||||
if (!isNaN(v) && v >= 1 && v <= 60) setPruneStaleMinutes(v);
|
||||
const nextValue = e.target.value;
|
||||
setPruneWindowInput(nextValue);
|
||||
if (nextValue === '') return;
|
||||
const parsed = Number.parseInt(nextValue, 10);
|
||||
if (Number.isNaN(parsed)) return;
|
||||
if (parsed >= 1 && parsed <= 60) setPruneStaleMinutes(parsed);
|
||||
}}
|
||||
onBlur={() => {
|
||||
const parsed = Number.parseInt(pruneWindowInput, 10);
|
||||
const nextValue =
|
||||
Number.isNaN(parsed) || parsed < 1 || parsed > 60
|
||||
? pruneStaleMinutes
|
||||
: parsed;
|
||||
setPruneWindowInput(String(nextValue));
|
||||
if (nextValue !== pruneStaleMinutes) {
|
||||
setPruneStaleMinutes(nextValue);
|
||||
}
|
||||
}}
|
||||
className="w-14 rounded border border-border bg-background px-2 py-0.5 text-sm"
|
||||
/>
|
||||
|
||||
@@ -275,7 +275,9 @@ interface UseConversationMessagesResult {
|
||||
}
|
||||
|
||||
function isMessageConversation(conversation: Conversation | null): conversation is Conversation {
|
||||
return !!conversation && !['raw', 'map', 'visualizer', 'search'].includes(conversation.type);
|
||||
return (
|
||||
!!conversation && !['raw', 'map', 'visualizer', 'search', 'trace'].includes(conversation.type)
|
||||
);
|
||||
}
|
||||
|
||||
function isActiveConversationMessage(
|
||||
|
||||
@@ -62,7 +62,6 @@ export function useConversationRouter({
|
||||
// Only needs channels (fast path) - doesn't wait for contacts
|
||||
useEffect(() => {
|
||||
if (hasSetDefaultConversation.current || activeConversation) return;
|
||||
if (channels.length === 0) return;
|
||||
|
||||
const hashConv = parseHashSettingsSection() ? null : parseHashConversation();
|
||||
|
||||
@@ -92,6 +91,29 @@ export function useConversationRouter({
|
||||
hasSetDefaultConversation.current = true;
|
||||
return;
|
||||
}
|
||||
if (hashConv?.type === 'trace') {
|
||||
setActiveConversationState({ type: 'trace', id: 'trace', name: 'Trace' });
|
||||
hasSetDefaultConversation.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// No hash: optionally restore last-viewed non-data conversation if enabled on this device.
|
||||
if (!hashConv && getReopenLastConversationEnabled()) {
|
||||
const lastViewed = getLastViewedConversation();
|
||||
if (
|
||||
lastViewed &&
|
||||
(lastViewed.type === 'raw' ||
|
||||
lastViewed.type === 'map' ||
|
||||
lastViewed.type === 'visualizer' ||
|
||||
lastViewed.type === 'trace')
|
||||
) {
|
||||
setActiveConversationState(lastViewed);
|
||||
hasSetDefaultConversation.current = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (channels.length === 0) return;
|
||||
|
||||
// Handle channel hash (ID-first with legacy-name fallback)
|
||||
if (hashConv?.type === 'channel') {
|
||||
@@ -109,14 +131,6 @@ export function useConversationRouter({
|
||||
// No hash: optionally restore last-viewed conversation if enabled on this device.
|
||||
if (!hashConv && getReopenLastConversationEnabled()) {
|
||||
const lastViewed = getLastViewedConversation();
|
||||
if (
|
||||
lastViewed &&
|
||||
(lastViewed.type === 'raw' || lastViewed.type === 'map' || lastViewed.type === 'visualizer')
|
||||
) {
|
||||
setActiveConversationState(lastViewed);
|
||||
hasSetDefaultConversation.current = true;
|
||||
return;
|
||||
}
|
||||
if (lastViewed?.type === 'channel') {
|
||||
const channel =
|
||||
channels.find((c) => c.key.toLowerCase() === lastViewed.id.toLowerCase()) ||
|
||||
|
||||
@@ -43,6 +43,7 @@ interface UseRealtimeAppStateArgs {
|
||||
hasMention?: boolean;
|
||||
}) => void;
|
||||
renameConversationState: (oldStateKey: string, newStateKey: string) => void;
|
||||
removeConversationState: (stateKey: string) => void;
|
||||
checkMention: (text: string) => boolean;
|
||||
pendingDeleteFallbackRef: MutableRefObject<boolean>;
|
||||
setActiveConversation: (conv: Conversation | null) => void;
|
||||
@@ -96,6 +97,7 @@ export function useRealtimeAppState({
|
||||
observeMessage,
|
||||
recordMessageEvent,
|
||||
renameConversationState,
|
||||
removeConversationState,
|
||||
checkMention,
|
||||
pendingDeleteFallbackRef,
|
||||
setActiveConversation,
|
||||
@@ -232,6 +234,7 @@ export function useRealtimeAppState({
|
||||
onContactDeleted: (publicKey: string) => {
|
||||
setContacts((prev) => prev.filter((c) => c.public_key !== publicKey));
|
||||
removeConversationMessages(publicKey);
|
||||
removeConversationState(getStateKey('contact', publicKey));
|
||||
const active = activeConversationRef.current;
|
||||
if (active?.type === 'contact' && active.id === publicKey) {
|
||||
pendingDeleteFallbackRef.current = true;
|
||||
@@ -241,6 +244,7 @@ export function useRealtimeAppState({
|
||||
onChannelDeleted: (key: string) => {
|
||||
setChannels((prev) => prev.filter((c) => c.key !== key));
|
||||
removeConversationMessages(key);
|
||||
removeConversationState(getStateKey('channel', key));
|
||||
const active = activeConversationRef.current;
|
||||
if (active?.type === 'channel' && active.id === key) {
|
||||
pendingDeleteFallbackRef.current = true;
|
||||
@@ -267,6 +271,7 @@ export function useRealtimeAppState({
|
||||
checkMention,
|
||||
fetchAllContacts,
|
||||
fetchConfig,
|
||||
removeConversationState,
|
||||
renameConversationState,
|
||||
renameConversationMessages,
|
||||
maxRawPackets,
|
||||
|
||||
@@ -10,6 +10,14 @@ import {
|
||||
import type { Channel, Contact, Conversation, Message, UnreadCounts } from '../types';
|
||||
import { takePrefetchOrFetch } from '../prefetch';
|
||||
|
||||
type UnreadTrackedConversation = Conversation & { type: 'channel' | 'contact' };
|
||||
|
||||
function isUnreadTrackedConversation(
|
||||
conversation: Conversation | null
|
||||
): conversation is UnreadTrackedConversation {
|
||||
return conversation?.type === 'channel' || conversation?.type === 'contact';
|
||||
}
|
||||
|
||||
interface UseUnreadCountsResult {
|
||||
unreadCounts: Record<string, number>;
|
||||
/** Tracks which conversations have unread messages that mention the user */
|
||||
@@ -23,6 +31,7 @@ interface UseUnreadCountsResult {
|
||||
hasMention?: boolean;
|
||||
}) => void;
|
||||
renameConversationState: (oldStateKey: string, newStateKey: string) => void;
|
||||
removeConversationState: (stateKey: string) => void;
|
||||
markAllRead: () => void;
|
||||
refreshUnreads: () => Promise<void>;
|
||||
}
|
||||
@@ -47,14 +56,7 @@ export function useUnreadCounts(
|
||||
// (the user is already viewing it, so its count should stay at 0).
|
||||
const applyUnreads = useCallback((data: UnreadCounts) => {
|
||||
const ac = activeConvRef.current;
|
||||
const activeKey =
|
||||
ac &&
|
||||
ac.type !== 'raw' &&
|
||||
ac.type !== 'map' &&
|
||||
ac.type !== 'visualizer' &&
|
||||
ac.type !== 'search'
|
||||
? getStateKey(ac.type as 'channel' | 'contact', ac.id)
|
||||
: null;
|
||||
const activeKey = isUnreadTrackedConversation(ac) ? getStateKey(ac.type, ac.id) : null;
|
||||
|
||||
if (activeKey) {
|
||||
const counts = { ...data.counts };
|
||||
@@ -122,16 +124,8 @@ export function useUnreadCounts(
|
||||
// Mark conversation as read when user views it
|
||||
// Calls server API to persist read state across devices
|
||||
useEffect(() => {
|
||||
if (
|
||||
activeConversation &&
|
||||
activeConversation.type !== 'raw' &&
|
||||
activeConversation.type !== 'map' &&
|
||||
activeConversation.type !== 'visualizer'
|
||||
) {
|
||||
const key = getStateKey(
|
||||
activeConversation.type as 'channel' | 'contact',
|
||||
activeConversation.id
|
||||
);
|
||||
if (isUnreadTrackedConversation(activeConversation)) {
|
||||
const key = getStateKey(activeConversation.type, activeConversation.id);
|
||||
|
||||
// Update local state immediately for responsive UI
|
||||
setUnreadCounts((prev) => {
|
||||
@@ -235,6 +229,27 @@ export function useUnreadCounts(
|
||||
setLastMessageTimes(renameConversationTimeKey(oldStateKey, newStateKey));
|
||||
}, []);
|
||||
|
||||
const removeConversationState = useCallback((stateKey: string) => {
|
||||
setUnreadCounts((prev) => {
|
||||
if (!(stateKey in prev)) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[stateKey];
|
||||
return next;
|
||||
});
|
||||
setMentions((prev) => {
|
||||
if (!(stateKey in prev)) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[stateKey];
|
||||
return next;
|
||||
});
|
||||
setUnreadLastReadAts((prev) => {
|
||||
if (!(stateKey in prev)) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[stateKey];
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Mark all conversations as read
|
||||
// Calls single bulk API endpoint to persist read state
|
||||
const markAllRead = useCallback(() => {
|
||||
@@ -256,6 +271,7 @@ export function useUnreadCounts(
|
||||
unreadLastReadAts,
|
||||
recordMessageEvent,
|
||||
renameConversationState,
|
||||
removeConversationState,
|
||||
markAllRead,
|
||||
refreshUnreads: fetchUnreads,
|
||||
};
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
font-size: 0.875rem;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ import './index.css';
|
||||
import './themes.css';
|
||||
import './styles.css';
|
||||
import { getSavedTheme, applyTheme } from './utils/theme';
|
||||
import { applyFontScale, getSavedFontScale } from './utils/fontScale';
|
||||
|
||||
// Apply saved theme before first render
|
||||
applyTheme(getSavedTheme());
|
||||
applyFontScale(getSavedFontScale());
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
|
||||
@@ -195,6 +195,53 @@ describe('App startup hash resolution', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('restores the trace tool from the URL hash', async () => {
|
||||
window.location.hash = '#trace';
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
for (const node of screen.getAllByTestId('active-conversation')) {
|
||||
expect(node).toHaveTextContent('trace:trace:Trace');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('restores the trace tool from the URL hash even when channels are unavailable', async () => {
|
||||
window.location.hash = '#trace';
|
||||
mocks.api.getChannels.mockResolvedValue([]);
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
for (const node of screen.getAllByTestId('active-conversation')) {
|
||||
expect(node).toHaveTextContent('trace:trace:Trace');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('reopens the last viewed trace tool even when channels are unavailable', async () => {
|
||||
window.location.hash = '';
|
||||
localStorage.setItem(REOPEN_LAST_CONVERSATION_KEY, '1');
|
||||
localStorage.setItem(
|
||||
LAST_VIEWED_CONVERSATION_KEY,
|
||||
JSON.stringify({
|
||||
type: 'trace',
|
||||
id: 'trace',
|
||||
name: 'Trace',
|
||||
})
|
||||
);
|
||||
mocks.api.getChannels.mockResolvedValue([]);
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
for (const node of screen.getAllByTestId('active-conversation')) {
|
||||
expect(node).toHaveTextContent('trace:trace:Trace');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('restores last viewed channel when hash is empty and reopen preference is enabled', async () => {
|
||||
const chatChannel = {
|
||||
key: '11111111111111111111111111111111',
|
||||
|
||||
@@ -181,7 +181,10 @@ describe('ContactInfoPane', () => {
|
||||
|
||||
await screen.findByText('Mystery');
|
||||
await waitFor(() => {
|
||||
expect(getContactAnalytics).toHaveBeenCalledWith({ name: 'Mystery' });
|
||||
expect(getContactAnalytics).toHaveBeenCalledWith(
|
||||
{ name: 'Mystery' },
|
||||
expect.any(AbortSignal)
|
||||
);
|
||||
expect(screen.getByText('Messages')).toBeInTheDocument();
|
||||
expect(screen.getByText('Channel Messages')).toBeInTheDocument();
|
||||
expect(screen.getByText('4', { selector: 'p' })).toBeInTheDocument();
|
||||
|
||||
@@ -64,6 +64,10 @@ vi.mock('../components/VisualizerView', () => ({
|
||||
VisualizerView: () => <div data-testid="visualizer-view" />,
|
||||
}));
|
||||
|
||||
vi.mock('../components/TracePane', () => ({
|
||||
TracePane: () => <div data-testid="trace-pane" />,
|
||||
}));
|
||||
|
||||
const config: RadioConfig = {
|
||||
public_key: 'aa'.repeat(32),
|
||||
name: 'Radio',
|
||||
@@ -141,6 +145,7 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
|
||||
loadingNewer: false,
|
||||
messageInputRef: { current: null },
|
||||
onTrace: vi.fn(async () => {}),
|
||||
onRunTracePath: vi.fn(async () => ({ path_len: 0, timeout_seconds: 5, nodes: [] })),
|
||||
onPathDiscovery: vi.fn(async () => {
|
||||
throw new Error('unused');
|
||||
}),
|
||||
@@ -231,6 +236,23 @@ describe('ConversationPane', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the trace tool pane for trace conversations', () => {
|
||||
render(
|
||||
<ConversationPane
|
||||
{...createProps({
|
||||
activeConversation: {
|
||||
type: 'trace',
|
||||
id: 'trace',
|
||||
name: 'Trace',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('trace-pane')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('message-list')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('gates room chat behind room login controls until authenticated', async () => {
|
||||
render(
|
||||
<ConversationPane
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -206,6 +206,56 @@ describe('SettingsFanoutSection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('shows an error info button and dialog when the integration has a retained error', async () => {
|
||||
mockedApi.getFanoutConfigs.mockResolvedValue([webhookConfig]);
|
||||
renderSection({
|
||||
health: {
|
||||
...baseHealth,
|
||||
fanout_statuses: {
|
||||
'wh-1': {
|
||||
name: 'Test Hook',
|
||||
type: 'webhook',
|
||||
status: 'error',
|
||||
last_error: 'HTTP 500',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Hook')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'View error details for Test Hook' }));
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'Test Hook Error' })).toBeInTheDocument();
|
||||
expect(screen.getByText('HTTP 500')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show an error info button when the integration has no retained error', async () => {
|
||||
mockedApi.getFanoutConfigs.mockResolvedValue([webhookConfig]);
|
||||
renderSection({
|
||||
health: {
|
||||
...baseHealth,
|
||||
fanout_statuses: {
|
||||
'wh-1': {
|
||||
name: 'Test Hook',
|
||||
type: 'webhook',
|
||||
status: 'connected',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Hook')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'View error details for Test Hook' })
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates to edit view when clicking edit', async () => {
|
||||
mockedApi.getFanoutConfigs.mockResolvedValue([webhookConfig]);
|
||||
renderSection();
|
||||
@@ -654,6 +704,75 @@ describe('SettingsFanoutSection', () => {
|
||||
expect(audienceInput).toHaveValue('');
|
||||
});
|
||||
|
||||
it('existing community MQTT defaults can be cleared while editing and normalize on save', async () => {
|
||||
const communityConfig: FanoutConfig = {
|
||||
id: 'comm-1',
|
||||
type: 'mqtt_community',
|
||||
name: 'Community Feed',
|
||||
enabled: false,
|
||||
config: {
|
||||
broker_host: 'mqtt-us-v1.letsmesh.net',
|
||||
broker_port: 443,
|
||||
transport: 'websockets',
|
||||
use_tls: true,
|
||||
tls_verify: true,
|
||||
auth_mode: 'token',
|
||||
iata: 'LAX',
|
||||
email: '',
|
||||
token_audience: '',
|
||||
topic_template: 'meshcore/{IATA}/{PUBLIC_KEY}/packets',
|
||||
},
|
||||
scope: { messages: 'none', raw_packets: 'all' },
|
||||
sort_order: 0,
|
||||
created_at: 1000,
|
||||
};
|
||||
mockedApi.getFanoutConfigs.mockResolvedValue([communityConfig]);
|
||||
mockedApi.updateFanoutConfig.mockResolvedValue({
|
||||
...communityConfig,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
renderSection();
|
||||
await waitFor(() => expect(screen.getByText('Community Feed')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
|
||||
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||
|
||||
const hostInput = screen.getByLabelText('Broker Host') as HTMLInputElement;
|
||||
const portInput = screen.getByLabelText('Broker Port') as HTMLInputElement;
|
||||
const topicTemplateInput = screen.getByLabelText('Packet Topic Template') as HTMLInputElement;
|
||||
|
||||
fireEvent.change(hostInput, { target: { value: '' } });
|
||||
fireEvent.change(portInput, { target: { value: '' } });
|
||||
fireEvent.change(topicTemplateInput, { target: { value: '' } });
|
||||
|
||||
expect(hostInput.value).toBe('');
|
||||
expect(portInput.value).toBe('');
|
||||
expect(topicTemplateInput.value).toBe('');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save as Enabled' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockedApi.updateFanoutConfig).toHaveBeenCalledWith('comm-1', {
|
||||
name: 'Community Feed',
|
||||
config: {
|
||||
broker_host: 'mqtt-us-v1.letsmesh.net',
|
||||
broker_port: 443,
|
||||
transport: 'websockets',
|
||||
use_tls: true,
|
||||
tls_verify: true,
|
||||
auth_mode: 'token',
|
||||
iata: 'LAX',
|
||||
email: '',
|
||||
token_audience: '',
|
||||
topic_template: 'meshcore/{IATA}/{PUBLIC_KEY}/packets',
|
||||
},
|
||||
scope: { messages: 'none', raw_packets: 'all' },
|
||||
enabled: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('community MQTT can be configured for no auth', async () => {
|
||||
const communityConfig: FanoutConfig = {
|
||||
id: 'comm-1',
|
||||
@@ -733,6 +852,65 @@ describe('SettingsFanoutSection', () => {
|
||||
expect(screen.queryByLabelText('Broker Host')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('private MQTT fields can be cleared while editing and normalize defaults on create', async () => {
|
||||
const createdConfig: FanoutConfig = {
|
||||
id: 'mqtt-private-1',
|
||||
type: 'mqtt_private',
|
||||
name: 'Private MQTT 1',
|
||||
enabled: true,
|
||||
config: {
|
||||
broker_host: 'broker.local',
|
||||
broker_port: 1883,
|
||||
username: '',
|
||||
password: '',
|
||||
use_tls: false,
|
||||
tls_insecure: false,
|
||||
topic_prefix: 'meshcore',
|
||||
},
|
||||
scope: { messages: 'all', raw_packets: 'all' },
|
||||
sort_order: 0,
|
||||
created_at: 2000,
|
||||
};
|
||||
mockedApi.createFanoutConfig.mockResolvedValue(createdConfig);
|
||||
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdConfig]);
|
||||
|
||||
renderSection();
|
||||
await openCreateIntegrationDialog();
|
||||
selectCreateIntegration('Private MQTT');
|
||||
confirmCreateIntegration();
|
||||
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Broker Host'), { target: { value: 'broker.local' } });
|
||||
|
||||
const portInput = screen.getByLabelText('Broker Port') as HTMLInputElement;
|
||||
const prefixInput = screen.getByLabelText('Topic Prefix') as HTMLInputElement;
|
||||
fireEvent.change(portInput, { target: { value: '' } });
|
||||
fireEvent.change(prefixInput, { target: { value: '' } });
|
||||
|
||||
expect(portInput.value).toBe('');
|
||||
expect(prefixInput.value).toBe('');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save as Enabled' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockedApi.createFanoutConfig).toHaveBeenCalledWith({
|
||||
type: 'mqtt_private',
|
||||
name: 'Private MQTT #1',
|
||||
config: {
|
||||
broker_host: 'broker.local',
|
||||
broker_port: 1883,
|
||||
username: '',
|
||||
password: '',
|
||||
use_tls: false,
|
||||
tls_insecure: false,
|
||||
topic_prefix: 'meshcore',
|
||||
},
|
||||
scope: { messages: 'all', raw_packets: 'all' },
|
||||
enabled: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('creates MeshRank preset as a regular mqtt_community config', async () => {
|
||||
const createdConfig: FanoutConfig = {
|
||||
id: 'comm-meshrank',
|
||||
@@ -862,6 +1040,57 @@ describe('SettingsFanoutSection', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('map upload geofence radius can be cleared while editing and normalizes to zero', async () => {
|
||||
const createdConfig: FanoutConfig = {
|
||||
id: 'map-1',
|
||||
type: 'map_upload',
|
||||
name: 'Map Upload 1',
|
||||
enabled: true,
|
||||
config: {
|
||||
api_url: '',
|
||||
dry_run: true,
|
||||
geofence_enabled: true,
|
||||
geofence_radius_km: 0,
|
||||
},
|
||||
scope: { messages: 'none', raw_packets: 'all' },
|
||||
sort_order: 0,
|
||||
created_at: 2000,
|
||||
};
|
||||
mockedApi.createFanoutConfig.mockResolvedValue(createdConfig);
|
||||
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdConfig]);
|
||||
|
||||
renderSection();
|
||||
await openCreateIntegrationDialog();
|
||||
selectCreateIntegration('Map Upload');
|
||||
confirmCreateIntegration();
|
||||
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByText('Enable Geofence'));
|
||||
const radiusInput = screen.getByLabelText('Radius (km)') as HTMLInputElement;
|
||||
|
||||
fireEvent.change(radiusInput, { target: { value: '100' } });
|
||||
fireEvent.change(radiusInput, { target: { value: '' } });
|
||||
|
||||
expect(radiusInput.value).toBe('');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save as Enabled' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockedApi.createFanoutConfig).toHaveBeenCalledWith({
|
||||
type: 'map_upload',
|
||||
name: 'Map Upload #1',
|
||||
config: {
|
||||
api_url: '',
|
||||
dry_run: true,
|
||||
geofence_enabled: true,
|
||||
geofence_radius_km: 0,
|
||||
},
|
||||
scope: { messages: 'none', raw_packets: 'all' },
|
||||
enabled: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('LetsMesh (EU) preset saves the EU broker defaults', async () => {
|
||||
const createdConfig: FanoutConfig = {
|
||||
id: 'comm-letsmesh-eu',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
it('renders valid channel references as clickable links and ignores invalid ones', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChannelReferenceClick = vi.fn();
|
||||
|
||||
render(
|
||||
<MessageList
|
||||
messages={[
|
||||
createMessage({
|
||||
text: 'Alice: Join #mesh-room now skip #bad--room and visit https://example.com/#also-skip',
|
||||
}),
|
||||
]}
|
||||
contacts={[]}
|
||||
loading={false}
|
||||
onChannelReferenceClick={onChannelReferenceClick}
|
||||
/>
|
||||
);
|
||||
|
||||
const linkedChannel = screen.getByRole('button', { name: '#mesh-room' });
|
||||
expect(linkedChannel).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '#bad--room' })).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'https://example.com/#also-skip' })
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(linkedChannel);
|
||||
|
||||
expect(onChannelReferenceClick).toHaveBeenCalledWith('#mesh-room');
|
||||
});
|
||||
|
||||
it('links valid channel references in direct messages too', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChannelReferenceClick = vi.fn();
|
||||
|
||||
render(
|
||||
<MessageList
|
||||
messages={[
|
||||
createMessage({
|
||||
type: 'PRIV',
|
||||
text: 'check #ops-room',
|
||||
conversation_key: 'ab'.repeat(32),
|
||||
}),
|
||||
]}
|
||||
contacts={[]}
|
||||
loading={false}
|
||||
onChannelReferenceClick={onChannelReferenceClick}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '#ops-room' }));
|
||||
|
||||
expect(onChannelReferenceClick).toHaveBeenCalledWith('#ops-room');
|
||||
});
|
||||
|
||||
it('renders and dismisses an unread marker at the first unread message boundary', async () => {
|
||||
const user = userEvent.setup();
|
||||
const messages = [
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseSenderFromText, formatTime } from '../utils/messageParser';
|
||||
import {
|
||||
findLinkedChannelReferences,
|
||||
formatTime,
|
||||
isValidLinkedChannelName,
|
||||
parseSenderFromText,
|
||||
} from '../utils/messageParser';
|
||||
|
||||
describe('parseSenderFromText', () => {
|
||||
it('extracts sender and content from "sender: message" format', () => {
|
||||
@@ -95,3 +100,33 @@ describe('formatTime', () => {
|
||||
expect(result).toMatch(/\d{1,2}:\d{2}/); // time portion
|
||||
});
|
||||
});
|
||||
|
||||
describe('linked channel references', () => {
|
||||
it('accepts lowercase alphanumeric names with single dashes', () => {
|
||||
expect(isValidLinkedChannelName('ops')).toBe(true);
|
||||
expect(isValidLinkedChannelName('ops-1')).toBe(true);
|
||||
expect(isValidLinkedChannelName('1-2-3')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects uppercase, leading or trailing dashes, and repeated dashes', () => {
|
||||
expect(isValidLinkedChannelName('Ops')).toBe(false);
|
||||
expect(isValidLinkedChannelName('-ops')).toBe(false);
|
||||
expect(isValidLinkedChannelName('ops-')).toBe(false);
|
||||
expect(isValidLinkedChannelName('ops--room')).toBe(false);
|
||||
});
|
||||
|
||||
it('finds standalone linked channel references in message text', () => {
|
||||
expect(findLinkedChannelReferences('Join #mesh-room then say hi in #ops2')).toEqual([
|
||||
{ label: '#mesh-room', start: 5, end: 15 },
|
||||
{ label: '#ops2', start: 31, end: 36 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores invalid or embedded channel-like text', () => {
|
||||
expect(
|
||||
findLinkedChannelReferences(
|
||||
'skip #Bad #bad--name abc#ops #ops- #opsRoom #ops_room #good-room,'
|
||||
)
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,7 +32,10 @@ describe('NewMessageModal form reset', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function renderModal(open = true) {
|
||||
function renderModal(
|
||||
open = true,
|
||||
overrides: Partial<Parameters<typeof NewMessageModal>[0]> = {}
|
||||
) {
|
||||
return render(
|
||||
<NewMessageModal
|
||||
open={open}
|
||||
@@ -41,6 +44,7 @@ describe('NewMessageModal form reset', () => {
|
||||
onCreateContact={onCreateContact}
|
||||
onCreateChannel={onCreateChannel}
|
||||
onCreateHashtagChannel={onCreateHashtagChannel}
|
||||
{...overrides}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -50,6 +54,26 @@ describe('NewMessageModal form reset', () => {
|
||||
}
|
||||
|
||||
describe('hashtag tab', () => {
|
||||
it('prefills the hashtag tab from a linked channel request', async () => {
|
||||
renderModal(true, {
|
||||
prefillRequest: {
|
||||
tab: 'hashtag',
|
||||
hashtagName: 'mesh-room',
|
||||
nonce: 1,
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('tab', { name: 'Hashtag Channel' })).toHaveAttribute(
|
||||
'data-state',
|
||||
'active'
|
||||
);
|
||||
});
|
||||
expect((screen.getByPlaceholderText('channel-name') as HTMLInputElement).value).toBe(
|
||||
'mesh-room'
|
||||
);
|
||||
});
|
||||
|
||||
it('clears name after successful Create', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { unmount } = renderModal();
|
||||
|
||||
@@ -283,6 +283,8 @@ describe('RawPacketFeedView', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /show stats/i }));
|
||||
fireEvent.change(screen.getByLabelText('Stats window'), { target: { value: 'session' } });
|
||||
expect(screen.getAllByText('Alpha').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('Strongest Neighbor')).toBeInTheDocument();
|
||||
expect(screen.getByText('-70 dBm best heard')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marks unresolved neighbor identities explicitly', () => {
|
||||
|
||||
@@ -20,6 +20,12 @@ import {
|
||||
} from '../utils/lastViewedConversation';
|
||||
import { api } from '../api';
|
||||
import { DISTANCE_UNIT_KEY } from '../utils/distanceUnits';
|
||||
import {
|
||||
DEFAULT_FONT_SCALE,
|
||||
FONT_SCALE_KEY,
|
||||
MAX_FONT_SCALE,
|
||||
MIN_FONT_SCALE,
|
||||
} from '../utils/fontScale';
|
||||
|
||||
const baseConfig: RadioConfig = {
|
||||
public_key: 'aa'.repeat(32),
|
||||
@@ -186,6 +192,7 @@ describe('SettingsModal', () => {
|
||||
vi.restoreAllMocks();
|
||||
localStorage.clear();
|
||||
window.location.hash = '';
|
||||
document.documentElement.style.fontSize = '';
|
||||
});
|
||||
|
||||
it('refreshes app settings when opened', async () => {
|
||||
@@ -300,6 +307,7 @@ describe('SettingsModal', () => {
|
||||
results: [
|
||||
{
|
||||
public_key: '11'.repeat(32),
|
||||
name: null,
|
||||
node_type: 'repeater',
|
||||
heard_count: 2,
|
||||
local_snr: 7.5,
|
||||
@@ -548,6 +556,55 @@ describe('SettingsModal', () => {
|
||||
expect(localStorage.getItem(DISTANCE_UNIT_KEY)).toBe('smoots');
|
||||
});
|
||||
|
||||
it('defaults relative font size to 100% and exposes the expected input bounds', () => {
|
||||
renderModal();
|
||||
openLocalSection();
|
||||
|
||||
const slider = screen.getByLabelText('Relative font size slider');
|
||||
const input = screen.getByLabelText('Relative font size percentage');
|
||||
|
||||
expect(slider).toHaveValue(String(DEFAULT_FONT_SCALE));
|
||||
expect(slider).toHaveAttribute('step', '5');
|
||||
expect(input).toHaveValue(DEFAULT_FONT_SCALE);
|
||||
expect(input).toHaveAttribute('min', String(MIN_FONT_SCALE));
|
||||
expect(input).toHaveAttribute('max', String(MAX_FONT_SCALE));
|
||||
});
|
||||
|
||||
it('stores and applies relative font size changes locally', async () => {
|
||||
renderModal();
|
||||
openLocalSection();
|
||||
|
||||
const slider = screen.getByLabelText('Relative font size slider');
|
||||
|
||||
fireEvent.change(slider, { target: { value: '135' } });
|
||||
|
||||
expect(localStorage.getItem(FONT_SCALE_KEY)).toBeNull();
|
||||
expect(document.documentElement.style.fontSize).toBe('');
|
||||
|
||||
fireEvent.mouseUp(slider);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem(FONT_SCALE_KEY)).toBe('135');
|
||||
expect(document.documentElement.style.fontSize).toBe('135%');
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Relative font size percentage'), {
|
||||
target: { value: '137.5' },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem(FONT_SCALE_KEY)).toBe('137.5');
|
||||
expect(document.documentElement.style.fontSize).toBe('137.5%');
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reset' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem(FONT_SCALE_KEY)).toBeNull();
|
||||
expect(document.documentElement.style.fontSize).toBe('100%');
|
||||
});
|
||||
});
|
||||
|
||||
it('purges decrypted raw packets via maintenance endpoint action', async () => {
|
||||
const runMaintenanceSpy = vi.spyOn(api, 'runMaintenance').mockResolvedValue({
|
||||
packets_deleted: 12,
|
||||
@@ -594,6 +651,14 @@ describe('SettingsModal', () => {
|
||||
double_byte_pct: 30,
|
||||
triple_byte_pct: 20,
|
||||
},
|
||||
noise_floor_24h: {
|
||||
sample_interval_seconds: 300,
|
||||
coverage_seconds: 3600,
|
||||
latest_noise_floor_dbm: -105,
|
||||
latest_timestamp: 1711800000,
|
||||
supported: true,
|
||||
samples: [],
|
||||
},
|
||||
};
|
||||
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
@@ -625,17 +690,11 @@ describe('SettingsModal', () => {
|
||||
expect(
|
||||
screen.getByText(/Parsed stored raw packets from the last 24 hours: 120/)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('1-byte hops')).toBeInTheDocument();
|
||||
expect(screen.getByText('60 (50.0%)')).toBeInTheDocument();
|
||||
expect(screen.getByText('36 (30.0%)')).toBeInTheDocument();
|
||||
expect(screen.getByText('24 (20.0%)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Contacts heard')).toBeInTheDocument();
|
||||
expect(screen.getByText('Repeaters heard')).toBeInTheDocument();
|
||||
expect(screen.getByText('Known-channels active')).toBeInTheDocument();
|
||||
|
||||
// Busiest channels
|
||||
expect(screen.getByText('general')).toBeInTheDocument();
|
||||
expect(screen.getByText('42 msgs')).toBeInTheDocument();
|
||||
expect(screen.getByText('Busiest Channels (24h)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Noise Floor (24h)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fetches statistics when expanded in mobile external-nav mode', async () => {
|
||||
@@ -662,6 +721,14 @@ describe('SettingsModal', () => {
|
||||
double_byte_pct: 30,
|
||||
triple_byte_pct: 20,
|
||||
},
|
||||
noise_floor_24h: {
|
||||
sample_interval_seconds: 300,
|
||||
coverage_seconds: 0,
|
||||
latest_noise_floor_dbm: null,
|
||||
latest_timestamp: null,
|
||||
supported: null,
|
||||
samples: [],
|
||||
},
|
||||
};
|
||||
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
|
||||
@@ -75,13 +75,14 @@ function renderSidebar(overrides?: {
|
||||
|
||||
const favorites = overrides?.favorites ?? [{ type: 'channel', id: flightChannel.key }];
|
||||
const channels = overrides?.channels ?? [publicChannel, flightChannel, opsChannel];
|
||||
const onSelectConversation = vi.fn();
|
||||
|
||||
const view = render(
|
||||
<Sidebar
|
||||
contacts={[alice, board, relay]}
|
||||
channels={channels}
|
||||
activeConversation={null}
|
||||
onSelectConversation={vi.fn()}
|
||||
onSelectConversation={onSelectConversation}
|
||||
onNewMessage={vi.fn()}
|
||||
lastMessageTimes={overrides?.lastMessageTimes ?? {}}
|
||||
unreadCounts={unreadCounts}
|
||||
@@ -96,7 +97,7 @@ function renderSidebar(overrides?: {
|
||||
/>
|
||||
);
|
||||
|
||||
return { ...view, flightChannel, opsChannel, aliceName, roomName };
|
||||
return { ...view, flightChannel, opsChannel, aliceName, roomName, onSelectConversation };
|
||||
}
|
||||
|
||||
function getSectionHeaderContainer(title: string): HTMLElement {
|
||||
@@ -121,6 +122,46 @@ describe('Sidebar section summaries', () => {
|
||||
expect(within(getSectionHeaderContainer('Repeaters')).getByText('4')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a full add channel/contact button above search and calls onNewMessage', () => {
|
||||
const onNewMessage = vi.fn();
|
||||
|
||||
render(
|
||||
<Sidebar
|
||||
contacts={[]}
|
||||
channels={[makeChannel(PUBLIC_CHANNEL_KEY, 'Public')]}
|
||||
activeConversation={null}
|
||||
onSelectConversation={vi.fn()}
|
||||
onNewMessage={onNewMessage}
|
||||
lastMessageTimes={{}}
|
||||
unreadCounts={{}}
|
||||
mentions={{}}
|
||||
showCracker={false}
|
||||
crackerRunning={false}
|
||||
onToggleCracker={vi.fn()}
|
||||
onMarkAllRead={vi.fn()}
|
||||
favorites={[]}
|
||||
legacySortOrder="recent"
|
||||
/>
|
||||
);
|
||||
|
||||
const addButton = screen.getByRole('button', { name: 'Add channel or contact' });
|
||||
const search = screen.getByLabelText('Search conversations');
|
||||
const nav = screen.getByRole('navigation', { name: 'Conversations' });
|
||||
const toolsButton = screen.getByRole('button', { name: 'Tools' });
|
||||
|
||||
expect(addButton).toHaveTextContent('Add Channel/Contact');
|
||||
expect(
|
||||
addButton.compareDocumentPosition(search) & Node.DOCUMENT_POSITION_FOLLOWING
|
||||
).toBeTruthy();
|
||||
expect(nav.compareDocumentPosition(search) & Node.DOCUMENT_POSITION_CONTAINED_BY).toBeTruthy();
|
||||
expect(
|
||||
search.compareDocumentPosition(toolsButton) & Node.DOCUMENT_POSITION_FOLLOWING
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.click(addButton);
|
||||
expect(onNewMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('turns favorites and channels rollups red when they contain a mention', () => {
|
||||
renderSidebar({
|
||||
mentions: {
|
||||
@@ -306,6 +347,18 @@ describe('Sidebar section summaries', () => {
|
||||
expect(bell.compareDocumentPosition(unread) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows the trace tool row and selects it', () => {
|
||||
const { onSelectConversation } = renderSidebar();
|
||||
|
||||
fireEvent.click(screen.getByText('Trace'));
|
||||
|
||||
expect(onSelectConversation).toHaveBeenCalledWith({
|
||||
type: 'trace',
|
||||
id: 'trace',
|
||||
name: 'Trace',
|
||||
});
|
||||
});
|
||||
|
||||
it('sorts each section independently and persists per-section sort preferences', () => {
|
||||
const publicChannel = makeChannel('AA'.repeat(16), 'Public');
|
||||
const zebraChannel = makeChannel('BB'.repeat(16), '#zebra');
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
|
||||
it('parses #trace as trace type', () => {
|
||||
window.location.hash = '#trace';
|
||||
|
||||
const result = parseHashConversation();
|
||||
|
||||
expect(result).toEqual({ type: 'trace', name: 'trace' });
|
||||
});
|
||||
|
||||
it('parses #map/focus/PUBKEY with focus key', () => {
|
||||
window.location.hash = '#map/focus/ABCD1234';
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ function createRealtimeArgs(overrides: Partial<Parameters<typeof useRealtimeAppS
|
||||
observeMessage: vi.fn(() => ({ added: false, activeConversation: false })),
|
||||
recordMessageEvent: vi.fn(),
|
||||
renameConversationState: vi.fn(),
|
||||
removeConversationState: vi.fn(),
|
||||
checkMention: vi.fn(() => false),
|
||||
pendingDeleteFallbackRef: { current: false },
|
||||
setActiveConversation: vi.fn(),
|
||||
|
||||
@@ -221,6 +221,49 @@ describe('useUnreadCounts', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not treat search or trace views as readable conversations', async () => {
|
||||
const mocks = await getMockedApi();
|
||||
mocks.getUnreads.mockResolvedValue({
|
||||
counts: {
|
||||
[getStateKey('channel', CHANNEL_KEY)]: 4,
|
||||
[getStateKey('contact', CONTACT_KEY)]: 2,
|
||||
},
|
||||
mentions: {
|
||||
[getStateKey('channel', CHANNEL_KEY)]: true,
|
||||
},
|
||||
last_message_times: {},
|
||||
last_read_ats: {},
|
||||
});
|
||||
|
||||
const { result, rerender } = renderWith({
|
||||
channels: [makeChannel(CHANNEL_KEY, 'Test')],
|
||||
contacts: [makeContact(CONTACT_KEY)],
|
||||
activeConversation: { type: 'search', id: 'search', name: 'Message Search' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.waitFor(() => expect(mocks.getUnreads).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
expect(result.current.unreadCounts[getStateKey('channel', CHANNEL_KEY)]).toBe(4);
|
||||
expect(result.current.unreadCounts[getStateKey('contact', CONTACT_KEY)]).toBe(2);
|
||||
expect(mocks.markChannelRead).not.toHaveBeenCalled();
|
||||
expect(mocks.markContactRead).not.toHaveBeenCalled();
|
||||
|
||||
rerender({
|
||||
channels: [makeChannel(CHANNEL_KEY, 'Test')],
|
||||
contacts: [makeContact(CONTACT_KEY)],
|
||||
activeConversation: { type: 'trace', id: 'trace', name: 'Trace' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mocks.markChannelRead).not.toHaveBeenCalled();
|
||||
expect(mocks.markContactRead).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-fetches and filters when refreshUnreads is called (simulating WS reconnect)', async () => {
|
||||
const mocks = await getMockedApi();
|
||||
const channels = [makeChannel(CHANNEL_KEY, 'Test')];
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { VisualizerControls } from '../components/visualizer/VisualizerControls';
|
||||
|
||||
describe('VisualizerControls', () => {
|
||||
it('allows clearing numeric inputs while editing', () => {
|
||||
render(
|
||||
<VisualizerControls
|
||||
showControls
|
||||
setShowControls={vi.fn()}
|
||||
showAmbiguousPaths={false}
|
||||
setShowAmbiguousPaths={vi.fn()}
|
||||
showAmbiguousNodes={false}
|
||||
setShowAmbiguousNodes={vi.fn()}
|
||||
useAdvertPathHints={false}
|
||||
setUseAdvertPathHints={vi.fn()}
|
||||
collapseLikelyKnownSiblingRepeaters={false}
|
||||
setCollapseLikelyKnownSiblingRepeaters={vi.fn()}
|
||||
splitAmbiguousByTraffic={false}
|
||||
setSplitAmbiguousByTraffic={vi.fn()}
|
||||
observationWindowSec={5}
|
||||
setObservationWindowSec={vi.fn()}
|
||||
pruneStaleNodes
|
||||
setPruneStaleNodes={vi.fn()}
|
||||
pruneStaleMinutes={10}
|
||||
setPruneStaleMinutes={vi.fn()}
|
||||
letEmDrift={false}
|
||||
setLetEmDrift={vi.fn()}
|
||||
autoOrbit={false}
|
||||
setAutoOrbit={vi.fn()}
|
||||
chargeStrength={-100}
|
||||
setChargeStrength={vi.fn()}
|
||||
particleSpeedMultiplier={1}
|
||||
setParticleSpeedMultiplier={vi.fn()}
|
||||
nodeCount={0}
|
||||
linkCount={0}
|
||||
onExpandContract={vi.fn()}
|
||||
onClearAndReset={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const observationInput = screen.getByLabelText('Ack/echo listen window:') as HTMLInputElement;
|
||||
const pruneInput = screen.getByLabelText('Window:') as HTMLInputElement;
|
||||
|
||||
fireEvent.change(observationInput, { target: { value: '' } });
|
||||
fireEvent.change(pruneInput, { target: { value: '' } });
|
||||
|
||||
expect(observationInput.value).toBe('');
|
||||
expect(pruneInput.value).toBe('');
|
||||
});
|
||||
});
|
||||
+37
-1
@@ -34,6 +34,7 @@ export type RadioDiscoveryTarget = 'repeaters' | 'sensors' | 'all';
|
||||
|
||||
export interface RadioDiscoveryResult {
|
||||
public_key: string;
|
||||
name: string | null;
|
||||
node_type: 'repeater' | 'sensor';
|
||||
heard_count: number;
|
||||
local_snr: number | null;
|
||||
@@ -53,6 +54,7 @@ export interface FanoutStatusEntry {
|
||||
name: string;
|
||||
type: string;
|
||||
status: string;
|
||||
last_error?: string | null;
|
||||
}
|
||||
|
||||
export interface AppInfo {
|
||||
@@ -284,7 +286,7 @@ export interface ResendChannelMessageResponse {
|
||||
message?: Message;
|
||||
}
|
||||
|
||||
type ConversationType = 'contact' | 'channel' | 'raw' | 'map' | 'visualizer' | 'search';
|
||||
type ConversationType = 'contact' | 'channel' | 'raw' | 'map' | 'visualizer' | 'search' | 'trace';
|
||||
|
||||
export interface Conversation {
|
||||
type: ConversationType;
|
||||
@@ -472,6 +474,25 @@ export interface TraceResponse {
|
||||
path_len: number;
|
||||
}
|
||||
|
||||
export interface RadioTraceNode {
|
||||
role: 'repeater' | 'custom' | 'local';
|
||||
public_key: string | null;
|
||||
name: string | null;
|
||||
observed_hash: string | null;
|
||||
snr: number | null;
|
||||
}
|
||||
|
||||
export interface RadioTraceHopRequest {
|
||||
public_key?: string | null;
|
||||
hop_hex?: string | null;
|
||||
}
|
||||
|
||||
export interface RadioTraceResponse {
|
||||
path_len: number;
|
||||
timeout_seconds: number;
|
||||
nodes: RadioTraceNode[];
|
||||
}
|
||||
|
||||
export interface PathDiscoveryRoute {
|
||||
path: string;
|
||||
path_len: number;
|
||||
@@ -503,6 +524,20 @@ interface ContactActivityCounts {
|
||||
last_week: number;
|
||||
}
|
||||
|
||||
export interface NoiseFloorSample {
|
||||
timestamp: number;
|
||||
noise_floor_dbm: number;
|
||||
}
|
||||
|
||||
export interface NoiseFloorHistoryStats {
|
||||
sample_interval_seconds: number;
|
||||
coverage_seconds: number;
|
||||
latest_noise_floor_dbm: number | null;
|
||||
latest_timestamp: number | null;
|
||||
supported: boolean | null;
|
||||
samples: NoiseFloorSample[];
|
||||
}
|
||||
|
||||
export interface StatisticsResponse {
|
||||
busiest_channels_24h: BusyChannel[];
|
||||
contact_count: number;
|
||||
@@ -526,4 +561,5 @@ export interface StatisticsResponse {
|
||||
double_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 LAST_VIEWED_CONVERSATION_KEY = 'remoteterm-last-viewed-conversation';
|
||||
|
||||
const SUPPORTED_TYPES: Conversation['type'][] = ['contact', 'channel', 'raw', 'map', 'visualizer'];
|
||||
const SUPPORTED_TYPES: Conversation['type'][] = [
|
||||
'contact',
|
||||
'channel',
|
||||
'raw',
|
||||
'map',
|
||||
'visualizer',
|
||||
'trace',
|
||||
];
|
||||
|
||||
function isSupportedType(value: unknown): value is Conversation['type'] {
|
||||
return typeof value === 'string' && SUPPORTED_TYPES.includes(value as Conversation['type']);
|
||||
@@ -94,6 +101,10 @@ export function captureLastViewedConversationFromHash(): void {
|
||||
saveLastViewedConversation({ type: 'visualizer', id: 'visualizer', name: 'Mesh Visualizer' });
|
||||
return;
|
||||
}
|
||||
if (hashConversation.type === 'trace') {
|
||||
saveLastViewedConversation({ type: 'trace', id: 'trace', name: 'Trace' });
|
||||
return;
|
||||
}
|
||||
|
||||
saveLastViewedConversation({
|
||||
type: hashConversation.type,
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
* Parse sender from channel message text.
|
||||
* Channel messages have format "sender: message".
|
||||
*/
|
||||
const HASHTAG_CHANNEL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
const HASHTAG_CHANNEL_REFERENCE_PATTERN = /(^|\s)(#[a-z0-9]+(?:-[a-z0-9]+)*)(?=$|\s)/g;
|
||||
|
||||
export function parseSenderFromText(text: string): { sender: string | null; content: string } {
|
||||
const colonIndex = text.indexOf(': ');
|
||||
if (colonIndex > 0 && colonIndex < 50) {
|
||||
@@ -17,6 +20,35 @@ export function parseSenderFromText(text: string): { sender: string | null; cont
|
||||
return { sender: null, content: text };
|
||||
}
|
||||
|
||||
export interface HashtagChannelReference {
|
||||
label: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export function isValidLinkedChannelName(name: string): boolean {
|
||||
return HASHTAG_CHANNEL_NAME_PATTERN.test(name);
|
||||
}
|
||||
|
||||
export function findLinkedChannelReferences(text: string): HashtagChannelReference[] {
|
||||
const references: HashtagChannelReference[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
HASHTAG_CHANNEL_REFERENCE_PATTERN.lastIndex = 0;
|
||||
while ((match = HASHTAG_CHANNEL_REFERENCE_PATTERN.exec(text)) !== null) {
|
||||
const prefix = match[1];
|
||||
const label = match[2];
|
||||
const start = match.index + prefix.length;
|
||||
references.push({
|
||||
label,
|
||||
start,
|
||||
end: start + label.length,
|
||||
});
|
||||
}
|
||||
|
||||
return references;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Unix timestamp to a time string.
|
||||
* Shows date for messages not from today.
|
||||
|
||||
@@ -106,9 +106,6 @@ export interface RawPacketStatsSnapshot {
|
||||
medianRssi: number | null;
|
||||
bestRssi: number | null;
|
||||
rssiBuckets: RankedPacketStat[];
|
||||
strongestPacketSourceKey: string | null;
|
||||
strongestPacketSourceLabel: string | null;
|
||||
strongestPacketPayloadType: string | null;
|
||||
coverageSeconds: number;
|
||||
windowFullyCovered: boolean;
|
||||
oldestStoredTimestamp: number | null;
|
||||
@@ -377,8 +374,6 @@ export function buildRawPacketStatsSnapshot(
|
||||
['Weak (<-85 dBm)', 0],
|
||||
]);
|
||||
|
||||
let strongestPacket: RawPacketStatsObservation | null = null;
|
||||
|
||||
for (const packet of packets) {
|
||||
payloadCounts.set(packet.payloadType, (payloadCounts.get(packet.payloadType) ?? 0) + 1);
|
||||
routeCounts.set(packet.routeType, (routeCounts.get(packet.routeType) ?? 0) + 1);
|
||||
@@ -436,10 +431,6 @@ export function buildRawPacketStatsSnapshot(
|
||||
} else {
|
||||
rssiBucketCounts.set('Weak (<-85 dBm)', (rssiBucketCounts.get('Weak (<-85 dBm)') ?? 0) + 1);
|
||||
}
|
||||
|
||||
if (!strongestPacket || strongestPacket.rssi === null || packet.rssi > strongestPacket.rssi) {
|
||||
strongestPacket = packet;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -527,9 +518,6 @@ export function buildRawPacketStatsSnapshot(
|
||||
medianRssi,
|
||||
bestRssi,
|
||||
rssiBuckets: rankedBreakdown(rssiBucketCounts, rssiValues.length),
|
||||
strongestPacketSourceKey: strongestPacket?.sourceKey ?? null,
|
||||
strongestPacketSourceLabel: strongestPacket?.sourceLabel ?? null,
|
||||
strongestPacketPayloadType: strongestPacket?.payloadType ?? null,
|
||||
coverageSeconds,
|
||||
windowFullyCovered,
|
||||
oldestStoredTimestamp,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getContactDisplayName } from './pubkey';
|
||||
import type { SettingsSection } from '../components/settings/settingsConstants';
|
||||
|
||||
interface ParsedHashConversation {
|
||||
type: 'channel' | 'contact' | 'raw' | 'map' | 'visualizer' | 'search';
|
||||
type: 'channel' | 'contact' | 'raw' | 'map' | 'visualizer' | 'search' | 'trace';
|
||||
/** Conversation identity token (channel key or contact public key, or legacy name token) */
|
||||
name: string;
|
||||
/** Optional human-readable label segment (ignored for identity resolution) */
|
||||
@@ -44,6 +44,10 @@ export function parseHashConversation(): ParsedHashConversation | null {
|
||||
return { type: 'search', name: 'search' };
|
||||
}
|
||||
|
||||
if (hash === 'trace') {
|
||||
return { type: 'trace', name: 'trace' };
|
||||
}
|
||||
|
||||
// Check for map with focus: #map/focus/{pubkey_prefix}
|
||||
if (hash.startsWith('map/focus/')) {
|
||||
const focusKey = hash.slice('map/focus/'.length);
|
||||
@@ -149,6 +153,7 @@ function getConversationHash(conv: Conversation | null): string {
|
||||
if (conv.type === 'map') return '#map';
|
||||
if (conv.type === 'visualizer') return '#visualizer';
|
||||
if (conv.type === 'search') return '#search';
|
||||
if (conv.type === 'trace') return '#trace';
|
||||
|
||||
// Use immutable IDs for identity, append readable label for UX.
|
||||
if (conv.type === 'channel') {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "remoteterm-meshcore"
|
||||
version = "3.6.1"
|
||||
version = "3.6.3"
|
||||
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
|
||||
readme = "README.md"
|
||||
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
|
||||
|
||||
# Collect third-party license texts into LICENSES.md
|
||||
# Usage: scripts/collect_licenses.sh [output-path]
|
||||
# Usage: scripts/build/collect_licenses.sh [output-path]
|
||||
# output-path defaults to LICENSES.md at the repo root
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
OUT="${1:-$REPO_ROOT/LICENSES.md}"
|
||||
FRONTEND_LICENSE_IMAGE="${FRONTEND_LICENSE_IMAGE:-node:20-slim}"
|
||||
FRONTEND_LICENSE_NPM="${FRONTEND_LICENSE_NPM:-10.9.5}"
|
||||
@@ -59,7 +59,7 @@ for d in data:
|
||||
# ── Frontend (npm) ───────────────────────────────────────────────────
|
||||
frontend_licenses_local() {
|
||||
cd "$REPO_ROOT/frontend"
|
||||
node "$REPO_ROOT/scripts/print_frontend_licenses.cjs"
|
||||
node "$REPO_ROOT/scripts/build/print_frontend_licenses.cjs"
|
||||
}
|
||||
|
||||
frontend_licenses_docker() {
|
||||
@@ -73,7 +73,7 @@ frontend_licenses_docker() {
|
||||
cd frontend
|
||||
npm i -g npm@$FRONTEND_LICENSE_NPM >/dev/null
|
||||
npm ci --ignore-scripts >/dev/null
|
||||
node /src/scripts/print_frontend_licenses.cjs
|
||||
node /src/scripts/build/print_frontend_licenses.cjs
|
||||
"
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ frontend_licenses() {
|
||||
{
|
||||
echo "# Third-Party Licenses"
|
||||
echo
|
||||
echo "Auto-generated by \`scripts/collect_licenses.sh\` — do not edit by hand."
|
||||
echo "Auto-generated by \`scripts/build/collect_licenses.sh\` — do not edit by hand."
|
||||
echo
|
||||
echo "## Backend (Python) Dependencies"
|
||||
echo
|
||||
@@ -7,23 +7,51 @@ GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
RELEASE_WORK_DIR=""
|
||||
RELEASE_BUNDLE_DIR_NAME="Remote-Terminal-for-MeshCore"
|
||||
RELEASE_ASSET=""
|
||||
DOCKER_IMAGE="jkingsman/remoteterm-meshcore"
|
||||
DOCKER_PLATFORMS="linux/amd64,linux/arm64"
|
||||
|
||||
cleanup_release_build_artifacts() {
|
||||
if [ -d "$SCRIPT_DIR/frontend/prebuilt" ]; then
|
||||
rm -rf "$SCRIPT_DIR/frontend/prebuilt"
|
||||
if [ -d "$REPO_ROOT/frontend/prebuilt" ]; then
|
||||
rm -rf "$REPO_ROOT/frontend/prebuilt"
|
||||
fi
|
||||
if [ -n "$RELEASE_WORK_DIR" ] && [ -d "$RELEASE_WORK_DIR" ]; then
|
||||
rm -rf "$RELEASE_WORK_DIR"
|
||||
fi
|
||||
if [ -n "$RELEASE_ASSET" ] && [ -f "$REPO_ROOT/$RELEASE_ASSET" ]; then
|
||||
rm -f "$REPO_ROOT/$RELEASE_ASSET"
|
||||
fi
|
||||
}
|
||||
|
||||
trap cleanup_release_build_artifacts EXIT
|
||||
|
||||
ensure_buildx_builder() {
|
||||
if ! docker buildx version >/dev/null 2>&1; then
|
||||
echo -e "${RED}Error: docker buildx is required for multi-arch Docker builds.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local current_builder
|
||||
current_builder="$(docker buildx inspect --format '{{ .Name }}' 2>/dev/null || true)"
|
||||
|
||||
if [ -n "$current_builder" ]; then
|
||||
docker buildx inspect --bootstrap >/dev/null
|
||||
return
|
||||
fi
|
||||
|
||||
if docker buildx inspect remoteterm-multiarch >/dev/null 2>&1; then
|
||||
docker buildx use remoteterm-multiarch >/dev/null
|
||||
else
|
||||
docker buildx create --name remoteterm-multiarch --use >/dev/null
|
||||
fi
|
||||
docker buildx inspect --bootstrap >/dev/null
|
||||
}
|
||||
|
||||
echo -e "${YELLOW}=== RemoteTerm for MeshCore Publish Script ===${NC}"
|
||||
echo
|
||||
|
||||
@@ -50,7 +78,7 @@ echo
|
||||
|
||||
# Run frontend linting and formatting check
|
||||
echo -e "${YELLOW}Running frontend lint (ESLint)...${NC}"
|
||||
cd "$SCRIPT_DIR/frontend"
|
||||
cd "$REPO_ROOT/frontend"
|
||||
npm run lint
|
||||
echo -e "${GREEN}Frontend lint passed!${NC}"
|
||||
echo
|
||||
@@ -69,11 +97,11 @@ echo
|
||||
echo -e "${YELLOW}Building frontend...${NC}"
|
||||
npm run build
|
||||
echo -e "${GREEN}Frontend build complete!${NC}"
|
||||
cd "$SCRIPT_DIR"
|
||||
cd "$REPO_ROOT"
|
||||
echo
|
||||
|
||||
echo -e "${YELLOW}Regenerating LICENSES.md...${NC}"
|
||||
bash scripts/collect_licenses.sh LICENSES.md
|
||||
bash scripts/build/collect_licenses.sh LICENSES.md
|
||||
echo -e "${GREEN}LICENSES.md updated!${NC}"
|
||||
echo
|
||||
|
||||
@@ -174,16 +202,16 @@ FULL_GIT_HASH=$(git rev-parse HEAD)
|
||||
RELEASE_ASSET="remoteterm-prebuilt-frontend-v${VERSION}-${GIT_HASH}.zip"
|
||||
|
||||
echo -e "${YELLOW}Building packaged frontend artifact...${NC}"
|
||||
cd "$SCRIPT_DIR/frontend"
|
||||
cd "$REPO_ROOT/frontend"
|
||||
npm run packaged-build
|
||||
cd "$SCRIPT_DIR"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
RELEASE_WORK_DIR=$(mktemp -d)
|
||||
RELEASE_BUNDLE_DIR="$RELEASE_WORK_DIR/$RELEASE_BUNDLE_DIR_NAME"
|
||||
mkdir -p "$RELEASE_BUNDLE_DIR"
|
||||
git archive "$FULL_GIT_HASH" | tar -x -C "$RELEASE_BUNDLE_DIR"
|
||||
mkdir -p "$RELEASE_BUNDLE_DIR/frontend"
|
||||
cp -R "$SCRIPT_DIR/frontend/prebuilt" "$RELEASE_BUNDLE_DIR/frontend/prebuilt"
|
||||
cp -R "$REPO_ROOT/frontend/prebuilt" "$RELEASE_BUNDLE_DIR/frontend/prebuilt"
|
||||
cat > "$RELEASE_BUNDLE_DIR/build_info.json" <<EOF
|
||||
{
|
||||
"version": "$VERSION",
|
||||
@@ -191,29 +219,26 @@ cat > "$RELEASE_BUNDLE_DIR/build_info.json" <<EOF
|
||||
"build_source": "prebuilt-release"
|
||||
}
|
||||
EOF
|
||||
rm -f "$SCRIPT_DIR/$RELEASE_ASSET"
|
||||
rm -f "$REPO_ROOT/$RELEASE_ASSET"
|
||||
(
|
||||
cd "$RELEASE_WORK_DIR"
|
||||
zip -qr "$SCRIPT_DIR/$RELEASE_ASSET" "$(basename "$RELEASE_BUNDLE_DIR")"
|
||||
zip -qr "$REPO_ROOT/$RELEASE_ASSET" "$(basename "$RELEASE_BUNDLE_DIR")"
|
||||
)
|
||||
echo -e "${GREEN}Packaged release artifact created: $RELEASE_ASSET${NC}"
|
||||
echo
|
||||
|
||||
# Build docker image
|
||||
echo -e "${YELLOW}Building Docker image...${NC}"
|
||||
docker build --build-arg COMMIT_HASH=$GIT_HASH \
|
||||
-t jkingsman/remoteterm-meshcore:latest \
|
||||
-t jkingsman/remoteterm-meshcore:$VERSION \
|
||||
-t jkingsman/remoteterm-meshcore:$GIT_HASH .
|
||||
echo -e "${GREEN}Docker build complete!${NC}"
|
||||
echo
|
||||
|
||||
# Push docker images
|
||||
echo -e "${YELLOW}Pushing Docker images...${NC}"
|
||||
docker push jkingsman/remoteterm-meshcore:latest
|
||||
docker push jkingsman/remoteterm-meshcore:$VERSION
|
||||
docker push jkingsman/remoteterm-meshcore:$GIT_HASH
|
||||
echo -e "${GREEN}Docker push complete!${NC}"
|
||||
# Build and push multi-arch docker image
|
||||
echo -e "${YELLOW}Building and pushing multi-arch Docker image...${NC}"
|
||||
ensure_buildx_builder
|
||||
docker buildx build \
|
||||
--platform "$DOCKER_PLATFORMS" \
|
||||
--build-arg COMMIT_HASH="$GIT_HASH" \
|
||||
-t "$DOCKER_IMAGE:latest" \
|
||||
-t "$DOCKER_IMAGE:$VERSION" \
|
||||
-t "$DOCKER_IMAGE:$GIT_HASH" \
|
||||
--push \
|
||||
.
|
||||
echo -e "${GREEN}Multi-arch Docker build + push complete!${NC}"
|
||||
echo
|
||||
|
||||
# Create GitHub release using the changelog notes for this version.
|
||||
@@ -254,9 +279,12 @@ echo -e "${GREEN}=== Publish complete! ===${NC}"
|
||||
echo -e "Version: ${YELLOW}$VERSION${NC}"
|
||||
echo -e "Git hash: ${YELLOW}$GIT_HASH${NC}"
|
||||
echo -e "Docker tags pushed:"
|
||||
echo -e " - jkingsman/remoteterm-meshcore:latest"
|
||||
echo -e " - jkingsman/remoteterm-meshcore:$VERSION"
|
||||
echo -e " - jkingsman/remoteterm-meshcore:$GIT_HASH"
|
||||
echo -e " - $DOCKER_IMAGE:latest"
|
||||
echo -e " - $DOCKER_IMAGE:$VERSION"
|
||||
echo -e " - $DOCKER_IMAGE:$GIT_HASH"
|
||||
echo -e "Platforms:"
|
||||
echo -e " - linux/amd64"
|
||||
echo -e " - linux/arm64"
|
||||
echo -e "GitHub release:"
|
||||
echo -e " - $VERSION"
|
||||
echo -e "Release artifact:"
|
||||
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'
|
||||
NC='\033[0m'
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
|
||||
NODE_VERSIONS=("20" "22" "24")
|
||||
# Use explicit npm patch versions so resolver regressions are caught.
|
||||
@@ -27,7 +27,7 @@ run_combo() {
|
||||
local image="node:${node_version}-slim"
|
||||
|
||||
docker run --rm \
|
||||
-v "$SCRIPT_DIR:/src:ro" \
|
||||
-v "$REPO_ROOT:/src:ro" \
|
||||
-w /tmp \
|
||||
"$image" \
|
||||
bash -lc "
|
||||
@@ -79,7 +79,7 @@ cleanup() {
|
||||
trap cleanup EXIT
|
||||
|
||||
echo -e "${YELLOW}=== Frontend Docker CI Matrix ===${NC}"
|
||||
echo -e "${BLUE}Repo:${NC} $SCRIPT_DIR"
|
||||
echo -e "${BLUE}Repo:${NC} $REPO_ROOT"
|
||||
echo
|
||||
|
||||
for case_spec in "${TEST_CASES[@]}"; do
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
|
||||
echo "Starting E2E tests..."
|
||||
cd "$SCRIPT_DIR/tests/e2e"
|
||||
cd "$REPO_ROOT/tests/e2e"
|
||||
npx playwright test "$@"
|
||||
@@ -6,23 +6,23 @@ GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
|
||||
echo -e "${YELLOW}=== Extended Quality Checks ===${NC}"
|
||||
echo
|
||||
|
||||
echo -e "${BLUE}[all_quality]${NC} Running full lint, typecheck, unit tests, and the standard frontend build..."
|
||||
"$SCRIPT_DIR/scripts/all_quality.sh"
|
||||
"$REPO_ROOT/scripts/quality/all_quality.sh"
|
||||
echo -e "${GREEN}[all_quality]${NC} Passed!"
|
||||
echo
|
||||
|
||||
echo -e "${BLUE}[e2e]${NC} Running end-to-end tests..."
|
||||
"$SCRIPT_DIR/scripts/e2e.sh" "$@"
|
||||
"$REPO_ROOT/scripts/quality/e2e.sh" "$@"
|
||||
echo -e "${GREEN}[e2e]${NC} Passed!"
|
||||
echo
|
||||
|
||||
echo -e "${BLUE}[docker_ci]${NC} Running Docker frontend install/build matrix..."
|
||||
"$SCRIPT_DIR/scripts/docker_ci.sh"
|
||||
"$REPO_ROOT/scripts/quality/docker_ci.sh"
|
||||
echo -e "${GREEN}[docker_ci]${NC} Passed!"
|
||||
echo
|
||||
|
||||
@@ -21,7 +21,8 @@ API_URL = f"https://api.github.com/repos/{REPO}/releases/latest"
|
||||
PREBUILT_PREFIX = "Remote-Terminal-for-MeshCore/frontend/prebuilt/"
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
PREBUILT_DIR = SCRIPT_DIR.parent / "frontend" / "prebuilt"
|
||||
REPO_ROOT = SCRIPT_DIR.parent.parent
|
||||
PREBUILT_DIR = REPO_ROOT / "frontend" / "prebuilt"
|
||||
|
||||
|
||||
def fetch_json(url: str) -> dict:
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user