mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-12 20:36:05 +02:00
Compare commits
64 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 | |||
| 7151cf3846 | |||
| 6e5256acce | |||
| 7d27567ae9 | |||
| 5f0d042252 | |||
| 6f68dfc609 | |||
| a32ddda79d | |||
| ac6a5774af | |||
| b12e612596 | |||
| d1499ad75f | |||
| 79d5e69ee0 | |||
| 1405df6039 | |||
| ac5e71d6f2 | |||
| 650a24a68c | |||
| 53f122e503 | |||
| efeb047116 | |||
| b7972f50a8 | |||
| bab1693c82 | |||
| f93844a01b | |||
| e15e6d83f7 | |||
| f9ca35b3ae | |||
| 7c4a244e05 | |||
| 6eab75ec7e | |||
| 95c874e643 |
@@ -2,6 +2,8 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[oc]
|
*.py[oc]
|
||||||
build/
|
build/
|
||||||
|
!scripts/build/
|
||||||
|
!scripts/build/**
|
||||||
wheels/
|
wheels/
|
||||||
*.egg-info
|
*.egg-info
|
||||||
|
|
||||||
@@ -23,3 +25,9 @@ references/
|
|||||||
|
|
||||||
# ancillary LLM files
|
# ancillary LLM files
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
# local Docker compose files
|
||||||
|
docker-compose.yml
|
||||||
|
docker-compose.yaml
|
||||||
|
.docker-certs/
|
||||||
|
.docker-nginx/
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
If instructed to "run all tests" or "get ready for a commit" or other summative, work ending directives, run:
|
If instructed to "run all tests" or "get ready for a commit" or other summative, work ending directives, run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/all_quality.sh
|
./scripts/quality/all_quality.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
This is the repo's end-to-end quality gate. It runs backend/frontend autofixers first, then type checking, tests, and the standard frontend build. All checks must pass green, and the script may leave formatting/lint edits behind.
|
This is the repo's end-to-end quality gate. It runs backend/frontend autofixers first, then type checking, tests, and the standard frontend build. All checks must pass green, and the script may leave formatting/lint edits behind.
|
||||||
@@ -210,10 +210,16 @@ This message-layer echo/path handling is independent of raw-packet storage dedup
|
|||||||
│ │ └── ...
|
│ │ └── ...
|
||||||
│ └── vite.config.ts
|
│ └── vite.config.ts
|
||||||
├── scripts/ # Quality / release helpers (listing below is representative, not exhaustive)
|
├── scripts/ # Quality / release helpers (listing below is representative, not exhaustive)
|
||||||
│ ├── all_quality.sh # Repo-standard autofix + validate gate
|
│ ├── build/
|
||||||
│ ├── collect_licenses.sh # Gather third-party license attributions
|
│ │ ├── collect_licenses.sh # Gather third-party license attributions
|
||||||
│ ├── e2e.sh # End-to-end test runner
|
│ │ └── publish.sh # Version bump, changelog, docker build & push
|
||||||
│ └── publish.sh # Version bump, changelog, docker build & push
|
│ ├── quality/
|
||||||
|
│ │ ├── all_quality.sh # Repo-standard autofix + validate gate
|
||||||
|
│ │ ├── e2e.sh # End-to-end test runner
|
||||||
|
│ │ └── extended_quality.sh # Quality gate plus e2e and Docker matrix
|
||||||
|
│ └── setup/
|
||||||
|
│ ├── fetch_prebuilt_frontend.py # Download release frontend fallback
|
||||||
|
│ └── install_service.sh # Install/configure Linux systemd service
|
||||||
├── README_ADVANCED.md # Advanced setup, troubleshooting, and service guidance
|
├── README_ADVANCED.md # Advanced setup, troubleshooting, and service guidance
|
||||||
├── CONTRIBUTING.md # Contributor workflow and testing guidance
|
├── CONTRIBUTING.md # Contributor workflow and testing guidance
|
||||||
├── tests/ # Backend tests (pytest)
|
├── tests/ # Backend tests (pytest)
|
||||||
@@ -271,23 +277,23 @@ PYTHONPATH=. uv run pytest tests/ -v
|
|||||||
```
|
```
|
||||||
|
|
||||||
Key test files:
|
Key test files:
|
||||||
- `tests/test_decoder.py` - Channel + direct message decryption, key exchange
|
- `tests/test_api.py` - Broad API integration coverage across routers and read-state flows
|
||||||
- `tests/test_keystore.py` - Ephemeral key store
|
- `tests/test_packet_pipeline.py` - End-to-end packet processing, decrypt, dedup, and message creation
|
||||||
- `tests/test_event_handlers.py` - ACK tracking, repeat detection
|
- `tests/test_event_handlers.py` - ACK tracking, fallback DM handling, and event subscription cleanup
|
||||||
- `tests/test_packet_pipeline.py` - End-to-end packet processing
|
- `tests/test_send_messages.py` - Outgoing DM/channel send workflows, retries, and bot-trigger wiring
|
||||||
- `tests/test_api.py` - API endpoints, read state tracking
|
- `tests/test_packets_router.py` - Historical decrypt, maintenance, and raw-packet detail endpoints
|
||||||
- `tests/test_migrations.py` - Database migration system
|
- `tests/test_repeater_routes.py` - Repeater command/telemetry/trace pane endpoints
|
||||||
- `tests/test_frontend_static.py` - Frontend static route registration (missing `dist`/`index.html` handling)
|
- `tests/test_room_routes.py` - Room-server login/status/ACL/telemetry endpoints
|
||||||
- `tests/test_messages_search.py` - Message search, around endpoint, forward pagination
|
- `tests/test_radio_router.py` - Radio config, advert, discovery, trace, and reconnect endpoints
|
||||||
- `tests/test_rx_log_data.py` - on_rx_log_data event handler integration
|
- `tests/test_radio_sync.py` - Radio sync, periodic tasks, contact offload/reload, and pending-message flushes
|
||||||
- `tests/test_ack_tracking_wiring.py` - DM ACK tracking extraction and wiring
|
- `tests/test_fanout.py` - Fanout config CRUD, scope matching, and manager dispatch
|
||||||
- `tests/test_radio_lifecycle_service.py` - Radio reconnect/setup orchestration helpers
|
- `tests/test_fanout_integration.py` - Integration-module lifecycle and delivery behavior
|
||||||
- `tests/test_radio_commands_service.py` - Radio config/private-key service workflows
|
- `tests/test_statistics.py` - Aggregated mesh/network statistics and noise-floor snapshots
|
||||||
- `tests/test_health_mqtt_status.py` - Health endpoint MQTT status field
|
- `tests/test_version_info.py` - Version/build metadata resolution
|
||||||
- `tests/test_community_mqtt.py` - Community MQTT publisher (JWT, packet format, hash, broadcast)
|
- `tests/test_websocket.py` - WS manager broadcast and cleanup behavior
|
||||||
- `tests/test_radio_sync.py` - Radio sync, periodic tasks, and contact offload back to the radio
|
- `tests/test_frontend_static.py` - Frontend static route registration and fallback behavior
|
||||||
- `tests/test_real_crypto.py` - Real cryptographic operations
|
|
||||||
- `tests/test_disable_bots.py` - MESHCORE_DISABLE_BOTS=true feature
|
For the fuller backend inventory, see `app/AGENTS.md`. For frontend-specific suites, see `frontend/AGENTS.md`.
|
||||||
|
|
||||||
### Frontend (Vitest)
|
### Frontend (Vitest)
|
||||||
|
|
||||||
@@ -298,7 +304,7 @@ npm run test:run
|
|||||||
|
|
||||||
### Before Completing Major Changes
|
### Before Completing Major Changes
|
||||||
|
|
||||||
**Run `./scripts/all_quality.sh` before finishing major changes that have modified code or tests.** It is the standard repo gate: autofix first, then type checks, tests, and the standard frontend build. This is not necessary for docs-only changes. For minor changes (like wording, color, spacing, etc.), wait until prompted to run the quality gate.
|
**Run `./scripts/quality/all_quality.sh` before finishing major changes that have modified code or tests.** It is the standard repo gate: autofix first, then type checks, tests, and the standard frontend build. This is not necessary for docs-only changes. For minor changes (like wording, color, spacing, etc.), wait until prompted to run the quality gate.
|
||||||
|
|
||||||
## API Summary
|
## API Summary
|
||||||
|
|
||||||
@@ -313,6 +319,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
|||||||
| PUT | `/api/radio/private-key` | Import private key to radio |
|
| PUT | `/api/radio/private-key` | Import private key to radio |
|
||||||
| POST | `/api/radio/advertise` | Send advertisement (`mode`: `flood` or `zero_hop`, default `flood`) |
|
| POST | `/api/radio/advertise` | Send advertisement (`mode`: `flood` or `zero_hop`, default `flood`) |
|
||||||
| POST | `/api/radio/discover` | Run a short mesh discovery sweep for nearby repeaters/sensors |
|
| POST | `/api/radio/discover` | Run a short mesh discovery sweep for nearby repeaters/sensors |
|
||||||
|
| POST | `/api/radio/trace` | Send a multi-hop trace loop through known repeaters and back to the local radio |
|
||||||
| POST | `/api/radio/reboot` | Reboot radio or reconnect if disconnected |
|
| POST | `/api/radio/reboot` | Reboot radio or reconnect if disconnected |
|
||||||
| POST | `/api/radio/disconnect` | Disconnect from radio and pause automatic reconnect attempts |
|
| POST | `/api/radio/disconnect` | Disconnect from radio and pause automatic reconnect attempts |
|
||||||
| POST | `/api/radio/reconnect` | Manual radio reconnection |
|
| POST | `/api/radio/reconnect` | Manual radio reconnection |
|
||||||
@@ -335,6 +342,10 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
|||||||
| POST | `/api/contacts/{public_key}/repeater/radio-settings` | Fetch repeater radio config via CLI |
|
| POST | `/api/contacts/{public_key}/repeater/radio-settings` | Fetch repeater radio config via CLI |
|
||||||
| POST | `/api/contacts/{public_key}/repeater/advert-intervals` | Fetch advert intervals |
|
| POST | `/api/contacts/{public_key}/repeater/advert-intervals` | Fetch advert intervals |
|
||||||
| POST | `/api/contacts/{public_key}/repeater/owner-info` | Fetch owner info |
|
| POST | `/api/contacts/{public_key}/repeater/owner-info` | Fetch owner info |
|
||||||
|
| POST | `/api/contacts/{public_key}/room/login` | Log in to a room server |
|
||||||
|
| POST | `/api/contacts/{public_key}/room/status` | Fetch room-server status telemetry |
|
||||||
|
| POST | `/api/contacts/{public_key}/room/lpp-telemetry` | Fetch room-server CayenneLPP sensor data |
|
||||||
|
| POST | `/api/contacts/{public_key}/room/acl` | Fetch room-server ACL entries |
|
||||||
|
|
||||||
| GET | `/api/channels` | List channels |
|
| GET | `/api/channels` | List channels |
|
||||||
| GET | `/api/channels/{key}/detail` | Comprehensive channel profile (message stats, top senders) |
|
| GET | `/api/channels/{key}/detail` | Comprehensive channel profile (message stats, top senders) |
|
||||||
@@ -348,6 +359,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
|||||||
| POST | `/api/messages/channel` | Send channel message |
|
| POST | `/api/messages/channel` | Send channel message |
|
||||||
| POST | `/api/messages/channel/{message_id}/resend` | Resend channel message (default: byte-perfect within 30s; `?new_timestamp=true`: fresh timestamp, no time limit, creates new message row) |
|
| POST | `/api/messages/channel/{message_id}/resend` | Resend channel message (default: byte-perfect within 30s; `?new_timestamp=true`: fresh timestamp, no time limit, creates new message row) |
|
||||||
| GET | `/api/packets/undecrypted/count` | Count of undecrypted packets |
|
| GET | `/api/packets/undecrypted/count` | Count of undecrypted packets |
|
||||||
|
| GET | `/api/packets/{packet_id}` | Fetch one stored raw packet by row ID for on-demand inspection |
|
||||||
| POST | `/api/packets/decrypt/historical` | Decrypt stored packets |
|
| POST | `/api/packets/decrypt/historical` | Decrypt stored packets |
|
||||||
| POST | `/api/packets/maintenance` | Delete old packets and vacuum |
|
| POST | `/api/packets/maintenance` | Delete old packets and vacuum |
|
||||||
| GET | `/api/read-state/unreads` | Server-computed unread counts, mentions, last message times, and `last_read_ats` boundaries |
|
| GET | `/api/read-state/unreads` | Server-computed unread counts, mentions, last message times, and `last_read_ats` boundaries |
|
||||||
@@ -362,6 +374,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
|||||||
| POST | `/api/fanout` | Create new fanout config |
|
| POST | `/api/fanout` | Create new fanout config |
|
||||||
| PATCH | `/api/fanout/{id}` | Update fanout config (triggers module reload) |
|
| PATCH | `/api/fanout/{id}` | Update fanout config (triggers module reload) |
|
||||||
| DELETE | `/api/fanout/{id}` | Delete fanout config (stops module) |
|
| DELETE | `/api/fanout/{id}` | Delete fanout config (stops module) |
|
||||||
|
| POST | `/api/fanout/bots/disable-until-restart` | Stop bot fanout modules and keep bots disabled until the process restarts |
|
||||||
| GET | `/api/statistics` | Aggregated mesh network statistics |
|
| GET | `/api/statistics` | Aggregated mesh network statistics |
|
||||||
| WS | `/api/ws` | Real-time updates |
|
| WS | `/api/ws` | Real-time updates |
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,39 @@
|
|||||||
|
## [3.6.3] - 2026-03-30
|
||||||
|
|
||||||
|
Feature: Add multi-byte trace
|
||||||
|
Feature: Show node name on discovered node if we know it
|
||||||
|
Feature: Add docker installation script
|
||||||
|
Feature: Add historical noise floor to stats
|
||||||
|
Feature: Add trace tool
|
||||||
|
Bugfix: 100x performance on statistics endpoint with indices and better queries
|
||||||
|
Misc: Performance and correctness improvements for backend-of-the-frontend
|
||||||
|
Misc: Reorganize scripts
|
||||||
|
|
||||||
|
## [3.6.2] - 2026-03-29
|
||||||
|
|
||||||
|
Feature: Be more flexible about timing and volume of full contact offload
|
||||||
|
Feature: Improve room server and repeater ops to be much more clearer about auth status
|
||||||
|
Feature: Show last error status on integrations
|
||||||
|
Feature: Push multi-platform docker builds
|
||||||
|
Bugfix: Fix advert interval time unit display
|
||||||
|
Bugfix: Don't cast RSSI/SNR to string for community MQTT
|
||||||
|
Bugfix: Map uploader follows redirect
|
||||||
|
Misc: Thin out unnecessary cruft in unreads endpoint
|
||||||
|
Misc: Fall back gracefully if linked to an unknown contact
|
||||||
|
|
||||||
|
## [3.6.1] - 2026-03-26
|
||||||
|
|
||||||
|
Feature: MeshCore Map integration
|
||||||
|
Feature: Add warning screen about bots
|
||||||
|
Feature: Favicon reflects unread message state
|
||||||
|
Feature: Show hop map in larger modal
|
||||||
|
Feature: Add prebuilt frontend install script
|
||||||
|
Feature: Add clean service installer script
|
||||||
|
Feature: Swipe in to show menu
|
||||||
|
Bugfix: Invalid backend API path serves error, not fallback index
|
||||||
|
Bugfix: Fix some spacing/page height issues
|
||||||
|
Misc: Misc. bugfixes and performance and test improvements
|
||||||
|
|
||||||
## [3.6.0] - 2026-03-22
|
## [3.6.0] - 2026-03-22
|
||||||
|
|
||||||
Feature: Add incoming-packet analytics
|
Feature: Add incoming-packet analytics
|
||||||
|
|||||||
+2
-1
@@ -48,7 +48,7 @@ Run both the backend and `npm run dev` for hot-reloading frontend development.
|
|||||||
Run the full quality suite before proposing or handing off code changes:
|
Run the full quality suite before proposing or handing off code changes:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/all_quality.sh
|
./scripts/quality/all_quality.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
That runs linting, formatting, type checking, tests, and builds for both backend and frontend.
|
That runs linting, formatting, type checking, tests, and builds for both backend and frontend.
|
||||||
@@ -78,6 +78,7 @@ These tests are only guaranteed to run correctly in a narrow subset of environme
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd tests/e2e
|
cd tests/e2e
|
||||||
|
npm install
|
||||||
npx playwright test # headless
|
npx playwright test # headless
|
||||||
npx playwright test --headed # you can probably guess
|
npx playwright test --headed # you can probably guess
|
||||||
```
|
```
|
||||||
|
|||||||
+65
-1
@@ -1,6 +1,6 @@
|
|||||||
# Third-Party Licenses
|
# Third-Party Licenses
|
||||||
|
|
||||||
Auto-generated by `scripts/collect_licenses.sh` — do not edit by hand.
|
Auto-generated by `scripts/build/collect_licenses.sh` — do not edit by hand.
|
||||||
|
|
||||||
## Backend (Python) Dependencies
|
## Backend (Python) Dependencies
|
||||||
|
|
||||||
@@ -1592,6 +1592,70 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
### react-swipeable (7.0.2) — MIT
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Full license text</summary>
|
||||||
|
|
||||||
|
```
|
||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (C) 2014-2022 Josh Perez
|
||||||
|
Copyright (C) 2014-2022 Brian Emil Hartz
|
||||||
|
Copyright (C) 2022 Formidable Labs, Inc.
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
### recharts (3.8.1) — MIT
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Full license text</summary>
|
||||||
|
|
||||||
|
```
|
||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2015-present recharts
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
### sonner (2.0.7) — MIT
|
### sonner (2.0.7) — MIT
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
|||||||
@@ -16,12 +16,6 @@ Backend server + browser interface for MeshCore mesh radio networks. Connect you
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Disclaimer
|
|
||||||
|
|
||||||
This is developed with very heavy agentic assistance -- there is no warranty of fitness for any purpose. It's been lovingly guided by an engineer with a passion for clean code and good tests, but it's still mostly LLM output, so you may find some bugs.
|
|
||||||
|
|
||||||
If extending, have your LLM read the three `AGENTS.md` files: `./AGENTS.md`, `./frontend/AGENTS.md`, and `./app/AGENTS.md`.
|
|
||||||
|
|
||||||
## Start Here
|
## Start Here
|
||||||
|
|
||||||
Most users should choose one of these paths:
|
Most users should choose one of these paths:
|
||||||
@@ -95,65 +89,83 @@ Access the app at http://localhost:8000.
|
|||||||
|
|
||||||
Source checkouts expect a normal frontend build in `frontend/dist`.
|
Source checkouts expect a normal frontend build in `frontend/dist`.
|
||||||
|
|
||||||
On Linux, if you want this installed as a persistent `systemd` service that starts on boot and restarts automatically on failure, run `bash scripts/install_service.sh` from the repo root.
|
> [!NOTE]
|
||||||
|
> Running on lightweight hardware, or just do not want to build the frontend locally? From a cloned checkout, run `python3 scripts/setup/fetch_prebuilt_frontend.py` to fetch and unpack a prebuilt frontend into `frontend/prebuilt`, then start the app normally with `uv run uvicorn app.main:app --host 0.0.0.0 --port 8000`.
|
||||||
|
|
||||||
## Path 1.5: Use The Prebuilt Release Zip
|
> [!TIP]
|
||||||
|
> On Linux, you can also install RemoteTerm as a persistent `systemd` service that starts on boot and restarts automatically on failure:
|
||||||
Release zips can be found as an asset within the [releases listed here](https://github.com/jkingsman/Remote-Terminal-for-MeshCore/releases). This can be beneficial on resource constrained systems that cannot cope with the RAM-hungry frontend build process.
|
>
|
||||||
|
> ```bash
|
||||||
If you downloaded the release zip instead of cloning the repo, unpack it and run:
|
> bash scripts/setup/install_service.sh
|
||||||
|
> ```
|
||||||
```bash
|
>
|
||||||
cd Remote-Terminal-for-MeshCore
|
> For the full service workflow and post-install operations, see [README_ADVANCED.md](README_ADVANCED.md).
|
||||||
uv sync
|
|
||||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
|
||||||
```
|
|
||||||
|
|
||||||
The release bundle includes `frontend/prebuilt`, so it does not require a local frontend build.
|
|
||||||
|
|
||||||
Alternatively, if you have already cloned the repo, you can fetch just the prebuilt frontend into your working tree without downloading the full release zip via `python3 scripts/fetch_prebuilt_frontend.py`.
|
|
||||||
|
|
||||||
## Path 2: Docker
|
## Path 2: Docker
|
||||||
|
|
||||||
> **Warning:** Docker has had reports intermittent issues with serial event subscriptions. The native method above is more reliable.
|
> **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
|
```bash
|
||||||
docker compose up -d
|
cp docker-compose.example.yml docker-compose.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
The database is stored in `./data/` (bind-mounted), so the container shares the same database as the native app. To rebuild after pulling updates:
|
2. Or generate one interactively:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d --build
|
bash scripts/setup/install_docker.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
To use the prebuilt Docker Hub image instead of building locally, replace:
|
Your local `docker-compose.yml` is gitignored so future pulls do not overwrite your Docker settings.
|
||||||
|
|
||||||
|
The guided Docker flow can collect BLE settings, but BLE access from Docker still needs manual compose customization such as Bluetooth passthrough and possibly privileged mode or host networking. If you want the simpler path for BLE, use the regular Python launch flow instead.
|
||||||
|
|
||||||
|
Then customize the local compose file for your transport and launch:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo docker compose up # add -d for background once you validate it's working
|
||||||
|
```
|
||||||
|
|
||||||
|
The database is stored in `./data/` (bind-mounted), so the container shares the same database as the native app.
|
||||||
|
|
||||||
|
To rebuild after pulling updates:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo docker compose pull
|
||||||
|
sudo docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
The example file and setup script default to the published Docker Hub image. To build locally from your checkout instead, replace:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
build: .
|
image: docker.io/jkingsman/remoteterm-meshcore:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
with:
|
with:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
image: jkingsman/remoteterm-meshcore:latest
|
build: .
|
||||||
```
|
```
|
||||||
|
|
||||||
Then run:
|
Then run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose pull
|
sudo docker compose up -d --build
|
||||||
docker compose up -d
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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:
|
To stop:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose down
|
sudo docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
## Standard Environment Variables
|
## 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)
|
- Advanced setup, troubleshooting, HTTPS, systemd, remediation variables, and debug logging: [README_ADVANCED.md](README_ADVANCED.md)
|
||||||
- Contributing, tests, linting, E2E notes, and important AGENTS files: [CONTRIBUTING.md](CONTRIBUTING.md)
|
- Contributing, tests, linting, E2E notes, and important AGENTS files: [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||||
- Live API docs after the backend is running: http://localhost:8000/docs
|
- Live API docs after the backend is running: http://localhost:8000/docs
|
||||||
|
|
||||||
|
## Disclaimer
|
||||||
|
|
||||||
|
This is developed with very heavy agentic assistance -- there is no warranty of fitness for any purpose. It's been lovingly guided by an engineer with a passion for clean code and good tests, but it's still mostly LLM output, so you may find some bugs.
|
||||||
|
|
||||||
|
If extending, have your LLM read the three `AGENTS.md` files: `./AGENTS.md`, `./frontend/AGENTS.md`, and `./app/AGENTS.md`.
|
||||||
|
|||||||
+1
-26
@@ -46,39 +46,14 @@ Accept the browser warning, or use [mkcert](https://github.com/FiloSottile/mkcer
|
|||||||
|
|
||||||
## Systemd Service
|
## Systemd Service
|
||||||
|
|
||||||
Two paths are available depending on your comfort level with Linux system administration.
|
|
||||||
|
|
||||||
### Simple install (recommended for most users)
|
|
||||||
|
|
||||||
On Linux systems, this is the recommended installation method if you want RemoteTerm set up as a persistent systemd service that starts automatically on boot and restarts automatically if it crashes. Run the installer script from the repo root. It runs as your current user, installs from wherever you cloned the repo, and prints a quick-reference cheatsheet when done — no separate service account or path juggling required.
|
On Linux systems, this is the recommended installation method if you want RemoteTerm set up as a persistent systemd service that starts automatically on boot and restarts automatically if it crashes. Run the installer script from the repo root. It runs as your current user, installs from wherever you cloned the repo, and prints a quick-reference cheatsheet when done — no separate service account or path juggling required.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash scripts/install_service.sh
|
bash scripts/setup/install_service.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The script interactively asks which transport to use (serial auto-detect, serial with explicit port, TCP, or BLE), whether to build the frontend locally or download a prebuilt copy, whether to enable the bot system, and whether to set up HTTP Basic Auth. It handles dependency installation (`uv sync`), validates `node`/`npm` for local builds, adds your user to the `dialout` group if needed, writes the systemd unit file, and enables the service. After installation, normal operations work without any `sudo -u` gymnastics:
|
|
||||||
|
|
||||||
You can also rerun the script later to change transport, bot, or auth settings. If the service is already running, the installer stops it, rewrites the unit file, reloads systemd, and starts it again with the new configuration.
|
You can also rerun the script later to change transport, bot, or auth settings. If the service is already running, the installer stops it, rewrites the unit file, reloads systemd, and starts it again with the new configuration.
|
||||||
|
|
||||||
```bash
|
|
||||||
# Update to latest and restart
|
|
||||||
cd /path/to/repo
|
|
||||||
git pull
|
|
||||||
uv sync
|
|
||||||
cd frontend && npm install && npm run build && cd ..
|
|
||||||
sudo systemctl restart remoteterm
|
|
||||||
|
|
||||||
# Refresh prebuilt frontend only (skips local build)
|
|
||||||
python3 scripts/fetch_prebuilt_frontend.py
|
|
||||||
sudo systemctl restart remoteterm
|
|
||||||
|
|
||||||
# View live logs
|
|
||||||
sudo journalctl -u remoteterm -f
|
|
||||||
|
|
||||||
# Service control
|
|
||||||
sudo systemctl start|stop|restart|status remoteterm
|
|
||||||
```
|
|
||||||
|
|
||||||
## Debug Logging And Bug Reports
|
## Debug Logging And Bug Reports
|
||||||
|
|
||||||
If you're experiencing issues or opening a bug report, please start the backend with debug logging enabled. Debug mode provides a much more detailed breakdown of radio communication, packet processing, and other internal operations, which makes it significantly easier to diagnose problems.
|
If you're experiencing issues or opening a bug report, please start the backend with debug logging enabled. Debug mode provides a much more detailed breakdown of radio communication, packet processing, and other internal operations, which makes it significantly easier to diagnose problems.
|
||||||
|
|||||||
+26
-10
@@ -25,18 +25,22 @@ Keep it aligned with `app/` source files and router behavior.
|
|||||||
app/
|
app/
|
||||||
├── main.py # App startup/lifespan, router registration, static frontend mounting
|
├── main.py # App startup/lifespan, router registration, static frontend mounting
|
||||||
├── config.py # Env-driven runtime settings
|
├── config.py # Env-driven runtime settings
|
||||||
|
├── channel_constants.py # Public/default channel constants shared across sync/send logic
|
||||||
├── database.py # SQLite connection + base schema + migration runner
|
├── database.py # SQLite connection + base schema + migration runner
|
||||||
├── migrations.py # Schema migrations (SQLite user_version)
|
├── migrations.py # Schema migrations (SQLite user_version)
|
||||||
├── models.py # Pydantic request/response models and typed write contracts (for example ContactUpsert)
|
├── models.py # Pydantic request/response models and typed write contracts (for example ContactUpsert)
|
||||||
|
├── version_info.py # Unified version/build metadata resolution for debug + startup surfaces
|
||||||
├── repository/ # Data access layer (contacts, channels, messages, raw_packets, settings, fanout)
|
├── repository/ # Data access layer (contacts, channels, messages, raw_packets, settings, fanout)
|
||||||
├── services/ # Shared orchestration/domain services
|
├── services/ # Shared orchestration/domain services
|
||||||
│ ├── messages.py # Shared message creation, dedup, ACK application
|
│ ├── messages.py # Shared message creation, dedup, ACK application
|
||||||
│ ├── message_send.py # Direct send, channel send, resend workflows
|
│ ├── message_send.py # Direct send, channel send, resend workflows
|
||||||
│ ├── dm_ingest.py # Shared direct-message ingest / dedup seam for packet + fallback paths
|
│ ├── dm_ingest.py # Shared direct-message ingest / dedup seam for packet + fallback paths
|
||||||
|
│ ├── dm_ack_apply.py # Shared DM ACK application over pending/buffered ACK state
|
||||||
│ ├── dm_ack_tracker.py # Pending DM ACK state
|
│ ├── dm_ack_tracker.py # Pending DM ACK state
|
||||||
│ ├── contact_reconciliation.py # Prefix-claim, sender-key backfill, name-history wiring
|
│ ├── contact_reconciliation.py # Prefix-claim, sender-key backfill, name-history wiring
|
||||||
│ ├── radio_lifecycle.py # Post-connect setup and reconnect/setup helpers
|
│ ├── radio_lifecycle.py # Post-connect setup and reconnect/setup helpers
|
||||||
│ ├── radio_commands.py # Radio config/private-key command workflows
|
│ ├── radio_commands.py # Radio config/private-key command workflows
|
||||||
|
│ ├── radio_noise_floor.py # In-memory local radio noise-floor sampling/history
|
||||||
│ └── radio_runtime.py # Router/dependency seam over the global RadioManager
|
│ └── radio_runtime.py # Router/dependency seam over the global RadioManager
|
||||||
├── radio.py # RadioManager transport/session state + lock management
|
├── radio.py # RadioManager transport/session state + lock management
|
||||||
├── radio_sync.py # Polling, sync, periodic advertisement loop
|
├── radio_sync.py # Polling, sync, periodic advertisement loop
|
||||||
@@ -61,6 +65,8 @@ app/
|
|||||||
├── messages.py
|
├── messages.py
|
||||||
├── packets.py
|
├── packets.py
|
||||||
├── read_state.py
|
├── read_state.py
|
||||||
|
├── rooms.py
|
||||||
|
├── server_control.py
|
||||||
├── settings.py
|
├── settings.py
|
||||||
├── fanout.py
|
├── fanout.py
|
||||||
├── repeaters.py
|
├── repeaters.py
|
||||||
@@ -174,6 +180,7 @@ app/
|
|||||||
- `PUT /radio/private-key`
|
- `PUT /radio/private-key`
|
||||||
- `POST /radio/advertise` — manual advert send; request body may set `mode` to `flood` or `zero_hop` (defaults to `flood`)
|
- `POST /radio/advertise` — manual advert send; request body may set `mode` to `flood` or `zero_hop` (defaults to `flood`)
|
||||||
- `POST /radio/discover` — short mesh discovery sweep for nearby repeaters/sensors
|
- `POST /radio/discover` — short mesh discovery sweep for nearby repeaters/sensors
|
||||||
|
- `POST /radio/trace` — send a multi-hop trace loop through known repeaters and back to the local radio
|
||||||
- `POST /radio/disconnect`
|
- `POST /radio/disconnect`
|
||||||
- `POST /radio/reboot`
|
- `POST /radio/reboot`
|
||||||
- `POST /radio/reconnect`
|
- `POST /radio/reconnect`
|
||||||
@@ -198,6 +205,10 @@ app/
|
|||||||
- `POST /contacts/{public_key}/repeater/radio-settings`
|
- `POST /contacts/{public_key}/repeater/radio-settings`
|
||||||
- `POST /contacts/{public_key}/repeater/advert-intervals`
|
- `POST /contacts/{public_key}/repeater/advert-intervals`
|
||||||
- `POST /contacts/{public_key}/repeater/owner-info`
|
- `POST /contacts/{public_key}/repeater/owner-info`
|
||||||
|
- `POST /contacts/{public_key}/room/login`
|
||||||
|
- `POST /contacts/{public_key}/room/status`
|
||||||
|
- `POST /contacts/{public_key}/room/lpp-telemetry`
|
||||||
|
- `POST /contacts/{public_key}/room/acl`
|
||||||
|
|
||||||
### Channels
|
### Channels
|
||||||
- `GET /channels`
|
- `GET /channels`
|
||||||
@@ -216,6 +227,7 @@ app/
|
|||||||
|
|
||||||
### Packets
|
### Packets
|
||||||
- `GET /packets/undecrypted/count`
|
- `GET /packets/undecrypted/count`
|
||||||
|
- `GET /packets/{packet_id}` — fetch one stored raw packet by row ID for on-demand inspection
|
||||||
- `POST /packets/decrypt/historical`
|
- `POST /packets/decrypt/historical`
|
||||||
- `POST /packets/maintenance`
|
- `POST /packets/maintenance`
|
||||||
|
|
||||||
@@ -236,6 +248,7 @@ app/
|
|||||||
- `POST /fanout` — create new fanout config
|
- `POST /fanout` — create new fanout config
|
||||||
- `PATCH /fanout/{id}` — update fanout config (triggers module reload)
|
- `PATCH /fanout/{id}` — update fanout config (triggers module reload)
|
||||||
- `DELETE /fanout/{id}` — delete fanout config (stops module)
|
- `DELETE /fanout/{id}` — delete fanout config (stops module)
|
||||||
|
- `POST /fanout/bots/disable-until-restart` — stop bot modules and keep bots disabled until restart
|
||||||
|
|
||||||
### Statistics
|
### Statistics
|
||||||
- `GET /statistics` — aggregated mesh network stats (entity counts, message/packet splits, activity windows, busiest channels)
|
- `GET /statistics` — aggregated mesh network stats (entity counts, message/packet splits, activity windows, busiest channels)
|
||||||
@@ -322,9 +335,11 @@ tests/
|
|||||||
├── conftest.py # Shared fixtures
|
├── conftest.py # Shared fixtures
|
||||||
├── test_ack_tracking_wiring.py # DM ACK tracking extraction and wiring
|
├── test_ack_tracking_wiring.py # DM ACK tracking extraction and wiring
|
||||||
├── test_api.py # REST endpoint integration tests
|
├── test_api.py # REST endpoint integration tests
|
||||||
|
├── test_block_lists.py # Blocked keys/names filtering across list/search surfaces
|
||||||
├── test_bot.py # Bot execution and sandboxing
|
├── test_bot.py # Bot execution and sandboxing
|
||||||
├── test_channels_router.py # Channels router endpoints
|
|
||||||
├── test_channel_sender_backfill.py # Sender-key backfill uniqueness rules for channel messages
|
├── test_channel_sender_backfill.py # Sender-key backfill uniqueness rules for channel messages
|
||||||
|
├── test_channels_router.py # Channels router endpoints
|
||||||
|
├── test_community_mqtt.py # Community MQTT publisher (JWT, packet format, hash, broadcast)
|
||||||
├── test_config.py # Configuration validation
|
├── test_config.py # Configuration validation
|
||||||
├── test_contact_reconciliation_service.py # Prefix/contact reconciliation service helpers
|
├── test_contact_reconciliation_service.py # Prefix/contact reconciliation service helpers
|
||||||
├── test_contacts_router.py # Contacts router endpoints
|
├── test_contacts_router.py # Contacts router endpoints
|
||||||
@@ -332,40 +347,41 @@ tests/
|
|||||||
├── test_disable_bots.py # MESHCORE_DISABLE_BOTS=true feature
|
├── test_disable_bots.py # MESHCORE_DISABLE_BOTS=true feature
|
||||||
├── test_echo_dedup.py # Echo/repeat deduplication (incl. concurrent)
|
├── test_echo_dedup.py # Echo/repeat deduplication (incl. concurrent)
|
||||||
├── test_fanout.py # Fanout bus CRUD, scope matching, manager dispatch
|
├── test_fanout.py # Fanout bus CRUD, scope matching, manager dispatch
|
||||||
├── test_fanout_integration.py # Fanout integration tests
|
|
||||||
├── test_fanout_hitlist.py # Fanout-related hitlist regression tests
|
├── test_fanout_hitlist.py # Fanout-related hitlist regression tests
|
||||||
|
├── test_fanout_integration.py # Fanout integration tests
|
||||||
├── test_event_handlers.py # ACK tracking, event registration, cleanup
|
├── test_event_handlers.py # ACK tracking, event registration, cleanup
|
||||||
├── test_frontend_static.py # Frontend static file serving
|
├── test_frontend_static.py # Frontend static file serving
|
||||||
├── test_health_mqtt_status.py # Health endpoint MQTT status field
|
├── test_health_mqtt_status.py # Health endpoint MQTT status field
|
||||||
├── test_http_quality.py # Cache-control / gzip / basic-auth HTTP quality checks
|
├── test_http_quality.py # Cache-control / gzip / basic-auth HTTP quality checks
|
||||||
├── test_key_normalization.py # Public key normalization
|
├── test_key_normalization.py # Public key normalization
|
||||||
├── test_keystore.py # Ephemeral keystore
|
├── test_keystore.py # Ephemeral keystore
|
||||||
|
├── test_main_startup.py # App startup and lifespan
|
||||||
|
├── test_map_upload.py # Map upload fanout module
|
||||||
├── test_message_pagination.py # Cursor-based message pagination
|
├── test_message_pagination.py # Cursor-based message pagination
|
||||||
├── test_message_prefix_claim.py # Message prefix claim logic
|
├── test_message_prefix_claim.py # Message prefix claim logic
|
||||||
├── test_migrations.py # Schema migration system
|
|
||||||
├── test_community_mqtt.py # Community MQTT publisher (JWT, packet format, hash, broadcast)
|
|
||||||
├── test_mqtt.py # MQTT publisher topic routing and lifecycle
|
├── test_mqtt.py # MQTT publisher topic routing and lifecycle
|
||||||
|
├── test_messages_search.py # Message search, around, forward pagination
|
||||||
|
├── test_migrations.py # Schema migration system
|
||||||
├── test_packet_pipeline.py # End-to-end packet processing
|
├── test_packet_pipeline.py # End-to-end packet processing
|
||||||
├── test_packets_router.py # Packets router endpoints (decrypt, maintenance)
|
├── test_packets_router.py # Packets router endpoints (decrypt, maintenance)
|
||||||
|
├── test_path_utils.py # Path hex rendering helpers
|
||||||
├── test_radio.py # RadioManager, serial detection
|
├── test_radio.py # RadioManager, serial detection
|
||||||
├── test_radio_commands_service.py # Radio config/private-key service workflows
|
├── test_radio_commands_service.py # Radio config/private-key service workflows
|
||||||
├── test_radio_lifecycle_service.py # Reconnect/setup orchestration helpers
|
├── test_radio_lifecycle_service.py # Reconnect/setup orchestration helpers
|
||||||
├── test_radio_runtime_service.py # radio_runtime seam behavior and helpers
|
|
||||||
├── test_real_crypto.py # Real cryptographic operations
|
|
||||||
├── test_radio_operation.py # radio_operation() context manager
|
├── test_radio_operation.py # radio_operation() context manager
|
||||||
├── test_radio_router.py # Radio router endpoints
|
├── test_radio_router.py # Radio router endpoints
|
||||||
|
├── test_radio_runtime_service.py # radio_runtime seam behavior and helpers
|
||||||
├── test_radio_sync.py # Polling, sync, advertisement
|
├── test_radio_sync.py # Polling, sync, advertisement
|
||||||
|
├── test_real_crypto.py # Real cryptographic operations
|
||||||
├── test_repeater_routes.py # Repeater command/telemetry/trace + granular pane endpoints
|
├── test_repeater_routes.py # Repeater command/telemetry/trace + granular pane endpoints
|
||||||
├── test_repository.py # Data access layer
|
├── test_repository.py # Data access layer
|
||||||
|
├── test_room_routes.py # Room-server login/status/telemetry/ACL endpoints
|
||||||
├── test_rx_log_data.py # on_rx_log_data event handler integration
|
├── test_rx_log_data.py # on_rx_log_data event handler integration
|
||||||
├── test_messages_search.py # Message search, around, forward pagination
|
|
||||||
├── test_block_lists.py # Blocked keys/names filtering
|
|
||||||
├── test_security.py # Optional Basic Auth middleware / config behavior
|
├── test_security.py # Optional Basic Auth middleware / config behavior
|
||||||
├── test_send_messages.py # Outgoing messages, bot triggers, concurrent sends
|
├── test_send_messages.py # Outgoing messages, bot triggers, concurrent sends
|
||||||
├── test_settings_router.py # Settings endpoints, advert validation
|
├── test_settings_router.py # Settings endpoints, advert validation
|
||||||
├── test_statistics.py # Statistics aggregation
|
├── test_statistics.py # Statistics aggregation
|
||||||
├── test_main_startup.py # App startup and lifespan
|
├── test_version_info.py # Version/build metadata resolution
|
||||||
├── test_path_utils.py # Path hex rendering helpers
|
|
||||||
├── test_websocket.py # WS manager broadcast/cleanup
|
├── test_websocket.py # WS manager broadcast/cleanup
|
||||||
└── test_websocket_route.py # WS endpoint lifecycle
|
└── test_websocket_route.py # WS endpoint lifecycle
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -96,8 +96,12 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_dedup_null_safe
|
|||||||
ON messages(type, conversation_key, text, COALESCE(sender_timestamp, 0))
|
ON messages(type, conversation_key, text, COALESCE(sender_timestamp, 0))
|
||||||
WHERE type = 'CHAN';
|
WHERE type = 'CHAN';
|
||||||
CREATE INDEX IF NOT EXISTS idx_raw_packets_message_id ON raw_packets(message_id);
|
CREATE INDEX IF NOT EXISTS idx_raw_packets_message_id ON raw_packets(message_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_raw_packets_timestamp ON raw_packets(timestamp);
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_packets_payload_hash ON raw_packets(payload_hash);
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_packets_payload_hash ON raw_packets(payload_hash);
|
||||||
CREATE INDEX IF NOT EXISTS idx_contacts_on_radio ON contacts(on_radio);
|
CREATE INDEX IF NOT EXISTS idx_contacts_on_radio ON contacts(on_radio);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_contacts_type_last_seen ON contacts(type, last_seen);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_type_received_conversation
|
||||||
|
ON messages(type, received_at, conversation_key);
|
||||||
-- idx_messages_sender_key is created by migration 25 (after adding the sender_key column)
|
-- idx_messages_sender_key is created by migration 25 (after adding the sender_key column)
|
||||||
-- idx_messages_incoming_priv_dedup is created by migration 44 after legacy rows are reconciled
|
-- idx_messages_incoming_priv_dedup is created by migration 44 after legacy rows are reconciled
|
||||||
CREATE INDEX IF NOT EXISTS idx_contact_advert_paths_recent
|
CREATE INDEX IF NOT EXISTS idx_contact_advert_paths_recent
|
||||||
|
|||||||
@@ -30,8 +30,6 @@ logger = logging.getLogger(__name__)
|
|||||||
# Track active subscriptions so we can unsubscribe before re-registering
|
# Track active subscriptions so we can unsubscribe before re-registering
|
||||||
# This prevents handler duplication after reconnects
|
# This prevents handler duplication after reconnects
|
||||||
_active_subscriptions: list["Subscription"] = []
|
_active_subscriptions: list["Subscription"] = []
|
||||||
_pending_acks = dm_ack_tracker._pending_acks
|
|
||||||
_buffered_acks = dm_ack_tracker._buffered_acks
|
|
||||||
|
|
||||||
|
|
||||||
def track_pending_ack(expected_ack: str, message_id: int, timeout_ms: int) -> bool:
|
def track_pending_ack(expected_ack: str, message_id: int, timeout_ms: int) -> bool:
|
||||||
|
|||||||
@@ -89,6 +89,19 @@ Amazon SQS delivery. Config blob:
|
|||||||
- Publishes a JSON envelope of the form `{"event_type":"message"|"raw_packet","data":...}`
|
- Publishes a JSON envelope of the form `{"event_type":"message"|"raw_packet","data":...}`
|
||||||
- Supports both decoded messages and raw packets via normal scope selection
|
- Supports both decoded messages and raw packets via normal scope selection
|
||||||
|
|
||||||
|
### map_upload (map_upload.py)
|
||||||
|
Uploads heard repeater and room-server advertisements to map.meshcore.dev. Config blob:
|
||||||
|
- `api_url` (optional, default `""`) — upload endpoint; empty falls back to the public map.meshcore.dev API
|
||||||
|
- `dry_run` (bool, default `true`) — when true, logs the payload at INFO level without sending
|
||||||
|
- `geofence_enabled` (bool, default `false`) — when true, only uploads nodes within `geofence_radius_km` of the radio's own configured lat/lon
|
||||||
|
- `geofence_radius_km` (float, default `0`) — filter radius in kilometres
|
||||||
|
|
||||||
|
Geofence notes:
|
||||||
|
- The reference center is always the radio's own `adv_lat`/`adv_lon` from `radio_runtime.meshcore.self_info`, read **live at upload time** — no lat/lon is stored in the fanout config itself.
|
||||||
|
- If the radio's lat/lon is `(0, 0)` or the radio is not connected, the geofence check is silently skipped so uploads continue normally until coordinates are configured.
|
||||||
|
- Requires the radio to have `ENABLE_PRIVATE_KEY_EXPORT=1` firmware to sign uploads.
|
||||||
|
- Scope is always `{"messages": "none", "raw_packets": "all"}` — only raw RF packets are processed.
|
||||||
|
|
||||||
## Adding a New Integration Type
|
## Adding a New Integration Type
|
||||||
|
|
||||||
### Step-by-step checklist
|
### Step-by-step checklist
|
||||||
@@ -291,6 +304,7 @@ Migrations:
|
|||||||
- `app/fanout/webhook.py` — Webhook fanout module
|
- `app/fanout/webhook.py` — Webhook fanout module
|
||||||
- `app/fanout/apprise_mod.py` — Apprise fanout module
|
- `app/fanout/apprise_mod.py` — Apprise fanout module
|
||||||
- `app/fanout/sqs.py` — Amazon SQS fanout module
|
- `app/fanout/sqs.py` — Amazon SQS fanout module
|
||||||
|
- `app/fanout/map_upload.py` — Map Upload fanout module
|
||||||
- `app/repository/fanout.py` — Database CRUD
|
- `app/repository/fanout.py` — Database CRUD
|
||||||
- `app/routers/fanout.py` — REST API
|
- `app/routers/fanout.py` — REST API
|
||||||
- `app/websocket.py` — `broadcast_event()` dispatches to fanout
|
- `app/websocket.py` — `broadcast_event()` dispatches to fanout
|
||||||
|
|||||||
@@ -95,7 +95,6 @@ class AppriseModule(FanoutModule):
|
|||||||
|
|
||||||
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
|
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
|
||||||
super().__init__(config_id, config, name=name)
|
super().__init__(config_id, config, name=name)
|
||||||
self._last_error: str | None = None
|
|
||||||
|
|
||||||
async def on_message(self, data: dict) -> None:
|
async def on_message(self, data: dict) -> None:
|
||||||
# Skip outgoing messages — only notify on incoming
|
# Skip outgoing messages — only notify on incoming
|
||||||
@@ -114,17 +113,17 @@ class AppriseModule(FanoutModule):
|
|||||||
success = await asyncio.to_thread(
|
success = await asyncio.to_thread(
|
||||||
_send_sync, urls, body, preserve_identity=preserve_identity
|
_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:
|
if not success:
|
||||||
logger.warning("Apprise notification failed for module %s", self.config_id)
|
logger.warning("Apprise notification failed for module %s", self.config_id)
|
||||||
except Exception as exc:
|
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)
|
logger.exception("Apprise send error for module %s", self.config_id)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def status(self) -> str:
|
def status(self) -> str:
|
||||||
if not self.config.get("urls", "").strip():
|
if not self.config.get("urls", "").strip():
|
||||||
return "disconnected"
|
return "disconnected"
|
||||||
if self._last_error:
|
if self.last_error:
|
||||||
return "error"
|
return "error"
|
||||||
return "connected"
|
return "connected"
|
||||||
|
|||||||
@@ -3,6 +3,14 @@
|
|||||||
from __future__ import annotations
|
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:
|
class FanoutModule:
|
||||||
"""Base class for all fanout integrations.
|
"""Base class for all fanout integrations.
|
||||||
|
|
||||||
@@ -16,6 +24,7 @@ class FanoutModule:
|
|||||||
self.config_id = config_id
|
self.config_id = config_id
|
||||||
self.config = config
|
self.config = config
|
||||||
self.name = name
|
self.name = name
|
||||||
|
self._last_error: str | None = None
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Start the module (e.g. connect to broker). Override for persistent connections."""
|
"""Start the module (e.g. connect to broker). Override for persistent connections."""
|
||||||
@@ -34,6 +43,18 @@ class FanoutModule:
|
|||||||
"""Return 'connected', 'disconnected', or 'error'."""
|
"""Return 'connected', 'disconnected', or 'error'."""
|
||||||
raise NotImplementedError
|
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:
|
def get_fanout_message_text(data: dict) -> str:
|
||||||
"""Return the best human-readable message body for fanout consumers.
|
"""Return the best human-readable message body for fanout consumers.
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ from datetime import datetime
|
|||||||
from typing import Any, Protocol
|
from typing import Any, Protocol
|
||||||
|
|
||||||
import aiomqtt
|
import aiomqtt
|
||||||
import nacl.bindings
|
|
||||||
|
|
||||||
from app.fanout.mqtt_base import BaseMqttPublisher
|
from app.fanout.mqtt_base import BaseMqttPublisher
|
||||||
|
from app.keystore import ed25519_sign_expanded
|
||||||
from app.path_utils import parse_packet_envelope, split_path_hex
|
from app.path_utils import parse_packet_envelope, split_path_hex
|
||||||
from app.version_info import get_app_build_info
|
from app.version_info import get_app_build_info
|
||||||
|
|
||||||
@@ -40,9 +40,6 @@ _TOKEN_RENEWAL_THRESHOLD = _TOKEN_LIFETIME - 3600 # 23 hours
|
|||||||
_STATS_REFRESH_INTERVAL = 300 # 5 minutes
|
_STATS_REFRESH_INTERVAL = 300 # 5 minutes
|
||||||
_STATS_MIN_CACHE_SECS = 60 # Don't re-fetch stats within 60s
|
_STATS_MIN_CACHE_SECS = 60 # Don't re-fetch stats within 60s
|
||||||
|
|
||||||
# Ed25519 group order
|
|
||||||
_L = 2**252 + 27742317777372353535851937790883648493
|
|
||||||
|
|
||||||
# Route type mapping: bottom 2 bits of first byte
|
# Route type mapping: bottom 2 bits of first byte
|
||||||
_ROUTE_MAP = {0: "F", 1: "F", 2: "D", 3: "T"}
|
_ROUTE_MAP = {0: "F", 1: "F", 2: "D", 3: "T"}
|
||||||
|
|
||||||
@@ -69,28 +66,6 @@ def _base64url_encode(data: bytes) -> str:
|
|||||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
def _ed25519_sign_expanded(
|
|
||||||
message: bytes, scalar: bytes, prefix: bytes, public_key: bytes
|
|
||||||
) -> bytes:
|
|
||||||
"""Sign a message using MeshCore's expanded Ed25519 key format.
|
|
||||||
|
|
||||||
MeshCore stores 64-byte "orlp" format keys: scalar(32) || prefix(32).
|
|
||||||
Standard Ed25519 libraries expect seed format and would re-SHA-512 the key.
|
|
||||||
This performs the signing manually using the already-expanded key material.
|
|
||||||
|
|
||||||
Port of meshcore-packet-capture's ed25519_sign_with_expanded_key().
|
|
||||||
"""
|
|
||||||
# r = SHA-512(prefix || message) mod L
|
|
||||||
r = int.from_bytes(hashlib.sha512(prefix + message).digest(), "little") % _L
|
|
||||||
# R = r * B (base point multiplication)
|
|
||||||
R = nacl.bindings.crypto_scalarmult_ed25519_base_noclamp(r.to_bytes(32, "little"))
|
|
||||||
# k = SHA-512(R || public_key || message) mod L
|
|
||||||
k = int.from_bytes(hashlib.sha512(R + public_key + message).digest(), "little") % _L
|
|
||||||
# s = (r + k * scalar) mod L
|
|
||||||
s = (r + k * int.from_bytes(scalar, "little")) % _L
|
|
||||||
return R + s.to_bytes(32, "little")
|
|
||||||
|
|
||||||
|
|
||||||
def _generate_jwt_token(
|
def _generate_jwt_token(
|
||||||
private_key: bytes,
|
private_key: bytes,
|
||||||
public_key: bytes,
|
public_key: bytes,
|
||||||
@@ -127,7 +102,7 @@ def _generate_jwt_token(
|
|||||||
|
|
||||||
scalar = private_key[:32]
|
scalar = private_key[:32]
|
||||||
prefix = private_key[32:]
|
prefix = private_key[32:]
|
||||||
signature = _ed25519_sign_expanded(signing_input, scalar, prefix, public_key)
|
signature = ed25519_sign_expanded(signing_input, scalar, prefix, public_key)
|
||||||
|
|
||||||
return f"{header_b64}.{payload_b64}.{signature.hex()}"
|
return f"{header_b64}.{payload_b64}.{signature.hex()}"
|
||||||
|
|
||||||
@@ -200,11 +175,12 @@ def _format_raw_packet(data: dict[str, Any], device_name: str, public_key_hex: s
|
|||||||
current_time = datetime.now()
|
current_time = datetime.now()
|
||||||
ts_str = current_time.isoformat()
|
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")
|
snr_val = data.get("snr")
|
||||||
rssi_val = data.get("rssi")
|
rssi_val = data.get("rssi")
|
||||||
snr = str(snr_val) if snr_val is not None else "Unknown"
|
snr: float | str = float(snr_val) if snr_val is not None else "Unknown"
|
||||||
rssi = str(rssi_val) if rssi_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)
|
packet_hash = _calculate_packet_hash(raw_bytes)
|
||||||
|
|
||||||
|
|||||||
+65
-6
@@ -15,12 +15,21 @@ _DISPATCH_TIMEOUT_SECONDS = 30.0
|
|||||||
_MODULE_TYPES: dict[str, type] = {}
|
_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:
|
def _register_module_types() -> None:
|
||||||
"""Lazily populate the type registry to avoid circular imports."""
|
"""Lazily populate the type registry to avoid circular imports."""
|
||||||
if _MODULE_TYPES:
|
if _MODULE_TYPES:
|
||||||
return
|
return
|
||||||
from app.fanout.apprise_mod import AppriseModule
|
from app.fanout.apprise_mod import AppriseModule
|
||||||
from app.fanout.bot import BotModule
|
from app.fanout.bot import BotModule
|
||||||
|
from app.fanout.map_upload import MapUploadModule
|
||||||
from app.fanout.mqtt_community import MqttCommunityModule
|
from app.fanout.mqtt_community import MqttCommunityModule
|
||||||
from app.fanout.mqtt_private import MqttPrivateModule
|
from app.fanout.mqtt_private import MqttPrivateModule
|
||||||
from app.fanout.sqs import SqsModule
|
from app.fanout.sqs import SqsModule
|
||||||
@@ -32,6 +41,7 @@ def _register_module_types() -> None:
|
|||||||
_MODULE_TYPES["webhook"] = WebhookModule
|
_MODULE_TYPES["webhook"] = WebhookModule
|
||||||
_MODULE_TYPES["apprise"] = AppriseModule
|
_MODULE_TYPES["apprise"] = AppriseModule
|
||||||
_MODULE_TYPES["sqs"] = SqsModule
|
_MODULE_TYPES["sqs"] = SqsModule
|
||||||
|
_MODULE_TYPES["map_upload"] = MapUploadModule
|
||||||
|
|
||||||
|
|
||||||
def _matches_filter(filter_value: Any, key: str) -> bool:
|
def _matches_filter(filter_value: Any, key: str) -> bool:
|
||||||
@@ -83,6 +93,23 @@ class FanoutManager:
|
|||||||
self._modules: dict[str, tuple[FanoutModule, dict]] = {} # id -> (module, scope)
|
self._modules: dict[str, tuple[FanoutModule, dict]] = {} # id -> (module, scope)
|
||||||
self._restart_locks: dict[str, asyncio.Lock] = {}
|
self._restart_locks: dict[str, asyncio.Lock] = {}
|
||||||
self._bots_disabled_until_restart = False
|
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:
|
def get_bots_disabled_source(self) -> str | None:
|
||||||
"""Return why bot modules are unavailable, if at all."""
|
"""Return why bot modules are unavailable, if at all."""
|
||||||
@@ -132,11 +159,13 @@ class FanoutManager:
|
|||||||
module = cls(config_id, config_blob, name=cfg.get("name", ""))
|
module = cls(config_id, config_blob, name=cfg.get("name", ""))
|
||||||
await module.start()
|
await module.start()
|
||||||
self._modules[config_id] = (module, scope)
|
self._modules[config_id] = (module, scope)
|
||||||
|
self._clear_module_error(config_id)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Started fanout module %s (type=%s)", cfg.get("name", config_id), config_type
|
"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)
|
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:
|
async def reload_config(self, config_id: str) -> None:
|
||||||
"""Stop old module (if any) and start updated config."""
|
"""Stop old module (if any) and start updated config."""
|
||||||
@@ -160,6 +189,7 @@ class FanoutManager:
|
|||||||
await module.stop()
|
await module.stop()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Error stopping fanout module %s", config_id)
|
logger.exception("Error stopping fanout module %s", config_id)
|
||||||
|
self._clear_module_error(config_id)
|
||||||
|
|
||||||
async def _dispatch_matching(
|
async def _dispatch_matching(
|
||||||
self,
|
self,
|
||||||
@@ -189,7 +219,10 @@ class FanoutManager:
|
|||||||
try:
|
try:
|
||||||
handler = getattr(module, handler_name)
|
handler = getattr(module, handler_name)
|
||||||
await asyncio.wait_for(handler(data), timeout=_DISPATCH_TIMEOUT_SECONDS)
|
await asyncio.wait_for(handler(data), timeout=_DISPATCH_TIMEOUT_SECONDS)
|
||||||
|
self._clear_module_error(config_id)
|
||||||
except asyncio.TimeoutError:
|
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(
|
logger.error(
|
||||||
"Fanout %s %s timed out after %.1fs; restarting module",
|
"Fanout %s %s timed out after %.1fs; restarting module",
|
||||||
config_id,
|
config_id,
|
||||||
@@ -197,7 +230,8 @@ class FanoutManager:
|
|||||||
_DISPATCH_TIMEOUT_SECONDS,
|
_DISPATCH_TIMEOUT_SECONDS,
|
||||||
)
|
)
|
||||||
await self._restart_module(config_id, module)
|
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)
|
logger.exception("Fanout %s %s error", config_id, log_label)
|
||||||
|
|
||||||
async def _restart_module(self, config_id: str, module: FanoutModule) -> None:
|
async def _restart_module(self, config_id: str, module: FanoutModule) -> None:
|
||||||
@@ -213,6 +247,10 @@ class FanoutManager:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to restart timed-out fanout module %s", config_id)
|
logger.exception("Failed to restart timed-out fanout module %s", config_id)
|
||||||
self._modules.pop(config_id, None)
|
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:
|
async def broadcast_message(self, data: dict) -> None:
|
||||||
"""Dispatch a decoded message to modules whose scope matches."""
|
"""Dispatch a decoded message to modules whose scope matches."""
|
||||||
@@ -241,18 +279,39 @@ class FanoutManager:
|
|||||||
logger.exception("Error stopping fanout module %s", config_id)
|
logger.exception("Error stopping fanout module %s", config_id)
|
||||||
self._modules.clear()
|
self._modules.clear()
|
||||||
self._restart_locks.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."""
|
"""Return status info for each active module."""
|
||||||
from app.repository.fanout import _configs_cache
|
from app.repository.fanout import _configs_cache
|
||||||
|
|
||||||
result: dict[str, dict[str, str]] = {}
|
result: dict[str, dict[str, str | None]] = {}
|
||||||
for config_id, (module, _) in self._modules.items():
|
all_ids = set(_configs_cache) | set(self._modules) | set(self._module_errors)
|
||||||
|
for config_id in all_ids:
|
||||||
info = _configs_cache.get(config_id, {})
|
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] = {
|
result[config_id] = {
|
||||||
"name": info.get("name", config_id),
|
"name": info.get("name", config_id),
|
||||||
"type": info.get("type", "unknown"),
|
"type": info.get("type", "unknown"),
|
||||||
"status": module.status,
|
"status": status,
|
||||||
|
"last_error": last_error,
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
"""Fanout module for uploading heard advert packets to map.meshcore.dev.
|
||||||
|
|
||||||
|
Mirrors the logic of the standalone map.meshcore.dev-uploader project:
|
||||||
|
- Listens on raw RF packets via on_raw
|
||||||
|
- Filters for ADVERT packets, only processes repeaters (role 2) and rooms (role 3)
|
||||||
|
- Skips nodes with no valid location (lat/lon None)
|
||||||
|
- Applies per-pubkey rate-limiting (1-hour window, matching the uploader)
|
||||||
|
- Signs the upload request with the radio's own Ed25519 private key
|
||||||
|
- POSTs to the map API (or logs in dry-run mode)
|
||||||
|
|
||||||
|
Dry-run mode (default: True) logs the full would-be payload at INFO level
|
||||||
|
without making any HTTP requests. Disable it only after verifying the log
|
||||||
|
output looks correct — in particular the radio params (freq/bw/sf/cr) and
|
||||||
|
the raw hex link.
|
||||||
|
|
||||||
|
Config keys
|
||||||
|
-----------
|
||||||
|
api_url : str, default ""
|
||||||
|
Upload endpoint. Empty string falls back to the public map.meshcore.dev API.
|
||||||
|
dry_run : bool, default True
|
||||||
|
When True, log the payload at INFO level instead of sending it.
|
||||||
|
geofence_enabled : bool, default False
|
||||||
|
When True, only upload nodes whose location falls within geofence_radius_km of
|
||||||
|
the radio's own configured latitude/longitude (read live from the radio at upload
|
||||||
|
time — no lat/lon is stored in this config). When the radio's lat/lon is not set
|
||||||
|
(0, 0) or unavailable, the geofence check is silently skipped so uploads continue
|
||||||
|
normally until coordinates are configured.
|
||||||
|
geofence_radius_km : float, default 0.0
|
||||||
|
Radius of the geofence in kilometres. Nodes further than this distance
|
||||||
|
from the radio's own position are skipped.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.decoder import parse_advertisement, parse_packet
|
||||||
|
from app.fanout.base import FanoutModule
|
||||||
|
from app.keystore import ed25519_sign_expanded, get_private_key, get_public_key
|
||||||
|
from app.services.radio_runtime import radio_runtime
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_DEFAULT_API_URL = "https://map.meshcore.dev/api/v1/uploader/node"
|
||||||
|
|
||||||
|
# Re-upload guard: skip re-uploading a pubkey seen within this window (AU parity)
|
||||||
|
_REUPLOAD_SECONDS = 3600
|
||||||
|
|
||||||
|
# Only upload repeaters (2) and rooms (3). Any other role — including future
|
||||||
|
# roles not yet defined — is rejected. An allowlist is used rather than a
|
||||||
|
# blocklist so that new roles cannot accidentally start populating the map.
|
||||||
|
_ALLOWED_DEVICE_ROLES = {2, 3}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_radio_params() -> dict:
|
||||||
|
"""Read radio frequency parameters from the connected radio's self_info.
|
||||||
|
|
||||||
|
The Python meshcore library returns radio_freq in MHz (e.g. 910.525) and
|
||||||
|
radio_bw in kHz (e.g. 62.5). These are exactly the units the map API
|
||||||
|
expects, matching what the JS reference uploader produces after its own
|
||||||
|
/1000 division on raw integer values. No further scaling is applied here.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
mc = radio_runtime.meshcore
|
||||||
|
if not mc:
|
||||||
|
return {"freq": 0, "cr": 0, "sf": 0, "bw": 0}
|
||||||
|
info = mc.self_info
|
||||||
|
if not isinstance(info, dict):
|
||||||
|
return {"freq": 0, "cr": 0, "sf": 0, "bw": 0}
|
||||||
|
freq = info.get("radio_freq", 0) or 0
|
||||||
|
bw = info.get("radio_bw", 0) or 0
|
||||||
|
sf = info.get("radio_sf", 0) or 0
|
||||||
|
cr = info.get("radio_cr", 0) or 0
|
||||||
|
return {
|
||||||
|
"freq": freq,
|
||||||
|
"cr": cr,
|
||||||
|
"sf": sf,
|
||||||
|
"bw": bw,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("MapUpload: could not read radio params: %s", exc)
|
||||||
|
return {"freq": 0, "cr": 0, "sf": 0, "bw": 0}
|
||||||
|
|
||||||
|
|
||||||
|
_ROLE_NAMES: dict[int, str] = {2: "repeater", 3: "room"}
|
||||||
|
|
||||||
|
|
||||||
|
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||||
|
"""Return the great-circle distance in kilometres between two lat/lon points."""
|
||||||
|
r = 6371.0
|
||||||
|
phi1, phi2 = math.radians(lat1), math.radians(lat2)
|
||||||
|
dphi = math.radians(lat2 - lat1)
|
||||||
|
dlam = math.radians(lon2 - lon1)
|
||||||
|
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2
|
||||||
|
return 2 * r * math.asin(math.sqrt(a))
|
||||||
|
|
||||||
|
|
||||||
|
class MapUploadModule(FanoutModule):
|
||||||
|
"""Uploads heard ADVERT packets to the MeshCore community map."""
|
||||||
|
|
||||||
|
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
|
||||||
|
super().__init__(config_id, config, name=name)
|
||||||
|
self._client: httpx.AsyncClient | 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),
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
self._last_error = None
|
||||||
|
self._seen.clear()
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
if self._client:
|
||||||
|
await self._client.aclose()
|
||||||
|
self._client = None
|
||||||
|
self._last_error = None
|
||||||
|
|
||||||
|
async def on_raw(self, data: dict) -> None:
|
||||||
|
if data.get("payload_type") != "ADVERT":
|
||||||
|
return
|
||||||
|
|
||||||
|
raw_hex = data.get("data", "")
|
||||||
|
if not raw_hex:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_bytes = bytes.fromhex(raw_hex)
|
||||||
|
except ValueError:
|
||||||
|
return
|
||||||
|
|
||||||
|
packet_info = parse_packet(raw_bytes)
|
||||||
|
if packet_info is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
advert = parse_advertisement(packet_info.payload, raw_packet=raw_bytes)
|
||||||
|
if advert is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# TODO: advert Ed25519 signature verification is skipped here.
|
||||||
|
# The radio has already validated the packet before passing it to RT,
|
||||||
|
# so re-verification is redundant in practice. If added, verify that
|
||||||
|
# nacl.bindings.crypto_sign_open(sig + (pubkey_bytes || timestamp_bytes),
|
||||||
|
# advert.public_key_bytes) succeeds before proceeding.
|
||||||
|
|
||||||
|
# Only process repeaters (2) and rooms (3) — any other role is rejected
|
||||||
|
if advert.device_role not in _ALLOWED_DEVICE_ROLES:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Skip nodes with no valid location — the decoder already nulls out
|
||||||
|
# impossible values, so None means either no location flag or bad coords.
|
||||||
|
if advert.lat is None or advert.lon is None:
|
||||||
|
logger.debug(
|
||||||
|
"MapUpload: skipping %s — no valid location",
|
||||||
|
advert.public_key[:12],
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
pubkey = advert.public_key.lower()
|
||||||
|
|
||||||
|
# Rate-limit: skip if this pubkey's timestamp hasn't advanced enough
|
||||||
|
last_seen = self._seen.get(pubkey)
|
||||||
|
if last_seen is not None:
|
||||||
|
if last_seen >= advert.timestamp:
|
||||||
|
logger.debug(
|
||||||
|
"MapUpload: skipping %s — possible replay (last=%d, advert=%d)",
|
||||||
|
pubkey[:12],
|
||||||
|
last_seen,
|
||||||
|
advert.timestamp,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if advert.timestamp < last_seen + _REUPLOAD_SECONDS:
|
||||||
|
logger.debug(
|
||||||
|
"MapUpload: skipping %s — within 1-hr rate-limit window (delta=%ds)",
|
||||||
|
pubkey[:12],
|
||||||
|
advert.timestamp - last_seen,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await self._upload(
|
||||||
|
pubkey, advert.timestamp, advert.device_role, raw_hex, advert.lat, advert.lon
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _upload(
|
||||||
|
self,
|
||||||
|
pubkey: str,
|
||||||
|
advert_timestamp: int,
|
||||||
|
device_role: int,
|
||||||
|
raw_hex: str,
|
||||||
|
lat: float,
|
||||||
|
lon: float,
|
||||||
|
) -> None:
|
||||||
|
# Geofence check: if enabled, skip nodes outside the configured radius.
|
||||||
|
# The reference center is the radio's own lat/lon read live from self_info —
|
||||||
|
# no coordinates are stored in the fanout config. If the radio lat/lon is
|
||||||
|
# (0, 0) or unavailable the check is skipped transparently so uploads
|
||||||
|
# continue normally until the operator sets coordinates in radio settings.
|
||||||
|
geofence_dist_km: float | None = None
|
||||||
|
if self.config.get("geofence_enabled"):
|
||||||
|
try:
|
||||||
|
mc = radio_runtime.meshcore
|
||||||
|
sinfo = mc.self_info if mc else None
|
||||||
|
fence_lat = float((sinfo or {}).get("adv_lat", 0) or 0)
|
||||||
|
fence_lon = float((sinfo or {}).get("adv_lon", 0) or 0)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("MapUpload: could not read radio lat/lon for geofence: %s", exc)
|
||||||
|
fence_lat = 0.0
|
||||||
|
fence_lon = 0.0
|
||||||
|
|
||||||
|
if fence_lat == 0.0 and fence_lon == 0.0:
|
||||||
|
logger.debug(
|
||||||
|
"MapUpload: geofence skipped for %s — radio lat/lon not configured",
|
||||||
|
pubkey[:12],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
fence_radius_km = float(self.config.get("geofence_radius_km", 0) or 0)
|
||||||
|
geofence_dist_km = _haversine_km(fence_lat, fence_lon, lat, lon)
|
||||||
|
if geofence_dist_km > fence_radius_km:
|
||||||
|
logger.debug(
|
||||||
|
"MapUpload: skipping %s — outside geofence (%.2f km > %.2f km)",
|
||||||
|
pubkey[:12],
|
||||||
|
geofence_dist_km,
|
||||||
|
fence_radius_km,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
private_key = get_private_key()
|
||||||
|
public_key = get_public_key()
|
||||||
|
|
||||||
|
if private_key is None or public_key is None:
|
||||||
|
logger.warning(
|
||||||
|
"MapUpload: private key not available — cannot sign upload for %s. "
|
||||||
|
"Ensure radio firmware has ENABLE_PRIVATE_KEY_EXPORT=1.",
|
||||||
|
pubkey[:12],
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
api_url = str(self.config.get("api_url", "") or _DEFAULT_API_URL).strip()
|
||||||
|
dry_run = bool(self.config.get("dry_run", True))
|
||||||
|
role_name = _ROLE_NAMES.get(device_role, f"role={device_role}")
|
||||||
|
|
||||||
|
params = _get_radio_params()
|
||||||
|
upload_data = {
|
||||||
|
"params": params,
|
||||||
|
"links": [f"meshcore://{raw_hex}"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sign: SHA-256 the compact JSON, then Ed25519-sign the hash
|
||||||
|
json_str = json.dumps(upload_data, separators=(",", ":"))
|
||||||
|
data_hash = hashlib.sha256(json_str.encode()).digest()
|
||||||
|
scalar = private_key[:32]
|
||||||
|
prefix_bytes = private_key[32:]
|
||||||
|
signature = ed25519_sign_expanded(data_hash, scalar, prefix_bytes, public_key)
|
||||||
|
|
||||||
|
request_payload = {
|
||||||
|
"data": json_str,
|
||||||
|
"signature": signature.hex(),
|
||||||
|
"publicKey": public_key.hex(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
geofence_note = (
|
||||||
|
f" | geofence: {geofence_dist_km:.2f} km from observer"
|
||||||
|
if geofence_dist_km is not None
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"MapUpload [DRY RUN] %s (%s)%s → would POST to %s\n payload: %s",
|
||||||
|
pubkey[:12],
|
||||||
|
role_name,
|
||||||
|
geofence_note,
|
||||||
|
api_url,
|
||||||
|
json.dumps(request_payload, separators=(",", ":")),
|
||||||
|
)
|
||||||
|
# Still update _seen so rate-limiting works during dry-run testing
|
||||||
|
self._seen[pubkey] = advert_timestamp
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self._client:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = await self._client.post(
|
||||||
|
api_url,
|
||||||
|
content=json.dumps(request_payload, separators=(",", ":")),
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
self._seen[pubkey] = advert_timestamp
|
||||||
|
self._set_last_error(None)
|
||||||
|
logger.info(
|
||||||
|
"MapUpload: uploaded %s (%s) → HTTP %d",
|
||||||
|
pubkey[:12],
|
||||||
|
role_name,
|
||||||
|
resp.status_code,
|
||||||
|
)
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
self._set_last_error(f"HTTP {exc.response.status_code}")
|
||||||
|
logger.warning(
|
||||||
|
"MapUpload: server returned %d for %s: %s",
|
||||||
|
exc.response.status_code,
|
||||||
|
pubkey[:12],
|
||||||
|
exc.response.text[:200],
|
||||||
|
)
|
||||||
|
except httpx.RequestError as 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:
|
||||||
|
return "error"
|
||||||
|
return "connected"
|
||||||
@@ -23,6 +23,14 @@ logger = logging.getLogger(__name__)
|
|||||||
_BACKOFF_MIN = 5
|
_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:
|
def _broadcast_health() -> None:
|
||||||
"""Push updated health (including MQTT status) to all WS clients."""
|
"""Push updated health (including MQTT status) to all WS clients."""
|
||||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
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._version_event: asyncio.Event = asyncio.Event()
|
||||||
self.connected: bool = False
|
self.connected: bool = False
|
||||||
self.integration_name: str = ""
|
self.integration_name: str = ""
|
||||||
|
self._last_error: str | None = None
|
||||||
|
|
||||||
def set_integration_name(self, name: str) -> None:
|
def set_integration_name(self, name: str) -> None:
|
||||||
"""Attach the configured fanout-module name for operator-facing logs."""
|
"""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 f"{self._log_prefix} [{self.integration_name}]"
|
||||||
return self._log_prefix
|
return self._log_prefix
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_error(self) -> str | None:
|
||||||
|
"""Return the most recent retained connection/publish error."""
|
||||||
|
return self._last_error
|
||||||
|
|
||||||
# ── Lifecycle ──────────────────────────────────────────────────────
|
# ── Lifecycle ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def start(self, settings: object) -> None:
|
async def start(self, settings: object) -> None:
|
||||||
"""Start the background connection loop."""
|
"""Start the background connection loop."""
|
||||||
self._settings = settings
|
self._settings = settings
|
||||||
|
self._last_error = None
|
||||||
self._settings_version += 1
|
self._settings_version += 1
|
||||||
self._version_event.set()
|
self._version_event.set()
|
||||||
if self._task is None or self._task.done():
|
if self._task is None or self._task.done():
|
||||||
@@ -87,6 +102,7 @@ class BaseMqttPublisher(ABC):
|
|||||||
self._task = None
|
self._task = None
|
||||||
self._client = None
|
self._client = None
|
||||||
self.connected = False
|
self.connected = False
|
||||||
|
self._last_error = None
|
||||||
|
|
||||||
async def restart(self, settings: object) -> None:
|
async def restart(self, settings: object) -> None:
|
||||||
"""Called when settings change — stop + start."""
|
"""Called when settings change — stop + start."""
|
||||||
@@ -109,6 +125,7 @@ class BaseMqttPublisher(ABC):
|
|||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
self.connected = False
|
self.connected = False
|
||||||
|
self._last_error = _format_error_detail(e)
|
||||||
# Wake the connection loop so it exits the wait and reconnects
|
# Wake the connection loop so it exits the wait and reconnects
|
||||||
self._settings_version += 1
|
self._settings_version += 1
|
||||||
self._version_event.set()
|
self._version_event.set()
|
||||||
@@ -198,6 +215,7 @@ class BaseMqttPublisher(ABC):
|
|||||||
async with aiomqtt.Client(**client_kwargs) as client:
|
async with aiomqtt.Client(**client_kwargs) as client:
|
||||||
self._client = client
|
self._client = client
|
||||||
self.connected = True
|
self.connected = True
|
||||||
|
self._last_error = None
|
||||||
backoff = _BACKOFF_MIN
|
backoff = _BACKOFF_MIN
|
||||||
|
|
||||||
title, detail = self._on_connected(settings)
|
title, detail = self._on_connected(settings)
|
||||||
@@ -232,6 +250,7 @@ class BaseMqttPublisher(ABC):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.connected = False
|
self.connected = False
|
||||||
self._client = None
|
self._client = None
|
||||||
|
self._last_error = _format_error_detail(e)
|
||||||
|
|
||||||
title, detail = self._on_error()
|
title, detail = self._on_error()
|
||||||
broadcast_error(title, detail)
|
broadcast_error(title, detail)
|
||||||
|
|||||||
@@ -98,9 +98,15 @@ class MqttCommunityModule(FanoutModule):
|
|||||||
@property
|
@property
|
||||||
def status(self) -> str:
|
def status(self) -> str:
|
||||||
if self._publisher._is_configured():
|
if self._publisher._is_configured():
|
||||||
|
if self._publisher.last_error:
|
||||||
|
return "error"
|
||||||
return "connected" if self._publisher.connected else "disconnected"
|
return "connected" if self._publisher.connected else "disconnected"
|
||||||
return "disconnected"
|
return "disconnected"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_error(self) -> str | None:
|
||||||
|
return self._publisher.last_error
|
||||||
|
|
||||||
|
|
||||||
async def _publish_community_packet(
|
async def _publish_community_packet(
|
||||||
publisher: CommunityMqttPublisher,
|
publisher: CommunityMqttPublisher,
|
||||||
|
|||||||
@@ -59,4 +59,10 @@ class MqttPrivateModule(FanoutModule):
|
|||||||
def status(self) -> str:
|
def status(self) -> str:
|
||||||
if not self.config.get("broker_host"):
|
if not self.config.get("broker_host"):
|
||||||
return "disconnected"
|
return "disconnected"
|
||||||
|
if self._publisher.last_error:
|
||||||
|
return "error"
|
||||||
return "connected" if self._publisher.connected else "disconnected"
|
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:
|
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
|
||||||
super().__init__(config_id, config, name=name)
|
super().__init__(config_id, config, name=name)
|
||||||
self._client = None
|
self._client = None
|
||||||
self._last_error: str | None = None
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
kwargs: dict[str, str] = {}
|
kwargs: dict[str, str] = {}
|
||||||
@@ -147,18 +146,18 @@ class SqsModule(FanoutModule):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(partial(self._client.send_message, **request_kwargs))
|
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:
|
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)
|
logger.warning("SQS %s send error: %s", self.config_id, exc)
|
||||||
except Exception as 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)
|
logger.exception("Unexpected SQS send error for %s", self.config_id)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def status(self) -> str:
|
def status(self) -> str:
|
||||||
if not str(self.config.get("queue_url", "")).strip():
|
if not str(self.config.get("queue_url", "")).strip():
|
||||||
return "disconnected"
|
return "disconnected"
|
||||||
if self._last_error:
|
if self.last_error:
|
||||||
return "error"
|
return "error"
|
||||||
return "connected"
|
return "connected"
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ class WebhookModule(FanoutModule):
|
|||||||
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
|
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
|
||||||
super().__init__(config_id, config, name=name)
|
super().__init__(config_id, config, name=name)
|
||||||
self._client: httpx.AsyncClient | None = None
|
self._client: httpx.AsyncClient | None = None
|
||||||
self._last_error: str | None = None
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
self._client = httpx.AsyncClient(timeout=httpx.Timeout(10.0))
|
self._client = httpx.AsyncClient(timeout=httpx.Timeout(10.0))
|
||||||
@@ -62,9 +61,9 @@ class WebhookModule(FanoutModule):
|
|||||||
try:
|
try:
|
||||||
resp = await self._client.request(method, url, content=body_bytes, headers=headers)
|
resp = await self._client.request(method, url, content=body_bytes, headers=headers)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
self._last_error = None
|
self._set_last_error(None)
|
||||||
except httpx.HTTPStatusError as exc:
|
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(
|
logger.warning(
|
||||||
"Webhook %s returned %s for %s",
|
"Webhook %s returned %s for %s",
|
||||||
self.config_id,
|
self.config_id,
|
||||||
@@ -72,13 +71,13 @@ class WebhookModule(FanoutModule):
|
|||||||
url,
|
url,
|
||||||
)
|
)
|
||||||
except httpx.RequestError as exc:
|
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)
|
logger.warning("Webhook %s request error: %s", self.config_id, exc)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def status(self) -> str:
|
def status(self) -> str:
|
||||||
if not self.config.get("url"):
|
if not self.config.get("url"):
|
||||||
return "disconnected"
|
return "disconnected"
|
||||||
if self._last_error:
|
if self.last_error:
|
||||||
return "error"
|
return "error"
|
||||||
return "connected"
|
return "connected"
|
||||||
|
|||||||
+24
-1
@@ -1,14 +1,18 @@
|
|||||||
"""
|
"""
|
||||||
Ephemeral keystore for storing sensitive keys in memory.
|
Ephemeral keystore for storing sensitive keys in memory, plus the Ed25519
|
||||||
|
signing primitive used by fanout modules that need to sign requests with the
|
||||||
|
radio's own key.
|
||||||
|
|
||||||
The private key is stored in memory only and is never persisted to disk.
|
The private key is stored in memory only and is never persisted to disk.
|
||||||
It's exported from the radio on startup and reconnect, then used for
|
It's exported from the radio on startup and reconnect, then used for
|
||||||
server-side decryption of direct messages.
|
server-side decryption of direct messages.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import nacl.bindings
|
||||||
from meshcore import EventType
|
from meshcore import EventType
|
||||||
|
|
||||||
from app.decoder import derive_public_key
|
from app.decoder import derive_public_key
|
||||||
@@ -25,11 +29,30 @@ NO_EVENT_RECEIVED_GUIDANCE = (
|
|||||||
"issue commands to the radio."
|
"issue commands to the radio."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Ed25519 group order (L) — used in the expanded signing primitive below
|
||||||
|
_L = 2**252 + 27742317777372353535851937790883648493
|
||||||
|
|
||||||
# In-memory storage for the private key and derived public key
|
# In-memory storage for the private key and derived public key
|
||||||
_private_key: bytes | None = None
|
_private_key: bytes | None = None
|
||||||
_public_key: bytes | None = None
|
_public_key: bytes | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def ed25519_sign_expanded(message: bytes, scalar: bytes, prefix: bytes, public_key: bytes) -> bytes:
|
||||||
|
"""Sign a message using MeshCore's expanded Ed25519 key format.
|
||||||
|
|
||||||
|
MeshCore stores 64-byte keys as scalar(32) || prefix(32). Standard
|
||||||
|
Ed25519 libraries expect seed format and would re-SHA-512 the key, so we
|
||||||
|
perform the signing manually using the already-expanded key material.
|
||||||
|
|
||||||
|
Port of meshcore-packet-capture's ed25519_sign_with_expanded_key().
|
||||||
|
"""
|
||||||
|
r = int.from_bytes(hashlib.sha512(prefix + message).digest(), "little") % _L
|
||||||
|
R = nacl.bindings.crypto_scalarmult_ed25519_base_noclamp(r.to_bytes(32, "little"))
|
||||||
|
k = int.from_bytes(hashlib.sha512(R + public_key + message).digest(), "little") % _L
|
||||||
|
s = (r + k * int.from_bytes(scalar, "little")) % _L
|
||||||
|
return R + s.to_bytes(32, "little")
|
||||||
|
|
||||||
|
|
||||||
def clear_keys() -> None:
|
def clear_keys() -> None:
|
||||||
"""Clear any stored private/public key material from memory."""
|
"""Clear any stored private/public key material from memory."""
|
||||||
global _private_key, _public_key
|
global _private_key, _public_key
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from app.frontend_static import (
|
|||||||
)
|
)
|
||||||
from app.radio import RadioDisconnectedError
|
from app.radio import RadioDisconnectedError
|
||||||
from app.radio_sync import (
|
from app.radio_sync import (
|
||||||
|
stop_background_contact_reconciliation,
|
||||||
stop_message_polling,
|
stop_message_polling,
|
||||||
stop_periodic_advert,
|
stop_periodic_advert,
|
||||||
stop_periodic_sync,
|
stop_periodic_sync,
|
||||||
@@ -38,6 +39,7 @@ from app.routers import (
|
|||||||
ws,
|
ws,
|
||||||
)
|
)
|
||||||
from app.security import add_optional_basic_auth_middleware
|
from app.security import add_optional_basic_auth_middleware
|
||||||
|
from app.services.radio_noise_floor import start_noise_floor_sampling, stop_noise_floor_sampling
|
||||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||||
from app.version_info import get_app_build_info
|
from app.version_info import get_app_build_info
|
||||||
|
|
||||||
@@ -69,6 +71,7 @@ async def lifespan(app: FastAPI):
|
|||||||
from app.radio_sync import ensure_default_channels
|
from app.radio_sync import ensure_default_channels
|
||||||
|
|
||||||
await ensure_default_channels()
|
await ensure_default_channels()
|
||||||
|
await start_noise_floor_sampling()
|
||||||
|
|
||||||
# Always start connection monitor (even if initial connection failed)
|
# Always start connection monitor (even if initial connection failed)
|
||||||
await radio_manager.start_connection_monitor()
|
await radio_manager.start_connection_monitor()
|
||||||
@@ -95,7 +98,9 @@ async def lifespan(app: FastAPI):
|
|||||||
pass
|
pass
|
||||||
await fanout_manager.stop_all()
|
await fanout_manager.stop_all()
|
||||||
await radio_manager.stop_connection_monitor()
|
await radio_manager.stop_connection_monitor()
|
||||||
|
await stop_background_contact_reconciliation()
|
||||||
await stop_message_polling()
|
await stop_message_polling()
|
||||||
|
await stop_noise_floor_sampling()
|
||||||
await stop_periodic_advert()
|
await stop_periodic_advert()
|
||||||
await stop_periodic_sync()
|
await stop_periodic_sync()
|
||||||
if radio_manager.meshcore:
|
if radio_manager.meshcore:
|
||||||
|
|||||||
@@ -360,6 +360,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
|
|||||||
await set_version(conn, 46)
|
await set_version(conn, 46)
|
||||||
applied += 1
|
applied += 1
|
||||||
|
|
||||||
|
# Migration 47: Add statistics indexes for time-windowed scans
|
||||||
|
if version < 47:
|
||||||
|
logger.info("Applying migration 47: add statistics indexes")
|
||||||
|
await _migrate_047_add_statistics_indexes(conn)
|
||||||
|
await set_version(conn, 47)
|
||||||
|
applied += 1
|
||||||
|
|
||||||
if applied > 0:
|
if applied > 0:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
|
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
|
||||||
@@ -2868,3 +2875,37 @@ async def _migrate_046_cleanup_orphaned_contact_child_rows(conn: aiosqlite.Conne
|
|||||||
)
|
)
|
||||||
|
|
||||||
await conn.commit()
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _migrate_047_add_statistics_indexes(conn: aiosqlite.Connection) -> None:
|
||||||
|
"""Add indexes used by the statistics endpoint's time-windowed scans."""
|
||||||
|
cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||||
|
tables = {row[0] for row in await cursor.fetchall()}
|
||||||
|
|
||||||
|
if "raw_packets" in tables:
|
||||||
|
cursor = await conn.execute("PRAGMA table_info(raw_packets)")
|
||||||
|
raw_packet_columns = {row[1] for row in await cursor.fetchall()}
|
||||||
|
if "timestamp" in raw_packet_columns:
|
||||||
|
await conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_raw_packets_timestamp ON raw_packets(timestamp)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "contacts" in tables:
|
||||||
|
cursor = await conn.execute("PRAGMA table_info(contacts)")
|
||||||
|
contact_columns = {row[1] for row in await cursor.fetchall()}
|
||||||
|
if {"type", "last_seen"}.issubset(contact_columns):
|
||||||
|
await conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_contacts_type_last_seen ON contacts(type, last_seen)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "messages" in tables:
|
||||||
|
cursor = await conn.execute("PRAGMA table_info(messages)")
|
||||||
|
message_columns = {row[1] for row in await cursor.fetchall()}
|
||||||
|
if {"type", "received_at", "conversation_key"}.issubset(message_columns):
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_type_received_conversation
|
||||||
|
ON messages(type, received_at, conversation_key)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|||||||
@@ -628,6 +628,59 @@ class TraceResponse(BaseModel):
|
|||||||
path_len: int = Field(description="Number of hops in the trace path")
|
path_len: int = Field(description="Number of hops in the trace path")
|
||||||
|
|
||||||
|
|
||||||
|
class RadioTraceHopRequest(BaseModel):
|
||||||
|
"""One requested hop in a radio trace path."""
|
||||||
|
|
||||||
|
public_key: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Full repeater public key when this hop maps to a known repeater",
|
||||||
|
)
|
||||||
|
hop_hex: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Raw hop hash hex when using a custom repeater prefix",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RadioTraceRequest(BaseModel):
|
||||||
|
"""Ordered trace path for a radio trace loop."""
|
||||||
|
|
||||||
|
hop_hash_bytes: Literal[1, 2, 4] = Field(
|
||||||
|
default=4,
|
||||||
|
description="Hash width in bytes for every hop in this trace path",
|
||||||
|
)
|
||||||
|
hops: list[RadioTraceHopRequest] = Field(
|
||||||
|
min_length=1,
|
||||||
|
description="Ordered repeater hops, using either known repeater keys or custom hop hex",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RadioTraceNode(BaseModel):
|
||||||
|
"""One resolved node in a radio trace result."""
|
||||||
|
|
||||||
|
role: Literal["repeater", "custom", "local"] = Field(description="Node role in the trace")
|
||||||
|
public_key: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Resolved full public key for this node when known",
|
||||||
|
)
|
||||||
|
name: str | None = Field(default=None, description="Display name for this node when known")
|
||||||
|
observed_hash: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Observed 4-byte trace hash for this node as hex",
|
||||||
|
)
|
||||||
|
snr: float | None = Field(default=None, description="Reported SNR for this node in dB")
|
||||||
|
|
||||||
|
|
||||||
|
class RadioTraceResponse(BaseModel):
|
||||||
|
"""Resolved multi-hop radio trace result."""
|
||||||
|
|
||||||
|
path_len: int = Field(description="Number of hashed nodes returned by the trace response")
|
||||||
|
timeout_seconds: float = Field(description="Timeout window used while waiting for the trace")
|
||||||
|
nodes: list[RadioTraceNode] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Ordered trace nodes: repeater hops followed by the terminal local radio",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PathDiscoveryRoute(BaseModel):
|
class PathDiscoveryRoute(BaseModel):
|
||||||
"""One resolved route returned by contact path discovery."""
|
"""One resolved route returned by contact path discovery."""
|
||||||
|
|
||||||
@@ -681,6 +734,10 @@ class RadioDiscoveryResult(BaseModel):
|
|||||||
"""One mesh node heard during a discovery sweep."""
|
"""One mesh node heard during a discovery sweep."""
|
||||||
|
|
||||||
public_key: str = Field(description="Discovered node public key as hex")
|
public_key: str = Field(description="Discovered node public key as hex")
|
||||||
|
name: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Known name for this node from contacts DB, if any",
|
||||||
|
)
|
||||||
node_type: Literal["repeater", "sensor"] = Field(description="Discovered node class")
|
node_type: Literal["repeater", "sensor"] = Field(description="Discovered node class")
|
||||||
heard_count: int = Field(default=1, description="How many responses were heard from this node")
|
heard_count: int = Field(default=1, description="How many responses were heard from this node")
|
||||||
local_snr: float | None = Field(
|
local_snr: float | None = Field(
|
||||||
@@ -820,6 +877,27 @@ class PathHashWidthStats(BaseModel):
|
|||||||
triple_byte_pct: float
|
triple_byte_pct: float
|
||||||
|
|
||||||
|
|
||||||
|
class NoiseFloorSample(BaseModel):
|
||||||
|
timestamp: int = Field(description="Unix timestamp of the sampled reading")
|
||||||
|
noise_floor_dbm: int = Field(description="Noise floor in dBm")
|
||||||
|
|
||||||
|
|
||||||
|
class NoiseFloorHistoryStats(BaseModel):
|
||||||
|
sample_interval_seconds: int = Field(description="Expected spacing between samples")
|
||||||
|
coverage_seconds: int = Field(description="How much of the last 24 hours is represented")
|
||||||
|
latest_noise_floor_dbm: int | None = Field(
|
||||||
|
default=None, description="Most recent sampled noise floor in dBm"
|
||||||
|
)
|
||||||
|
latest_timestamp: int | None = Field(
|
||||||
|
default=None, description="Unix timestamp of the most recent sample"
|
||||||
|
)
|
||||||
|
supported: bool | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Whether the connected radio appears to support radio stats sampling",
|
||||||
|
)
|
||||||
|
samples: list[NoiseFloorSample] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class StatisticsResponse(BaseModel):
|
class StatisticsResponse(BaseModel):
|
||||||
busiest_channels_24h: list[BusyChannel]
|
busiest_channels_24h: list[BusyChannel]
|
||||||
contact_count: int
|
contact_count: int
|
||||||
@@ -835,3 +913,4 @@ class StatisticsResponse(BaseModel):
|
|||||||
repeaters_heard: ContactActivityCounts
|
repeaters_heard: ContactActivityCounts
|
||||||
known_channels_active: ContactActivityCounts
|
known_channels_active: ContactActivityCounts
|
||||||
path_hash_width_24h: PathHashWidthStats
|
path_hash_width_24h: PathHashWidthStats
|
||||||
|
noise_floor_24h: NoiseFloorHistoryStats
|
||||||
|
|||||||
+16
-11
@@ -122,20 +122,20 @@ async def run_historical_dm_decryption(
|
|||||||
"""Background task to decrypt historical DM packets with contact's key."""
|
"""Background task to decrypt historical DM packets with contact's key."""
|
||||||
from app.websocket import broadcast_success
|
from app.websocket import broadcast_success
|
||||||
|
|
||||||
packets = await RawPacketRepository.get_undecrypted_text_messages()
|
total = 0
|
||||||
total = len(packets)
|
|
||||||
decrypted_count = 0
|
decrypted_count = 0
|
||||||
|
|
||||||
if total == 0:
|
logger.info("Starting historical DM decryption scan for undecrypted TEXT_MESSAGE packets")
|
||||||
logger.info("No undecrypted TEXT_MESSAGE packets to process")
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info("Starting historical DM decryption of %d TEXT_MESSAGE packets", total)
|
|
||||||
|
|
||||||
# Derive our public key from the private key
|
# Derive our public key from the private key
|
||||||
our_public_key_bytes = derive_public_key(private_key_bytes)
|
our_public_key_bytes = derive_public_key(private_key_bytes)
|
||||||
|
|
||||||
for packet_id, packet_data, packet_timestamp in packets:
|
async for (
|
||||||
|
packet_id,
|
||||||
|
packet_data,
|
||||||
|
packet_timestamp,
|
||||||
|
) in RawPacketRepository.stream_undecrypted_text_messages():
|
||||||
|
total += 1
|
||||||
# Note: passing our_public_key=None disables the outbound hash check in
|
# Note: passing our_public_key=None disables the outbound hash check in
|
||||||
# try_decrypt_dm (only the inbound check src_hash == their_first_byte runs).
|
# try_decrypt_dm (only the inbound check src_hash == their_first_byte runs).
|
||||||
# For the 255/256 case where our first byte differs from the contact's,
|
# For the 255/256 case where our first byte differs from the contact's,
|
||||||
@@ -187,6 +187,10 @@ async def run_historical_dm_decryption(
|
|||||||
if msg_id is not None:
|
if msg_id is not None:
|
||||||
decrypted_count += 1
|
decrypted_count += 1
|
||||||
|
|
||||||
|
if total == 0:
|
||||||
|
logger.info("No undecrypted TEXT_MESSAGE packets to process")
|
||||||
|
return
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Historical DM decryption complete: %d/%d packets decrypted",
|
"Historical DM decryption complete: %d/%d packets decrypted",
|
||||||
decrypted_count,
|
decrypted_count,
|
||||||
@@ -264,9 +268,10 @@ async def process_raw_packet(
|
|||||||
This is the main entry point for all incoming RF packets.
|
This is the main entry point for all incoming RF packets.
|
||||||
|
|
||||||
Note: Packets are deduplicated by payload hash in the database. If we receive
|
Note: Packets are deduplicated by payload hash in the database. If we receive
|
||||||
a duplicate packet (same payload, different path), we still broadcast it to
|
a duplicate payload (same payload, different path), we still broadcast it to
|
||||||
the frontend (for the real-time packet feed) but skip decryption processing
|
the frontend for realtime packet-feed fidelity. Some payload types are also
|
||||||
since the original packet was already processed.
|
intentionally reprocessed on duplicate arrival so message-level dedup/path
|
||||||
|
merge logic and advert/path-history tracking still see each observation.
|
||||||
"""
|
"""
|
||||||
ts = timestamp or int(time.time())
|
ts = timestamp or int(time.time())
|
||||||
observation_id = next(_raw_observation_counter)
|
observation_id = next(_raw_observation_counter)
|
||||||
|
|||||||
@@ -548,11 +548,14 @@ class RadioManager:
|
|||||||
|
|
||||||
async def disconnect(self) -> None:
|
async def disconnect(self) -> None:
|
||||||
"""Disconnect from the radio."""
|
"""Disconnect from the radio."""
|
||||||
|
from app.radio_sync import stop_background_contact_reconciliation
|
||||||
|
|
||||||
clear_keys()
|
clear_keys()
|
||||||
self._reset_reconnect_error_broadcasts()
|
self._reset_reconnect_error_broadcasts()
|
||||||
if self._meshcore is None:
|
if self._meshcore is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
await stop_background_contact_reconciliation()
|
||||||
await self._acquire_operation_lock("disconnect", blocking=True)
|
await self._acquire_operation_lock("disconnect", blocking=True)
|
||||||
try:
|
try:
|
||||||
mc = self._meshcore
|
mc = self._meshcore
|
||||||
|
|||||||
+293
-33
@@ -20,7 +20,7 @@ from meshcore import EventType, MeshCore
|
|||||||
|
|
||||||
from app.channel_constants import PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME
|
from app.channel_constants import PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.event_handlers import cleanup_expired_acks
|
from app.event_handlers import cleanup_expired_acks, on_contact_message
|
||||||
from app.models import Contact, ContactUpsert
|
from app.models import Contact, ContactUpsert
|
||||||
from app.radio import RadioOperationBusyError
|
from app.radio import RadioOperationBusyError
|
||||||
from app.repository import (
|
from app.repository import (
|
||||||
@@ -166,6 +166,9 @@ async def pause_polling():
|
|||||||
# Background task handle
|
# Background task handle
|
||||||
_sync_task: asyncio.Task | None = None
|
_sync_task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
# Startup/background contact reconciliation task handle
|
||||||
|
_contact_reconcile_task: asyncio.Task | None = None
|
||||||
|
|
||||||
# Periodic maintenance check interval in seconds (5 minutes)
|
# Periodic maintenance check interval in seconds (5 minutes)
|
||||||
SYNC_INTERVAL = 300
|
SYNC_INTERVAL = 300
|
||||||
|
|
||||||
@@ -266,30 +269,7 @@ async def sync_and_offload_contacts(mc: MeshCore) -> dict:
|
|||||||
remove_result = await mc.commands.remove_contact(contact_data)
|
remove_result = await mc.commands.remove_contact(contact_data)
|
||||||
if remove_result.type == EventType.OK:
|
if remove_result.type == EventType.OK:
|
||||||
removed += 1
|
removed += 1
|
||||||
|
_evict_removed_contact_from_library_cache(mc, public_key)
|
||||||
# LIBRARY INTERNAL FIXUP: The MeshCore library's
|
|
||||||
# commands.remove_contact() sends the remove command over
|
|
||||||
# the wire but does NOT update the library's in-memory
|
|
||||||
# contact cache (mc._contacts). This is a gap in the
|
|
||||||
# library — there's no public API to clear a single
|
|
||||||
# contact from the cache, and the library only refreshes
|
|
||||||
# it on a full get_contacts() call.
|
|
||||||
#
|
|
||||||
# Why this matters: sync_recent_contacts_to_radio() uses
|
|
||||||
# mc.get_contact_by_key_prefix() to check whether a
|
|
||||||
# contact is already loaded on the radio. That method
|
|
||||||
# searches mc._contacts. If we don't evict the removed
|
|
||||||
# contact from the cache here, get_contact_by_key_prefix()
|
|
||||||
# will still find it and skip the add_contact() call —
|
|
||||||
# meaning contacts never get loaded back onto the radio
|
|
||||||
# after offload. The result: no DM ACKs, degraded routing
|
|
||||||
# for potentially minutes until the next periodic sync
|
|
||||||
# refreshes the cache from the (now-empty) radio.
|
|
||||||
#
|
|
||||||
# We access mc._contacts directly because the library
|
|
||||||
# exposes it as a read-only property (mc.contacts) with
|
|
||||||
# no removal API. The dict is keyed by public_key string.
|
|
||||||
mc._contacts.pop(public_key, None)
|
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Failed to remove contact %s: %s", public_key[:12], remove_result.payload
|
"Failed to remove contact %s: %s", public_key[:12], remove_result.payload
|
||||||
@@ -399,6 +379,14 @@ async def _resolve_channel_for_pending_message(
|
|||||||
return cached_key, channel.name if channel else None
|
return cached_key, channel.name if channel else None
|
||||||
|
|
||||||
|
|
||||||
|
async def _store_pending_direct_message(event) -> None:
|
||||||
|
"""Route a CONTACT_MSG_RECV event pulled via get_msg() through the DM ingest path."""
|
||||||
|
try:
|
||||||
|
await on_contact_message(event)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to store pending direct message", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
async def _store_pending_channel_message(mc: MeshCore, payload: dict) -> None:
|
async def _store_pending_channel_message(mc: MeshCore, payload: dict) -> None:
|
||||||
"""Persist a CHANNEL_MSG_RECV event pulled via get_msg()."""
|
"""Persist a CHANNEL_MSG_RECV event pulled via get_msg()."""
|
||||||
channel_idx = payload.get("channel_idx")
|
channel_idx = payload.get("channel_idx")
|
||||||
@@ -423,7 +411,8 @@ async def _store_pending_channel_message(mc: MeshCore, payload: dict) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
received_at = int(time.time())
|
received_at = int(time.time())
|
||||||
sender_timestamp = payload.get("sender_timestamp") or received_at
|
ts = payload.get("sender_timestamp")
|
||||||
|
sender_timestamp = ts if ts is not None else received_at
|
||||||
sender_name, message_text = _split_channel_sender_and_text(payload.get("text", ""))
|
sender_name, message_text = _split_channel_sender_and_text(payload.get("text", ""))
|
||||||
|
|
||||||
await create_fallback_channel_message(
|
await create_fallback_channel_message(
|
||||||
@@ -461,28 +450,28 @@ async def ensure_default_channels() -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def sync_and_offload_all(mc: MeshCore) -> dict:
|
async def sync_and_offload_all(mc: MeshCore) -> dict:
|
||||||
"""Sync and offload both contacts and channels, then ensure defaults exist."""
|
"""Run fast startup sync, then background contact reconcile."""
|
||||||
logger.info("Starting full radio sync and offload")
|
logger.info("Starting full radio sync and offload")
|
||||||
|
|
||||||
# Contact on_radio is legacy/stale metadata. Clear it during the offload/reload
|
# Contact on_radio is legacy/stale metadata. Clear it during the offload/reload
|
||||||
# cycle so old rows stop claiming radio residency we do not actively track.
|
# cycle so old rows stop claiming radio residency we do not actively track.
|
||||||
await ContactRepository.clear_on_radio_except([])
|
await ContactRepository.clear_on_radio_except([])
|
||||||
|
|
||||||
contacts_result = await sync_and_offload_contacts(mc)
|
contacts_result = await sync_contacts_from_radio(mc)
|
||||||
channels_result = await sync_and_offload_channels(mc)
|
channels_result = await sync_and_offload_channels(mc)
|
||||||
|
|
||||||
# Ensure default channels exist
|
# Ensure default channels exist
|
||||||
await ensure_default_channels()
|
await ensure_default_channels()
|
||||||
|
|
||||||
# Reload favorites plus a working-set fill back onto the radio immediately.
|
start_background_contact_reconciliation(
|
||||||
# Pass mc directly since the caller already holds the radio operation lock
|
initial_radio_contacts=contacts_result.get("radio_contacts", {}),
|
||||||
# (asyncio.Lock is not reentrant).
|
expected_mc=mc,
|
||||||
reload_result = await sync_recent_contacts_to_radio(force=True, mc=mc)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"contacts": contacts_result,
|
"contacts": contacts_result,
|
||||||
"channels": channels_result,
|
"channels": channels_result,
|
||||||
"reloaded": reload_result,
|
"contact_reconcile_started": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -508,6 +497,8 @@ async def drain_pending_messages(mc: MeshCore) -> int:
|
|||||||
elif result.type in (EventType.CONTACT_MSG_RECV, EventType.CHANNEL_MSG_RECV):
|
elif result.type in (EventType.CONTACT_MSG_RECV, EventType.CHANNEL_MSG_RECV):
|
||||||
if result.type == EventType.CHANNEL_MSG_RECV:
|
if result.type == EventType.CHANNEL_MSG_RECV:
|
||||||
await _store_pending_channel_message(mc, result.payload)
|
await _store_pending_channel_message(mc, result.payload)
|
||||||
|
elif result.type == EventType.CONTACT_MSG_RECV:
|
||||||
|
await _store_pending_direct_message(result)
|
||||||
count += 1
|
count += 1
|
||||||
|
|
||||||
# Small delay between fetches
|
# Small delay between fetches
|
||||||
@@ -545,6 +536,8 @@ async def poll_for_messages(mc: MeshCore) -> int:
|
|||||||
elif result.type in (EventType.CONTACT_MSG_RECV, EventType.CHANNEL_MSG_RECV):
|
elif result.type in (EventType.CONTACT_MSG_RECV, EventType.CHANNEL_MSG_RECV):
|
||||||
if result.type == EventType.CHANNEL_MSG_RECV:
|
if result.type == EventType.CHANNEL_MSG_RECV:
|
||||||
await _store_pending_channel_message(mc, result.payload)
|
await _store_pending_channel_message(mc, result.payload)
|
||||||
|
elif result.type == EventType.CONTACT_MSG_RECV:
|
||||||
|
await _store_pending_direct_message(result)
|
||||||
count += 1
|
count += 1
|
||||||
# If we got a message, there might be more - drain them
|
# If we got a message, there might be more - drain them
|
||||||
count += await drain_pending_messages(mc)
|
count += await drain_pending_messages(mc)
|
||||||
@@ -1036,6 +1029,273 @@ async def stop_periodic_sync():
|
|||||||
# Throttling for contact sync to radio
|
# Throttling for contact sync to radio
|
||||||
_last_contact_sync: float = 0.0
|
_last_contact_sync: float = 0.0
|
||||||
CONTACT_SYNC_THROTTLE_SECONDS = 30 # Don't sync more than once per 30 seconds
|
CONTACT_SYNC_THROTTLE_SECONDS = 30 # Don't sync more than once per 30 seconds
|
||||||
|
CONTACT_RECONCILE_BATCH_SIZE = 2
|
||||||
|
CONTACT_RECONCILE_YIELD_SECONDS = 0.05
|
||||||
|
CONTACT_RECONCILE_BUSY_BACKOFF_SECONDS = 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def _evict_removed_contact_from_library_cache(mc: MeshCore, public_key: str) -> None:
|
||||||
|
"""Keep the library's contact cache consistent after a successful removal."""
|
||||||
|
# LIBRARY INTERNAL FIXUP: The MeshCore library's remove_contact() sends the
|
||||||
|
# remove command over the wire but does NOT update the library's in-memory
|
||||||
|
# contact cache (mc._contacts). This is a gap in the library — there's no
|
||||||
|
# public API to clear a single contact from the cache, and the library only
|
||||||
|
# refreshes it on a full get_contacts() call.
|
||||||
|
#
|
||||||
|
# Why this matters: contact sync and targeted ensure/load paths use
|
||||||
|
# mc.get_contact_by_key_prefix() to check whether a contact is already
|
||||||
|
# loaded on the radio. That method searches mc._contacts. If we don't evict
|
||||||
|
# the removed contact from the cache here, later syncs will still find it
|
||||||
|
# and skip add_contact() calls, leaving the radio without the contact even
|
||||||
|
# though the app thinks it is resident.
|
||||||
|
mc._contacts.pop(public_key, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_radio_contacts_payload(contacts: dict | None) -> dict[str, dict]:
|
||||||
|
"""Return radio contacts keyed by normalized lowercase full public key."""
|
||||||
|
normalized: dict[str, dict] = {}
|
||||||
|
for public_key, contact_data in (contacts or {}).items():
|
||||||
|
normalized[str(public_key).lower()] = contact_data
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_contacts_from_radio(mc: MeshCore) -> dict:
|
||||||
|
"""Pull contacts from the radio and persist them to the database without removing them."""
|
||||||
|
synced = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await mc.commands.get_contacts()
|
||||||
|
|
||||||
|
if result is None or result.type == EventType.ERROR:
|
||||||
|
logger.error(
|
||||||
|
"Failed to get contacts from radio: %s. "
|
||||||
|
"If you see this repeatedly, the radio may be visible on the "
|
||||||
|
"serial/TCP/BLE port but not responding to commands. Check for "
|
||||||
|
"another process with the serial port open (other RemoteTerm "
|
||||||
|
"instances, serial monitors, etc.), verify the firmware is "
|
||||||
|
"up-to-date and in client mode (not repeater), or try a "
|
||||||
|
"power cycle.",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
return {"synced": 0, "radio_contacts": {}, "error": str(result)}
|
||||||
|
|
||||||
|
contacts = _normalize_radio_contacts_payload(result.payload)
|
||||||
|
logger.info("Found %d contacts on radio", len(contacts))
|
||||||
|
|
||||||
|
for public_key, contact_data in contacts.items():
|
||||||
|
await ContactRepository.upsert(
|
||||||
|
ContactUpsert.from_radio_dict(public_key, contact_data, on_radio=False)
|
||||||
|
)
|
||||||
|
asyncio.create_task(
|
||||||
|
_reconcile_contact_messages_background(
|
||||||
|
public_key,
|
||||||
|
contact_data.get("adv_name"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
synced += 1
|
||||||
|
|
||||||
|
logger.info("Synced %d contacts from radio snapshot", synced)
|
||||||
|
return {"synced": synced, "radio_contacts": contacts}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error during contact snapshot sync: %s", e)
|
||||||
|
return {"synced": synced, "radio_contacts": {}, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
async def _reconcile_radio_contacts_in_background(
|
||||||
|
*,
|
||||||
|
initial_radio_contacts: dict[str, dict],
|
||||||
|
expected_mc: MeshCore,
|
||||||
|
) -> None:
|
||||||
|
"""Converge radio contacts toward the desired favorites+recents working set."""
|
||||||
|
radio_contacts = dict(initial_radio_contacts)
|
||||||
|
removed = 0
|
||||||
|
loaded = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
if not radio_manager.is_connected or radio_manager.meshcore is not expected_mc:
|
||||||
|
logger.info("Stopping background contact reconcile: radio transport changed")
|
||||||
|
break
|
||||||
|
|
||||||
|
selected_contacts = await get_contacts_selected_for_radio_sync()
|
||||||
|
desired_contacts = {
|
||||||
|
contact.public_key.lower(): contact
|
||||||
|
for contact in selected_contacts
|
||||||
|
if len(contact.public_key) >= 64
|
||||||
|
}
|
||||||
|
removable_keys = [key for key in radio_contacts if key not in desired_contacts]
|
||||||
|
missing_contacts = [
|
||||||
|
contact for key, contact in desired_contacts.items() if key not in radio_contacts
|
||||||
|
]
|
||||||
|
|
||||||
|
if not removable_keys and not missing_contacts:
|
||||||
|
logger.info(
|
||||||
|
"Background contact reconcile complete: %d contacts on radio working set",
|
||||||
|
len(radio_contacts),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
progressed = False
|
||||||
|
try:
|
||||||
|
async with radio_manager.radio_operation(
|
||||||
|
"background_contact_reconcile",
|
||||||
|
blocking=False,
|
||||||
|
) as mc:
|
||||||
|
if mc is not expected_mc:
|
||||||
|
logger.info(
|
||||||
|
"Stopping background contact reconcile: radio transport changed"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
budget = CONTACT_RECONCILE_BATCH_SIZE
|
||||||
|
selected_contacts = await get_contacts_selected_for_radio_sync()
|
||||||
|
desired_contacts = {
|
||||||
|
contact.public_key.lower(): contact
|
||||||
|
for contact in selected_contacts
|
||||||
|
if len(contact.public_key) >= 64
|
||||||
|
}
|
||||||
|
|
||||||
|
for public_key in list(radio_contacts):
|
||||||
|
if budget <= 0:
|
||||||
|
break
|
||||||
|
if public_key in desired_contacts:
|
||||||
|
continue
|
||||||
|
|
||||||
|
remove_payload = (
|
||||||
|
mc.get_contact_by_key_prefix(public_key[:12])
|
||||||
|
or radio_contacts.get(public_key)
|
||||||
|
or {"public_key": public_key}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
remove_result = await mc.commands.remove_contact(remove_payload)
|
||||||
|
except Exception as exc:
|
||||||
|
failed += 1
|
||||||
|
budget -= 1
|
||||||
|
logger.warning(
|
||||||
|
"Error removing contact %s during background reconcile: %s",
|
||||||
|
public_key[:12],
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
budget -= 1
|
||||||
|
if remove_result.type == EventType.OK:
|
||||||
|
radio_contacts.pop(public_key, None)
|
||||||
|
_evict_removed_contact_from_library_cache(mc, public_key)
|
||||||
|
removed += 1
|
||||||
|
progressed = True
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
logger.warning(
|
||||||
|
"Failed to remove contact %s during background reconcile: %s",
|
||||||
|
public_key[:12],
|
||||||
|
remove_result.payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
if budget > 0:
|
||||||
|
for public_key, contact in desired_contacts.items():
|
||||||
|
if budget <= 0:
|
||||||
|
break
|
||||||
|
if public_key in radio_contacts:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if mc.get_contact_by_key_prefix(public_key[:12]):
|
||||||
|
radio_contacts[public_key] = {"public_key": public_key}
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
add_payload = contact.to_radio_dict()
|
||||||
|
add_result = await mc.commands.add_contact(add_payload)
|
||||||
|
except Exception as exc:
|
||||||
|
failed += 1
|
||||||
|
budget -= 1
|
||||||
|
logger.warning(
|
||||||
|
"Error adding contact %s during background reconcile: %s",
|
||||||
|
public_key[:12],
|
||||||
|
exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
budget -= 1
|
||||||
|
if add_result.type == EventType.OK:
|
||||||
|
radio_contacts[public_key] = add_payload
|
||||||
|
loaded += 1
|
||||||
|
progressed = True
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
reason = add_result.payload
|
||||||
|
hint = ""
|
||||||
|
if reason is None:
|
||||||
|
hint = (
|
||||||
|
" (no response from radio — if this repeats, check for "
|
||||||
|
"serial port contention from another process or try a "
|
||||||
|
"power cycle)"
|
||||||
|
)
|
||||||
|
logger.warning(
|
||||||
|
"Failed to add contact %s during background reconcile: %s%s",
|
||||||
|
public_key[:12],
|
||||||
|
reason,
|
||||||
|
hint,
|
||||||
|
)
|
||||||
|
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:
|
||||||
|
continue
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("Background contact reconcile task cancelled")
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Background contact reconcile failed: %s", exc, exc_info=True)
|
||||||
|
finally:
|
||||||
|
if removed > 0 or loaded > 0 or failed > 0:
|
||||||
|
logger.info(
|
||||||
|
"Background contact reconcile summary: removed %d, loaded %d, failed %d",
|
||||||
|
removed,
|
||||||
|
loaded,
|
||||||
|
failed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def start_background_contact_reconciliation(
|
||||||
|
*,
|
||||||
|
initial_radio_contacts: dict[str, dict],
|
||||||
|
expected_mc: MeshCore,
|
||||||
|
) -> None:
|
||||||
|
"""Start or replace the background contact reconcile task for the current radio."""
|
||||||
|
global _contact_reconcile_task
|
||||||
|
|
||||||
|
if _contact_reconcile_task is not None and not _contact_reconcile_task.done():
|
||||||
|
_contact_reconcile_task.cancel()
|
||||||
|
|
||||||
|
_contact_reconcile_task = asyncio.create_task(
|
||||||
|
_reconcile_radio_contacts_in_background(
|
||||||
|
initial_radio_contacts=initial_radio_contacts,
|
||||||
|
expected_mc=expected_mc,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Started background contact reconcile for %d radio contact(s)",
|
||||||
|
len(initial_radio_contacts),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def stop_background_contact_reconciliation() -> None:
|
||||||
|
"""Stop the background contact reconcile task."""
|
||||||
|
global _contact_reconcile_task
|
||||||
|
|
||||||
|
if _contact_reconcile_task and not _contact_reconcile_task.done():
|
||||||
|
_contact_reconcile_task.cancel()
|
||||||
|
try:
|
||||||
|
await _contact_reconcile_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
_contact_reconcile_task = None
|
||||||
|
|
||||||
|
|
||||||
async def get_contacts_selected_for_radio_sync() -> list[Contact]:
|
async def get_contacts_selected_for_radio_sync() -> list[Contact]:
|
||||||
|
|||||||
@@ -723,6 +723,11 @@ class MessageRepository:
|
|||||||
state_key = f"{prefix}-{row['conversation_key']}"
|
state_key = f"{prefix}-{row['conversation_key']}"
|
||||||
last_message_times[state_key] = row["last_message_time"]
|
last_message_times[state_key] = row["last_message_time"]
|
||||||
|
|
||||||
|
# Only include last_read_ats for conversations that actually have messages.
|
||||||
|
# Without this filter, every contact heard via advertisement (even without
|
||||||
|
# any DMs) bloats the payload — 391KB down to ~46KB on a typical database.
|
||||||
|
last_read_ats = {k: v for k, v in last_read_ats.items() if k in last_message_times}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"counts": counts,
|
"counts": counts,
|
||||||
"mentions": mention_flags,
|
"mentions": mention_flags,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
|
|
||||||
from app.database import db
|
from app.database import db
|
||||||
@@ -8,6 +9,8 @@ from app.decoder import PayloadType, extract_payload, get_packet_payload_type
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
UNDECRYPTED_PACKET_BATCH_SIZE = 500
|
||||||
|
|
||||||
|
|
||||||
class RawPacketRepository:
|
class RawPacketRepository:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -100,6 +103,40 @@ class RawPacketRepository:
|
|||||||
rows = await cursor.fetchall()
|
rows = await cursor.fetchall()
|
||||||
return [(row["id"], bytes(row["data"]), row["timestamp"]) for row in rows]
|
return [(row["id"], bytes(row["data"]), row["timestamp"]) for row in rows]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def stream_undecrypted_text_messages(
|
||||||
|
batch_size: int = UNDECRYPTED_PACKET_BATCH_SIZE,
|
||||||
|
) -> AsyncIterator[tuple[int, bytes, int]]:
|
||||||
|
"""Yield undecrypted TEXT_MESSAGE packets in bounded-size batches."""
|
||||||
|
cursor = await db.conn.execute(
|
||||||
|
"SELECT id, data, timestamp FROM raw_packets WHERE message_id IS NULL ORDER BY timestamp ASC"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
rows = await cursor.fetchmany(batch_size)
|
||||||
|
if not rows:
|
||||||
|
break
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
data = bytes(row["data"])
|
||||||
|
payload_type = get_packet_payload_type(data)
|
||||||
|
if payload_type == PayloadType.TEXT_MESSAGE:
|
||||||
|
yield (row["id"], data, row["timestamp"])
|
||||||
|
finally:
|
||||||
|
await cursor.close()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def count_undecrypted_text_messages(
|
||||||
|
batch_size: int = UNDECRYPTED_PACKET_BATCH_SIZE,
|
||||||
|
) -> int:
|
||||||
|
"""Count undecrypted TEXT_MESSAGE packets without materializing them all."""
|
||||||
|
count = 0
|
||||||
|
async for _packet in RawPacketRepository.stream_undecrypted_text_messages(
|
||||||
|
batch_size=batch_size
|
||||||
|
):
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def mark_decrypted(packet_id: int, message_id: int) -> None:
|
async def mark_decrypted(packet_id: int, message_id: int) -> None:
|
||||||
"""Link a raw packet to its decrypted message."""
|
"""Link a raw packet to its decrypted message."""
|
||||||
@@ -158,17 +195,4 @@ class RawPacketRepository:
|
|||||||
Filters raw packets to only include those with PayloadType.TEXT_MESSAGE (0x02).
|
Filters raw packets to only include those with PayloadType.TEXT_MESSAGE (0x02).
|
||||||
These are direct messages that can be decrypted with contact ECDH keys.
|
These are direct messages that can be decrypted with contact ECDH keys.
|
||||||
"""
|
"""
|
||||||
cursor = await db.conn.execute(
|
return [packet async for packet in RawPacketRepository.stream_undecrypted_text_messages()]
|
||||||
"SELECT id, data, timestamp FROM raw_packets WHERE message_id IS NULL ORDER BY timestamp ASC"
|
|
||||||
)
|
|
||||||
rows = await cursor.fetchall()
|
|
||||||
|
|
||||||
# Filter for TEXT_MESSAGE packets
|
|
||||||
result = []
|
|
||||||
for row in rows:
|
|
||||||
data = bytes(row["data"])
|
|
||||||
payload_type = get_packet_payload_type(data)
|
|
||||||
if payload_type == PayloadType.TEXT_MESSAGE:
|
|
||||||
result.append((row["id"], data, row["timestamp"]))
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|||||||
+56
-37
@@ -12,6 +12,7 @@ logger = logging.getLogger(__name__)
|
|||||||
SECONDS_1H = 3600
|
SECONDS_1H = 3600
|
||||||
SECONDS_24H = 86400
|
SECONDS_24H = 86400
|
||||||
SECONDS_7D = 604800
|
SECONDS_7D = 604800
|
||||||
|
RAW_PACKET_STATS_BATCH_SIZE = 500
|
||||||
|
|
||||||
|
|
||||||
class AppSettingsRepository:
|
class AppSettingsRepository:
|
||||||
@@ -246,6 +247,26 @@ class AppSettingsRepository:
|
|||||||
|
|
||||||
|
|
||||||
class StatisticsRepository:
|
class StatisticsRepository:
|
||||||
|
@staticmethod
|
||||||
|
async def get_database_message_totals() -> dict[str, int]:
|
||||||
|
"""Return message totals needed by lightweight debug surfaces."""
|
||||||
|
cursor = await db.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
SUM(CASE WHEN type = 'PRIV' THEN 1 ELSE 0 END) AS total_dms,
|
||||||
|
SUM(CASE WHEN type = 'CHAN' THEN 1 ELSE 0 END) AS total_channel_messages,
|
||||||
|
SUM(CASE WHEN outgoing = 1 THEN 1 ELSE 0 END) AS total_outgoing
|
||||||
|
FROM messages
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
assert row is not None
|
||||||
|
return {
|
||||||
|
"total_dms": row["total_dms"] or 0,
|
||||||
|
"total_channel_messages": row["total_channel_messages"] or 0,
|
||||||
|
"total_outgoing": row["total_outgoing"] or 0,
|
||||||
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _activity_counts(*, contact_type: int, exclude: bool = False) -> dict[str, int]:
|
async def _activity_counts(*, contact_type: int, exclude: bool = False) -> dict[str, int]:
|
||||||
"""Get time-windowed counts for contacts/repeaters heard."""
|
"""Get time-windowed counts for contacts/repeaters heard."""
|
||||||
@@ -272,17 +293,26 @@ class StatisticsRepository:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _known_channels_active() -> dict[str, int]:
|
async def _known_channels_active() -> dict[str, int]:
|
||||||
"""Count distinct known channel keys with channel traffic in each time window."""
|
"""Count known channel keys with any traffic in each time window.
|
||||||
|
|
||||||
|
Channel keys are stored canonically as uppercase hex, so we can avoid
|
||||||
|
the old UPPER(...) join and aggregate per known channel directly.
|
||||||
|
"""
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
cursor = await db.conn.execute(
|
cursor = await db.conn.execute(
|
||||||
"""
|
"""
|
||||||
|
WITH known AS (
|
||||||
|
SELECT conversation_key, MAX(received_at) AS last_received_at
|
||||||
|
FROM messages
|
||||||
|
WHERE type = 'CHAN'
|
||||||
|
AND conversation_key IN (SELECT key FROM channels)
|
||||||
|
GROUP BY conversation_key
|
||||||
|
)
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(DISTINCT CASE WHEN m.received_at >= ? THEN m.conversation_key END) AS last_hour,
|
SUM(CASE WHEN last_received_at >= ? THEN 1 ELSE 0 END) AS last_hour,
|
||||||
COUNT(DISTINCT CASE WHEN m.received_at >= ? THEN m.conversation_key END) AS last_24_hours,
|
SUM(CASE WHEN last_received_at >= ? THEN 1 ELSE 0 END) AS last_24_hours,
|
||||||
COUNT(DISTINCT CASE WHEN m.received_at >= ? THEN m.conversation_key END) AS last_week
|
SUM(CASE WHEN last_received_at >= ? THEN 1 ELSE 0 END) AS last_week
|
||||||
FROM messages m
|
FROM known
|
||||||
INNER JOIN channels c ON UPPER(m.conversation_key) = UPPER(c.key)
|
|
||||||
WHERE m.type = 'CHAN'
|
|
||||||
""",
|
""",
|
||||||
(now - SECONDS_1H, now - SECONDS_24H, now - SECONDS_7D),
|
(now - SECONDS_1H, now - SECONDS_24H, now - SECONDS_7D),
|
||||||
)
|
)
|
||||||
@@ -302,22 +332,26 @@ class StatisticsRepository:
|
|||||||
"SELECT data FROM raw_packets WHERE timestamp >= ?",
|
"SELECT data FROM raw_packets WHERE timestamp >= ?",
|
||||||
(now - SECONDS_24H,),
|
(now - SECONDS_24H,),
|
||||||
)
|
)
|
||||||
rows = await cursor.fetchall()
|
|
||||||
|
|
||||||
single_byte = 0
|
single_byte = 0
|
||||||
double_byte = 0
|
double_byte = 0
|
||||||
triple_byte = 0
|
triple_byte = 0
|
||||||
|
|
||||||
for row in rows:
|
while True:
|
||||||
envelope = parse_packet_envelope(bytes(row["data"]))
|
rows = await cursor.fetchmany(RAW_PACKET_STATS_BATCH_SIZE)
|
||||||
if envelope is None:
|
if not rows:
|
||||||
continue
|
break
|
||||||
if envelope.hash_size == 1:
|
|
||||||
single_byte += 1
|
for row in rows:
|
||||||
elif envelope.hash_size == 2:
|
envelope = parse_packet_envelope(bytes(row["data"]))
|
||||||
double_byte += 1
|
if envelope is None:
|
||||||
elif envelope.hash_size == 3:
|
continue
|
||||||
triple_byte += 1
|
if envelope.hash_size == 1:
|
||||||
|
single_byte += 1
|
||||||
|
elif envelope.hash_size == 2:
|
||||||
|
double_byte += 1
|
||||||
|
elif envelope.hash_size == 3:
|
||||||
|
triple_byte += 1
|
||||||
|
|
||||||
total_packets = single_byte + double_byte + triple_byte
|
total_packets = single_byte + double_byte + triple_byte
|
||||||
if total_packets == 0:
|
if total_packets == 0:
|
||||||
@@ -400,22 +434,7 @@ class StatisticsRepository:
|
|||||||
decrypted_packets = pkt_row["decrypted"] or 0
|
decrypted_packets = pkt_row["decrypted"] or 0
|
||||||
undecrypted_packets = total_packets - decrypted_packets
|
undecrypted_packets = total_packets - decrypted_packets
|
||||||
|
|
||||||
# Message type counts
|
message_totals = await StatisticsRepository.get_database_message_totals()
|
||||||
cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM messages WHERE type = 'PRIV'")
|
|
||||||
row = await cursor.fetchone()
|
|
||||||
assert row is not None
|
|
||||||
total_dms: int = row["cnt"]
|
|
||||||
|
|
||||||
cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM messages WHERE type = 'CHAN'")
|
|
||||||
row = await cursor.fetchone()
|
|
||||||
assert row is not None
|
|
||||||
total_channel_messages: int = row["cnt"]
|
|
||||||
|
|
||||||
# Outgoing count
|
|
||||||
cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM messages WHERE outgoing = 1")
|
|
||||||
row = await cursor.fetchone()
|
|
||||||
assert row is not None
|
|
||||||
total_outgoing: int = row["cnt"]
|
|
||||||
|
|
||||||
# Activity windows
|
# Activity windows
|
||||||
contacts_heard = await StatisticsRepository._activity_counts(contact_type=2, exclude=True)
|
contacts_heard = await StatisticsRepository._activity_counts(contact_type=2, exclude=True)
|
||||||
@@ -431,9 +450,9 @@ class StatisticsRepository:
|
|||||||
"total_packets": total_packets,
|
"total_packets": total_packets,
|
||||||
"decrypted_packets": decrypted_packets,
|
"decrypted_packets": decrypted_packets,
|
||||||
"undecrypted_packets": undecrypted_packets,
|
"undecrypted_packets": undecrypted_packets,
|
||||||
"total_dms": total_dms,
|
"total_dms": message_totals["total_dms"],
|
||||||
"total_channel_messages": total_channel_messages,
|
"total_channel_messages": message_totals["total_channel_messages"],
|
||||||
"total_outgoing": total_outgoing,
|
"total_outgoing": message_totals["total_outgoing"],
|
||||||
"contacts_heard": contacts_heard,
|
"contacts_heard": contacts_heard,
|
||||||
"repeaters_heard": repeaters_heard,
|
"repeaters_heard": repeaters_heard,
|
||||||
"known_channels_active": known_channels_active,
|
"known_channels_active": known_channels_active,
|
||||||
|
|||||||
+14
-6
@@ -40,6 +40,10 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(prefix="/contacts", tags=["contacts"])
|
router = APIRouter(prefix="/contacts", tags=["contacts"])
|
||||||
|
|
||||||
|
|
||||||
|
TRACE_HASH_BYTES = 4
|
||||||
|
TRACE_FLAGS_4BYTE = 2
|
||||||
|
|
||||||
|
|
||||||
def _ambiguous_contact_detail(err: AmbiguousPublicKeyPrefixError) -> str:
|
def _ambiguous_contact_detail(err: AmbiguousPublicKeyPrefixError) -> str:
|
||||||
sample = ", ".join(key[:12] for key in err.matches[:2])
|
sample = ", ".join(key[:12] for key in err.matches[:2])
|
||||||
return (
|
return (
|
||||||
@@ -373,17 +377,17 @@ async def delete_contact(public_key: str) -> dict:
|
|||||||
async def request_trace(public_key: str) -> TraceResponse:
|
async def request_trace(public_key: str) -> TraceResponse:
|
||||||
"""Send a single-hop trace to a contact and wait for the result.
|
"""Send a single-hop trace to a contact and wait for the result.
|
||||||
|
|
||||||
The trace path contains the contact's 1-byte pubkey hash as the sole hop
|
The trace path contains the contact's 4-byte pubkey hash as the sole hop
|
||||||
(no intermediate repeaters). The radio firmware requires at least one
|
(no intermediate repeaters). This uses TRACE's dedicated width flags rather
|
||||||
node in the path.
|
than the radio's normal path_hash_mode setting.
|
||||||
"""
|
"""
|
||||||
require_connected()
|
require_connected()
|
||||||
|
|
||||||
contact = await _resolve_contact_or_404(public_key)
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
|
|
||||||
tag = random.randint(1, 0xFFFFFFFF)
|
tag = random.randint(1, 0xFFFFFFFF)
|
||||||
# First 2 hex chars of pubkey = 1-byte hash used by the trace protocol
|
# Use a 4-byte contact hash for low-collision direct trace targeting.
|
||||||
contact_hash = contact.public_key[:2]
|
contact_hash = contact.public_key[: TRACE_HASH_BYTES * 2]
|
||||||
|
|
||||||
# Trace does not need auto-fetch suspension: response arrives as TRACE_DATA
|
# Trace does not need auto-fetch suspension: response arrives as TRACE_DATA
|
||||||
# from the reader loop, not via get_msg().
|
# from the reader loop, not via get_msg().
|
||||||
@@ -394,7 +398,11 @@ async def request_trace(public_key: str) -> TraceResponse:
|
|||||||
logger.info(
|
logger.info(
|
||||||
"Sending trace to %s (tag=%d, hash=%s)", contact.public_key[:12], tag, contact_hash
|
"Sending trace to %s (tag=%d, hash=%s)", contact.public_key[:12], tag, contact_hash
|
||||||
)
|
)
|
||||||
result = await mc.commands.send_trace(path=contact_hash, tag=tag)
|
result = await mc.commands.send_trace(
|
||||||
|
path=contact_hash,
|
||||||
|
tag=tag,
|
||||||
|
flags=TRACE_FLAGS_4BYTE,
|
||||||
|
)
|
||||||
|
|
||||||
if result.type == EventType.ERROR:
|
if result.type == EventType.ERROR:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to send trace: {result.payload}")
|
raise HTTPException(status_code=500, detail=f"Failed to send trace: {result.payload}")
|
||||||
|
|||||||
@@ -265,7 +265,7 @@ async def _probe_radio() -> DebugRadioProbe:
|
|||||||
async def debug_support_snapshot() -> DebugSnapshotResponse:
|
async def debug_support_snapshot() -> DebugSnapshotResponse:
|
||||||
"""Return a support/debug snapshot with recent logs and live radio state."""
|
"""Return a support/debug snapshot with recent logs and live radio state."""
|
||||||
health_data = await build_health_data(radio_runtime.is_connected, radio_runtime.connection_info)
|
health_data = await build_health_data(radio_runtime.is_connected, radio_runtime.connection_info)
|
||||||
statistics = await StatisticsRepository.get_all()
|
message_totals = await StatisticsRepository.get_database_message_totals()
|
||||||
radio_probe = await _probe_radio()
|
radio_probe = await _probe_radio()
|
||||||
channels_with_incoming_messages = (
|
channels_with_incoming_messages = (
|
||||||
await MessageRepository.count_channels_with_incoming_messages()
|
await MessageRepository.count_channels_with_incoming_messages()
|
||||||
@@ -291,9 +291,9 @@ async def debug_support_snapshot() -> DebugSnapshotResponse:
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
database=DebugDatabaseInfo(
|
database=DebugDatabaseInfo(
|
||||||
total_dms=statistics["total_dms"],
|
total_dms=message_totals["total_dms"],
|
||||||
total_channel_messages=statistics["total_channel_messages"],
|
total_channel_messages=message_totals["total_channel_messages"],
|
||||||
total_outgoing=statistics["total_outgoing"],
|
total_outgoing=message_totals["total_outgoing"],
|
||||||
),
|
),
|
||||||
radio_probe=radio_probe,
|
radio_probe=radio_probe,
|
||||||
logs=[*LOG_COPY_BOUNDARY_PREFIX, *get_recent_log_lines(limit=1000)],
|
logs=[*LOG_COPY_BOUNDARY_PREFIX, *get_recent_log_lines(limit=1000)],
|
||||||
|
|||||||
+26
-1
@@ -16,7 +16,7 @@ from app.repository.fanout import FanoutConfigRepository
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/fanout", tags=["fanout"])
|
router = APIRouter(prefix="/fanout", tags=["fanout"])
|
||||||
|
|
||||||
_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot", "webhook", "apprise", "sqs"}
|
_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot", "webhook", "apprise", "sqs", "map_upload"}
|
||||||
|
|
||||||
_IATA_RE = re.compile(r"^[A-Z]{3}$")
|
_IATA_RE = re.compile(r"^[A-Z]{3}$")
|
||||||
_DEFAULT_COMMUNITY_MQTT_TOPIC_TEMPLATE = "meshcore/{IATA}/{PUBLIC_KEY}/packets"
|
_DEFAULT_COMMUNITY_MQTT_TOPIC_TEMPLATE = "meshcore/{IATA}/{PUBLIC_KEY}/packets"
|
||||||
@@ -94,6 +94,8 @@ def _validate_and_normalize_config(config_type: str, config: dict) -> dict:
|
|||||||
_validate_apprise_config(normalized)
|
_validate_apprise_config(normalized)
|
||||||
elif config_type == "sqs":
|
elif config_type == "sqs":
|
||||||
_validate_sqs_config(normalized)
|
_validate_sqs_config(normalized)
|
||||||
|
elif config_type == "map_upload":
|
||||||
|
_validate_map_upload_config(normalized)
|
||||||
|
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
@@ -295,10 +297,33 @@ def _validate_sqs_config(config: dict) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_map_upload_config(config: dict) -> None:
|
||||||
|
"""Validate and normalize map_upload config blob."""
|
||||||
|
api_url = str(config.get("api_url", "")).strip()
|
||||||
|
if api_url and not api_url.startswith(("http://", "https://")):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="api_url must start with http:// or https://",
|
||||||
|
)
|
||||||
|
# Persist the cleaned value (empty string means use the module default)
|
||||||
|
config["api_url"] = api_url
|
||||||
|
config["dry_run"] = bool(config.get("dry_run", True))
|
||||||
|
config["geofence_enabled"] = bool(config.get("geofence_enabled", False))
|
||||||
|
try:
|
||||||
|
radius = float(config.get("geofence_radius_km", 0) or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise HTTPException(status_code=400, detail="geofence_radius_km must be a number") from None
|
||||||
|
if radius < 0:
|
||||||
|
raise HTTPException(status_code=400, detail="geofence_radius_km must be >= 0")
|
||||||
|
config["geofence_radius_km"] = radius
|
||||||
|
|
||||||
|
|
||||||
def _enforce_scope(config_type: str, scope: dict) -> dict:
|
def _enforce_scope(config_type: str, scope: dict) -> dict:
|
||||||
"""Enforce type-specific scope constraints. Returns normalized scope."""
|
"""Enforce type-specific scope constraints. Returns normalized scope."""
|
||||||
if config_type == "mqtt_community":
|
if config_type == "mqtt_community":
|
||||||
return {"messages": "none", "raw_packets": "all"}
|
return {"messages": "none", "raw_packets": "all"}
|
||||||
|
if config_type == "map_upload":
|
||||||
|
return {"messages": "none", "raw_packets": "all"}
|
||||||
if config_type == "bot":
|
if config_type == "bot":
|
||||||
return {"messages": "all", "raw_packets": "none"}
|
return {"messages": "all", "raw_packets": "none"}
|
||||||
if config_type in ("webhook", "apprise"):
|
if config_type in ("webhook", "apprise"):
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import os
|
|||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.repository import RawPacketRepository
|
from app.repository import RawPacketRepository
|
||||||
@@ -25,6 +25,13 @@ class AppInfoResponse(BaseModel):
|
|||||||
commit_hash: str | None = None
|
commit_hash: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FanoutStatusResponse(BaseModel):
|
||||||
|
name: str
|
||||||
|
type: str
|
||||||
|
status: str
|
||||||
|
last_error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class HealthResponse(BaseModel):
|
class HealthResponse(BaseModel):
|
||||||
status: str
|
status: str
|
||||||
radio_connected: bool
|
radio_connected: bool
|
||||||
@@ -35,7 +42,7 @@ class HealthResponse(BaseModel):
|
|||||||
radio_device_info: RadioDeviceInfoResponse | None = None
|
radio_device_info: RadioDeviceInfoResponse | None = None
|
||||||
database_size_mb: float
|
database_size_mb: float
|
||||||
oldest_undecrypted_timestamp: int | None
|
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: bool = False
|
||||||
bots_disabled_source: Literal["env", "until_restart"] | None = None
|
bots_disabled_source: Literal["env", "until_restart"] | None = None
|
||||||
basic_auth_enabled: bool = False
|
basic_auth_enabled: bool = False
|
||||||
|
|||||||
@@ -210,8 +210,7 @@ async def decrypt_historical_packets(
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
raise _bad_request("Invalid hex string for contact public key") from None
|
raise _bad_request("Invalid hex string for contact public key") from None
|
||||||
|
|
||||||
packets = await RawPacketRepository.get_undecrypted_text_messages()
|
count = await RawPacketRepository.count_undecrypted_text_messages()
|
||||||
count = len(packets)
|
|
||||||
if count == 0:
|
if count == 0:
|
||||||
return DecryptResult(
|
return DecryptResult(
|
||||||
started=False,
|
started=False,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
|
from contextlib import suppress
|
||||||
from typing import Literal, TypeAlias
|
from typing import Literal, TypeAlias
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
@@ -10,14 +11,20 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
from app.dependencies import require_connected
|
from app.dependencies import require_connected
|
||||||
from app.models import (
|
from app.models import (
|
||||||
|
CONTACT_TYPE_REPEATER,
|
||||||
ContactUpsert,
|
ContactUpsert,
|
||||||
RadioDiscoveryRequest,
|
RadioDiscoveryRequest,
|
||||||
RadioDiscoveryResponse,
|
RadioDiscoveryResponse,
|
||||||
RadioDiscoveryResult,
|
RadioDiscoveryResult,
|
||||||
|
RadioTraceHopRequest,
|
||||||
|
RadioTraceNode,
|
||||||
|
RadioTraceRequest,
|
||||||
|
RadioTraceResponse,
|
||||||
)
|
)
|
||||||
from app.radio_sync import send_advertisement as do_send_advertisement
|
from app.radio_sync import send_advertisement as do_send_advertisement
|
||||||
from app.radio_sync import sync_radio_time
|
from app.radio_sync import sync_radio_time
|
||||||
from app.repository import ContactRepository
|
from app.repository import ContactRepository
|
||||||
|
from app.services.contact_reconciliation import promote_prefix_contacts_for_contact
|
||||||
from app.services.radio_commands import (
|
from app.services.radio_commands import (
|
||||||
KeystoreRefreshError,
|
KeystoreRefreshError,
|
||||||
PathHashModeUnsupportedError,
|
PathHashModeUnsupportedError,
|
||||||
@@ -44,6 +51,12 @@ _DISCOVERY_NODE_TYPES: dict[int, DiscoveryNodeType] = {
|
|||||||
2: "repeater",
|
2: "repeater",
|
||||||
4: "sensor",
|
4: "sensor",
|
||||||
}
|
}
|
||||||
|
TRACE_WAIT_TIMEOUT_SECONDS = 45.0
|
||||||
|
TRACE_DEFAULT_TIMEOUT_SECONDS = 15.0
|
||||||
|
TRACE_TIMEOUT_MIN_SECONDS = 5.0
|
||||||
|
TRACE_TIMEOUT_MAX_SECONDS = 30.0
|
||||||
|
TRACE_TIMEOUT_MARGIN = 1.2
|
||||||
|
TRACE_HASH_FLAGS = {1: 0, 2: 1, 4: 2}
|
||||||
|
|
||||||
|
|
||||||
async def _prepare_connected(*, broadcast_on_success: bool) -> bool:
|
async def _prepare_connected(*, broadcast_on_success: bool) -> bool:
|
||||||
@@ -197,9 +210,118 @@ async def _persist_new_discovery_contacts(results: list[RadioDiscoveryResult]) -
|
|||||||
on_radio=False,
|
on_radio=False,
|
||||||
)
|
)
|
||||||
await ContactRepository.upsert(contact)
|
await ContactRepository.upsert(contact)
|
||||||
|
promoted_keys = await promote_prefix_contacts_for_contact(
|
||||||
|
public_key=result.public_key,
|
||||||
|
log=logger,
|
||||||
|
)
|
||||||
created = await ContactRepository.get_by_key(result.public_key)
|
created = await ContactRepository.get_by_key(result.public_key)
|
||||||
if created is not None:
|
if created is not None:
|
||||||
broadcast_event("contact", created.model_dump())
|
broadcast_event("contact", created.model_dump())
|
||||||
|
for old_key in promoted_keys:
|
||||||
|
broadcast_event("contact_deleted", {"public_key": old_key})
|
||||||
|
|
||||||
|
|
||||||
|
async def _attach_known_names(results: list[RadioDiscoveryResult]) -> None:
|
||||||
|
"""Resolve known contact names for discovery results from the DB."""
|
||||||
|
for result in results:
|
||||||
|
contact = await ContactRepository.get_by_key(result.public_key)
|
||||||
|
if contact is not None and contact.name:
|
||||||
|
result.name = contact.name
|
||||||
|
|
||||||
|
|
||||||
|
def _trace_hash_for_key(public_key: str, hop_hash_bytes: int) -> str:
|
||||||
|
return public_key[: hop_hash_bytes * 2].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _trace_timeout_seconds(send_result: object) -> float:
|
||||||
|
payload = getattr(send_result, "payload", None) or {}
|
||||||
|
suggested_timeout = payload.get("suggested_timeout")
|
||||||
|
try:
|
||||||
|
if suggested_timeout is None:
|
||||||
|
raise TypeError
|
||||||
|
timeout_seconds = float(suggested_timeout) / 1000.0 * TRACE_TIMEOUT_MARGIN
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
timeout_seconds = TRACE_DEFAULT_TIMEOUT_SECONDS
|
||||||
|
return max(TRACE_TIMEOUT_MIN_SECONDS, min(TRACE_TIMEOUT_MAX_SECONDS, timeout_seconds))
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_trace_hops(
|
||||||
|
hops: list[RadioTraceHopRequest], hop_hash_bytes: int
|
||||||
|
) -> tuple[list[RadioTraceNode], list[str]]:
|
||||||
|
trace_nodes: list[RadioTraceNode] = []
|
||||||
|
requested_hashes: list[str] = []
|
||||||
|
expected_hex_len = hop_hash_bytes * 2
|
||||||
|
|
||||||
|
for hop in hops:
|
||||||
|
public_key = hop.public_key.strip().lower() if isinstance(hop.public_key, str) else None
|
||||||
|
hop_hex = hop.hop_hex.strip().lower() if isinstance(hop.hop_hex, str) else None
|
||||||
|
if bool(public_key) == bool(hop_hex):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Each trace hop must provide exactly one of public_key or hop_hex",
|
||||||
|
)
|
||||||
|
|
||||||
|
if public_key:
|
||||||
|
if len(public_key) != 64:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Trace repeater keys must be full 64-character public keys",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
bytes.fromhex(public_key)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Trace repeater keys must be valid hex public keys",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
contact = await ContactRepository.get_by_key(public_key)
|
||||||
|
if contact is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404, detail=f"Trace repeater not found: {public_key}"
|
||||||
|
)
|
||||||
|
if contact.type != CONTACT_TYPE_REPEATER:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Trace node is not a repeater: {public_key[:12]}",
|
||||||
|
)
|
||||||
|
requested_hashes.append(_trace_hash_for_key(contact.public_key, hop_hash_bytes))
|
||||||
|
trace_nodes.append(
|
||||||
|
RadioTraceNode(
|
||||||
|
role="repeater",
|
||||||
|
public_key=contact.public_key,
|
||||||
|
name=contact.name,
|
||||||
|
observed_hash=None,
|
||||||
|
snr=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
assert hop_hex is not None
|
||||||
|
if len(hop_hex) != expected_hex_len:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Custom trace hops must be exactly {expected_hex_len} hex characters",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
bytes.fromhex(hop_hex)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Custom trace hops must be valid hex",
|
||||||
|
) from exc
|
||||||
|
requested_hashes.append(hop_hex)
|
||||||
|
trace_nodes.append(
|
||||||
|
RadioTraceNode(
|
||||||
|
role="custom",
|
||||||
|
public_key=None,
|
||||||
|
name=None,
|
||||||
|
observed_hash=hop_hex,
|
||||||
|
snr=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return trace_nodes, requested_hashes
|
||||||
|
|
||||||
|
|
||||||
@router.get("/config", response_model=RadioConfigResponse)
|
@router.get("/config", response_model=RadioConfigResponse)
|
||||||
@@ -365,6 +487,7 @@ async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryRespons
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
await _persist_new_discovery_contacts(results)
|
await _persist_new_discovery_contacts(results)
|
||||||
|
await _attach_known_names(results)
|
||||||
return RadioDiscoveryResponse(
|
return RadioDiscoveryResponse(
|
||||||
target=request.target,
|
target=request.target,
|
||||||
duration_seconds=DISCOVERY_WINDOW_SECONDS,
|
duration_seconds=DISCOVERY_WINDOW_SECONDS,
|
||||||
@@ -372,6 +495,105 @@ async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryRespons
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/trace", response_model=RadioTraceResponse)
|
||||||
|
async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse:
|
||||||
|
"""Send a multi-hop trace loop through known repeaters and back to the local radio."""
|
||||||
|
require_connected()
|
||||||
|
trace_nodes, requested_hashes = await _resolve_trace_hops(request.hops, request.hop_hash_bytes)
|
||||||
|
|
||||||
|
tag = random.randint(1, 0xFFFFFFFF)
|
||||||
|
trace_flags = TRACE_HASH_FLAGS[request.hop_hash_bytes]
|
||||||
|
|
||||||
|
async with radio_manager.radio_operation("radio_trace", pause_polling=True) as mc:
|
||||||
|
local_public_key = str((mc.self_info or {}).get("public_key") or "").lower()
|
||||||
|
if len(local_public_key) != 64:
|
||||||
|
raise HTTPException(status_code=503, detail="Local radio public key is unavailable")
|
||||||
|
local_name = (mc.self_info or {}).get("name")
|
||||||
|
|
||||||
|
response_task = asyncio.create_task(
|
||||||
|
mc.wait_for_event(
|
||||||
|
EventType.TRACE_DATA,
|
||||||
|
attribute_filters={"tag": tag},
|
||||||
|
timeout=TRACE_WAIT_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
send_result = await mc.commands.send_trace(
|
||||||
|
path=",".join(requested_hashes),
|
||||||
|
tag=tag,
|
||||||
|
flags=trace_flags,
|
||||||
|
)
|
||||||
|
if send_result is None or send_result.type == EventType.ERROR:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to send trace")
|
||||||
|
|
||||||
|
timeout_seconds = _trace_timeout_seconds(send_result)
|
||||||
|
try:
|
||||||
|
event = await asyncio.wait_for(response_task, timeout=timeout_seconds)
|
||||||
|
except asyncio.TimeoutError as exc:
|
||||||
|
raise HTTPException(status_code=504, detail="No trace response heard") from exc
|
||||||
|
finally:
|
||||||
|
if not response_task.done():
|
||||||
|
response_task.cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await response_task
|
||||||
|
|
||||||
|
if event is None:
|
||||||
|
raise HTTPException(status_code=504, detail="No trace response heard")
|
||||||
|
|
||||||
|
payload = event.payload if isinstance(event.payload, dict) else {}
|
||||||
|
path_len = payload.get("path_len")
|
||||||
|
if not isinstance(path_len, int):
|
||||||
|
raise HTTPException(status_code=500, detail="Trace response was malformed")
|
||||||
|
|
||||||
|
raw_path = payload.get("path")
|
||||||
|
path_nodes = raw_path if isinstance(raw_path, list) else []
|
||||||
|
final_local_node = (
|
||||||
|
path_nodes[-1]
|
||||||
|
if path_nodes
|
||||||
|
and isinstance(path_nodes[-1], dict)
|
||||||
|
and not isinstance(path_nodes[-1].get("hash"), str)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
hashed_nodes = path_nodes[:-1] if final_local_node is not None else path_nodes
|
||||||
|
|
||||||
|
if len(hashed_nodes) < len(trace_nodes):
|
||||||
|
raise HTTPException(status_code=500, detail="Trace response was incomplete")
|
||||||
|
|
||||||
|
nodes: list[RadioTraceNode] = []
|
||||||
|
for index, trace_node in enumerate(trace_nodes):
|
||||||
|
observed = hashed_nodes[index] if index < len(hashed_nodes) else {}
|
||||||
|
observed_hash = observed.get("hash") if isinstance(observed, dict) else None
|
||||||
|
observed_snr = observed.get("snr") if isinstance(observed, dict) else None
|
||||||
|
nodes.append(
|
||||||
|
RadioTraceNode(
|
||||||
|
role=trace_node.role,
|
||||||
|
public_key=trace_node.public_key,
|
||||||
|
name=trace_node.name,
|
||||||
|
observed_hash=(
|
||||||
|
observed_hash if isinstance(observed_hash, str) else trace_node.observed_hash
|
||||||
|
),
|
||||||
|
snr=float(observed_snr) if isinstance(observed_snr, (int, float)) else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
terminal_snr_value = final_local_node.get("snr") if isinstance(final_local_node, dict) else None
|
||||||
|
nodes.append(
|
||||||
|
RadioTraceNode(
|
||||||
|
role="local",
|
||||||
|
public_key=local_public_key,
|
||||||
|
name=local_name if isinstance(local_name, str) and local_name else None,
|
||||||
|
observed_hash=None,
|
||||||
|
snr=float(terminal_snr_value) if isinstance(terminal_snr_value, (int, float)) else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return RadioTraceResponse(
|
||||||
|
path_len=path_len,
|
||||||
|
timeout_seconds=timeout_seconds,
|
||||||
|
nodes=nodes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _attempt_reconnect() -> dict:
|
async def _attempt_reconnect() -> dict:
|
||||||
"""Shared reconnection logic for reboot and reconnect endpoints."""
|
"""Shared reconnection logic for reboot and reconnect endpoints."""
|
||||||
radio_manager.resume_connection()
|
radio_manager.resume_connection()
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from meshcore import EventType
|
|
||||||
|
|
||||||
from app.dependencies import require_connected
|
from app.dependencies import require_connected
|
||||||
from app.models import (
|
from app.models import (
|
||||||
@@ -28,7 +25,6 @@ from app.models import (
|
|||||||
from app.repository import ContactRepository
|
from app.repository import ContactRepository
|
||||||
from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404
|
from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404
|
||||||
from app.routers.server_control import (
|
from app.routers.server_control import (
|
||||||
_monotonic,
|
|
||||||
batch_cli_fetch,
|
batch_cli_fetch,
|
||||||
extract_response_text,
|
extract_response_text,
|
||||||
prepare_authenticated_contact_connection,
|
prepare_authenticated_contact_connection,
|
||||||
@@ -37,9 +33,6 @@ from app.routers.server_control import (
|
|||||||
)
|
)
|
||||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from meshcore.events import Event
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ACL permission level names
|
# ACL permission level names
|
||||||
@@ -57,58 +50,6 @@ def _extract_response_text(event) -> str:
|
|||||||
return extract_response_text(event)
|
return extract_response_text(event)
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_repeater_response(
|
|
||||||
mc,
|
|
||||||
target_pubkey_prefix: str,
|
|
||||||
timeout: float = 20.0,
|
|
||||||
) -> "Event | None":
|
|
||||||
deadline = _monotonic() + timeout
|
|
||||||
|
|
||||||
while _monotonic() < deadline:
|
|
||||||
try:
|
|
||||||
result = await mc.commands.get_msg(timeout=2.0)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
continue
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("get_msg() exception: %s", exc)
|
|
||||||
await asyncio.sleep(1.0)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if result.type == EventType.NO_MORE_MSGS:
|
|
||||||
await asyncio.sleep(1.0)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if result.type == EventType.ERROR:
|
|
||||||
logger.debug("get_msg() error: %s", result.payload)
|
|
||||||
await asyncio.sleep(1.0)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if result.type == EventType.CONTACT_MSG_RECV:
|
|
||||||
msg_prefix = result.payload.get("pubkey_prefix", "")
|
|
||||||
txt_type = result.payload.get("txt_type", 0)
|
|
||||||
if msg_prefix == target_pubkey_prefix and txt_type == 1:
|
|
||||||
return result
|
|
||||||
logger.debug(
|
|
||||||
"Skipping non-target message (from=%s, txt_type=%d) while waiting for %s",
|
|
||||||
msg_prefix,
|
|
||||||
txt_type,
|
|
||||||
target_pubkey_prefix,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if result.type == EventType.CHANNEL_MSG_RECV:
|
|
||||||
logger.debug(
|
|
||||||
"Skipping channel message (channel_idx=%s) during repeater fetch",
|
|
||||||
result.payload.get("channel_idx"),
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
logger.debug("Unexpected event type %s during repeater fetch, skipping", result.type)
|
|
||||||
|
|
||||||
logger.warning("No CLI response from repeater %s within %.1fs", target_pubkey_prefix, timeout)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def prepare_repeater_connection(mc, contact: Contact, password: str) -> RepeaterLoginResponse:
|
async def prepare_repeater_connection(mc, contact: Contact, password: str) -> RepeaterLoginResponse:
|
||||||
return await prepare_authenticated_contact_connection(
|
return await prepare_authenticated_contact_connection(
|
||||||
mc,
|
mc,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from app.models import (
|
|||||||
Contact,
|
Contact,
|
||||||
RepeaterLoginResponse,
|
RepeaterLoginResponse,
|
||||||
)
|
)
|
||||||
|
from app.radio_sync import _store_pending_channel_message, _store_pending_direct_message
|
||||||
from app.routers.contacts import _ensure_on_radio
|
from app.routers.contacts import _ensure_on_radio
|
||||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||||
|
|
||||||
@@ -62,7 +63,7 @@ def _login_rejected_message(label: str) -> str:
|
|||||||
def _login_send_failed_message(label: str) -> str:
|
def _login_send_failed_message(label: str) -> str:
|
||||||
return (
|
return (
|
||||||
f"The login request could not be sent to the {label}. "
|
f"The login request could not be sent to the {label}. "
|
||||||
f"The control panel is still available, but authenticated actions may fail until a login succeeds."
|
f"You're free to attempt interaction; try logging in again if authenticated actions fail."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -70,7 +71,7 @@ def _login_timeout_message(label: str) -> str:
|
|||||||
return (
|
return (
|
||||||
f"No login confirmation was heard from the {label}. "
|
f"No login confirmation was heard from the {label}. "
|
||||||
"That can mean the password was wrong or the reply was missed in transit. "
|
"That can mean the password was wrong or the reply was missed in transit. "
|
||||||
"The control panel is still available; try logging in again if authenticated actions fail."
|
"You're free to attempt interaction; try logging in again if authenticated actions fail."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -115,18 +116,20 @@ async def fetch_contact_cli_response(
|
|||||||
if msg_prefix == target_pubkey_prefix and txt_type == 1:
|
if msg_prefix == target_pubkey_prefix and txt_type == 1:
|
||||||
return result
|
return result
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Skipping non-target message (from=%s, txt_type=%d) while waiting for %s",
|
"Storing non-target DM (from=%s, txt_type=%d) consumed while waiting for %s",
|
||||||
msg_prefix,
|
msg_prefix,
|
||||||
txt_type,
|
txt_type,
|
||||||
target_pubkey_prefix,
|
target_pubkey_prefix,
|
||||||
)
|
)
|
||||||
|
await _store_pending_direct_message(result)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if result.type == EventType.CHANNEL_MSG_RECV:
|
if result.type == EventType.CHANNEL_MSG_RECV:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Skipping channel message (channel_idx=%s) during CLI fetch",
|
"Storing channel message (channel_idx=%s) consumed during CLI fetch",
|
||||||
result.payload.get("channel_idx"),
|
result.payload.get("channel_idx"),
|
||||||
)
|
)
|
||||||
|
await _store_pending_channel_message(mc, result.payload)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
logger.debug("Unexpected event type %s during CLI fetch, skipping", result.type)
|
logger.debug("Unexpected event type %s during CLI fetch, skipping", result.type)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from fastapi import APIRouter
|
|||||||
|
|
||||||
from app.models import StatisticsResponse
|
from app.models import StatisticsResponse
|
||||||
from app.repository import StatisticsRepository
|
from app.repository import StatisticsRepository
|
||||||
|
from app.services.radio_noise_floor import get_noise_floor_history
|
||||||
|
|
||||||
router = APIRouter(prefix="/statistics", tags=["statistics"])
|
router = APIRouter(prefix="/statistics", tags=["statistics"])
|
||||||
|
|
||||||
@@ -9,4 +10,5 @@ router = APIRouter(prefix="/statistics", tags=["statistics"])
|
|||||||
@router.get("", response_model=StatisticsResponse)
|
@router.get("", response_model=StatisticsResponse)
|
||||||
async def get_statistics() -> StatisticsResponse:
|
async def get_statistics() -> StatisticsResponse:
|
||||||
data = await StatisticsRepository.get_all()
|
data = await StatisticsRepository.get_all()
|
||||||
|
data["noise_floor_24h"] = await get_noise_floor_history()
|
||||||
return StatisticsResponse(**data)
|
return StatisticsResponse(**data)
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""In-memory local-radio noise floor history sampling."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
from meshcore import EventType
|
||||||
|
|
||||||
|
from app.radio import RadioDisconnectedError, RadioOperationBusyError
|
||||||
|
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
NOISE_FLOOR_SAMPLE_INTERVAL_SECONDS = 300
|
||||||
|
NOISE_FLOOR_WINDOW_SECONDS = 24 * 60 * 60
|
||||||
|
MAX_NOISE_FLOOR_SAMPLES = 300
|
||||||
|
|
||||||
|
_noise_floor_task: asyncio.Task | None = None
|
||||||
|
_noise_floor_samples: deque[tuple[int, int]] = deque(maxlen=MAX_NOISE_FLOOR_SAMPLES)
|
||||||
|
_noise_floor_supported: bool | None = None
|
||||||
|
_samples_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
async def _append_sample(timestamp: int, noise_floor_dbm: int) -> None:
|
||||||
|
async with _samples_lock:
|
||||||
|
_noise_floor_samples.append((timestamp, noise_floor_dbm))
|
||||||
|
|
||||||
|
|
||||||
|
async def sample_noise_floor_once(*, blocking: bool = False) -> None:
|
||||||
|
"""Fetch the current radio noise floor once and record it when available."""
|
||||||
|
global _noise_floor_supported
|
||||||
|
|
||||||
|
if not radio_manager.is_connected:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with radio_manager.radio_operation("noise_floor_sample", blocking=blocking) as mc:
|
||||||
|
event = await mc.commands.get_stats_radio()
|
||||||
|
except (RadioDisconnectedError, RadioOperationBusyError):
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Noise floor sampling failed: %s", exc)
|
||||||
|
return
|
||||||
|
|
||||||
|
if event.type == EventType.ERROR:
|
||||||
|
_noise_floor_supported = False
|
||||||
|
return
|
||||||
|
|
||||||
|
if event.type != EventType.STATS_RADIO:
|
||||||
|
return
|
||||||
|
|
||||||
|
noise_floor = event.payload.get("noise_floor")
|
||||||
|
if not isinstance(noise_floor, int):
|
||||||
|
return
|
||||||
|
|
||||||
|
_noise_floor_supported = True
|
||||||
|
await _append_sample(int(time.time()), noise_floor)
|
||||||
|
|
||||||
|
|
||||||
|
async def _noise_floor_sampling_loop() -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await sample_noise_floor_once()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Noise floor sampling loop crashed during sample")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(NOISE_FLOOR_SAMPLE_INTERVAL_SECONDS)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def start_noise_floor_sampling() -> None:
|
||||||
|
global _noise_floor_task
|
||||||
|
if _noise_floor_task is not None and not _noise_floor_task.done():
|
||||||
|
return
|
||||||
|
_noise_floor_task = asyncio.create_task(_noise_floor_sampling_loop())
|
||||||
|
|
||||||
|
|
||||||
|
async def stop_noise_floor_sampling() -> None:
|
||||||
|
global _noise_floor_task
|
||||||
|
if _noise_floor_task is None:
|
||||||
|
return
|
||||||
|
if not _noise_floor_task.done():
|
||||||
|
_noise_floor_task.cancel()
|
||||||
|
try:
|
||||||
|
await _noise_floor_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
_noise_floor_task = None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_noise_floor_history() -> dict:
|
||||||
|
"""Return the current 24-hour in-memory noise floor history snapshot."""
|
||||||
|
now = int(time.time())
|
||||||
|
cutoff = now - NOISE_FLOOR_WINDOW_SECONDS
|
||||||
|
|
||||||
|
async with _samples_lock:
|
||||||
|
samples = [
|
||||||
|
{"timestamp": timestamp, "noise_floor_dbm": noise_floor_dbm}
|
||||||
|
for timestamp, noise_floor_dbm in _noise_floor_samples
|
||||||
|
if timestamp >= cutoff
|
||||||
|
]
|
||||||
|
|
||||||
|
latest = samples[-1] if samples else None
|
||||||
|
oldest_timestamp = samples[0]["timestamp"] if samples else None
|
||||||
|
coverage_seconds = 0 if oldest_timestamp is None else max(0, now - oldest_timestamp)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"sample_interval_seconds": NOISE_FLOOR_SAMPLE_INTERVAL_SECONDS,
|
||||||
|
"coverage_seconds": coverage_seconds,
|
||||||
|
"latest_noise_floor_dbm": latest["noise_floor_dbm"] if latest else None,
|
||||||
|
"latest_timestamp": latest["timestamp"] if latest else None,
|
||||||
|
"supported": _noise_floor_supported,
|
||||||
|
"samples": samples,
|
||||||
|
}
|
||||||
@@ -43,9 +43,6 @@ class WebSocketManager:
|
|||||||
3. Send to all clients concurrently with timeout
|
3. Send to all clients concurrently with timeout
|
||||||
4. Re-acquire lock to clean up disconnected clients
|
4. Re-acquire lock to clean up disconnected clients
|
||||||
"""
|
"""
|
||||||
if not self.active_connections:
|
|
||||||
return
|
|
||||||
|
|
||||||
message = dump_ws_event(event_type, data)
|
message = dump_ws_event(event_type, data)
|
||||||
|
|
||||||
# Copy connection list under lock to avoid holding lock during I/O
|
# Copy connection list under lock to avoid holding lock during I/O
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
services:
|
||||||
|
remoteterm:
|
||||||
|
# build: .
|
||||||
|
image: docker.io/jkingsman/remoteterm-meshcore:latest
|
||||||
|
|
||||||
|
# Optional on Linux: run container as your host user to avoid root-owned files in ./data
|
||||||
|
# This is less reliable for serial-device access than running as root and may require
|
||||||
|
# extra group setup (for example dialout) or other manual customization.
|
||||||
|
# user: "${UID:-1000}:${GID:-1000}"
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
|
||||||
|
#####################################################################
|
||||||
|
# Map your radio by stable device ID if available. #
|
||||||
|
# If your by-id path contains ':' characters, Docker Compose cannot #
|
||||||
|
# represent it here directly; use a colon-free host alias instead. #
|
||||||
|
# (e.g. /dev/ttyUSB0) #
|
||||||
|
#####################################################################
|
||||||
|
devices:
|
||||||
|
- /dev/serial/by-id/your-meshcore-radio:/dev/meshcore-radio
|
||||||
|
|
||||||
|
environment:
|
||||||
|
MESHCORE_DATABASE_PATH: data/meshcore.db
|
||||||
|
|
||||||
|
# Radio connection
|
||||||
|
# Serial (USB)
|
||||||
|
MESHCORE_SERIAL_PORT: /dev/meshcore-radio
|
||||||
|
# MESHCORE_SERIAL_BAUDRATE: 115200
|
||||||
|
|
||||||
|
# TCP
|
||||||
|
# MESHCORE_TCP_HOST: 192.168.1.100
|
||||||
|
# MESHCORE_TCP_PORT: 4000
|
||||||
|
|
||||||
|
# BLE
|
||||||
|
# BLE in Docker usually needs additional manual compose changes such as
|
||||||
|
# Bluetooth device passthrough, privileged mode, host networking, or
|
||||||
|
# other host-specific tweaks before it will actually work.
|
||||||
|
# MESHCORE_BLE_ADDRESS: AA:BB:CC:DD:EE:FF
|
||||||
|
# MESHCORE_BLE_PIN: 123456
|
||||||
|
|
||||||
|
# Security
|
||||||
|
# MESHCORE_DISABLE_BOTS: "true"
|
||||||
|
# MESHCORE_BASIC_AUTH_USERNAME: changeme
|
||||||
|
# MESHCORE_BASIC_AUTH_PASSWORD: changeme
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
# MESHCORE_LOG_LEVEL: INFO
|
||||||
|
restart: unless-stopped
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
services:
|
|
||||||
remoteterm:
|
|
||||||
build: .
|
|
||||||
# image: jkingsman/remoteterm-meshcore:latest
|
|
||||||
|
|
||||||
# Optional on Linux: run container as your host user to avoid root-owned files in ./data
|
|
||||||
# user: "${UID:-1000}:${GID:-1000}"
|
|
||||||
ports:
|
|
||||||
- "8000:8000"
|
|
||||||
volumes:
|
|
||||||
- ./data:/app/data
|
|
||||||
################################################
|
|
||||||
# Set your serial device for passthrough here! #
|
|
||||||
################################################
|
|
||||||
devices:
|
|
||||||
- /dev/ttyACM0:/dev/ttyUSB0
|
|
||||||
|
|
||||||
environment:
|
|
||||||
MESHCORE_DATABASE_PATH: data/meshcore.db
|
|
||||||
# Radio connection -- optional if you map just a single serial device above, as the app will autodetect
|
|
||||||
# Serial (USB)
|
|
||||||
# MESHCORE_SERIAL_PORT: /dev/ttyUSB0
|
|
||||||
# MESHCORE_SERIAL_BAUDRATE: 115200
|
|
||||||
# TCP
|
|
||||||
# MESHCORE_TCP_HOST: 192.168.1.100
|
|
||||||
# MESHCORE_TCP_PORT: 4000
|
|
||||||
|
|
||||||
# Security
|
|
||||||
# MESHCORE_DISABLE_BOTS: "true"
|
|
||||||
# MESHCORE_BASIC_AUTH_USERNAME: changeme
|
|
||||||
# MESHCORE_BASIC_AUTH_PASSWORD: changeme
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
# MESHCORE_LOG_LEVEL: INFO
|
|
||||||
restart: unless-stopped
|
|
||||||
+50
-7
@@ -39,6 +39,8 @@ frontend/src/
|
|||||||
├── index.css # Global styles/utilities
|
├── index.css # Global styles/utilities
|
||||||
├── styles.css # Additional global app styles
|
├── styles.css # Additional global app styles
|
||||||
├── themes.css # Color theme definitions
|
├── themes.css # Color theme definitions
|
||||||
|
├── contexts/
|
||||||
|
│ └── DistanceUnitContext.tsx # Browser-local distance-unit context/provider
|
||||||
├── lib/
|
├── lib/
|
||||||
│ └── utils.ts # cn() — clsx + tailwind-merge helper
|
│ └── utils.ts # cn() — clsx + tailwind-merge helper
|
||||||
├── hooks/
|
├── hooks/
|
||||||
@@ -53,10 +55,14 @@ frontend/src/
|
|||||||
│ ├── useRadioControl.ts # Radio health/config state, reconnection, mesh discovery sweeps
|
│ ├── useRadioControl.ts # Radio health/config state, reconnection, mesh discovery sweeps
|
||||||
│ ├── useAppSettings.ts # Settings, favorites, preferences migration
|
│ ├── useAppSettings.ts # Settings, favorites, preferences migration
|
||||||
│ ├── useConversationRouter.ts # URL hash → active conversation routing
|
│ ├── useConversationRouter.ts # URL hash → active conversation routing
|
||||||
│ └── useContactsAndChannels.ts # Contact/channel loading, creation, deletion
|
│ ├── useContactsAndChannels.ts # Contact/channel loading, creation, deletion
|
||||||
|
│ ├── useBrowserNotifications.ts # Per-conversation browser notification preferences + dispatch
|
||||||
|
│ ├── useFaviconBadge.ts # Browser tab unread badge state
|
||||||
|
│ ├── useRawPacketStatsSession.ts # Session-scoped packet-feed stats history
|
||||||
|
│ └── useRememberedServerPassword.ts # Browser-local repeater/room password persistence
|
||||||
├── components/
|
├── components/
|
||||||
│ ├── AppShell.tsx # App-shell layout: status, sidebar, search/settings panes, cracker, modals
|
│ ├── AppShell.tsx # App-shell layout: status, sidebar, search/settings panes, cracker, modals, security warning
|
||||||
│ ├── ConversationPane.tsx # Active conversation surface selection (map/raw/repeater/chat/empty)
|
│ ├── ConversationPane.tsx # Active conversation surface selection (map/raw/trace/repeater/room/chat/empty)
|
||||||
│ ├── visualizer/
|
│ ├── visualizer/
|
||||||
│ │ ├── useVisualizerData3D.ts # Packet→graph data pipeline, repeat aggregation, simulation state
|
│ │ ├── useVisualizerData3D.ts # Packet→graph data pipeline, repeat aggregation, simulation state
|
||||||
│ │ ├── useVisualizer3DScene.ts # Three.js scene lifecycle, buffers, hover/pin interaction
|
│ │ ├── useVisualizer3DScene.ts # Three.js scene lifecycle, buffers, hover/pin interaction
|
||||||
@@ -73,14 +79,18 @@ frontend/src/
|
|||||||
│ ├── pubkey.ts # getContactDisplayName (12-char prefix fallback)
|
│ ├── pubkey.ts # getContactDisplayName (12-char prefix fallback)
|
||||||
│ ├── contactAvatar.ts # Avatar color derivation from public key
|
│ ├── contactAvatar.ts # Avatar color derivation from public key
|
||||||
│ ├── rawPacketIdentity.ts # observation_id vs id dedup helpers
|
│ ├── rawPacketIdentity.ts # observation_id vs id dedup helpers
|
||||||
|
│ ├── rawPacketStats.ts # Session packet stats windows, rankings, and coverage helpers
|
||||||
│ ├── regionScope.ts # Regional flood-scope label/normalization helpers
|
│ ├── regionScope.ts # Regional flood-scope label/normalization helpers
|
||||||
│ ├── visualizerUtils.ts # 3D visualizer node types, colors, particles
|
│ ├── visualizerUtils.ts # 3D visualizer node types, colors, particles
|
||||||
│ ├── visualizerSettings.ts # LocalStorage persistence for visualizer options
|
│ ├── visualizerSettings.ts # LocalStorage persistence for visualizer options
|
||||||
│ ├── a11y.ts # Keyboard accessibility helper
|
│ ├── a11y.ts # Keyboard accessibility helper
|
||||||
|
│ ├── distanceUnits.ts # Browser-local distance unit persistence/helpers
|
||||||
│ ├── lastViewedConversation.ts # localStorage for last-viewed conversation
|
│ ├── lastViewedConversation.ts # localStorage for last-viewed conversation
|
||||||
│ ├── contactMerge.ts # Merge WS contact updates into list
|
│ ├── contactMerge.ts # Merge WS contact updates into list
|
||||||
│ ├── localLabel.ts # Local label (text + color) in localStorage
|
│ ├── localLabel.ts # Local label (text + color) in localStorage
|
||||||
│ ├── radioPresets.ts # LoRa radio preset configurations
|
│ ├── radioPresets.ts # LoRa radio preset configurations
|
||||||
|
│ ├── publicChannel.ts # Public-channel resolution helpers for routing/hash defaults
|
||||||
|
│ ├── fontScale.ts # Browser-local relative font scale persistence/application
|
||||||
│ └── theme.ts # Theme switching helpers
|
│ └── theme.ts # Theme switching helpers
|
||||||
├── components/
|
├── components/
|
||||||
│ ├── StatusBar.tsx
|
│ ├── StatusBar.tsx
|
||||||
@@ -91,8 +101,12 @@ frontend/src/
|
|||||||
│ ├── NewMessageModal.tsx
|
│ ├── NewMessageModal.tsx
|
||||||
│ ├── SearchView.tsx # Full-text message search pane
|
│ ├── SearchView.tsx # Full-text message search pane
|
||||||
│ ├── SettingsModal.tsx # Layout shell — delegates to settings/ sections
|
│ ├── SettingsModal.tsx # Layout shell — delegates to settings/ sections
|
||||||
|
│ ├── SecurityWarningModal.tsx # Startup warning for trusted-network / bot execution posture
|
||||||
│ ├── RawPacketList.tsx
|
│ ├── RawPacketList.tsx
|
||||||
|
│ ├── RawPacketFeedView.tsx # Live raw packet feed + session stats drawer
|
||||||
|
│ ├── RawPacketDetailModal.tsx # On-demand packet inspector dialog
|
||||||
│ ├── MapView.tsx
|
│ ├── MapView.tsx
|
||||||
|
│ ├── TracePane.tsx # Multi-hop route trace builder/results view
|
||||||
│ ├── VisualizerView.tsx
|
│ ├── VisualizerView.tsx
|
||||||
│ ├── PacketVisualizer3D.tsx
|
│ ├── PacketVisualizer3D.tsx
|
||||||
│ ├── PathModal.tsx
|
│ ├── PathModal.tsx
|
||||||
@@ -102,15 +116,20 @@ frontend/src/
|
|||||||
│ ├── ContactAvatar.tsx
|
│ ├── ContactAvatar.tsx
|
||||||
│ ├── ContactInfoPane.tsx # Contact detail sheet (stats, name history, paths)
|
│ ├── ContactInfoPane.tsx # Contact detail sheet (stats, name history, paths)
|
||||||
│ ├── ContactStatusInfo.tsx # Contact status info component
|
│ ├── ContactStatusInfo.tsx # Contact status info component
|
||||||
|
│ ├── ContactPathDiscoveryModal.tsx # Forward/return path discovery dialog
|
||||||
|
│ ├── ContactRoutingOverrideModal.tsx # Manual direct-route override editor
|
||||||
│ ├── RepeaterDashboard.tsx # Layout shell — delegates to repeater/ panes
|
│ ├── RepeaterDashboard.tsx # Layout shell — delegates to repeater/ panes
|
||||||
│ ├── RepeaterLogin.tsx # Repeater login form (password + guest)
|
│ ├── RepeaterLogin.tsx # Repeater login form (password + guest)
|
||||||
|
│ ├── RoomServerPanel.tsx # Room-server auth gate + status banner ahead of room chat
|
||||||
|
│ ├── ServerLoginStatusBanner.tsx # Shared repeater/room login state banner
|
||||||
│ ├── ChannelInfoPane.tsx # Channel detail sheet (stats, top senders)
|
│ ├── ChannelInfoPane.tsx # Channel detail sheet (stats, top senders)
|
||||||
|
│ ├── ChannelFloodScopeOverrideModal.tsx # Per-channel flood-scope override editor
|
||||||
│ ├── DirectTraceIcon.tsx # Shared direct-trace glyph used in header/dashboard
|
│ ├── DirectTraceIcon.tsx # Shared direct-trace glyph used in header/dashboard
|
||||||
│ ├── NeighborsMiniMap.tsx # Leaflet mini-map for repeater neighbor locations
|
│ ├── NeighborsMiniMap.tsx # Leaflet mini-map for repeater neighbor locations
|
||||||
│ ├── settings/
|
│ ├── settings/
|
||||||
│ │ ├── settingsConstants.ts # Settings section type, ordering, labels
|
│ │ ├── settingsConstants.ts # Settings section type, ordering, labels
|
||||||
│ │ ├── SettingsRadioSection.tsx # Name, keys, advert interval, max contacts, radio preset, freq/bw/sf/cr, txPower, lat/lon, reboot, mesh discovery
|
│ │ ├── SettingsRadioSection.tsx # Name, keys, advert interval, max contacts, radio preset, freq/bw/sf/cr, txPower, lat/lon, reboot, mesh discovery
|
||||||
│ │ ├── SettingsLocalSection.tsx # Browser-local settings: theme, local label, reopen last conversation
|
│ │ ├── SettingsLocalSection.tsx # Browser-local settings: theme, relative font scale, local label, reopen last conversation
|
||||||
│ │ ├── SettingsFanoutSection.tsx # Fanout integrations: MQTT, bots, config CRUD
|
│ │ ├── SettingsFanoutSection.tsx # Fanout integrations: MQTT, bots, config CRUD
|
||||||
│ │ ├── SettingsDatabaseSection.tsx # DB size, cleanup, auto-decrypt, local label
|
│ │ ├── SettingsDatabaseSection.tsx # DB size, cleanup, auto-decrypt, local label
|
||||||
│ │ ├── SettingsStatisticsSection.tsx # Read-only mesh network stats
|
│ │ ├── SettingsStatisticsSection.tsx # Read-only mesh network stats
|
||||||
@@ -130,12 +149,13 @@ frontend/src/
|
|||||||
│ └── ui/ # shadcn/ui primitives
|
│ └── ui/ # shadcn/ui primitives
|
||||||
├── types/
|
├── types/
|
||||||
│ └── d3-force-3d.d.ts # Type declarations for d3-force-3d
|
│ └── d3-force-3d.d.ts # Type declarations for d3-force-3d
|
||||||
└── test/
|
└── test/ # Representative frontend test suites (not an exhaustive listing)
|
||||||
├── setup.ts
|
├── setup.ts
|
||||||
├── fixtures/websocket_events.json
|
├── fixtures/websocket_events.json
|
||||||
├── api.test.ts
|
├── api.test.ts
|
||||||
├── appFavorites.test.tsx
|
├── appFavorites.test.tsx
|
||||||
├── appStartupHash.test.tsx
|
├── appStartupHash.test.tsx
|
||||||
|
├── conversationPane.test.tsx
|
||||||
├── contactAvatar.test.ts
|
├── contactAvatar.test.ts
|
||||||
├── contactInfoPane.test.tsx
|
├── contactInfoPane.test.tsx
|
||||||
├── integration.test.ts
|
├── integration.test.ts
|
||||||
@@ -146,18 +166,23 @@ frontend/src/
|
|||||||
├── rawPacketList.test.tsx
|
├── rawPacketList.test.tsx
|
||||||
├── pathUtils.test.ts
|
├── pathUtils.test.ts
|
||||||
├── prefetch.test.ts
|
├── prefetch.test.ts
|
||||||
|
├── rawPacketDetailModal.test.tsx
|
||||||
|
├── rawPacketFeedView.test.tsx
|
||||||
├── radioPresets.test.ts
|
├── radioPresets.test.ts
|
||||||
├── rawPacketIdentity.test.ts
|
├── rawPacketIdentity.test.ts
|
||||||
├── repeaterDashboard.test.tsx
|
├── repeaterDashboard.test.tsx
|
||||||
├── repeaterFormatters.test.ts
|
├── repeaterFormatters.test.ts
|
||||||
├── repeaterLogin.test.tsx
|
├── repeaterLogin.test.tsx
|
||||||
├── repeaterMessageParsing.test.ts
|
├── repeaterMessageParsing.test.ts
|
||||||
|
├── roomServerPanel.test.tsx
|
||||||
|
├── securityWarningModal.test.tsx
|
||||||
├── localLabel.test.ts
|
├── localLabel.test.ts
|
||||||
├── messageInput.test.tsx
|
├── messageInput.test.tsx
|
||||||
├── newMessageModal.test.tsx
|
├── newMessageModal.test.tsx
|
||||||
├── settingsModal.test.tsx
|
├── settingsModal.test.tsx
|
||||||
├── sidebar.test.tsx
|
├── sidebar.test.tsx
|
||||||
├── statusBar.test.tsx
|
├── statusBar.test.tsx
|
||||||
|
├── tracePane.test.tsx
|
||||||
├── unreadCounts.test.ts
|
├── unreadCounts.test.ts
|
||||||
├── urlHash.test.ts
|
├── urlHash.test.ts
|
||||||
├── appSearchJump.test.tsx
|
├── appSearchJump.test.tsx
|
||||||
@@ -169,12 +194,17 @@ frontend/src/
|
|||||||
├── useConversationMessages.race.test.ts
|
├── useConversationMessages.race.test.ts
|
||||||
├── useConversationNavigation.test.ts
|
├── useConversationNavigation.test.ts
|
||||||
├── useAppShell.test.ts
|
├── useAppShell.test.ts
|
||||||
|
├── useBrowserNotifications.test.ts
|
||||||
|
├── useFaviconBadge.test.ts
|
||||||
├── useRepeaterDashboard.test.ts
|
├── useRepeaterDashboard.test.ts
|
||||||
|
├── useRememberedServerPassword.test.ts
|
||||||
├── useContactsAndChannels.test.ts
|
├── useContactsAndChannels.test.ts
|
||||||
├── useRealtimeAppState.test.ts
|
├── useRealtimeAppState.test.ts
|
||||||
├── useUnreadCounts.test.ts
|
├── useUnreadCounts.test.ts
|
||||||
├── useWebSocket.dispatch.test.ts
|
├── useWebSocket.dispatch.test.ts
|
||||||
├── useWebSocket.lifecycle.test.ts
|
├── useWebSocket.lifecycle.test.ts
|
||||||
|
├── rawPacketStats.test.ts
|
||||||
|
├── fontScale.test.ts
|
||||||
└── wsEvents.test.ts
|
└── wsEvents.test.ts
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -190,6 +220,7 @@ frontend/src/
|
|||||||
- search/settings surface switching
|
- search/settings surface switching
|
||||||
- global cracker mount/focus behavior
|
- global cracker mount/focus behavior
|
||||||
- new-message modal and info panes
|
- new-message modal and info panes
|
||||||
|
- trusted-network `SecurityWarningModal`
|
||||||
|
|
||||||
High-level state is delegated to hooks:
|
High-level state is delegated to hooks:
|
||||||
- `useAppShell`: app-shell view state (settings section, sidebar, cracker, new-message modal)
|
- `useAppShell`: app-shell view state (settings section, sidebar, cracker, new-message modal)
|
||||||
@@ -211,7 +242,9 @@ High-level state is delegated to hooks:
|
|||||||
- map view
|
- map view
|
||||||
- visualizer
|
- visualizer
|
||||||
- raw packet feed
|
- raw packet feed
|
||||||
|
- trace view
|
||||||
- repeater dashboard
|
- repeater dashboard
|
||||||
|
- room-server auth/status gate before room chat
|
||||||
- normal chat chrome (`ChatHeader` + `MessageList` + `MessageInput`)
|
- normal chat chrome (`ChatHeader` + `MessageList` + `MessageInput`)
|
||||||
|
|
||||||
### Initial load + realtime
|
### Initial load + realtime
|
||||||
@@ -272,12 +305,16 @@ Supported routes:
|
|||||||
- `#map/focus/{pubkey_or_prefix}`
|
- `#map/focus/{pubkey_or_prefix}`
|
||||||
- `#visualizer`
|
- `#visualizer`
|
||||||
- `#search`
|
- `#search`
|
||||||
|
- `#trace`
|
||||||
|
- `#settings/{section}`
|
||||||
- `#channel/{channelKey}`
|
- `#channel/{channelKey}`
|
||||||
- `#channel/{channelKey}/{label}`
|
- `#channel/{channelKey}/{label}`
|
||||||
- `#contact/{publicKey}`
|
- `#contact/{publicKey}`
|
||||||
- `#contact/{publicKey}/{label}`
|
- `#contact/{publicKey}/{label}`
|
||||||
|
|
||||||
Legacy name-based hashes are still accepted for compatibility.
|
Where `{section}` is one of `radio`, `local`, `fanout`, `database`, `statistics`, or `about`.
|
||||||
|
|
||||||
|
Legacy name-based channel/contact hashes are still accepted for compatibility.
|
||||||
|
|
||||||
## Conversation State Keys (`utils/conversationState.ts`)
|
## Conversation State Keys (`utils/conversationState.ts`)
|
||||||
|
|
||||||
@@ -377,6 +414,12 @@ For repeater contacts (`type=2`), `ConversationPane.tsx` renders `RepeaterDashbo
|
|||||||
|
|
||||||
All state is managed by `useRepeaterDashboard` hook. State resets on conversation change.
|
All state is managed by `useRepeaterDashboard` hook. State resets on conversation change.
|
||||||
|
|
||||||
|
## Room Server Panel
|
||||||
|
|
||||||
|
For room contacts (`type=3`), `ConversationPane.tsx` keeps the normal chat surface but inserts `RoomServerPanel` above it. That panel handles room-server login/status messaging and gates room chat behind the room-authenticated state when required.
|
||||||
|
|
||||||
|
`ServerLoginStatusBanner` is shared between repeater and room login surfaces for inline status/error display.
|
||||||
|
|
||||||
## Message Search Pane
|
## Message Search Pane
|
||||||
|
|
||||||
The `SearchView` component (`components/SearchView.tsx`) provides full-text search across all DMs and channel messages. Key behaviors:
|
The `SearchView` component (`components/SearchView.tsx`) provides full-text search across all DMs and channel messages. Key behaviors:
|
||||||
@@ -404,7 +447,7 @@ Do not rely on old class-only layout assumptions.
|
|||||||
Run all quality checks (backend + frontend) from the repo root:
|
Run all quality checks (backend + frontend) from the repo root:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/all_quality.sh
|
./scripts/quality/all_quality.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Or run frontend checks individually:
|
Or run frontend checks individually:
|
||||||
|
|||||||
Generated
+384
-3
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "remoteterm-meshcore-frontend",
|
"name": "remoteterm-meshcore-frontend",
|
||||||
"version": "3.6.0",
|
"version": "3.6.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "remoteterm-meshcore-frontend",
|
"name": "remoteterm-meshcore-frontend",
|
||||||
"version": "3.6.0",
|
"version": "3.6.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-python": "^6.2.1",
|
"@codemirror/lang-python": "^6.2.1",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
"react-swipeable": "^7.0.2",
|
"react-swipeable": "^7.0.2",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
@@ -2057,6 +2058,42 @@
|
|||||||
"react-dom": "^18.0.0"
|
"react-dom": "^18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit": {
|
||||||
|
"version": "2.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||||
|
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"@standard-schema/utils": "^0.3.0",
|
||||||
|
"immer": "^11.0.0",
|
||||||
|
"redux": "^5.0.1",
|
||||||
|
"redux-thunk": "^3.1.0",
|
||||||
|
"reselect": "^5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||||
|
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||||
|
"version": "11.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
|
||||||
|
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
@@ -2414,6 +2451,18 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/spec": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@standard-schema/utils": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@testing-library/dom": {
|
"node_modules/@testing-library/dom": {
|
||||||
"version": "10.4.1",
|
"version": "10.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||||
@@ -2564,6 +2613,24 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@babel/types": "^7.28.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-array": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/d3-force": {
|
"node_modules/@types/d3-force": {
|
||||||
"version": "3.0.10",
|
"version": "3.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
|
||||||
@@ -2571,6 +2638,51 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-shape": {
|
||||||
|
"version": "3.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||||
|
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -2663,6 +2775,12 @@
|
|||||||
"meshoptimizer": "~0.22.0"
|
"meshoptimizer": "~0.22.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/webxr": {
|
"node_modules/@types/webxr": {
|
||||||
"version": "0.5.24",
|
"version": "0.5.24",
|
||||||
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||||
@@ -3712,12 +3830,33 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/d3-binarytree": {
|
"node_modules/d3-binarytree": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz",
|
||||||
"integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==",
|
"integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/d3-dispatch": {
|
"node_modules/d3-dispatch": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||||
@@ -3727,6 +3866,15 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/d3-force": {
|
"node_modules/d3-force": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
|
||||||
@@ -3757,12 +3905,42 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/d3-octree": {
|
"node_modules/d3-octree": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz",
|
||||||
"integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==",
|
"integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/d3-quadtree": {
|
"node_modules/d3-quadtree": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
|
||||||
@@ -3772,6 +3950,58 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/d3-timer": {
|
"node_modules/d3-timer": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
@@ -3820,6 +4050,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/decimal.js-light": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/deep-eql": {
|
"node_modules/deep-eql": {
|
||||||
"version": "5.0.2",
|
"version": "5.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
||||||
@@ -3974,6 +4210,16 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-toolkit": {
|
||||||
|
"version": "1.45.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
|
||||||
|
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"docs",
|
||||||
|
"benchmarks"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||||
@@ -4216,6 +4462,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/expect-type": {
|
"node_modules/expect-type": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||||
@@ -4618,6 +4870,16 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "10.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||||
|
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -4655,6 +4917,15 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-binary-path": {
|
"node_modules/is-binary-path": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||||
@@ -5599,7 +5870,6 @@
|
|||||||
"version": "17.0.2",
|
"version": "17.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
@@ -5617,6 +5887,29 @@
|
|||||||
"react-dom": "^18.0.0"
|
"react-dom": "^18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-redux": {
|
||||||
|
"version": "9.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^18.2.25 || ^19",
|
||||||
|
"react": "^18.0 || ^19",
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||||
@@ -5726,6 +6019,36 @@
|
|||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/recharts": {
|
||||||
|
"version": "3.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
|
||||||
|
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"www"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"decimal.js-light": "^2.5.1",
|
||||||
|
"es-toolkit": "^1.39.3",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"immer": "^10.1.1",
|
||||||
|
"react-redux": "8.x.x || 9.x.x",
|
||||||
|
"reselect": "5.1.1",
|
||||||
|
"tiny-invariant": "^1.3.3",
|
||||||
|
"use-sync-external-store": "^1.2.2",
|
||||||
|
"victory-vendor": "^37.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/redent": {
|
"node_modules/redent": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||||
@@ -5740,6 +6063,27 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/redux-thunk": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/reselect": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
@@ -6134,6 +6478,12 @@
|
|||||||
"integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==",
|
"integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-invariant": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinybench": {
|
"node_modules/tinybench": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
@@ -6448,12 +6798,43 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/victory-vendor": {
|
||||||
|
"version": "37.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||||
|
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||||
|
"license": "MIT AND ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-array": "^3.0.3",
|
||||||
|
"@types/d3-ease": "^3.0.0",
|
||||||
|
"@types/d3-interpolate": "^3.0.1",
|
||||||
|
"@types/d3-scale": "^4.0.2",
|
||||||
|
"@types/d3-shape": "^3.1.0",
|
||||||
|
"@types/d3-time": "^3.0.0",
|
||||||
|
"@types/d3-timer": "^3.0.0",
|
||||||
|
"d3-array": "^3.1.6",
|
||||||
|
"d3-ease": "^3.0.1",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.1.0",
|
||||||
|
"d3-time": "^3.0.0",
|
||||||
|
"d3-timer": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.4.1",
|
"version": "6.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "remoteterm-meshcore-frontend",
|
"name": "remoteterm-meshcore-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "3.6.0",
|
"version": "3.6.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -38,6 +38,7 @@
|
|||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
"react-swipeable": "^7.0.2",
|
"react-swipeable": "^7.0.2",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
|||||||
+61
-4
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useCallback, useRef, useState } from 'react';
|
import { useEffect, useCallback, useRef, useState, useMemo } from 'react';
|
||||||
import { api } from './api';
|
import { api } from './api';
|
||||||
import { takePrefetchOrFetch } from './prefetch';
|
import { takePrefetchOrFetch } from './prefetch';
|
||||||
import { useWebSocket } from './useWebSocket';
|
import { useWebSocket } from './useWebSocket';
|
||||||
@@ -24,12 +24,19 @@ import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
|
|||||||
import { messageContainsMention } from './utils/messageParser';
|
import { messageContainsMention } from './utils/messageParser';
|
||||||
import { getStateKey } from './utils/conversationState';
|
import { getStateKey } from './utils/conversationState';
|
||||||
import type { Conversation, Message, RawPacket } from './types';
|
import type { Conversation, Message, RawPacket } from './types';
|
||||||
|
import { CONTACT_TYPE_ROOM } from './types';
|
||||||
|
|
||||||
interface ChannelUnreadMarker {
|
interface ChannelUnreadMarker {
|
||||||
channelId: string;
|
channelId: string;
|
||||||
lastReadAt: number | null;
|
lastReadAt: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NewMessagePrefillRequest {
|
||||||
|
tab: 'hashtag';
|
||||||
|
hashtagName: string;
|
||||||
|
nonce: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface UnreadBoundaryBackfillParams {
|
interface UnreadBoundaryBackfillParams {
|
||||||
activeConversation: Conversation | null;
|
activeConversation: Conversation | null;
|
||||||
unreadMarker: ChannelUnreadMarker | null;
|
unreadMarker: ChannelUnreadMarker | null;
|
||||||
@@ -76,6 +83,8 @@ export function App() {
|
|||||||
const messageInputRef = useRef<MessageInputHandle>(null);
|
const messageInputRef = useRef<MessageInputHandle>(null);
|
||||||
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
|
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
|
||||||
const [channelUnreadMarker, setChannelUnreadMarker] = useState<ChannelUnreadMarker | null>(null);
|
const [channelUnreadMarker, setChannelUnreadMarker] = useState<ChannelUnreadMarker | null>(null);
|
||||||
|
const [newMessagePrefillRequest, setNewMessagePrefillRequest] =
|
||||||
|
useState<NewMessagePrefillRequest | null>(null);
|
||||||
const [visibilityVersion, setVisibilityVersion] = useState(0);
|
const [visibilityVersion, setVisibilityVersion] = useState(0);
|
||||||
const lastUnreadBackfillAttemptRef = useRef<string | null>(null);
|
const lastUnreadBackfillAttemptRef = useRef<string | null>(null);
|
||||||
const {
|
const {
|
||||||
@@ -102,8 +111,8 @@ export function App() {
|
|||||||
setDistanceUnit,
|
setDistanceUnit,
|
||||||
handleCloseSettingsView,
|
handleCloseSettingsView,
|
||||||
handleToggleSettingsView,
|
handleToggleSettingsView,
|
||||||
handleOpenNewMessage,
|
handleOpenNewMessage: openNewMessageModal,
|
||||||
handleCloseNewMessage,
|
handleCloseNewMessage: closeNewMessageModal,
|
||||||
handleToggleCracker,
|
handleToggleCracker,
|
||||||
} = useAppShell();
|
} = useAppShell();
|
||||||
|
|
||||||
@@ -251,6 +260,21 @@ export function App() {
|
|||||||
} = useConversationMessages(activeConversation, targetMessageId);
|
} = useConversationMessages(activeConversation, targetMessageId);
|
||||||
removeConversationMessagesRef.current = removeConversationMessages;
|
removeConversationMessagesRef.current = removeConversationMessages;
|
||||||
|
|
||||||
|
// Room servers replay stored history as a burst of DMs, all arriving with similar received_at
|
||||||
|
// but spanning a wide range of sender_timestamps. Sort by sender_timestamp for room contacts
|
||||||
|
// so the display reflects the original send order rather than our radio's receipt order.
|
||||||
|
const activeContactIsRoom =
|
||||||
|
activeConversation?.type === 'contact' &&
|
||||||
|
contacts.find((c) => c.public_key === activeConversation.id)?.type === CONTACT_TYPE_ROOM;
|
||||||
|
const sortedMessages = useMemo(() => {
|
||||||
|
if (!activeContactIsRoom || messages.length === 0) return messages;
|
||||||
|
return [...messages].sort((a, b) => {
|
||||||
|
const aTs = a.sender_timestamp ?? a.received_at;
|
||||||
|
const bTs = b.sender_timestamp ?? b.received_at;
|
||||||
|
return aTs !== bTs ? aTs - bTs : a.id - b.id;
|
||||||
|
});
|
||||||
|
}, [activeContactIsRoom, messages]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
unreadCounts,
|
unreadCounts,
|
||||||
mentions,
|
mentions,
|
||||||
@@ -258,6 +282,7 @@ export function App() {
|
|||||||
unreadLastReadAts,
|
unreadLastReadAts,
|
||||||
recordMessageEvent,
|
recordMessageEvent,
|
||||||
renameConversationState,
|
renameConversationState,
|
||||||
|
removeConversationState,
|
||||||
markAllRead,
|
markAllRead,
|
||||||
refreshUnreads,
|
refreshUnreads,
|
||||||
} = useUnreadCounts(channels, contacts, activeConversation);
|
} = useUnreadCounts(channels, contacts, activeConversation);
|
||||||
@@ -333,6 +358,7 @@ export function App() {
|
|||||||
observeMessage,
|
observeMessage,
|
||||||
recordMessageEvent,
|
recordMessageEvent,
|
||||||
renameConversationState,
|
renameConversationState,
|
||||||
|
removeConversationState,
|
||||||
checkMention,
|
checkMention,
|
||||||
pendingDeleteFallbackRef,
|
pendingDeleteFallbackRef,
|
||||||
setActiveConversation,
|
setActiveConversation,
|
||||||
@@ -395,6 +421,34 @@ export function App() {
|
|||||||
[fetchUndecryptedCount, setChannels]
|
[fetchUndecryptedCount, setChannels]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleOpenNewMessage = useCallback(() => {
|
||||||
|
setNewMessagePrefillRequest(null);
|
||||||
|
openNewMessageModal();
|
||||||
|
}, [openNewMessageModal]);
|
||||||
|
|
||||||
|
const handleCloseNewMessage = useCallback(() => {
|
||||||
|
setNewMessagePrefillRequest(null);
|
||||||
|
closeNewMessageModal();
|
||||||
|
}, [closeNewMessageModal]);
|
||||||
|
|
||||||
|
const handleChannelReferenceClick = useCallback(
|
||||||
|
(channelName: string) => {
|
||||||
|
const existingChannel = channels.find((channel) => channel.name === channelName);
|
||||||
|
if (existingChannel) {
|
||||||
|
handleNavigateToChannel(existingChannel.key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setNewMessagePrefillRequest((previous) => ({
|
||||||
|
tab: 'hashtag',
|
||||||
|
hashtagName: channelName.slice(1),
|
||||||
|
nonce: (previous?.nonce ?? 0) + 1,
|
||||||
|
}));
|
||||||
|
openNewMessageModal();
|
||||||
|
},
|
||||||
|
[channels, handleNavigateToChannel, openNewMessageModal]
|
||||||
|
);
|
||||||
|
|
||||||
const statusProps = {
|
const statusProps = {
|
||||||
health,
|
health,
|
||||||
config,
|
config,
|
||||||
@@ -427,7 +481,7 @@ export function App() {
|
|||||||
config,
|
config,
|
||||||
health,
|
health,
|
||||||
favorites,
|
favorites,
|
||||||
messages,
|
messages: sortedMessages,
|
||||||
messagesLoading,
|
messagesLoading,
|
||||||
loadingOlder,
|
loadingOlder,
|
||||||
hasOlderMessages,
|
hasOlderMessages,
|
||||||
@@ -441,6 +495,7 @@ export function App() {
|
|||||||
loadingNewer,
|
loadingNewer,
|
||||||
messageInputRef,
|
messageInputRef,
|
||||||
onTrace: handleTrace,
|
onTrace: handleTrace,
|
||||||
|
onRunTracePath: api.requestRadioTrace,
|
||||||
onPathDiscovery: handlePathDiscovery,
|
onPathDiscovery: handlePathDiscovery,
|
||||||
onToggleFavorite: handleToggleFavorite,
|
onToggleFavorite: handleToggleFavorite,
|
||||||
onDeleteContact: handleDeleteContact,
|
onDeleteContact: handleDeleteContact,
|
||||||
@@ -449,6 +504,7 @@ export function App() {
|
|||||||
onOpenContactInfo: handleOpenContactInfo,
|
onOpenContactInfo: handleOpenContactInfo,
|
||||||
onOpenChannelInfo: handleOpenChannelInfo,
|
onOpenChannelInfo: handleOpenChannelInfo,
|
||||||
onSenderClick: handleSenderClick,
|
onSenderClick: handleSenderClick,
|
||||||
|
onChannelReferenceClick: handleChannelReferenceClick,
|
||||||
onLoadOlder: fetchOlderMessages,
|
onLoadOlder: fetchOlderMessages,
|
||||||
onResendChannelMessage: handleResendChannelMessage,
|
onResendChannelMessage: handleResendChannelMessage,
|
||||||
onTargetReached: () => setTargetMessageId(null),
|
onTargetReached: () => setTargetMessageId(null),
|
||||||
@@ -507,6 +563,7 @@ export function App() {
|
|||||||
};
|
};
|
||||||
const newMessageModalProps = {
|
const newMessageModalProps = {
|
||||||
undecryptedCount,
|
undecryptedCount,
|
||||||
|
prefillRequest: newMessagePrefillRequest,
|
||||||
onCreateContact: handleCreateContact,
|
onCreateContact: handleCreateContact,
|
||||||
onCreateChannel: handleCreateChannel,
|
onCreateChannel: handleCreateChannel,
|
||||||
onCreateHashtagChannel: handleCreateHashtagChannel,
|
onCreateHashtagChannel: handleCreateHashtagChannel,
|
||||||
|
|||||||
+11
-2
@@ -20,6 +20,8 @@ import type {
|
|||||||
RadioConfig,
|
RadioConfig,
|
||||||
RadioConfigUpdate,
|
RadioConfigUpdate,
|
||||||
RadioDiscoveryResponse,
|
RadioDiscoveryResponse,
|
||||||
|
RadioTraceHopRequest,
|
||||||
|
RadioTraceResponse,
|
||||||
RadioDiscoveryTarget,
|
RadioDiscoveryTarget,
|
||||||
PathDiscoveryResponse,
|
PathDiscoveryResponse,
|
||||||
ResendChannelMessageResponse,
|
ResendChannelMessageResponse,
|
||||||
@@ -107,6 +109,11 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ target }),
|
body: JSON.stringify({ target }),
|
||||||
}),
|
}),
|
||||||
|
requestRadioTrace: (hopHashBytes: 1 | 2 | 4, hops: RadioTraceHopRequest[]) =>
|
||||||
|
fetchJson<RadioTraceResponse>('/radio/trace', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ hop_hash_bytes: hopHashBytes, hops }),
|
||||||
|
}),
|
||||||
rebootRadio: () =>
|
rebootRadio: () =>
|
||||||
fetchJson<{ status: string; message: string }>('/radio/reboot', {
|
fetchJson<{ status: string; message: string }>('/radio/reboot', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -130,11 +137,13 @@ export const api = {
|
|||||||
fetchJson<ContactAdvertPathSummary[]>(
|
fetchJson<ContactAdvertPathSummary[]>(
|
||||||
`/contacts/repeaters/advert-paths?limit_per_repeater=${limitPerRepeater}`
|
`/contacts/repeaters/advert-paths?limit_per_repeater=${limitPerRepeater}`
|
||||||
),
|
),
|
||||||
getContactAnalytics: (params: { publicKey?: string; name?: string }) => {
|
getContactAnalytics: (params: { publicKey?: string; name?: string }, signal?: AbortSignal) => {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
if (params.publicKey) searchParams.set('public_key', params.publicKey);
|
if (params.publicKey) searchParams.set('public_key', params.publicKey);
|
||||||
if (params.name) searchParams.set('name', params.name);
|
if (params.name) searchParams.set('name', params.name);
|
||||||
return fetchJson<ContactAnalytics>(`/contacts/analytics?${searchParams.toString()}`);
|
return fetchJson<ContactAnalytics>(`/contacts/analytics?${searchParams.toString()}`, {
|
||||||
|
signal,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
deleteContact: (publicKey: string) =>
|
deleteContact: (publicKey: string) =>
|
||||||
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
|
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
|
||||||
|
|||||||
@@ -268,7 +268,7 @@ export function ChatHeader({
|
|||||||
title={
|
title={
|
||||||
activeContactIsPrefixOnly
|
activeContactIsPrefixOnly
|
||||||
? 'Direct Trace unavailable until the full contact key is known'
|
? 'Direct Trace unavailable until the full contact key is known'
|
||||||
: 'Direct Trace. Send a zero-hop packet to this contact and display out and back SNR'
|
: 'Direct Trace. Send a direct trace probe to this contact and display out and back SNR'
|
||||||
}
|
}
|
||||||
aria-label="Direct Trace"
|
aria-label="Direct Trace"
|
||||||
disabled={activeContactIsPrefixOnly}
|
disabled={activeContactIsPrefixOnly}
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import { type ReactNode, useEffect, useState } from 'react';
|
import { type ReactNode, useEffect, useState } from 'react';
|
||||||
import { Ban, Search, Star } from 'lucide-react';
|
import { Ban, Search, Star } from 'lucide-react';
|
||||||
import { api } from '../api';
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip as RechartsTooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Legend,
|
||||||
|
} from 'recharts';
|
||||||
|
import { api, isAbortError } from '../api';
|
||||||
import { formatTime } from '../utils/messageParser';
|
import { formatTime } from '../utils/messageParser';
|
||||||
import {
|
import {
|
||||||
getContactDisplayName,
|
getContactDisplayName,
|
||||||
@@ -100,29 +110,29 @@ export function ContactInfoPane({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
const controller = new AbortController();
|
||||||
setAnalytics(null);
|
setAnalytics(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const request =
|
const request =
|
||||||
isNameOnly && nameOnlyValue
|
isNameOnly && nameOnlyValue
|
||||||
? api.getContactAnalytics({ name: nameOnlyValue })
|
? api.getContactAnalytics({ name: nameOnlyValue }, controller.signal)
|
||||||
: api.getContactAnalytics({ publicKey: contactKey });
|
: api.getContactAnalytics({ publicKey: contactKey }, controller.signal);
|
||||||
|
|
||||||
request
|
request
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (!cancelled) setAnalytics(data);
|
if (!controller.signal.aborted) setAnalytics(data);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (!cancelled) {
|
if (!isAbortError(err)) {
|
||||||
console.error('Failed to fetch contact analytics:', err);
|
console.error('Failed to fetch contact analytics:', err);
|
||||||
toast.error('Failed to load contact info');
|
toast.error('Failed to load contact info');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setLoading(false);
|
if (!controller.signal.aborted) setLoading(false);
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
controller.abort();
|
||||||
};
|
};
|
||||||
}, [contactKey, isNameOnly, nameOnlyValue]);
|
}, [contactKey, isNameOnly, nameOnlyValue]);
|
||||||
|
|
||||||
@@ -650,20 +660,18 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu
|
|||||||
{hasHourlyActivity && (
|
{hasHourlyActivity && (
|
||||||
<div>
|
<div>
|
||||||
<SectionLabel>Messages Per Hour</SectionLabel>
|
<SectionLabel>Messages Per Hour</SectionLabel>
|
||||||
<ChartLegend
|
|
||||||
items={[
|
|
||||||
{ label: 'Last 24h', color: '#2563eb' },
|
|
||||||
{ label: '7-day avg', color: '#ea580c' },
|
|
||||||
{ label: 'All-time avg', color: '#64748b' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<ActivityLineChart
|
<ActivityLineChart
|
||||||
ariaLabel="Messages per hour"
|
ariaLabel="Messages per hour"
|
||||||
points={analytics.hourly_activity}
|
points={analytics.hourly_activity}
|
||||||
series={[
|
series={[
|
||||||
{ key: 'last_24h_count', color: '#2563eb' },
|
{ key: 'last_24h_count', color: '#2563eb', label: 'Last 24h' },
|
||||||
{ key: 'last_week_average', color: '#ea580c' },
|
{ key: 'last_week_average', color: '#ea580c', label: '7-day avg' },
|
||||||
{ key: 'all_time_average', color: '#64748b' },
|
{ key: 'all_time_average', color: '#64748b', label: 'All-time avg' },
|
||||||
|
]}
|
||||||
|
legendItems={[
|
||||||
|
{ label: 'Last 24h', color: '#2563eb' },
|
||||||
|
{ label: '7-day avg', color: '#ea580c' },
|
||||||
|
{ label: 'All-time avg', color: '#64748b' },
|
||||||
]}
|
]}
|
||||||
valueFormatter={(value) => value.toFixed(value % 1 === 0 ? 0 : 1)}
|
valueFormatter={(value) => value.toFixed(value % 1 === 0 ? 0 : 1)}
|
||||||
tickFormatter={(bucket) =>
|
tickFormatter={(bucket) =>
|
||||||
@@ -683,7 +691,7 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu
|
|||||||
<ActivityLineChart
|
<ActivityLineChart
|
||||||
ariaLabel="Messages per week"
|
ariaLabel="Messages per week"
|
||||||
points={analytics.weekly_activity}
|
points={analytics.weekly_activity}
|
||||||
series={[{ key: 'message_count', color: '#16a34a' }]}
|
series={[{ key: 'message_count', color: '#16a34a', label: 'Messages' }]}
|
||||||
valueFormatter={(value) => value.toFixed(0)}
|
valueFormatter={(value) => value.toFixed(0)}
|
||||||
tickFormatter={(bucket) =>
|
tickFormatter={(bucket) =>
|
||||||
new Date(bucket.bucket_start * 1000).toLocaleDateString([], {
|
new Date(bucket.bucket_start * 1000).toLocaleDateString([], {
|
||||||
@@ -705,133 +713,115 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChartLegend({ items }: { items: Array<{ label: string; color: string }> }) {
|
const TOOLTIP_STYLE = {
|
||||||
return (
|
contentStyle: {
|
||||||
<div className="flex flex-wrap gap-x-3 gap-y-1 mb-2 text-[11px] text-muted-foreground">
|
backgroundColor: 'hsl(var(--popover))',
|
||||||
{items.map((item) => (
|
border: '1px solid hsl(var(--border))',
|
||||||
<span key={item.label} className="inline-flex items-center gap-1.5">
|
borderRadius: '6px',
|
||||||
<span
|
fontSize: '11px',
|
||||||
className="inline-block h-2 w-2 rounded-full"
|
color: 'hsl(var(--popover-foreground))',
|
||||||
style={{ backgroundColor: item.color }}
|
},
|
||||||
aria-hidden="true"
|
itemStyle: { color: 'hsl(var(--popover-foreground))' },
|
||||||
/>
|
labelStyle: { color: 'hsl(var(--muted-foreground))' },
|
||||||
{item.label}
|
} as const;
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActivityLineChart<T extends ContactAnalyticsHourlyBucket | ContactAnalyticsWeeklyBucket>({
|
function ActivityLineChart<T extends ContactAnalyticsHourlyBucket | ContactAnalyticsWeeklyBucket>({
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
points,
|
points,
|
||||||
series,
|
series,
|
||||||
|
legendItems,
|
||||||
tickFormatter,
|
tickFormatter,
|
||||||
valueFormatter,
|
valueFormatter,
|
||||||
}: {
|
}: {
|
||||||
ariaLabel: string;
|
ariaLabel: string;
|
||||||
points: T[];
|
points: T[];
|
||||||
series: Array<{ key: keyof T; color: string }>;
|
series: Array<{ key: keyof T; color: string; label?: string }>;
|
||||||
|
legendItems?: Array<{ label: string; color: string }>;
|
||||||
tickFormatter: (point: T) => string;
|
tickFormatter: (point: T) => string;
|
||||||
valueFormatter: (value: number) => string;
|
valueFormatter: (value: number) => string;
|
||||||
}) {
|
}) {
|
||||||
const width = 320;
|
const data = points.map((point, i) => {
|
||||||
const height = 132;
|
const entry: Record<string, string | number> = { idx: i, tick: tickFormatter(point) };
|
||||||
const padding = { top: 8, right: 8, bottom: 24, left: 32 };
|
for (const s of series) {
|
||||||
const plotWidth = width - padding.left - padding.right;
|
const raw = point[s.key];
|
||||||
const plotHeight = height - padding.top - padding.bottom;
|
entry[String(s.key)] = typeof raw === 'number' ? raw : 0;
|
||||||
const allValues = points.flatMap((point) =>
|
}
|
||||||
series.map((entry) => {
|
return entry;
|
||||||
const value = point[entry.key];
|
});
|
||||||
return typeof value === 'number' ? value : 0;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const maxValue = Math.max(1, ...allValues);
|
|
||||||
const tickIndices = Array.from(
|
|
||||||
new Set([
|
|
||||||
0,
|
|
||||||
Math.floor((points.length - 1) / 3),
|
|
||||||
Math.floor(((points.length - 1) * 2) / 3),
|
|
||||||
points.length - 1,
|
|
||||||
])
|
|
||||||
);
|
|
||||||
|
|
||||||
const buildPolyline = (key: keyof T) =>
|
const tickCount = Math.min(5, points.length);
|
||||||
points
|
const tickIndices: number[] = [];
|
||||||
.map((point, index) => {
|
if (points.length > 1) {
|
||||||
const rawValue = point[key];
|
for (let i = 0; i < tickCount; i++) {
|
||||||
const value = typeof rawValue === 'number' ? rawValue : 0;
|
tickIndices.push(Math.round((i / (tickCount - 1)) * (points.length - 1)));
|
||||||
const x =
|
}
|
||||||
padding.left + (points.length === 1 ? 0 : (index / (points.length - 1)) * plotWidth);
|
}
|
||||||
const y = padding.top + plotHeight - (value / maxValue) * plotHeight;
|
|
||||||
return `${x},${y}`;
|
|
||||||
})
|
|
||||||
.join(' ');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div role="img" aria-label={ariaLabel}>
|
||||||
<svg
|
<ResponsiveContainer width="100%" height={140}>
|
||||||
viewBox={`0 0 ${width} ${height}`}
|
<LineChart data={data} margin={{ top: 4, right: 4, bottom: 0, left: -16 }}>
|
||||||
className="w-full h-auto"
|
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
||||||
role="img"
|
<XAxis
|
||||||
aria-label={ariaLabel}
|
dataKey="idx"
|
||||||
>
|
type="number"
|
||||||
{[0, 0.5, 1].map((ratio) => {
|
domain={[0, Math.max(1, points.length - 1)]}
|
||||||
const y = padding.top + plotHeight - ratio * plotHeight;
|
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
const value = maxValue * ratio;
|
tickLine={false}
|
||||||
return (
|
axisLine={false}
|
||||||
<g key={ratio}>
|
ticks={tickIndices}
|
||||||
<line
|
tickFormatter={(idx) => String(data[idx]?.tick ?? '')}
|
||||||
x1={padding.left}
|
|
||||||
x2={width - padding.right}
|
|
||||||
y1={y}
|
|
||||||
y2={y}
|
|
||||||
stroke="hsl(var(--border))"
|
|
||||||
strokeWidth="1"
|
|
||||||
/>
|
|
||||||
<text
|
|
||||||
x={padding.left - 6}
|
|
||||||
y={y + 4}
|
|
||||||
fontSize="10"
|
|
||||||
textAnchor="end"
|
|
||||||
fill="hsl(var(--muted-foreground))"
|
|
||||||
>
|
|
||||||
{valueFormatter(value)}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{series.map((entry) => (
|
|
||||||
<polyline
|
|
||||||
key={String(entry.key)}
|
|
||||||
fill="none"
|
|
||||||
stroke={entry.color}
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeLinecap="round"
|
|
||||||
points={buildPolyline(entry.key)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
<YAxis
|
||||||
|
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
{tickIndices.map((index) => {
|
tickLine={false}
|
||||||
const point = points[index];
|
axisLine={false}
|
||||||
const x =
|
tickFormatter={(v) => valueFormatter(v)}
|
||||||
padding.left + (points.length === 1 ? 0 : (index / (points.length - 1)) * plotWidth);
|
width={40}
|
||||||
return (
|
/>
|
||||||
<text
|
<RechartsTooltip
|
||||||
key={`${ariaLabel}-${point.bucket_start}`}
|
{...TOOLTIP_STYLE}
|
||||||
x={x}
|
cursor={{
|
||||||
y={height - 6}
|
stroke: 'hsl(var(--muted-foreground))',
|
||||||
fontSize="10"
|
strokeWidth: 1,
|
||||||
textAnchor={index === 0 ? 'start' : index === points.length - 1 ? 'end' : 'middle'}
|
strokeDasharray: '3 3',
|
||||||
fill="hsl(var(--muted-foreground))"
|
}}
|
||||||
>
|
labelFormatter={(idx) => String(data[Number(idx)]?.tick ?? '')}
|
||||||
{tickFormatter(point)}
|
formatter={(value, name) => {
|
||||||
</text>
|
const match = series.find((s) => String(s.key) === name);
|
||||||
);
|
return [valueFormatter(Number(value)), match?.label ?? String(name)];
|
||||||
})}
|
}}
|
||||||
</svg>
|
/>
|
||||||
|
{legendItems && (
|
||||||
|
<Legend
|
||||||
|
content={() => (
|
||||||
|
<div className="flex flex-wrap justify-center gap-x-3 gap-y-1 mt-1 text-[11px] text-muted-foreground">
|
||||||
|
{legendItems.map((item) => (
|
||||||
|
<span key={item.label} className="inline-flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="inline-block h-2 w-2 rounded-full"
|
||||||
|
style={{ backgroundColor: item.color }}
|
||||||
|
/>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{series.map((entry) => (
|
||||||
|
<Line
|
||||||
|
key={String(entry.key)}
|
||||||
|
type="linear"
|
||||||
|
dataKey={String(entry.key)}
|
||||||
|
stroke={entry.color}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 4, strokeWidth: 2, stroke: 'hsl(var(--popover))' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { MessageInput, type MessageInputHandle } from './MessageInput';
|
|||||||
import { MessageList } from './MessageList';
|
import { MessageList } from './MessageList';
|
||||||
import { RawPacketFeedView } from './RawPacketFeedView';
|
import { RawPacketFeedView } from './RawPacketFeedView';
|
||||||
import { RoomServerPanel } from './RoomServerPanel';
|
import { RoomServerPanel } from './RoomServerPanel';
|
||||||
|
import { TracePane } from './TracePane';
|
||||||
import type {
|
import type {
|
||||||
Channel,
|
Channel,
|
||||||
Contact,
|
Contact,
|
||||||
@@ -15,6 +16,8 @@ import type {
|
|||||||
PathDiscoveryResponse,
|
PathDiscoveryResponse,
|
||||||
RawPacket,
|
RawPacket,
|
||||||
RadioConfig,
|
RadioConfig,
|
||||||
|
RadioTraceHopRequest,
|
||||||
|
RadioTraceResponse,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
|
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
|
||||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
|
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
|
||||||
@@ -50,6 +53,10 @@ interface ConversationPaneProps {
|
|||||||
loadingNewer: boolean;
|
loadingNewer: boolean;
|
||||||
messageInputRef: Ref<MessageInputHandle>;
|
messageInputRef: Ref<MessageInputHandle>;
|
||||||
onTrace: () => Promise<void>;
|
onTrace: () => Promise<void>;
|
||||||
|
onRunTracePath: (
|
||||||
|
hopHashBytes: 1 | 2 | 4,
|
||||||
|
hops: RadioTraceHopRequest[]
|
||||||
|
) => Promise<RadioTraceResponse>;
|
||||||
onPathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
|
onPathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
|
||||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => Promise<void>;
|
onToggleFavorite: (type: 'channel' | 'contact', id: string) => Promise<void>;
|
||||||
onDeleteContact: (publicKey: string) => Promise<void>;
|
onDeleteContact: (publicKey: string) => Promise<void>;
|
||||||
@@ -58,6 +65,7 @@ interface ConversationPaneProps {
|
|||||||
onOpenContactInfo: (publicKey: string, fromChannel?: boolean) => void;
|
onOpenContactInfo: (publicKey: string, fromChannel?: boolean) => void;
|
||||||
onOpenChannelInfo: (channelKey: string) => void;
|
onOpenChannelInfo: (channelKey: string) => void;
|
||||||
onSenderClick: (sender: string) => void;
|
onSenderClick: (sender: string) => void;
|
||||||
|
onChannelReferenceClick?: (channelName: string) => void;
|
||||||
onLoadOlder: () => Promise<void>;
|
onLoadOlder: () => Promise<void>;
|
||||||
onResendChannelMessage: (messageId: number, newTimestamp?: boolean) => Promise<void>;
|
onResendChannelMessage: (messageId: number, newTimestamp?: boolean) => Promise<void>;
|
||||||
onTargetReached: () => void;
|
onTargetReached: () => void;
|
||||||
@@ -115,6 +123,7 @@ export function ConversationPane({
|
|||||||
loadingNewer,
|
loadingNewer,
|
||||||
messageInputRef,
|
messageInputRef,
|
||||||
onTrace,
|
onTrace,
|
||||||
|
onRunTracePath,
|
||||||
onPathDiscovery,
|
onPathDiscovery,
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
onDeleteContact,
|
onDeleteContact,
|
||||||
@@ -123,6 +132,7 @@ export function ConversationPane({
|
|||||||
onOpenContactInfo,
|
onOpenContactInfo,
|
||||||
onOpenChannelInfo,
|
onOpenChannelInfo,
|
||||||
onSenderClick,
|
onSenderClick,
|
||||||
|
onChannelReferenceClick,
|
||||||
onLoadOlder,
|
onLoadOlder,
|
||||||
onResendChannelMessage,
|
onResendChannelMessage,
|
||||||
onTargetReached,
|
onTargetReached,
|
||||||
@@ -200,6 +210,10 @@ export function ConversationPane({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (activeConversation.type === 'trace') {
|
||||||
|
return <TracePane contacts={contacts} config={config} onRunTracePath={onRunTracePath} />;
|
||||||
|
}
|
||||||
|
|
||||||
if (activeContactIsRepeater) {
|
if (activeContactIsRepeater) {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<LoadingPane label="Loading dashboard..." />}>
|
<Suspense fallback={<LoadingPane label="Loading dashboard..." />}>
|
||||||
@@ -272,6 +286,7 @@ export function ConversationPane({
|
|||||||
activeConversation.type === 'channel' ? onDismissUnreadMarker : undefined
|
activeConversation.type === 'channel' ? onDismissUnreadMarker : undefined
|
||||||
}
|
}
|
||||||
onSenderClick={activeConversation.type === 'channel' ? onSenderClick : undefined}
|
onSenderClick={activeConversation.type === 'channel' ? onSenderClick : undefined}
|
||||||
|
onChannelReferenceClick={onChannelReferenceClick}
|
||||||
onLoadOlder={onLoadOlder}
|
onLoadOlder={onLoadOlder}
|
||||||
onResendChannelMessage={
|
onResendChannelMessage={
|
||||||
activeConversation.type === 'channel' ? onResendChannelMessage : undefined
|
activeConversation.type === 'channel' ? onResendChannelMessage : undefined
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export function CrackerPanel({
|
|||||||
}: CrackerPanelProps) {
|
}: CrackerPanelProps) {
|
||||||
const [isRunning, setIsRunning] = useState(false);
|
const [isRunning, setIsRunning] = useState(false);
|
||||||
const [maxLength, setMaxLength] = useState(6);
|
const [maxLength, setMaxLength] = useState(6);
|
||||||
|
const [maxLengthInput, setMaxLengthInput] = useState('6');
|
||||||
const [retryFailedAtNextLength, setRetryFailedAtNextLength] = useState(false);
|
const [retryFailedAtNextLength, setRetryFailedAtNextLength] = useState(false);
|
||||||
const [decryptHistorical, setDecryptHistorical] = useState(true);
|
const [decryptHistorical, setDecryptHistorical] = useState(true);
|
||||||
const [turboMode, setTurboMode] = useState(false);
|
const [turboMode, setTurboMode] = useState(false);
|
||||||
@@ -127,8 +128,9 @@ export function CrackerPanel({
|
|||||||
}, [existingChannelKeys]);
|
}, [existingChannelKeys]);
|
||||||
|
|
||||||
// Filter packets to only undecrypted GROUP_TEXT
|
// Filter packets to only undecrypted GROUP_TEXT
|
||||||
const undecryptedGroupText = packets.filter(
|
const undecryptedGroupText = useMemo(
|
||||||
(p) => p.payload_type === 'GROUP_TEXT' && !p.decrypted
|
() => packets.filter((p) => p.payload_type === 'GROUP_TEXT' && !p.decrypted),
|
||||||
|
[packets]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update queue when packets change (deduplicated by payload)
|
// Update queue when packets change (deduplicated by payload)
|
||||||
@@ -191,6 +193,10 @@ export function CrackerPanel({
|
|||||||
maxLengthRef.current = maxLength;
|
maxLengthRef.current = maxLength;
|
||||||
}, [maxLength]);
|
}, [maxLength]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMaxLengthInput(String(maxLength));
|
||||||
|
}, [maxLength]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
decryptHistoricalRef.current = decryptHistorical;
|
decryptHistoricalRef.current = decryptHistorical;
|
||||||
}, [decryptHistorical]);
|
}, [decryptHistorical]);
|
||||||
@@ -434,8 +440,25 @@ export function CrackerPanel({
|
|||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
max={10}
|
max={10}
|
||||||
value={maxLength}
|
value={maxLengthInput}
|
||||||
onChange={(e) => setMaxLength(Math.min(10, Math.max(1, parseInt(e.target.value) || 6)))}
|
onChange={(e) => {
|
||||||
|
const nextValue = e.target.value;
|
||||||
|
setMaxLengthInput(nextValue);
|
||||||
|
if (nextValue === '') return;
|
||||||
|
const parsed = Number.parseInt(nextValue, 10);
|
||||||
|
if (Number.isNaN(parsed)) return;
|
||||||
|
setMaxLength(Math.min(10, Math.max(1, parsed)));
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
const parsed = Number.parseInt(maxLengthInput, 10);
|
||||||
|
const nextValue = Number.isNaN(parsed)
|
||||||
|
? maxLength
|
||||||
|
: Math.min(10, Math.max(1, parsed));
|
||||||
|
setMaxLengthInput(String(nextValue));
|
||||||
|
if (nextValue !== maxLength) {
|
||||||
|
setMaxLength(nextValue);
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="w-14 px-2 py-1 text-sm bg-muted border border-border rounded"
|
className="w-14 px-2 py-1 text-sm bg-muted border border-border rounded"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -131,9 +131,23 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
|||||||
|
|
||||||
// Store ref for a marker
|
// Store ref for a marker
|
||||||
const setMarkerRef = useCallback((key: string, ref: LeafletCircleMarker | null) => {
|
const setMarkerRef = useCallback((key: string, ref: LeafletCircleMarker | null) => {
|
||||||
|
if (ref === null) {
|
||||||
|
delete markerRefs.current[key];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
markerRefs.current[key] = ref;
|
markerRefs.current[key] = ref;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentKeys = new Set(mappableContacts.map((contact) => contact.public_key));
|
||||||
|
for (const key of Object.keys(markerRefs.current)) {
|
||||||
|
if (!currentKeys.has(key)) {
|
||||||
|
delete markerRefs.current[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [mappableContacts]);
|
||||||
|
|
||||||
// Open popup for focused contact after map is ready
|
// Open popup for focused contact after map is ready
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (focusedContact && markerRefs.current[focusedContact.public_key]) {
|
if (focusedContact && markerRefs.current[focusedContact.public_key]) {
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ import {
|
|||||||
import type { Channel, Contact, Message, MessagePath, RadioConfig, RawPacket } from '../types';
|
import type { Channel, Contact, Message, MessagePath, RadioConfig, RawPacket } from '../types';
|
||||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
|
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
|
||||||
import { api } from '../api';
|
import { api } from '../api';
|
||||||
import { formatTime, parseSenderFromText } from '../utils/messageParser';
|
import {
|
||||||
|
findLinkedChannelReferences,
|
||||||
|
formatTime,
|
||||||
|
parseSenderFromText,
|
||||||
|
} from '../utils/messageParser';
|
||||||
import { formatHopCounts, type SenderInfo } from '../utils/pathUtils';
|
import { formatHopCounts, type SenderInfo } from '../utils/pathUtils';
|
||||||
import { getDirectContactRoute } from '../utils/pathUtils';
|
import { getDirectContactRoute } from '../utils/pathUtils';
|
||||||
import { ContactAvatar } from './ContactAvatar';
|
import { ContactAvatar } from './ContactAvatar';
|
||||||
@@ -33,6 +37,7 @@ interface MessageListProps {
|
|||||||
onSenderClick?: (sender: string) => void;
|
onSenderClick?: (sender: string) => void;
|
||||||
onLoadOlder?: () => void;
|
onLoadOlder?: () => void;
|
||||||
onResendChannelMessage?: (messageId: number, newTimestamp?: boolean) => void;
|
onResendChannelMessage?: (messageId: number, newTimestamp?: boolean) => void;
|
||||||
|
onChannelReferenceClick?: (channelName: string) => void;
|
||||||
radioName?: string;
|
radioName?: string;
|
||||||
config?: RadioConfig | null;
|
config?: RadioConfig | null;
|
||||||
onOpenContactInfo?: (publicKey: string, fromChannel?: boolean) => void;
|
onOpenContactInfo?: (publicKey: string, fromChannel?: boolean) => void;
|
||||||
@@ -48,8 +53,64 @@ interface MessageListProps {
|
|||||||
const URL_PATTERN =
|
const URL_PATTERN =
|
||||||
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/g;
|
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/g;
|
||||||
|
|
||||||
// Helper to convert URLs in a plain text string into clickable links
|
function renderChannelReferences(
|
||||||
function linkifyText(text: string, keyPrefix: string): ReactNode[] {
|
text: string,
|
||||||
|
keyPrefix: string,
|
||||||
|
onChannelReferenceClick?: (channelName: string) => void
|
||||||
|
): ReactNode[] {
|
||||||
|
const references = findLinkedChannelReferences(text);
|
||||||
|
if (references.length === 0) {
|
||||||
|
return [text];
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts: ReactNode[] = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
|
||||||
|
references.forEach((reference, index) => {
|
||||||
|
if (reference.start > lastIndex) {
|
||||||
|
parts.push(text.slice(lastIndex, reference.start));
|
||||||
|
}
|
||||||
|
|
||||||
|
const className =
|
||||||
|
'rounded px-0.5 font-medium text-primary underline underline-offset-2 transition-colors';
|
||||||
|
if (onChannelReferenceClick) {
|
||||||
|
parts.push(
|
||||||
|
<button
|
||||||
|
key={`${keyPrefix}-channel-${index}`}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
className,
|
||||||
|
'inline border-0 bg-transparent p-0 align-baseline hover:text-primary/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
||||||
|
)}
|
||||||
|
onClick={() => onChannelReferenceClick(reference.label)}
|
||||||
|
>
|
||||||
|
{reference.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
parts.push(
|
||||||
|
<span key={`${keyPrefix}-channel-${index}`} className={className}>
|
||||||
|
{reference.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastIndex = reference.end;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
parts.push(text.slice(lastIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to convert URLs and channel references in a plain text string into rich content
|
||||||
|
function linkifyText(
|
||||||
|
text: string,
|
||||||
|
keyPrefix: string,
|
||||||
|
onChannelReferenceClick?: (channelName: string) => void
|
||||||
|
): ReactNode[] {
|
||||||
const parts: ReactNode[] = [];
|
const parts: ReactNode[] = [];
|
||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
let match: RegExpExecArray | null;
|
let match: RegExpExecArray | null;
|
||||||
@@ -58,7 +119,13 @@ function linkifyText(text: string, keyPrefix: string): ReactNode[] {
|
|||||||
URL_PATTERN.lastIndex = 0;
|
URL_PATTERN.lastIndex = 0;
|
||||||
while ((match = URL_PATTERN.exec(text)) !== null) {
|
while ((match = URL_PATTERN.exec(text)) !== null) {
|
||||||
if (match.index > lastIndex) {
|
if (match.index > lastIndex) {
|
||||||
parts.push(text.slice(lastIndex, match.index));
|
parts.push(
|
||||||
|
...renderChannelReferences(
|
||||||
|
text.slice(lastIndex, match.index),
|
||||||
|
`${keyPrefix}-text-${keyIndex}`,
|
||||||
|
onChannelReferenceClick
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
parts.push(
|
parts.push(
|
||||||
<a
|
<a
|
||||||
@@ -74,15 +141,27 @@ function linkifyText(text: string, keyPrefix: string): ReactNode[] {
|
|||||||
lastIndex = match.index + match[0].length;
|
lastIndex = match.index + match[0].length;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastIndex === 0) return [text];
|
if (lastIndex === 0) {
|
||||||
|
return renderChannelReferences(text, keyPrefix, onChannelReferenceClick);
|
||||||
|
}
|
||||||
if (lastIndex < text.length) {
|
if (lastIndex < text.length) {
|
||||||
parts.push(text.slice(lastIndex));
|
parts.push(
|
||||||
|
...renderChannelReferences(
|
||||||
|
text.slice(lastIndex),
|
||||||
|
`${keyPrefix}-tail`,
|
||||||
|
onChannelReferenceClick
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return parts;
|
return parts;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to render text with highlighted @[Name] mentions and clickable URLs
|
// Helper to render text with highlighted @[Name] mentions and clickable URLs
|
||||||
function renderTextWithMentions(text: string, radioName?: string): ReactNode {
|
function renderTextWithMentions(
|
||||||
|
text: string,
|
||||||
|
radioName?: string,
|
||||||
|
onChannelReferenceClick?: (channelName: string) => void
|
||||||
|
): ReactNode {
|
||||||
const mentionPattern = /@\[([^\]]+)\]/g;
|
const mentionPattern = /@\[([^\]]+)\]/g;
|
||||||
const parts: ReactNode[] = [];
|
const parts: ReactNode[] = [];
|
||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
@@ -92,7 +171,13 @@ function renderTextWithMentions(text: string, radioName?: string): ReactNode {
|
|||||||
while ((match = mentionPattern.exec(text)) !== null) {
|
while ((match = mentionPattern.exec(text)) !== null) {
|
||||||
// Add text before the match (with linkification)
|
// Add text before the match (with linkification)
|
||||||
if (match.index > lastIndex) {
|
if (match.index > lastIndex) {
|
||||||
parts.push(...linkifyText(text.slice(lastIndex, match.index), `pre-${keyIndex}`));
|
parts.push(
|
||||||
|
...linkifyText(
|
||||||
|
text.slice(lastIndex, match.index),
|
||||||
|
`pre-${keyIndex}`,
|
||||||
|
onChannelReferenceClick
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mentionedName = match[1];
|
const mentionedName = match[1];
|
||||||
@@ -115,7 +200,7 @@ function renderTextWithMentions(text: string, radioName?: string): ReactNode {
|
|||||||
|
|
||||||
// Add remaining text after last match (with linkification)
|
// Add remaining text after last match (with linkification)
|
||||||
if (lastIndex < text.length) {
|
if (lastIndex < text.length) {
|
||||||
parts.push(...linkifyText(text.slice(lastIndex), `post-${keyIndex}`));
|
parts.push(...linkifyText(text.slice(lastIndex), `post-${keyIndex}`, onChannelReferenceClick));
|
||||||
}
|
}
|
||||||
|
|
||||||
return parts.length > 0 ? parts : text;
|
return parts.length > 0 ? parts : text;
|
||||||
@@ -188,6 +273,7 @@ export function MessageList({
|
|||||||
onSenderClick,
|
onSenderClick,
|
||||||
onLoadOlder,
|
onLoadOlder,
|
||||||
onResendChannelMessage,
|
onResendChannelMessage,
|
||||||
|
onChannelReferenceClick,
|
||||||
radioName,
|
radioName,
|
||||||
config,
|
config,
|
||||||
onOpenContactInfo,
|
onOpenContactInfo,
|
||||||
@@ -373,7 +459,22 @@ export function MessageList({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setResendableIds(newResendable);
|
setResendableIds((prev) => {
|
||||||
|
if (prev.size === newResendable.size) {
|
||||||
|
let changed = false;
|
||||||
|
for (const id of newResendable) {
|
||||||
|
if (!prev.has(id)) {
|
||||||
|
changed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!changed) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newResendable;
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
for (const timer of timers.values()) clearTimeout(timer);
|
for (const timer of timers.values()) clearTimeout(timer);
|
||||||
@@ -896,7 +997,7 @@ export function MessageList({
|
|||||||
<div className="break-words whitespace-pre-wrap">
|
<div className="break-words whitespace-pre-wrap">
|
||||||
{content.split('\n').map((line, i, arr) => (
|
{content.split('\n').map((line, i, arr) => (
|
||||||
<span key={i}>
|
<span key={i}>
|
||||||
{renderTextWithMentions(line, radioName)}
|
{renderTextWithMentions(line, radioName, onChannelReferenceClick)}
|
||||||
{i < arr.length - 1 && <br />}
|
{i < arr.length - 1 && <br />}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Dice5 } from 'lucide-react';
|
import { Dice5 } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -20,6 +20,11 @@ type Tab = 'new-contact' | 'new-channel' | 'hashtag';
|
|||||||
interface NewMessageModalProps {
|
interface NewMessageModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
undecryptedCount: number;
|
undecryptedCount: number;
|
||||||
|
prefillRequest?: {
|
||||||
|
tab: 'hashtag';
|
||||||
|
hashtagName: string;
|
||||||
|
nonce: number;
|
||||||
|
} | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>;
|
onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>;
|
||||||
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
|
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
|
||||||
@@ -29,6 +34,7 @@ interface NewMessageModalProps {
|
|||||||
export function NewMessageModal({
|
export function NewMessageModal({
|
||||||
open,
|
open,
|
||||||
undecryptedCount,
|
undecryptedCount,
|
||||||
|
prefillRequest = null,
|
||||||
onClose,
|
onClose,
|
||||||
onCreateContact,
|
onCreateContact,
|
||||||
onCreateChannel,
|
onCreateChannel,
|
||||||
@@ -53,6 +59,24 @@ export function NewMessageModal({
|
|||||||
setError('');
|
setError('');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !prefillRequest) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTab(prefillRequest.tab);
|
||||||
|
setName(prefillRequest.hashtagName);
|
||||||
|
setContactKey('');
|
||||||
|
setChannelKey('');
|
||||||
|
setTryHistorical(false);
|
||||||
|
setPermitCapitals(false);
|
||||||
|
setError('');
|
||||||
|
setLoading(false);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
hashtagInputRef.current?.focus();
|
||||||
|
});
|
||||||
|
}, [open, prefillRequest]);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
setError('');
|
setError('');
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|||||||
@@ -81,13 +81,14 @@ export function PathModal({
|
|||||||
) : hasSinglePath ? (
|
) : hasSinglePath ? (
|
||||||
<>
|
<>
|
||||||
This shows <em>one route</em> that this message traveled through the mesh network.
|
This shows <em>one route</em> that this message traveled through the mesh network.
|
||||||
Repeaters may be incorrectly identified due to prefix collisions between heard and
|
Repeater identities are inferred from locally known advert and path data, so some
|
||||||
non-heard repeater advertisements.
|
hops may be missing or misidentified when that data is incomplete.
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
This message was received via <strong>{paths.length} different routes</strong>.
|
This message was received via <strong>{paths.length} different routes</strong>.
|
||||||
Repeaters may be incorrectly identified due to prefix collisions.
|
Repeater identities are inferred from locally known advert and path data, so some
|
||||||
|
hops may be missing or misidentified when that data is incomplete.
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip as RechartsTooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Cell,
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
import { RawPacketList } from './RawPacketList';
|
import { RawPacketList } from './RawPacketList';
|
||||||
import { RawPacketInspectorDialog } from './RawPacketDetailModal';
|
import { RawPacketInspectorDialog } from './RawPacketDetailModal';
|
||||||
@@ -24,6 +34,18 @@ interface RawPacketFeedViewProps {
|
|||||||
channels: Channel[];
|
channels: Channel[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TOOLTIP_STYLE = {
|
||||||
|
contentStyle: {
|
||||||
|
backgroundColor: 'hsl(var(--popover))',
|
||||||
|
border: '1px solid hsl(var(--border))',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: 'hsl(var(--popover-foreground))',
|
||||||
|
},
|
||||||
|
itemStyle: { color: 'hsl(var(--popover-foreground))' },
|
||||||
|
labelStyle: { color: 'hsl(var(--muted-foreground))' },
|
||||||
|
} as const;
|
||||||
|
|
||||||
const WINDOW_LABELS: Record<RawPacketStatsWindow, string> = {
|
const WINDOW_LABELS: Record<RawPacketStatsWindow, string> = {
|
||||||
'1m': '1 min',
|
'1m': '1 min',
|
||||||
'5m': '5 min',
|
'5m': '5 min',
|
||||||
@@ -32,13 +54,7 @@ const WINDOW_LABELS: Record<RawPacketStatsWindow, string> = {
|
|||||||
session: 'Session',
|
session: 'Session',
|
||||||
};
|
};
|
||||||
|
|
||||||
const TIMELINE_COLORS = [
|
const TIMELINE_FILL_COLORS = ['#0ea5e9', '#10b981', '#f59e0b', '#f43f5e', '#8b5cf6'];
|
||||||
'bg-sky-500/80',
|
|
||||||
'bg-emerald-500/80',
|
|
||||||
'bg-amber-500/80',
|
|
||||||
'bg-rose-500/80',
|
|
||||||
'bg-violet-500/80',
|
|
||||||
];
|
|
||||||
|
|
||||||
function formatTimestamp(timestampMs: number): string {
|
function formatTimestamp(timestampMs: number): string {
|
||||||
return new Date(timestampMs).toLocaleString([], {
|
return new Date(timestampMs).toLocaleString([], {
|
||||||
@@ -155,24 +171,17 @@ function isNeighborIdentityResolvable(item: NeighborStat, contacts: Contact[]):
|
|||||||
return resolveContact(item.key, contacts) !== null;
|
return resolveContact(item.key, contacts) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatStrongestPacketDetail(
|
function formatStrongestNeighborDetail(
|
||||||
stats: ReturnType<typeof buildRawPacketStatsSnapshot>,
|
stats: ReturnType<typeof buildRawPacketStatsSnapshot>,
|
||||||
contacts: Contact[]
|
contacts: Contact[]
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
if (!stats.strongestPacketPayloadType) {
|
const strongestNeighbor = stats.strongestNeighbors[0];
|
||||||
|
if (!strongestNeighbor || strongestNeighbor.bestRssi === null) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedLabel =
|
const resolvedNeighbor = resolveNeighbor(strongestNeighbor, contacts);
|
||||||
resolveContactLabel(stats.strongestPacketSourceKey, contacts) ??
|
return `${formatRssi(resolvedNeighbor.bestRssi)} best heard`;
|
||||||
stats.strongestPacketSourceLabel;
|
|
||||||
if (resolvedLabel) {
|
|
||||||
return `${resolvedLabel} · ${stats.strongestPacketPayloadType}`;
|
|
||||||
}
|
|
||||||
if (stats.strongestPacketPayloadType === 'GroupText') {
|
|
||||||
return '<unknown sender> · GroupText';
|
|
||||||
}
|
|
||||||
return stats.strongestPacketPayloadType;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCoverageMessage(
|
function getCoverageMessage(
|
||||||
@@ -220,7 +229,13 @@ function RankedBars({
|
|||||||
emptyLabel: string;
|
emptyLabel: string;
|
||||||
formatter?: (item: RankedPacketStat) => string;
|
formatter?: (item: RankedPacketStat) => string;
|
||||||
}) {
|
}) {
|
||||||
const maxCount = Math.max(...items.map((item) => item.count), 1);
|
const data = items.map((item) => ({
|
||||||
|
name: item.label,
|
||||||
|
value: item.count,
|
||||||
|
detail: formatter
|
||||||
|
? formatter(item)
|
||||||
|
: `${item.count.toLocaleString()} · ${formatPercent(item.share)}`,
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
|
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
|
||||||
@@ -228,25 +243,36 @@ function RankedBars({
|
|||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<p className="mt-3 text-sm text-muted-foreground">{emptyLabel}</p>
|
<p className="mt-3 text-sm text-muted-foreground">{emptyLabel}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-3 space-y-2">
|
<div className="mt-2">
|
||||||
{items.map((item) => (
|
<ResponsiveContainer width="100%" height={items.length * 28 + 8}>
|
||||||
<div key={item.label}>
|
<BarChart
|
||||||
<div className="mb-1 flex items-center justify-between gap-3 text-xs">
|
data={data}
|
||||||
<span className="truncate text-foreground">{item.label}</span>
|
layout="vertical"
|
||||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
margin={{ top: 0, right: 4, bottom: 0, left: 0 }}
|
||||||
{formatter
|
barCategoryGap="20%"
|
||||||
? formatter(item)
|
>
|
||||||
: `${item.count.toLocaleString()} · ${formatPercent(item.share)}`}
|
<XAxis type="number" hide />
|
||||||
</span>
|
<YAxis
|
||||||
</div>
|
type="category"
|
||||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
dataKey="name"
|
||||||
<div
|
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
className="h-full rounded-full bg-primary/80"
|
tickLine={false}
|
||||||
style={{ width: `${(item.count / maxCount) * 100}%` }}
|
axisLine={false}
|
||||||
/>
|
width={80}
|
||||||
</div>
|
/>
|
||||||
</div>
|
<RechartsTooltip
|
||||||
))}
|
{...TOOLTIP_STYLE}
|
||||||
|
cursor={{ fill: 'hsl(var(--muted))', opacity: 0.5 }}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
formatter={(_v: any, _n: any, props: any) => [props.payload.detail, null]}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="value" radius={[0, 4, 4, 0]} maxBarSize={16}>
|
||||||
|
{data.map((_, i) => (
|
||||||
|
<Cell key={i} fill={TIMELINE_FILL_COLORS[i % TIMELINE_FILL_COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -320,53 +346,66 @@ function NeighborList({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TimelineChart({ bins }: { bins: PacketTimelineBin[] }) {
|
function TimelineChart({ bins }: { bins: PacketTimelineBin[] }) {
|
||||||
const maxTotal = Math.max(...bins.map((bin) => bin.total), 1);
|
|
||||||
const typeOrder = Array.from(new Set(bins.flatMap((bin) => Object.keys(bin.countsByType)))).slice(
|
const typeOrder = Array.from(new Set(bins.flatMap((bin) => Object.keys(bin.countsByType)))).slice(
|
||||||
0,
|
0,
|
||||||
TIMELINE_COLORS.length
|
TIMELINE_FILL_COLORS.length
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const data = bins.map((bin) => {
|
||||||
|
const entry: Record<string, string | number> = { label: bin.label };
|
||||||
|
for (const type of typeOrder) {
|
||||||
|
entry[type] = bin.countsByType[type] ?? 0;
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
|
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<h3 className="text-sm font-semibold text-foreground">Traffic Timeline</h3>
|
<h3 className="text-sm font-semibold text-foreground">Traffic Timeline</h3>
|
||||||
<div className="flex flex-wrap justify-end gap-2 text-[11px] text-muted-foreground">
|
<div className="flex flex-wrap justify-end gap-2 text-[11px] text-muted-foreground">
|
||||||
{typeOrder.map((type, index) => (
|
{typeOrder.map((type, i) => (
|
||||||
<span key={type} className="inline-flex items-center gap-1">
|
<span key={type} className="inline-flex items-center gap-1">
|
||||||
<span className={cn('h-2 w-2 rounded-full', TIMELINE_COLORS[index])} />
|
<span
|
||||||
|
className="h-2 w-2 rounded-full"
|
||||||
|
style={{ backgroundColor: TIMELINE_FILL_COLORS[i] }}
|
||||||
|
/>
|
||||||
<span>{type}</span>
|
<span>{type}</span>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
<div className="mt-3 flex items-start gap-1">
|
<ResponsiveContainer width="100%" height={110}>
|
||||||
{bins.map((bin, index) => (
|
<BarChart data={data} margin={{ top: 4, right: 0, bottom: 0, left: -24 }}>
|
||||||
<div
|
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
||||||
key={`${bin.label}-${index}`}
|
<XAxis
|
||||||
className="flex min-w-0 flex-1 flex-col items-center gap-1"
|
dataKey="label"
|
||||||
>
|
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
<div className="flex h-24 w-full items-end overflow-hidden rounded-sm bg-muted/60">
|
tickLine={false}
|
||||||
<div className="flex h-full w-full flex-col justify-end">
|
axisLine={false}
|
||||||
{typeOrder.map((type, index) => {
|
/>
|
||||||
const count = bin.countsByType[type] ?? 0;
|
<YAxis
|
||||||
if (count === 0) return null;
|
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
return (
|
tickLine={false}
|
||||||
<div
|
axisLine={false}
|
||||||
key={type}
|
allowDecimals={false}
|
||||||
className={cn('w-full', TIMELINE_COLORS[index])}
|
/>
|
||||||
style={{
|
<RechartsTooltip
|
||||||
height: `${(count / maxTotal) * 100}%`,
|
{...TOOLTIP_STYLE}
|
||||||
}}
|
cursor={{ fill: 'hsl(var(--muted))', opacity: 0.5 }}
|
||||||
title={`${bin.label}: ${type} ${count.toLocaleString()}`}
|
/>
|
||||||
/>
|
{typeOrder.map((type, i) => (
|
||||||
);
|
<Bar
|
||||||
})}
|
key={type}
|
||||||
</div>
|
dataKey={type}
|
||||||
</div>
|
stackId="packets"
|
||||||
<div className="text-[10px] text-muted-foreground">{bin.label}</div>
|
fill={TIMELINE_FILL_COLORS[i]}
|
||||||
</div>
|
radius={i === typeOrder.length - 1 ? [2, 2, 0, 0] : undefined}
|
||||||
))}
|
/>
|
||||||
|
))}
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -404,8 +443,13 @@ export function RawPacketFeedView({
|
|||||||
[nowSec, rawPacketStatsSession, selectedWindow]
|
[nowSec, rawPacketStatsSession, selectedWindow]
|
||||||
);
|
);
|
||||||
const coverageMessage = getCoverageMessage(stats, rawPacketStatsSession);
|
const coverageMessage = getCoverageMessage(stats, rawPacketStatsSession);
|
||||||
const strongestPacketDetail = useMemo(
|
const strongestNeighbor = useMemo(() => {
|
||||||
() => formatStrongestPacketDetail(stats, contacts),
|
const topNeighbor = stats.strongestNeighbors[0];
|
||||||
|
return topNeighbor ? resolveNeighbor(topNeighbor, contacts) : null;
|
||||||
|
}, [contacts, stats]);
|
||||||
|
|
||||||
|
const strongestNeighborDetail = useMemo(
|
||||||
|
() => formatStrongestNeighborDetail(stats, contacts),
|
||||||
[contacts, stats]
|
[contacts, stats]
|
||||||
);
|
);
|
||||||
const strongestNeighbors = useMemo(
|
const strongestNeighbors = useMemo(
|
||||||
@@ -532,9 +576,9 @@ export function RawPacketFeedView({
|
|||||||
detail={`${formatPercent(stats.pathBearingRate)} path-bearing packets`}
|
detail={`${formatPercent(stats.pathBearingRate)} path-bearing packets`}
|
||||||
/>
|
/>
|
||||||
<StatTile
|
<StatTile
|
||||||
label="Best RSSI"
|
label="Strongest Neighbor"
|
||||||
value={formatRssi(stats.bestRssi)}
|
value={strongestNeighbor?.label ?? '-'}
|
||||||
detail={strongestPacketDetail ?? 'No signal sample in window'}
|
detail={strongestNeighborDetail ?? 'No neighbor RSSI sample in window'}
|
||||||
/>
|
/>
|
||||||
<StatTile
|
<StatTile
|
||||||
label="Median RSSI"
|
label="Median RSSI"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Button } from './ui/button';
|
|||||||
import { Bell, Route, Star, Trash2 } from 'lucide-react';
|
import { Bell, Route, Star, Trash2 } from 'lucide-react';
|
||||||
import { DirectTraceIcon } from './DirectTraceIcon';
|
import { DirectTraceIcon } from './DirectTraceIcon';
|
||||||
import { RepeaterLogin } from './RepeaterLogin';
|
import { RepeaterLogin } from './RepeaterLogin';
|
||||||
|
import { ServerLoginStatusBanner } from './ServerLoginStatusBanner';
|
||||||
import { useRememberedServerPassword } from '../hooks/useRememberedServerPassword';
|
import { useRememberedServerPassword } from '../hooks/useRememberedServerPassword';
|
||||||
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
|
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
|
||||||
import { isFavorite } from '../utils/favorites';
|
import { isFavorite } from '../utils/favorites';
|
||||||
@@ -69,6 +70,7 @@ export function RepeaterDashboard({
|
|||||||
loggedIn,
|
loggedIn,
|
||||||
loginLoading,
|
loginLoading,
|
||||||
loginError,
|
loginError,
|
||||||
|
lastLoginAttempt,
|
||||||
paneData,
|
paneData,
|
||||||
paneStates,
|
paneStates,
|
||||||
consoleHistory,
|
consoleHistory,
|
||||||
@@ -249,6 +251,14 @@ export function RepeaterDashboard({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<ServerLoginStatusBanner
|
||||||
|
attempt={lastLoginAttempt}
|
||||||
|
loading={loginLoading}
|
||||||
|
canRetryPassword={password.trim().length > 0}
|
||||||
|
onRetryPassword={() => handleRepeaterLogin(password)}
|
||||||
|
onRetryBlank={handleRepeaterGuestLogin}
|
||||||
|
blankRetryLabel="Retry Existing-Access Login"
|
||||||
|
/>
|
||||||
{/* Top row: Telemetry + Radio Settings | Node Info + Neighbors */}
|
{/* Top row: Telemetry + Radio Settings | Node Info + Neighbors */}
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 md:items-stretch">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 md:items-stretch">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
|||||||
@@ -16,7 +16,13 @@ import { AclPane } from './repeater/RepeaterAclPane';
|
|||||||
import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane';
|
import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane';
|
||||||
import { ConsolePane } from './repeater/RepeaterConsolePane';
|
import { ConsolePane } from './repeater/RepeaterConsolePane';
|
||||||
import { RepeaterLogin } from './RepeaterLogin';
|
import { RepeaterLogin } from './RepeaterLogin';
|
||||||
|
import { ServerLoginStatusBanner } from './ServerLoginStatusBanner';
|
||||||
import { useRememberedServerPassword } from '../hooks/useRememberedServerPassword';
|
import { useRememberedServerPassword } from '../hooks/useRememberedServerPassword';
|
||||||
|
import {
|
||||||
|
buildServerLoginAttemptFromError,
|
||||||
|
buildServerLoginAttemptFromResponse,
|
||||||
|
type ServerLoginAttemptState,
|
||||||
|
} from '../utils/serverLoginState';
|
||||||
|
|
||||||
interface RoomServerPanelProps {
|
interface RoomServerPanelProps {
|
||||||
contact: Contact;
|
contact: Contact;
|
||||||
@@ -61,6 +67,7 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
|
|||||||
const [loginLoading, setLoginLoading] = useState(false);
|
const [loginLoading, setLoginLoading] = useState(false);
|
||||||
const [loginError, setLoginError] = useState<string | null>(null);
|
const [loginError, setLoginError] = useState<string | null>(null);
|
||||||
const [authenticated, setAuthenticated] = useState(false);
|
const [authenticated, setAuthenticated] = useState(false);
|
||||||
|
const [lastLoginAttempt, setLastLoginAttempt] = useState<ServerLoginAttemptState | null>(null);
|
||||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||||
const [paneData, setPaneData] = useState<RoomPaneData>({
|
const [paneData, setPaneData] = useState<RoomPaneData>({
|
||||||
status: null,
|
status: null,
|
||||||
@@ -75,6 +82,7 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
|
|||||||
setLoginLoading(false);
|
setLoginLoading(false);
|
||||||
setLoginError(null);
|
setLoginError(null);
|
||||||
setAuthenticated(false);
|
setAuthenticated(false);
|
||||||
|
setLastLoginAttempt(null);
|
||||||
setAdvancedOpen(false);
|
setAdvancedOpen(false);
|
||||||
setPaneData({
|
setPaneData({
|
||||||
status: null,
|
status: null,
|
||||||
@@ -129,26 +137,32 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
|
|||||||
);
|
);
|
||||||
|
|
||||||
const performLogin = useCallback(
|
const performLogin = useCallback(
|
||||||
async (password: string) => {
|
async (nextPassword: string, method: 'password' | 'blank') => {
|
||||||
if (loginLoading) return;
|
if (loginLoading) return;
|
||||||
|
|
||||||
setLoginLoading(true);
|
setLoginLoading(true);
|
||||||
setLoginError(null);
|
setLoginError(null);
|
||||||
try {
|
try {
|
||||||
const result = await api.roomLogin(contact.public_key, password);
|
const result = await api.roomLogin(contact.public_key, nextPassword);
|
||||||
|
setLastLoginAttempt(buildServerLoginAttemptFromResponse(method, result, 'room server'));
|
||||||
setAuthenticated(true);
|
setAuthenticated(true);
|
||||||
if (result.authenticated) {
|
if (result.authenticated) {
|
||||||
toast.success('Room login confirmed');
|
toast.success('Login confirmed by the room server.');
|
||||||
} else {
|
} else {
|
||||||
toast.warning('Room login not confirmed', {
|
toast.warning("Couldn't confirm room login", {
|
||||||
description: result.message ?? 'Room login was not confirmed',
|
description:
|
||||||
|
result.message ??
|
||||||
|
'No confirmation came back from the room server. You can still open tools and try again.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
setLastLoginAttempt(buildServerLoginAttemptFromError(method, message, 'room server'));
|
||||||
setAuthenticated(true);
|
setAuthenticated(true);
|
||||||
setLoginError(message);
|
setLoginError(message);
|
||||||
toast.error('Room login failed', { description: message });
|
toast.error('Room login request failed', {
|
||||||
|
description: `${message}. You can still open tools and retry the login from here.`,
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoginLoading(false);
|
setLoginLoading(false);
|
||||||
}
|
}
|
||||||
@@ -157,15 +171,15 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleLogin = useCallback(
|
const handleLogin = useCallback(
|
||||||
async (password: string) => {
|
async (nextPassword: string) => {
|
||||||
await performLogin(password);
|
await performLogin(nextPassword, 'password');
|
||||||
persistAfterLogin(password);
|
persistAfterLogin(nextPassword);
|
||||||
},
|
},
|
||||||
[performLogin, persistAfterLogin]
|
[performLogin, persistAfterLogin]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleLoginAsGuest = useCallback(async () => {
|
const handleLoginAsGuest = useCallback(async () => {
|
||||||
await performLogin('');
|
await performLogin('', 'blank');
|
||||||
persistAfterLogin('');
|
persistAfterLogin('');
|
||||||
}, [performLogin, persistAfterLogin]);
|
}, [performLogin, persistAfterLogin]);
|
||||||
|
|
||||||
@@ -207,6 +221,8 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
|
|||||||
);
|
);
|
||||||
|
|
||||||
const panelTitle = useMemo(() => contact.name || contact.public_key.slice(0, 12), [contact]);
|
const panelTitle = useMemo(() => contact.name || contact.public_key.slice(0, 12), [contact]);
|
||||||
|
const showLoginFailureState =
|
||||||
|
lastLoginAttempt !== null && lastLoginAttempt.outcome !== 'confirmed';
|
||||||
|
|
||||||
if (!authenticated) {
|
if (!authenticated) {
|
||||||
return (
|
return (
|
||||||
@@ -236,7 +252,7 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
|
|||||||
onLoginAsGuest={handleLoginAsGuest}
|
onLoginAsGuest={handleLoginAsGuest}
|
||||||
description="Log in with the room password or use ACL/guest access to enter this room server"
|
description="Log in with the room password or use ACL/guest access to enter this room server"
|
||||||
passwordPlaceholder="Room server password..."
|
passwordPlaceholder="Room server password..."
|
||||||
guestLabel="Login with ACL / Guest"
|
guestLabel="Login with Existing Access / Guest"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -245,15 +261,52 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="border-b border-border bg-muted/20 px-4 py-3">
|
<section className="border-b border-border bg-muted/20 px-4 py-3">
|
||||||
<div className="flex justify-end">
|
<div className="space-y-3">
|
||||||
<Button
|
{showLoginFailureState ? (
|
||||||
type="button"
|
<ServerLoginStatusBanner
|
||||||
variant="outline"
|
attempt={lastLoginAttempt}
|
||||||
size="sm"
|
loading={loginLoading}
|
||||||
onClick={() => setAdvancedOpen((prev) => !prev)}
|
canRetryPassword={password.trim().length > 0}
|
||||||
>
|
onRetryPassword={() => handleLogin(password)}
|
||||||
{advancedOpen ? 'Hide Tools' : 'Show Tools'}
|
onRetryBlank={handleLoginAsGuest}
|
||||||
</Button>
|
blankRetryLabel="Retry Existing-Access Login"
|
||||||
|
showRetryActions={false}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
{showLoginFailureState ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void handleLogin(password)}
|
||||||
|
disabled={loginLoading || password.trim().length === 0}
|
||||||
|
>
|
||||||
|
Retry Password Login
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleLoginAsGuest}
|
||||||
|
disabled={loginLoading}
|
||||||
|
>
|
||||||
|
Retry Existing-Access Login
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setAdvancedOpen((prev) => !prev)}
|
||||||
|
>
|
||||||
|
{advancedOpen ? 'Hide Tools' : 'Show Tools'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Sheet open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
<Sheet open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||||
<SheetContent side="right" className="w-full sm:max-w-4xl p-0 flex flex-col">
|
<SheetContent side="right" className="w-full sm:max-w-4xl p-0 flex flex-col">
|
||||||
@@ -269,15 +322,6 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
|
|||||||
<h2 className="truncate text-base font-semibold">Room Server Tools</h2>
|
<h2 className="truncate text-base font-semibold">Room Server Tools</h2>
|
||||||
<p className="text-sm text-muted-foreground">{panelTitle}</p>
|
<p className="text-sm text-muted-foreground">{panelTitle}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleLoginAsGuest}
|
|
||||||
disabled={loginLoading}
|
|
||||||
className="self-start sm:self-auto"
|
|
||||||
>
|
|
||||||
Refresh ACL Login
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
|||||||
@@ -174,7 +174,11 @@ export function SearchView({
|
|||||||
api
|
api
|
||||||
.getMessages({ q: debouncedQuery, limit: SEARCH_PAGE_SIZE, offset }, controller.signal)
|
.getMessages({ q: debouncedQuery, limit: SEARCH_PAGE_SIZE, offset }, controller.signal)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
setResults((prev) => [...prev, ...(data as SearchResult[])]);
|
setResults((prev) => {
|
||||||
|
const existingIds = new Set(prev.map((r) => r.id));
|
||||||
|
const unique = (data as SearchResult[]).filter((r) => !existingIds.has(r.id));
|
||||||
|
return [...prev, ...unique];
|
||||||
|
});
|
||||||
setHasMore(data.length >= SEARCH_PAGE_SIZE);
|
setHasMore(data.length >= SEARCH_PAGE_SIZE);
|
||||||
setOffset((prev) => prev + data.length);
|
setOffset((prev) => prev + data.length);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { Button } from './ui/button';
|
||||||
|
import type { ServerLoginAttemptState } from '../utils/serverLoginState';
|
||||||
|
import { getServerLoginAttemptTone } from '../utils/serverLoginState';
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
|
|
||||||
|
interface ServerLoginStatusBannerProps {
|
||||||
|
attempt: ServerLoginAttemptState | null;
|
||||||
|
loading: boolean;
|
||||||
|
canRetryPassword: boolean;
|
||||||
|
onRetryPassword: () => Promise<void> | void;
|
||||||
|
onRetryBlank: () => Promise<void> | void;
|
||||||
|
passwordRetryLabel?: string;
|
||||||
|
blankRetryLabel?: string;
|
||||||
|
showRetryActions?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServerLoginStatusBanner({
|
||||||
|
attempt,
|
||||||
|
loading,
|
||||||
|
canRetryPassword,
|
||||||
|
onRetryPassword,
|
||||||
|
onRetryBlank,
|
||||||
|
passwordRetryLabel = 'Retry Password Login',
|
||||||
|
blankRetryLabel = 'Retry Existing-Access Login',
|
||||||
|
showRetryActions = true,
|
||||||
|
}: ServerLoginStatusBannerProps) {
|
||||||
|
if (attempt?.outcome === 'confirmed') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tone = getServerLoginAttemptTone(attempt);
|
||||||
|
const shouldShowActions = showRetryActions;
|
||||||
|
const toneClassName =
|
||||||
|
tone === 'success'
|
||||||
|
? 'border-success/30 bg-success/10 text-success'
|
||||||
|
: tone === 'warning'
|
||||||
|
? 'border-warning/30 bg-warning/10 text-warning'
|
||||||
|
: tone === 'destructive'
|
||||||
|
? 'border-destructive/30 bg-destructive/10 text-destructive'
|
||||||
|
: 'border-border bg-muted/40 text-foreground';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('rounded-md border px-4 py-3', toneClassName)}>
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{attempt?.summary ?? 'No server login attempt has been recorded in this view yet.'}
|
||||||
|
</p>
|
||||||
|
{attempt?.details && <p className="text-xs opacity-90">{attempt.details}</p>}
|
||||||
|
</div>
|
||||||
|
{shouldShowActions ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void onRetryPassword()}
|
||||||
|
disabled={loading || !canRetryPassword}
|
||||||
|
>
|
||||||
|
{passwordRetryLabel}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void onRetryBlank()}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{blankRetryLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Bell,
|
Bell,
|
||||||
|
Cable,
|
||||||
|
ChartNetwork,
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@@ -9,7 +11,6 @@ import {
|
|||||||
Map,
|
Map,
|
||||||
Search as SearchIcon,
|
Search as SearchIcon,
|
||||||
SquarePen,
|
SquarePen,
|
||||||
Waypoints,
|
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
@@ -197,7 +198,7 @@ export function Sidebar({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isActive = (
|
const isActive = (
|
||||||
type: 'contact' | 'channel' | 'raw' | 'map' | 'visualizer' | 'search',
|
type: 'contact' | 'channel' | 'raw' | 'map' | 'visualizer' | 'search' | 'trace',
|
||||||
id: string
|
id: string
|
||||||
) => activeConversation?.type === type && activeConversation?.id === id;
|
) => activeConversation?.type === type && activeConversation?.id === id;
|
||||||
|
|
||||||
@@ -721,7 +722,7 @@ export function Sidebar({
|
|||||||
renderSidebarActionRow({
|
renderSidebarActionRow({
|
||||||
key: 'tool-visualizer',
|
key: 'tool-visualizer',
|
||||||
active: isActive('visualizer', 'visualizer'),
|
active: isActive('visualizer', 'visualizer'),
|
||||||
icon: <Waypoints className="h-4 w-4" />,
|
icon: <ChartNetwork className="h-4 w-4" />,
|
||||||
label: 'Mesh Visualizer',
|
label: 'Mesh Visualizer',
|
||||||
onClick: () =>
|
onClick: () =>
|
||||||
handleSelectConversation({
|
handleSelectConversation({
|
||||||
@@ -730,6 +731,18 @@ export function Sidebar({
|
|||||||
name: 'Mesh Visualizer',
|
name: 'Mesh Visualizer',
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
renderSidebarActionRow({
|
||||||
|
key: 'tool-trace',
|
||||||
|
active: isActive('trace', 'trace'),
|
||||||
|
icon: <Cable className="h-4 w-4" />,
|
||||||
|
label: 'Trace',
|
||||||
|
onClick: () =>
|
||||||
|
handleSelectConversation({
|
||||||
|
type: 'trace',
|
||||||
|
id: 'trace',
|
||||||
|
name: 'Trace',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
renderSidebarActionRow({
|
renderSidebarActionRow({
|
||||||
key: 'tool-search',
|
key: 'tool-search',
|
||||||
active: isActive('search', 'search'),
|
active: isActive('search', 'search'),
|
||||||
@@ -840,41 +853,45 @@ export function Sidebar({
|
|||||||
aria-label="Conversations"
|
aria-label="Conversations"
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border">
|
<div className="px-3 py-2 border-b border-border">
|
||||||
<div className="relative min-w-0 flex-1">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search channels/contacts..."
|
|
||||||
aria-label="Search conversations"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className={cn('h-7 text-[13px] bg-background/50', searchQuery ? 'pr-8' : 'pr-3')}
|
|
||||||
/>
|
|
||||||
{searchQuery && (
|
|
||||||
<button
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded"
|
|
||||||
onClick={() => setSearchQuery('')}
|
|
||||||
title="Clear search"
|
|
||||||
aria-label="Clear search"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onNewMessage}
|
onClick={onNewMessage}
|
||||||
title="New Message"
|
title="Add channel or contact"
|
||||||
aria-label="New message"
|
aria-label="Add channel or contact"
|
||||||
className="h-7 w-7 shrink-0 p-0 text-muted-foreground hover:text-foreground transition-colors"
|
className="h-8 w-full justify-start gap-2 px-3 text-[13px]"
|
||||||
>
|
>
|
||||||
<SquarePen className="h-4 w-4" />
|
<SquarePen className="h-4 w-4" />
|
||||||
|
<span>Add Channel/Contact</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* List */}
|
{/* List */}
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto [contain:layout_paint]">
|
<div className="flex-1 min-h-0 overflow-y-auto [contain:layout_paint]">
|
||||||
|
<div className="px-3 py-2 border-b border-border/60">
|
||||||
|
<div className="relative min-w-0">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search channels/contacts..."
|
||||||
|
aria-label="Search conversations"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className={cn('h-7 text-[13px] bg-background/50', searchQuery ? 'pr-8' : 'pr-3')}
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded"
|
||||||
|
onClick={() => setSearchQuery('')}
|
||||||
|
title="Clear search"
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Tools */}
|
{/* Tools */}
|
||||||
{toolRows.length > 0 && (
|
{toolRows.length > 0 && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -0,0 +1,721 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||||
|
import { ArrowDown, ArrowUp, Plus, X } from 'lucide-react';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Contact,
|
||||||
|
RadioConfig,
|
||||||
|
RadioTraceHopRequest,
|
||||||
|
RadioTraceNode,
|
||||||
|
RadioTraceResponse,
|
||||||
|
} from '../types';
|
||||||
|
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||||
|
import { calculateDistance, isValidLocation } from '../utils/pathUtils';
|
||||||
|
import { getContactDisplayName } from '../utils/pubkey';
|
||||||
|
import { handleKeyboardActivate } from '../utils/a11y';
|
||||||
|
import { ContactAvatar } from './ContactAvatar';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from './ui/dialog';
|
||||||
|
import { Input } from './ui/input';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
type TraceSortMode = 'alpha' | 'recent' | 'distance';
|
||||||
|
type CustomHopBytes = 1 | 2 | 4;
|
||||||
|
|
||||||
|
type TraceDraftHop =
|
||||||
|
| { id: string; kind: 'repeater'; publicKey: string }
|
||||||
|
| { id: string; kind: 'custom'; hopHex: string; hopBytes: CustomHopBytes };
|
||||||
|
|
||||||
|
interface TracePaneProps {
|
||||||
|
contacts: Contact[];
|
||||||
|
config: RadioConfig | null;
|
||||||
|
onRunTracePath: (
|
||||||
|
hopHashBytes: CustomHopBytes,
|
||||||
|
hops: RadioTraceHopRequest[]
|
||||||
|
) => Promise<RadioTraceResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHeardTimestamp(contact: Contact): number {
|
||||||
|
return Math.max(contact.last_seen ?? 0, contact.last_advert ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDistanceKm(contact: Contact, config: RadioConfig | null): number | null {
|
||||||
|
if (
|
||||||
|
!config ||
|
||||||
|
!isValidLocation(config.lat, config.lon) ||
|
||||||
|
!isValidLocation(contact.lat, contact.lon)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return calculateDistance(config.lat, config.lon, contact.lat, contact.lon);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getShortKey(publicKey: string | null | undefined): string {
|
||||||
|
if (!publicKey) return 'unknown';
|
||||||
|
return publicKey.slice(0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSNR(snr: number | null | undefined): string {
|
||||||
|
if (typeof snr !== 'number' || Number.isNaN(snr)) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
return `${snr >= 0 ? '+' : ''}${snr.toFixed(1)} dB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveHop(hops: TraceDraftHop[], index: number, direction: -1 | 1): TraceDraftHop[] {
|
||||||
|
const nextIndex = index + direction;
|
||||||
|
if (nextIndex < 0 || nextIndex >= hops.length) {
|
||||||
|
return hops;
|
||||||
|
}
|
||||||
|
const next = [...hops];
|
||||||
|
const [item] = next.splice(index, 1);
|
||||||
|
next.splice(nextIndex, 0, item);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCustomHopHex(value: string): string {
|
||||||
|
return value.replace(/[^a-fA-F0-9]/g, '').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextDraftHopId(prefix: string, currentLength: number): string {
|
||||||
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
|
return `${prefix}-${crypto.randomUUID()}`;
|
||||||
|
}
|
||||||
|
return `${prefix}-${Date.now()}-${currentLength}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TraceNodeRow({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
meta,
|
||||||
|
note,
|
||||||
|
fixed = false,
|
||||||
|
compact = false,
|
||||||
|
actions,
|
||||||
|
snr,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
meta?: string | null;
|
||||||
|
note?: string | null;
|
||||||
|
fixed?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
actions?: ReactNode;
|
||||||
|
snr?: string | null;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center rounded-md border border-border bg-background',
|
||||||
|
compact ? 'gap-2 px-2.5 py-2' : 'gap-3 px-3 py-3'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-9 w-9 items-center justify-center rounded-full border text-[11px] font-semibold uppercase tracking-wide',
|
||||||
|
fixed
|
||||||
|
? 'border-primary/30 bg-primary/10 text-primary'
|
||||||
|
: 'border-border bg-muted text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{fixed ? 'Self' : 'Hop'}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-sm font-medium">{title}</div>
|
||||||
|
<div className="truncate text-xs text-muted-foreground">{subtitle}</div>
|
||||||
|
{meta ? <div className="mt-1 text-[11px] text-muted-foreground">{meta}</div> : null}
|
||||||
|
{note ? <div className="mt-1 text-[11px] text-muted-foreground">{note}</div> : null}
|
||||||
|
</div>
|
||||||
|
{snr ? (
|
||||||
|
<div className="shrink-0 text-right">
|
||||||
|
<div className="text-[11px] text-muted-foreground">SNR</div>
|
||||||
|
<div className="font-mono text-sm">{snr}</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{actions ? <div className="ml-1 flex items-center gap-1">{actions}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps) {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [sortMode, setSortMode] = useState<TraceSortMode>('alpha');
|
||||||
|
const [draftHops, setDraftHops] = useState<TraceDraftHop[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [result, setResult] = useState<RadioTraceResponse | null>(null);
|
||||||
|
const [customDialogOpen, setCustomDialogOpen] = useState(false);
|
||||||
|
const [customHopBytesDraft, setCustomHopBytesDraft] = useState<CustomHopBytes>(1);
|
||||||
|
const [customHopHexDraft, setCustomHopHexDraft] = useState('');
|
||||||
|
const [customHopError, setCustomHopError] = useState<string | null>(null);
|
||||||
|
const activeRunTokenRef = useRef(0);
|
||||||
|
|
||||||
|
const repeaters = useMemo(() => {
|
||||||
|
const deduped = new Map<string, Contact>();
|
||||||
|
for (const contact of contacts) {
|
||||||
|
if (contact.type !== CONTACT_TYPE_REPEATER || contact.public_key.length !== 64) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!deduped.has(contact.public_key)) {
|
||||||
|
deduped.set(contact.public_key, contact);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...deduped.values()];
|
||||||
|
}, [contacts]);
|
||||||
|
|
||||||
|
const repeatersByKey = useMemo(
|
||||||
|
() => new Map(repeaters.map((contact) => [contact.public_key, contact])),
|
||||||
|
[repeaters]
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredRepeaters = useMemo(() => {
|
||||||
|
const query = searchQuery.trim().toLowerCase();
|
||||||
|
const matching = query
|
||||||
|
? repeaters.filter(
|
||||||
|
(contact) =>
|
||||||
|
contact.public_key.toLowerCase().includes(query) ||
|
||||||
|
(contact.name ?? '').toLowerCase().includes(query)
|
||||||
|
)
|
||||||
|
: repeaters;
|
||||||
|
|
||||||
|
return [...matching].sort((left, right) => {
|
||||||
|
if (sortMode === 'recent') {
|
||||||
|
const leftTs = getHeardTimestamp(left);
|
||||||
|
const rightTs = getHeardTimestamp(right);
|
||||||
|
if (leftTs !== rightTs) {
|
||||||
|
return rightTs - leftTs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sortMode === 'distance') {
|
||||||
|
const leftDistance = getDistanceKm(left, config);
|
||||||
|
const rightDistance = getDistanceKm(right, config);
|
||||||
|
if (leftDistance !== null && rightDistance !== null && leftDistance !== rightDistance) {
|
||||||
|
return leftDistance - rightDistance;
|
||||||
|
}
|
||||||
|
if (leftDistance !== null && rightDistance === null) return -1;
|
||||||
|
if (leftDistance === null && rightDistance !== null) return 1;
|
||||||
|
}
|
||||||
|
return getContactDisplayName(left.name, left.public_key, left.last_advert).localeCompare(
|
||||||
|
getContactDisplayName(right.name, right.public_key, right.last_advert)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [config, repeaters, searchQuery, sortMode]);
|
||||||
|
|
||||||
|
const localRadioName = config?.name || 'Local radio';
|
||||||
|
const localRadioKey = config?.public_key ?? null;
|
||||||
|
const canSortByDistance = !!config && isValidLocation(config.lat, config.lon);
|
||||||
|
const customHopBytesLocked = useMemo(
|
||||||
|
() => draftHops.find((hop) => hop.kind === 'custom')?.hopBytes ?? null,
|
||||||
|
[draftHops]
|
||||||
|
);
|
||||||
|
const effectiveHopHashBytes: CustomHopBytes = customHopBytesLocked ?? 4;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!customDialogOpen) return;
|
||||||
|
setCustomHopBytesDraft(customHopBytesLocked ?? 1);
|
||||||
|
setCustomHopHexDraft('');
|
||||||
|
setCustomHopError(null);
|
||||||
|
}, [customDialogOpen, customHopBytesLocked]);
|
||||||
|
|
||||||
|
const clearPendingResult = () => {
|
||||||
|
activeRunTokenRef.current += 1;
|
||||||
|
setLoading(false);
|
||||||
|
if (result) setResult(null);
|
||||||
|
if (error) setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddRepeater = (publicKey: string) => {
|
||||||
|
setDraftHops((current) => [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
id: nextDraftHopId('repeater', current.length),
|
||||||
|
kind: 'repeater',
|
||||||
|
publicKey,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
clearPendingResult();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddCustomHop = () => {
|
||||||
|
const hopBytes = customHopBytesLocked ?? customHopBytesDraft;
|
||||||
|
const hopHex = normalizeCustomHopHex(customHopHexDraft);
|
||||||
|
if (hopHex.length !== hopBytes * 2) {
|
||||||
|
setCustomHopError(`Custom hop must be exactly ${hopBytes * 2} hex characters.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDraftHops((current) => [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
id: nextDraftHopId('custom', current.length),
|
||||||
|
kind: 'custom',
|
||||||
|
hopHex,
|
||||||
|
hopBytes,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
clearPendingResult();
|
||||||
|
setCustomDialogOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveHop = (id: string) => {
|
||||||
|
setDraftHops((current) => current.filter((hop) => hop.id !== id));
|
||||||
|
clearPendingResult();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMoveHop = (index: number, direction: -1 | 1) => {
|
||||||
|
setDraftHops((current) => moveHop(current, index, direction));
|
||||||
|
clearPendingResult();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRunTrace = async () => {
|
||||||
|
if (draftHops.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const runToken = activeRunTokenRef.current + 1;
|
||||||
|
activeRunTokenRef.current = runToken;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setResult(null);
|
||||||
|
try {
|
||||||
|
const traceResult = await onRunTracePath(
|
||||||
|
effectiveHopHashBytes,
|
||||||
|
draftHops.map((hop) =>
|
||||||
|
hop.kind === 'repeater' ? { public_key: hop.publicKey } : { hop_hex: hop.hopHex }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (activeRunTokenRef.current !== runToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setResult(traceResult);
|
||||||
|
} catch (err) {
|
||||||
|
if (activeRunTokenRef.current !== runToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||||
|
} finally {
|
||||||
|
if (activeRunTokenRef.current === runToken) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resultNodes: RadioTraceNode[] = result
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
role: 'local',
|
||||||
|
public_key: localRadioKey,
|
||||||
|
name: localRadioName,
|
||||||
|
observed_hash: null,
|
||||||
|
snr: null,
|
||||||
|
},
|
||||||
|
...result.nodes,
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-col overflow-y-auto lg:overflow-hidden">
|
||||||
|
<div className="shrink-0 border-b border-border px-4 py-3">
|
||||||
|
<h2 className="text-base font-semibold">Trace</h2>
|
||||||
|
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
|
||||||
|
Build a repeater loop and trace it back to the local radio. The selectable hop list only
|
||||||
|
includes known full-key repeaters, but you can also add custom repeater prefixes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-4 p-4 lg:min-h-0 lg:flex-row lg:overflow-hidden">
|
||||||
|
<section className="flex w-full flex-col rounded-lg border border-border bg-card lg:min-h-0 lg:max-w-[24rem]">
|
||||||
|
<div className="shrink-0 border-b border-border p-4">
|
||||||
|
<h3 className="text-sm font-semibold">Repeater Hops</h3>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Search by name or key, then add repeaters in the order you want to traverse them.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="mt-3"
|
||||||
|
onClick={() => setCustomDialogOpen(true)}
|
||||||
|
>
|
||||||
|
Custom path
|
||||||
|
</Button>
|
||||||
|
<Input
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(event) => setSearchQuery(event.target.value)}
|
||||||
|
placeholder="Search name or public key"
|
||||||
|
aria-label="Search repeaters"
|
||||||
|
className="mt-3"
|
||||||
|
/>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['alpha', 'Alpha'],
|
||||||
|
['recent', 'Recent Heard'],
|
||||||
|
['distance', 'Distance'],
|
||||||
|
] as const
|
||||||
|
).map(([value, label]) => (
|
||||||
|
<Button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={sortMode === value ? 'default' : 'outline'}
|
||||||
|
onClick={() => setSortMode(value)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{sortMode === 'distance' && !canSortByDistance ? (
|
||||||
|
<p className="mt-2 text-[11px] text-muted-foreground">
|
||||||
|
Distance sorting is using known repeater coordinates, but the local radio does not
|
||||||
|
currently have a valid location.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[40vh] overflow-y-auto p-2 lg:min-h-0 lg:max-h-none lg:flex-1">
|
||||||
|
{filteredRepeaters.length === 0 ? (
|
||||||
|
<div className="rounded-md border border-dashed border-border px-3 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
No repeaters matched this search.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filteredRepeaters.map((contact) => {
|
||||||
|
const displayName = getContactDisplayName(
|
||||||
|
contact.name,
|
||||||
|
contact.public_key,
|
||||||
|
contact.last_advert
|
||||||
|
);
|
||||||
|
const distanceKm = getDistanceKm(contact, config);
|
||||||
|
const selectedCount = draftHops.filter(
|
||||||
|
(hop) => hop.kind === 'repeater' && hop.publicKey === contact.public_key
|
||||||
|
).length;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={contact.public_key}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`Add repeater ${displayName}`}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-3 rounded-md border px-3 py-3 text-left transition-colors',
|
||||||
|
selectedCount > 0
|
||||||
|
? 'border-primary/30 bg-primary/5'
|
||||||
|
: 'border-border bg-background hover:bg-accent'
|
||||||
|
)}
|
||||||
|
onClick={() => handleAddRepeater(contact.public_key)}
|
||||||
|
onKeyDown={handleKeyboardActivate}
|
||||||
|
>
|
||||||
|
<ContactAvatar
|
||||||
|
name={contact.name}
|
||||||
|
publicKey={contact.public_key}
|
||||||
|
size={28}
|
||||||
|
contactType={contact.type}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-sm font-medium">{displayName}</div>
|
||||||
|
<div className="truncate text-xs text-muted-foreground">
|
||||||
|
{getShortKey(contact.public_key)}
|
||||||
|
</div>
|
||||||
|
{sortMode === 'distance' && distanceKm !== null ? (
|
||||||
|
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||||
|
{distanceKm.toFixed(1)} km away
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{selectedCount > 0 ? (
|
||||||
|
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||||
|
Added {selectedCount} time{selectedCount === 1 ? '' : 's'}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-input bg-background text-muted-foreground"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-1 flex-col gap-4 lg:min-h-0 lg:overflow-hidden">
|
||||||
|
<div className="flex flex-col rounded-lg border border-border bg-card lg:min-h-0 lg:max-h-[50%]">
|
||||||
|
<div className="shrink-0 flex items-start justify-between gap-3 border-b border-border px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">Trace Path</h3>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
The first node is display-only. The terminal node is the local radio.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{draftHops.length > 0 ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="shrink-0 text-muted-foreground"
|
||||||
|
onClick={() => {
|
||||||
|
setDraftHops([]);
|
||||||
|
clearPendingResult();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 p-4 lg:min-h-0 lg:flex-1 lg:overflow-y-auto">
|
||||||
|
<TraceNodeRow
|
||||||
|
title={localRadioName}
|
||||||
|
subtitle={getShortKey(localRadioKey)}
|
||||||
|
meta="Origin"
|
||||||
|
fixed
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
{draftHops.length === 0 ? (
|
||||||
|
<div className="rounded-md border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
|
||||||
|
Add at least one hop to build a trace loop.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
draftHops.map((hop, index) => {
|
||||||
|
const contact =
|
||||||
|
hop.kind === 'repeater' ? (repeatersByKey.get(hop.publicKey) ?? null) : null;
|
||||||
|
const displayName =
|
||||||
|
hop.kind === 'repeater'
|
||||||
|
? getContactDisplayName(
|
||||||
|
contact?.name,
|
||||||
|
hop.publicKey,
|
||||||
|
contact?.last_advert ?? null
|
||||||
|
)
|
||||||
|
: 'Custom hop';
|
||||||
|
const subtitle =
|
||||||
|
hop.kind === 'repeater'
|
||||||
|
? getShortKey(hop.publicKey)
|
||||||
|
: `${hop.hopHex.toUpperCase()} (${hop.hopBytes}-byte)`;
|
||||||
|
return (
|
||||||
|
<div key={hop.id}>
|
||||||
|
<TraceNodeRow
|
||||||
|
title={displayName}
|
||||||
|
subtitle={subtitle}
|
||||||
|
meta={`Hop ${index + 1}`}
|
||||||
|
note={
|
||||||
|
index === draftHops.length - 1
|
||||||
|
? 'Note: you must be able to hear the final repeater in the trace for trace success.'
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
compact
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8"
|
||||||
|
aria-label={`Move ${displayName} up`}
|
||||||
|
onClick={() => handleMoveHop(index, -1)}
|
||||||
|
disabled={index === 0}
|
||||||
|
>
|
||||||
|
<ArrowUp className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8"
|
||||||
|
aria-label={`Move ${displayName} down`}
|
||||||
|
onClick={() => handleMoveHop(index, 1)}
|
||||||
|
disabled={index === draftHops.length - 1}
|
||||||
|
>
|
||||||
|
<ArrowDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8"
|
||||||
|
aria-label={`Remove ${displayName}`}
|
||||||
|
onClick={() => handleRemoveHop(hop.id)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
<TraceNodeRow
|
||||||
|
title={localRadioName}
|
||||||
|
subtitle={getShortKey(localRadioKey)}
|
||||||
|
meta="Terminal"
|
||||||
|
fixed
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 flex flex-wrap items-center justify-between gap-3 border-t border-border px-4 py-3">
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{draftHops.length === 0
|
||||||
|
? 'No hops selected'
|
||||||
|
: `${draftHops.length} hop${draftHops.length === 1 ? '' : 's'} selected · ${effectiveHopHashBytes}-byte trace`}
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleRunTrace} disabled={loading || draftHops.length === 0}>
|
||||||
|
{loading ? 'Tracing...' : 'Send trace'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col rounded-lg border border-border bg-card lg:min-h-0 lg:flex-1">
|
||||||
|
<div className="shrink-0 flex items-center justify-between gap-3 border-b border-border px-4 py-3">
|
||||||
|
<h3 className="text-sm font-semibold">
|
||||||
|
Results{result ? ` (${result.timeout_seconds.toFixed(1)}s)` : ''}
|
||||||
|
</h3>
|
||||||
|
{result || error ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="shrink-0 text-muted-foreground"
|
||||||
|
onClick={() => {
|
||||||
|
setResult(null);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 space-y-3 p-4 lg:overflow-y-auto">
|
||||||
|
{error ? (
|
||||||
|
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{!error && !result ? (
|
||||||
|
<div className="rounded-md border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
|
||||||
|
Send a trace to see the returned hop-by-hop SNR values.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{result
|
||||||
|
? resultNodes.map((node, index) => {
|
||||||
|
const title =
|
||||||
|
node.name ||
|
||||||
|
(node.role === 'custom'
|
||||||
|
? 'Custom hop'
|
||||||
|
: node.role === 'local'
|
||||||
|
? localRadioName
|
||||||
|
: getShortKey(node.public_key));
|
||||||
|
const subtitle =
|
||||||
|
node.role === 'custom'
|
||||||
|
? `Key prefix ${node.observed_hash?.toUpperCase() ?? 'unknown'}`
|
||||||
|
: node.observed_hash &&
|
||||||
|
node.public_key &&
|
||||||
|
node.observed_hash.toLowerCase() !==
|
||||||
|
getShortKey(node.public_key).toLowerCase()
|
||||||
|
? `${getShortKey(node.public_key)} · key prefix ${node.observed_hash.toUpperCase()}`
|
||||||
|
: getShortKey(node.public_key);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${node.role}-${node.public_key ?? node.observed_hash ?? 'local'}-${index}`}
|
||||||
|
>
|
||||||
|
<TraceNodeRow
|
||||||
|
title={title}
|
||||||
|
subtitle={subtitle}
|
||||||
|
meta={
|
||||||
|
index === 0
|
||||||
|
? 'Origin'
|
||||||
|
: node.role === 'local'
|
||||||
|
? 'Terminal'
|
||||||
|
: `Hop ${index}`
|
||||||
|
}
|
||||||
|
fixed={node.role === 'local'}
|
||||||
|
snr={index === 0 ? null : formatSNR(node.snr)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={customDialogOpen} onOpenChange={setCustomDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[440px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Custom path hop</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Add a raw repeater prefix as a 1-byte, 2-byte, or 4-byte hop. Once you add a custom
|
||||||
|
hop, all later custom hops must use the same byte width.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm font-medium">Hop width</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{([1, 2, 4] as const).map((value) => {
|
||||||
|
const locked = customHopBytesLocked !== null && customHopBytesLocked !== value;
|
||||||
|
const active = (customHopBytesLocked ?? customHopBytesDraft) === value;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={active ? 'default' : 'outline'}
|
||||||
|
disabled={locked}
|
||||||
|
onClick={() => setCustomHopBytesDraft(value)}
|
||||||
|
>
|
||||||
|
{value}-byte
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{customHopBytesLocked !== null ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Custom hops are locked to {customHopBytesLocked}-byte prefixes for this trace.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium" htmlFor="custom-hop-hex">
|
||||||
|
Repeater prefix
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="custom-hop-hex"
|
||||||
|
value={customHopHexDraft}
|
||||||
|
onChange={(event) =>
|
||||||
|
setCustomHopHexDraft(normalizeCustomHopHex(event.target.value))
|
||||||
|
}
|
||||||
|
placeholder={`${(customHopBytesLocked ?? customHopBytesDraft) * 2} hex chars`}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Enter exactly {(customHopBytesLocked ?? customHopBytesDraft) * 2} hex characters.
|
||||||
|
</p>
|
||||||
|
{customHopError ? (
|
||||||
|
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{customHopError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:justify-between">
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setCustomDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={handleAddCustomHop}>
|
||||||
|
Add custom hop
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -112,7 +112,10 @@ export function RadioSettingsPane({
|
|||||||
<NotFetched />
|
<NotFetched />
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<KvRow label="Local Advert" value={formatAdvertInterval(advertData.advert_interval)} />
|
<KvRow
|
||||||
|
label="Local Advert"
|
||||||
|
value={formatAdvertInterval(advertData.advert_interval, 'minutes')}
|
||||||
|
/>
|
||||||
<KvRow
|
<KvRow
|
||||||
label="Flood Advert"
|
label="Flood Advert"
|
||||||
value={formatAdvertInterval(advertData.flood_advert_interval)}
|
value={formatAdvertInterval(advertData.flood_advert_interval)}
|
||||||
|
|||||||
@@ -76,11 +76,19 @@ export function formatClockDrift(
|
|||||||
return { text: parts.join(''), isLarge: false };
|
return { text: parts.join(''), isLarge: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatAdvertInterval(val: string | null): string {
|
export function formatAdvertInterval(
|
||||||
|
val: string | null,
|
||||||
|
unit: 'minutes' | 'hours' = 'hours'
|
||||||
|
): string {
|
||||||
if (val == null) return '—';
|
if (val == null) return '—';
|
||||||
const trimmed = val.trim();
|
const trimmed = val.trim();
|
||||||
if (trimmed === '0') return '<disabled>';
|
if (trimmed === '0') return '<disabled>';
|
||||||
return `${trimmed}h`;
|
if (unit === 'hours') return `${trimmed}h`;
|
||||||
|
const mins = parseInt(trimmed, 10);
|
||||||
|
if (isNaN(mins)) return trimmed;
|
||||||
|
if (mins >= 60 && mins % 60 === 0) return `${mins / 60}h`;
|
||||||
|
if (mins >= 60) return `${Math.floor(mins / 60)}h${mins % 60}m`;
|
||||||
|
return `${mins}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFetchedRelative(fetchedAt: number): string {
|
function formatFetchedRelative(fetchedAt: number): string {
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo, useRef, lazy, Suspense } from 'react';
|
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 { Input } from '../ui/input';
|
||||||
import { Label } from '../ui/label';
|
import { Label } from '../ui/label';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { Separator } from '../ui/separator';
|
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 { toast } from '../ui/sonner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { api } from '../../api';
|
import { api } from '../../api';
|
||||||
@@ -16,11 +23,12 @@ const BotCodeEditor = lazy(() =>
|
|||||||
|
|
||||||
const TYPE_LABELS: Record<string, string> = {
|
const TYPE_LABELS: Record<string, string> = {
|
||||||
mqtt_private: 'Private MQTT',
|
mqtt_private: 'Private MQTT',
|
||||||
mqtt_community: 'Community MQTT',
|
mqtt_community: 'Community Sharing',
|
||||||
bot: 'Python Bot',
|
bot: 'Python Bot',
|
||||||
webhook: 'Webhook',
|
webhook: 'Webhook',
|
||||||
apprise: 'Apprise',
|
apprise: 'Apprise',
|
||||||
sqs: 'Amazon SQS',
|
sqs: 'Amazon SQS',
|
||||||
|
map_upload: 'Map Upload',
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE = 'meshcore/{IATA}/{PUBLIC_KEY}/packets';
|
const DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE = 'meshcore/{IATA}/{PUBLIC_KEY}/packets';
|
||||||
@@ -100,7 +108,8 @@ type DraftType =
|
|||||||
| 'webhook'
|
| 'webhook'
|
||||||
| 'apprise'
|
| 'apprise'
|
||||||
| 'sqs'
|
| 'sqs'
|
||||||
| 'bot';
|
| 'bot'
|
||||||
|
| 'map_upload';
|
||||||
|
|
||||||
type CreateIntegrationDefinition = {
|
type CreateIntegrationDefinition = {
|
||||||
value: DraftType;
|
value: DraftType;
|
||||||
@@ -143,7 +152,7 @@ const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [
|
|||||||
value: 'mqtt_community',
|
value: 'mqtt_community',
|
||||||
savedType: 'mqtt_community',
|
savedType: 'mqtt_community',
|
||||||
label: 'Community MQTT/meshcoretomqtt',
|
label: 'Community MQTT/meshcoretomqtt',
|
||||||
section: 'Community MQTT',
|
section: 'Community Sharing',
|
||||||
description:
|
description:
|
||||||
'MeshcoreToMQTT-compatible raw-packet feed publishing, compatible with community aggregators (in other words, make your companion radio also serve as an observer node). Superset of other Community MQTT presets.',
|
'MeshcoreToMQTT-compatible raw-packet feed publishing, compatible with community aggregators (in other words, make your companion radio also serve as an observer node). Superset of other Community MQTT presets.',
|
||||||
defaultName: 'Community MQTT',
|
defaultName: 'Community MQTT',
|
||||||
@@ -157,7 +166,7 @@ const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [
|
|||||||
value: 'mqtt_community_meshrank',
|
value: 'mqtt_community_meshrank',
|
||||||
savedType: 'mqtt_community',
|
savedType: 'mqtt_community',
|
||||||
label: 'MeshRank',
|
label: 'MeshRank',
|
||||||
section: 'Community MQTT',
|
section: 'Community Sharing',
|
||||||
description:
|
description:
|
||||||
'A community MQTT config preconfigured for MeshRank, requiring only the provided topic from your MeshRank configuration. A subset of the primary Community MQTT/meshcoretomqtt configuration; you are free to edit all configuration after creation.',
|
'A community MQTT config preconfigured for MeshRank, requiring only the provided topic from your MeshRank configuration. A subset of the primary Community MQTT/meshcoretomqtt configuration; you are free to edit all configuration after creation.',
|
||||||
defaultName: 'MeshRank',
|
defaultName: 'MeshRank',
|
||||||
@@ -180,7 +189,7 @@ const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [
|
|||||||
value: 'mqtt_community_letsmesh_us',
|
value: 'mqtt_community_letsmesh_us',
|
||||||
savedType: 'mqtt_community',
|
savedType: 'mqtt_community',
|
||||||
label: 'LetsMesh (US)',
|
label: 'LetsMesh (US)',
|
||||||
section: 'Community MQTT',
|
section: 'Community Sharing',
|
||||||
description:
|
description:
|
||||||
'A community MQTT config preconfigured for the LetsMesh US-ingest endpoint, requiring only your email and IATA region code. Good to use with an additional EU configuration for redundancy. A subset of the primary Community MQTT/meshcoretomqtt configuration; you are free to edit all configuration after creation.',
|
'A community MQTT config preconfigured for the LetsMesh US-ingest endpoint, requiring only your email and IATA region code. Good to use with an additional EU configuration for redundancy. A subset of the primary Community MQTT/meshcoretomqtt configuration; you are free to edit all configuration after creation.',
|
||||||
defaultName: 'LetsMesh (US)',
|
defaultName: 'LetsMesh (US)',
|
||||||
@@ -197,7 +206,7 @@ const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [
|
|||||||
value: 'mqtt_community_letsmesh_eu',
|
value: 'mqtt_community_letsmesh_eu',
|
||||||
savedType: 'mqtt_community',
|
savedType: 'mqtt_community',
|
||||||
label: 'LetsMesh (EU)',
|
label: 'LetsMesh (EU)',
|
||||||
section: 'Community MQTT',
|
section: 'Community Sharing',
|
||||||
description:
|
description:
|
||||||
'A community MQTT config preconfigured for the LetsMesh EU-ingest endpoint, requiring only your email and IATA region code. Good to use with an additional US configuration for redundancy. A subset of the primary Community MQTT/meshcoretomqtt configuration; you are free to edit all configuration after creation.',
|
'A community MQTT config preconfigured for the LetsMesh EU-ingest endpoint, requiring only your email and IATA region code. Good to use with an additional US configuration for redundancy. A subset of the primary Community MQTT/meshcoretomqtt configuration; you are free to edit all configuration after creation.',
|
||||||
defaultName: 'LetsMesh (EU)',
|
defaultName: 'LetsMesh (EU)',
|
||||||
@@ -284,12 +293,103 @@ const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [
|
|||||||
scope: { messages: 'all', raw_packets: 'none' },
|
scope: { messages: 'all', raw_packets: 'none' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: 'map_upload',
|
||||||
|
savedType: 'map_upload',
|
||||||
|
label: 'Map Upload',
|
||||||
|
section: 'Community Sharing',
|
||||||
|
description:
|
||||||
|
'Upload repeaters and room servers to map.meshcore.dev or a compatible map API endpoint.',
|
||||||
|
defaultName: 'Map Upload',
|
||||||
|
nameMode: 'counted',
|
||||||
|
defaults: {
|
||||||
|
config: {
|
||||||
|
api_url: '',
|
||||||
|
dry_run: true,
|
||||||
|
},
|
||||||
|
scope: { messages: 'none', raw_packets: 'all' },
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const CREATE_INTEGRATION_DEFINITIONS_BY_VALUE = Object.fromEntries(
|
const CREATE_INTEGRATION_DEFINITIONS_BY_VALUE = Object.fromEntries(
|
||||||
CREATE_INTEGRATION_DEFINITIONS.map((definition) => [definition.value, definition])
|
CREATE_INTEGRATION_DEFINITIONS.map((definition) => [definition.value, definition])
|
||||||
) as Record<DraftType, CreateIntegrationDefinition>;
|
) as Record<DraftType, CreateIntegrationDefinition>;
|
||||||
|
|
||||||
|
function getNumberInputValue(value: unknown, fallback: number): string | number {
|
||||||
|
if (value === '') return '';
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOptionalNumberInputValue(value: unknown): string | number {
|
||||||
|
if (value === '') return '';
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIntegerInputValue(value: string): number | string {
|
||||||
|
if (value === '') return '';
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
return Number.isNaN(parsed) ? value : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFloatInputValue(value: string): number | string {
|
||||||
|
if (value === '') return '';
|
||||||
|
const parsed = Number.parseFloat(value);
|
||||||
|
return Number.isNaN(parsed) ? value : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIntegrationConfigForSave(
|
||||||
|
configType: string,
|
||||||
|
config: Record<string, unknown>
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const normalized = { ...config };
|
||||||
|
|
||||||
|
if (configType === 'mqtt_private') {
|
||||||
|
const port = normalized.broker_port;
|
||||||
|
if (port === '' || port === undefined || port === null) {
|
||||||
|
normalized.broker_port = 1883;
|
||||||
|
} else if (typeof port === 'string') {
|
||||||
|
const parsed = Number.parseInt(port, 10);
|
||||||
|
normalized.broker_port = Number.isNaN(parsed) ? 1883 : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const topicPrefix = String(normalized.topic_prefix ?? '').trim();
|
||||||
|
normalized.topic_prefix = topicPrefix || 'meshcore';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configType === 'mqtt_community') {
|
||||||
|
const brokerHost = String(normalized.broker_host ?? '').trim();
|
||||||
|
normalized.broker_host = brokerHost || DEFAULT_COMMUNITY_BROKER_HOST;
|
||||||
|
|
||||||
|
const port = normalized.broker_port;
|
||||||
|
if (port === '' || port === undefined || port === null) {
|
||||||
|
normalized.broker_port = DEFAULT_COMMUNITY_BROKER_PORT;
|
||||||
|
} else if (typeof port === 'string') {
|
||||||
|
const parsed = Number.parseInt(port, 10);
|
||||||
|
normalized.broker_port = Number.isNaN(parsed) ? DEFAULT_COMMUNITY_BROKER_PORT : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const topicTemplate = String(normalized.topic_template ?? '').trim();
|
||||||
|
normalized.topic_template = topicTemplate || DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configType === 'map_upload') {
|
||||||
|
const radius = normalized.geofence_radius_km;
|
||||||
|
if (radius === '' || radius === undefined || radius === null) {
|
||||||
|
normalized.geofence_radius_km = 0;
|
||||||
|
} else if (typeof radius === 'string') {
|
||||||
|
const parsed = Number.parseFloat(radius);
|
||||||
|
normalized.geofence_radius_km = Number.isNaN(parsed) ? 0 : parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
function isDraftType(value: string): value is DraftType {
|
function isDraftType(value: string): value is DraftType {
|
||||||
return value in CREATE_INTEGRATION_DEFINITIONS_BY_VALUE;
|
return value in CREATE_INTEGRATION_DEFINITIONS_BY_VALUE;
|
||||||
}
|
}
|
||||||
@@ -312,7 +412,7 @@ function normalizeDraftConfig(draftType: DraftType, config: Record<string, unkno
|
|||||||
throw new Error('MeshRank packet topic is required');
|
throw new Error('MeshRank packet topic is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return normalizeIntegrationConfigForSave('mqtt_community', {
|
||||||
...config,
|
...config,
|
||||||
broker_host: DEFAULT_MESHRANK_BROKER_HOST,
|
broker_host: DEFAULT_MESHRANK_BROKER_HOST,
|
||||||
broker_port: DEFAULT_MESHRANK_BROKER_PORT,
|
broker_port: DEFAULT_MESHRANK_BROKER_PORT,
|
||||||
@@ -326,7 +426,7 @@ function normalizeDraftConfig(draftType: DraftType, config: Record<string, unkno
|
|||||||
topic_template: topicTemplate,
|
topic_template: topicTemplate,
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (draftType === 'mqtt_community_letsmesh_us' || draftType === 'mqtt_community_letsmesh_eu') {
|
if (draftType === 'mqtt_community_letsmesh_us' || draftType === 'mqtt_community_letsmesh_eu') {
|
||||||
@@ -334,7 +434,7 @@ function normalizeDraftConfig(draftType: DraftType, config: Record<string, unkno
|
|||||||
draftType === 'mqtt_community_letsmesh_eu'
|
draftType === 'mqtt_community_letsmesh_eu'
|
||||||
? DEFAULT_COMMUNITY_BROKER_HOST_EU
|
? DEFAULT_COMMUNITY_BROKER_HOST_EU
|
||||||
: DEFAULT_COMMUNITY_BROKER_HOST;
|
: DEFAULT_COMMUNITY_BROKER_HOST;
|
||||||
return {
|
return normalizeIntegrationConfigForSave('mqtt_community', {
|
||||||
...config,
|
...config,
|
||||||
broker_host: brokerHost,
|
broker_host: brokerHost,
|
||||||
broker_port: DEFAULT_COMMUNITY_BROKER_PORT,
|
broker_port: DEFAULT_COMMUNITY_BROKER_PORT,
|
||||||
@@ -346,10 +446,13 @@ function normalizeDraftConfig(draftType: DraftType, config: Record<string, unkno
|
|||||||
topic_template: (config.topic_template as string) || DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE,
|
topic_template: (config.topic_template as string) || DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE,
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return config;
|
return normalizeIntegrationConfigForSave(
|
||||||
|
getCreateIntegrationDefinition(draftType).savedType,
|
||||||
|
config
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDraftScope(draftType: DraftType, scope: Record<string, unknown>) {
|
function normalizeDraftScope(draftType: DraftType, scope: Record<string, unknown>) {
|
||||||
@@ -566,7 +669,9 @@ function getDefaultIntegrationName(type: string, configs: FanoutConfig[]) {
|
|||||||
|
|
||||||
function getStatusLabel(status: string | undefined, type?: string) {
|
function getStatusLabel(status: string | undefined, type?: string) {
|
||||||
if (status === 'connected')
|
if (status === 'connected')
|
||||||
return type === 'bot' || type === 'webhook' || type === 'apprise' ? 'Active' : 'Connected';
|
return type === 'bot' || type === 'webhook' || type === 'apprise' || type === 'map_upload'
|
||||||
|
? 'Active'
|
||||||
|
: 'Connected';
|
||||||
if (status === 'error') return 'Error';
|
if (status === 'error') return 'Error';
|
||||||
if (status === 'disconnected') return 'Disconnected';
|
if (status === 'disconnected') return 'Disconnected';
|
||||||
return 'Inactive';
|
return 'Inactive';
|
||||||
@@ -621,9 +726,9 @@ function MqttPrivateConfigEditor({
|
|||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="65535"
|
max="65535"
|
||||||
value={(config.broker_port as number) || 1883}
|
value={getNumberInputValue(config.broker_port, 1883)}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onChange({ ...config, broker_port: parseInt(e.target.value, 10) || 1883 })
|
onChange({ ...config, broker_port: parseIntegerInputValue(e.target.value) })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -681,7 +786,8 @@ function MqttPrivateConfigEditor({
|
|||||||
<Input
|
<Input
|
||||||
id="fanout-mqtt-prefix"
|
id="fanout-mqtt-prefix"
|
||||||
type="text"
|
type="text"
|
||||||
value={(config.topic_prefix as string) || 'meshcore'}
|
placeholder="meshcore"
|
||||||
|
value={(config.topic_prefix as string | undefined) ?? ''}
|
||||||
onChange={(e) => onChange({ ...config, topic_prefix: e.target.value })}
|
onChange={(e) => onChange({ ...config, topic_prefix: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -717,7 +823,7 @@ function MqttCommunityConfigEditor({
|
|||||||
id="fanout-comm-host"
|
id="fanout-comm-host"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={DEFAULT_COMMUNITY_BROKER_HOST}
|
placeholder={DEFAULT_COMMUNITY_BROKER_HOST}
|
||||||
value={(config.broker_host as string) || DEFAULT_COMMUNITY_BROKER_HOST}
|
value={(config.broker_host as string | undefined) ?? ''}
|
||||||
onChange={(e) => onChange({ ...config, broker_host: e.target.value })}
|
onChange={(e) => onChange({ ...config, broker_host: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -728,11 +834,11 @@ function MqttCommunityConfigEditor({
|
|||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="65535"
|
max="65535"
|
||||||
value={(config.broker_port as number) || DEFAULT_COMMUNITY_BROKER_PORT}
|
value={getNumberInputValue(config.broker_port, DEFAULT_COMMUNITY_BROKER_PORT)}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onChange({
|
onChange({
|
||||||
...config,
|
...config,
|
||||||
broker_port: parseInt(e.target.value, 10) || DEFAULT_COMMUNITY_BROKER_PORT,
|
broker_port: parseIntegerInputValue(e.target.value),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -867,7 +973,8 @@ function MqttCommunityConfigEditor({
|
|||||||
<Input
|
<Input
|
||||||
id="fanout-comm-topic-template"
|
id="fanout-comm-topic-template"
|
||||||
type="text"
|
type="text"
|
||||||
value={(config.topic_template as string) || DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE}
|
placeholder={DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE}
|
||||||
|
value={(config.topic_template as string | undefined) ?? ''}
|
||||||
onChange={(e) => onChange({ ...config, topic_template: e.target.value })}
|
onChange={(e) => onChange({ ...config, topic_template: e.target.value })}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -1059,6 +1166,152 @@ function BotConfigEditor({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MapUploadConfigEditor({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
onChange: (config: Record<string, unknown>) => void;
|
||||||
|
}) {
|
||||||
|
const isDryRun = config.dry_run !== false;
|
||||||
|
const [radioLat, setRadioLat] = useState<number | null>(null);
|
||||||
|
const [radioLon, setRadioLon] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api
|
||||||
|
.getRadioConfig()
|
||||||
|
.then((rc) => {
|
||||||
|
setRadioLat(rc.lat ?? 0);
|
||||||
|
setRadioLon(rc.lon ?? 0);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setRadioLat(0);
|
||||||
|
setRadioLon(0);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const radioLatLonConfigured =
|
||||||
|
radioLat !== null && radioLon !== null && !(radioLat === 0 && radioLon === 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Automatically upload heard repeater and room server advertisements to{' '}
|
||||||
|
<a
|
||||||
|
href="https://map.meshcore.dev"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline hover:text-foreground"
|
||||||
|
>
|
||||||
|
map.meshcore.dev
|
||||||
|
</a>
|
||||||
|
. Requires the radio's private key to be available (firmware must have{' '}
|
||||||
|
<code>ENABLE_PRIVATE_KEY_EXPORT=1</code>). Only raw RF packets are shared — never
|
||||||
|
decrypted messages.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-xs text-warning">
|
||||||
|
<strong>Dry Run is {isDryRun ? 'ON' : 'OFF'}.</strong>{' '}
|
||||||
|
{isDryRun
|
||||||
|
? 'No uploads will be sent. Check the backend logs to verify the payload looks correct before enabling live sends.'
|
||||||
|
: 'Live uploads are enabled. Each advert is rate-limited to once per hour per node.'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isDryRun}
|
||||||
|
onChange={(e) => onChange({ ...config, dry_run: e.target.checked })}
|
||||||
|
className="h-4 w-4 rounded border-border"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium">Dry Run (log only, no uploads)</span>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
When enabled, upload payloads are logged at INFO level but not sent. Disable once you
|
||||||
|
have confirmed the logged output looks correct.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="fanout-map-api-url">API URL (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="fanout-map-api-url"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://map.meshcore.dev/api/v1/uploader/node"
|
||||||
|
value={(config.api_url as string) || ''}
|
||||||
|
onChange={(e) => onChange({ ...config, api_url: e.target.value })}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Leave blank to use the default <code>map.meshcore.dev</code> endpoint.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!config.geofence_enabled}
|
||||||
|
onChange={(e) => onChange({ ...config, geofence_enabled: e.target.checked })}
|
||||||
|
className="h-4 w-4 rounded border-border"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium">Enable Geofence</span>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Only upload nodes whose location falls within the configured radius of your radio's
|
||||||
|
own position. Helps exclude nodes with false or spoofed coordinates. Uses the
|
||||||
|
latitude/longitude set in Radio Settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{!!config.geofence_enabled && (
|
||||||
|
<div className="space-y-3 pl-7">
|
||||||
|
{!radioLatLonConfigured && (
|
||||||
|
<div className="rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-xs text-warning">
|
||||||
|
Your radio does not currently have a latitude/longitude configured. Geofencing will be
|
||||||
|
silently skipped until coordinates are set in{' '}
|
||||||
|
<strong>Settings → Radio → Location</strong>.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{radioLatLonConfigured && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Using radio position{' '}
|
||||||
|
<code>
|
||||||
|
{radioLat?.toFixed(5)}, {radioLon?.toFixed(5)}
|
||||||
|
</code>{' '}
|
||||||
|
as the geofence center. Update coordinates in Radio Settings to move the center.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="fanout-map-geofence-radius">Radius (km)</Label>
|
||||||
|
<Input
|
||||||
|
id="fanout-map-geofence-radius"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="any"
|
||||||
|
placeholder="e.g. 100"
|
||||||
|
value={getOptionalNumberInputValue(config.geofence_radius_km)}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({
|
||||||
|
...config,
|
||||||
|
geofence_radius_km: parseFloatInputValue(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Nodes further than this distance from your radio's position will not be uploaded.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type ScopeMode = 'all' | 'none' | 'only' | 'except';
|
type ScopeMode = 'all' | 'none' | 'only' | 'except';
|
||||||
|
|
||||||
function getScopeMode(value: unknown): ScopeMode {
|
function getScopeMode(value: unknown): ScopeMode {
|
||||||
@@ -1687,6 +1940,10 @@ export function SettingsFanoutSection({
|
|||||||
const [inlineEditName, setInlineEditName] = useState('');
|
const [inlineEditName, setInlineEditName] = useState('');
|
||||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||||
const [selectedCreateType, setSelectedCreateType] = useState<DraftType | null>(null);
|
const [selectedCreateType, setSelectedCreateType] = useState<DraftType | null>(null);
|
||||||
|
const [errorDialogState, setErrorDialogState] = useState<{
|
||||||
|
integrationName: string;
|
||||||
|
error: string;
|
||||||
|
} | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const loadConfigs = useCallback(async () => {
|
const loadConfigs = useCallback(async () => {
|
||||||
@@ -1819,9 +2076,10 @@ export function SettingsFanoutSection({
|
|||||||
if (!currentEditingId) {
|
if (!currentEditingId) {
|
||||||
throw new Error('Missing fanout config id for update');
|
throw new Error('Missing fanout config id for update');
|
||||||
}
|
}
|
||||||
|
const editingType = configs.find((cfg) => cfg.id === currentEditingId)?.type ?? '';
|
||||||
const update: Record<string, unknown> = {
|
const update: Record<string, unknown> = {
|
||||||
name: editName,
|
name: editName,
|
||||||
config: editConfig,
|
config: normalizeIntegrationConfigForSave(editingType, editConfig),
|
||||||
scope: editScope,
|
scope: editScope,
|
||||||
};
|
};
|
||||||
if (enabled !== undefined) update.enabled = enabled;
|
if (enabled !== undefined) update.enabled = enabled;
|
||||||
@@ -1975,6 +2233,10 @@ export function SettingsFanoutSection({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{detailType === 'map_upload' && (
|
||||||
|
<MapUploadConfigEditor config={editConfig} onChange={setEditConfig} />
|
||||||
|
)}
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -2036,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 && (
|
{configGroups.length > 0 && (
|
||||||
<div className="columns-1 gap-4 md:columns-2">
|
<div className="columns-1 gap-4 md:columns-2">
|
||||||
{configGroups.map((group) => (
|
{configGroups.map((group) => (
|
||||||
@@ -2049,6 +2336,7 @@ export function SettingsFanoutSection({
|
|||||||
{group.configs.map((cfg) => {
|
{group.configs.map((cfg) => {
|
||||||
const statusEntry = health?.fanout_statuses?.[cfg.id];
|
const statusEntry = health?.fanout_statuses?.[cfg.id];
|
||||||
const status = cfg.enabled ? statusEntry?.status : undefined;
|
const status = cfg.enabled ? statusEntry?.status : undefined;
|
||||||
|
const lastError = cfg.enabled ? statusEntry?.last_error : null;
|
||||||
const communityConfig = cfg.config as Record<string, unknown>;
|
const communityConfig = cfg.config as Record<string, unknown>;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -2115,6 +2403,25 @@ export function SettingsFanoutSection({
|
|||||||
{cfg.enabled ? getStatusLabel(status, cfg.type) : 'Disabled'}
|
{cfg.enabled ? getStatusLabel(status, cfg.type) : 'Disabled'}
|
||||||
</span>
|
</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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ import {
|
|||||||
setSavedDistanceUnit,
|
setSavedDistanceUnit,
|
||||||
} from '../../utils/distanceUnits';
|
} from '../../utils/distanceUnits';
|
||||||
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
|
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
|
||||||
|
import {
|
||||||
|
DEFAULT_FONT_SCALE,
|
||||||
|
FONT_SCALE_SLIDER_STEP,
|
||||||
|
MAX_FONT_SCALE,
|
||||||
|
MIN_FONT_SCALE,
|
||||||
|
getSavedFontScale,
|
||||||
|
setSavedFontScale,
|
||||||
|
} from '../../utils/fontScale';
|
||||||
|
|
||||||
export function SettingsLocalSection({
|
export function SettingsLocalSection({
|
||||||
onLocalLabelChange,
|
onLocalLabelChange,
|
||||||
@@ -31,6 +39,29 @@ export function SettingsLocalSection({
|
|||||||
);
|
);
|
||||||
const [localLabelText, setLocalLabelText] = useState(() => getLocalLabel().text);
|
const [localLabelText, setLocalLabelText] = useState(() => getLocalLabel().text);
|
||||||
const [localLabelColor, setLocalLabelColor] = useState(() => getLocalLabel().color);
|
const [localLabelColor, setLocalLabelColor] = useState(() => getLocalLabel().color);
|
||||||
|
const [fontScale, setFontScale] = useState(getSavedFontScale);
|
||||||
|
const [fontScaleSlider, setFontScaleSlider] = useState(getSavedFontScale);
|
||||||
|
const [fontScaleInput, setFontScaleInput] = useState(() => String(getSavedFontScale()));
|
||||||
|
|
||||||
|
const commitFontScale = (nextScale: number) => {
|
||||||
|
const normalized = setSavedFontScale(nextScale);
|
||||||
|
setFontScale(normalized);
|
||||||
|
setFontScaleSlider(normalized);
|
||||||
|
setFontScaleInput(String(normalized));
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoreFontScaleInput = () => {
|
||||||
|
setFontScaleInput(String(fontScale));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSliderChange = (nextScale: number) => {
|
||||||
|
setFontScaleSlider(nextScale);
|
||||||
|
setFontScaleInput(String(nextScale));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSliderCommit = (nextScale: number) => {
|
||||||
|
commitFontScale(nextScale);
|
||||||
|
};
|
||||||
|
|
||||||
const handleToggleReopenLastConversation = (enabled: boolean) => {
|
const handleToggleReopenLastConversation = (enabled: boolean) => {
|
||||||
setReopenLastConversation(enabled);
|
setReopenLastConversation(enabled);
|
||||||
@@ -89,6 +120,85 @@ export function SettingsLocalSection({
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label htmlFor="font-scale-input">Relative Font Size</Label>
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={MIN_FONT_SCALE}
|
||||||
|
max={MAX_FONT_SCALE}
|
||||||
|
step={FONT_SCALE_SLIDER_STEP}
|
||||||
|
value={fontScaleSlider}
|
||||||
|
onChange={(event) => handleSliderChange(Number(event.target.value))}
|
||||||
|
onMouseUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
|
||||||
|
onTouchEnd={(event) => handleSliderCommit(Number(event.currentTarget.value))}
|
||||||
|
onKeyUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
|
||||||
|
onBlur={(event) => handleSliderCommit(Number(event.currentTarget.value))}
|
||||||
|
aria-label="Relative font size slider"
|
||||||
|
className="w-full accent-primary sm:flex-1"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 sm:w-40">
|
||||||
|
<Input
|
||||||
|
id="font-scale-input"
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
min={MIN_FONT_SCALE}
|
||||||
|
max={MAX_FONT_SCALE}
|
||||||
|
step="any"
|
||||||
|
value={fontScaleInput}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextValue = event.target.value;
|
||||||
|
setFontScaleInput(nextValue);
|
||||||
|
|
||||||
|
if (nextValue === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.target.validity.valid && Number.isFinite(event.target.valueAsNumber)) {
|
||||||
|
commitFontScale(event.target.valueAsNumber);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
const parsed = Number.parseFloat(fontScaleInput);
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
restoreFontScaleInput();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
commitFontScale(parsed);
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== 'Enter') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
const parsed = Number.parseFloat(fontScaleInput);
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
restoreFontScaleInput();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
commitFontScale(parsed);
|
||||||
|
}}
|
||||||
|
aria-label="Relative font size percentage"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">%</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => commitFontScale(DEFAULT_FONT_SCALE)}
|
||||||
|
className="inline-flex h-9 items-center justify-center rounded-md border border-input px-3 text-sm font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={fontScale === DEFAULT_FONT_SCALE}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Scales the app's typography for this browser only. The slider moves in 5% steps; the
|
||||||
|
number field accepts any value from 25% to 400%.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label htmlFor="distance-units">Distance Units</Label>
|
<Label htmlFor="distance-units">Distance Units</Label>
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -846,11 +846,16 @@ export function SettingsRadioSection({
|
|||||||
className="rounded-md border border-input bg-background px-3 py-2"
|
className="rounded-md border border-input bg-background px-3 py-2"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span className="text-sm font-medium capitalize">{result.node_type}</span>
|
<span className="text-sm font-medium">
|
||||||
|
{result.name ?? <span className="capitalize">{result.node_type}</span>}
|
||||||
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
heard {result.heard_count} time{result.heard_count === 1 ? '' : 's'}
|
heard {result.heard_count} time{result.heard_count === 1 ? '' : 's'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{result.name && (
|
||||||
|
<p className="text-xs capitalize text-muted-foreground">{result.node_type}</p>
|
||||||
|
)}
|
||||||
<p className="mt-1 break-all font-mono text-xs text-muted-foreground">
|
<p className="mt-1 break-all font-mono text-xs text-muted-foreground">
|
||||||
{result.public_key}
|
{result.public_key}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,4 +1,16 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip as RechartsTooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
Cell,
|
||||||
|
} from 'recharts';
|
||||||
import { Separator } from '../ui/separator';
|
import { Separator } from '../ui/separator';
|
||||||
import { api } from '../../api';
|
import { api } from '../../api';
|
||||||
import type { StatisticsResponse } from '../../types';
|
import type { StatisticsResponse } from '../../types';
|
||||||
@@ -7,6 +19,94 @@ function formatPercent(value: number): string {
|
|||||||
return `${value.toFixed(1)}%`;
|
return `${value.toFixed(1)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CHANNEL_BAR_COLORS = ['#0ea5e9', '#10b981', '#f59e0b', '#f43f5e', '#8b5cf6'];
|
||||||
|
|
||||||
|
const TOOLTIP_STYLE = {
|
||||||
|
contentStyle: {
|
||||||
|
backgroundColor: 'hsl(var(--popover))',
|
||||||
|
border: '1px solid hsl(var(--border))',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: 'hsl(var(--popover-foreground))',
|
||||||
|
},
|
||||||
|
itemStyle: { color: 'hsl(var(--popover-foreground))' },
|
||||||
|
labelStyle: { color: 'hsl(var(--muted-foreground))' },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function formatTime(ts: number): string {
|
||||||
|
return new Date(ts * 1000).toLocaleTimeString([], {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoiseFloorChart({
|
||||||
|
samples,
|
||||||
|
}: {
|
||||||
|
samples: { timestamp: number; noise_floor_dbm: number }[];
|
||||||
|
}) {
|
||||||
|
const data = samples.map((s, i) => ({
|
||||||
|
idx: i,
|
||||||
|
time: formatTime(s.timestamp),
|
||||||
|
noise_floor: s.noise_floor_dbm,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const tickCount = Math.min(6, samples.length);
|
||||||
|
const tickIndices: number[] = [];
|
||||||
|
if (samples.length > 1) {
|
||||||
|
for (let i = 0; i < tickCount; i++) {
|
||||||
|
tickIndices.push(Math.round((i / (tickCount - 1)) * (samples.length - 1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={120}>
|
||||||
|
<AreaChart data={data} margin={{ top: 4, right: 4, bottom: 0, left: -8 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="idx"
|
||||||
|
type="number"
|
||||||
|
domain={[0, samples.length - 1]}
|
||||||
|
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
ticks={tickIndices}
|
||||||
|
tickFormatter={(idx) => data[idx]?.time ?? ''}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
domain={['dataMin - 5', 'dataMax + 5']}
|
||||||
|
tickFormatter={(v) => `${v}`}
|
||||||
|
/>
|
||||||
|
<RechartsTooltip
|
||||||
|
{...TOOLTIP_STYLE}
|
||||||
|
cursor={{
|
||||||
|
stroke: 'hsl(var(--muted-foreground))',
|
||||||
|
strokeWidth: 1,
|
||||||
|
strokeDasharray: '3 3',
|
||||||
|
}}
|
||||||
|
labelFormatter={(idx) => data[Number(idx)]?.time ?? ''}
|
||||||
|
formatter={(value) => [`${value} dBm`, 'Noise Floor']}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="linear"
|
||||||
|
dataKey="noise_floor"
|
||||||
|
stroke="#8b5cf6"
|
||||||
|
fill="#8b5cf6"
|
||||||
|
fillOpacity={0.15}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 4, fill: '#8b5cf6', strokeWidth: 2, stroke: 'hsl(var(--popover))' }}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SettingsStatisticsSection({ className }: { className?: string }) {
|
export function SettingsStatisticsSection({ className }: { className?: string }) {
|
||||||
const [stats, setStats] = useState<StatisticsResponse | null>(null);
|
const [stats, setStats] = useState<StatisticsResponse | null>(null);
|
||||||
const [statsLoading, setStatsLoading] = useState(false);
|
const [statsLoading, setStatsLoading] = useState(false);
|
||||||
@@ -85,60 +185,6 @@ export function SettingsStatisticsSection({ className }: { className?: string })
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* Packets */}
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium mb-2">Packets</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-sm text-muted-foreground">Total stored</span>
|
|
||||||
<span className="font-medium">{stats.total_packets}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-sm text-success">Decrypted</span>
|
|
||||||
<span className="font-medium text-success">{stats.decrypted_packets}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-sm text-warning">Undecrypted</span>
|
|
||||||
<span className="font-medium text-warning">{stats.undecrypted_packets}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium mb-2">Path Hash Width (24h)</h4>
|
|
||||||
<div className="mb-2 text-xs text-muted-foreground">
|
|
||||||
Parsed stored raw packets from the last 24 hours:{' '}
|
|
||||||
{stats.path_hash_width_24h.total_packets}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex justify-between items-center text-sm">
|
|
||||||
<span>1-byte hops</span>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{stats.path_hash_width_24h.single_byte} (
|
|
||||||
{formatPercent(stats.path_hash_width_24h.single_byte_pct)})
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center text-sm">
|
|
||||||
<span>2-byte hops</span>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{stats.path_hash_width_24h.double_byte} (
|
|
||||||
{formatPercent(stats.path_hash_width_24h.double_byte_pct)})
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center text-sm">
|
|
||||||
<span>3-byte hops</span>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{stats.path_hash_width_24h.triple_byte} (
|
|
||||||
{formatPercent(stats.path_hash_width_24h.triple_byte_pct)})
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{/* Activity */}
|
{/* Activity */}
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-medium mb-2">Activity</h4>
|
<h4 className="text-sm font-medium mb-2">Activity</h4>
|
||||||
@@ -174,23 +220,172 @@ export function SettingsStatisticsSection({ className }: { className?: string })
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Packets */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium mb-2">Packets</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-muted-foreground">Total stored</span>
|
||||||
|
<span className="font-medium">{stats.total_packets}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-success">Decrypted</span>
|
||||||
|
<span className="font-medium text-success">{stats.decrypted_packets}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-warning">Undecrypted</span>
|
||||||
|
<span className="font-medium text-warning">{stats.undecrypted_packets}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Path Hash Width */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium mb-2">Path Hash Width (24h)</h4>
|
||||||
|
<div className="mb-2 text-xs text-muted-foreground">
|
||||||
|
Parsed stored raw packets from the last 24 hours:{' '}
|
||||||
|
{stats.path_hash_width_24h.total_packets}
|
||||||
|
</div>
|
||||||
|
{stats.path_hash_width_24h.total_packets > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={120}>
|
||||||
|
<BarChart
|
||||||
|
data={[
|
||||||
|
{
|
||||||
|
name: '1-byte',
|
||||||
|
count: stats.path_hash_width_24h.single_byte,
|
||||||
|
pct: stats.path_hash_width_24h.single_byte_pct,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '2-byte',
|
||||||
|
count: stats.path_hash_width_24h.double_byte,
|
||||||
|
pct: stats.path_hash_width_24h.double_byte_pct,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '3-byte',
|
||||||
|
count: stats.path_hash_width_24h.triple_byte,
|
||||||
|
pct: stats.path_hash_width_24h.triple_byte_pct,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
margin={{ top: 4, right: 4, bottom: 0, left: -16 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
stroke="hsl(var(--border))"
|
||||||
|
vertical={false}
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="name"
|
||||||
|
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
allowDecimals={false}
|
||||||
|
/>
|
||||||
|
<RechartsTooltip
|
||||||
|
{...TOOLTIP_STYLE}
|
||||||
|
cursor={{ fill: 'hsl(var(--muted))', opacity: 0.5 }}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
formatter={(value: any, _: any, props: any) => [
|
||||||
|
`${Number(value).toLocaleString()} (${formatPercent(props.payload.pct)})`,
|
||||||
|
'Packets',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="count" radius={[4, 4, 0, 0]} maxBarSize={40}>
|
||||||
|
<Cell fill="#0ea5e9" />
|
||||||
|
<Cell fill="#10b981" />
|
||||||
|
<Cell fill="#f59e0b" />
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No path data in the last 24 hours.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Busiest Channels */}
|
{/* Busiest Channels */}
|
||||||
{stats.busiest_channels_24h.length > 0 && (
|
{stats.busiest_channels_24h.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-medium mb-2">Busiest Channels (24h)</h4>
|
<h4 className="text-sm font-medium mb-2">Busiest Channels (24h)</h4>
|
||||||
<div className="space-y-1">
|
<ResponsiveContainer
|
||||||
{stats.busiest_channels_24h.map((ch, i) => (
|
width="100%"
|
||||||
<div key={ch.channel_key} className="flex justify-between items-center text-sm">
|
height={stats.busiest_channels_24h.length * 28 + 8}
|
||||||
<span>
|
>
|
||||||
<span className="text-muted-foreground mr-2">{i + 1}.</span>
|
<BarChart
|
||||||
{ch.channel_name}
|
data={stats.busiest_channels_24h.map((ch) => ({
|
||||||
</span>
|
name: ch.channel_name,
|
||||||
<span className="text-muted-foreground">{ch.message_count} msgs</span>
|
messages: ch.message_count,
|
||||||
</div>
|
}))}
|
||||||
))}
|
layout="vertical"
|
||||||
</div>
|
margin={{ top: 0, right: 4, bottom: 0, left: 0 }}
|
||||||
|
barCategoryGap="20%"
|
||||||
|
>
|
||||||
|
<XAxis type="number" hide />
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="name"
|
||||||
|
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={100}
|
||||||
|
/>
|
||||||
|
<RechartsTooltip
|
||||||
|
{...TOOLTIP_STYLE}
|
||||||
|
cursor={{ fill: 'hsl(var(--muted))', opacity: 0.5 }}
|
||||||
|
formatter={(value) => [`${Number(value).toLocaleString()} messages`, null]}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="messages" radius={[0, 4, 4, 0]} maxBarSize={16}>
|
||||||
|
{stats.busiest_channels_24h.map((_, i) => (
|
||||||
|
<Cell key={i} fill={CHANNEL_BAR_COLORS[i % CHANNEL_BAR_COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Noise Floor */}
|
||||||
|
{stats.noise_floor_24h.supported !== false && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium mb-2">Noise Floor (24h)</h4>
|
||||||
|
{stats.noise_floor_24h.latest_noise_floor_dbm != null && (
|
||||||
|
<div className="mb-2 text-xs text-muted-foreground">
|
||||||
|
Latest reading: {stats.noise_floor_24h.latest_noise_floor_dbm} dBm
|
||||||
|
{stats.noise_floor_24h.latest_timestamp != null &&
|
||||||
|
` at ${new Date(
|
||||||
|
stats.noise_floor_24h.latest_timestamp * 1000
|
||||||
|
).toLocaleTimeString([], {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{stats.noise_floor_24h.samples.length > 1 ? (
|
||||||
|
<NoiseFloorChart samples={stats.noise_floor_24h.samples} />
|
||||||
|
) : stats.noise_floor_24h.samples.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No noise floor samples collected yet. Samples are collected every five minutes,
|
||||||
|
and retained until server restart.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Only one sample so far ({stats.noise_floor_24h.samples[0].noise_floor_dbm} dBm).
|
||||||
|
More data needed for a chart. Samples are collected every five minutes, and
|
||||||
|
retained until server restart.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { Checkbox } from '../ui/checkbox';
|
import { Checkbox } from '../ui/checkbox';
|
||||||
import { PACKET_LEGEND_ITEMS } from '../../utils/visualizerUtils';
|
import { PACKET_LEGEND_ITEMS } from '../../utils/visualizerUtils';
|
||||||
import { NODE_LEGEND_ITEMS } from './shared';
|
import { NODE_LEGEND_ITEMS } from './shared';
|
||||||
@@ -71,6 +72,19 @@ export function VisualizerControls({
|
|||||||
onExpandContract,
|
onExpandContract,
|
||||||
onClearAndReset,
|
onClearAndReset,
|
||||||
}: VisualizerControlsProps) {
|
}: VisualizerControlsProps) {
|
||||||
|
const [observationWindowInput, setObservationWindowInput] = useState(
|
||||||
|
String(observationWindowSec)
|
||||||
|
);
|
||||||
|
const [pruneWindowInput, setPruneWindowInput] = useState(String(pruneStaleMinutes));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setObservationWindowInput(String(observationWindowSec));
|
||||||
|
}, [observationWindowSec]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPruneWindowInput(String(pruneStaleMinutes));
|
||||||
|
}, [pruneStaleMinutes]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{showControls && (
|
{showControls && (
|
||||||
@@ -212,12 +226,25 @@ export function VisualizerControls({
|
|||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="60"
|
max="60"
|
||||||
value={observationWindowSec}
|
value={observationWindowInput}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
setObservationWindowSec(
|
const nextValue = e.target.value;
|
||||||
Math.max(1, Math.min(60, parseInt(e.target.value, 10) || 1))
|
setObservationWindowInput(nextValue);
|
||||||
)
|
if (nextValue === '') return;
|
||||||
}
|
const parsed = Number.parseInt(nextValue, 10);
|
||||||
|
if (Number.isNaN(parsed)) return;
|
||||||
|
setObservationWindowSec(Math.max(1, Math.min(60, parsed)));
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
const parsed = Number.parseInt(observationWindowInput, 10);
|
||||||
|
const nextValue = Number.isNaN(parsed)
|
||||||
|
? observationWindowSec
|
||||||
|
: Math.max(1, Math.min(60, parsed));
|
||||||
|
setObservationWindowInput(String(nextValue));
|
||||||
|
if (nextValue !== observationWindowSec) {
|
||||||
|
setObservationWindowSec(nextValue);
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="w-12 px-1 py-0.5 bg-background border border-border rounded text-xs text-center"
|
className="w-12 px-1 py-0.5 bg-background border border-border rounded text-xs text-center"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground">sec</span>
|
<span className="text-muted-foreground">sec</span>
|
||||||
@@ -247,10 +274,25 @@ export function VisualizerControls({
|
|||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
max={60}
|
max={60}
|
||||||
value={pruneStaleMinutes}
|
value={pruneWindowInput}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const v = parseInt(e.target.value, 10);
|
const nextValue = e.target.value;
|
||||||
if (!isNaN(v) && v >= 1 && v <= 60) setPruneStaleMinutes(v);
|
setPruneWindowInput(nextValue);
|
||||||
|
if (nextValue === '') return;
|
||||||
|
const parsed = Number.parseInt(nextValue, 10);
|
||||||
|
if (Number.isNaN(parsed)) return;
|
||||||
|
if (parsed >= 1 && parsed <= 60) setPruneStaleMinutes(parsed);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
const parsed = Number.parseInt(pruneWindowInput, 10);
|
||||||
|
const nextValue =
|
||||||
|
Number.isNaN(parsed) || parsed < 1 || parsed > 60
|
||||||
|
? pruneStaleMinutes
|
||||||
|
: parsed;
|
||||||
|
setPruneWindowInput(String(nextValue));
|
||||||
|
if (nextValue !== pruneStaleMinutes) {
|
||||||
|
setPruneStaleMinutes(nextValue);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className="w-14 rounded border border-border bg-background px-2 py-0.5 text-sm"
|
className="w-14 rounded border border-border bg-background px-2 py-0.5 text-sm"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -275,7 +275,9 @@ interface UseConversationMessagesResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isMessageConversation(conversation: Conversation | null): conversation is Conversation {
|
function isMessageConversation(conversation: Conversation | null): conversation is Conversation {
|
||||||
return !!conversation && !['raw', 'map', 'visualizer', 'search'].includes(conversation.type);
|
return (
|
||||||
|
!!conversation && !['raw', 'map', 'visualizer', 'search', 'trace'].includes(conversation.type)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isActiveConversationMessage(
|
function isActiveConversationMessage(
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from '../utils/lastViewedConversation';
|
} from '../utils/lastViewedConversation';
|
||||||
import { findPublicChannel } from '../utils/publicChannel';
|
import { findPublicChannel } from '../utils/publicChannel';
|
||||||
import { getContactDisplayName } from '../utils/pubkey';
|
import { getContactDisplayName } from '../utils/pubkey';
|
||||||
|
import { toast } from '../components/ui/sonner';
|
||||||
import type { Channel, Contact, Conversation } from '../types';
|
import type { Channel, Contact, Conversation } from '../types';
|
||||||
|
|
||||||
interface UseConversationRouterArgs {
|
interface UseConversationRouterArgs {
|
||||||
@@ -61,7 +62,6 @@ export function useConversationRouter({
|
|||||||
// Only needs channels (fast path) - doesn't wait for contacts
|
// Only needs channels (fast path) - doesn't wait for contacts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasSetDefaultConversation.current || activeConversation) return;
|
if (hasSetDefaultConversation.current || activeConversation) return;
|
||||||
if (channels.length === 0) return;
|
|
||||||
|
|
||||||
const hashConv = parseHashSettingsSection() ? null : parseHashConversation();
|
const hashConv = parseHashSettingsSection() ? null : parseHashConversation();
|
||||||
|
|
||||||
@@ -91,6 +91,29 @@ export function useConversationRouter({
|
|||||||
hasSetDefaultConversation.current = true;
|
hasSetDefaultConversation.current = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (hashConv?.type === 'trace') {
|
||||||
|
setActiveConversationState({ type: 'trace', id: 'trace', name: 'Trace' });
|
||||||
|
hasSetDefaultConversation.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No hash: optionally restore last-viewed non-data conversation if enabled on this device.
|
||||||
|
if (!hashConv && getReopenLastConversationEnabled()) {
|
||||||
|
const lastViewed = getLastViewedConversation();
|
||||||
|
if (
|
||||||
|
lastViewed &&
|
||||||
|
(lastViewed.type === 'raw' ||
|
||||||
|
lastViewed.type === 'map' ||
|
||||||
|
lastViewed.type === 'visualizer' ||
|
||||||
|
lastViewed.type === 'trace')
|
||||||
|
) {
|
||||||
|
setActiveConversationState(lastViewed);
|
||||||
|
hasSetDefaultConversation.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channels.length === 0) return;
|
||||||
|
|
||||||
// Handle channel hash (ID-first with legacy-name fallback)
|
// Handle channel hash (ID-first with legacy-name fallback)
|
||||||
if (hashConv?.type === 'channel') {
|
if (hashConv?.type === 'channel') {
|
||||||
@@ -108,14 +131,6 @@ export function useConversationRouter({
|
|||||||
// No hash: optionally restore last-viewed conversation if enabled on this device.
|
// No hash: optionally restore last-viewed conversation if enabled on this device.
|
||||||
if (!hashConv && getReopenLastConversationEnabled()) {
|
if (!hashConv && getReopenLastConversationEnabled()) {
|
||||||
const lastViewed = getLastViewedConversation();
|
const lastViewed = getLastViewedConversation();
|
||||||
if (
|
|
||||||
lastViewed &&
|
|
||||||
(lastViewed.type === 'raw' || lastViewed.type === 'map' || lastViewed.type === 'visualizer')
|
|
||||||
) {
|
|
||||||
setActiveConversationState(lastViewed);
|
|
||||||
hasSetDefaultConversation.current = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (lastViewed?.type === 'channel') {
|
if (lastViewed?.type === 'channel') {
|
||||||
const channel =
|
const channel =
|
||||||
channels.find((c) => c.key.toLowerCase() === lastViewed.id.toLowerCase()) ||
|
channels.find((c) => c.key.toLowerCase() === lastViewed.id.toLowerCase()) ||
|
||||||
@@ -137,6 +152,11 @@ export function useConversationRouter({
|
|||||||
// No hash or unresolvable — default to Public
|
// No hash or unresolvable — default to Public
|
||||||
const publicConversation = getPublicChannelConversation();
|
const publicConversation = getPublicChannelConversation();
|
||||||
if (publicConversation) {
|
if (publicConversation) {
|
||||||
|
if (hashConv?.type === 'channel') {
|
||||||
|
const token =
|
||||||
|
hashConv.name.length > 16 ? hashConv.name.substring(0, 16) + '…' : hashConv.name;
|
||||||
|
toast.error(`Channel not found: ${token}`);
|
||||||
|
}
|
||||||
setActiveConversationState(publicConversation);
|
setActiveConversationState(publicConversation);
|
||||||
hasSetDefaultConversation.current = true;
|
hasSetDefaultConversation.current = true;
|
||||||
}
|
}
|
||||||
@@ -162,6 +182,9 @@ export function useConversationRouter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Contact hash didn't match — fall back to Public if channels loaded.
|
// Contact hash didn't match — fall back to Public if channels loaded.
|
||||||
|
const token =
|
||||||
|
hashConv.name.length > 16 ? hashConv.name.substring(0, 16) + '…' : hashConv.name;
|
||||||
|
toast.error(`Contact not found: ${token}`);
|
||||||
const publicConversation = getPublicChannelConversation();
|
const publicConversation = getPublicChannelConversation();
|
||||||
if (publicConversation) {
|
if (publicConversation) {
|
||||||
setActiveConversationState(publicConversation);
|
setActiveConversationState(publicConversation);
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ interface UseRealtimeAppStateArgs {
|
|||||||
hasMention?: boolean;
|
hasMention?: boolean;
|
||||||
}) => void;
|
}) => void;
|
||||||
renameConversationState: (oldStateKey: string, newStateKey: string) => void;
|
renameConversationState: (oldStateKey: string, newStateKey: string) => void;
|
||||||
|
removeConversationState: (stateKey: string) => void;
|
||||||
checkMention: (text: string) => boolean;
|
checkMention: (text: string) => boolean;
|
||||||
pendingDeleteFallbackRef: MutableRefObject<boolean>;
|
pendingDeleteFallbackRef: MutableRefObject<boolean>;
|
||||||
setActiveConversation: (conv: Conversation | null) => void;
|
setActiveConversation: (conv: Conversation | null) => void;
|
||||||
@@ -96,6 +97,7 @@ export function useRealtimeAppState({
|
|||||||
observeMessage,
|
observeMessage,
|
||||||
recordMessageEvent,
|
recordMessageEvent,
|
||||||
renameConversationState,
|
renameConversationState,
|
||||||
|
removeConversationState,
|
||||||
checkMention,
|
checkMention,
|
||||||
pendingDeleteFallbackRef,
|
pendingDeleteFallbackRef,
|
||||||
setActiveConversation,
|
setActiveConversation,
|
||||||
@@ -232,6 +234,7 @@ export function useRealtimeAppState({
|
|||||||
onContactDeleted: (publicKey: string) => {
|
onContactDeleted: (publicKey: string) => {
|
||||||
setContacts((prev) => prev.filter((c) => c.public_key !== publicKey));
|
setContacts((prev) => prev.filter((c) => c.public_key !== publicKey));
|
||||||
removeConversationMessages(publicKey);
|
removeConversationMessages(publicKey);
|
||||||
|
removeConversationState(getStateKey('contact', publicKey));
|
||||||
const active = activeConversationRef.current;
|
const active = activeConversationRef.current;
|
||||||
if (active?.type === 'contact' && active.id === publicKey) {
|
if (active?.type === 'contact' && active.id === publicKey) {
|
||||||
pendingDeleteFallbackRef.current = true;
|
pendingDeleteFallbackRef.current = true;
|
||||||
@@ -241,6 +244,7 @@ export function useRealtimeAppState({
|
|||||||
onChannelDeleted: (key: string) => {
|
onChannelDeleted: (key: string) => {
|
||||||
setChannels((prev) => prev.filter((c) => c.key !== key));
|
setChannels((prev) => prev.filter((c) => c.key !== key));
|
||||||
removeConversationMessages(key);
|
removeConversationMessages(key);
|
||||||
|
removeConversationState(getStateKey('channel', key));
|
||||||
const active = activeConversationRef.current;
|
const active = activeConversationRef.current;
|
||||||
if (active?.type === 'channel' && active.id === key) {
|
if (active?.type === 'channel' && active.id === key) {
|
||||||
pendingDeleteFallbackRef.current = true;
|
pendingDeleteFallbackRef.current = true;
|
||||||
@@ -267,6 +271,7 @@ export function useRealtimeAppState({
|
|||||||
checkMention,
|
checkMention,
|
||||||
fetchAllContacts,
|
fetchAllContacts,
|
||||||
fetchConfig,
|
fetchConfig,
|
||||||
|
removeConversationState,
|
||||||
renameConversationState,
|
renameConversationState,
|
||||||
renameConversationMessages,
|
renameConversationMessages,
|
||||||
maxRawPackets,
|
maxRawPackets,
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|||||||
|
|
||||||
type ServerLoginKind = 'repeater' | 'room';
|
type ServerLoginKind = 'repeater' | 'room';
|
||||||
|
|
||||||
const STORAGE_KEY_PREFIX = 'remoteterm-server-password';
|
|
||||||
|
|
||||||
type StoredPassword = {
|
type StoredPassword = {
|
||||||
password: string;
|
password: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const STORAGE_KEY_PREFIX = 'remoteterm-server-password';
|
||||||
|
const inMemoryPasswords = new Map<string, StoredPassword>();
|
||||||
|
|
||||||
function getStorageKey(kind: ServerLoginKind, publicKey: string): string {
|
function getStorageKey(kind: ServerLoginKind, publicKey: string): string {
|
||||||
return `${STORAGE_KEY_PREFIX}:${kind}:${publicKey}`;
|
return `${STORAGE_KEY_PREFIX}:${kind}:${publicKey}`;
|
||||||
}
|
}
|
||||||
@@ -33,37 +34,46 @@ export function useRememberedServerPassword(kind: ServerLoginKind, publicKey: st
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const stored = loadStoredPassword(kind, publicKey);
|
const stored = loadStoredPassword(kind, publicKey);
|
||||||
if (!stored) {
|
if (stored) {
|
||||||
setPassword('');
|
setPassword(stored.password);
|
||||||
|
setRememberPassword(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inMemoryStored = inMemoryPasswords.get(storageKey);
|
||||||
|
if (inMemoryStored) {
|
||||||
|
setPassword(inMemoryStored.password);
|
||||||
setRememberPassword(false);
|
setRememberPassword(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPassword(stored.password);
|
|
||||||
setRememberPassword(true);
|
setPassword('');
|
||||||
}, [kind, publicKey]);
|
setRememberPassword(false);
|
||||||
|
}, [kind, publicKey, storageKey]);
|
||||||
|
|
||||||
const persistAfterLogin = useCallback(
|
const persistAfterLogin = useCallback(
|
||||||
(submittedPassword: string) => {
|
(submittedPassword: string) => {
|
||||||
|
const trimmedPassword = submittedPassword.trim();
|
||||||
|
if (!trimmedPassword) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
inMemoryPasswords.set(storageKey, { password: trimmedPassword });
|
||||||
|
|
||||||
if (!rememberPassword) {
|
if (!rememberPassword) {
|
||||||
try {
|
try {
|
||||||
localStorage.removeItem(storageKey);
|
localStorage.removeItem(storageKey);
|
||||||
} catch {
|
} catch {
|
||||||
// localStorage may be unavailable
|
// localStorage may be unavailable
|
||||||
}
|
}
|
||||||
setPassword('');
|
} else {
|
||||||
return;
|
try {
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify({ password: trimmedPassword }));
|
||||||
|
} catch {
|
||||||
|
// localStorage may be unavailable
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const trimmedPassword = submittedPassword.trim();
|
|
||||||
if (!trimmedPassword) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
localStorage.setItem(storageKey, JSON.stringify({ password: trimmedPassword }));
|
|
||||||
} catch {
|
|
||||||
// localStorage may be unavailable
|
|
||||||
}
|
|
||||||
setPassword(trimmedPassword);
|
setPassword(trimmedPassword);
|
||||||
},
|
},
|
||||||
[rememberPassword, storageKey]
|
[rememberPassword, storageKey]
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ import type {
|
|||||||
RepeaterLppTelemetryResponse,
|
RepeaterLppTelemetryResponse,
|
||||||
CommandResponse,
|
CommandResponse,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
import {
|
||||||
|
buildServerLoginAttemptFromError,
|
||||||
|
buildServerLoginAttemptFromResponse,
|
||||||
|
type ServerLoginAttemptState,
|
||||||
|
} from '../utils/serverLoginState';
|
||||||
|
|
||||||
const MAX_RETRIES = 3;
|
const MAX_RETRIES = 3;
|
||||||
const RETRY_DELAY_MS = 2000;
|
const RETRY_DELAY_MS = 2000;
|
||||||
@@ -41,6 +46,7 @@ interface PaneData {
|
|||||||
interface RepeaterDashboardCacheEntry {
|
interface RepeaterDashboardCacheEntry {
|
||||||
loggedIn: boolean;
|
loggedIn: boolean;
|
||||||
loginError: string | null;
|
loginError: string | null;
|
||||||
|
lastLoginAttempt: ServerLoginAttemptState | null;
|
||||||
paneData: PaneData;
|
paneData: PaneData;
|
||||||
paneStates: Record<PaneName, PaneState>;
|
paneStates: Record<PaneName, PaneState>;
|
||||||
consoleHistory: ConsoleEntry[];
|
consoleHistory: ConsoleEntry[];
|
||||||
@@ -119,6 +125,7 @@ function getCachedState(publicKey: string | null): RepeaterDashboardCacheEntry |
|
|||||||
return {
|
return {
|
||||||
loggedIn: cached.loggedIn,
|
loggedIn: cached.loggedIn,
|
||||||
loginError: cached.loginError,
|
loginError: cached.loginError,
|
||||||
|
lastLoginAttempt: cached.lastLoginAttempt,
|
||||||
paneData: clonePaneData(cached.paneData),
|
paneData: clonePaneData(cached.paneData),
|
||||||
paneStates: normalizePaneStates(cached.paneStates),
|
paneStates: normalizePaneStates(cached.paneStates),
|
||||||
consoleHistory: cloneConsoleHistory(cached.consoleHistory),
|
consoleHistory: cloneConsoleHistory(cached.consoleHistory),
|
||||||
@@ -130,6 +137,7 @@ function cacheState(publicKey: string, entry: RepeaterDashboardCacheEntry) {
|
|||||||
repeaterDashboardCache.set(publicKey, {
|
repeaterDashboardCache.set(publicKey, {
|
||||||
loggedIn: entry.loggedIn,
|
loggedIn: entry.loggedIn,
|
||||||
loginError: entry.loginError,
|
loginError: entry.loginError,
|
||||||
|
lastLoginAttempt: entry.lastLoginAttempt,
|
||||||
paneData: clonePaneData(entry.paneData),
|
paneData: clonePaneData(entry.paneData),
|
||||||
paneStates: normalizePaneStates(entry.paneStates),
|
paneStates: normalizePaneStates(entry.paneStates),
|
||||||
consoleHistory: cloneConsoleHistory(entry.consoleHistory),
|
consoleHistory: cloneConsoleHistory(entry.consoleHistory),
|
||||||
@@ -173,6 +181,7 @@ export interface UseRepeaterDashboardResult {
|
|||||||
loggedIn: boolean;
|
loggedIn: boolean;
|
||||||
loginLoading: boolean;
|
loginLoading: boolean;
|
||||||
loginError: string | null;
|
loginError: string | null;
|
||||||
|
lastLoginAttempt: ServerLoginAttemptState | null;
|
||||||
paneData: PaneData;
|
paneData: PaneData;
|
||||||
paneStates: Record<PaneName, PaneState>;
|
paneStates: Record<PaneName, PaneState>;
|
||||||
consoleHistory: ConsoleEntry[];
|
consoleHistory: ConsoleEntry[];
|
||||||
@@ -203,6 +212,9 @@ export function useRepeaterDashboard(
|
|||||||
const [loggedIn, setLoggedIn] = useState(cachedState?.loggedIn ?? false);
|
const [loggedIn, setLoggedIn] = useState(cachedState?.loggedIn ?? false);
|
||||||
const [loginLoading, setLoginLoading] = useState(false);
|
const [loginLoading, setLoginLoading] = useState(false);
|
||||||
const [loginError, setLoginError] = useState<string | null>(cachedState?.loginError ?? null);
|
const [loginError, setLoginError] = useState<string | null>(cachedState?.loginError ?? null);
|
||||||
|
const [lastLoginAttempt, setLastLoginAttempt] = useState<ServerLoginAttemptState | null>(
|
||||||
|
cachedState?.lastLoginAttempt ?? null
|
||||||
|
);
|
||||||
|
|
||||||
const [paneData, setPaneData] = useState<PaneData>(
|
const [paneData, setPaneData] = useState<PaneData>(
|
||||||
cachedState?.paneData ?? createInitialPaneData
|
cachedState?.paneData ?? createInitialPaneData
|
||||||
@@ -243,11 +255,20 @@ export function useRepeaterDashboard(
|
|||||||
cacheState(conversationId, {
|
cacheState(conversationId, {
|
||||||
loggedIn,
|
loggedIn,
|
||||||
loginError,
|
loginError,
|
||||||
|
lastLoginAttempt,
|
||||||
paneData,
|
paneData,
|
||||||
paneStates,
|
paneStates,
|
||||||
consoleHistory,
|
consoleHistory,
|
||||||
});
|
});
|
||||||
}, [consoleHistory, conversationId, loggedIn, loginError, paneData, paneStates]);
|
}, [
|
||||||
|
consoleHistory,
|
||||||
|
conversationId,
|
||||||
|
loggedIn,
|
||||||
|
loginError,
|
||||||
|
lastLoginAttempt,
|
||||||
|
paneData,
|
||||||
|
paneStates,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
paneDataRef.current = paneData;
|
paneDataRef.current = paneData;
|
||||||
@@ -267,12 +288,14 @@ export function useRepeaterDashboard(
|
|||||||
const publicKey = getPublicKey();
|
const publicKey = getPublicKey();
|
||||||
if (!publicKey) return;
|
if (!publicKey) return;
|
||||||
const conversationId = publicKey;
|
const conversationId = publicKey;
|
||||||
|
const method = password.trim().length > 0 ? 'password' : 'blank';
|
||||||
|
|
||||||
setLoginLoading(true);
|
setLoginLoading(true);
|
||||||
setLoginError(null);
|
setLoginError(null);
|
||||||
try {
|
try {
|
||||||
const result = await api.repeaterLogin(publicKey, password);
|
const result = await api.repeaterLogin(publicKey, password);
|
||||||
if (activeIdRef.current !== conversationId) return;
|
if (activeIdRef.current !== conversationId) return;
|
||||||
|
setLastLoginAttempt(buildServerLoginAttemptFromResponse(method, result, 'repeater'));
|
||||||
setLoggedIn(true);
|
setLoggedIn(true);
|
||||||
if (!result.authenticated) {
|
if (!result.authenticated) {
|
||||||
const msg = result.message ?? 'Repeater login was not confirmed';
|
const msg = result.message ?? 'Repeater login was not confirmed';
|
||||||
@@ -282,6 +305,7 @@ export function useRepeaterDashboard(
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (activeIdRef.current !== conversationId) return;
|
if (activeIdRef.current !== conversationId) return;
|
||||||
const msg = err instanceof Error ? err.message : 'Login failed';
|
const msg = err instanceof Error ? err.message : 'Login failed';
|
||||||
|
setLastLoginAttempt(buildServerLoginAttemptFromError(method, msg, 'repeater'));
|
||||||
setLoggedIn(true);
|
setLoggedIn(true);
|
||||||
setLoginError(msg);
|
setLoginError(msg);
|
||||||
toast.error('Login request failed', {
|
toast.error('Login request failed', {
|
||||||
@@ -475,6 +499,7 @@ export function useRepeaterDashboard(
|
|||||||
loggedIn,
|
loggedIn,
|
||||||
loginLoading,
|
loginLoading,
|
||||||
loginError,
|
loginError,
|
||||||
|
lastLoginAttempt,
|
||||||
paneData,
|
paneData,
|
||||||
paneStates,
|
paneStates,
|
||||||
consoleHistory,
|
consoleHistory,
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ import {
|
|||||||
import type { Channel, Contact, Conversation, Message, UnreadCounts } from '../types';
|
import type { Channel, Contact, Conversation, Message, UnreadCounts } from '../types';
|
||||||
import { takePrefetchOrFetch } from '../prefetch';
|
import { takePrefetchOrFetch } from '../prefetch';
|
||||||
|
|
||||||
|
type UnreadTrackedConversation = Conversation & { type: 'channel' | 'contact' };
|
||||||
|
|
||||||
|
function isUnreadTrackedConversation(
|
||||||
|
conversation: Conversation | null
|
||||||
|
): conversation is UnreadTrackedConversation {
|
||||||
|
return conversation?.type === 'channel' || conversation?.type === 'contact';
|
||||||
|
}
|
||||||
|
|
||||||
interface UseUnreadCountsResult {
|
interface UseUnreadCountsResult {
|
||||||
unreadCounts: Record<string, number>;
|
unreadCounts: Record<string, number>;
|
||||||
/** Tracks which conversations have unread messages that mention the user */
|
/** Tracks which conversations have unread messages that mention the user */
|
||||||
@@ -23,6 +31,7 @@ interface UseUnreadCountsResult {
|
|||||||
hasMention?: boolean;
|
hasMention?: boolean;
|
||||||
}) => void;
|
}) => void;
|
||||||
renameConversationState: (oldStateKey: string, newStateKey: string) => void;
|
renameConversationState: (oldStateKey: string, newStateKey: string) => void;
|
||||||
|
removeConversationState: (stateKey: string) => void;
|
||||||
markAllRead: () => void;
|
markAllRead: () => void;
|
||||||
refreshUnreads: () => Promise<void>;
|
refreshUnreads: () => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -47,14 +56,7 @@ export function useUnreadCounts(
|
|||||||
// (the user is already viewing it, so its count should stay at 0).
|
// (the user is already viewing it, so its count should stay at 0).
|
||||||
const applyUnreads = useCallback((data: UnreadCounts) => {
|
const applyUnreads = useCallback((data: UnreadCounts) => {
|
||||||
const ac = activeConvRef.current;
|
const ac = activeConvRef.current;
|
||||||
const activeKey =
|
const activeKey = isUnreadTrackedConversation(ac) ? getStateKey(ac.type, ac.id) : null;
|
||||||
ac &&
|
|
||||||
ac.type !== 'raw' &&
|
|
||||||
ac.type !== 'map' &&
|
|
||||||
ac.type !== 'visualizer' &&
|
|
||||||
ac.type !== 'search'
|
|
||||||
? getStateKey(ac.type as 'channel' | 'contact', ac.id)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (activeKey) {
|
if (activeKey) {
|
||||||
const counts = { ...data.counts };
|
const counts = { ...data.counts };
|
||||||
@@ -122,16 +124,8 @@ export function useUnreadCounts(
|
|||||||
// Mark conversation as read when user views it
|
// Mark conversation as read when user views it
|
||||||
// Calls server API to persist read state across devices
|
// Calls server API to persist read state across devices
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (isUnreadTrackedConversation(activeConversation)) {
|
||||||
activeConversation &&
|
const key = getStateKey(activeConversation.type, activeConversation.id);
|
||||||
activeConversation.type !== 'raw' &&
|
|
||||||
activeConversation.type !== 'map' &&
|
|
||||||
activeConversation.type !== 'visualizer'
|
|
||||||
) {
|
|
||||||
const key = getStateKey(
|
|
||||||
activeConversation.type as 'channel' | 'contact',
|
|
||||||
activeConversation.id
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update local state immediately for responsive UI
|
// Update local state immediately for responsive UI
|
||||||
setUnreadCounts((prev) => {
|
setUnreadCounts((prev) => {
|
||||||
@@ -235,6 +229,27 @@ export function useUnreadCounts(
|
|||||||
setLastMessageTimes(renameConversationTimeKey(oldStateKey, newStateKey));
|
setLastMessageTimes(renameConversationTimeKey(oldStateKey, newStateKey));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const removeConversationState = useCallback((stateKey: string) => {
|
||||||
|
setUnreadCounts((prev) => {
|
||||||
|
if (!(stateKey in prev)) return prev;
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[stateKey];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setMentions((prev) => {
|
||||||
|
if (!(stateKey in prev)) return prev;
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[stateKey];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setUnreadLastReadAts((prev) => {
|
||||||
|
if (!(stateKey in prev)) return prev;
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[stateKey];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Mark all conversations as read
|
// Mark all conversations as read
|
||||||
// Calls single bulk API endpoint to persist read state
|
// Calls single bulk API endpoint to persist read state
|
||||||
const markAllRead = useCallback(() => {
|
const markAllRead = useCallback(() => {
|
||||||
@@ -256,6 +271,7 @@ export function useUnreadCounts(
|
|||||||
unreadLastReadAts,
|
unreadLastReadAts,
|
||||||
recordMessageEvent,
|
recordMessageEvent,
|
||||||
renameConversationState,
|
renameConversationState,
|
||||||
|
removeConversationState,
|
||||||
markAllRead,
|
markAllRead,
|
||||||
refreshUnreads: fetchUnreads,
|
refreshUnreads: fetchUnreads,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -85,7 +85,7 @@
|
|||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import './index.css';
|
|||||||
import './themes.css';
|
import './themes.css';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
import { getSavedTheme, applyTheme } from './utils/theme';
|
import { getSavedTheme, applyTheme } from './utils/theme';
|
||||||
|
import { applyFontScale, getSavedFontScale } from './utils/fontScale';
|
||||||
|
|
||||||
// Apply saved theme before first render
|
// Apply saved theme before first render
|
||||||
applyTheme(getSavedTheme());
|
applyTheme(getSavedTheme());
|
||||||
|
applyFontScale(getSavedFontScale());
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
|||||||
@@ -195,6 +195,53 @@ describe('App startup hash resolution', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('restores the trace tool from the URL hash', async () => {
|
||||||
|
window.location.hash = '#trace';
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
for (const node of screen.getAllByTestId('active-conversation')) {
|
||||||
|
expect(node).toHaveTextContent('trace:trace:Trace');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores the trace tool from the URL hash even when channels are unavailable', async () => {
|
||||||
|
window.location.hash = '#trace';
|
||||||
|
mocks.api.getChannels.mockResolvedValue([]);
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
for (const node of screen.getAllByTestId('active-conversation')) {
|
||||||
|
expect(node).toHaveTextContent('trace:trace:Trace');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reopens the last viewed trace tool even when channels are unavailable', async () => {
|
||||||
|
window.location.hash = '';
|
||||||
|
localStorage.setItem(REOPEN_LAST_CONVERSATION_KEY, '1');
|
||||||
|
localStorage.setItem(
|
||||||
|
LAST_VIEWED_CONVERSATION_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'trace',
|
||||||
|
id: 'trace',
|
||||||
|
name: 'Trace',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
mocks.api.getChannels.mockResolvedValue([]);
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
for (const node of screen.getAllByTestId('active-conversation')) {
|
||||||
|
expect(node).toHaveTextContent('trace:trace:Trace');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('restores last viewed channel when hash is empty and reopen preference is enabled', async () => {
|
it('restores last viewed channel when hash is empty and reopen preference is enabled', async () => {
|
||||||
const chatChannel = {
|
const chatChannel = {
|
||||||
key: '11111111111111111111111111111111',
|
key: '11111111111111111111111111111111',
|
||||||
|
|||||||
@@ -181,7 +181,10 @@ describe('ContactInfoPane', () => {
|
|||||||
|
|
||||||
await screen.findByText('Mystery');
|
await screen.findByText('Mystery');
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(getContactAnalytics).toHaveBeenCalledWith({ name: 'Mystery' });
|
expect(getContactAnalytics).toHaveBeenCalledWith(
|
||||||
|
{ name: 'Mystery' },
|
||||||
|
expect.any(AbortSignal)
|
||||||
|
);
|
||||||
expect(screen.getByText('Messages')).toBeInTheDocument();
|
expect(screen.getByText('Messages')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Channel Messages')).toBeInTheDocument();
|
expect(screen.getByText('Channel Messages')).toBeInTheDocument();
|
||||||
expect(screen.getByText('4', { selector: 'p' })).toBeInTheDocument();
|
expect(screen.getByText('4', { selector: 'p' })).toBeInTheDocument();
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ vi.mock('../components/VisualizerView', () => ({
|
|||||||
VisualizerView: () => <div data-testid="visualizer-view" />,
|
VisualizerView: () => <div data-testid="visualizer-view" />,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../components/TracePane', () => ({
|
||||||
|
TracePane: () => <div data-testid="trace-pane" />,
|
||||||
|
}));
|
||||||
|
|
||||||
const config: RadioConfig = {
|
const config: RadioConfig = {
|
||||||
public_key: 'aa'.repeat(32),
|
public_key: 'aa'.repeat(32),
|
||||||
name: 'Radio',
|
name: 'Radio',
|
||||||
@@ -141,6 +145,7 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
|
|||||||
loadingNewer: false,
|
loadingNewer: false,
|
||||||
messageInputRef: { current: null },
|
messageInputRef: { current: null },
|
||||||
onTrace: vi.fn(async () => {}),
|
onTrace: vi.fn(async () => {}),
|
||||||
|
onRunTracePath: vi.fn(async () => ({ path_len: 0, timeout_seconds: 5, nodes: [] })),
|
||||||
onPathDiscovery: vi.fn(async () => {
|
onPathDiscovery: vi.fn(async () => {
|
||||||
throw new Error('unused');
|
throw new Error('unused');
|
||||||
}),
|
}),
|
||||||
@@ -231,6 +236,23 @@ describe('ConversationPane', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders the trace tool pane for trace conversations', () => {
|
||||||
|
render(
|
||||||
|
<ConversationPane
|
||||||
|
{...createProps({
|
||||||
|
activeConversation: {
|
||||||
|
type: 'trace',
|
||||||
|
id: 'trace',
|
||||||
|
name: 'Trace',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('trace-pane')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('message-list')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('gates room chat behind room login controls until authenticated', async () => {
|
it('gates room chat behind room login controls until authenticated', async () => {
|
||||||
render(
|
render(
|
||||||
<ConversationPane
|
<ConversationPane
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { CrackerPanel } from '../components/CrackerPanel';
|
||||||
|
|
||||||
|
vi.mock('meshcore-hashtag-cracker', () => ({
|
||||||
|
GroupTextCracker: class {
|
||||||
|
isGpuAvailable() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
destroy() {}
|
||||||
|
setWordlist() {}
|
||||||
|
abort() {}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('nosleep.js', () => ({
|
||||||
|
default: class {
|
||||||
|
enable() {}
|
||||||
|
disable() {}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../api', () => ({
|
||||||
|
api: {
|
||||||
|
getUndecryptedPacketCount: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../components/ui/sonner', () => ({
|
||||||
|
toast: {
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { api } from '../api';
|
||||||
|
|
||||||
|
const mockedApi = vi.mocked(api);
|
||||||
|
|
||||||
|
describe('CrackerPanel', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockedApi.getUndecryptedPacketCount.mockResolvedValue({ count: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows clearing max length while editing', async () => {
|
||||||
|
render(<CrackerPanel packets={[]} channels={[]} onChannelCreate={vi.fn()} visible={false} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedApi.getUndecryptedPacketCount).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxLengthInput = screen.getByLabelText('Max Length:') as HTMLInputElement;
|
||||||
|
fireEvent.change(maxLengthInput, { target: { value: '' } });
|
||||||
|
|
||||||
|
expect(maxLengthInput.value).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,7 @@ vi.mock('../api', () => ({
|
|||||||
deleteFanoutConfig: vi.fn(),
|
deleteFanoutConfig: vi.fn(),
|
||||||
getChannels: vi.fn(),
|
getChannels: vi.fn(),
|
||||||
getContacts: vi.fn(),
|
getContacts: vi.fn(),
|
||||||
|
getRadioConfig: vi.fn(),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -96,6 +97,17 @@ beforeEach(() => {
|
|||||||
mockedApi.getFanoutConfigs.mockResolvedValue([]);
|
mockedApi.getFanoutConfigs.mockResolvedValue([]);
|
||||||
mockedApi.getChannels.mockResolvedValue([]);
|
mockedApi.getChannels.mockResolvedValue([]);
|
||||||
mockedApi.getContacts.mockResolvedValue([]);
|
mockedApi.getContacts.mockResolvedValue([]);
|
||||||
|
mockedApi.getRadioConfig.mockResolvedValue({
|
||||||
|
public_key: 'aa'.repeat(32),
|
||||||
|
name: 'TestNode',
|
||||||
|
lat: 0,
|
||||||
|
lon: 0,
|
||||||
|
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: false,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('SettingsFanoutSection', () => {
|
describe('SettingsFanoutSection', () => {
|
||||||
@@ -106,7 +118,7 @@ describe('SettingsFanoutSection', () => {
|
|||||||
const optionButtons = within(dialog)
|
const optionButtons = within(dialog)
|
||||||
.getAllByRole('button')
|
.getAllByRole('button')
|
||||||
.filter((button) => button.hasAttribute('aria-pressed'));
|
.filter((button) => button.hasAttribute('aria-pressed'));
|
||||||
expect(optionButtons).toHaveLength(9);
|
expect(optionButtons).toHaveLength(10);
|
||||||
expect(within(dialog).getByRole('button', { name: 'Close' })).toBeInTheDocument();
|
expect(within(dialog).getByRole('button', { name: 'Close' })).toBeInTheDocument();
|
||||||
expect(within(dialog).getByRole('button', { name: 'Create' })).toBeInTheDocument();
|
expect(within(dialog).getByRole('button', { name: 'Create' })).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
@@ -138,6 +150,9 @@ describe('SettingsFanoutSection', () => {
|
|||||||
expect(
|
expect(
|
||||||
within(dialog).getByRole('button', { name: startsWithAccessibleName('Python Bot') })
|
within(dialog).getByRole('button', { name: startsWithAccessibleName('Python Bot') })
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
within(dialog).getByRole('button', { name: startsWithAccessibleName('Map Upload') })
|
||||||
|
).toBeInTheDocument();
|
||||||
expect(within(dialog).getByRole('heading', { level: 3 })).toBeInTheDocument();
|
expect(within(dialog).getByRole('heading', { level: 3 })).toBeInTheDocument();
|
||||||
|
|
||||||
const genericCommunityIndex = optionButtons.findIndex((button) =>
|
const genericCommunityIndex = optionButtons.findIndex((button) =>
|
||||||
@@ -191,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 () => {
|
it('navigates to edit view when clicking edit', async () => {
|
||||||
mockedApi.getFanoutConfigs.mockResolvedValue([webhookConfig]);
|
mockedApi.getFanoutConfigs.mockResolvedValue([webhookConfig]);
|
||||||
renderSection();
|
renderSection();
|
||||||
@@ -639,6 +704,75 @@ describe('SettingsFanoutSection', () => {
|
|||||||
expect(audienceInput).toHaveValue('');
|
expect(audienceInput).toHaveValue('');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('existing community MQTT defaults can be cleared while editing and normalize on save', async () => {
|
||||||
|
const communityConfig: FanoutConfig = {
|
||||||
|
id: 'comm-1',
|
||||||
|
type: 'mqtt_community',
|
||||||
|
name: 'Community Feed',
|
||||||
|
enabled: false,
|
||||||
|
config: {
|
||||||
|
broker_host: 'mqtt-us-v1.letsmesh.net',
|
||||||
|
broker_port: 443,
|
||||||
|
transport: 'websockets',
|
||||||
|
use_tls: true,
|
||||||
|
tls_verify: true,
|
||||||
|
auth_mode: 'token',
|
||||||
|
iata: 'LAX',
|
||||||
|
email: '',
|
||||||
|
token_audience: '',
|
||||||
|
topic_template: 'meshcore/{IATA}/{PUBLIC_KEY}/packets',
|
||||||
|
},
|
||||||
|
scope: { messages: 'none', raw_packets: 'all' },
|
||||||
|
sort_order: 0,
|
||||||
|
created_at: 1000,
|
||||||
|
};
|
||||||
|
mockedApi.getFanoutConfigs.mockResolvedValue([communityConfig]);
|
||||||
|
mockedApi.updateFanoutConfig.mockResolvedValue({
|
||||||
|
...communityConfig,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderSection();
|
||||||
|
await waitFor(() => expect(screen.getByText('Community Feed')).toBeInTheDocument());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
|
||||||
|
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||||
|
|
||||||
|
const hostInput = screen.getByLabelText('Broker Host') as HTMLInputElement;
|
||||||
|
const portInput = screen.getByLabelText('Broker Port') as HTMLInputElement;
|
||||||
|
const topicTemplateInput = screen.getByLabelText('Packet Topic Template') as HTMLInputElement;
|
||||||
|
|
||||||
|
fireEvent.change(hostInput, { target: { value: '' } });
|
||||||
|
fireEvent.change(portInput, { target: { value: '' } });
|
||||||
|
fireEvent.change(topicTemplateInput, { target: { value: '' } });
|
||||||
|
|
||||||
|
expect(hostInput.value).toBe('');
|
||||||
|
expect(portInput.value).toBe('');
|
||||||
|
expect(topicTemplateInput.value).toBe('');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Save as Enabled' }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockedApi.updateFanoutConfig).toHaveBeenCalledWith('comm-1', {
|
||||||
|
name: 'Community Feed',
|
||||||
|
config: {
|
||||||
|
broker_host: 'mqtt-us-v1.letsmesh.net',
|
||||||
|
broker_port: 443,
|
||||||
|
transport: 'websockets',
|
||||||
|
use_tls: true,
|
||||||
|
tls_verify: true,
|
||||||
|
auth_mode: 'token',
|
||||||
|
iata: 'LAX',
|
||||||
|
email: '',
|
||||||
|
token_audience: '',
|
||||||
|
topic_template: 'meshcore/{IATA}/{PUBLIC_KEY}/packets',
|
||||||
|
},
|
||||||
|
scope: { messages: 'none', raw_packets: 'all' },
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('community MQTT can be configured for no auth', async () => {
|
it('community MQTT can be configured for no auth', async () => {
|
||||||
const communityConfig: FanoutConfig = {
|
const communityConfig: FanoutConfig = {
|
||||||
id: 'comm-1',
|
id: 'comm-1',
|
||||||
@@ -718,6 +852,65 @@ describe('SettingsFanoutSection', () => {
|
|||||||
expect(screen.queryByLabelText('Broker Host')).not.toBeInTheDocument();
|
expect(screen.queryByLabelText('Broker Host')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('private MQTT fields can be cleared while editing and normalize defaults on create', async () => {
|
||||||
|
const createdConfig: FanoutConfig = {
|
||||||
|
id: 'mqtt-private-1',
|
||||||
|
type: 'mqtt_private',
|
||||||
|
name: 'Private MQTT 1',
|
||||||
|
enabled: true,
|
||||||
|
config: {
|
||||||
|
broker_host: 'broker.local',
|
||||||
|
broker_port: 1883,
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
use_tls: false,
|
||||||
|
tls_insecure: false,
|
||||||
|
topic_prefix: 'meshcore',
|
||||||
|
},
|
||||||
|
scope: { messages: 'all', raw_packets: 'all' },
|
||||||
|
sort_order: 0,
|
||||||
|
created_at: 2000,
|
||||||
|
};
|
||||||
|
mockedApi.createFanoutConfig.mockResolvedValue(createdConfig);
|
||||||
|
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdConfig]);
|
||||||
|
|
||||||
|
renderSection();
|
||||||
|
await openCreateIntegrationDialog();
|
||||||
|
selectCreateIntegration('Private MQTT');
|
||||||
|
confirmCreateIntegration();
|
||||||
|
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('Broker Host'), { target: { value: 'broker.local' } });
|
||||||
|
|
||||||
|
const portInput = screen.getByLabelText('Broker Port') as HTMLInputElement;
|
||||||
|
const prefixInput = screen.getByLabelText('Topic Prefix') as HTMLInputElement;
|
||||||
|
fireEvent.change(portInput, { target: { value: '' } });
|
||||||
|
fireEvent.change(prefixInput, { target: { value: '' } });
|
||||||
|
|
||||||
|
expect(portInput.value).toBe('');
|
||||||
|
expect(prefixInput.value).toBe('');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Save as Enabled' }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockedApi.createFanoutConfig).toHaveBeenCalledWith({
|
||||||
|
type: 'mqtt_private',
|
||||||
|
name: 'Private MQTT #1',
|
||||||
|
config: {
|
||||||
|
broker_host: 'broker.local',
|
||||||
|
broker_port: 1883,
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
use_tls: false,
|
||||||
|
tls_insecure: false,
|
||||||
|
topic_prefix: 'meshcore',
|
||||||
|
},
|
||||||
|
scope: { messages: 'all', raw_packets: 'all' },
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('creates MeshRank preset as a regular mqtt_community config', async () => {
|
it('creates MeshRank preset as a regular mqtt_community config', async () => {
|
||||||
const createdConfig: FanoutConfig = {
|
const createdConfig: FanoutConfig = {
|
||||||
id: 'comm-meshrank',
|
id: 'comm-meshrank',
|
||||||
@@ -847,6 +1040,57 @@ describe('SettingsFanoutSection', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('map upload geofence radius can be cleared while editing and normalizes to zero', async () => {
|
||||||
|
const createdConfig: FanoutConfig = {
|
||||||
|
id: 'map-1',
|
||||||
|
type: 'map_upload',
|
||||||
|
name: 'Map Upload 1',
|
||||||
|
enabled: true,
|
||||||
|
config: {
|
||||||
|
api_url: '',
|
||||||
|
dry_run: true,
|
||||||
|
geofence_enabled: true,
|
||||||
|
geofence_radius_km: 0,
|
||||||
|
},
|
||||||
|
scope: { messages: 'none', raw_packets: 'all' },
|
||||||
|
sort_order: 0,
|
||||||
|
created_at: 2000,
|
||||||
|
};
|
||||||
|
mockedApi.createFanoutConfig.mockResolvedValue(createdConfig);
|
||||||
|
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdConfig]);
|
||||||
|
|
||||||
|
renderSection();
|
||||||
|
await openCreateIntegrationDialog();
|
||||||
|
selectCreateIntegration('Map Upload');
|
||||||
|
confirmCreateIntegration();
|
||||||
|
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Enable Geofence'));
|
||||||
|
const radiusInput = screen.getByLabelText('Radius (km)') as HTMLInputElement;
|
||||||
|
|
||||||
|
fireEvent.change(radiusInput, { target: { value: '100' } });
|
||||||
|
fireEvent.change(radiusInput, { target: { value: '' } });
|
||||||
|
|
||||||
|
expect(radiusInput.value).toBe('');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Save as Enabled' }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockedApi.createFanoutConfig).toHaveBeenCalledWith({
|
||||||
|
type: 'map_upload',
|
||||||
|
name: 'Map Upload #1',
|
||||||
|
config: {
|
||||||
|
api_url: '',
|
||||||
|
dry_run: true,
|
||||||
|
geofence_enabled: true,
|
||||||
|
geofence_radius_km: 0,
|
||||||
|
},
|
||||||
|
scope: { messages: 'none', raw_packets: 'all' },
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('LetsMesh (EU) preset saves the EU broker defaults', async () => {
|
it('LetsMesh (EU) preset saves the EU broker defaults', async () => {
|
||||||
const createdConfig: FanoutConfig = {
|
const createdConfig: FanoutConfig = {
|
||||||
id: 'comm-letsmesh-eu',
|
id: 'comm-letsmesh-eu',
|
||||||
@@ -916,7 +1160,7 @@ describe('SettingsFanoutSection', () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||||
|
|
||||||
expect(screen.getByLabelText('Name')).toHaveValue('Community MQTT #1');
|
expect(screen.getByLabelText('Name')).toHaveValue('Community Sharing #1');
|
||||||
expect(screen.getByLabelText('Broker Host')).toBeInTheDocument();
|
expect(screen.getByLabelText('Broker Host')).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText('Authentication')).toBeInTheDocument();
|
expect(screen.getByLabelText('Authentication')).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText('Packet Topic Template')).toBeInTheDocument();
|
expect(screen.getByLabelText('Packet Topic Template')).toBeInTheDocument();
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
applyFontScale,
|
||||||
|
DEFAULT_FONT_SCALE,
|
||||||
|
FONT_SCALE_KEY,
|
||||||
|
MAX_FONT_SCALE,
|
||||||
|
MIN_FONT_SCALE,
|
||||||
|
getSavedFontScale,
|
||||||
|
setSavedFontScale,
|
||||||
|
} from '../utils/fontScale';
|
||||||
|
|
||||||
|
describe('fontScale utilities', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
document.documentElement.style.fontSize = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.documentElement.style.fontSize = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to 100% when nothing is saved', () => {
|
||||||
|
expect(getSavedFontScale()).toBe(DEFAULT_FONT_SCALE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a saved scale from localStorage', () => {
|
||||||
|
localStorage.setItem(FONT_SCALE_KEY, '135');
|
||||||
|
|
||||||
|
expect(getSavedFontScale()).toBe(135);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the default when the saved value is invalid', () => {
|
||||||
|
localStorage.setItem(FONT_SCALE_KEY, 'giant');
|
||||||
|
|
||||||
|
expect(getSavedFontScale()).toBe(DEFAULT_FONT_SCALE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the scale to the document root', () => {
|
||||||
|
expect(applyFontScale(150)).toBe(150);
|
||||||
|
expect(document.documentElement.style.fontSize).toBe('150%');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores non-default values and applies them immediately', () => {
|
||||||
|
expect(setSavedFontScale(137.5)).toBe(137.5);
|
||||||
|
expect(localStorage.getItem(FONT_SCALE_KEY)).toBe('137.5');
|
||||||
|
expect(document.documentElement.style.fontSize).toBe('137.5%');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes the saved value when returning to the default scale', () => {
|
||||||
|
localStorage.setItem(FONT_SCALE_KEY, '150');
|
||||||
|
|
||||||
|
expect(setSavedFontScale(DEFAULT_FONT_SCALE)).toBe(DEFAULT_FONT_SCALE);
|
||||||
|
expect(localStorage.getItem(FONT_SCALE_KEY)).toBeNull();
|
||||||
|
expect(document.documentElement.style.fontSize).toBe('100%');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps saved and applied values to the supported range', () => {
|
||||||
|
localStorage.setItem(FONT_SCALE_KEY, '900');
|
||||||
|
expect(getSavedFontScale()).toBe(MAX_FONT_SCALE);
|
||||||
|
|
||||||
|
expect(setSavedFontScale(5)).toBe(MIN_FONT_SCALE);
|
||||||
|
expect(localStorage.getItem(FONT_SCALE_KEY)).toBe(String(MIN_FONT_SCALE));
|
||||||
|
expect(document.documentElement.style.fontSize).toBe(`${MIN_FONT_SCALE}%`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -140,6 +140,59 @@ describe('MessageList channel sender rendering', () => {
|
|||||||
expect(screen.getByRole('button', { name: 'View info for Alice' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: 'View info for Alice' })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders valid channel references as clickable links and ignores invalid ones', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onChannelReferenceClick = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MessageList
|
||||||
|
messages={[
|
||||||
|
createMessage({
|
||||||
|
text: 'Alice: Join #mesh-room now skip #bad--room and visit https://example.com/#also-skip',
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
contacts={[]}
|
||||||
|
loading={false}
|
||||||
|
onChannelReferenceClick={onChannelReferenceClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const linkedChannel = screen.getByRole('button', { name: '#mesh-room' });
|
||||||
|
expect(linkedChannel).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: '#bad--room' })).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole('link', { name: 'https://example.com/#also-skip' })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(linkedChannel);
|
||||||
|
|
||||||
|
expect(onChannelReferenceClick).toHaveBeenCalledWith('#mesh-room');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links valid channel references in direct messages too', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onChannelReferenceClick = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MessageList
|
||||||
|
messages={[
|
||||||
|
createMessage({
|
||||||
|
type: 'PRIV',
|
||||||
|
text: 'check #ops-room',
|
||||||
|
conversation_key: 'ab'.repeat(32),
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
contacts={[]}
|
||||||
|
loading={false}
|
||||||
|
onChannelReferenceClick={onChannelReferenceClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '#ops-room' }));
|
||||||
|
|
||||||
|
expect(onChannelReferenceClick).toHaveBeenCalledWith('#ops-room');
|
||||||
|
});
|
||||||
|
|
||||||
it('renders and dismisses an unread marker at the first unread message boundary', async () => {
|
it('renders and dismisses an unread marker at the first unread message boundary', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const messages = [
|
const messages = [
|
||||||
|
|||||||
@@ -6,7 +6,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { parseSenderFromText, formatTime } from '../utils/messageParser';
|
import {
|
||||||
|
findLinkedChannelReferences,
|
||||||
|
formatTime,
|
||||||
|
isValidLinkedChannelName,
|
||||||
|
parseSenderFromText,
|
||||||
|
} from '../utils/messageParser';
|
||||||
|
|
||||||
describe('parseSenderFromText', () => {
|
describe('parseSenderFromText', () => {
|
||||||
it('extracts sender and content from "sender: message" format', () => {
|
it('extracts sender and content from "sender: message" format', () => {
|
||||||
@@ -95,3 +100,33 @@ describe('formatTime', () => {
|
|||||||
expect(result).toMatch(/\d{1,2}:\d{2}/); // time portion
|
expect(result).toMatch(/\d{1,2}:\d{2}/); // time portion
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('linked channel references', () => {
|
||||||
|
it('accepts lowercase alphanumeric names with single dashes', () => {
|
||||||
|
expect(isValidLinkedChannelName('ops')).toBe(true);
|
||||||
|
expect(isValidLinkedChannelName('ops-1')).toBe(true);
|
||||||
|
expect(isValidLinkedChannelName('1-2-3')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects uppercase, leading or trailing dashes, and repeated dashes', () => {
|
||||||
|
expect(isValidLinkedChannelName('Ops')).toBe(false);
|
||||||
|
expect(isValidLinkedChannelName('-ops')).toBe(false);
|
||||||
|
expect(isValidLinkedChannelName('ops-')).toBe(false);
|
||||||
|
expect(isValidLinkedChannelName('ops--room')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds standalone linked channel references in message text', () => {
|
||||||
|
expect(findLinkedChannelReferences('Join #mesh-room then say hi in #ops2')).toEqual([
|
||||||
|
{ label: '#mesh-room', start: 5, end: 15 },
|
||||||
|
{ label: '#ops2', start: 31, end: 36 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores invalid or embedded channel-like text', () => {
|
||||||
|
expect(
|
||||||
|
findLinkedChannelReferences(
|
||||||
|
'skip #Bad #bad--name abc#ops #ops- #opsRoom #ops_room #good-room,'
|
||||||
|
)
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -32,7 +32,10 @@ describe('NewMessageModal form reset', () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
function renderModal(open = true) {
|
function renderModal(
|
||||||
|
open = true,
|
||||||
|
overrides: Partial<Parameters<typeof NewMessageModal>[0]> = {}
|
||||||
|
) {
|
||||||
return render(
|
return render(
|
||||||
<NewMessageModal
|
<NewMessageModal
|
||||||
open={open}
|
open={open}
|
||||||
@@ -41,6 +44,7 @@ describe('NewMessageModal form reset', () => {
|
|||||||
onCreateContact={onCreateContact}
|
onCreateContact={onCreateContact}
|
||||||
onCreateChannel={onCreateChannel}
|
onCreateChannel={onCreateChannel}
|
||||||
onCreateHashtagChannel={onCreateHashtagChannel}
|
onCreateHashtagChannel={onCreateHashtagChannel}
|
||||||
|
{...overrides}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -50,6 +54,26 @@ describe('NewMessageModal form reset', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('hashtag tab', () => {
|
describe('hashtag tab', () => {
|
||||||
|
it('prefills the hashtag tab from a linked channel request', async () => {
|
||||||
|
renderModal(true, {
|
||||||
|
prefillRequest: {
|
||||||
|
tab: 'hashtag',
|
||||||
|
hashtagName: 'mesh-room',
|
||||||
|
nonce: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('tab', { name: 'Hashtag Channel' })).toHaveAttribute(
|
||||||
|
'data-state',
|
||||||
|
'active'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect((screen.getByPlaceholderText('channel-name') as HTMLInputElement).value).toBe(
|
||||||
|
'mesh-room'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('clears name after successful Create', async () => {
|
it('clears name after successful Create', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { unmount } = renderModal();
|
const { unmount } = renderModal();
|
||||||
|
|||||||
@@ -283,6 +283,8 @@ describe('RawPacketFeedView', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: /show stats/i }));
|
fireEvent.click(screen.getByRole('button', { name: /show stats/i }));
|
||||||
fireEvent.change(screen.getByLabelText('Stats window'), { target: { value: 'session' } });
|
fireEvent.change(screen.getByLabelText('Stats window'), { target: { value: 'session' } });
|
||||||
expect(screen.getAllByText('Alpha').length).toBeGreaterThan(0);
|
expect(screen.getAllByText('Alpha').length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByText('Strongest Neighbor')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('-70 dBm best heard')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks unresolved neighbor identities explicitly', () => {
|
it('marks unresolved neighbor identities explicitly', () => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const mockHook: {
|
|||||||
loggedIn: false,
|
loggedIn: false,
|
||||||
loginLoading: false,
|
loginLoading: false,
|
||||||
loginError: null,
|
loginError: null,
|
||||||
|
lastLoginAttempt: null,
|
||||||
paneData: {
|
paneData: {
|
||||||
status: null,
|
status: null,
|
||||||
nodeInfo: null,
|
nodeInfo: null,
|
||||||
|
|||||||
@@ -56,22 +56,84 @@ describe('RoomServerPanel', () => {
|
|||||||
status: 'timeout',
|
status: 'timeout',
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
message:
|
message:
|
||||||
'No login confirmation was heard from the room server. The control panel is still available; try logging in again if authenticated actions fail.',
|
"No login confirmation was heard from the room server. You're free to try sending messages; try logging in again if authenticated actions fail.",
|
||||||
});
|
});
|
||||||
const onAuthenticatedChange = vi.fn();
|
const onAuthenticatedChange = vi.fn();
|
||||||
|
|
||||||
render(<RoomServerPanel contact={roomContact} onAuthenticatedChange={onAuthenticatedChange} />);
|
render(<RoomServerPanel contact={roomContact} onAuthenticatedChange={onAuthenticatedChange} />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('Login with ACL / Guest'));
|
fireEvent.click(screen.getByText('Login with Existing Access / Guest'));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Show Tools')).toBeInTheDocument();
|
expect(screen.getByText('Show Tools')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
expect(screen.getByText('Show Tools')).toBeInTheDocument();
|
expect(screen.getByText('Show Tools')).toBeInTheDocument();
|
||||||
expect(mockToast.warning).toHaveBeenCalledWith('Room login not confirmed', {
|
expect(screen.getByText('Retry Existing-Access Login')).toBeInTheDocument();
|
||||||
|
expect(mockToast.warning).toHaveBeenCalledWith("Couldn't confirm room login", {
|
||||||
description:
|
description:
|
||||||
'No login confirmation was heard from the room server. The control panel is still available; try logging in again if authenticated actions fail.',
|
"No login confirmation was heard from the room server. You're free to try sending messages; try logging in again if authenticated actions fail.",
|
||||||
});
|
});
|
||||||
expect(onAuthenticatedChange).toHaveBeenLastCalledWith(true);
|
expect(onAuthenticatedChange).toHaveBeenLastCalledWith(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('retains the last password for one-click retry after unlocking the panel', async () => {
|
||||||
|
mockApi.roomLogin
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
status: 'timeout',
|
||||||
|
authenticated: false,
|
||||||
|
message: 'No reply heard',
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
status: 'ok',
|
||||||
|
authenticated: true,
|
||||||
|
message: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<RoomServerPanel contact={roomContact} />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('Repeater password'), {
|
||||||
|
target: { value: 'secret-room-password' },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByText('Login with Password'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Retry Password Login')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Retry Password Login'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockApi.roomLogin).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
roomContact.public_key,
|
||||||
|
'secret-room-password'
|
||||||
|
);
|
||||||
|
expect(mockApi.roomLogin).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
roomContact.public_key,
|
||||||
|
'secret-room-password'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows only a success toast after a confirmed login', async () => {
|
||||||
|
mockApi.roomLogin.mockResolvedValueOnce({
|
||||||
|
status: 'ok',
|
||||||
|
authenticated: true,
|
||||||
|
message: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<RoomServerPanel contact={roomContact} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Login with Existing Access / Guest'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Show Tools')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByText('Login confirmed by the room server.')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Retry Password Login')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Retry Existing-Access Login')).not.toBeInTheDocument();
|
||||||
|
expect(mockToast.success).toHaveBeenCalledWith('Login confirmed by the room server.');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ import {
|
|||||||
} from '../utils/lastViewedConversation';
|
} from '../utils/lastViewedConversation';
|
||||||
import { api } from '../api';
|
import { api } from '../api';
|
||||||
import { DISTANCE_UNIT_KEY } from '../utils/distanceUnits';
|
import { DISTANCE_UNIT_KEY } from '../utils/distanceUnits';
|
||||||
|
import {
|
||||||
|
DEFAULT_FONT_SCALE,
|
||||||
|
FONT_SCALE_KEY,
|
||||||
|
MAX_FONT_SCALE,
|
||||||
|
MIN_FONT_SCALE,
|
||||||
|
} from '../utils/fontScale';
|
||||||
|
|
||||||
const baseConfig: RadioConfig = {
|
const baseConfig: RadioConfig = {
|
||||||
public_key: 'aa'.repeat(32),
|
public_key: 'aa'.repeat(32),
|
||||||
@@ -186,6 +192,7 @@ describe('SettingsModal', () => {
|
|||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
window.location.hash = '';
|
window.location.hash = '';
|
||||||
|
document.documentElement.style.fontSize = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
it('refreshes app settings when opened', async () => {
|
it('refreshes app settings when opened', async () => {
|
||||||
@@ -300,6 +307,7 @@ describe('SettingsModal', () => {
|
|||||||
results: [
|
results: [
|
||||||
{
|
{
|
||||||
public_key: '11'.repeat(32),
|
public_key: '11'.repeat(32),
|
||||||
|
name: null,
|
||||||
node_type: 'repeater',
|
node_type: 'repeater',
|
||||||
heard_count: 2,
|
heard_count: 2,
|
||||||
local_snr: 7.5,
|
local_snr: 7.5,
|
||||||
@@ -548,6 +556,55 @@ describe('SettingsModal', () => {
|
|||||||
expect(localStorage.getItem(DISTANCE_UNIT_KEY)).toBe('smoots');
|
expect(localStorage.getItem(DISTANCE_UNIT_KEY)).toBe('smoots');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('defaults relative font size to 100% and exposes the expected input bounds', () => {
|
||||||
|
renderModal();
|
||||||
|
openLocalSection();
|
||||||
|
|
||||||
|
const slider = screen.getByLabelText('Relative font size slider');
|
||||||
|
const input = screen.getByLabelText('Relative font size percentage');
|
||||||
|
|
||||||
|
expect(slider).toHaveValue(String(DEFAULT_FONT_SCALE));
|
||||||
|
expect(slider).toHaveAttribute('step', '5');
|
||||||
|
expect(input).toHaveValue(DEFAULT_FONT_SCALE);
|
||||||
|
expect(input).toHaveAttribute('min', String(MIN_FONT_SCALE));
|
||||||
|
expect(input).toHaveAttribute('max', String(MAX_FONT_SCALE));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores and applies relative font size changes locally', async () => {
|
||||||
|
renderModal();
|
||||||
|
openLocalSection();
|
||||||
|
|
||||||
|
const slider = screen.getByLabelText('Relative font size slider');
|
||||||
|
|
||||||
|
fireEvent.change(slider, { target: { value: '135' } });
|
||||||
|
|
||||||
|
expect(localStorage.getItem(FONT_SCALE_KEY)).toBeNull();
|
||||||
|
expect(document.documentElement.style.fontSize).toBe('');
|
||||||
|
|
||||||
|
fireEvent.mouseUp(slider);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(localStorage.getItem(FONT_SCALE_KEY)).toBe('135');
|
||||||
|
expect(document.documentElement.style.fontSize).toBe('135%');
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('Relative font size percentage'), {
|
||||||
|
target: { value: '137.5' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(localStorage.getItem(FONT_SCALE_KEY)).toBe('137.5');
|
||||||
|
expect(document.documentElement.style.fontSize).toBe('137.5%');
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Reset' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(localStorage.getItem(FONT_SCALE_KEY)).toBeNull();
|
||||||
|
expect(document.documentElement.style.fontSize).toBe('100%');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('purges decrypted raw packets via maintenance endpoint action', async () => {
|
it('purges decrypted raw packets via maintenance endpoint action', async () => {
|
||||||
const runMaintenanceSpy = vi.spyOn(api, 'runMaintenance').mockResolvedValue({
|
const runMaintenanceSpy = vi.spyOn(api, 'runMaintenance').mockResolvedValue({
|
||||||
packets_deleted: 12,
|
packets_deleted: 12,
|
||||||
@@ -594,6 +651,14 @@ describe('SettingsModal', () => {
|
|||||||
double_byte_pct: 30,
|
double_byte_pct: 30,
|
||||||
triple_byte_pct: 20,
|
triple_byte_pct: 20,
|
||||||
},
|
},
|
||||||
|
noise_floor_24h: {
|
||||||
|
sample_interval_seconds: 300,
|
||||||
|
coverage_seconds: 3600,
|
||||||
|
latest_noise_floor_dbm: -105,
|
||||||
|
latest_timestamp: 1711800000,
|
||||||
|
supported: true,
|
||||||
|
samples: [],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||||
@@ -625,17 +690,11 @@ describe('SettingsModal', () => {
|
|||||||
expect(
|
expect(
|
||||||
screen.getByText(/Parsed stored raw packets from the last 24 hours: 120/)
|
screen.getByText(/Parsed stored raw packets from the last 24 hours: 120/)
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.getByText('1-byte hops')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('60 (50.0%)')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('36 (30.0%)')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('24 (20.0%)')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Contacts heard')).toBeInTheDocument();
|
expect(screen.getByText('Contacts heard')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Repeaters heard')).toBeInTheDocument();
|
expect(screen.getByText('Repeaters heard')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Known-channels active')).toBeInTheDocument();
|
expect(screen.getByText('Known-channels active')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Busiest Channels (24h)')).toBeInTheDocument();
|
||||||
// Busiest channels
|
expect(screen.getByText('Noise Floor (24h)')).toBeInTheDocument();
|
||||||
expect(screen.getByText('general')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('42 msgs')).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fetches statistics when expanded in mobile external-nav mode', async () => {
|
it('fetches statistics when expanded in mobile external-nav mode', async () => {
|
||||||
@@ -662,6 +721,14 @@ describe('SettingsModal', () => {
|
|||||||
double_byte_pct: 30,
|
double_byte_pct: 30,
|
||||||
triple_byte_pct: 20,
|
triple_byte_pct: 20,
|
||||||
},
|
},
|
||||||
|
noise_floor_24h: {
|
||||||
|
sample_interval_seconds: 300,
|
||||||
|
coverage_seconds: 0,
|
||||||
|
latest_noise_floor_dbm: null,
|
||||||
|
latest_timestamp: null,
|
||||||
|
supported: null,
|
||||||
|
samples: [],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||||
|
|||||||
@@ -75,13 +75,14 @@ function renderSidebar(overrides?: {
|
|||||||
|
|
||||||
const favorites = overrides?.favorites ?? [{ type: 'channel', id: flightChannel.key }];
|
const favorites = overrides?.favorites ?? [{ type: 'channel', id: flightChannel.key }];
|
||||||
const channels = overrides?.channels ?? [publicChannel, flightChannel, opsChannel];
|
const channels = overrides?.channels ?? [publicChannel, flightChannel, opsChannel];
|
||||||
|
const onSelectConversation = vi.fn();
|
||||||
|
|
||||||
const view = render(
|
const view = render(
|
||||||
<Sidebar
|
<Sidebar
|
||||||
contacts={[alice, board, relay]}
|
contacts={[alice, board, relay]}
|
||||||
channels={channels}
|
channels={channels}
|
||||||
activeConversation={null}
|
activeConversation={null}
|
||||||
onSelectConversation={vi.fn()}
|
onSelectConversation={onSelectConversation}
|
||||||
onNewMessage={vi.fn()}
|
onNewMessage={vi.fn()}
|
||||||
lastMessageTimes={overrides?.lastMessageTimes ?? {}}
|
lastMessageTimes={overrides?.lastMessageTimes ?? {}}
|
||||||
unreadCounts={unreadCounts}
|
unreadCounts={unreadCounts}
|
||||||
@@ -96,7 +97,7 @@ function renderSidebar(overrides?: {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
return { ...view, flightChannel, opsChannel, aliceName, roomName };
|
return { ...view, flightChannel, opsChannel, aliceName, roomName, onSelectConversation };
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSectionHeaderContainer(title: string): HTMLElement {
|
function getSectionHeaderContainer(title: string): HTMLElement {
|
||||||
@@ -121,6 +122,46 @@ describe('Sidebar section summaries', () => {
|
|||||||
expect(within(getSectionHeaderContainer('Repeaters')).getByText('4')).toBeInTheDocument();
|
expect(within(getSectionHeaderContainer('Repeaters')).getByText('4')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders a full add channel/contact button above search and calls onNewMessage', () => {
|
||||||
|
const onNewMessage = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Sidebar
|
||||||
|
contacts={[]}
|
||||||
|
channels={[makeChannel(PUBLIC_CHANNEL_KEY, 'Public')]}
|
||||||
|
activeConversation={null}
|
||||||
|
onSelectConversation={vi.fn()}
|
||||||
|
onNewMessage={onNewMessage}
|
||||||
|
lastMessageTimes={{}}
|
||||||
|
unreadCounts={{}}
|
||||||
|
mentions={{}}
|
||||||
|
showCracker={false}
|
||||||
|
crackerRunning={false}
|
||||||
|
onToggleCracker={vi.fn()}
|
||||||
|
onMarkAllRead={vi.fn()}
|
||||||
|
favorites={[]}
|
||||||
|
legacySortOrder="recent"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const addButton = screen.getByRole('button', { name: 'Add channel or contact' });
|
||||||
|
const search = screen.getByLabelText('Search conversations');
|
||||||
|
const nav = screen.getByRole('navigation', { name: 'Conversations' });
|
||||||
|
const toolsButton = screen.getByRole('button', { name: 'Tools' });
|
||||||
|
|
||||||
|
expect(addButton).toHaveTextContent('Add Channel/Contact');
|
||||||
|
expect(
|
||||||
|
addButton.compareDocumentPosition(search) & Node.DOCUMENT_POSITION_FOLLOWING
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(nav.compareDocumentPosition(search) & Node.DOCUMENT_POSITION_CONTAINED_BY).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
search.compareDocumentPosition(toolsButton) & Node.DOCUMENT_POSITION_FOLLOWING
|
||||||
|
).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
expect(onNewMessage).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('turns favorites and channels rollups red when they contain a mention', () => {
|
it('turns favorites and channels rollups red when they contain a mention', () => {
|
||||||
renderSidebar({
|
renderSidebar({
|
||||||
mentions: {
|
mentions: {
|
||||||
@@ -306,6 +347,18 @@ describe('Sidebar section summaries', () => {
|
|||||||
expect(bell.compareDocumentPosition(unread) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
expect(bell.compareDocumentPosition(unread) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows the trace tool row and selects it', () => {
|
||||||
|
const { onSelectConversation } = renderSidebar();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Trace'));
|
||||||
|
|
||||||
|
expect(onSelectConversation).toHaveBeenCalledWith({
|
||||||
|
type: 'trace',
|
||||||
|
id: 'trace',
|
||||||
|
name: 'Trace',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('sorts each section independently and persists per-section sort preferences', () => {
|
it('sorts each section independently and persists per-section sort preferences', () => {
|
||||||
const publicChannel = makeChannel('AA'.repeat(16), 'Public');
|
const publicChannel = makeChannel('AA'.repeat(16), 'Public');
|
||||||
const zebraChannel = makeChannel('BB'.repeat(16), '#zebra');
|
const zebraChannel = makeChannel('BB'.repeat(16), '#zebra');
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { TracePane } from '../components/TracePane';
|
||||||
|
import type { Contact, RadioConfig, RadioTraceResponse } from '../types';
|
||||||
|
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||||
|
|
||||||
|
function makeContact(
|
||||||
|
publicKey: string,
|
||||||
|
name: string | null,
|
||||||
|
type = CONTACT_TYPE_REPEATER,
|
||||||
|
overrides: Partial<Contact> = {}
|
||||||
|
): Contact {
|
||||||
|
return {
|
||||||
|
public_key: publicKey,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
flags: 0,
|
||||||
|
direct_path: null,
|
||||||
|
direct_path_len: -1,
|
||||||
|
direct_path_hash_mode: -1,
|
||||||
|
last_advert: null,
|
||||||
|
lat: null,
|
||||||
|
lon: null,
|
||||||
|
last_seen: null,
|
||||||
|
on_radio: false,
|
||||||
|
last_contacted: null,
|
||||||
|
last_read_at: null,
|
||||||
|
first_seen: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: RadioConfig = {
|
||||||
|
public_key: 'ff'.repeat(32),
|
||||||
|
name: 'Base Radio',
|
||||||
|
lat: 10,
|
||||||
|
lon: 20,
|
||||||
|
tx_power: 17,
|
||||||
|
max_tx_power: 22,
|
||||||
|
radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 },
|
||||||
|
path_hash_mode: 0,
|
||||||
|
path_hash_mode_supported: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('TracePane', () => {
|
||||||
|
it('shows only full-key repeaters and filters by name or key', () => {
|
||||||
|
render(
|
||||||
|
<TracePane
|
||||||
|
config={config}
|
||||||
|
onRunTracePath={vi.fn()}
|
||||||
|
contacts={[
|
||||||
|
makeContact('11'.repeat(32), 'Relay Alpha'),
|
||||||
|
makeContact('22'.repeat(6), 'Prefix Relay'),
|
||||||
|
makeContact('33'.repeat(32), 'Client Node', 1),
|
||||||
|
makeContact('44'.repeat(32), 'Relay Beta'),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Relay Alpha')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Relay Beta')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Prefix Relay')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Client Node')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('Search repeaters'), { target: { value: 'beta' } });
|
||||||
|
expect(screen.queryByText('Relay Alpha')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Relay Beta')).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('Search repeaters'), { target: { value: '111111' } });
|
||||||
|
expect(screen.getByText('Relay Alpha')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds, reorders, removes, and sends a trace path with known repeaters', async () => {
|
||||||
|
const relayA = makeContact('11'.repeat(32), 'Relay Alpha');
|
||||||
|
const relayB = makeContact('22'.repeat(32), 'Relay Beta');
|
||||||
|
const onRunTracePath = vi.fn(
|
||||||
|
async (): Promise<RadioTraceResponse> => ({
|
||||||
|
path_len: 2,
|
||||||
|
timeout_seconds: 6,
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
role: 'repeater',
|
||||||
|
public_key: relayB.public_key,
|
||||||
|
name: relayB.name,
|
||||||
|
observed_hash: relayB.public_key.slice(0, 8),
|
||||||
|
snr: 7.5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'repeater',
|
||||||
|
public_key: relayA.public_key,
|
||||||
|
name: relayA.name,
|
||||||
|
observed_hash: relayA.public_key.slice(0, 8),
|
||||||
|
snr: 3.25,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'local',
|
||||||
|
public_key: config.public_key,
|
||||||
|
name: config.name,
|
||||||
|
observed_hash: null,
|
||||||
|
snr: 5.0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TracePane config={config} onRunTracePath={onRunTracePath} contacts={[relayA, relayB]} />
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay beta/i }));
|
||||||
|
|
||||||
|
expect(screen.getByText('2 hops selected · 4-byte trace')).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /move relay beta up/i }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /send trace/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onRunTracePath).toHaveBeenCalledWith(4, [
|
||||||
|
{ public_key: relayB.public_key },
|
||||||
|
{ public_key: relayA.public_key },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByRole('heading', { name: 'Results (6.0s)' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('+7.5 dB')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('+5.0 dB')).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /remove relay alpha/i }));
|
||||||
|
expect(screen.getByText('1 hop selected · 4-byte trace')).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /remove relay beta/i }));
|
||||||
|
expect(screen.getByText('No hops selected')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows adding the same repeater multiple times from the picker row', () => {
|
||||||
|
const relayA = makeContact('11'.repeat(32), 'Relay Alpha');
|
||||||
|
|
||||||
|
render(<TracePane config={config} onRunTracePath={vi.fn()} contacts={[relayA]} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i }));
|
||||||
|
|
||||||
|
expect(screen.getByText('2 hops selected · 4-byte trace')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Added 2 times')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds custom hops from the modal and locks later custom hops to the same byte width', async () => {
|
||||||
|
const relayA = makeContact('11'.repeat(32), 'Relay Alpha');
|
||||||
|
const onRunTracePath = vi.fn(
|
||||||
|
async (): Promise<RadioTraceResponse> => ({
|
||||||
|
path_len: 2,
|
||||||
|
timeout_seconds: 4.5,
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
role: 'custom',
|
||||||
|
public_key: null,
|
||||||
|
name: null,
|
||||||
|
observed_hash: 'ae',
|
||||||
|
snr: 4.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'repeater',
|
||||||
|
public_key: relayA.public_key,
|
||||||
|
name: relayA.name,
|
||||||
|
observed_hash: '11',
|
||||||
|
snr: 2.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'local',
|
||||||
|
public_key: config.public_key,
|
||||||
|
name: config.name,
|
||||||
|
observed_hash: null,
|
||||||
|
snr: 3.0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<TracePane config={config} onRunTracePath={onRunTracePath} contacts={[relayA]} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Custom path' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '1-byte' }));
|
||||||
|
fireEvent.change(screen.getByLabelText('Repeater prefix'), { target: { value: 'ae' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Add custom hop' }));
|
||||||
|
|
||||||
|
expect(screen.getByText('1 hop selected · 1-byte trace')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('AE (1-byte)')).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /send trace/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onRunTracePath).toHaveBeenCalledWith(1, [
|
||||||
|
{ hop_hex: 'ae' },
|
||||||
|
{ public_key: relayA.public_key },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Custom path' }));
|
||||||
|
expect(screen.getByRole('button', { name: '2-byte' })).toBeDisabled();
|
||||||
|
expect(screen.getByRole('button', { name: '4-byte' })).toBeDisabled();
|
||||||
|
expect(screen.getByText(/custom hops are locked to 1-byte prefixes/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops an in-flight result after the draft path changes', async () => {
|
||||||
|
const relayA = makeContact('11'.repeat(32), 'Relay Alpha');
|
||||||
|
const relayB = makeContact('22'.repeat(32), 'Relay Beta');
|
||||||
|
let resolveTrace: ((value: RadioTraceResponse) => void) | null = null;
|
||||||
|
const onRunTracePath = vi.fn(
|
||||||
|
() =>
|
||||||
|
new Promise<RadioTraceResponse>((resolve) => {
|
||||||
|
resolveTrace = resolve;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TracePane config={config} onRunTracePath={onRunTracePath} contacts={[relayA, relayB]} />
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /send trace/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onRunTracePath).toHaveBeenCalledWith(4, [{ public_key: relayA.public_key }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay beta/i }));
|
||||||
|
|
||||||
|
expect(screen.getByText('2 hops selected · 4-byte trace')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /send trace/i })).toBeEnabled();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
resolveTrace?.({
|
||||||
|
path_len: 1,
|
||||||
|
timeout_seconds: 6,
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
role: 'repeater',
|
||||||
|
public_key: relayA.public_key,
|
||||||
|
name: relayA.name,
|
||||||
|
observed_hash: relayA.public_key.slice(0, 8),
|
||||||
|
snr: 7.5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'local',
|
||||||
|
public_key: config.public_key,
|
||||||
|
name: config.name,
|
||||||
|
observed_hash: null,
|
||||||
|
snr: 5.0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByRole('heading', { name: 'Results (6.0s)' })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('+7.5 dB')).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText('Send a trace to see the returned hop-by-hop SNR values.')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -52,6 +52,14 @@ describe('parseHashConversation', () => {
|
|||||||
expect(result).toEqual({ type: 'map', name: 'map' });
|
expect(result).toEqual({ type: 'map', name: 'map' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('parses #trace as trace type', () => {
|
||||||
|
window.location.hash = '#trace';
|
||||||
|
|
||||||
|
const result = parseHashConversation();
|
||||||
|
|
||||||
|
expect(result).toEqual({ type: 'trace', name: 'trace' });
|
||||||
|
});
|
||||||
|
|
||||||
it('parses #map/focus/PUBKEY with focus key', () => {
|
it('parses #map/focus/PUBKEY with focus key', () => {
|
||||||
window.location.hash = '#map/focus/ABCD1234';
|
window.location.hash = '#map/focus/ABCD1234';
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ function createRealtimeArgs(overrides: Partial<Parameters<typeof useRealtimeAppS
|
|||||||
observeMessage: vi.fn(() => ({ added: false, activeConversation: false })),
|
observeMessage: vi.fn(() => ({ added: false, activeConversation: false })),
|
||||||
recordMessageEvent: vi.fn(),
|
recordMessageEvent: vi.fn(),
|
||||||
renameConversationState: vi.fn(),
|
renameConversationState: vi.fn(),
|
||||||
|
removeConversationState: vi.fn(),
|
||||||
checkMention: vi.fn(() => false),
|
checkMention: vi.fn(() => false),
|
||||||
pendingDeleteFallbackRef: { current: false },
|
pendingDeleteFallbackRef: { current: false },
|
||||||
setActiveConversation: vi.fn(),
|
setActiveConversation: vi.fn(),
|
||||||
|
|||||||
@@ -8,70 +8,24 @@ describe('useRememberedServerPassword', () => {
|
|||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads remembered passwords from localStorage', () => {
|
it('restores the last in-memory password when local remember is disabled', () => {
|
||||||
localStorage.setItem(
|
const { result, unmount } = renderHook(() =>
|
||||||
'remoteterm-server-password:repeater:abc123',
|
useRememberedServerPassword('room', 'aa'.repeat(32))
|
||||||
JSON.stringify({ password: 'stored-secret' })
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(() => useRememberedServerPassword('repeater', 'abc123'));
|
|
||||||
|
|
||||||
expect(result.current.password).toBe('stored-secret');
|
|
||||||
expect(result.current.rememberPassword).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stores passwords after login when remember is enabled', () => {
|
|
||||||
const { result } = renderHook(() => useRememberedServerPassword('room', 'room-key'));
|
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.setRememberPassword(true);
|
result.current.setPassword('room-secret');
|
||||||
|
result.current.persistAfterLogin('room-secret');
|
||||||
});
|
});
|
||||||
|
|
||||||
act(() => {
|
expect(result.current.password).toBe('room-secret');
|
||||||
result.current.persistAfterLogin(' hello ');
|
unmount();
|
||||||
});
|
|
||||||
|
|
||||||
expect(localStorage.getItem('remoteterm-server-password:room:room-key')).toBe(
|
const { result: remounted } = renderHook(() =>
|
||||||
JSON.stringify({ password: 'hello' })
|
useRememberedServerPassword('room', 'aa'.repeat(32))
|
||||||
);
|
|
||||||
expect(result.current.password).toBe('hello');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clears stored passwords when login is done with remember disabled', () => {
|
|
||||||
localStorage.setItem(
|
|
||||||
'remoteterm-server-password:repeater:abc123',
|
|
||||||
JSON.stringify({ password: 'stored-secret' })
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(() => useRememberedServerPassword('repeater', 'abc123'));
|
expect(remounted.current.password).toBe('room-secret');
|
||||||
|
expect(remounted.current.rememberPassword).toBe(false);
|
||||||
act(() => {
|
|
||||||
result.current.setRememberPassword(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.persistAfterLogin('new-secret');
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(localStorage.getItem('remoteterm-server-password:repeater:abc123')).toBeNull();
|
|
||||||
expect(result.current.password).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('preserves remembered passwords on guest login when remember stays enabled', () => {
|
|
||||||
localStorage.setItem(
|
|
||||||
'remoteterm-server-password:room:room-key',
|
|
||||||
JSON.stringify({ password: 'stored-secret' })
|
|
||||||
);
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useRememberedServerPassword('room', 'room-key'));
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.persistAfterLogin('');
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(localStorage.getItem('remoteterm-server-password:room:room-key')).toBe(
|
|
||||||
JSON.stringify({ password: 'stored-secret' })
|
|
||||||
);
|
|
||||||
expect(result.current.password).toBe('stored-secret');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ describe('useRepeaterDashboard', () => {
|
|||||||
|
|
||||||
expect(result.current.loggedIn).toBe(true);
|
expect(result.current.loggedIn).toBe(true);
|
||||||
expect(result.current.loginError).toBe(null);
|
expect(result.current.loginError).toBe(null);
|
||||||
|
expect(result.current.lastLoginAttempt?.heardBack).toBe(true);
|
||||||
|
expect(result.current.lastLoginAttempt?.outcome).toBe('confirmed');
|
||||||
expect(mockApi.repeaterLogin).toHaveBeenCalledWith(REPEATER_KEY, 'secret');
|
expect(mockApi.repeaterLogin).toHaveBeenCalledWith(REPEATER_KEY, 'secret');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -92,6 +94,8 @@ describe('useRepeaterDashboard', () => {
|
|||||||
|
|
||||||
expect(result.current.loggedIn).toBe(true);
|
expect(result.current.loggedIn).toBe(true);
|
||||||
expect(result.current.loginError).toBe('Auth failed');
|
expect(result.current.loginError).toBe('Auth failed');
|
||||||
|
expect(result.current.lastLoginAttempt?.heardBack).toBe(true);
|
||||||
|
expect(result.current.lastLoginAttempt?.outcome).toBe('not_confirmed');
|
||||||
expect(mockToast.error).toHaveBeenCalledWith('Login not confirmed', {
|
expect(mockToast.error).toHaveBeenCalledWith('Login not confirmed', {
|
||||||
description: 'Auth failed',
|
description: 'Auth failed',
|
||||||
});
|
});
|
||||||
@@ -125,6 +129,8 @@ describe('useRepeaterDashboard', () => {
|
|||||||
|
|
||||||
expect(result.current.loggedIn).toBe(true);
|
expect(result.current.loggedIn).toBe(true);
|
||||||
expect(result.current.loginError).toBe('Network error');
|
expect(result.current.loginError).toBe('Network error');
|
||||||
|
expect(result.current.lastLoginAttempt?.heardBack).toBe(false);
|
||||||
|
expect(result.current.lastLoginAttempt?.outcome).toBe('request_failed');
|
||||||
expect(mockToast.error).toHaveBeenCalledWith('Login request failed', {
|
expect(mockToast.error).toHaveBeenCalledWith('Login request failed', {
|
||||||
description:
|
description:
|
||||||
'Network error. The dashboard is still available, but repeater operations may fail until a login succeeds.',
|
'Network error. The dashboard is still available, but repeater operations may fail until a login succeeds.',
|
||||||
|
|||||||
@@ -221,6 +221,49 @@ describe('useUnreadCounts', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not treat search or trace views as readable conversations', async () => {
|
||||||
|
const mocks = await getMockedApi();
|
||||||
|
mocks.getUnreads.mockResolvedValue({
|
||||||
|
counts: {
|
||||||
|
[getStateKey('channel', CHANNEL_KEY)]: 4,
|
||||||
|
[getStateKey('contact', CONTACT_KEY)]: 2,
|
||||||
|
},
|
||||||
|
mentions: {
|
||||||
|
[getStateKey('channel', CHANNEL_KEY)]: true,
|
||||||
|
},
|
||||||
|
last_message_times: {},
|
||||||
|
last_read_ats: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result, rerender } = renderWith({
|
||||||
|
channels: [makeChannel(CHANNEL_KEY, 'Test')],
|
||||||
|
contacts: [makeContact(CONTACT_KEY)],
|
||||||
|
activeConversation: { type: 'search', id: 'search', name: 'Message Search' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await vi.waitFor(() => expect(mocks.getUnreads).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.unreadCounts[getStateKey('channel', CHANNEL_KEY)]).toBe(4);
|
||||||
|
expect(result.current.unreadCounts[getStateKey('contact', CONTACT_KEY)]).toBe(2);
|
||||||
|
expect(mocks.markChannelRead).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.markContactRead).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
rerender({
|
||||||
|
channels: [makeChannel(CHANNEL_KEY, 'Test')],
|
||||||
|
contacts: [makeContact(CONTACT_KEY)],
|
||||||
|
activeConversation: { type: 'trace', id: 'trace', name: 'Trace' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.markChannelRead).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.markContactRead).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('re-fetches and filters when refreshUnreads is called (simulating WS reconnect)', async () => {
|
it('re-fetches and filters when refreshUnreads is called (simulating WS reconnect)', async () => {
|
||||||
const mocks = await getMockedApi();
|
const mocks = await getMockedApi();
|
||||||
const channels = [makeChannel(CHANNEL_KEY, 'Test')];
|
const channels = [makeChannel(CHANNEL_KEY, 'Test')];
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { VisualizerControls } from '../components/visualizer/VisualizerControls';
|
||||||
|
|
||||||
|
describe('VisualizerControls', () => {
|
||||||
|
it('allows clearing numeric inputs while editing', () => {
|
||||||
|
render(
|
||||||
|
<VisualizerControls
|
||||||
|
showControls
|
||||||
|
setShowControls={vi.fn()}
|
||||||
|
showAmbiguousPaths={false}
|
||||||
|
setShowAmbiguousPaths={vi.fn()}
|
||||||
|
showAmbiguousNodes={false}
|
||||||
|
setShowAmbiguousNodes={vi.fn()}
|
||||||
|
useAdvertPathHints={false}
|
||||||
|
setUseAdvertPathHints={vi.fn()}
|
||||||
|
collapseLikelyKnownSiblingRepeaters={false}
|
||||||
|
setCollapseLikelyKnownSiblingRepeaters={vi.fn()}
|
||||||
|
splitAmbiguousByTraffic={false}
|
||||||
|
setSplitAmbiguousByTraffic={vi.fn()}
|
||||||
|
observationWindowSec={5}
|
||||||
|
setObservationWindowSec={vi.fn()}
|
||||||
|
pruneStaleNodes
|
||||||
|
setPruneStaleNodes={vi.fn()}
|
||||||
|
pruneStaleMinutes={10}
|
||||||
|
setPruneStaleMinutes={vi.fn()}
|
||||||
|
letEmDrift={false}
|
||||||
|
setLetEmDrift={vi.fn()}
|
||||||
|
autoOrbit={false}
|
||||||
|
setAutoOrbit={vi.fn()}
|
||||||
|
chargeStrength={-100}
|
||||||
|
setChargeStrength={vi.fn()}
|
||||||
|
particleSpeedMultiplier={1}
|
||||||
|
setParticleSpeedMultiplier={vi.fn()}
|
||||||
|
nodeCount={0}
|
||||||
|
linkCount={0}
|
||||||
|
onExpandContract={vi.fn()}
|
||||||
|
onClearAndReset={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const observationInput = screen.getByLabelText('Ack/echo listen window:') as HTMLInputElement;
|
||||||
|
const pruneInput = screen.getByLabelText('Window:') as HTMLInputElement;
|
||||||
|
|
||||||
|
fireEvent.change(observationInput, { target: { value: '' } });
|
||||||
|
fireEvent.change(pruneInput, { target: { value: '' } });
|
||||||
|
|
||||||
|
expect(observationInput.value).toBe('');
|
||||||
|
expect(pruneInput.value).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user