mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-11 12:00:28 +02:00
Compare commits
178 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 53a4d8186a | |||
| 70e1669113 | |||
| 3b1a292507 | |||
| 4f19e1ec9a | |||
| 59601bb98e | |||
| f6b0fd21fb | |||
| 8a4858a313 | |||
| 442c2fad20 | |||
| 8cc542ce23 | |||
| a7258c120e | |||
| 8752320f52 | |||
| f9f046a05f | |||
| 390c0624ea | |||
| 2f55d11b0b | |||
| fa0be24990 | |||
| 1e22a21445 | |||
| e09a3a01f7 | |||
| 3bd756ee4e | |||
| 43c5e0f67d | |||
| c0fc5fbba2 | |||
| c7248222dd | |||
| 1e18a91f12 | |||
| 18db6e4dd8 | |||
| 2393dadf1b | |||
| fd26576e0d | |||
| cb5a76eb5f | |||
| 7f5dde119f | |||
| 799a721761 | |||
| 152a584f35 | |||
| 5cc0476426 | |||
| e468c6c161 | |||
| e33537018b | |||
| 0727793560 | |||
| 5c4e04e024 | |||
| 967269ef7d | |||
| 1903797d0d | |||
| bb5af5ba82 | |||
| 424da7e232 | |||
| 159df1ec5b | |||
| 8e2e039985 | |||
| 01c86a486e | |||
| 7d5cfdec26 | |||
| 5fe0ac0ad4 | |||
| b98102ccac | |||
| a02c3cae9e | |||
| ca7349a1a8 | |||
| eeaa11b8b0 | |||
| 08eaf090b2 | |||
| 2f43420235 | |||
| af74663518 | |||
| b7981c0450 | |||
| 0f4976b9ee | |||
| 1991f2515b | |||
| a351c86ccb | |||
| c2e1a3cbe6 | |||
| c2d1339256 | |||
| cb7139a7e1 | |||
| 6332387704 | |||
| 3f2b8e2a1f | |||
| 40c37745b6 | |||
| 9edac47aa2 | |||
| 44f8aafb66 | |||
| 9e3805f5d0 | |||
| 457799d8df | |||
| de3ad2d51f | |||
| ad83bc7979 | |||
| 9ebf63491c | |||
| b19585db6d | |||
| c28d22379e | |||
| 1e5ccf6c29 | |||
| 81f5bde287 | |||
| c33eb469ac | |||
| 0fe6584e7a | |||
| 557d79d437 | |||
| daff3dcb4a | |||
| 77db7287d6 | |||
| 67873e8dd9 | |||
| e2ddf5f79f | |||
| 4a93641f04 | |||
| d5922a214b | |||
| 7ad1ee26a4 | |||
| 08238aa464 | |||
| 1046baf741 | |||
| 42e1b7b5d9 | |||
| 3ca4f7edf7 | |||
| 55081d4a2d | |||
| be2b2604df | |||
| 35981d8f8b | |||
| 8e998c03ba | |||
| d802dd4212 | |||
| 7557eb1fa6 | |||
| 6a4af5e602 | |||
| 1895e6a919 | |||
| 975bf7f03f | |||
| c7d5d3887d | |||
| 5c93d8487e | |||
| 5d2834a9fb | |||
| cfe485bf29 | |||
| e7f6bd0397 | |||
| 1e7dc6af46 | |||
| af40cc3c8e | |||
| 2561b70fed | |||
| 44f145b646 | |||
| 55e2dc478d | |||
| 0932800e1f | |||
| c333eb25e3 | |||
| 580aa1cefd | |||
| 30de09f71b | |||
| 93d31adecd | |||
| 5f969017f7 | |||
| 967dd05fad | |||
| c808f0930b | |||
| 87df4b4aa1 | |||
| 0511d6f69b | |||
| 78b5598f67 | |||
| 5e1bdb2cc1 | |||
| 4420d44838 | |||
| ead1774cd3 | |||
| 0d45cbd849 | |||
| 456f739f51 | |||
| 80c6cc44e5 | |||
| 35265d8ae8 | |||
| 4a2d7ed100 | |||
| 47c4f038fe | |||
| 630ba67ef0 | |||
| fd1188abcd | |||
| 94513d7177 | |||
| fbff9821be | |||
| 1fd281121b | |||
| 5653a43941 | |||
| 7f07aedb8a | |||
| e437ce74c6 | |||
| 4ff6d2018a | |||
| 1c634da687 | |||
| 738c21dd66 | |||
| 7d72448ebf | |||
| b4f3d1f14c | |||
| 416166b07c | |||
| 480798e117 | |||
| 704a3d8a87 | |||
| 96e108037c | |||
| 97aade3632 | |||
| e43584912b | |||
| fccde36ecb | |||
| 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 |
@@ -0,0 +1,73 @@
|
||||
name: Publish AUR package
|
||||
|
||||
# Pushes the contents of pkg/aur/ to the remoteterm-meshcore AUR repository
|
||||
# whenever a GitHub release is published. Can also be triggered manually for
|
||||
# testing or out-of-band republishes.
|
||||
#
|
||||
# Required secrets:
|
||||
# AUR_SSH_PRIVATE_KEY Private SSH key registered with the AUR maintainer
|
||||
# account that owns the remoteterm-meshcore package.
|
||||
# AUR_COMMIT_EMAIL Email used for the AUR git commit identity.
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to publish (no v prefix, e.g. 3.9.1)'
|
||||
required: true
|
||||
|
||||
concurrency:
|
||||
# Serialize publishes so a fast back-to-back release sequence cannot race
|
||||
# two pushes against the AUR repo. The later one wins by virtue of being
|
||||
# the final state.
|
||||
group: publish-aur
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish-aur:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve version from event
|
||||
id: version
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
VERSION="${{ inputs.version }}"
|
||||
else
|
||||
VERSION="${{ github.event.release.tag_name }}"
|
||||
fi
|
||||
VERSION="${VERSION#v}"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Publishing AUR package for version $VERSION"
|
||||
|
||||
- name: Stamp pkgver into PKGBUILD
|
||||
run: |
|
||||
sed -i "s/^pkgver=.*/pkgver=${{ steps.version.outputs.version }}/" pkg/aur/PKGBUILD
|
||||
sed -i "s/^pkgrel=.*/pkgrel=1/" pkg/aur/PKGBUILD
|
||||
|
||||
- name: Publish to AUR
|
||||
uses: KSXGitHub/github-actions-deploy-aur@v4.1.2
|
||||
with:
|
||||
pkgname: remoteterm-meshcore
|
||||
pkgbuild: pkg/aur/PKGBUILD
|
||||
assets: |
|
||||
pkg/aur/remoteterm-meshcore.install
|
||||
pkg/aur/remoteterm-meshcore.service
|
||||
pkg/aur/remoteterm-meshcore.sysusers
|
||||
pkg/aur/remoteterm-meshcore.tmpfiles
|
||||
pkg/aur/remoteterm.env
|
||||
commit_username: jackkingsman
|
||||
commit_email: ${{ secrets.AUR_COMMIT_EMAIL }}
|
||||
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||
commit_message: "Update to ${{ steps.version.outputs.version }}"
|
||||
# Recompute sha256sums from the live release tarball + the bundled
|
||||
# service/env files. The committed PKGBUILD has SKIP placeholders.
|
||||
updpkgsums: true
|
||||
# Validate the PKGBUILD parses and sources download, but skip the
|
||||
# actual build (which would run uv sync + npm install for several
|
||||
# minutes of CI time on every release).
|
||||
test: true
|
||||
test_flags: --clean --cleanbuild --nodeps --nobuild
|
||||
@@ -2,6 +2,8 @@
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
!scripts/build/
|
||||
!scripts/build/**
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
@@ -23,3 +25,8 @@ references/
|
||||
|
||||
# ancillary LLM files
|
||||
.claude/
|
||||
|
||||
# local Docker compose files
|
||||
docker-compose.yml
|
||||
docker-compose.yaml
|
||||
.docker-certs/
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
If instructed to "run all tests" or "get ready for a commit" or other summative, work ending directives, run:
|
||||
|
||||
```bash
|
||||
./scripts/all_quality.sh
|
||||
./scripts/quality/all_quality.sh
|
||||
```
|
||||
|
||||
This is the repo's end-to-end quality gate. It runs backend/frontend autofixers first, then type checking, tests, and the standard frontend build. All checks must pass green, and the script may leave formatting/lint edits behind.
|
||||
@@ -209,11 +209,19 @@ This message-layer echo/path handling is independent of raw-packet storage dedup
|
||||
│ │ ├── MapView.tsx # Leaflet map showing node locations
|
||||
│ │ └── ...
|
||||
│ └── vite.config.ts
|
||||
├── pkg/aur/ # AUR package files (PKGBUILD, systemd service, env, install hooks)
|
||||
├── scripts/ # Quality / release helpers (listing below is representative, not exhaustive)
|
||||
│ ├── all_quality.sh # Repo-standard autofix + validate gate
|
||||
│ ├── collect_licenses.sh # Gather third-party license attributions
|
||||
│ ├── e2e.sh # End-to-end test runner
|
||||
│ └── publish.sh # Version bump, changelog, docker build & push
|
||||
│ ├── build/
|
||||
│ │ ├── collect_licenses.sh # Gather third-party license attributions
|
||||
│ │ └── publish.sh # Version bump, changelog, docker build & push
|
||||
│ ├── quality/
|
||||
│ │ ├── all_quality.sh # Repo-standard autofix + validate gate
|
||||
│ │ ├── e2e.sh # End-to-end test runner
|
||||
│ │ ├── extended_quality.sh # Quality gate plus e2e and Docker matrix
|
||||
│ │ └── test_aur_package.sh # Build + install AUR package in Arch Docker containers
|
||||
│ └── setup/
|
||||
│ ├── fetch_prebuilt_frontend.py # Download release frontend fallback
|
||||
│ └── install_service.sh # Install/configure Linux systemd service
|
||||
├── README_ADVANCED.md # Advanced setup, troubleshooting, and service guidance
|
||||
├── CONTRIBUTING.md # Contributor workflow and testing guidance
|
||||
├── tests/ # Backend tests (pytest)
|
||||
@@ -271,23 +279,23 @@ PYTHONPATH=. uv run pytest tests/ -v
|
||||
```
|
||||
|
||||
Key test files:
|
||||
- `tests/test_decoder.py` - Channel + direct message decryption, key exchange
|
||||
- `tests/test_keystore.py` - Ephemeral key store
|
||||
- `tests/test_event_handlers.py` - ACK tracking, repeat detection
|
||||
- `tests/test_packet_pipeline.py` - End-to-end packet processing
|
||||
- `tests/test_api.py` - API endpoints, read state tracking
|
||||
- `tests/test_migrations.py` - Database migration system
|
||||
- `tests/test_frontend_static.py` - Frontend static route registration (missing `dist`/`index.html` handling)
|
||||
- `tests/test_messages_search.py` - Message search, around endpoint, forward pagination
|
||||
- `tests/test_rx_log_data.py` - on_rx_log_data event handler integration
|
||||
- `tests/test_ack_tracking_wiring.py` - DM ACK tracking extraction and wiring
|
||||
- `tests/test_radio_lifecycle_service.py` - Radio reconnect/setup orchestration helpers
|
||||
- `tests/test_radio_commands_service.py` - Radio config/private-key service workflows
|
||||
- `tests/test_health_mqtt_status.py` - Health endpoint MQTT status field
|
||||
- `tests/test_community_mqtt.py` - Community MQTT publisher (JWT, packet format, hash, broadcast)
|
||||
- `tests/test_radio_sync.py` - Radio sync, periodic tasks, and contact offload back to the radio
|
||||
- `tests/test_real_crypto.py` - Real cryptographic operations
|
||||
- `tests/test_disable_bots.py` - MESHCORE_DISABLE_BOTS=true feature
|
||||
- `tests/test_api.py` - Broad API integration coverage across routers and read-state flows
|
||||
- `tests/test_packet_pipeline.py` - End-to-end packet processing, decrypt, dedup, and message creation
|
||||
- `tests/test_event_handlers.py` - ACK tracking, fallback DM handling, and event subscription cleanup
|
||||
- `tests/test_send_messages.py` - Outgoing DM/channel send workflows, retries, and bot-trigger wiring
|
||||
- `tests/test_packets_router.py` - Historical decrypt, maintenance, and raw-packet detail endpoints
|
||||
- `tests/test_repeater_routes.py` - Repeater command/telemetry/trace pane endpoints
|
||||
- `tests/test_room_routes.py` - Room-server login/status/ACL/telemetry endpoints
|
||||
- `tests/test_radio_router.py` - Radio config, advert, discovery, trace, and reconnect endpoints
|
||||
- `tests/test_radio_sync.py` - Radio sync, periodic tasks, contact offload/reload, and pending-message flushes
|
||||
- `tests/test_fanout.py` - Fanout config CRUD, scope matching, and manager dispatch
|
||||
- `tests/test_fanout_integration.py` - Integration-module lifecycle and delivery behavior
|
||||
- `tests/test_statistics.py` - Aggregated mesh/network statistics and noise-floor snapshots
|
||||
- `tests/test_version_info.py` - Version/build metadata resolution
|
||||
- `tests/test_websocket.py` - WS manager broadcast and cleanup behavior
|
||||
- `tests/test_frontend_static.py` - Frontend static route registration and fallback behavior
|
||||
|
||||
For the fuller backend inventory, see `app/AGENTS.md`. For frontend-specific suites, see `frontend/AGENTS.md`.
|
||||
|
||||
### Frontend (Vitest)
|
||||
|
||||
@@ -298,7 +306,7 @@ npm run test:run
|
||||
|
||||
### Before Completing Major Changes
|
||||
|
||||
**Run `./scripts/all_quality.sh` before finishing major changes that have modified code or tests.** It is the standard repo gate: autofix first, then type checks, tests, and the standard frontend build. This is not necessary for docs-only changes. For minor changes (like wording, color, spacing, etc.), wait until prompted to run the quality gate.
|
||||
**Run `./scripts/quality/all_quality.sh` before finishing major changes that have modified code or tests.** It is the standard repo gate: autofix first, then type checks, tests, and the standard frontend build. This is not necessary for docs-only changes. For minor changes (like wording, color, spacing, etc.), wait until prompted to run the quality gate.
|
||||
|
||||
## API Summary
|
||||
|
||||
@@ -313,6 +321,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
| PUT | `/api/radio/private-key` | Import private key to radio |
|
||||
| POST | `/api/radio/advertise` | Send advertisement (`mode`: `flood` or `zero_hop`, default `flood`) |
|
||||
| POST | `/api/radio/discover` | Run a short mesh discovery sweep for nearby repeaters/sensors |
|
||||
| POST | `/api/radio/trace` | Send a multi-hop trace loop through known repeaters and back to the local radio |
|
||||
| POST | `/api/radio/reboot` | Reboot radio or reconnect if disconnected |
|
||||
| POST | `/api/radio/disconnect` | Disconnect from radio and pause automatic reconnect attempts |
|
||||
| POST | `/api/radio/reconnect` | Manual radio reconnection |
|
||||
@@ -320,6 +329,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
| GET | `/api/contacts/analytics` | Unified keyed-or-name contact analytics payload |
|
||||
| GET | `/api/contacts/repeaters/advert-paths` | List recent unique advert paths for all contacts |
|
||||
| POST | `/api/contacts` | Create contact (optionally trigger historical DM decrypt) |
|
||||
| POST | `/api/contacts/bulk-delete` | Delete multiple contacts |
|
||||
| DELETE | `/api/contacts/{public_key}` | Delete contact |
|
||||
| POST | `/api/contacts/{public_key}/mark-read` | Mark contact conversation as read |
|
||||
| POST | `/api/contacts/{public_key}/command` | Send CLI command to repeater |
|
||||
@@ -335,12 +345,17 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
| POST | `/api/contacts/{public_key}/repeater/radio-settings` | Fetch repeater radio config via CLI |
|
||||
| POST | `/api/contacts/{public_key}/repeater/advert-intervals` | Fetch advert intervals |
|
||||
| POST | `/api/contacts/{public_key}/repeater/owner-info` | Fetch owner info |
|
||||
|
||||
| POST | `/api/contacts/{public_key}/room/login` | Log in to a room server |
|
||||
| POST | `/api/contacts/{public_key}/room/status` | Fetch room-server status telemetry |
|
||||
| POST | `/api/contacts/{public_key}/room/lpp-telemetry` | Fetch room-server CayenneLPP sensor data |
|
||||
| POST | `/api/contacts/{public_key}/room/acl` | Fetch room-server ACL entries |
|
||||
| GET | `/api/channels` | List channels |
|
||||
| GET | `/api/channels/{key}/detail` | Comprehensive channel profile (message stats, top senders) |
|
||||
| POST | `/api/channels` | Create channel |
|
||||
| POST | `/api/channels/bulk-hashtag` | Create multiple hashtag channels |
|
||||
| DELETE | `/api/channels/{key}` | Delete channel |
|
||||
| POST | `/api/channels/{key}/flood-scope-override` | Set or clear a per-channel regional flood-scope override |
|
||||
| POST | `/api/channels/{key}/path-hash-mode-override` | Set or clear a per-channel path hash mode override |
|
||||
| POST | `/api/channels/{key}/mark-read` | Mark channel as read |
|
||||
| GET | `/api/messages` | List with filters (`q`, `after`/`after_id` for forward pagination) |
|
||||
| GET | `/api/messages/around/{id}` | Get messages around a specific message (for jump-to-message) |
|
||||
@@ -348,6 +363,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
| POST | `/api/messages/channel` | Send channel message |
|
||||
| POST | `/api/messages/channel/{message_id}/resend` | Resend channel message (default: byte-perfect within 30s; `?new_timestamp=true`: fresh timestamp, no time limit, creates new message row) |
|
||||
| GET | `/api/packets/undecrypted/count` | Count of undecrypted packets |
|
||||
| GET | `/api/packets/{packet_id}` | Fetch one stored raw packet by row ID for on-demand inspection |
|
||||
| POST | `/api/packets/decrypt/historical` | Decrypt stored packets |
|
||||
| POST | `/api/packets/maintenance` | Delete old packets and vacuum |
|
||||
| GET | `/api/read-state/unreads` | Server-computed unread counts, mentions, last message times, and `last_read_ats` boundaries |
|
||||
@@ -357,11 +373,12 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
| POST | `/api/settings/favorites/toggle` | Toggle favorite status |
|
||||
| POST | `/api/settings/blocked-keys/toggle` | Toggle blocked key |
|
||||
| POST | `/api/settings/blocked-names/toggle` | Toggle blocked name |
|
||||
| POST | `/api/settings/migrate` | One-time migration from frontend localStorage |
|
||||
| POST | `/api/settings/tracked-telemetry/toggle` | Toggle tracked telemetry repeater |
|
||||
| GET | `/api/fanout` | List all fanout configs |
|
||||
| POST | `/api/fanout` | Create new fanout config |
|
||||
| PATCH | `/api/fanout/{id}` | Update fanout config (triggers module reload) |
|
||||
| DELETE | `/api/fanout/{id}` | Delete fanout config (stops module) |
|
||||
| POST | `/api/fanout/bots/disable-until-restart` | Stop bot fanout modules and keep bots disabled until the process restarts |
|
||||
| GET | `/api/statistics` | Aggregated mesh network statistics |
|
||||
| WS | `/api/ws` | Real-time updates |
|
||||
|
||||
@@ -387,6 +404,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
- Hashtag channels: `SHA256("#name")[:16]` converted to hex
|
||||
- Custom channels: User-provided or generated
|
||||
- Channels may also persist `flood_scope_override`; when set, channel sends temporarily switch the radio flood scope to that value for the duration of the send, then restore the global app setting.
|
||||
- Channels may persist `path_hash_mode_override` (0/1/2); when set, channel sends temporarily switch the radio path hash mode for the duration of the send, then restore the radio default.
|
||||
|
||||
### Message Types
|
||||
|
||||
@@ -450,7 +468,7 @@ mc.subscribe(EventType.ACK, handler)
|
||||
|----------|---------|-------------|
|
||||
| `MESHCORE_SERIAL_PORT` | auto-detect | Serial port for radio |
|
||||
| `MESHCORE_TCP_HOST` | *(none)* | TCP host for radio (mutually exclusive with serial/BLE) |
|
||||
| `MESHCORE_TCP_PORT` | `4000` | TCP port (used with `MESHCORE_TCP_HOST`) |
|
||||
| `MESHCORE_TCP_PORT` | `5000` | TCP port (used with `MESHCORE_TCP_HOST`) |
|
||||
| `MESHCORE_BLE_ADDRESS` | *(none)* | BLE device address (mutually exclusive with serial/TCP) |
|
||||
| `MESHCORE_BLE_PIN` | *(required with BLE)* | BLE PIN code |
|
||||
| `MESHCORE_SERIAL_BAUDRATE` | `115200` | Serial baud rate |
|
||||
@@ -462,7 +480,7 @@ mc.subscribe(EventType.ACK, handler)
|
||||
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | `false` | Switch the always-on radio audit task from hourly checks to aggressive 10-second polling; the audit checks both missed message drift and channel-slot cache drift |
|
||||
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | `false` | Disable channel-slot reuse and force `set_channel(...)` before every channel send, even on serial/BLE |
|
||||
|
||||
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `flood_scope`, `blocked_keys`, and `blocked_names`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. The backend still carries `sidebar_sort_order` for compatibility and migration, but the current frontend sidebar stores sort order per section (`Channels`, `Contacts`, `Repeaters`) in localStorage rather than treating it as one shared server-backed preference. MQTT, bot, webhook, Apprise, and SQS configs are stored in the `fanout_configs` table, managed via `/api/fanout`. If the radio's channel slots appear unstable or another client is mutating them underneath this app, operators can force the old always-reconfigure send path with `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true`.
|
||||
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `advert_interval`, `last_advert_time`, `last_message_times`, `flood_scope`, `blocked_keys`, `blocked_names`, `discovery_blocked_types`, `tracked_telemetry_repeaters`, and `auto_resend_channel`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, Apprise, and SQS configs are stored in the `fanout_configs` table, managed via `/api/fanout`. If the radio's channel slots appear unstable or another client is mutating them underneath this app, operators can force the old always-reconfigure send path with `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true`.
|
||||
|
||||
Byte-perfect channel retries are user-triggered via `POST /api/messages/channel/{message_id}/resend` and are allowed for 30 seconds after the original send.
|
||||
|
||||
|
||||
+432
-330
@@ -1,449 +1,551 @@
|
||||
## [3.11.0] - 2026-04-10
|
||||
|
||||
* Feature: Radio health and contact data accessible on fanout bus
|
||||
* Feature: Local node radio stats (voltage etc.) on WS health bus
|
||||
* Feature: Battery indicator optional in status bar (configured in Local Settings)
|
||||
* Bugfix: Fix same-second same-message collision in room servers
|
||||
* Bugfix: Don't consume DM resend attempt if the radio was just busy
|
||||
* Bugfix: Assume that a same-second same-message same-first-byte-key DM is more likely an echo than them sending the same message
|
||||
* Bugfix: Multi-retry for flood scope restoration
|
||||
* Misc: Testing & documentation improvements
|
||||
|
||||
## [3.10.0] - 2026-04-10
|
||||
|
||||
* Feature: Add Arch AUR package
|
||||
* Feature: 72hr packet density view in statistics
|
||||
* Feature: Add warnings for event loop selection for MQTT on Windows startup
|
||||
* Bugfix: Bump Apprise to 1.9.9 to fix Matrix bug
|
||||
* Misc: More memory-conscious on recent contact fetch
|
||||
* Misc: Fix statistics pane e2e test
|
||||
|
||||
## [3.9.0] - 2026-04-06
|
||||
|
||||
* Feature: Add hop counts to hop-width selection options
|
||||
* Feature: Show cached repeater telemetry inline in settings
|
||||
* Feature: Retain recent traces and make them click-to-re-run
|
||||
* Feature: Autofocus channel/DM textbox on desktop
|
||||
* Feature: Favorites on the radio are now imported as favorites
|
||||
* Bugfix: Be clearer on issue identification for missing HTTPS context in channel finder
|
||||
* Bugfix: Don't use sender timestamp for message sequence display
|
||||
* Bugfix: Function on subdomains happily
|
||||
* Misc: Be gentler, room s/cracker/finder/
|
||||
* Misc: Test and frontend correctness & test fixes
|
||||
* Misc: Don't repeat clock sync failure logs
|
||||
* Misc: Make warning in readme clearer about taking over the radio
|
||||
* Misc: Improve readme phrasings
|
||||
* Misc: Better y-axis selection for battery read-out
|
||||
* Misc: Provide clearer warning on docker setup without docker installed
|
||||
* Misc: Default visualizer stale pruning to on/5 minutes
|
||||
* Misc: Migrate favorites to better storage pattern
|
||||
* Misc: Provide dumper script for API + WS interfaces for prep for HA integration
|
||||
|
||||
## [3.8.0] - 2026-04-03
|
||||
|
||||
* Feature: Per-channel hop width override
|
||||
* Feature: Intervalized repeater telemetry collection
|
||||
* Feature: Auto-resend option for byte-perfect resends on no repeater echo
|
||||
* Feature: Attach RSSI/SNR to received packets
|
||||
* Feature: Add motion packet display to map
|
||||
* Feature: Map dark mode
|
||||
* Bugfix: Make DB indices more useful around capitalization
|
||||
* Misc: Bump required Python to 3.11
|
||||
* Misc: Performance, documentation, and test improvements
|
||||
* Misc: More yields during long radio operations
|
||||
* Misc: Dead code & crufty test removal
|
||||
* Misc: Remove all but stub frontend favorites migration for very very old versions
|
||||
|
||||
## [3.7.1] - 2026-04-02
|
||||
|
||||
* Feature: Redact Apprise URLs to prevent sensitive information disclosure
|
||||
|
||||
## [3.7.0] - 2026-04-02
|
||||
|
||||
* Feature: Repeater battery tracking
|
||||
* Feature: Repeater info pane just like contacts
|
||||
* Feature: Make repeaters blockable
|
||||
* Feature: Add new-node advert blocking
|
||||
* Feature: Add bulk deletion interface
|
||||
* Feature: Bulk room add on alt+click of new channel button
|
||||
* Feature: More info in debug endpoint
|
||||
* Bugfix: Be more conservative around radio load limits and don't exceed radio-reported capacity
|
||||
* Misc: Default auto-DM decrypt to true
|
||||
* Misc: Reorganize some settings panes
|
||||
* Misc: Enable FK pragma
|
||||
* Misc: Various performance and correctness fixes
|
||||
* Misc: Correct TCP default port
|
||||
|
||||
## [3.6.7] - 2026-03-31
|
||||
|
||||
* Misc: Remove armv7 (for now)
|
||||
|
||||
## [3.6.6] - 2026-03-31
|
||||
|
||||
* Misc: Please I'm begging for the build scripts to be working now
|
||||
|
||||
## [3.6.5] - 2026-03-31
|
||||
|
||||
* Bugfix: Maybe fix problem with publish script
|
||||
|
||||
## [3.6.4] - 2026-03-31
|
||||
|
||||
* Feature: Clarify New Channel/Contact button
|
||||
* Bugfix: Rename "Best RSSI" to "Strongest Neighbor"
|
||||
* Bugfix: Improve layout of Trace pane
|
||||
* Misc: Docker setup improvements
|
||||
|
||||
## [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
|
||||
* 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
|
||||
* 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
|
||||
|
||||
Feature: Add incoming-packet analytics
|
||||
Feature: BYOPacket for analysis
|
||||
Feature: Add room activity to stats view
|
||||
Bugfix: Handle Heltec v3 serial noise
|
||||
Misc: Swap repeaters and room servers for better ordering
|
||||
* Feature: Add incoming-packet analytics
|
||||
* Feature: BYOPacket for analysis
|
||||
* Feature: Add room activity to stats view
|
||||
* Bugfix: Handle Heltec v3 serial noise
|
||||
* Misc: Swap repeaters and room servers for better ordering
|
||||
|
||||
## [3.5.0] - 2026-03-19
|
||||
|
||||
Feature: Add room server alpha support
|
||||
Feature: Add option to force-reset node clock when it's too far ahead
|
||||
Feature: DMs auto-retry before resorting to flood
|
||||
Feature: Add impulse zero-hop advert
|
||||
Feature: Utilize PATH packets to correctly source a contact's route
|
||||
Feature: Metrics view on raw packet pane
|
||||
Feature: Metric, Imperial, and Smoots are now selectable for distance display
|
||||
Feature: Allow favorites to be sorted
|
||||
Feature: Add multi-ack support
|
||||
Feature: Password-remember checkbox on repeaters + room servers
|
||||
Bugfix: Serialize radio disconnect in a lock
|
||||
Bugfix: Fix contact bar layout issues
|
||||
Bugfix: Fix sidebar ordering for contacts by advert recency
|
||||
Bugfix: Fix version reporting in community MQTT
|
||||
Bugfix: Fix Apprise duplicate names
|
||||
Bugfix: Be better about identity resolution in the stats pane
|
||||
Misc: Docs, test, and performance enhancements
|
||||
Misc: Don't prompt "Are you sure" when leaving an unedited interation
|
||||
Misc: Log node time on startup
|
||||
Misc: Improve community MQTT error bubble-up
|
||||
Misc: Unread DMs always have a red unread counter
|
||||
Misc: Improve information in the debug view to show DB status
|
||||
* Feature: Add room server alpha support
|
||||
* Feature: Add option to force-reset node clock when it's too far ahead
|
||||
* Feature: DMs auto-retry before resorting to flood
|
||||
* Feature: Add impulse zero-hop advert
|
||||
* Feature: Utilize PATH packets to correctly source a contact's route
|
||||
* Feature: Metrics view on raw packet pane
|
||||
* Feature: Metric, Imperial, and Smoots are now selectable for distance display
|
||||
* Feature: Allow favorites to be sorted
|
||||
* Feature: Add multi-ack support
|
||||
* Feature: Password-remember checkbox on repeaters + room servers
|
||||
* Bugfix: Serialize radio disconnect in a lock
|
||||
* Bugfix: Fix contact bar layout issues
|
||||
* Bugfix: Fix sidebar ordering for contacts by advert recency
|
||||
* Bugfix: Fix version reporting in community MQTT
|
||||
* Bugfix: Fix Apprise duplicate names
|
||||
* Bugfix: Be better about identity resolution in the stats pane
|
||||
* Misc: Docs, test, and performance enhancements
|
||||
* Misc: Don't prompt "Are you sure" when leaving an unedited integration
|
||||
* Misc: Log node time on startup
|
||||
* Misc: Improve community MQTT error bubble-up
|
||||
* Misc: Unread DMs always have a red unread counter
|
||||
* Misc: Improve information in the debug view to show DB status
|
||||
|
||||
## [3.4.1] - 2026-03-16
|
||||
|
||||
Bugfix: Improve handling of version information on prebuilt bundles
|
||||
Bugfix: Improve frontend usability on disconnected radio
|
||||
Misc: Docs and readme updates
|
||||
Misc: Overhaul DM ingest and frontend state handling
|
||||
* Bugfix: Improve handling of version information on prebuilt bundles
|
||||
* Bugfix: Improve frontend usability on disconnected radio
|
||||
* Misc: Docs and readme updates
|
||||
* Misc: Overhaul DM ingest and frontend state handling
|
||||
|
||||
## [3.4.0] - 2026-03-16
|
||||
|
||||
Feature: Add radio model and stats display
|
||||
Feature: Add prebuilt frontends, then deleted that and moved to prebuilt release artifacts
|
||||
Bugfix: Misc. frontend performance and correctness fixes
|
||||
Bugfix: Fix same-second same-content DM send collition
|
||||
Bugfix: Discard clearly-wrong GPS data
|
||||
Bugfix: Prevent repeater clock skew drift on page nav
|
||||
Misc: Use repeater's advertised location if we haven't loaded one from repeater admin
|
||||
Misc: Don't permit invalid fanout configs to be saved ever`
|
||||
* Feature: Add radio model and stats display
|
||||
* Feature: Add prebuilt frontends, then deleted that and moved to prebuilt release artifacts
|
||||
* Bugfix: Misc. frontend performance and correctness fixes
|
||||
* Bugfix: Fix same-second same-content DM send collition
|
||||
* Bugfix: Discard clearly-wrong GPS data
|
||||
* Bugfix: Prevent repeater clock skew drift on page nav
|
||||
* Misc: Use repeater's advertised location if we haven't loaded one from repeater admin
|
||||
* Misc: Don't permit invalid fanout configs to be saved ever`
|
||||
|
||||
## [3.3.0] - 2026-03-13
|
||||
|
||||
Feature: Use dashed lines to show collapsed ambiguous router results
|
||||
Feature: Jump to unred
|
||||
Feature: Local channel management to prevent need to reload channel every time
|
||||
Feature: Debug endpoint
|
||||
Feature: Force-singleton channel management
|
||||
Feature: Local node discovery
|
||||
Feature: Node routing discovery
|
||||
Bugfix: Don't tell users to us npm ci
|
||||
Bugfix: Fallback polling dm message persistence
|
||||
Bugfix: All native-JS inputs are now modals
|
||||
Bugfix: Same-second send collision resolution
|
||||
Bugfix: Proper browser updates on resend
|
||||
Bugfix: Don't use last-heard when we actually want last-advert for path discovery for nodes
|
||||
Bugfix: Don't treat prefix-matching DM echoes as acks like we do for channel messages
|
||||
Misc: Visualizer data layer overhaul for future map work
|
||||
Misc: Parallelize docker tests
|
||||
* Feature: Use dashed lines to show collapsed ambiguous router results
|
||||
* Feature: Jump to unread
|
||||
* Feature: Local channel management to prevent need to reload channel every time
|
||||
* Feature: Debug endpoint
|
||||
* Feature: Force-singleton channel management
|
||||
* Feature: Local node discovery
|
||||
* Feature: Node routing discovery
|
||||
* Bugfix: Don't tell users to us npm ci
|
||||
* Bugfix: Fallback polling dm message persistence
|
||||
* Bugfix: All native-JS inputs are now modals
|
||||
* Bugfix: Same-second send collision resolution
|
||||
* Bugfix: Proper browser updates on resend
|
||||
* Bugfix: Don't use last-heard when we actually want last-advert for path discovery for nodes
|
||||
* Bugfix: Don't treat prefix-matching DM echoes as acks like we do for channel messages
|
||||
* Misc: Visualizer data layer overhaul for future map work
|
||||
* Misc: Parallelize docker tests
|
||||
|
||||
## [3.2.0] - 2026-03-12
|
||||
|
||||
Feature: Improve ambiguous-sender DM handling and visibility
|
||||
Feature: Allow for toggling of node GPS broadcast
|
||||
Feature: Add path width to bot and move example to full kwargs
|
||||
Feature: Improve node map color contrast
|
||||
Bugfix: More accurate tracking of contact data
|
||||
Bugfix: Misc. frontend performance and bugfixes
|
||||
Misc: Clearer warnings on user-key linkage
|
||||
Misc: Documentation improvements
|
||||
* Feature: Improve ambiguous-sender DM handling and visibility
|
||||
* Feature: Allow for toggling of node GPS broadcast
|
||||
* Feature: Add path width to bot and move example to full kwargs
|
||||
* Feature: Improve node map color contrast
|
||||
* Bugfix: More accurate tracking of contact data
|
||||
* Bugfix: Misc. frontend performance and bugfixes
|
||||
* Misc: Clearer warnings on user-key linkage
|
||||
* Misc: Documentation improvements
|
||||
|
||||
## [3.1.1] - 2026-03-11
|
||||
|
||||
Feature: Add basic auth
|
||||
Feature: SQS fanout
|
||||
Feature: Enrich contact info pane
|
||||
Feature: Search operators for node and channel
|
||||
Feature: Pause radio connection attempts from Radio settings
|
||||
Feature: New themes! What a great use of time!
|
||||
Feature: Github workflows runs for validation
|
||||
Bugfix: More consistent log format with times
|
||||
Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses
|
||||
* Feature: Add basic auth
|
||||
* Feature: SQS fanout
|
||||
* Feature: Enrich contact info pane
|
||||
* Feature: Search operators for node and channel
|
||||
* Feature: Pause radio connection attempts from Radio settings
|
||||
* Feature: New themes! What a great use of time!
|
||||
* Feature: Github workflows runs for validation
|
||||
* Bugfix: More consistent log format with times
|
||||
* Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses
|
||||
|
||||
## [3.1.0] - 2026-03-11
|
||||
|
||||
Feature: Add basic auth
|
||||
Feature: SQS fanout
|
||||
Feature: Enrich contact info pane
|
||||
Feature: Search operators for node and channel
|
||||
Feature: Pause radio connection attempts from Radio settings
|
||||
Feature: New themes! What a great use of time!
|
||||
Feature: Github workflows runs for validation
|
||||
Bugfix: More consistent log format with times
|
||||
Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses
|
||||
* Feature: Add basic auth
|
||||
* Feature: SQS fanout
|
||||
* Feature: Enrich contact info pane
|
||||
* Feature: Search operators for node and channel
|
||||
* Feature: Pause radio connection attempts from Radio settings
|
||||
* Feature: New themes! What a great use of time!
|
||||
* Feature: Github workflows runs for validation
|
||||
* Bugfix: More consistent log format with times
|
||||
* Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses
|
||||
|
||||
## [3.0.0] - 2026-03-10
|
||||
|
||||
Feature: Custom regions per-channel
|
||||
Feature: Add custom contact pathing
|
||||
Feature: Corrupt packets are more clear that they're corrupt
|
||||
Feature: Better, faster patterns around background fetching with explicit opt-in for recurring sync if the app detects you need it
|
||||
Feature: More consistent icons
|
||||
Feature: Add per-channel local notifications
|
||||
Feature: New themes
|
||||
Feature: Massive codebase refactor and overhaul
|
||||
Bugfix: Fix packet parsing for trace packets
|
||||
Bugfix: Refetch channels on reconnect
|
||||
Bugfix: Load All on repeater pane on mobile doesn't etend into lower text
|
||||
Bugfix: Timestamps in logs
|
||||
Bugfix: Correct wrong clock sync command
|
||||
Misc: Improve bot error bubble up
|
||||
Misc: Update to non-lib-included meshcore-decoder version
|
||||
Misc: Revise refactors to be more LLM friendly
|
||||
Misc: Fix script executability
|
||||
Misc: Better logging format with timestamp
|
||||
Misc: Repeater advert buttons separate flood and one-hop
|
||||
Misc: Preserve repeater pane on navigation away
|
||||
Misc: Clearer iconography and coloring for status bar buttons
|
||||
Misc: Search bar to top bar
|
||||
* Feature: Custom regions per-channel
|
||||
* Feature: Add custom contact pathing
|
||||
* Feature: Corrupt packets are more clear that they're corrupt
|
||||
* Feature: Better, faster patterns around background fetching with explicit opt-in for recurring sync if the app detects you need it
|
||||
* Feature: More consistent icons
|
||||
* Feature: Add per-channel local notifications
|
||||
* Feature: New themes
|
||||
* Feature: Massive codebase refactor and overhaul
|
||||
* Bugfix: Fix packet parsing for trace packets
|
||||
* Bugfix: Refetch channels on reconnect
|
||||
* Bugfix: Load All on repeater pane on mobile doesn't extend into lower text
|
||||
* Bugfix: Timestamps in logs
|
||||
* Bugfix: Correct wrong clock sync command
|
||||
* Misc: Improve bot error bubble up
|
||||
* Misc: Update to non-lib-included meshcore-decoder version
|
||||
* Misc: Revise refactors to be more LLM friendly
|
||||
* Misc: Fix script executability
|
||||
* Misc: Better logging format with timestamp
|
||||
* Misc: Repeater advert buttons separate flood and one-hop
|
||||
* Misc: Preserve repeater pane on navigation away
|
||||
* Misc: Clearer iconography and coloring for status bar buttons
|
||||
* Misc: Search bar to top bar
|
||||
|
||||
## [2.7.9] - 2026-03-08
|
||||
|
||||
Bugfix: Don't obscure new integration dropdown on session boundary
|
||||
* Bugfix: Don't obscure new integration dropdown on session boundary
|
||||
|
||||
## [2.7.8] - 2026-03-08
|
||||
|
||||
|
||||
|
||||
## [2.7.8] - 2026-03-08
|
||||
|
||||
Bugfix: Improve frontend asset resolution and fixup the build/push script
|
||||
* Bugfix: Improve frontend asset resolution and fixup the build/push script
|
||||
|
||||
## [2.7.1] - 2026-03-08
|
||||
|
||||
Bugfix: Fix historical DM packet length passing
|
||||
Misc: Follow better inclusion patterns for the patched meshcore-decoder and just publish the dang package
|
||||
Misc: Patch a bewildering browser quirk that cause large raw packet lists to extend past the bottom of the page
|
||||
* Bugfix: Fix historical DM packet length passing
|
||||
* Misc: Follow better inclusion patterns for the patched meshcore-decoder and just publish the dang package
|
||||
* Misc: Patch a bewildering browser quirk that cause large raw packet lists to extend past the bottom of the page
|
||||
|
||||
## [2.7.0] - 2026-03-08
|
||||
|
||||
Feature: Multibyte path support
|
||||
Feature: Add multibyte statistics to statistics pane
|
||||
Feature: Add path bittage to contact info pane
|
||||
Feature: Put tools in a collapsible
|
||||
* Feature: Multibyte path support
|
||||
* Feature: Add multibyte statistics to statistics pane
|
||||
* Feature: Add path bittage to contact info pane
|
||||
* Feature: Put tools in a collapsible
|
||||
|
||||
## [2.6.1] - 2026-03-08
|
||||
|
||||
Misc: Fix busted docker builds; we don't have a 2.6.0 build sorry
|
||||
* Misc: Fix busted docker builds; we don't have a 2.6.0 build sorry
|
||||
|
||||
## [2.6.0] - 2026-03-08
|
||||
|
||||
Feature: A11y improvements
|
||||
Feature: New themes
|
||||
Feature: Backfill channel sender identity when available
|
||||
Feature: Modular fanout bus, including Webhooks, more customizable community MQTT, and Apprise
|
||||
Bugfix: Unreads now respect blocklist
|
||||
Bugfix: Unreads can't accumulate on an open thread
|
||||
Bugfix: Channel name in broadcasts
|
||||
Bugfix: Add missing httpx dependency
|
||||
Bugfix: Improvements to radio startup frontend-blocking time and radio status reporting
|
||||
Misc: Improved button signage for app movement
|
||||
Misc: Test, performance, and documentation improvements
|
||||
* Feature: A11y improvements
|
||||
* Feature: New themes
|
||||
* Feature: Backfill channel sender identity when available
|
||||
* Feature: Modular fanout bus, including Webhooks, more customizable community MQTT, and Apprise
|
||||
* Bugfix: Unreads now respect blocklist
|
||||
* Bugfix: Unreads can't accumulate on an open thread
|
||||
* Bugfix: Channel name in broadcasts
|
||||
* Bugfix: Add missing httpx dependency
|
||||
* Bugfix: Improvements to radio startup frontend-blocking time and radio status reporting
|
||||
* Misc: Improved button signage for app movement
|
||||
* Misc: Test, performance, and documentation improvements
|
||||
|
||||
## [2.5.0] - 2026-03-05
|
||||
|
||||
Feature: Far better accessibility across the app (with far to go)
|
||||
Feature: Add community MQTT stats reporting, and improve over a few commits
|
||||
Feature: Color schemes and misc. settings reorg
|
||||
Feature: Add why-active to filtered nodes
|
||||
Feature: Add channel and contact info box
|
||||
Feature: Add contact blocking
|
||||
Feature: Add potential repeater path map display
|
||||
Feature: Add flood scoping/regions
|
||||
Feature: Global message search
|
||||
Feature: Fully safe bot disable
|
||||
Feature: Add default #remoteterm channel (lol sorry I had to)
|
||||
Feature: Custom recency pruning in visualizer
|
||||
Bugfix: Be more cautious around null byte stripping
|
||||
Bugfix: Clear channel-add interface on not-add-another
|
||||
Bugfix: Add status/name/MQTT LWT
|
||||
Bugfix: Channel deletion propagates over WS
|
||||
Bugfix: Show map location for all nodes on link, not 7-day-limited
|
||||
Bugfix: Hide private key channel keys by default
|
||||
Misc: Logline to show if cleanup loop on non-sync'd meshcore radio links fixes anything
|
||||
Misc: Doc, changelog, and test improvements
|
||||
Misc: Add, and remove, package lock (sorry Windows users)
|
||||
Misc: Don't show mark all as read if not necessary
|
||||
Misc: Fix stale closures and misc. frontend perf/correctness improvements
|
||||
Misc: Add Windows startup notes
|
||||
Misc: E2E expansion + improvement
|
||||
Misc: Move around visualizer settings
|
||||
* Feature: Far better accessibility across the app (with far to go)
|
||||
* Feature: Add community MQTT stats reporting, and improve over a few commits
|
||||
* Feature: Color schemes and misc. settings reorg
|
||||
* Feature: Add why-active to filtered nodes
|
||||
* Feature: Add channel and contact info box
|
||||
* Feature: Add contact blocking
|
||||
* Feature: Add potential repeater path map display
|
||||
* Feature: Add flood scoping/regions
|
||||
* Feature: Global message search
|
||||
* Feature: Fully safe bot disable
|
||||
* Feature: Add default #remoteterm channel (lol sorry I had to)
|
||||
* Feature: Custom recency pruning in visualizer
|
||||
* Bugfix: Be more cautious around null byte stripping
|
||||
* Bugfix: Clear channel-add interface on not-add-another
|
||||
* Bugfix: Add status/name/MQTT LWT
|
||||
* Bugfix: Channel deletion propagates over WS
|
||||
* Bugfix: Show map location for all nodes on link, not 7-day-limited
|
||||
* Bugfix: Hide private key channel keys by default
|
||||
* Misc: Logline to show if cleanup loop on non-sync'd meshcore radio links fixes anything
|
||||
* Misc: Doc, changelog, and test improvements
|
||||
* Misc: Add, and remove, package lock (sorry Windows users)
|
||||
* Misc: Don't show mark all as read if not necessary
|
||||
* Misc: Fix stale closures and misc. frontend perf/correctness improvements
|
||||
* Misc: Add Windows startup notes
|
||||
* Misc: E2E expansion + improvement
|
||||
* Misc: Move around visualizer settings
|
||||
|
||||
## [2.4.0] - 2026-03-02
|
||||
|
||||
Feature: Add community MQTT reporting (e.g. LetsMesh.net)
|
||||
Misc: Build scripts and library attribution
|
||||
Misc: Add sign of life to E2E tests
|
||||
* Feature: Add community MQTT reporting (e.g. LetsMesh.net)
|
||||
* Misc: Build scripts and library attribution
|
||||
* Misc: Add sign of life to E2E tests
|
||||
|
||||
## [2.3.0] - 2026-03-01
|
||||
|
||||
Feature: Click path description to reset to flood
|
||||
Feature: Add MQTT publishing
|
||||
Feature: Visualizer remembers settings
|
||||
Bugfix: Fix prefetch usage
|
||||
Bugfix: Fixed an issue where busy channels can result in double-display of incoming messages
|
||||
Misc: Drop py3.12 requirement
|
||||
Misc: Performance, documentation, test, and file structure optimizations
|
||||
Misc: Add arrows between route nodes on contact info
|
||||
Misc: Show repeater path/type in title bar
|
||||
* Feature: Click path description to reset to flood
|
||||
* Feature: Add MQTT publishing
|
||||
* Feature: Visualizer remembers settings
|
||||
* Bugfix: Fix prefetch usage
|
||||
* Bugfix: Fixed an issue where busy channels can result in double-display of incoming messages
|
||||
* Misc: Drop py3.12 requirement
|
||||
* Misc: Performance, documentation, test, and file structure optimizations
|
||||
* Misc: Add arrows between route nodes on contact info
|
||||
* Misc: Show repeater path/type in title bar
|
||||
|
||||
## [2.2.0] - 2026-02-28
|
||||
|
||||
Feature: Track advert paths and use to disambiguate repeater identity in visualizer
|
||||
Feature: Contact info pane
|
||||
Feature: Overhaul repeater interface
|
||||
Bugfix: Misc. frontend rendering + perf improvements
|
||||
Bugfix: Better behavior around radio locking and autofetch/polling
|
||||
Bugfix: Clear channel name field on new-channel modal tab change
|
||||
Bugfix: Repeater inforbox can scroll
|
||||
Bugfix: Better handling of historical DM encrypts
|
||||
Bugfix: Handle errors if returned in prefetch phase
|
||||
Misc: Radio event response failure is logged/surfaced better
|
||||
Misc: Improve test coverage and remove dead code
|
||||
Misc: Documentation and errata improvements
|
||||
Misc: Database storage optimization
|
||||
* Feature: Track advert paths and use to disambiguate repeater identity in visualizer
|
||||
* Feature: Contact info pane
|
||||
* Feature: Overhaul repeater interface
|
||||
* Bugfix: Misc. frontend rendering + perf improvements
|
||||
* Bugfix: Better behavior around radio locking and autofetch/polling
|
||||
* Bugfix: Clear channel name field on new-channel modal tab change
|
||||
* Bugfix: Repeater inforbox can scroll
|
||||
* Bugfix: Better handling of historical DM encrypts
|
||||
* Bugfix: Handle errors if returned in prefetch phase
|
||||
* Misc: Radio event response failure is logged/surfaced better
|
||||
* Misc: Improve test coverage and remove dead code
|
||||
* Misc: Documentation and errata improvements
|
||||
* Misc: Database storage optimization
|
||||
|
||||
## [2.1.0] - 2026-02-23
|
||||
|
||||
Feature: Add ability to remember last-used channel on load
|
||||
Feature: Add `docker compose` support (thanks @suymur !)
|
||||
Feature: Better-aligned favicon (lol)
|
||||
Bugfix: Disable autocomplete on message field
|
||||
Bugfix: Legacy hash restoration on page load
|
||||
Bugfix: Align resend buttons in pathing modal
|
||||
Bugfix: Update README.md (briefly), then docker-compose.yaml, to reflect correct docker image host
|
||||
Bugfix: Correct settings pane scroll lock on zoom (thanks @yellowcooln !)
|
||||
Bugfix: Improved repeater comms on busy meshes
|
||||
Bugfix: Drain before autofetch from radio
|
||||
Bugfix: Fix, or document exceptions to, sub-second resolution message failure
|
||||
Bugfix: Improved handling of radio connection, disconnection, and connection-aliveness-status
|
||||
Bugfix: Force server-side keystore update when radio key changes
|
||||
Bugfix: Reduce WS churn for incoming message handling
|
||||
Bugfix: Fix content type signalling for irrelevant endpoints
|
||||
Bugfix: Handle stuck post-connect failure state
|
||||
Misc: Documentation & version parsing improvements
|
||||
Misc: Hide char counter on mobile for short messages
|
||||
Misc: Typo fixes in docs and settings
|
||||
Misc: Add dynamic webmanifest for hosts that can support it
|
||||
Misc: Improve DB size via dropping unnecessary uniqs, indices, vacuum, and offering ability to drop historical matches packets
|
||||
Misc: Drop weird rounded bounding box for settings
|
||||
Misc: Move resend buttons to pathing modal
|
||||
Misc: Improved comments around database ownership on *nix systems
|
||||
Misc: Move to SSoT for message dedupe on frontend
|
||||
Misc: Move DM ack clearing to standard poll, and increase hold time between polling
|
||||
Misc: Holistic testing overhaul
|
||||
* Feature: Add ability to remember last-used channel on load
|
||||
* Feature: Add `docker compose` support (thanks @suymur !)
|
||||
* Feature: Better-aligned favicon (lol)
|
||||
* Bugfix: Disable autocomplete on message field
|
||||
* Bugfix: Legacy hash restoration on page load
|
||||
* Bugfix: Align resend buttons in pathing modal
|
||||
* Bugfix: Update README.md (briefly), then docker-compose.yaml, to reflect correct docker image host
|
||||
* Bugfix: Correct settings pane scroll lock on zoom (thanks @yellowcooln !)
|
||||
* Bugfix: Improved repeater comms on busy meshes
|
||||
* Bugfix: Drain before autofetch from radio
|
||||
* Bugfix: Fix, or document exceptions to, sub-second resolution message failure
|
||||
* Bugfix: Improved handling of radio connection, disconnection, and connection-aliveness-status
|
||||
* Bugfix: Force server-side keystore update when radio key changes
|
||||
* Bugfix: Reduce WS churn for incoming message handling
|
||||
* Bugfix: Fix content type signalling for irrelevant endpoints
|
||||
* Bugfix: Handle stuck post-connect failure state
|
||||
* Misc: Documentation & version parsing improvements
|
||||
* Misc: Hide char counter on mobile for short messages
|
||||
* Misc: Typo fixes in docs and settings
|
||||
* Misc: Add dynamic webmanifest for hosts that can support it
|
||||
* Misc: Improve DB size via dropping unnecessary uniqs, indices, vacuum, and offering ability to drop historical matches packets
|
||||
* Misc: Drop weird rounded bounding box for settings
|
||||
* Misc: Move resend buttons to pathing modal
|
||||
* Misc: Improved comments around database ownership on *nix systems
|
||||
* Misc: Move to SSoT for message dedupe on frontend
|
||||
* Misc: Move DM ack clearing to standard poll, and increase hold time between polling
|
||||
* Misc: Holistic testing overhaul
|
||||
|
||||
## [2.0.1] - 2026-02-16
|
||||
|
||||
Bugfix: Fix missing trigger condition on statistics pane expansion on mobile
|
||||
* Bugfix: Fix missing trigger condition on statistics pane expansion on mobile
|
||||
|
||||
## [2.0.0] - 2026-02-16
|
||||
|
||||
Feature: Frontend UX + log overhaul
|
||||
Bugfix: Use contact object from DB for broadcast rather than handrolling
|
||||
Bugfix: Fix out of order path WS messages overwriting each other
|
||||
Bugfix: Make broadcast timestamp match fallback logic used in storage code
|
||||
Bugfix: Fix repeater command timestamp selection logic
|
||||
Bugfix: Use actual pubkey matching for path update, and don't action serial path update events (use RX packet)
|
||||
Bugfix: Add missing radio operation locks in a few spots
|
||||
Bugfix: Fix dedupe for frontend raw packet delivery (mesh visualizer much more active now!)
|
||||
Bugfix: Less aggressive dedupe for advert packets (we don't care about the payload, we care about the path, duh)
|
||||
Misc: Visualizer layout refinement & option labels
|
||||
* Feature: Frontend UX + log overhaul
|
||||
* Bugfix: Use contact object from DB for broadcast rather than handrolling
|
||||
* Bugfix: Fix out of order path WS messages overwriting each other
|
||||
* Bugfix: Make broadcast timestamp match fallback logic used in storage code
|
||||
* Bugfix: Fix repeater command timestamp selection logic
|
||||
* Bugfix: Use actual pubkey matching for path update, and don't action serial path update events (use RX packet)
|
||||
* Bugfix: Add missing radio operation locks in a few spots
|
||||
* Bugfix: Fix dedupe for frontend raw packet delivery (mesh visualizer much more active now!)
|
||||
* Bugfix: Less aggressive dedupe for advert packets (we don't care about the payload, we care about the path, duh)
|
||||
* Misc: Visualizer layout refinement & option labels
|
||||
|
||||
## [1.10.0] - 2026-02-16
|
||||
|
||||
Feature: Collapsible sidebar sections with per-section unread badge (thanks @rgregg !)
|
||||
Feature: 3D mesh visualizer
|
||||
Feature: Statistics pane
|
||||
Feature: Support incoming/outgoing indication for bot invocations
|
||||
Feature: Quick byte-perfect message resend if you got unlucky with repeats (thanks @rgregg -- we had a parallel implementation but I appreciate your work!)
|
||||
Bugfix: Fix top padding out outgoing message
|
||||
Bugfix: Frontend performance, appearance, and Lighthouse improvements (prefetches, form labelling, contrast, channel/roomlist changes)
|
||||
Bugfix: Multiple-sent messages had path appearing delays until rerender
|
||||
Bugfix: Fix ack/message race condition that caused dropped ack displays until rerender
|
||||
Misc: Dedupe contacts/rooms by key and not name to prevent name collisions creating unreachable conversations
|
||||
Misc: s/stopped/idle/ for room finder
|
||||
* Feature: Collapsible sidebar sections with per-section unread badge (thanks @rgregg !)
|
||||
* Feature: 3D mesh visualizer
|
||||
* Feature: Statistics pane
|
||||
* Feature: Support incoming/outgoing indication for bot invocations
|
||||
* Feature: Quick byte-perfect message resend if you got unlucky with repeats (thanks @rgregg -- we had a parallel implementation but I appreciate your work!)
|
||||
* Bugfix: Fix top padding out outgoing message
|
||||
* Bugfix: Frontend performance, appearance, and Lighthouse improvements (prefetches, form labelling, contrast, channel/roomlist changes)
|
||||
* Bugfix: Multiple-sent messages had path appearing delays until rerender
|
||||
* Bugfix: Fix ack/message race condition that caused dropped ack displays until rerender
|
||||
* Misc: Dedupe contacts/rooms by key and not name to prevent name collisions creating unreachable conversations
|
||||
* Misc: s/stopped/idle/ for room finder
|
||||
|
||||
## [1.9.3] - 2026-02-12
|
||||
|
||||
Feature: Upgrade the room finder to support two-word rooms
|
||||
* Feature: Upgrade the room finder to support two-word rooms
|
||||
|
||||
## [1.9.2] - 2026-02-12
|
||||
|
||||
Feature: Options dialog sucks less
|
||||
Bugfix: Move tests to isolated memory DB
|
||||
Bugfix: Mention case sensitivity
|
||||
Bugfix: Stale header retention on settings page view
|
||||
Bugfix: Non-isolated path writing
|
||||
Bugfix: Nullable contact fields are now passed as real nulls
|
||||
Bugfix: Look at all fields on message reconcile, not just text
|
||||
Bugfix: Make mark-all-as-read atomic
|
||||
Misc: Purge unused WS handlers from back when we did chans and contacts over WS, not API
|
||||
Misc: Massive test and AGENTS.md overhauls and additions
|
||||
* Feature: Options dialog sucks less
|
||||
* Bugfix: Move tests to isolated memory DB
|
||||
* Bugfix: Mention case sensitivity
|
||||
* Bugfix: Stale header retention on settings page view
|
||||
* Bugfix: Non-isolated path writing
|
||||
* Bugfix: Nullable contact fields are now passed as real nulls
|
||||
* Bugfix: Look at all fields on message reconcile, not just text
|
||||
* Bugfix: Make mark-all-as-read atomic
|
||||
* Misc: Purge unused WS handlers from back when we did chans and contacts over WS, not API
|
||||
* Misc: Massive test and AGENTS.md overhauls and additions
|
||||
|
||||
## [1.9.1] - 2026-02-10
|
||||
|
||||
Feature: Contacts and channels use keys, not names
|
||||
Bugfix: Fix falsy casting of 0 in lat lon and timing data
|
||||
Bugfix: Show message length in bytes, not chars
|
||||
Bugfix: Fix phantom unread badges on focused convos
|
||||
Misc: Bot invocation to async
|
||||
Misc: Use full key, not prefix, where we can
|
||||
* Feature: Contacts and channels use keys, not names
|
||||
* Bugfix: Fix falsy casting of 0 in lat lon and timing data
|
||||
* Bugfix: Show message length in bytes, not chars
|
||||
* Bugfix: Fix phantom unread badges on focused convos
|
||||
* Misc: Bot invocation to async
|
||||
* Misc: Use full key, not prefix, where we can
|
||||
|
||||
## [1.9.0] - 2026-02-10
|
||||
|
||||
Feature: Favorited contacts are preferentially loaded onto the radio
|
||||
Feature: Add recent-message caching for fast switching
|
||||
Feature: Add echo paths modal when echo-heard checkbox is clicked
|
||||
Feature: Add experimental byte-perfect double-send for bad RF environments to try to punch the message out
|
||||
* Feature: Favorited contacts are preferentially loaded onto the radio
|
||||
* Feature: Add recent-message caching for fast switching
|
||||
* Feature: Add echo paths modal when echo-heard checkbox is clicked
|
||||
* Feature: Add experimental byte-perfect double-send for bad RF environments to try to punch the message out
|
||||
Frontend: Better styling on echo + message path display
|
||||
Bugfix: Prevent frontend static file serving path traversal vuln
|
||||
Bugfix: Safer prefix-claiming for DMs we don't have the key for
|
||||
Bugfix: Prevent injection from mentions with special characters
|
||||
Bugfix: Fix repeaters comms showing in wrong channel when repeater operations are in flight and the channel is changed quickly
|
||||
Bugfix: App can boot and test without a frontend dir
|
||||
Misc: Improve and consistent-ify (?) backend radio operation lock management
|
||||
Misc: Frontend performance and safety enhancements
|
||||
Misc: Move builds to non-bundled; usage requires building the Frontend
|
||||
Misc: Update tests and agent docs
|
||||
* Bugfix: Prevent frontend static file serving path traversal vuln
|
||||
* Bugfix: Safer prefix-claiming for DMs we don't have the key for
|
||||
* Bugfix: Prevent injection from mentions with special characters
|
||||
* Bugfix: Fix repeaters comms showing in wrong channel when repeater operations are in flight and the channel is changed quickly
|
||||
* Bugfix: App can boot and test without a frontend dir
|
||||
* Misc: Improve and consistent-ify (?) backend radio operation lock management
|
||||
* Misc: Frontend performance and safety enhancements
|
||||
* Misc: Move builds to non-bundled; usage requires building the Frontend
|
||||
* Misc: Update tests and agent docs
|
||||
|
||||
## [1.8.0] - 2026-02-07
|
||||
|
||||
Feature: Single hop ping
|
||||
Feature: PWA viewport fixes(thanks @rgregg)
|
||||
* Feature: Single hop ping
|
||||
* Feature: PWA viewport fixes(thanks @rgregg)
|
||||
Feature (?): No frontend distribution; build it yourself ;P
|
||||
Bugfix: Fix channel message send race condition (concurrent sends could corrupt shared radio slot)
|
||||
Bugfix: Fix TOCTOU race in radio reconnect (duplicate connections under contention)
|
||||
Bugfix: Better guarding around reconnection
|
||||
Bugfix: Duplicate websocket connection fixes
|
||||
Bugfix: Settings tab error cleanliness on tab swap
|
||||
Bugfix: Fix path traversal vuln
|
||||
* Bugfix: Fix channel message send race condition (concurrent sends could corrupt shared radio slot)
|
||||
* Bugfix: Fix TOCTOU race in radio reconnect (duplicate connections under contention)
|
||||
* Bugfix: Better guarding around reconnection
|
||||
* Bugfix: Duplicate websocket connection fixes
|
||||
* Bugfix: Settings tab error cleanliness on tab swap
|
||||
* Bugfix: Fix path traversal vuln
|
||||
UI: Swap visualizer legend ordering (yay prettier)
|
||||
Misc: Perf and locking improvements
|
||||
Misc: Always flood advertisements
|
||||
Misc: Better packet dupe handling
|
||||
Misc: Dead code cleanup, test improvements
|
||||
* Misc: Perf and locking improvements
|
||||
* Misc: Always flood advertisements
|
||||
* Misc: Better packet dupe handling
|
||||
* Misc: Dead code cleanup, test improvements
|
||||
|
||||
## [1.7.1] - 2026-02-03
|
||||
|
||||
Feature: Clickable hyperlinks
|
||||
Bugfix: More consistent public key normalization
|
||||
Bugfix: Use more reliable cursor paging
|
||||
Bugfix: Fix null timestamp dedupe failure
|
||||
Bugfix: More consistent prefix-based message claiming on key receipt
|
||||
Misc: Bot can respond to its own messages
|
||||
Misc: Additional tests
|
||||
Misc: Remove unneeded message dedupe logic
|
||||
Misc: Resync settings after radio settings mutation
|
||||
* Feature: Clickable hyperlinks
|
||||
* Bugfix: More consistent public key normalization
|
||||
* Bugfix: Use more reliable cursor paging
|
||||
* Bugfix: Fix null timestamp dedupe failure
|
||||
* Bugfix: More consistent prefix-based message claiming on key receipt
|
||||
* Misc: Bot can respond to its own messages
|
||||
* Misc: Additional tests
|
||||
* Misc: Remove unneeded message dedupe logic
|
||||
* Misc: Resync settings after radio settings mutation
|
||||
|
||||
## [1.7.0] - 2026-01-27
|
||||
|
||||
Feature: Multi-bot functionality
|
||||
Bugfix: Adjust bot code editor display and add line numbers
|
||||
Bugfix: Fix clock filtering and contact lookup behavior bugs
|
||||
Bugfix: Fix repeater message duplication issue
|
||||
Bugfix: Correct outbound message timestamp assignment (affecting outgoing messages seen as incoming)
|
||||
* Feature: Multi-bot functionality
|
||||
* Bugfix: Adjust bot code editor display and add line numbers
|
||||
* Bugfix: Fix clock filtering and contact lookup behavior bugs
|
||||
* Bugfix: Fix repeater message duplication issue
|
||||
* Bugfix: Correct outbound message timestamp assignment (affecting outgoing messages seen as incoming)
|
||||
UI: Move advertise button to identity tab
|
||||
Misc: Clarify fallback functionality for missing private key export in logs
|
||||
* Misc: Clarify fallback functionality for missing private key export in logs
|
||||
|
||||
## [1.6.0] - 2026-01-26
|
||||
|
||||
Feature: Visualizer: extract public key from AnonReq, add heuristic repeater disambiguation, add reset button, draggable nodes
|
||||
Feature: Customizable advertising interval
|
||||
Feature: In-app bot setup
|
||||
Bugfix: Force contact onto radio before DM send
|
||||
Misc: Remove unused code
|
||||
* Feature: Visualizer: extract public key from AnonReq, add heuristic repeater disambiguation, add reset button, draggable nodes
|
||||
* Feature: Customizable advertising interval
|
||||
* Feature: In-app bot setup
|
||||
* Bugfix: Force contact onto radio before DM send
|
||||
* Misc: Remove unused code
|
||||
|
||||
## [1.5.0] - 2026-01-19
|
||||
|
||||
Feature: Network visualizer
|
||||
* Feature: Network visualizer
|
||||
|
||||
## [1.4.1] - 2026-01-19
|
||||
|
||||
Feature: Add option to attempt historical DM decrypt on new-contact advertisement (disabled by default)
|
||||
Feature: Server-side preference management for favorites, read status, etc.
|
||||
* Feature: Add option to attempt historical DM decrypt on new-contact advertisement (disabled by default)
|
||||
* Feature: Server-side preference management for favorites, read status, etc.
|
||||
UI: More compact hop labelling
|
||||
Bugfix: Misc. race conditions and websocket handling
|
||||
Bugfix: Reduce fetching cadence by loading all contact data at start to prevent fetches on advertise-driven update
|
||||
* Bugfix: Misc. race conditions and websocket handling
|
||||
* Bugfix: Reduce fetching cadence by loading all contact data at start to prevent fetches on advertise-driven update
|
||||
|
||||
## [1.4.0] - 2026-01-18
|
||||
|
||||
UI: Improve button layout for room searcher
|
||||
UI: Improve favicon coloring
|
||||
UI: Improve status bar button layout on small screen
|
||||
Feature: Show multi-path hop display with distance estimates
|
||||
Feature: Search rooms and contacts by key, not just name
|
||||
Bugfix: Historical DM decryption now works as expected
|
||||
Bugfix: Don't double-set active conversation after addition; wait for backend room name normalization
|
||||
* Feature: Show multi-path hop display with distance estimates
|
||||
* Feature: Search rooms and contacts by key, not just name
|
||||
* Bugfix: Historical DM decryption now works as expected
|
||||
* Bugfix: Don't double-set active conversation after addition; wait for backend room name normalization
|
||||
|
||||
## [1.3.1] - 2026-01-17
|
||||
|
||||
UI: Rework restart handling
|
||||
Feature: Add `dutycyle_start` command to logged-in repeater session to start five min duty cycle tracking
|
||||
* Feature: Add `dutycyle_start` command to logged-in repeater session to start five min duty cycle tracking
|
||||
Bug: Improve error message rendering from server-side errors
|
||||
UI: Remove octothorpe from channel listing
|
||||
|
||||
## [1.3.0] - 2026-01-17
|
||||
|
||||
Feature: Rework database schema to drop unnecessary columns and dedupe payloads at the DB level
|
||||
Feature: Massive frontend settings overhaul. It ain't gorgeous but it's easier to navigate.
|
||||
Feature: Drop repeater login wait time; vestigial from debugging a different issue
|
||||
* Feature: Rework database schema to drop unnecessary columns and dedupe payloads at the DB level
|
||||
* Feature: Massive frontend settings overhaul. It ain't gorgeous but it's easier to navigate.
|
||||
* Feature: Drop repeater login wait time; vestigial from debugging a different issue
|
||||
|
||||
## [1.2.1] - 2026-01-17
|
||||
|
||||
@@ -451,27 +553,27 @@ Update: Update meshcore-hashtag-cracker to include sender-identification correct
|
||||
|
||||
## [1.2.0] - 2026-01-16
|
||||
|
||||
Feature: Add favorites
|
||||
* Feature: Add favorites
|
||||
|
||||
## [1.1.0] - 2026-01-14
|
||||
|
||||
Bugfix: Use actual pathing data from advertisements, not just always flood (oops)
|
||||
Bugfix: Autosync radio clock periodically to prevent drift (would show up most commonly as issues with repeater comms)
|
||||
* Bugfix: Use actual pathing data from advertisements, not just always flood (oops)
|
||||
* Bugfix: Autosync radio clock periodically to prevent drift (would show up most commonly as issues with repeater comms)
|
||||
|
||||
## [1.0.3] - 2026-01-13
|
||||
|
||||
Bugfix: Add missing test management packages
|
||||
Improvement: Drop unnecessary repeater timeouts, and retain timeout for login only -- repeater ops are faster AND more reliable!
|
||||
* Bugfix: Add missing test management packages
|
||||
* Improvement: Drop unnecessary repeater timeouts, and retain timeout for login only -- repeater ops are faster AND more reliable!
|
||||
|
||||
## [1.0.2] - 2026-01-13
|
||||
|
||||
Improvement: Add delays between router ops to prevent traffic collisions
|
||||
* Improvement: Add delays between router ops to prevent traffic collisions
|
||||
|
||||
## [1.0.1] - 2026-01-13
|
||||
|
||||
Bugixes: Cleaner DB shutdown, radio reconnect contention, packet dedupe garbage removal
|
||||
* Bugixes: Cleaner DB shutdown, radio reconnect contention, packet dedupe garbage removal
|
||||
|
||||
## [1.0.0] - 2026-01-13
|
||||
|
||||
Initial full release!
|
||||
* Initial full release!
|
||||
|
||||
|
||||
+100
-5
@@ -48,7 +48,7 @@ Run both the backend and `npm run dev` for hot-reloading frontend development.
|
||||
Run the full quality suite before proposing or handing off code changes:
|
||||
|
||||
```bash
|
||||
./scripts/all_quality.sh
|
||||
./scripts/quality/all_quality.sh
|
||||
```
|
||||
|
||||
That runs linting, formatting, type checking, tests, and builds for both backend and frontend.
|
||||
@@ -70,16 +70,111 @@ npm run test:run
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Quality + Publishing Scripts
|
||||
|
||||
<details>
|
||||
<summary>scripts/quality/</summary>
|
||||
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `all_quality.sh` | Repo-standard gate: autofix (ruff, eslint, prettier), then pyright, pytest, vitest, and frontend build. Run before finishing any code change. |
|
||||
| `extended_quality.sh` | `all_quality.sh` plus e2e tests and Docker build matrix. Used for release validation. |
|
||||
| `e2e.sh` | Thin wrapper that runs Playwright e2e tests from `tests/e2e/`. |
|
||||
| `docker_ci.sh` | Builds the Docker image and runs a smoke test against it. |
|
||||
| `test_aur_package.sh` | Builds the AUR package in an Arch container, then installs and boots it in a second container with port 8000 exposed (hang finish). |
|
||||
| `run_aur_with_radio.sh` | Like `test_aur_package.sh` but passes through the host serial device for testing with a real radio (hang finish). |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>scripts/build/</summary>
|
||||
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `publish.sh` | Full release ceremony: quality gate, version bump, changelog, frontend build, Docker multi-arch push, GitHub release. |
|
||||
| `release_common.sh` | Shared shell helpers (version validation, formatting) sourced by other build scripts. |
|
||||
| `package_release_artifact.sh` | Builds the prebuilt-frontend release zip attached to GitHub releases. |
|
||||
| `push_docker_multiarch.sh` | Builds and pushes multi-arch Docker images (amd64 + arm64). |
|
||||
| `create_github_release.sh` | Creates a GitHub release with changelog notes and the release artifact. |
|
||||
| `extract_release_notes.sh` | Extracts the latest version's notes from `CHANGELOG.md` for the release body. |
|
||||
| `collect_licenses.sh` | Gathers third-party license attributions into `LICENSES.md`. |
|
||||
| `print_frontend_licenses.cjs` | Helper that extracts frontend npm dependency licenses. |
|
||||
| `dump_api_specs.py` | Dumps the OpenAPI spec from the running backend (developer utility). |
|
||||
|
||||
</details>
|
||||
|
||||
## E2E Testing
|
||||
|
||||
E2E coverage exists, but it is intentionally not part of the normal development path.
|
||||
E2E tests exercise the full stack (backend + frontend + real radio hardware) via Playwright.
|
||||
|
||||
These tests are only guaranteed to run correctly in a narrow subset of environments; they require a busy mesh with messages arriving constantly, an available autodetect-able radio, and a contact in the test database (which you can provide in `tests/e2e/.tmp/e2e-test.db` after an initial run). E2E tests are generally not necessary to run for normal development work.
|
||||
> [!WARNING]
|
||||
> E2E tests are **not part of the normal development path** — most contributors will never need to run them. They exist to catch integration issues that unit tests can't and generally only need to be run by maintainers.
|
||||
|
||||
### Hardware requirements
|
||||
|
||||
- A MeshCore radio connected via serial (auto-detected, or set `MESHCORE_SERIAL_PORT`)
|
||||
- The radio must be powered on and past its startup sequence before tests begin
|
||||
|
||||
### Running
|
||||
|
||||
```bash
|
||||
cd tests/e2e
|
||||
npx playwright test # headless
|
||||
npx playwright test --headed # you can probably guess
|
||||
npm install
|
||||
npx playwright install chromium # first time only
|
||||
npx playwright test # headless
|
||||
npx playwright test --headed # watch it run
|
||||
```
|
||||
|
||||
The test harness starts its own uvicorn instance on port 8001 with a fresh temporary database. Your development server (port 8000) is unaffected.
|
||||
|
||||
### Test tiers
|
||||
|
||||
**Most tests (22 of 28) are fully self-contained.** They seed their own data via API calls or direct DB writes and need only a connected radio. These cover messaging, pagination, search, favorites, settings, fanout integrations, historical decryption, and all UI-only views.
|
||||
|
||||
**Mesh-traffic tests (tagged `@mesh-traffic`)** wait up to 3 minutes for an incoming message from another node on the network. If no traffic arrives, they fail with an advisory that the failure may be RF conditions, not a bug. These are: `incoming-message` and `packet-feed` (second test only).
|
||||
|
||||
**The partner-radio DM ACK test (tagged `@partner-radio`)** validates direct-route learning by sending a DM and waiting for an ACK. It requires a second radio in range that has your test radio in its contacts. Configure the partner node's public key and name via `E2E_PARTNER_RADIO_PUBKEY` and `E2E_PARTNER_RADIO_NAME`.
|
||||
|
||||
### Making mesh-traffic tests reliable: the echo bot
|
||||
|
||||
The most practical way to guarantee incoming traffic is to run an **echo bot on a second radio** monitoring a known channel. When the test suite starts a `@mesh-traffic` test, it sends a trigger message to that channel. If a bot on another radio is listening, it replies — generating the incoming RF packet the test needs within seconds instead of waiting for organic mesh traffic.
|
||||
|
||||
The test suite sends `!echo please give incoming message` to the echo channel (default `#flightless`) at the start of each `@mesh-traffic` test. The trigger message is configurable via `E2E_ECHO_TRIGGER_MESSAGE`.
|
||||
|
||||
Setup:
|
||||
1. Set up a second MeshCore radio within RF range of your test radio
|
||||
2. Run a RemoteTerm instance on the second radio
|
||||
3. Configure a bot on the second radio that monitors the echo channel and replies when it sees the trigger. Example bot code:
|
||||
```python
|
||||
def bot(sender_name, sender_key, message_text, is_dm,
|
||||
channel_key, channel_name, sender_timestamp, path):
|
||||
if "!echo" in message_text.lower():
|
||||
return f"[ECHO] {message_text}"
|
||||
return None
|
||||
```
|
||||
4. The test suite calls `nudgeEchoBot()` automatically — no manual intervention needed
|
||||
|
||||
Without the echo bot, `@mesh-traffic` tests rely on organic traffic from other nodes. In a quiet RF environment they will time out.
|
||||
|
||||
### Environment variables
|
||||
|
||||
All E2E environment configuration is centralized in `tests/e2e/helpers/env.ts` with defaults that work for the maintainer's test rig. Override via environment variables:
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `MESHCORE_SERIAL_PORT` | auto-detect | Serial port for the test radio |
|
||||
| `E2E_ECHO_CHANNEL` | `#flightless` | Channel the echo bot monitors for traffic generation |
|
||||
| `E2E_ECHO_TRIGGER_MESSAGE` | `!echo please give incoming message` | Message sent to nudge the echo bot |
|
||||
| `E2E_PARTNER_RADIO_PUBKEY` | *(maintainer's test node)* | 64-char hex public key of a node that will ACK DMs from your radio |
|
||||
| `E2E_PARTNER_RADIO_NAME` | *(maintainer's test node)* | Display name of that node (used in UI assertions) |
|
||||
|
||||
Example for a contributor with their own two-radio setup:
|
||||
|
||||
```bash
|
||||
E2E_ECHO_CHANNEL="#mytest" \
|
||||
E2E_PARTNER_RADIO_PUBKEY="abcd1234...full64charhexkey..." \
|
||||
E2E_PARTNER_RADIO_NAME="MyTestNode" \
|
||||
npx playwright test
|
||||
```
|
||||
|
||||
## Pull Request Expectations
|
||||
|
||||
+65
-3
@@ -1,6 +1,6 @@
|
||||
# Third-Party Licenses
|
||||
|
||||
Auto-generated by `scripts/collect_licenses.sh` — do not edit by hand.
|
||||
Auto-generated by `scripts/build/collect_licenses.sh` — do not edit by hand.
|
||||
|
||||
## Backend (Python) Dependencies
|
||||
|
||||
@@ -56,7 +56,7 @@ SOFTWARE.
|
||||
|
||||
</details>
|
||||
|
||||
### apprise (1.9.7) — BSD-2-Clause
|
||||
### apprise (1.9.9) — BSD-2-Clause
|
||||
|
||||
<details>
|
||||
<summary>Full license text</summary>
|
||||
@@ -64,7 +64,7 @@ SOFTWARE.
|
||||
```
|
||||
BSD 2-Clause License
|
||||
|
||||
Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
|
||||
Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
@@ -1188,6 +1188,37 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
|
||||
|
||||
</details>
|
||||
|
||||
### cmdk (1.1.1) — MIT
|
||||
|
||||
<details>
|
||||
<summary>Full license text</summary>
|
||||
|
||||
```
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Paco Coursey
|
||||
|
||||
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>
|
||||
|
||||
### d3-force (3.0.0) — ISC
|
||||
|
||||
<details>
|
||||
@@ -1625,6 +1656,37 @@ THE SOFTWARE.
|
||||
|
||||
</details>
|
||||
|
||||
### recharts (3.8.1) — MIT
|
||||
|
||||
<details>
|
||||
<summary>Full license text</summary>
|
||||
|
||||
```
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015-present recharts
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### sonner (2.0.7) — MIT
|
||||
|
||||
<details>
|
||||
|
||||
@@ -12,31 +12,18 @@ Backend server + browser interface for MeshCore mesh radio networks. Connect you
|
||||
* Use the more recent 1.14 firmwares which support multibyte pathing
|
||||
* Visualize the mesh as a map or node set, view repeater stats, and more!
|
||||
|
||||
For advanced setup and troubleshooting see [README_ADVANCED.md](README_ADVANCED.md). If you plan to contribute, read [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
**Warning:** This app is for trusted environments only. _Do not put this on an untrusted network, or open it to the public._ You can optionally set `MESHCORE_BASIC_AUTH_USERNAME` and `MESHCORE_BASIC_AUTH_PASSWORD` for app-wide HTTP Basic auth, but that is only a coarse gate and must be paired with HTTPS. The bots can execute arbitrary Python code which means anyone who gets access to the app can, too. To completely disable the bot system, start the server with `MESHCORE_DISABLE_BOTS=true` — this prevents all bot execution and blocks bot configuration changes via the API. If you need stronger access control, consider using a reverse proxy like Nginx, or extending FastAPI; full access control and user management are outside the scope of this app.
|
||||
|
||||

|
||||
|
||||
## Disclaimer
|
||||
|
||||
This is developed with very heavy agentic assistance -- there is no warranty of fitness for any purpose. It's been lovingly guided by an engineer with a passion for clean code and good tests, but it's still mostly LLM output, so you may find some bugs.
|
||||
|
||||
If extending, have your LLM read the three `AGENTS.md` files: `./AGENTS.md`, `./frontend/AGENTS.md`, and `./app/AGENTS.md`.
|
||||
|
||||
## Start Here
|
||||
|
||||
Most users should choose one of these paths:
|
||||
|
||||
1. Clone and build from source.
|
||||
2. Download the prebuilt release zip if you are on a resource-constrained system and do not want to build the frontend locally.
|
||||
3. Use Docker if that better matches how you deploy.
|
||||
|
||||
For advanced setup, troubleshooting, HTTPS, systemd service setup, and remediation environment variables, see [README_ADVANCED.md](README_ADVANCED.md).
|
||||
|
||||
If you plan to contribute, read [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
> [!WARNING]
|
||||
> RemoteTerm does *full* management of the radio, meaning that once a radio is connected to RemoteTerm, all contacts/channels will be imported and offloaded to RemoteTerm and the contacts actually synced to the device will be governed by RemoteTerm. This means that RemoteTerm can be a poor fit for users who are looking to swap radios in and out, maintaining radio state (favorites, channels, etc.) irrespective of app usage.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+
|
||||
- Python 3.11+
|
||||
- Node.js LTS or current (20, 22, 24, 25) if you're not using a prebuilt release
|
||||
- [UV](https://astral.sh/uv) package manager: `curl -LsSf https://astral.sh/uv/install.sh | sh`
|
||||
- MeshCore radio connected via USB serial, TCP, or BLE
|
||||
@@ -77,7 +64,7 @@ usbipd attach --wsl --busid 3-8
|
||||
```
|
||||
</details>
|
||||
|
||||
## Path 1: Clone And Build
|
||||
## Install Path 1: Clone And Build
|
||||
|
||||
**This approach is recommended over Docker due to intermittent serial communications issues I've seen on \*nix systems.**
|
||||
|
||||
@@ -95,78 +82,112 @@ Access the app at http://localhost:8000.
|
||||
|
||||
Source checkouts expect a normal frontend build in `frontend/dist`.
|
||||
|
||||
On Linux, if you want this installed as a persistent `systemd` service that starts on boot and restarts automatically on failure, run `bash scripts/install_service.sh` from the repo root.
|
||||
> [!TIP]
|
||||
> 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
|
||||
> [!NOTE]
|
||||
> On Linux, you can also install RemoteTerm as a persistent `systemd` service that starts on boot and restarts automatically on failure:
|
||||
>
|
||||
> ```bash
|
||||
> bash scripts/setup/install_service.sh
|
||||
> ```
|
||||
>
|
||||
> For the full service workflow and post-install operations, see [README_ADVANCED.md](README_ADVANCED.md).
|
||||
|
||||
Release zips can be found as an asset within the [releases listed here](https://github.com/jkingsman/Remote-Terminal-for-MeshCore/releases). This can be beneficial on resource constrained systems that cannot cope with the RAM-hungry frontend build process.
|
||||
|
||||
If you downloaded the release zip instead of cloning the repo, unpack it and run:
|
||||
|
||||
```bash
|
||||
cd Remote-Terminal-for-MeshCore
|
||||
uv sync
|
||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
The release bundle includes `frontend/prebuilt`, so it does not require a local frontend build.
|
||||
|
||||
Alternatively, if you have already cloned the repo, you can fetch just the prebuilt frontend into your working tree without downloading the full release zip via `python3 scripts/fetch_prebuilt_frontend.py`.
|
||||
|
||||
## Path 2: Docker
|
||||
## Install Path 2: Docker
|
||||
|
||||
> **Warning:** Docker has had reports intermittent issues with serial event subscriptions. The native method above is more reliable.
|
||||
|
||||
Local Docker builds are architecture-native by default. On Apple Silicon Macs and ARM64 Linux hosts such as Raspberry Pi, `docker compose build` / `docker compose up --build` will produce an ARM64 image unless you override the platform.
|
||||
|
||||
Edit `docker-compose.yaml` to set a serial device for passthrough, or uncomment your transport (serial or TCP). Then:
|
||||
For serial-device passthrough, use rootful Docker. In practice that usually means starting the stack with `sudo docker compose ...` unless your Docker daemon is already configured for rootful access via your user/group. Rootless Docker has been observed to fail on serial-device mappings even when the compose file itself is correct.
|
||||
|
||||
Create a local `docker-compose.yml` in one of two ways:
|
||||
|
||||
1. Copy the example file and edit it by hand:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
cp docker-compose.example.yml docker-compose.yml
|
||||
```
|
||||
|
||||
The database is stored in `./data/` (bind-mounted), so the container shares the same database as the native app. To rebuild after pulling updates:
|
||||
2. Or generate one interactively:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
bash scripts/setup/install_docker.sh
|
||||
```
|
||||
|
||||
To use the prebuilt Docker Hub image instead of building locally, replace:
|
||||
> The interactive generator enables a self-signed (snakeoil) TLS certificate by default. If you accept the default, the app will be served over HTTPS and the generated compose file will include certificate mounts and an SSL command override. Decline if you prefer plain HTTP or plan to terminate TLS externally.
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
> If you switched to a local build (`build: .` instead of `image:`), use `sudo docker compose up -d --build` instead — `pull` only fetches remote images.
|
||||
|
||||
The example file and setup script default to the published Docker Hub image. To build locally from your checkout instead, replace:
|
||||
|
||||
```yaml
|
||||
build: .
|
||||
image: docker.io/jkingsman/remoteterm-meshcore:latest
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```yaml
|
||||
image: jkingsman/remoteterm-meshcore:latest
|
||||
build: .
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
sudo docker compose up -d --build
|
||||
```
|
||||
|
||||
Published Docker tags are intended to be multi-arch (`linux/amd64` and `linux/arm64`). If you are building and publishing manually, use Docker Buildx:
|
||||
|
||||
```bash
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
-t jkingsman/remoteterm-meshcore:latest \
|
||||
--push .
|
||||
```
|
||||
|
||||
The container runs as root by default for maximum serial passthrough compatibility across host setups. On Linux, if you switch between native and Docker runs, `./data` can end up root-owned. If you do not need that serial compatibility behavior, you can enable the optional `user: "${UID:-1000}:${GID:-1000}"` line in `docker-compose.yaml` to keep ownership aligned with your host user.
|
||||
The container runs as root by default for maximum serial passthrough compatibility across host setups. On Linux, if you switch between native and Docker runs, `./data` can end up root-owned. If you do not need that serial compatibility behavior, you can enable the optional `user: "${UID:-1000}:${GID:-1000}"` line in `docker-compose.yml` to keep ownership aligned with your host user.
|
||||
|
||||
To stop:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
sudo docker compose down
|
||||
```
|
||||
|
||||
## Install Path 3: Arch Linux (AUR)
|
||||
|
||||
A [`remoteterm-meshcore`](https://aur.archlinux.org/packages/remoteterm-meshcore) package is available in the AUR. Install it with an AUR helper or build it manually:
|
||||
|
||||
```bash
|
||||
# with an AUR helper
|
||||
yay -S remoteterm-meshcore
|
||||
|
||||
# or manually
|
||||
git clone https://aur.archlinux.org/remoteterm-meshcore.git
|
||||
cd remoteterm-meshcore
|
||||
makepkg -si
|
||||
```
|
||||
|
||||
Configure your radio connection, then start the service:
|
||||
|
||||
```bash
|
||||
sudo vi /etc/remoteterm-meshcore/remoteterm.env
|
||||
sudo systemctl enable --now remoteterm-meshcore
|
||||
```
|
||||
|
||||
Access the app at http://localhost:8000.
|
||||
|
||||
## Standard Environment Variables
|
||||
|
||||
Only one transport may be active at a time. If multiple are set, the server will refuse to start.
|
||||
@@ -176,7 +197,7 @@ Only one transport may be active at a time. If multiple are set, the server will
|
||||
| `MESHCORE_SERIAL_PORT` | (auto-detect) | Serial port path |
|
||||
| `MESHCORE_SERIAL_BAUDRATE` | 115200 | Serial baud rate |
|
||||
| `MESHCORE_TCP_HOST` | | TCP host (mutually exclusive with serial/BLE) |
|
||||
| `MESHCORE_TCP_PORT` | 4000 | TCP port |
|
||||
| `MESHCORE_TCP_PORT` | 5000 | TCP port |
|
||||
| `MESHCORE_BLE_ADDRESS` | | BLE device address (mutually exclusive with serial/TCP) |
|
||||
| `MESHCORE_BLE_PIN` | | BLE PIN (required when BLE address is set) |
|
||||
| `MESHCORE_LOG_LEVEL` | INFO | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
|
||||
@@ -192,7 +213,7 @@ Common launch patterns:
|
||||
MESHCORE_SERIAL_PORT=/dev/ttyUSB0 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
|
||||
# TCP
|
||||
MESHCORE_TCP_HOST=192.168.1.100 MESHCORE_TCP_PORT=4000 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
MESHCORE_TCP_HOST=192.168.1.100 MESHCORE_TCP_PORT=5000 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
|
||||
# BLE
|
||||
MESHCORE_BLE_ADDRESS=AA:BB:CC:DD:EE:FF MESHCORE_BLE_PIN=123456 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
@@ -205,6 +226,15 @@ $env:MESHCORE_SERIAL_PORT="COM8" # or your COM port
|
||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> **Windows + MQTT fanout:** Python's default Windows event loop (ProactorEventLoop) is not compatible with the MQTT libraries used by RemoteTerm. If you configure any MQTT integration, add `--loop none` to your uvicorn command:
|
||||
>
|
||||
> ```powershell
|
||||
> uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --loop none
|
||||
> ```
|
||||
>
|
||||
> If you forget, the app will start normally but MQTT connections will fail and you'll see a toast in the UI with this same guidance.
|
||||
|
||||
If you enable Basic Auth, protect the app with HTTPS. HTTP Basic credentials are not safe on plain HTTP. Also note that the app's permissive CORS policy is a deliberate trusted-network tradeoff, so cross-origin browser JavaScript is not a reliable way to use that Basic Auth gate.
|
||||
|
||||
## Where To Go Next
|
||||
@@ -212,3 +242,9 @@ If you enable Basic Auth, protect the app with HTTPS. HTTP Basic credentials are
|
||||
- Advanced setup, troubleshooting, HTTPS, systemd, remediation variables, and debug logging: [README_ADVANCED.md](README_ADVANCED.md)
|
||||
- Contributing, tests, linting, E2E notes, and important AGENTS files: [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
- Live API docs after the backend is running: http://localhost:8000/docs
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This is developed with very heavy agentic assistance -- there is no warranty of fitness for any purpose. It's been lovingly guided by an engineer with a passion for clean code and good tests, but it's still mostly LLM output, so you may find some bugs.
|
||||
|
||||
If extending, have your LLM read the three `AGENTS.md` files: `./AGENTS.md`, `./frontend/AGENTS.md`, and `./app/AGENTS.md`.
|
||||
|
||||
+10
-26
@@ -19,6 +19,15 @@ If the audit finds a mismatch, you'll see an error in the application UI and you
|
||||
|
||||
`__CLOWNTOWN_DO_CLOCK_WRAPAROUND=true` is a last-resort clock remediation for nodes whose RTC is stuck in the future and where rescue-mode time setting or GPS-based time is not available. It intentionally relies on the clock rolling past the 32-bit epoch boundary, which is board-specific behavior and may not be safe or effective on all MeshCore targets. Treat it as highly experimental.
|
||||
|
||||
## Sub-Path Reverse Proxy
|
||||
|
||||
RemoteTerm works behind a reverse proxy that serves it under a sub-path (e.g. `/meshcore/` or Home Assistant ingress). All frontend asset and API paths are relative, so they resolve correctly under any prefix.
|
||||
|
||||
**Requirements:**
|
||||
|
||||
- The proxy must ensure the sub-path URL has a **trailing slash**. If a user visits `/meshcore` (no slash), relative paths break. Most proxies handle this automatically; for Nginx, a `location /meshcore/ { ... }` block (note the trailing slash) does the right thing.
|
||||
- For correct PWA install behavior, the proxy should forward `X-Forwarded-Prefix` (set to the sub-path, e.g. `/meshcore`) so the web manifest generates correct `start_url` and `scope` values. `X-Forwarded-Proto` and `X-Forwarded-Host` are also respected for origin resolution.
|
||||
|
||||
## HTTPS
|
||||
|
||||
WebGPU channel-finding requires a secure context when you are not on `localhost`.
|
||||
@@ -46,39 +55,14 @@ Accept the browser warning, or use [mkcert](https://github.com/FiloSottile/mkcer
|
||||
|
||||
## Systemd Service
|
||||
|
||||
Two paths are available depending on your comfort level with Linux system administration.
|
||||
|
||||
### Simple install (recommended for most users)
|
||||
|
||||
On Linux systems, this is the recommended installation method if you want RemoteTerm set up as a persistent systemd service that starts automatically on boot and restarts automatically if it crashes. Run the installer script from the repo root. It runs as your current user, installs from wherever you cloned the repo, and prints a quick-reference cheatsheet when done — no separate service account or path juggling required.
|
||||
|
||||
```bash
|
||||
bash scripts/install_service.sh
|
||||
bash scripts/setup/install_service.sh
|
||||
```
|
||||
|
||||
The script interactively asks which transport to use (serial auto-detect, serial with explicit port, TCP, or BLE), whether to build the frontend locally or download a prebuilt copy, whether to enable the bot system, and whether to set up HTTP Basic Auth. It handles dependency installation (`uv sync`), validates `node`/`npm` for local builds, adds your user to the `dialout` group if needed, writes the systemd unit file, and enables the service. After installation, normal operations work without any `sudo -u` gymnastics:
|
||||
|
||||
You can also rerun the script later to change transport, bot, or auth settings. If the service is already running, the installer stops it, rewrites the unit file, reloads systemd, and starts it again with the new configuration.
|
||||
|
||||
```bash
|
||||
# Update to latest and restart
|
||||
cd /path/to/repo
|
||||
git pull
|
||||
uv sync
|
||||
cd frontend && npm install && npm run build && cd ..
|
||||
sudo systemctl restart remoteterm
|
||||
|
||||
# Refresh prebuilt frontend only (skips local build)
|
||||
python3 scripts/fetch_prebuilt_frontend.py
|
||||
sudo systemctl restart remoteterm
|
||||
|
||||
# View live logs
|
||||
sudo journalctl -u remoteterm -f
|
||||
|
||||
# Service control
|
||||
sudo systemctl start|stop|restart|status remoteterm
|
||||
```
|
||||
|
||||
## Debug Logging And Bug Reports
|
||||
|
||||
If you're experiencing issues or opening a bug report, please start the backend with debug logging enabled. Debug mode provides a much more detailed breakdown of radio communication, packet processing, and other internal operations, which makes it significantly easier to diagnose problems.
|
||||
|
||||
+41
-21
@@ -25,18 +25,22 @@ Keep it aligned with `app/` source files and router behavior.
|
||||
app/
|
||||
├── main.py # App startup/lifespan, router registration, static frontend mounting
|
||||
├── config.py # Env-driven runtime settings
|
||||
├── channel_constants.py # Public/default channel constants shared across sync/send logic
|
||||
├── database.py # SQLite connection + base schema + migration runner
|
||||
├── migrations.py # Schema migrations (SQLite user_version)
|
||||
├── models.py # Pydantic request/response models and typed write contracts (for example ContactUpsert)
|
||||
├── version_info.py # Unified version/build metadata resolution for debug + startup surfaces
|
||||
├── repository/ # Data access layer (contacts, channels, messages, raw_packets, settings, fanout)
|
||||
├── services/ # Shared orchestration/domain services
|
||||
│ ├── messages.py # Shared message creation, dedup, ACK application
|
||||
│ ├── message_send.py # Direct send, channel send, resend workflows
|
||||
│ ├── dm_ingest.py # Shared direct-message ingest / dedup seam for packet + fallback paths
|
||||
│ ├── dm_ack_apply.py # Shared DM ACK application over pending/buffered ACK state
|
||||
│ ├── dm_ack_tracker.py # Pending DM ACK state
|
||||
│ ├── contact_reconciliation.py # Prefix-claim, sender-key backfill, name-history wiring
|
||||
│ ├── radio_lifecycle.py # Post-connect setup and reconnect/setup helpers
|
||||
│ ├── radio_commands.py # Radio config/private-key command workflows
|
||||
│ ├── radio_stats.py # In-memory local radio stats sampling and noise-floor history
|
||||
│ └── radio_runtime.py # Router/dependency seam over the global RadioManager
|
||||
├── radio.py # RadioManager transport/session state + lock management
|
||||
├── radio_sync.py # Polling, sync, periodic advertisement loop
|
||||
@@ -61,6 +65,8 @@ app/
|
||||
├── messages.py
|
||||
├── packets.py
|
||||
├── read_state.py
|
||||
├── rooms.py
|
||||
├── server_control.py
|
||||
├── settings.py
|
||||
├── fanout.py
|
||||
├── repeaters.py
|
||||
@@ -155,10 +161,12 @@ app/
|
||||
|
||||
- All external integrations (MQTT, bots, webhooks, Apprise, SQS) are managed through the fanout bus (`app/fanout/`).
|
||||
- Configs stored in `fanout_configs` table, managed via `GET/POST/PATCH/DELETE /api/fanout`.
|
||||
- `broadcast_event()` in `websocket.py` dispatches to the fanout manager for `message` and `raw_packet` events.
|
||||
- Each integration is a `FanoutModule` with scope-based filtering.
|
||||
- `broadcast_event()` in `websocket.py` dispatches to the fanout manager for `message`, `raw_packet`, and `contact` events.
|
||||
- `on_message` and `on_raw` are scope-gated. `on_contact`, `on_telemetry`, and `on_health` are dispatched to all modules unconditionally (modules filter internally).
|
||||
- Repeater telemetry broadcasts are emitted after `RepeaterTelemetryRepository.record()` in both `radio_sync.py` (auto-collect) and `routers/repeaters.py` (manual fetch).
|
||||
- The 60-second radio stats sampling loop in `radio_stats.py` dispatches an enriched health snapshot (radio identity + full stats) to all fanout modules after each sample.
|
||||
- Community MQTT publishes raw packets only, but its derived `path` field for direct packets is emitted as comma-separated hop identifiers, not flat path bytes.
|
||||
- See `app/fanout/AGENTS_fanout.md` for full architecture details.
|
||||
- See `app/fanout/AGENTS_fanout.md` for full architecture details and event payload shapes.
|
||||
|
||||
## API Surface (all under `/api`)
|
||||
|
||||
@@ -174,6 +182,7 @@ app/
|
||||
- `PUT /radio/private-key`
|
||||
- `POST /radio/advertise` — manual advert send; request body may set `mode` to `flood` or `zero_hop` (defaults to `flood`)
|
||||
- `POST /radio/discover` — short mesh discovery sweep for nearby repeaters/sensors
|
||||
- `POST /radio/trace` — send a multi-hop trace loop through known repeaters and back to the local radio
|
||||
- `POST /radio/disconnect`
|
||||
- `POST /radio/reboot`
|
||||
- `POST /radio/reconnect`
|
||||
@@ -183,6 +192,7 @@ app/
|
||||
- `GET /contacts/analytics` — unified keyed-or-name analytics payload
|
||||
- `GET /contacts/repeaters/advert-paths` — recent advert paths for all contacts
|
||||
- `POST /contacts`
|
||||
- `POST /contacts/bulk-delete`
|
||||
- `DELETE /contacts/{public_key}`
|
||||
- `POST /contacts/{public_key}/mark-read`
|
||||
- `POST /contacts/{public_key}/command`
|
||||
@@ -198,13 +208,19 @@ app/
|
||||
- `POST /contacts/{public_key}/repeater/radio-settings`
|
||||
- `POST /contacts/{public_key}/repeater/advert-intervals`
|
||||
- `POST /contacts/{public_key}/repeater/owner-info`
|
||||
- `POST /contacts/{public_key}/room/login`
|
||||
- `POST /contacts/{public_key}/room/status`
|
||||
- `POST /contacts/{public_key}/room/lpp-telemetry`
|
||||
- `POST /contacts/{public_key}/room/acl`
|
||||
|
||||
### Channels
|
||||
- `GET /channels`
|
||||
- `GET /channels/{key}/detail`
|
||||
- `POST /channels`
|
||||
- `POST /channels/bulk-hashtag`
|
||||
- `DELETE /channels/{key}`
|
||||
- `POST /channels/{key}/flood-scope-override`
|
||||
- `POST /channels/{key}/path-hash-mode-override`
|
||||
- `POST /channels/{key}/mark-read`
|
||||
|
||||
### Messages
|
||||
@@ -216,6 +232,7 @@ app/
|
||||
|
||||
### Packets
|
||||
- `GET /packets/undecrypted/count`
|
||||
- `GET /packets/{packet_id}` — fetch one stored raw packet by row ID for on-demand inspection
|
||||
- `POST /packets/decrypt/historical`
|
||||
- `POST /packets/maintenance`
|
||||
|
||||
@@ -229,13 +246,14 @@ app/
|
||||
- `POST /settings/favorites/toggle`
|
||||
- `POST /settings/blocked-keys/toggle`
|
||||
- `POST /settings/blocked-names/toggle`
|
||||
- `POST /settings/migrate`
|
||||
- `POST /settings/tracked-telemetry/toggle`
|
||||
|
||||
### Fanout
|
||||
- `GET /fanout` — list all fanout configs
|
||||
- `POST /fanout` — create new fanout config
|
||||
- `PATCH /fanout/{id}` — update fanout config (triggers module reload)
|
||||
- `DELETE /fanout/{id}` — delete fanout config (stops module)
|
||||
- `POST /fanout/bots/disable-until-restart` — stop bot modules and keep bots disabled until restart
|
||||
|
||||
### Statistics
|
||||
- `GET /statistics` — aggregated mesh network stats (entity counts, message/packet splits, activity windows, busiest channels)
|
||||
@@ -265,11 +283,13 @@ Client sends `"ping"` text; server replies `{"type":"pong"}`.
|
||||
Main tables:
|
||||
- `contacts` (includes `first_seen` for contact age tracking and `direct_path_hash_mode` / `route_override_*` for DM routing)
|
||||
- `channels`
|
||||
Includes optional `flood_scope_override` for channel-specific regional sends.
|
||||
Includes optional `flood_scope_override` for channel-specific regional sends and optional `path_hash_mode_override` for per-channel path hop width.
|
||||
- `messages` (includes `sender_name`, `sender_key` for per-contact channel message attribution)
|
||||
- `raw_packets`
|
||||
- `contact_advert_paths` (recent unique advertisement paths per contact, keyed by contact + path bytes + hop count)
|
||||
- `contact_name_history` (tracks name changes over time)
|
||||
- `repeater_telemetry_history` (time-series telemetry snapshots for tracked repeaters)
|
||||
- `fanout_configs` (MQTT, bot, webhook, Apprise, SQS integration configs)
|
||||
- `app_settings`
|
||||
|
||||
Contact route state is canonicalized on the backend:
|
||||
@@ -285,17 +305,14 @@ Repository writes should prefer typed models such as `ContactUpsert` over ad hoc
|
||||
|
||||
`app_settings` fields in active model:
|
||||
- `max_radio_contacts`
|
||||
- `favorites`
|
||||
- `auto_decrypt_dm_on_advert`
|
||||
- `sidebar_sort_order`
|
||||
- `last_message_times`
|
||||
- `preferences_migrated`
|
||||
- `advert_interval`
|
||||
- `last_advert_time`
|
||||
- `flood_scope`
|
||||
- `blocked_keys`, `blocked_names`
|
||||
|
||||
Note: `sidebar_sort_order` remains in the backend model for compatibility and migration, but the current frontend sidebar uses per-section localStorage sort preferences instead of a single shared server-backed sort mode.
|
||||
- `blocked_keys`, `blocked_names`, `discovery_blocked_types`
|
||||
- `tracked_telemetry_repeaters`
|
||||
- `auto_resend_channel`
|
||||
|
||||
Note: MQTT, community MQTT, and bot configs were migrated to the `fanout_configs` table (migrations 36-38).
|
||||
|
||||
@@ -322,9 +339,11 @@ tests/
|
||||
├── conftest.py # Shared fixtures
|
||||
├── test_ack_tracking_wiring.py # DM ACK tracking extraction and wiring
|
||||
├── test_api.py # REST endpoint integration tests
|
||||
├── test_block_lists.py # Blocked keys/names filtering across list/search surfaces
|
||||
├── test_bot.py # Bot execution and sandboxing
|
||||
├── test_channels_router.py # Channels router endpoints
|
||||
├── test_channel_sender_backfill.py # Sender-key backfill uniqueness rules for channel messages
|
||||
├── test_channels_router.py # Channels router endpoints
|
||||
├── test_community_mqtt.py # Community MQTT publisher (JWT, packet format, hash, broadcast)
|
||||
├── test_config.py # Configuration validation
|
||||
├── test_contact_reconciliation_service.py # Prefix/contact reconciliation service helpers
|
||||
├── test_contacts_router.py # Contacts router endpoints
|
||||
@@ -332,40 +351,41 @@ tests/
|
||||
├── test_disable_bots.py # MESHCORE_DISABLE_BOTS=true feature
|
||||
├── test_echo_dedup.py # Echo/repeat deduplication (incl. concurrent)
|
||||
├── test_fanout.py # Fanout bus CRUD, scope matching, manager dispatch
|
||||
├── test_fanout_integration.py # Fanout integration tests
|
||||
├── test_fanout_hitlist.py # Fanout-related hitlist regression tests
|
||||
├── test_fanout_integration.py # Fanout integration tests
|
||||
├── test_event_handlers.py # ACK tracking, event registration, cleanup
|
||||
├── test_frontend_static.py # Frontend static file serving
|
||||
├── test_health_mqtt_status.py # Health endpoint MQTT status field
|
||||
├── test_http_quality.py # Cache-control / gzip / basic-auth HTTP quality checks
|
||||
├── test_key_normalization.py # Public key normalization
|
||||
├── test_keystore.py # Ephemeral keystore
|
||||
├── test_main_startup.py # App startup and lifespan
|
||||
├── test_map_upload.py # Map upload fanout module
|
||||
├── test_message_pagination.py # Cursor-based message pagination
|
||||
├── test_message_prefix_claim.py # Message prefix claim logic
|
||||
├── test_migrations.py # Schema migration system
|
||||
├── test_community_mqtt.py # Community MQTT publisher (JWT, packet format, hash, broadcast)
|
||||
├── test_mqtt.py # MQTT publisher topic routing and lifecycle
|
||||
├── test_messages_search.py # Message search, around, forward pagination
|
||||
├── test_migrations.py # Schema migration system
|
||||
├── test_packet_pipeline.py # End-to-end packet processing
|
||||
├── test_packets_router.py # Packets router endpoints (decrypt, maintenance)
|
||||
├── test_path_utils.py # Path hex rendering helpers
|
||||
├── test_radio.py # RadioManager, serial detection
|
||||
├── test_radio_commands_service.py # Radio config/private-key service workflows
|
||||
├── test_radio_lifecycle_service.py # Reconnect/setup orchestration helpers
|
||||
├── test_radio_runtime_service.py # radio_runtime seam behavior and helpers
|
||||
├── test_real_crypto.py # Real cryptographic operations
|
||||
├── test_radio_operation.py # radio_operation() context manager
|
||||
├── test_radio_router.py # Radio router endpoints
|
||||
├── test_radio_runtime_service.py # radio_runtime seam behavior and helpers
|
||||
├── test_radio_sync.py # Polling, sync, advertisement
|
||||
├── test_real_crypto.py # Real cryptographic operations
|
||||
├── test_repeater_routes.py # Repeater command/telemetry/trace + granular pane endpoints
|
||||
├── test_repository.py # Data access layer
|
||||
├── test_room_routes.py # Room-server login/status/telemetry/ACL endpoints
|
||||
├── test_rx_log_data.py # on_rx_log_data event handler integration
|
||||
├── test_messages_search.py # Message search, around, forward pagination
|
||||
├── test_block_lists.py # Blocked keys/names filtering
|
||||
├── test_security.py # Optional Basic Auth middleware / config behavior
|
||||
├── test_send_messages.py # Outgoing messages, bot triggers, concurrent sends
|
||||
├── test_settings_router.py # Settings endpoints, advert validation
|
||||
├── test_statistics.py # Statistics aggregation
|
||||
├── test_main_startup.py # App startup and lifespan
|
||||
├── test_path_utils.py # Path hex rendering helpers
|
||||
├── test_version_info.py # Version/build metadata resolution
|
||||
├── test_websocket.py # WS manager broadcast/cleanup
|
||||
└── test_websocket_route.py # WS endpoint lifecycle
|
||||
```
|
||||
|
||||
+2
-1
@@ -14,7 +14,7 @@ class Settings(BaseSettings):
|
||||
serial_port: str = "" # Empty string triggers auto-detection
|
||||
serial_baudrate: int = 115200
|
||||
tcp_host: str = ""
|
||||
tcp_port: int = 4000
|
||||
tcp_port: int = 5000
|
||||
ble_address: str = ""
|
||||
ble_pin: str = ""
|
||||
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
|
||||
@@ -26,6 +26,7 @@ class Settings(BaseSettings):
|
||||
default=False,
|
||||
validation_alias="__CLOWNTOWN_DO_CLOCK_WRAPAROUND",
|
||||
)
|
||||
skip_post_connect_sync: bool = False
|
||||
basic_auth_username: str = ""
|
||||
basic_auth_password: str = ""
|
||||
|
||||
|
||||
+70
-9
@@ -27,7 +27,8 @@ CREATE TABLE IF NOT EXISTS contacts (
|
||||
on_radio INTEGER DEFAULT 0,
|
||||
last_contacted INTEGER,
|
||||
first_seen INTEGER,
|
||||
last_read_at INTEGER
|
||||
last_read_at INTEGER,
|
||||
favorite INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channels (
|
||||
@@ -36,7 +37,9 @@ CREATE TABLE IF NOT EXISTS channels (
|
||||
is_hashtag INTEGER DEFAULT 0,
|
||||
on_radio INTEGER DEFAULT 0,
|
||||
flood_scope_override TEXT,
|
||||
last_read_at INTEGER
|
||||
path_hash_mode_override INTEGER,
|
||||
last_read_at INTEGER,
|
||||
favorite INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
@@ -46,7 +49,7 @@ CREATE TABLE IF NOT EXISTS messages (
|
||||
text TEXT NOT NULL,
|
||||
sender_timestamp INTEGER,
|
||||
received_at INTEGER NOT NULL,
|
||||
path TEXT,
|
||||
paths TEXT,
|
||||
txt_type INTEGER DEFAULT 0,
|
||||
signature TEXT,
|
||||
outgoing INTEGER DEFAULT 0,
|
||||
@@ -66,7 +69,7 @@ CREATE TABLE IF NOT EXISTS raw_packets (
|
||||
data BLOB NOT NULL,
|
||||
message_id INTEGER,
|
||||
payload_hash BLOB,
|
||||
FOREIGN KEY (message_id) REFERENCES messages(id)
|
||||
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS contact_advert_paths (
|
||||
@@ -78,7 +81,7 @@ CREATE TABLE IF NOT EXISTS contact_advert_paths (
|
||||
last_seen INTEGER NOT NULL,
|
||||
heard_count INTEGER NOT NULL DEFAULT 1,
|
||||
UNIQUE(public_key, path_hex, path_len),
|
||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key)
|
||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS contact_name_history (
|
||||
@@ -88,22 +91,70 @@ CREATE TABLE IF NOT EXISTS contact_name_history (
|
||||
first_seen INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
UNIQUE(public_key, name),
|
||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key)
|
||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
max_radio_contacts INTEGER DEFAULT 200,
|
||||
favorites TEXT DEFAULT '[]',
|
||||
auto_decrypt_dm_on_advert INTEGER DEFAULT 1,
|
||||
last_message_times TEXT DEFAULT '{}',
|
||||
preferences_migrated INTEGER DEFAULT 0,
|
||||
advert_interval INTEGER DEFAULT 0,
|
||||
last_advert_time INTEGER DEFAULT 0,
|
||||
flood_scope TEXT DEFAULT '',
|
||||
blocked_keys TEXT DEFAULT '[]',
|
||||
blocked_names TEXT DEFAULT '[]',
|
||||
discovery_blocked_types TEXT DEFAULT '[]',
|
||||
tracked_telemetry_repeaters TEXT DEFAULT '[]',
|
||||
auto_resend_channel INTEGER DEFAULT 0
|
||||
);
|
||||
INSERT OR IGNORE INTO app_settings (id) VALUES (1);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fanout_configs (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
enabled INTEGER DEFAULT 0,
|
||||
config TEXT NOT NULL DEFAULT '{}',
|
||||
scope TEXT NOT NULL DEFAULT '{}',
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS repeater_telemetry_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
public_key TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_received ON messages(received_at);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_dedup_null_safe
|
||||
ON messages(type, conversation_key, text, COALESCE(sender_timestamp, 0))
|
||||
WHERE type = 'CHAN';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_incoming_priv_dedup
|
||||
ON messages(type, conversation_key, text, COALESCE(sender_timestamp, 0), COALESCE(sender_key, ''))
|
||||
WHERE type = 'PRIV' AND outgoing = 0;
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_sender_key ON messages(sender_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_pagination
|
||||
ON messages(type, conversation_key, received_at DESC, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_unread_covering
|
||||
ON messages(type, conversation_key, outgoing, received_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_raw_packets_message_id ON raw_packets(message_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_raw_packets_timestamp ON raw_packets(timestamp);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_packets_payload_hash ON raw_packets(payload_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_contacts_on_radio ON contacts(on_radio);
|
||||
-- idx_messages_sender_key is created by migration 25 (after adding the sender_key column)
|
||||
-- idx_messages_incoming_priv_dedup is created by migration 44 after legacy rows are reconciled
|
||||
CREATE INDEX IF NOT EXISTS idx_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);
|
||||
CREATE INDEX IF NOT EXISTS idx_contact_advert_paths_recent
|
||||
ON contact_advert_paths(public_key, last_seen DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_contact_name_history_key
|
||||
ON contact_name_history(public_key, last_seen DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_repeater_telemetry_pk_ts
|
||||
ON repeater_telemetry_history(public_key, timestamp);
|
||||
"""
|
||||
|
||||
|
||||
@@ -128,6 +179,12 @@ class Database:
|
||||
# migration 20 handles the one-time VACUUM to restructure the file.
|
||||
await self._connection.execute("PRAGMA auto_vacuum = INCREMENTAL")
|
||||
|
||||
# Foreign key enforcement: must be set per-connection (not persisted).
|
||||
# Disabled during schema init and migrations to avoid issues with
|
||||
# historical table-rebuild migrations that may temporarily violate
|
||||
# constraints, then re-enabled for all subsequent application queries.
|
||||
await self._connection.execute("PRAGMA foreign_keys = OFF")
|
||||
|
||||
await self._connection.executescript(SCHEMA)
|
||||
await self._connection.commit()
|
||||
logger.debug("Database schema initialized")
|
||||
@@ -137,6 +194,10 @@ class Database:
|
||||
|
||||
await run_migrations(self._connection)
|
||||
|
||||
# Enable FK enforcement for all application queries from this point on.
|
||||
await self._connection.execute("PRAGMA foreign_keys = ON")
|
||||
logger.debug("Foreign key enforcement enabled")
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
if self._connection:
|
||||
await self._connection.close()
|
||||
|
||||
+4
-1
@@ -299,8 +299,11 @@ def parse_advertisement(
|
||||
timestamp = int.from_bytes(payload[32:36], byteorder="little")
|
||||
flags = payload[100]
|
||||
|
||||
# Parse flags
|
||||
# Parse flags — clamp device_role to valid range (0-4); corrupted
|
||||
# advertisements can have junk in the lower nibble.
|
||||
device_role = flags & 0x0F
|
||||
if device_role > 4:
|
||||
device_role = 0
|
||||
has_location = bool(flags & 0x10)
|
||||
has_feature1 = bool(flags & 0x20)
|
||||
has_feature2 = bool(flags & 0x40)
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
"""Shared dependencies for FastAPI routers."""
|
||||
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
|
||||
def require_connected():
|
||||
"""Dependency that ensures radio is connected and returns meshcore instance."""
|
||||
return radio_manager.require_connected()
|
||||
@@ -30,8 +30,6 @@ logger = logging.getLogger(__name__)
|
||||
# Track active subscriptions so we can unsubscribe before re-registering
|
||||
# This prevents handler duplication after reconnects
|
||||
_active_subscriptions: list["Subscription"] = []
|
||||
_pending_acks = dm_ack_tracker._pending_acks
|
||||
_buffered_acks = dm_ack_tracker._buffered_acks
|
||||
|
||||
|
||||
def track_pending_ack(expected_ack: str, message_id: int, timeout_ms: int) -> bool:
|
||||
@@ -204,7 +202,6 @@ async def on_path_update(event: "Event") -> None:
|
||||
# Legacy firmware/library payloads only support 1-byte hop hashes.
|
||||
normalized_path_hash_mode = -1 if normalized_path_len == -1 else 0
|
||||
else:
|
||||
normalized_path_hash_mode = None
|
||||
try:
|
||||
normalized_path_hash_mode = int(path_hash_mode)
|
||||
except (TypeError, ValueError):
|
||||
|
||||
+2
-33
@@ -2,10 +2,10 @@
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Literal
|
||||
from typing import Any, Literal, NotRequired
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from app.models import Channel, Contact, Message, MessagePath, RawPacketBroadcast
|
||||
from app.routers.health import HealthResponse
|
||||
@@ -52,19 +52,6 @@ class ToastPayload(TypedDict):
|
||||
details: NotRequired[str]
|
||||
|
||||
|
||||
WsEventPayload = (
|
||||
HealthResponse
|
||||
| Message
|
||||
| Contact
|
||||
| ContactResolvedPayload
|
||||
| Channel
|
||||
| ContactDeletedPayload
|
||||
| ChannelDeletedPayload
|
||||
| RawPacketBroadcast
|
||||
| MessageAckedPayload
|
||||
| ToastPayload
|
||||
)
|
||||
|
||||
_PAYLOAD_ADAPTERS: dict[WsEventType, TypeAdapter[Any]] = {
|
||||
"health": TypeAdapter(HealthResponse),
|
||||
"message": TypeAdapter(Message),
|
||||
@@ -80,14 +67,6 @@ _PAYLOAD_ADAPTERS: dict[WsEventType, TypeAdapter[Any]] = {
|
||||
}
|
||||
|
||||
|
||||
def validate_ws_event_payload(event_type: str, data: Any) -> WsEventPayload | Any:
|
||||
"""Validate known WebSocket payloads; pass unknown events through unchanged."""
|
||||
adapter = _PAYLOAD_ADAPTERS.get(event_type) # type: ignore[arg-type]
|
||||
if adapter is None:
|
||||
return data
|
||||
return adapter.validate_python(data)
|
||||
|
||||
|
||||
def dump_ws_event(event_type: str, data: Any) -> str:
|
||||
"""Serialize a WebSocket event envelope with validation for known event types."""
|
||||
adapter = _PAYLOAD_ADAPTERS.get(event_type) # type: ignore[arg-type]
|
||||
@@ -104,13 +83,3 @@ def dump_ws_event(event_type: str, data: Any) -> str:
|
||||
event_type,
|
||||
)
|
||||
return json.dumps({"type": event_type, "data": data})
|
||||
|
||||
|
||||
def dump_ws_event_payload(event_type: str, data: Any) -> Any:
|
||||
"""Return the JSON-serializable payload for a WebSocket event."""
|
||||
adapter = _PAYLOAD_ADAPTERS.get(event_type) # type: ignore[arg-type]
|
||||
if adapter is None:
|
||||
return data
|
||||
|
||||
validated = adapter.validate_python(data)
|
||||
return adapter.dump_python(validated, mode="json")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Fanout Bus Architecture
|
||||
|
||||
The fanout bus is a unified system for dispatching mesh radio events (decoded messages and raw packets) to external integrations. It replaces the previous scattered singleton MQTT publishers with a modular, configurable framework.
|
||||
The fanout bus is a unified system for dispatching mesh radio events to external integrations. It replaces the previous scattered singleton MQTT publishers with a modular, configurable framework.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
@@ -8,10 +8,15 @@ The fanout bus is a unified system for dispatching mesh radio events (decoded me
|
||||
Base class that all integration modules extend:
|
||||
- `__init__(config_id, config, *, name="")` — constructor; receives the config UUID, the type-specific config dict, and the user-assigned name
|
||||
- `start()` / `stop()` — async lifecycle (e.g. open/close connections)
|
||||
- `on_message(data)` — receive decoded messages (DM/channel)
|
||||
- `on_raw(data)` — receive raw RF packets
|
||||
- `on_message(data)` — receive decoded messages (scope-gated)
|
||||
- `on_raw(data)` — receive raw RF packets (scope-gated)
|
||||
- `on_contact(data)` — receive contact upserts; dispatched to all modules
|
||||
- `on_telemetry(data)` — receive repeater telemetry snapshots; dispatched to all modules
|
||||
- `on_health(data)` — receive periodic radio health snapshots; dispatched to all modules
|
||||
- `status` property (**must override**) — return `"connected"`, `"disconnected"`, or `"error"`
|
||||
|
||||
All five event hooks are no-ops by default; modules override only the ones they care about.
|
||||
|
||||
### FanoutManager (manager.py)
|
||||
Singleton that owns all active modules and dispatches events:
|
||||
- `load_from_db()` — startup: load enabled configs, instantiate modules
|
||||
@@ -19,6 +24,9 @@ Singleton that owns all active modules and dispatches events:
|
||||
- `remove_config(id)` — delete: stop and remove
|
||||
- `broadcast_message(data)` — scope-check + dispatch `on_message`
|
||||
- `broadcast_raw(data)` — scope-check + dispatch `on_raw`
|
||||
- `broadcast_contact(data)` — dispatch `on_contact` to all modules
|
||||
- `broadcast_telemetry(data)` — dispatch `on_telemetry` to all modules
|
||||
- `broadcast_health_fanout(data)` — dispatch `on_health` to all modules
|
||||
- `stop_all()` — shutdown
|
||||
- `get_statuses()` — health endpoint data
|
||||
|
||||
@@ -33,19 +41,65 @@ Each config has a `scope` JSON blob controlling what events reach it:
|
||||
```
|
||||
Community MQTT always enforces `{"messages": "none", "raw_packets": "all"}`.
|
||||
|
||||
Scope only gates `on_message` and `on_raw`. The `on_contact`, `on_telemetry`, and `on_health` hooks are dispatched to all modules unconditionally — modules that care about specific contacts or repeaters filter internally based on their own config.
|
||||
|
||||
## Event Flow
|
||||
|
||||
```
|
||||
Radio Event -> packet_processor / event_handler
|
||||
-> broadcast_event("message"|"raw_packet", data, realtime=True)
|
||||
-> broadcast_event("message"|"raw_packet"|"contact", data, realtime=True)
|
||||
-> WebSocket broadcast (always)
|
||||
-> FanoutManager.broadcast_message/raw (only if realtime=True)
|
||||
-> scope check per module
|
||||
-> module.on_message / on_raw
|
||||
-> FanoutManager.broadcast_message/raw/contact (only if realtime=True)
|
||||
-> scope check per module (message/raw only)
|
||||
-> module.on_message / on_raw / on_contact
|
||||
|
||||
Telemetry collect (radio_sync.py / routers/repeaters.py)
|
||||
-> RepeaterTelemetryRepository.record(...)
|
||||
-> FanoutManager.broadcast_telemetry(data)
|
||||
-> module.on_telemetry (all modules, unconditional)
|
||||
|
||||
Health fanout (radio_stats.py, piggybacks on 60s stats sampling loop)
|
||||
-> FanoutManager.broadcast_health_fanout(data)
|
||||
-> module.on_health (all modules, unconditional)
|
||||
```
|
||||
|
||||
Setting `realtime=False` (used during historical decryption) skips fanout dispatch entirely.
|
||||
|
||||
## Event Payloads
|
||||
|
||||
### on_message(data)
|
||||
`Message.model_dump()` — the full Pydantic message model. Key fields:
|
||||
- `type` (`"PRIV"` | `"CHAN"`), `conversation_key`, `text`, `sender_name`, `sender_key`
|
||||
- `outgoing`, `acked`, `paths`, `sender_timestamp`, `received_at`
|
||||
|
||||
### on_raw(data)
|
||||
Raw packet dict from `packet_processor.py`. Key fields:
|
||||
- `id` (storage row ID), `observation_id` (per-arrival), `raw` (hex), `timestamp`
|
||||
- `decrypted_info` (optional: `channel_key`, `contact_key`, `text`)
|
||||
|
||||
### on_contact(data)
|
||||
`Contact.model_dump()` — the full Pydantic contact model. Key fields:
|
||||
- `public_key`, `name`, `type` (0=unknown, 1=client, 2=repeater, 3=room, 4=sensor)
|
||||
- `lat`, `lon`, `last_seen`, `first_seen`, `on_radio`
|
||||
|
||||
### on_telemetry(data)
|
||||
Repeater telemetry snapshot, broadcast after successful `RepeaterTelemetryRepository.record()`.
|
||||
Identical shape from both auto-collect (`radio_sync.py`) and manual fetch (`routers/repeaters.py`):
|
||||
- `public_key`, `name`, `timestamp`
|
||||
- `battery_volts`, `noise_floor_dbm`, `last_rssi_dbm`, `last_snr_db`
|
||||
- `packets_received`, `packets_sent`, `airtime_seconds`, `rx_airtime_seconds`
|
||||
- `uptime_seconds`, `sent_flood`, `sent_direct`, `recv_flood`, `recv_direct`
|
||||
- `flood_dups`, `direct_dups`, `full_events`, `tx_queue_len`
|
||||
|
||||
### on_health(data)
|
||||
Radio health + stats snapshot, broadcast every 60s by the stats sampling loop in `radio_stats.py`:
|
||||
- `connected` (bool), `connection_info` (str | None)
|
||||
- `public_key` (str | None), `name` (str | None)
|
||||
- `noise_floor_dbm`, `battery_mv`, `uptime_secs` (int | None)
|
||||
- `last_rssi` (int | None), `last_snr` (float | None)
|
||||
- `tx_air_secs`, `rx_air_secs` (int | None)
|
||||
- `packets_recv`, `packets_sent`, `flood_tx`, `direct_tx`, `flood_rx`, `direct_rx` (int | None)
|
||||
|
||||
## Current Module Types
|
||||
|
||||
### mqtt_private (mqtt_private.py)
|
||||
|
||||
@@ -38,6 +38,15 @@ class FanoutModule:
|
||||
async def on_raw(self, data: dict) -> None:
|
||||
"""Called for raw RF packets. Override if needed."""
|
||||
|
||||
async def on_contact(self, data: dict) -> None:
|
||||
"""Called for contact upserts (adverts, sync). Override if needed."""
|
||||
|
||||
async def on_telemetry(self, data: dict) -> None:
|
||||
"""Called for repeater telemetry snapshots. Override if needed."""
|
||||
|
||||
async def on_health(self, data: dict) -> None:
|
||||
"""Called for periodic radio health snapshots. Override if needed."""
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
"""Return 'connected', 'disconnected', or 'error'."""
|
||||
|
||||
+1
-1
@@ -164,7 +164,7 @@ class BotModule(FanoutModule):
|
||||
),
|
||||
timeout=BOT_EXECUTION_TIMEOUT,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
except TimeoutError:
|
||||
logger.warning("Bot '%s' execution timed out", self.name)
|
||||
return
|
||||
except Exception:
|
||||
|
||||
@@ -538,7 +538,7 @@ class CommunityMqttPublisher(BaseMqttPublisher):
|
||||
self._version_event.clear()
|
||||
try:
|
||||
await asyncio.wait_for(self._version_event.wait(), timeout=30)
|
||||
except asyncio.TimeoutError:
|
||||
except TimeoutError:
|
||||
pass
|
||||
return False
|
||||
return True
|
||||
|
||||
+33
-1
@@ -86,6 +86,11 @@ def _scope_matches_raw(scope: dict, _data: dict) -> bool:
|
||||
return scope.get("raw_packets", "none") == "all"
|
||||
|
||||
|
||||
def _always_match(_scope: dict, _data: dict) -> bool:
|
||||
"""Match all modules unconditionally (filtering is module-internal)."""
|
||||
return True
|
||||
|
||||
|
||||
class FanoutManager:
|
||||
"""Owns all active fanout modules and dispatches events."""
|
||||
|
||||
@@ -220,7 +225,7 @@ class FanoutManager:
|
||||
handler = getattr(module, handler_name)
|
||||
await asyncio.wait_for(handler(data), timeout=_DISPATCH_TIMEOUT_SECONDS)
|
||||
self._clear_module_error(config_id)
|
||||
except asyncio.TimeoutError:
|
||||
except TimeoutError:
|
||||
timeout_error = f"{handler_name} timed out after {_DISPATCH_TIMEOUT_SECONDS:.1f}s"
|
||||
self._set_module_error(config_id, timeout_error)
|
||||
logger.error(
|
||||
@@ -270,6 +275,33 @@ class FanoutManager:
|
||||
log_label="on_raw",
|
||||
)
|
||||
|
||||
async def broadcast_contact(self, data: dict) -> None:
|
||||
"""Dispatch a contact upsert to all modules."""
|
||||
await self._dispatch_matching(
|
||||
data,
|
||||
matcher=_always_match,
|
||||
handler_name="on_contact",
|
||||
log_label="on_contact",
|
||||
)
|
||||
|
||||
async def broadcast_telemetry(self, data: dict) -> None:
|
||||
"""Dispatch a repeater telemetry snapshot to all modules."""
|
||||
await self._dispatch_matching(
|
||||
data,
|
||||
matcher=_always_match,
|
||||
handler_name="on_telemetry",
|
||||
log_label="on_telemetry",
|
||||
)
|
||||
|
||||
async def broadcast_health_fanout(self, data: dict) -> None:
|
||||
"""Dispatch a radio health snapshot to all modules."""
|
||||
await self._dispatch_matching(
|
||||
data,
|
||||
matcher=_always_match,
|
||||
handler_name="on_health",
|
||||
log_label="on_health",
|
||||
)
|
||||
|
||||
async def stop_all(self) -> None:
|
||||
"""Shutdown all modules."""
|
||||
for config_id, (module, _) in list(self._modules.items()):
|
||||
|
||||
@@ -144,11 +144,8 @@ class MapUploadModule(FanoutModule):
|
||||
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.
|
||||
# Advert Ed25519 signature verification is intentionally skipped.
|
||||
# The radio validates packets before passing them to RT.
|
||||
|
||||
# Only process repeaters (2) and rooms (3) — any other role is rejected
|
||||
if advert.device_role not in _ALLOWED_DEVICE_ROLES:
|
||||
|
||||
+31
-2
@@ -12,6 +12,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
@@ -195,7 +196,7 @@ class BaseMqttPublisher(ABC):
|
||||
self._version_event.wait(),
|
||||
timeout=self._not_configured_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
except TimeoutError:
|
||||
continue
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
@@ -230,7 +231,7 @@ class BaseMqttPublisher(ABC):
|
||||
self._version_event.clear()
|
||||
try:
|
||||
await asyncio.wait_for(self._version_event.wait(), timeout=60)
|
||||
except asyncio.TimeoutError:
|
||||
except TimeoutError:
|
||||
elapsed = time.monotonic() - connect_time
|
||||
await self._on_periodic_wake(elapsed)
|
||||
if self._should_break_wait(elapsed):
|
||||
@@ -252,6 +253,34 @@ class BaseMqttPublisher(ABC):
|
||||
self._client = None
|
||||
self._last_error = _format_error_detail(e)
|
||||
|
||||
# Windows ProactorEventLoop does not implement add_reader /
|
||||
# add_writer, which paho-mqtt requires. The failure can
|
||||
# surface as a direct NotImplementedError (add_writer in
|
||||
# __aenter__) or as a generic timeout (add_reader fails
|
||||
# inside an event-loop callback, so paho never hears back).
|
||||
# Either way, if we're on Windows with Proactor the root
|
||||
# cause is the same and retrying won't help.
|
||||
_on_proactor = (
|
||||
sys.platform == "win32"
|
||||
and type(asyncio.get_event_loop()).__name__ == "ProactorEventLoop"
|
||||
)
|
||||
if _on_proactor:
|
||||
broadcast_error(
|
||||
"MQTT unavailable — Windows event loop incompatible",
|
||||
"The default Windows event loop (ProactorEventLoop) does "
|
||||
"not support MQTT. Add --loop none to your uvicorn "
|
||||
"command and restart. See README.md for details.",
|
||||
)
|
||||
_broadcast_health()
|
||||
logger.error(
|
||||
"%s cannot run: Windows ProactorEventLoop does not "
|
||||
"implement add_reader/add_writer required by paho-mqtt. "
|
||||
"Restart uvicorn with '--loop none' to use "
|
||||
"SelectorEventLoop instead. Giving up (will not retry).",
|
||||
self._integration_label(),
|
||||
)
|
||||
return
|
||||
|
||||
title, detail = self._on_error()
|
||||
broadcast_error(title, detail)
|
||||
_broadcast_health()
|
||||
|
||||
+31
-11
@@ -38,8 +38,17 @@ def _is_index_file(path: Path, index_file: Path) -> bool:
|
||||
return path == index_file
|
||||
|
||||
|
||||
def _resolve_request_origin(request: Request) -> str:
|
||||
"""Resolve the external origin, honoring common reverse-proxy headers."""
|
||||
def _resolve_request_base(request: Request) -> str:
|
||||
"""Resolve the external base URL, honoring common reverse-proxy headers.
|
||||
|
||||
Returns a URL like ``https://host:8000/meshcore/`` (always trailing-slash)
|
||||
so callers can append paths directly.
|
||||
|
||||
Recognized headers:
|
||||
- ``X-Forwarded-Proto`` + ``X-Forwarded-Host``: override scheme and host.
|
||||
- ``X-Forwarded-Prefix`` (or ``X-Forwarded-Path``): sub-path prefix added
|
||||
by the proxy (e.g. ``/meshcore``).
|
||||
"""
|
||||
forwarded_proto = request.headers.get("x-forwarded-proto")
|
||||
forwarded_host = request.headers.get("x-forwarded-host")
|
||||
|
||||
@@ -47,9 +56,20 @@ def _resolve_request_origin(request: Request) -> str:
|
||||
proto = forwarded_proto.split(",")[0].strip()
|
||||
host = forwarded_host.split(",")[0].strip()
|
||||
if proto and host:
|
||||
return f"{proto}://{host}"
|
||||
origin = f"{proto}://{host}"
|
||||
else:
|
||||
origin = str(request.base_url).rstrip("/")
|
||||
else:
|
||||
origin = str(request.base_url).rstrip("/")
|
||||
|
||||
return str(request.base_url).rstrip("/")
|
||||
# Sub-path prefix (e.g. /meshcore) communicated by the reverse proxy
|
||||
prefix = (
|
||||
(request.headers.get("x-forwarded-prefix") or request.headers.get("x-forwarded-path") or "")
|
||||
.strip()
|
||||
.rstrip("/")
|
||||
)
|
||||
|
||||
return f"{origin}{prefix}/"
|
||||
|
||||
|
||||
def _validate_frontend_dir(frontend_dir: Path, *, log_failures: bool = True) -> tuple[bool, Path]:
|
||||
@@ -103,27 +123,27 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
|
||||
|
||||
@app.get("/site.webmanifest")
|
||||
async def serve_webmanifest(request: Request):
|
||||
"""Serve a dynamic web manifest using the active request origin."""
|
||||
origin = _resolve_request_origin(request)
|
||||
"""Serve a dynamic web manifest using the active request base URL."""
|
||||
base = _resolve_request_base(request)
|
||||
manifest = {
|
||||
"name": "RemoteTerm for MeshCore",
|
||||
"short_name": "RemoteTerm",
|
||||
"id": f"{origin}/",
|
||||
"start_url": f"{origin}/",
|
||||
"scope": f"{origin}/",
|
||||
"id": base,
|
||||
"start_url": base,
|
||||
"scope": base,
|
||||
"display": "standalone",
|
||||
"display_override": ["window-controls-overlay", "standalone", "fullscreen"],
|
||||
"theme_color": "#111419",
|
||||
"background_color": "#111419",
|
||||
"icons": [
|
||||
{
|
||||
"src": f"{origin}/web-app-manifest-192x192.png",
|
||||
"src": f"{base}web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable",
|
||||
},
|
||||
{
|
||||
"src": f"{origin}/web-app-manifest-512x512.png",
|
||||
"src": f"{base}web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable",
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
NO_EVENT_RECEIVED_GUIDANCE = (
|
||||
"Radio command channel is unresponsive (no_event_received). Ensure that your firmware is not "
|
||||
"incompatible, outdated, or wrong-mode (e.g. repeater, not client), and that"
|
||||
"incompatible, outdated, or wrong-mode (e.g. repeater, not client), and that "
|
||||
"serial/TCP/BLE connectivity is successful (try another app and see if that one works?). The app cannot proceed because it cannot "
|
||||
"issue commands to the radio."
|
||||
)
|
||||
|
||||
+42
-1
@@ -1,5 +1,41 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Windows event-loop advisory for MQTT fanout
|
||||
# ---------------------------------------------------------------------------
|
||||
# On Windows, uvicorn's default event loop (ProactorEventLoop) does not
|
||||
# implement add_reader()/add_writer(), which paho-mqtt (via aiomqtt) requires.
|
||||
# We cannot fix this from inside the app — the loop is already created by the
|
||||
# time this module is imported. Log a prominent warning so Windows operators
|
||||
# who want MQTT know to add ``--loop none`` to their uvicorn command.
|
||||
# ---------------------------------------------------------------------------
|
||||
if sys.platform == "win32":
|
||||
import asyncio as _asyncio
|
||||
|
||||
_loop = _asyncio.get_event_loop()
|
||||
_is_proactor = type(_loop).__name__ == "ProactorEventLoop"
|
||||
if _is_proactor:
|
||||
print(
|
||||
"\n" + "!" * 78 + "\n"
|
||||
" NOTE FOR WINDOWS USERS\n" + "!" * 78 + "\n"
|
||||
"\n"
|
||||
" The running event loop is ProactorEventLoop, which is not\n"
|
||||
" compatible with MQTT fanout (aiomqtt / paho-mqtt).\n"
|
||||
"\n"
|
||||
" If you use MQTT integrations, restart with --loop none:\n"
|
||||
"\n"
|
||||
" uv run uvicorn app.main:app \033[1m--loop none\033[0m"
|
||||
" [... other options ...]\n"
|
||||
"\n"
|
||||
" Everything else works fine as-is.\n"
|
||||
"\n" + "!" * 78 + "\n",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
del _loop, _is_proactor
|
||||
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
@@ -21,6 +57,7 @@ from app.radio_sync import (
|
||||
stop_message_polling,
|
||||
stop_periodic_advert,
|
||||
stop_periodic_sync,
|
||||
stop_telemetry_collect,
|
||||
)
|
||||
from app.routers import (
|
||||
channels,
|
||||
@@ -40,6 +77,7 @@ from app.routers import (
|
||||
)
|
||||
from app.security import add_optional_basic_auth_middleware
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
from app.services.radio_stats import start_radio_stats_sampling, stop_radio_stats_sampling
|
||||
from app.version_info import get_app_build_info
|
||||
|
||||
setup_logging()
|
||||
@@ -70,6 +108,7 @@ async def lifespan(app: FastAPI):
|
||||
from app.radio_sync import ensure_default_channels
|
||||
|
||||
await ensure_default_channels()
|
||||
await start_radio_stats_sampling()
|
||||
|
||||
# Always start connection monitor (even if initial connection failed)
|
||||
await radio_manager.start_connection_monitor()
|
||||
@@ -98,8 +137,10 @@ async def lifespan(app: FastAPI):
|
||||
await radio_manager.stop_connection_monitor()
|
||||
await stop_background_contact_reconciliation()
|
||||
await stop_message_polling()
|
||||
await stop_radio_stats_sampling()
|
||||
await stop_periodic_advert()
|
||||
await stop_periodic_sync()
|
||||
await stop_telemetry_collect()
|
||||
if radio_manager.meshcore:
|
||||
await radio_manager.meshcore.stop_auto_message_fetching()
|
||||
await radio_manager.disconnect()
|
||||
|
||||
+491
-8
@@ -360,6 +360,71 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
|
||||
await set_version(conn, 46)
|
||||
applied += 1
|
||||
|
||||
# Migration 47: Add statistics indexes for time-windowed scans
|
||||
if version < 47:
|
||||
logger.info("Applying migration 47: add statistics indexes")
|
||||
await _migrate_047_add_statistics_indexes(conn)
|
||||
await set_version(conn, 47)
|
||||
applied += 1
|
||||
|
||||
# Migration 48: Add discovery_blocked_types column to app_settings
|
||||
if version < 48:
|
||||
logger.info("Applying migration 48: add discovery_blocked_types to app_settings")
|
||||
await _migrate_048_discovery_blocked_types(conn)
|
||||
await set_version(conn, 48)
|
||||
applied += 1
|
||||
|
||||
# Migration 49: Enable foreign key enforcement — rebuild tables with
|
||||
# CASCADE / SET NULL and clean up any orphaned rows first.
|
||||
if version < 49:
|
||||
logger.info("Applying migration 49: add foreign key cascade/set-null and clean orphans")
|
||||
await _migrate_049_foreign_key_cascade(conn)
|
||||
await set_version(conn, 49)
|
||||
applied += 1
|
||||
|
||||
# Migration 50: Repeater telemetry history table + tracking opt-in column
|
||||
if version < 50:
|
||||
logger.info("Applying migration 50: repeater telemetry history")
|
||||
await _migrate_050_repeater_telemetry_history(conn)
|
||||
await set_version(conn, 50)
|
||||
applied += 1
|
||||
|
||||
if version < 51:
|
||||
logger.info("Applying migration 51: drop sidebar_sort_order from app_settings")
|
||||
await _migrate_051_drop_sidebar_sort_order(conn)
|
||||
await set_version(conn, 51)
|
||||
applied += 1
|
||||
|
||||
if version < 52:
|
||||
logger.info("Applying migration 52: add path_hash_mode_override to channels")
|
||||
await _migrate_052_add_channel_path_hash_mode_override(conn)
|
||||
await set_version(conn, 52)
|
||||
applied += 1
|
||||
|
||||
if version < 53:
|
||||
logger.info("Applying migration 53: add tracked_telemetry_repeaters to app_settings")
|
||||
await _migrate_053_tracked_telemetry_repeaters(conn)
|
||||
await set_version(conn, 53)
|
||||
applied += 1
|
||||
|
||||
if version < 54:
|
||||
logger.info("Applying migration 54: add auto_resend_channel to app_settings")
|
||||
await _migrate_054_auto_resend_channel(conn)
|
||||
await set_version(conn, 54)
|
||||
applied += 1
|
||||
|
||||
if version < 55:
|
||||
logger.info("Applying migration 55: move favorites to per-entity columns")
|
||||
await _migrate_055_favorites_to_columns(conn)
|
||||
await set_version(conn, 55)
|
||||
applied += 1
|
||||
|
||||
if version < 56:
|
||||
logger.info("Applying migration 56: add sender_key to incoming PRIV dedup index")
|
||||
await _migrate_056_priv_dedup_include_sender_key(conn)
|
||||
await set_version(conn, 56)
|
||||
applied += 1
|
||||
|
||||
if applied > 0:
|
||||
logger.info(
|
||||
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
|
||||
@@ -822,7 +887,7 @@ async def _migrate_009_create_app_settings_table(conn: aiosqlite.Connection) ->
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
max_radio_contacts INTEGER DEFAULT 200,
|
||||
favorites TEXT DEFAULT '[]',
|
||||
auto_decrypt_dm_on_advert INTEGER DEFAULT 0,
|
||||
auto_decrypt_dm_on_advert INTEGER DEFAULT 1,
|
||||
sidebar_sort_order TEXT DEFAULT 'recent',
|
||||
last_message_times TEXT DEFAULT '{}',
|
||||
preferences_migrated INTEGER DEFAULT 0
|
||||
@@ -830,13 +895,9 @@ async def _migrate_009_create_app_settings_table(conn: aiosqlite.Connection) ->
|
||||
"""
|
||||
)
|
||||
|
||||
# Initialize with default row
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO app_settings (id, max_radio_contacts, favorites, auto_decrypt_dm_on_advert, sidebar_sort_order, last_message_times, preferences_migrated)
|
||||
VALUES (1, 200, '[]', 0, 'recent', '{}', 0)
|
||||
"""
|
||||
)
|
||||
# Initialize with default row (use only the id column so this works
|
||||
# regardless of which columns exist — defaults fill the rest).
|
||||
await conn.execute("INSERT OR IGNORE INTO app_settings (id) VALUES (1)")
|
||||
|
||||
await conn.commit()
|
||||
logger.debug("Created app_settings table with default values")
|
||||
@@ -2868,3 +2929,425 @@ async def _migrate_046_cleanup_orphaned_contact_child_rows(conn: aiosqlite.Conne
|
||||
)
|
||||
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def _migrate_047_add_statistics_indexes(conn: aiosqlite.Connection) -> None:
|
||||
"""Add indexes used by the statistics endpoint's time-windowed scans."""
|
||||
cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
tables = {row[0] for row in await cursor.fetchall()}
|
||||
|
||||
if "raw_packets" in tables:
|
||||
cursor = await conn.execute("PRAGMA table_info(raw_packets)")
|
||||
raw_packet_columns = {row[1] for row in await cursor.fetchall()}
|
||||
if "timestamp" in raw_packet_columns:
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_raw_packets_timestamp ON raw_packets(timestamp)"
|
||||
)
|
||||
|
||||
if "contacts" in tables:
|
||||
cursor = await conn.execute("PRAGMA table_info(contacts)")
|
||||
contact_columns = {row[1] for row in await cursor.fetchall()}
|
||||
if {"type", "last_seen"}.issubset(contact_columns):
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_contacts_type_last_seen ON contacts(type, last_seen)"
|
||||
)
|
||||
|
||||
if "messages" in tables:
|
||||
cursor = await conn.execute("PRAGMA table_info(messages)")
|
||||
message_columns = {row[1] for row in await cursor.fetchall()}
|
||||
if {"type", "received_at", "conversation_key"}.issubset(message_columns):
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_type_received_conversation
|
||||
ON messages(type, received_at, conversation_key)
|
||||
"""
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def _migrate_048_discovery_blocked_types(conn: aiosqlite.Connection) -> None:
|
||||
"""Add discovery_blocked_types column to app_settings.
|
||||
|
||||
Stores a JSON array of integer contact type codes (1=Client, 2=Repeater,
|
||||
3=Room, 4=Sensor) whose advertisements should not create new contacts.
|
||||
Empty list means all types are accepted.
|
||||
"""
|
||||
try:
|
||||
await conn.execute(
|
||||
"ALTER TABLE app_settings ADD COLUMN discovery_blocked_types TEXT DEFAULT '[]'"
|
||||
)
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
if "duplicate column" in error_msg:
|
||||
logger.debug("discovery_blocked_types column already exists, skipping")
|
||||
elif "no such table" in error_msg:
|
||||
logger.debug("app_settings table not ready, skipping discovery_blocked_types migration")
|
||||
else:
|
||||
raise
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def _migrate_049_foreign_key_cascade(conn: aiosqlite.Connection) -> None:
|
||||
"""Rebuild FK tables with CASCADE/SET NULL and clean orphaned rows.
|
||||
|
||||
SQLite cannot ALTER existing FK constraints, so each table is rebuilt.
|
||||
Orphaned child rows are cleaned up before the rebuild to ensure the
|
||||
INSERT...SELECT into the new table (which has enforced FKs) succeeds.
|
||||
"""
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
# Back up the database before table rebuilds (skip for in-memory DBs).
|
||||
cursor = await conn.execute("PRAGMA database_list")
|
||||
db_row = await cursor.fetchone()
|
||||
db_path = db_row[2] if db_row else ""
|
||||
if db_path and db_path != ":memory:" and Path(db_path).exists():
|
||||
backup_path = db_path + ".pre-fk-migration.bak"
|
||||
for suffix in ("", "-wal", "-shm"):
|
||||
src = Path(db_path + suffix)
|
||||
if src.exists():
|
||||
shutil.copy2(str(src), backup_path + suffix)
|
||||
logger.info("Database backed up to %s before FK migration", backup_path)
|
||||
|
||||
# --- Phase 1: clean orphans (guard each table's existence) ---
|
||||
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
existing_tables = {row[0] for row in await tables_cursor.fetchall()}
|
||||
|
||||
if "contact_advert_paths" in existing_tables and "contacts" in existing_tables:
|
||||
await conn.execute(
|
||||
"DELETE FROM contact_advert_paths "
|
||||
"WHERE public_key NOT IN (SELECT public_key FROM contacts)"
|
||||
)
|
||||
if "contact_name_history" in existing_tables and "contacts" in existing_tables:
|
||||
await conn.execute(
|
||||
"DELETE FROM contact_name_history "
|
||||
"WHERE public_key NOT IN (SELECT public_key FROM contacts)"
|
||||
)
|
||||
if "raw_packets" in existing_tables and "messages" in existing_tables:
|
||||
# Guard: message_id column may not exist on very old schemas
|
||||
col_cursor = await conn.execute("PRAGMA table_info(raw_packets)")
|
||||
raw_cols = {row[1] for row in await col_cursor.fetchall()}
|
||||
if "message_id" in raw_cols:
|
||||
await conn.execute(
|
||||
"UPDATE raw_packets SET message_id = NULL WHERE message_id IS NOT NULL "
|
||||
"AND message_id NOT IN (SELECT id FROM messages)"
|
||||
)
|
||||
await conn.commit()
|
||||
logger.debug("Cleaned orphaned child rows before FK rebuild")
|
||||
|
||||
# --- Phase 2: rebuild raw_packets with ON DELETE SET NULL ---
|
||||
# Skip if raw_packets doesn't have message_id (pre-migration-18 schema)
|
||||
raw_has_message_id = False
|
||||
if "raw_packets" in existing_tables:
|
||||
col_cursor2 = await conn.execute("PRAGMA table_info(raw_packets)")
|
||||
raw_has_message_id = "message_id" in {row[1] for row in await col_cursor2.fetchall()}
|
||||
|
||||
if raw_has_message_id:
|
||||
# Dynamically build column list based on what the old table actually has,
|
||||
# since very old schemas may lack payload_hash (added in migration 28).
|
||||
col_cursor3 = await conn.execute("PRAGMA table_info(raw_packets)")
|
||||
old_cols = [row[1] for row in await col_cursor3.fetchall()]
|
||||
|
||||
new_col_defs = [
|
||||
"id INTEGER PRIMARY KEY AUTOINCREMENT",
|
||||
"timestamp INTEGER NOT NULL",
|
||||
"data BLOB NOT NULL",
|
||||
"message_id INTEGER",
|
||||
]
|
||||
copy_cols = ["id", "timestamp", "data", "message_id"]
|
||||
if "payload_hash" in old_cols:
|
||||
new_col_defs.append("payload_hash BLOB")
|
||||
copy_cols.append("payload_hash")
|
||||
new_col_defs.append("FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE SET NULL")
|
||||
|
||||
cols_sql = ", ".join(new_col_defs)
|
||||
copy_sql = ", ".join(copy_cols)
|
||||
await conn.execute(f"CREATE TABLE raw_packets_fk ({cols_sql})")
|
||||
await conn.execute(
|
||||
f"INSERT INTO raw_packets_fk ({copy_sql}) SELECT {copy_sql} FROM raw_packets"
|
||||
)
|
||||
await conn.execute("DROP TABLE raw_packets")
|
||||
await conn.execute("ALTER TABLE raw_packets_fk RENAME TO raw_packets")
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_raw_packets_message_id ON raw_packets(message_id)"
|
||||
)
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_raw_packets_timestamp ON raw_packets(timestamp)"
|
||||
)
|
||||
if "payload_hash" in old_cols:
|
||||
await conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_packets_payload_hash ON raw_packets(payload_hash)"
|
||||
)
|
||||
await conn.commit()
|
||||
logger.debug("Rebuilt raw_packets with ON DELETE SET NULL")
|
||||
|
||||
# --- Phase 3: rebuild contact_advert_paths with ON DELETE CASCADE ---
|
||||
if "contact_advert_paths" in existing_tables:
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE contact_advert_paths_fk (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
public_key TEXT NOT NULL,
|
||||
path_hex TEXT NOT NULL,
|
||||
path_len INTEGER NOT NULL,
|
||||
first_seen INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
heard_count INTEGER NOT NULL DEFAULT 1,
|
||||
UNIQUE(public_key, path_hex, path_len),
|
||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
await conn.execute(
|
||||
"INSERT INTO contact_advert_paths_fk (id, public_key, path_hex, path_len, first_seen, last_seen, heard_count) "
|
||||
"SELECT id, public_key, path_hex, path_len, first_seen, last_seen, heard_count FROM contact_advert_paths"
|
||||
)
|
||||
await conn.execute("DROP TABLE contact_advert_paths")
|
||||
await conn.execute("ALTER TABLE contact_advert_paths_fk RENAME TO contact_advert_paths")
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_contact_advert_paths_recent "
|
||||
"ON contact_advert_paths(public_key, last_seen DESC)"
|
||||
)
|
||||
await conn.commit()
|
||||
logger.debug("Rebuilt contact_advert_paths with ON DELETE CASCADE")
|
||||
|
||||
# --- Phase 4: rebuild contact_name_history with ON DELETE CASCADE ---
|
||||
if "contact_name_history" in existing_tables:
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE contact_name_history_fk (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
public_key TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
first_seen INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
UNIQUE(public_key, name),
|
||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
await conn.execute(
|
||||
"INSERT INTO contact_name_history_fk (id, public_key, name, first_seen, last_seen) "
|
||||
"SELECT id, public_key, name, first_seen, last_seen FROM contact_name_history"
|
||||
)
|
||||
await conn.execute("DROP TABLE contact_name_history")
|
||||
await conn.execute("ALTER TABLE contact_name_history_fk RENAME TO contact_name_history")
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_contact_name_history_key "
|
||||
"ON contact_name_history(public_key, last_seen DESC)"
|
||||
)
|
||||
await conn.commit()
|
||||
logger.debug("Rebuilt contact_name_history with ON DELETE CASCADE")
|
||||
|
||||
|
||||
async def _migrate_050_repeater_telemetry_history(conn: aiosqlite.Connection) -> None:
|
||||
"""Create repeater_telemetry_history table for JSON-blob telemetry snapshots."""
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS repeater_telemetry_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
public_key TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_repeater_telemetry_pk_ts
|
||||
ON repeater_telemetry_history (public_key, timestamp)
|
||||
"""
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def _migrate_051_drop_sidebar_sort_order(conn: aiosqlite.Connection) -> None:
|
||||
"""Remove vestigial sidebar_sort_order column from app_settings."""
|
||||
col_cursor = await conn.execute("PRAGMA table_info(app_settings)")
|
||||
columns = {row[1] for row in await col_cursor.fetchall()}
|
||||
if "sidebar_sort_order" in columns:
|
||||
try:
|
||||
await conn.execute("ALTER TABLE app_settings DROP COLUMN sidebar_sort_order")
|
||||
await conn.commit()
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
if "syntax error" in error_msg or "drop column" in error_msg:
|
||||
logger.debug(
|
||||
"SQLite doesn't support DROP COLUMN, sidebar_sort_order column will remain"
|
||||
)
|
||||
await conn.commit()
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
async def _migrate_052_add_channel_path_hash_mode_override(conn: aiosqlite.Connection) -> None:
|
||||
"""Add nullable per-channel path hash mode override column."""
|
||||
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
if "channels" not in {row[0] for row in await tables_cursor.fetchall()}:
|
||||
await conn.commit()
|
||||
return
|
||||
try:
|
||||
await conn.execute("ALTER TABLE channels ADD COLUMN path_hash_mode_override INTEGER")
|
||||
await conn.commit()
|
||||
except Exception as e:
|
||||
if "duplicate column" in str(e).lower():
|
||||
await conn.commit()
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
async def _migrate_053_tracked_telemetry_repeaters(conn: aiosqlite.Connection) -> None:
|
||||
"""Add tracked_telemetry_repeaters JSON list column to app_settings."""
|
||||
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
if "app_settings" not in {row[0] for row in await tables_cursor.fetchall()}:
|
||||
await conn.commit()
|
||||
return
|
||||
col_cursor = await conn.execute("PRAGMA table_info(app_settings)")
|
||||
columns = {row[1] for row in await col_cursor.fetchall()}
|
||||
if "tracked_telemetry_repeaters" not in columns:
|
||||
await conn.execute(
|
||||
"ALTER TABLE app_settings ADD COLUMN tracked_telemetry_repeaters TEXT DEFAULT '[]'"
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def _migrate_054_auto_resend_channel(conn: aiosqlite.Connection) -> None:
|
||||
"""Add auto_resend_channel boolean column to app_settings."""
|
||||
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
if "app_settings" not in {row[0] for row in await tables_cursor.fetchall()}:
|
||||
await conn.commit()
|
||||
return
|
||||
col_cursor = await conn.execute("PRAGMA table_info(app_settings)")
|
||||
columns = {row[1] for row in await col_cursor.fetchall()}
|
||||
if "auto_resend_channel" not in columns:
|
||||
await conn.execute(
|
||||
"ALTER TABLE app_settings ADD COLUMN auto_resend_channel INTEGER DEFAULT 0"
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def _migrate_055_favorites_to_columns(conn: aiosqlite.Connection) -> None:
|
||||
"""Move favorites from app_settings JSON blob to per-entity boolean columns.
|
||||
|
||||
1. Add ``favorite`` column to contacts and channels tables.
|
||||
2. Backfill from the ``app_settings.favorites`` JSON array.
|
||||
3. Drop the ``favorites`` column from app_settings.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
# --- Add columns ---
|
||||
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
existing_tables = {row[0] for row in await tables_cursor.fetchall()}
|
||||
for table in ("contacts", "channels"):
|
||||
if table not in existing_tables:
|
||||
continue
|
||||
col_cursor = await conn.execute(f"PRAGMA table_info({table})")
|
||||
columns = {row[1] for row in await col_cursor.fetchall()}
|
||||
if "favorite" not in columns:
|
||||
await conn.execute(f"ALTER TABLE {table} ADD COLUMN favorite INTEGER DEFAULT 0")
|
||||
await conn.commit()
|
||||
|
||||
# --- Backfill from JSON ---
|
||||
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
if "app_settings" not in {row[0] for row in await tables_cursor.fetchall()}:
|
||||
await conn.commit()
|
||||
return
|
||||
|
||||
col_cursor = await conn.execute("PRAGMA table_info(app_settings)")
|
||||
settings_columns = {row[1] for row in await col_cursor.fetchall()}
|
||||
if "favorites" not in settings_columns:
|
||||
await conn.commit()
|
||||
return
|
||||
|
||||
cursor = await conn.execute("SELECT favorites FROM app_settings WHERE id = 1")
|
||||
row = await cursor.fetchone()
|
||||
if row and row[0]:
|
||||
try:
|
||||
favorites = _json.loads(row[0])
|
||||
except (ValueError, TypeError):
|
||||
favorites = []
|
||||
|
||||
contact_keys = []
|
||||
channel_keys = []
|
||||
for fav in favorites:
|
||||
if not isinstance(fav, dict):
|
||||
continue
|
||||
fav_type = fav.get("type")
|
||||
fav_id = fav.get("id")
|
||||
if not fav_id:
|
||||
continue
|
||||
if fav_type == "contact":
|
||||
contact_keys.append(fav_id)
|
||||
elif fav_type == "channel":
|
||||
channel_keys.append(fav_id)
|
||||
|
||||
if contact_keys:
|
||||
placeholders = ",".join("?" for _ in contact_keys)
|
||||
await conn.execute(
|
||||
f"UPDATE contacts SET favorite = 1 WHERE public_key IN ({placeholders})",
|
||||
contact_keys,
|
||||
)
|
||||
if channel_keys:
|
||||
placeholders = ",".join("?" for _ in channel_keys)
|
||||
await conn.execute(
|
||||
f"UPDATE channels SET favorite = 1 WHERE key IN ({placeholders})",
|
||||
channel_keys,
|
||||
)
|
||||
if contact_keys or channel_keys:
|
||||
logger.info(
|
||||
"Backfilled %d contact favorite(s) and %d channel favorite(s) from app_settings",
|
||||
len(contact_keys),
|
||||
len(channel_keys),
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
# --- Drop the JSON column ---
|
||||
try:
|
||||
await conn.execute("ALTER TABLE app_settings DROP COLUMN favorites")
|
||||
await conn.commit()
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
if "syntax error" in error_msg or "drop column" in error_msg:
|
||||
logger.debug("SQLite doesn't support DROP COLUMN; favorites column will remain unused")
|
||||
await conn.commit()
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
async def _migrate_056_priv_dedup_include_sender_key(conn: aiosqlite.Connection) -> None:
|
||||
"""Add sender_key to the incoming PRIV dedup index.
|
||||
|
||||
Room-server posts are stored as PRIV messages sharing one conversation_key
|
||||
(the room contact). Without sender_key in the uniqueness constraint, two
|
||||
different room participants sending identical text in the same clock second
|
||||
collide and the second message is silently dropped.
|
||||
|
||||
Adding COALESCE(sender_key, '') is strictly more permissive — no existing
|
||||
rows can conflict — so the migration only needs to rebuild the index.
|
||||
"""
|
||||
cursor = await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='messages'"
|
||||
)
|
||||
if await cursor.fetchone() is None:
|
||||
await conn.commit()
|
||||
return
|
||||
|
||||
# The index references type, conversation_key, sender_timestamp, outgoing,
|
||||
# and sender_key. Some migration tests create minimal messages tables that
|
||||
# lack these columns. Skip gracefully when the schema is too old.
|
||||
col_cursor = await conn.execute("PRAGMA table_info(messages)")
|
||||
columns = {row[1] for row in await col_cursor.fetchall()}
|
||||
required = {"type", "conversation_key", "sender_timestamp", "outgoing", "sender_key"}
|
||||
if not required.issubset(columns):
|
||||
await conn.commit()
|
||||
return
|
||||
|
||||
await conn.execute("DROP INDEX IF EXISTS idx_messages_incoming_priv_dedup")
|
||||
await conn.execute(
|
||||
"""CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_incoming_priv_dedup
|
||||
ON messages(type, conversation_key, text, COALESCE(sender_timestamp, 0),
|
||||
COALESCE(sender_key, ''))
|
||||
WHERE type = 'PRIV' AND outgoing = 0"""
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
+148
-76
@@ -4,6 +4,10 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.path_utils import normalize_contact_route, normalize_route_override
|
||||
|
||||
# Valid MeshCore contact types: 0=unknown, 1=client, 2=repeater, 3=room, 4=sensor.
|
||||
# Corrupted radio data can produce values outside this range.
|
||||
_VALID_CONTACT_TYPES = frozenset({0, 1, 2, 3, 4})
|
||||
|
||||
|
||||
class ContactRoute(BaseModel):
|
||||
"""A normalized contact route."""
|
||||
@@ -59,16 +63,30 @@ class ContactUpsert(BaseModel):
|
||||
-1 if radio_data.get("out_path_len", -1) == -1 else 0,
|
||||
),
|
||||
)
|
||||
# Clamp invalid contact types to 0 (unknown) — corrupted radio data
|
||||
# can produce values like 111 or 240 that break downstream branching.
|
||||
raw_type = radio_data.get("type", 0)
|
||||
contact_type = raw_type if raw_type in _VALID_CONTACT_TYPES else 0
|
||||
|
||||
# Null out impossible coordinates — the contact is still ingested,
|
||||
# but garbage lat/lon (e.g. 1953.7) is discarded rather than stored.
|
||||
lat = radio_data.get("adv_lat")
|
||||
lon = radio_data.get("adv_lon")
|
||||
if lat is not None and not (-90 <= lat <= 90):
|
||||
lat = None
|
||||
if lon is not None and not (-180 <= lon <= 180):
|
||||
lon = None
|
||||
|
||||
return cls(
|
||||
public_key=public_key,
|
||||
name=radio_data.get("adv_name"),
|
||||
type=radio_data.get("type", 0),
|
||||
type=contact_type,
|
||||
flags=radio_data.get("flags", 0),
|
||||
direct_path=direct_path,
|
||||
direct_path_len=direct_path_len,
|
||||
direct_path_hash_mode=direct_path_hash_mode,
|
||||
lat=radio_data.get("adv_lat"),
|
||||
lon=radio_data.get("adv_lon"),
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
last_advert=radio_data.get("last_advert"),
|
||||
on_radio=on_radio,
|
||||
)
|
||||
@@ -91,6 +109,7 @@ class Contact(BaseModel):
|
||||
lon: float | None = None
|
||||
last_seen: int | None = None
|
||||
on_radio: bool = False
|
||||
favorite: bool = False
|
||||
last_contacted: int | None = None # Last time we sent/received a message
|
||||
last_read_at: int | None = None # Server-side read state tracking
|
||||
first_seen: int | None = None
|
||||
@@ -196,15 +215,6 @@ class Contact(BaseModel):
|
||||
"""Convert the stored contact to the repository's write contract."""
|
||||
return ContactUpsert.from_contact(self, **changes)
|
||||
|
||||
@staticmethod
|
||||
def from_radio_dict(public_key: str, radio_data: dict, on_radio: bool = False) -> dict:
|
||||
"""Backward-compatible dict wrapper over ContactUpsert.from_radio_dict()."""
|
||||
return ContactUpsert.from_radio_dict(
|
||||
public_key,
|
||||
radio_data,
|
||||
on_radio=on_radio,
|
||||
).model_dump()
|
||||
|
||||
|
||||
class CreateContactRequest(BaseModel):
|
||||
"""Request to create a new contact."""
|
||||
@@ -283,30 +293,6 @@ class NearestRepeater(BaseModel):
|
||||
heard_count: int
|
||||
|
||||
|
||||
class ContactDetail(BaseModel):
|
||||
"""Comprehensive contact profile data."""
|
||||
|
||||
contact: Contact
|
||||
name_history: list[ContactNameHistory] = Field(default_factory=list)
|
||||
dm_message_count: int = 0
|
||||
channel_message_count: int = 0
|
||||
most_active_rooms: list[ContactActiveRoom] = Field(default_factory=list)
|
||||
advert_paths: list[ContactAdvertPath] = Field(default_factory=list)
|
||||
advert_frequency: float | None = Field(
|
||||
default=None,
|
||||
description="Advert observations per hour (includes multi-path arrivals of same advert)",
|
||||
)
|
||||
nearest_repeaters: list[NearestRepeater] = Field(default_factory=list)
|
||||
|
||||
|
||||
class NameOnlyContactDetail(BaseModel):
|
||||
"""Channel activity summary for a sender name that is not tied to a known key."""
|
||||
|
||||
name: str
|
||||
channel_message_count: int = 0
|
||||
most_active_rooms: list[ContactActiveRoom] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ContactAnalyticsHourlyBucket(BaseModel):
|
||||
"""A single hourly activity bucket for contact analytics."""
|
||||
|
||||
@@ -354,7 +340,12 @@ class Channel(BaseModel):
|
||||
default=None,
|
||||
description="Per-channel outbound flood scope override (null = use global app setting)",
|
||||
)
|
||||
path_hash_mode_override: int | None = Field(
|
||||
default=None,
|
||||
description="Per-channel path hash mode override (0=1-byte, 1=2-byte, 2=3-byte, null = use radio default)",
|
||||
)
|
||||
last_read_at: int | None = None # Server-side read state tracking
|
||||
favorite: bool = False
|
||||
|
||||
|
||||
class ChannelMessageCounts(BaseModel):
|
||||
@@ -375,6 +366,18 @@ class ChannelTopSender(BaseModel):
|
||||
message_count: int
|
||||
|
||||
|
||||
class PathHashWidthStats(BaseModel):
|
||||
"""Hop byte width distribution for parsed raw packets."""
|
||||
|
||||
total_packets: int = 0
|
||||
single_byte: int = 0
|
||||
double_byte: int = 0
|
||||
triple_byte: int = 0
|
||||
single_byte_pct: float = 0.0
|
||||
double_byte_pct: float = 0.0
|
||||
triple_byte_pct: float = 0.0
|
||||
|
||||
|
||||
class ChannelDetail(BaseModel):
|
||||
"""Comprehensive channel profile data."""
|
||||
|
||||
@@ -383,6 +386,7 @@ class ChannelDetail(BaseModel):
|
||||
first_message_at: int | None = None
|
||||
unique_sender_count: int = 0
|
||||
top_senders_24h: list[ChannelTopSender] = Field(default_factory=list)
|
||||
path_hash_width_24h: PathHashWidthStats = Field(default_factory=PathHashWidthStats)
|
||||
|
||||
|
||||
class MessagePath(BaseModel):
|
||||
@@ -394,6 +398,8 @@ class MessagePath(BaseModel):
|
||||
default=None,
|
||||
description="Hop count. None = legacy (infer as len(path)//2, i.e. 1-byte hops)",
|
||||
)
|
||||
rssi: int | None = Field(default=None, description="Last-hop RSSI in dBm")
|
||||
snr: float | None = Field(default=None, description="Last-hop SNR in dB")
|
||||
|
||||
|
||||
class Message(BaseModel):
|
||||
@@ -530,6 +536,9 @@ class RepeaterStatusResponse(BaseModel):
|
||||
flood_dups: int = Field(description="Duplicate flood packets")
|
||||
direct_dups: int = Field(description="Duplicate direct packets")
|
||||
full_events: int = Field(description="Full event queue count")
|
||||
telemetry_history: list["TelemetryHistoryEntry"] = Field(
|
||||
default_factory=list, description="Recent telemetry history snapshots"
|
||||
)
|
||||
|
||||
|
||||
class RepeaterNodeInfoResponse(BaseModel):
|
||||
@@ -628,6 +637,59 @@ class TraceResponse(BaseModel):
|
||||
path_len: int = Field(description="Number of hops in the trace path")
|
||||
|
||||
|
||||
class RadioTraceHopRequest(BaseModel):
|
||||
"""One requested hop in a radio trace path."""
|
||||
|
||||
public_key: str | None = Field(
|
||||
default=None,
|
||||
description="Full repeater public key when this hop maps to a known repeater",
|
||||
)
|
||||
hop_hex: str | None = Field(
|
||||
default=None,
|
||||
description="Raw hop hash hex when using a custom repeater prefix",
|
||||
)
|
||||
|
||||
|
||||
class RadioTraceRequest(BaseModel):
|
||||
"""Ordered trace path for a radio trace loop."""
|
||||
|
||||
hop_hash_bytes: Literal[1, 2, 4] = Field(
|
||||
default=4,
|
||||
description="Hash width in bytes for every hop in this trace path",
|
||||
)
|
||||
hops: list[RadioTraceHopRequest] = Field(
|
||||
min_length=1,
|
||||
description="Ordered repeater hops, using either known repeater keys or custom hop hex",
|
||||
)
|
||||
|
||||
|
||||
class RadioTraceNode(BaseModel):
|
||||
"""One resolved node in a radio trace result."""
|
||||
|
||||
role: Literal["repeater", "custom", "local"] = Field(description="Node role in the trace")
|
||||
public_key: str | None = Field(
|
||||
default=None,
|
||||
description="Resolved full public key for this node when known",
|
||||
)
|
||||
name: str | None = Field(default=None, description="Display name for this node when known")
|
||||
observed_hash: str | None = Field(
|
||||
default=None,
|
||||
description="Observed 4-byte trace hash for this node as hex",
|
||||
)
|
||||
snr: float | None = Field(default=None, description="Reported SNR for this node in dB")
|
||||
|
||||
|
||||
class RadioTraceResponse(BaseModel):
|
||||
"""Resolved multi-hop radio trace result."""
|
||||
|
||||
path_len: int = Field(description="Number of hashed nodes returned by the trace response")
|
||||
timeout_seconds: float = Field(description="Timeout window used while waiting for the trace")
|
||||
nodes: list[RadioTraceNode] = Field(
|
||||
default_factory=list,
|
||||
description="Ordered trace nodes: repeater hops followed by the terminal local radio",
|
||||
)
|
||||
|
||||
|
||||
class PathDiscoveryRoute(BaseModel):
|
||||
"""One resolved route returned by contact path discovery."""
|
||||
|
||||
@@ -681,6 +743,10 @@ class RadioDiscoveryResult(BaseModel):
|
||||
"""One mesh node heard during a discovery sweep."""
|
||||
|
||||
public_key: str = Field(description="Discovered node public key as hex")
|
||||
name: str | None = Field(
|
||||
default=None,
|
||||
description="Known name for this node from contacts DB, if any",
|
||||
)
|
||||
node_type: Literal["repeater", "sensor"] = Field(description="Discovered node class")
|
||||
heard_count: int = Field(default=1, description="How many responses were heard from this node")
|
||||
local_snr: float | None = Field(
|
||||
@@ -710,13 +776,6 @@ class RadioDiscoveryResponse(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class Favorite(BaseModel):
|
||||
"""A favorite conversation."""
|
||||
|
||||
type: Literal["channel", "contact"] = Field(description="'channel' or 'contact'")
|
||||
id: str = Field(description="Channel key or contact public key")
|
||||
|
||||
|
||||
class UnreadCounts(BaseModel):
|
||||
"""Aggregated unread counts, mention flags, and last message times for all conversations."""
|
||||
|
||||
@@ -744,25 +803,14 @@ class AppSettings(BaseModel):
|
||||
"favorites reload first, then background fill targets about 80% of this value"
|
||||
),
|
||||
)
|
||||
favorites: list[Favorite] = Field(
|
||||
default_factory=list, description="List of favorited conversations"
|
||||
)
|
||||
auto_decrypt_dm_on_advert: bool = Field(
|
||||
default=False,
|
||||
default=True,
|
||||
description="Whether to attempt historical DM decryption on new contact advertisement",
|
||||
)
|
||||
sidebar_sort_order: Literal["recent", "alpha"] = Field(
|
||||
default="recent",
|
||||
description="Sidebar sort order: 'recent' or 'alpha'",
|
||||
)
|
||||
last_message_times: dict[str, int] = Field(
|
||||
default_factory=dict,
|
||||
description="Map of conversation state keys to last message timestamps",
|
||||
)
|
||||
preferences_migrated: bool = Field(
|
||||
default=False,
|
||||
description="Whether preferences have been migrated from localStorage",
|
||||
)
|
||||
advert_interval: int = Field(
|
||||
default=0,
|
||||
description="Periodic advertisement interval in seconds (0 = disabled)",
|
||||
@@ -783,19 +831,24 @@ class AppSettings(BaseModel):
|
||||
default_factory=list,
|
||||
description="Display names whose messages are hidden from the UI",
|
||||
)
|
||||
|
||||
|
||||
class FanoutConfig(BaseModel):
|
||||
"""Configuration for a single fanout integration."""
|
||||
|
||||
id: str
|
||||
type: str # 'mqtt_private' | 'mqtt_community' | 'bot' | 'webhook' | 'apprise' | 'sqs'
|
||||
name: str
|
||||
enabled: bool
|
||||
config: dict
|
||||
scope: dict
|
||||
sort_order: int = 0
|
||||
created_at: int = 0
|
||||
discovery_blocked_types: list[int] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"Contact type codes (1=Client, 2=Repeater, 3=Room, 4=Sensor) whose "
|
||||
"advertisements should not create new contacts; existing contacts are still updated"
|
||||
),
|
||||
)
|
||||
tracked_telemetry_repeaters: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Public keys of repeaters opted into periodic telemetry collection (max 8)",
|
||||
)
|
||||
auto_resend_channel: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"When enabled, outgoing channel messages that receive no echo within 2 seconds "
|
||||
"are automatically byte-perfect resent once (within the 30-second dedup window)"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class BusyChannel(BaseModel):
|
||||
@@ -810,14 +863,26 @@ class ContactActivityCounts(BaseModel):
|
||||
last_week: int
|
||||
|
||||
|
||||
class PathHashWidthStats(BaseModel):
|
||||
total_packets: int
|
||||
single_byte: int
|
||||
double_byte: int
|
||||
triple_byte: int
|
||||
single_byte_pct: float
|
||||
double_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"
|
||||
)
|
||||
samples: list[NoiseFloorSample] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PacketsPerHourBucket(BaseModel):
|
||||
timestamp: int = Field(description="Unix timestamp at the start of the hour")
|
||||
count: int = Field(description="Number of packets received in that hour")
|
||||
|
||||
|
||||
class StatisticsResponse(BaseModel):
|
||||
@@ -835,3 +900,10 @@ class StatisticsResponse(BaseModel):
|
||||
repeaters_heard: ContactActivityCounts
|
||||
known_channels_active: ContactActivityCounts
|
||||
path_hash_width_24h: PathHashWidthStats
|
||||
packets_per_hour_72h: list[PacketsPerHourBucket]
|
||||
noise_floor_24h: NoiseFloorHistoryStats
|
||||
|
||||
|
||||
class TelemetryHistoryEntry(BaseModel):
|
||||
timestamp: int
|
||||
data: dict
|
||||
|
||||
+86
-24
@@ -39,6 +39,7 @@ from app.repository import (
|
||||
ChannelRepository,
|
||||
ContactAdvertPathRepository,
|
||||
ContactRepository,
|
||||
MessageRepository,
|
||||
RawPacketRepository,
|
||||
)
|
||||
from app.services.contact_reconciliation import (
|
||||
@@ -68,6 +69,8 @@ async def create_message_from_decrypted(
|
||||
received_at: int | None = None,
|
||||
path: str | None = None,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
channel_name: str | None = None,
|
||||
realtime: bool = True,
|
||||
) -> int | None:
|
||||
@@ -81,6 +84,8 @@ async def create_message_from_decrypted(
|
||||
received_at=received_at,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
channel_name=channel_name,
|
||||
realtime=realtime,
|
||||
broadcast_fn=broadcast_event,
|
||||
@@ -95,6 +100,8 @@ async def create_dm_message_from_decrypted(
|
||||
received_at: int | None = None,
|
||||
path: str | None = None,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
outgoing: bool = False,
|
||||
realtime: bool = True,
|
||||
) -> int | None:
|
||||
@@ -107,6 +114,8 @@ async def create_dm_message_from_decrypted(
|
||||
received_at=received_at,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
outgoing=outgoing,
|
||||
realtime=realtime,
|
||||
broadcast_fn=broadcast_event,
|
||||
@@ -122,20 +131,20 @@ async def run_historical_dm_decryption(
|
||||
"""Background task to decrypt historical DM packets with contact's key."""
|
||||
from app.websocket import broadcast_success
|
||||
|
||||
packets = await RawPacketRepository.get_undecrypted_text_messages()
|
||||
total = len(packets)
|
||||
total = 0
|
||||
decrypted_count = 0
|
||||
|
||||
if total == 0:
|
||||
logger.info("No undecrypted TEXT_MESSAGE packets to process")
|
||||
return
|
||||
|
||||
logger.info("Starting historical DM decryption of %d TEXT_MESSAGE packets", total)
|
||||
logger.info("Starting historical DM decryption scan for undecrypted TEXT_MESSAGE packets")
|
||||
|
||||
# Derive our public key from the private key
|
||||
our_public_key_bytes = derive_public_key(private_key_bytes)
|
||||
|
||||
for packet_id, packet_data, packet_timestamp in packets:
|
||||
async for (
|
||||
packet_id,
|
||||
packet_data,
|
||||
packet_timestamp,
|
||||
) in RawPacketRepository.stream_undecrypted_text_messages():
|
||||
total += 1
|
||||
# Note: passing our_public_key=None disables the outbound hash check in
|
||||
# try_decrypt_dm (only the inbound check src_hash == their_first_byte runs).
|
||||
# For the 255/256 case where our first byte differs from the contact's,
|
||||
@@ -187,6 +196,10 @@ async def run_historical_dm_decryption(
|
||||
if msg_id is not None:
|
||||
decrypted_count += 1
|
||||
|
||||
if total == 0:
|
||||
logger.info("No undecrypted TEXT_MESSAGE packets to process")
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Historical DM decryption complete: %d/%d packets decrypted",
|
||||
decrypted_count,
|
||||
@@ -264,9 +277,10 @@ async def process_raw_packet(
|
||||
This is the main entry point for all incoming RF packets.
|
||||
|
||||
Note: Packets are deduplicated by payload hash in the database. If we receive
|
||||
a duplicate packet (same payload, different path), we still broadcast it to
|
||||
the frontend (for the real-time packet feed) but skip decryption processing
|
||||
since the original packet was already processed.
|
||||
a duplicate payload (same payload, different path), we still broadcast it to
|
||||
the frontend for realtime packet-feed fidelity. Some payload types are also
|
||||
intentionally reprocessed on duplicate arrival so message-level dedup/path
|
||||
merge logic and advert/path-history tracking still see each observation.
|
||||
"""
|
||||
ts = timestamp or int(time.time())
|
||||
observation_id = next(_raw_observation_counter)
|
||||
@@ -314,7 +328,9 @@ async def process_raw_packet(
|
||||
# deduplication in create_message_from_decrypted handles adding paths to existing messages.
|
||||
# This is more reliable than trying to look up the message via raw packet linking.
|
||||
if payload_type == PayloadType.GROUP_TEXT:
|
||||
decrypt_result = await _process_group_text(raw_bytes, packet_id, ts, packet_info)
|
||||
decrypt_result = await _process_group_text(
|
||||
raw_bytes, packet_id, ts, packet_info, rssi=rssi, snr=snr
|
||||
)
|
||||
if decrypt_result:
|
||||
result.update(decrypt_result)
|
||||
|
||||
@@ -325,7 +341,9 @@ async def process_raw_packet(
|
||||
|
||||
elif payload_type == PayloadType.TEXT_MESSAGE:
|
||||
# Try to decrypt direct messages using stored private key and known contacts
|
||||
decrypt_result = await _process_direct_message(raw_bytes, packet_id, ts, packet_info)
|
||||
decrypt_result = await _process_direct_message(
|
||||
raw_bytes, packet_id, ts, packet_info, rssi=rssi, snr=snr
|
||||
)
|
||||
if decrypt_result:
|
||||
result.update(decrypt_result)
|
||||
|
||||
@@ -362,6 +380,8 @@ async def _process_group_text(
|
||||
packet_id: int,
|
||||
timestamp: int,
|
||||
packet_info: PacketInfo | None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
) -> dict | None:
|
||||
"""
|
||||
Process a GroupText (channel message) packet.
|
||||
@@ -398,6 +418,8 @@ async def _process_group_text(
|
||||
received_at=timestamp,
|
||||
path=packet_info.path.hex() if packet_info else None,
|
||||
path_len=packet_info.path_length if packet_info else None,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -457,14 +479,19 @@ async def _process_advertisement(
|
||||
advert.device_role if advert.device_role > 0 else (existing.type if existing else 0)
|
||||
)
|
||||
|
||||
# Keep recent unique advert paths for all contacts.
|
||||
await ContactAdvertPathRepository.record_observation(
|
||||
public_key=advert.public_key.lower(),
|
||||
path_hex=new_path_hex,
|
||||
timestamp=timestamp,
|
||||
max_paths=10,
|
||||
hop_count=new_path_len,
|
||||
)
|
||||
# Check discovery_blocked_types: skip new contacts whose type is blocked.
|
||||
# Existing contacts are always updated (location, name, last_seen, etc.).
|
||||
if existing is None and contact_type > 0:
|
||||
from app.repository import AppSettingsRepository
|
||||
|
||||
settings = await AppSettingsRepository.get()
|
||||
if contact_type in settings.discovery_blocked_types:
|
||||
logger.debug(
|
||||
"Skipping new contact %s: type %d is in discovery_blocked_types",
|
||||
advert.public_key[:12],
|
||||
contact_type,
|
||||
)
|
||||
return
|
||||
|
||||
contact_upsert = ContactUpsert(
|
||||
public_key=advert.public_key.lower(),
|
||||
@@ -477,7 +504,18 @@ async def _process_advertisement(
|
||||
first_seen=timestamp, # COALESCE in upsert preserves existing value
|
||||
)
|
||||
|
||||
# Upsert the contact BEFORE recording advert paths so the parent row
|
||||
# exists when foreign key enforcement is enabled.
|
||||
await ContactRepository.upsert(contact_upsert)
|
||||
|
||||
# Keep recent unique advert paths for all contacts.
|
||||
await ContactAdvertPathRepository.record_observation(
|
||||
public_key=advert.public_key.lower(),
|
||||
path_hex=new_path_hex,
|
||||
timestamp=timestamp,
|
||||
max_paths=10,
|
||||
hop_count=new_path_len,
|
||||
)
|
||||
promoted_keys = await promote_prefix_contacts_for_contact(
|
||||
public_key=advert.public_key,
|
||||
log=logger,
|
||||
@@ -523,6 +561,8 @@ async def _process_direct_message(
|
||||
packet_id: int,
|
||||
timestamp: int,
|
||||
packet_info: PacketInfo | None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
) -> dict | None:
|
||||
"""
|
||||
Process a TEXT_MESSAGE (direct message) packet.
|
||||
@@ -606,10 +646,30 @@ async def _process_direct_message(
|
||||
)
|
||||
|
||||
if result is not None:
|
||||
# Successfully decrypted!
|
||||
# In the ambiguous direction case (both first bytes match), we
|
||||
# defaulted to incoming. Check if a matching outgoing message
|
||||
# already exists — if so, this is actually our own outgoing echo
|
||||
# and should be treated as such instead of creating a duplicate
|
||||
# incoming row.
|
||||
effective_outgoing = is_outgoing
|
||||
if not is_outgoing and dest_hash == src_hash:
|
||||
existing_outgoing = await MessageRepository.get_by_content(
|
||||
msg_type="PRIV",
|
||||
conversation_key=contact.public_key.lower(),
|
||||
text=result.message,
|
||||
sender_timestamp=result.timestamp,
|
||||
outgoing=True,
|
||||
)
|
||||
if existing_outgoing is not None:
|
||||
effective_outgoing = True
|
||||
logger.debug(
|
||||
"Ambiguous DM resolved as outgoing echo (matched existing sent msg %d)",
|
||||
existing_outgoing.id,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Decrypted DM %s contact %s: %s",
|
||||
"to" if is_outgoing else "from",
|
||||
"to" if effective_outgoing else "from",
|
||||
contact.name or contact.public_key[:12],
|
||||
result.message[:50] if result.message else "",
|
||||
)
|
||||
@@ -623,7 +683,9 @@ async def _process_direct_message(
|
||||
received_at=timestamp,
|
||||
path=packet_info.path.hex() if packet_info else None,
|
||||
path_len=packet_info.path_length if packet_info else None,
|
||||
outgoing=is_outgoing,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
outgoing=effective_outgoing,
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -244,3 +244,51 @@ def parse_explicit_hop_route(route_text: str) -> tuple[str, int, int]:
|
||||
raise ValueError(f"Explicit path exceeds MAX_PATH_SIZE={MAX_PATH_SIZE} bytes")
|
||||
|
||||
return "".join(hops), len(hops), hash_size - 1
|
||||
|
||||
|
||||
async def bucket_path_hash_widths(cursor, *, batch_size: int = 500) -> dict[str, int | float]:
|
||||
"""Bucket raw packet rows by hop hash width and return counts + percentages.
|
||||
|
||||
*cursor* must be an already-executed async cursor whose rows have a ``data``
|
||||
column containing raw packet bytes.
|
||||
"""
|
||||
single_byte = 0
|
||||
double_byte = 0
|
||||
triple_byte = 0
|
||||
|
||||
while True:
|
||||
rows = await cursor.fetchmany(batch_size)
|
||||
if not rows:
|
||||
break
|
||||
for row in rows:
|
||||
envelope = parse_packet_envelope(bytes(row["data"]))
|
||||
if envelope is None:
|
||||
continue
|
||||
if envelope.hash_size == 1:
|
||||
single_byte += 1
|
||||
elif envelope.hash_size == 2:
|
||||
double_byte += 1
|
||||
elif envelope.hash_size == 3:
|
||||
triple_byte += 1
|
||||
|
||||
total = single_byte + double_byte + triple_byte
|
||||
if total == 0:
|
||||
return {
|
||||
"total_packets": 0,
|
||||
"single_byte": 0,
|
||||
"double_byte": 0,
|
||||
"triple_byte": 0,
|
||||
"single_byte_pct": 0.0,
|
||||
"double_byte_pct": 0.0,
|
||||
"triple_byte_pct": 0.0,
|
||||
}
|
||||
|
||||
return {
|
||||
"total_packets": total,
|
||||
"single_byte": single_byte,
|
||||
"double_byte": double_byte,
|
||||
"triple_byte": triple_byte,
|
||||
"single_byte_pct": (single_byte / total) * 100,
|
||||
"double_byte_pct": (double_byte / total) * 100,
|
||||
"triple_byte_pct": (triple_byte / total) * 100,
|
||||
}
|
||||
|
||||
+4
-1
@@ -118,7 +118,7 @@ async def test_serial_device(port: str, baudrate: int, timeout: float = 3.0) ->
|
||||
return True
|
||||
|
||||
return False
|
||||
except asyncio.TimeoutError:
|
||||
except TimeoutError:
|
||||
logger.debug("Device %s timed out", port)
|
||||
return False
|
||||
except Exception as e:
|
||||
@@ -192,6 +192,9 @@ class RadioManager:
|
||||
if not blocking:
|
||||
if self._operation_lock.locked():
|
||||
raise RadioOperationBusyError(f"Radio is busy (operation: {name})")
|
||||
# In single-threaded asyncio the lock cannot be acquired between the
|
||||
# check above and the await below (no other coroutine runs until we
|
||||
# yield). The await returns immediately for an uncontested lock.
|
||||
await self._operation_lock.acquire()
|
||||
else:
|
||||
await self._operation_lock.acquire()
|
||||
|
||||
+266
-100
@@ -20,16 +20,20 @@ from meshcore import EventType, MeshCore
|
||||
|
||||
from app.channel_constants import PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME
|
||||
from app.config import settings
|
||||
from app.event_handlers import cleanup_expired_acks
|
||||
from app.models import Contact, ContactUpsert
|
||||
from app.event_handlers import cleanup_expired_acks, on_contact_message
|
||||
from app.models import _VALID_CONTACT_TYPES, Contact, ContactUpsert
|
||||
from app.radio import RadioOperationBusyError
|
||||
from app.repository import (
|
||||
AmbiguousPublicKeyPrefixError,
|
||||
AppSettingsRepository,
|
||||
ChannelRepository,
|
||||
ContactRepository,
|
||||
RepeaterTelemetryRepository,
|
||||
)
|
||||
from app.services.contact_reconciliation import (
|
||||
promote_prefix_contacts_for_contact,
|
||||
reconcile_contact_messages,
|
||||
)
|
||||
from app.services.contact_reconciliation import reconcile_contact_messages
|
||||
from app.services.messages import create_fallback_channel_message
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
from app.websocket import broadcast_error, broadcast_event
|
||||
@@ -63,13 +67,25 @@ async def _reconcile_contact_messages_background(
|
||||
public_key: str,
|
||||
contact_name: str | None,
|
||||
) -> None:
|
||||
"""Run contact/message reconciliation outside the radio critical path."""
|
||||
"""Run prefix promotion and contact/message reconciliation outside the radio critical path."""
|
||||
try:
|
||||
promoted_keys = await promote_prefix_contacts_for_contact(
|
||||
public_key=public_key,
|
||||
log=logger,
|
||||
)
|
||||
await reconcile_contact_messages(
|
||||
public_key=public_key,
|
||||
contact_name=contact_name,
|
||||
log=logger,
|
||||
)
|
||||
if promoted_keys:
|
||||
contact = await ContactRepository.get_by_key(public_key.lower())
|
||||
if contact is not None:
|
||||
for old_key in promoted_keys:
|
||||
broadcast_event(
|
||||
"contact_resolved",
|
||||
{"previous_public_key": old_key, "contact": contact.model_dump()},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Background contact reconciliation failed for %s: %s",
|
||||
@@ -140,6 +156,15 @@ ADVERT_CHECK_INTERVAL = 60
|
||||
# more frequently than this.
|
||||
MIN_ADVERT_INTERVAL = 3600
|
||||
|
||||
# Periodic telemetry collection task handle
|
||||
_telemetry_collect_task: asyncio.Task | None = None
|
||||
|
||||
# Telemetry collection interval (8 hours)
|
||||
TELEMETRY_COLLECT_INTERVAL = 8 * 3600
|
||||
|
||||
# Initial delay before the first telemetry collection cycle (let radio settle)
|
||||
TELEMETRY_COLLECT_INITIAL_DELAY = 60
|
||||
|
||||
# Counter to pause polling during repeater operations (supports nested pauses)
|
||||
_polling_pause_count: int = 0
|
||||
|
||||
@@ -179,6 +204,22 @@ RADIO_CONTACT_REFILL_RATIO = 0.80
|
||||
RADIO_CONTACT_FULL_SYNC_RATIO = 0.95
|
||||
|
||||
|
||||
def _effective_radio_capacity(configured: int) -> int:
|
||||
"""Return the effective radio contact capacity.
|
||||
|
||||
Uses the lower of the user-configured ``max_radio_contacts`` and the
|
||||
hardware limit reported by the radio at connect time. The existing
|
||||
80% refill ratio already reserves headroom for the radio to
|
||||
organically add contacts it hears via adverts, so no additional
|
||||
reduction is applied here.
|
||||
"""
|
||||
capacity = max(1, configured)
|
||||
hw_limit = radio_manager.max_contacts
|
||||
if hw_limit is not None:
|
||||
capacity = min(capacity, hw_limit)
|
||||
return max(1, capacity)
|
||||
|
||||
|
||||
def _compute_radio_contact_limits(max_contacts: int) -> tuple[int, int]:
|
||||
"""Return (refill_target, full_sync_trigger) for the configured capacity."""
|
||||
capacity = max(1, max_contacts)
|
||||
@@ -193,7 +234,7 @@ def _compute_radio_contact_limits(max_contacts: int) -> tuple[int, int]:
|
||||
async def should_run_full_periodic_sync(mc: MeshCore) -> bool:
|
||||
"""Check current radio occupancy and decide whether to offload/reload."""
|
||||
app_settings = await AppSettingsRepository.get()
|
||||
capacity = app_settings.max_radio_contacts
|
||||
capacity = _effective_radio_capacity(app_settings.max_radio_contacts)
|
||||
refill_target, full_sync_trigger = _compute_radio_contact_limits(capacity)
|
||||
|
||||
result = await mc.commands.get_contacts()
|
||||
@@ -222,70 +263,6 @@ async def should_run_full_periodic_sync(mc: MeshCore) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def sync_and_offload_contacts(mc: MeshCore) -> dict:
|
||||
"""
|
||||
Sync contacts from radio to database, then remove them from radio.
|
||||
Returns counts of synced and removed contacts.
|
||||
"""
|
||||
synced = 0
|
||||
removed = 0
|
||||
|
||||
try:
|
||||
# Get all contacts from radio
|
||||
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, "removed": 0, "error": str(result)}
|
||||
|
||||
contacts = result.payload or {}
|
||||
logger.info("Found %d contacts on radio", len(contacts))
|
||||
|
||||
# Sync each contact to database, then remove from radio
|
||||
for public_key, contact_data in contacts.items():
|
||||
# Save to database
|
||||
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
|
||||
|
||||
# Remove from radio
|
||||
try:
|
||||
remove_result = await mc.commands.remove_contact(contact_data)
|
||||
if remove_result.type == EventType.OK:
|
||||
removed += 1
|
||||
_evict_removed_contact_from_library_cache(mc, public_key)
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to remove contact %s: %s", public_key[:12], remove_result.payload
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Error removing contact %s: %s", public_key[:12], e)
|
||||
|
||||
logger.info("Synced %d contacts, removed %d from radio", synced, removed)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error during contact sync: %s", e)
|
||||
return {"synced": synced, "removed": removed, "error": str(e)}
|
||||
|
||||
return {"synced": synced, "removed": removed}
|
||||
|
||||
|
||||
async def sync_and_offload_channels(mc: MeshCore, max_channels: int | None = None) -> dict:
|
||||
"""
|
||||
Sync channels from radio to database, then clear them from radio.
|
||||
@@ -330,7 +307,7 @@ async def sync_and_offload_channels(mc: MeshCore, max_channels: int | None = Non
|
||||
except Exception as e:
|
||||
logger.warning("Error clearing channel %d: %s", idx, e)
|
||||
|
||||
logger.info("Synced %d channels, cleared %d from radio", synced, cleared)
|
||||
logger.debug("Synced %d channels, cleared %d from radio", synced, cleared)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error during channel sync: %s", e)
|
||||
@@ -379,6 +356,14 @@ async def _resolve_channel_for_pending_message(
|
||||
return cached_key, channel.name if channel else None
|
||||
|
||||
|
||||
async def _store_pending_direct_message(event) -> None:
|
||||
"""Route a CONTACT_MSG_RECV event pulled via get_msg() through the DM ingest path."""
|
||||
try:
|
||||
await on_contact_message(event)
|
||||
except Exception:
|
||||
logger.warning("Failed to store pending direct message", exc_info=True)
|
||||
|
||||
|
||||
async def _store_pending_channel_message(mc: MeshCore, payload: dict) -> None:
|
||||
"""Persist a CHANNEL_MSG_RECV event pulled via get_msg()."""
|
||||
channel_idx = payload.get("channel_idx")
|
||||
@@ -403,7 +388,8 @@ async def _store_pending_channel_message(mc: MeshCore, payload: dict) -> None:
|
||||
return
|
||||
|
||||
received_at = int(time.time())
|
||||
sender_timestamp = payload.get("sender_timestamp") or received_at
|
||||
ts = payload.get("sender_timestamp")
|
||||
sender_timestamp = ts if ts is not None else received_at
|
||||
sender_name, message_text = _split_channel_sender_and_text(payload.get("text", ""))
|
||||
|
||||
await create_fallback_channel_message(
|
||||
@@ -442,7 +428,6 @@ async def ensure_default_channels() -> None:
|
||||
|
||||
async def sync_and_offload_all(mc: MeshCore) -> dict:
|
||||
"""Run fast startup sync, then background contact reconcile."""
|
||||
logger.info("Starting full radio sync and offload")
|
||||
|
||||
# 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.
|
||||
@@ -488,12 +473,14 @@ async def drain_pending_messages(mc: MeshCore) -> int:
|
||||
elif result.type in (EventType.CONTACT_MSG_RECV, EventType.CHANNEL_MSG_RECV):
|
||||
if result.type == EventType.CHANNEL_MSG_RECV:
|
||||
await _store_pending_channel_message(mc, result.payload)
|
||||
elif result.type == EventType.CONTACT_MSG_RECV:
|
||||
await _store_pending_direct_message(result)
|
||||
count += 1
|
||||
|
||||
# Small delay between fetches
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
except TimeoutError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning("Error draining messages: %s", e, exc_info=True)
|
||||
@@ -525,11 +512,13 @@ async def poll_for_messages(mc: MeshCore) -> int:
|
||||
elif result.type in (EventType.CONTACT_MSG_RECV, EventType.CHANNEL_MSG_RECV):
|
||||
if result.type == EventType.CHANNEL_MSG_RECV:
|
||||
await _store_pending_channel_message(mc, result.payload)
|
||||
elif result.type == EventType.CONTACT_MSG_RECV:
|
||||
await _store_pending_direct_message(result)
|
||||
count += 1
|
||||
# If we got a message, there might be more - drain them
|
||||
count += await drain_pending_messages(mc)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
except TimeoutError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning("Message poll exception: %s", e, exc_info=True)
|
||||
@@ -954,10 +943,8 @@ async def sync_radio_time(mc: MeshCore) -> bool:
|
||||
except Exception:
|
||||
logger.warning("Reboot command failed", exc_info=True)
|
||||
elif _clock_reboot_attempted:
|
||||
logger.warning(
|
||||
"Clock skew persists after reboot — the radio likely has a "
|
||||
"hardware RTC that preserved the wrong time. A manual "
|
||||
"'clkreboot' CLI command is needed to reset it."
|
||||
logger.debug(
|
||||
"Clock skew persists after reboot (hardware RTC); ignoring until next session."
|
||||
)
|
||||
|
||||
return False
|
||||
@@ -1018,6 +1005,7 @@ _last_contact_sync: float = 0.0
|
||||
CONTACT_SYNC_THROTTLE_SECONDS = 30 # Don't sync more than once per 30 seconds
|
||||
CONTACT_RECONCILE_BATCH_SIZE = 2
|
||||
CONTACT_RECONCILE_YIELD_SECONDS = 0.05
|
||||
CONTACT_RECONCILE_BUSY_BACKOFF_SECONDS = 2.0
|
||||
|
||||
|
||||
def _evict_removed_contact_from_library_cache(mc: MeshCore, public_key: str) -> None:
|
||||
@@ -1066,7 +1054,7 @@ async def sync_contacts_from_radio(mc: MeshCore) -> dict:
|
||||
return {"synced": 0, "radio_contacts": {}, "error": str(result)}
|
||||
|
||||
contacts = _normalize_radio_contacts_payload(result.payload)
|
||||
logger.info("Found %d contacts on radio", len(contacts))
|
||||
logger.debug("Found %d contacts on radio", len(contacts))
|
||||
|
||||
for public_key, contact_data in contacts.items():
|
||||
await ContactRepository.upsert(
|
||||
@@ -1080,7 +1068,29 @@ async def sync_contacts_from_radio(mc: MeshCore) -> dict:
|
||||
)
|
||||
synced += 1
|
||||
|
||||
logger.info("Synced %d contacts from radio snapshot", synced)
|
||||
logger.debug("Synced %d contacts from radio snapshot", synced)
|
||||
|
||||
# Import radio-favorited contacts into app favorites.
|
||||
# Only trust the favorite bit on contacts with a valid type (0-4);
|
||||
# garbled radio data can have junk flags with bit 0 set.
|
||||
radio_fav_keys = [
|
||||
pk
|
||||
for pk, data in contacts.items()
|
||||
if data.get("flags", 0) & 0x01 and data.get("type", -1) in _VALID_CONTACT_TYPES
|
||||
]
|
||||
if radio_fav_keys:
|
||||
try:
|
||||
imported = 0
|
||||
for pk in radio_fav_keys:
|
||||
existing = await ContactRepository.get_by_key(pk)
|
||||
if existing and not existing.favorite:
|
||||
await ContactRepository.set_favorite(pk, True)
|
||||
imported += 1
|
||||
if imported:
|
||||
logger.info("Imported %d radio favorite(s) into app favorites", imported)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to import radio favorites: %s", e)
|
||||
|
||||
return {"synced": synced, "radio_contacts": contacts}
|
||||
except Exception as e:
|
||||
logger.error("Error during contact snapshot sync: %s", e)
|
||||
@@ -1227,6 +1237,8 @@ async def _reconcile_radio_contacts_in_background(
|
||||
)
|
||||
except RadioOperationBusyError:
|
||||
logger.debug("Background contact reconcile yielding: radio busy")
|
||||
await asyncio.sleep(CONTACT_RECONCILE_BUSY_BACKOFF_SECONDS)
|
||||
continue
|
||||
|
||||
await asyncio.sleep(CONTACT_RECONCILE_YIELD_SECONDS)
|
||||
if not progressed:
|
||||
@@ -1285,31 +1297,14 @@ async def stop_background_contact_reconciliation() -> None:
|
||||
async def get_contacts_selected_for_radio_sync() -> list[Contact]:
|
||||
"""Return the contacts that would be loaded onto the radio right now."""
|
||||
app_settings = await AppSettingsRepository.get()
|
||||
max_contacts = app_settings.max_radio_contacts
|
||||
max_contacts = _effective_radio_capacity(app_settings.max_radio_contacts)
|
||||
refill_target, _full_sync_trigger = _compute_radio_contact_limits(max_contacts)
|
||||
selected_contacts: list[Contact] = []
|
||||
selected_keys: set[str] = set()
|
||||
|
||||
# Favorites first — always loaded up to max_contacts
|
||||
favorite_contacts_loaded = 0
|
||||
for favorite in app_settings.favorites:
|
||||
if favorite.type != "contact":
|
||||
continue
|
||||
try:
|
||||
contact = await ContactRepository.get_by_key_or_prefix(favorite.id)
|
||||
except AmbiguousPublicKeyPrefixError:
|
||||
logger.warning(
|
||||
"Skipping favorite contact '%s': ambiguous key prefix; use full key",
|
||||
favorite.id,
|
||||
)
|
||||
continue
|
||||
if not contact:
|
||||
continue
|
||||
if len(contact.public_key) < 64:
|
||||
logger.debug(
|
||||
"Skipping unresolved prefix-only favorite contact '%s' for radio sync",
|
||||
favorite.id,
|
||||
)
|
||||
continue
|
||||
for contact in await ContactRepository.get_favorites():
|
||||
key = contact.public_key.lower()
|
||||
if key in selected_keys:
|
||||
continue
|
||||
@@ -1541,3 +1536,174 @@ async def sync_recent_contacts_to_radio(force: bool = False, mc: MeshCore | None
|
||||
except Exception as e:
|
||||
logger.error("Error syncing contacts to radio: %s", e, exc_info=True)
|
||||
return {"loaded": 0, "error": str(e)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Periodic repeater telemetry collection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _collect_repeater_telemetry(mc: MeshCore, contact: Contact) -> bool:
|
||||
"""Fetch status telemetry from a single repeater and record it.
|
||||
|
||||
Returns True on success, False on failure (logged, not raised).
|
||||
"""
|
||||
try:
|
||||
await mc.commands.add_contact(contact.to_radio_dict())
|
||||
status = await mc.commands.req_status_sync(contact.public_key, timeout=10, min_timeout=5)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Telemetry collect: radio command failed for %s: %s",
|
||||
contact.public_key[:12],
|
||||
e,
|
||||
)
|
||||
return False
|
||||
|
||||
if status is None:
|
||||
logger.debug("Telemetry collect: no response from %s", contact.public_key[:12])
|
||||
return False
|
||||
|
||||
# Map to the same field names as the manual repeater status endpoint
|
||||
data = {
|
||||
"battery_volts": status.get("bat", 0) / 1000.0,
|
||||
"tx_queue_len": status.get("tx_queue_len", 0),
|
||||
"noise_floor_dbm": status.get("noise_floor", 0),
|
||||
"last_rssi_dbm": status.get("last_rssi", 0),
|
||||
"last_snr_db": status.get("last_snr", 0.0),
|
||||
"packets_received": status.get("nb_recv", 0),
|
||||
"packets_sent": status.get("nb_sent", 0),
|
||||
"airtime_seconds": status.get("airtime", 0),
|
||||
"rx_airtime_seconds": status.get("rx_airtime", 0),
|
||||
"uptime_seconds": status.get("uptime", 0),
|
||||
"sent_flood": status.get("sent_flood", 0),
|
||||
"sent_direct": status.get("sent_direct", 0),
|
||||
"recv_flood": status.get("recv_flood", 0),
|
||||
"recv_direct": status.get("recv_direct", 0),
|
||||
"flood_dups": status.get("flood_dups", 0),
|
||||
"direct_dups": status.get("direct_dups", 0),
|
||||
"full_events": status.get("full_evts", 0),
|
||||
}
|
||||
|
||||
try:
|
||||
timestamp = int(time.time())
|
||||
await RepeaterTelemetryRepository.record(
|
||||
public_key=contact.public_key,
|
||||
timestamp=timestamp,
|
||||
data=data,
|
||||
)
|
||||
logger.info(
|
||||
"Telemetry collect: recorded snapshot for %s (%s)",
|
||||
contact.name or contact.public_key[:12],
|
||||
contact.public_key[:12],
|
||||
)
|
||||
|
||||
# Dispatch to fanout modules (e.g. HA MQTT discovery)
|
||||
from app.fanout.manager import fanout_manager
|
||||
|
||||
asyncio.create_task(
|
||||
fanout_manager.broadcast_telemetry(
|
||||
{
|
||||
"public_key": contact.public_key,
|
||||
"name": contact.name or contact.public_key[:12],
|
||||
"timestamp": timestamp,
|
||||
**data,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Telemetry collect: failed to record for %s: %s",
|
||||
contact.public_key[:12],
|
||||
e,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def _telemetry_collect_loop() -> None:
|
||||
"""Background task that collects telemetry from tracked repeaters every 8 hours.
|
||||
|
||||
Runs a first cycle after a short initial delay (so newly tracked repeaters
|
||||
get a sample promptly), then sleeps the full interval between subsequent cycles.
|
||||
|
||||
Acquires the radio lock per-repeater (non-blocking) so manual operations can
|
||||
interleave. Failures are logged and skipped.
|
||||
"""
|
||||
first_run = True
|
||||
while True:
|
||||
try:
|
||||
delay = TELEMETRY_COLLECT_INITIAL_DELAY if first_run else TELEMETRY_COLLECT_INTERVAL
|
||||
await asyncio.sleep(delay)
|
||||
first_run = False
|
||||
|
||||
if not radio_manager.is_connected:
|
||||
logger.debug("Telemetry collect: radio not connected, skipping cycle")
|
||||
continue
|
||||
|
||||
app_settings = await AppSettingsRepository.get()
|
||||
tracked = app_settings.tracked_telemetry_repeaters
|
||||
if not tracked:
|
||||
continue
|
||||
|
||||
logger.info("Telemetry collect: starting cycle for %d repeater(s)", len(tracked))
|
||||
collected = 0
|
||||
|
||||
for pub_key in tracked:
|
||||
contact = await ContactRepository.get_by_key(pub_key)
|
||||
if not contact or contact.type != 2:
|
||||
logger.debug(
|
||||
"Telemetry collect: skipping %s (not found or not repeater)",
|
||||
pub_key[:12],
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
async with radio_manager.radio_operation(
|
||||
"telemetry_collect",
|
||||
blocking=False,
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
if await _collect_repeater_telemetry(mc, contact):
|
||||
collected += 1
|
||||
except RadioOperationBusyError:
|
||||
logger.debug(
|
||||
"Telemetry collect: radio busy, skipping %s",
|
||||
pub_key[:12],
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Telemetry collect: cycle complete, %d/%d successful",
|
||||
collected,
|
||||
len(tracked),
|
||||
)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Telemetry collect task cancelled")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error("Error in telemetry collect loop: %s", e, exc_info=True)
|
||||
|
||||
|
||||
def start_telemetry_collect() -> None:
|
||||
"""Start the periodic telemetry collection background task."""
|
||||
global _telemetry_collect_task
|
||||
if _telemetry_collect_task is None or _telemetry_collect_task.done():
|
||||
_telemetry_collect_task = asyncio.create_task(_telemetry_collect_loop())
|
||||
logger.info(
|
||||
"Started periodic telemetry collection (interval: %ds)",
|
||||
TELEMETRY_COLLECT_INTERVAL,
|
||||
)
|
||||
|
||||
|
||||
async def stop_telemetry_collect() -> None:
|
||||
"""Stop the periodic telemetry collection background task."""
|
||||
global _telemetry_collect_task
|
||||
if _telemetry_collect_task and not _telemetry_collect_task.done():
|
||||
_telemetry_collect_task.cancel()
|
||||
try:
|
||||
await _telemetry_collect_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
_telemetry_collect_task = None
|
||||
logger.info("Stopped periodic telemetry collection")
|
||||
|
||||
@@ -8,6 +8,7 @@ from app.repository.contacts import (
|
||||
from app.repository.fanout import FanoutConfigRepository
|
||||
from app.repository.messages import MessageRepository
|
||||
from app.repository.raw_packets import RawPacketRepository
|
||||
from app.repository.repeater_telemetry import RepeaterTelemetryRepository
|
||||
from app.repository.settings import AppSettingsRepository, StatisticsRepository
|
||||
|
||||
__all__ = [
|
||||
@@ -20,5 +21,6 @@ __all__ = [
|
||||
"FanoutConfigRepository",
|
||||
"MessageRepository",
|
||||
"RawPacketRepository",
|
||||
"RepeaterTelemetryRepository",
|
||||
"StatisticsRepository",
|
||||
]
|
||||
|
||||
+22
-22
@@ -26,7 +26,7 @@ class ChannelRepository:
|
||||
"""Get a channel by its key (32-char hex string)."""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT key, name, is_hashtag, on_radio, flood_scope_override, last_read_at
|
||||
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at, favorite
|
||||
FROM channels
|
||||
WHERE key = ?
|
||||
""",
|
||||
@@ -40,7 +40,9 @@ class ChannelRepository:
|
||||
is_hashtag=bool(row["is_hashtag"]),
|
||||
on_radio=bool(row["on_radio"]),
|
||||
flood_scope_override=row["flood_scope_override"],
|
||||
path_hash_mode_override=row["path_hash_mode_override"],
|
||||
last_read_at=row["last_read_at"],
|
||||
favorite=bool(row["favorite"]),
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -48,7 +50,7 @@ class ChannelRepository:
|
||||
async def get_all() -> list[Channel]:
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT key, name, is_hashtag, on_radio, flood_scope_override, last_read_at
|
||||
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at, favorite
|
||||
FROM channels
|
||||
ORDER BY name
|
||||
"""
|
||||
@@ -61,34 +63,22 @@ class ChannelRepository:
|
||||
is_hashtag=bool(row["is_hashtag"]),
|
||||
on_radio=bool(row["on_radio"]),
|
||||
flood_scope_override=row["flood_scope_override"],
|
||||
path_hash_mode_override=row["path_hash_mode_override"],
|
||||
last_read_at=row["last_read_at"],
|
||||
favorite=bool(row["favorite"]),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
async def get_on_radio() -> list[Channel]:
|
||||
"""Return channels currently marked as resident on the radio in the database."""
|
||||
async def set_favorite(key: str, value: bool) -> bool:
|
||||
"""Set or clear the favorite flag for a channel. Returns True if row was found."""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT key, name, is_hashtag, on_radio, flood_scope_override, last_read_at
|
||||
FROM channels
|
||||
WHERE on_radio = 1
|
||||
ORDER BY name
|
||||
"""
|
||||
"UPDATE channels SET favorite = ? WHERE key = ?",
|
||||
(1 if value else 0, key.upper()),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [
|
||||
Channel(
|
||||
key=row["key"],
|
||||
name=row["name"],
|
||||
is_hashtag=bool(row["is_hashtag"]),
|
||||
on_radio=bool(row["on_radio"]),
|
||||
flood_scope_override=row["flood_scope_override"],
|
||||
last_read_at=row["last_read_at"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
async def delete(key: str) -> None:
|
||||
@@ -123,6 +113,16 @@ class ChannelRepository:
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
async def update_path_hash_mode_override(key: str, path_hash_mode_override: int | None) -> bool:
|
||||
"""Set or clear a channel's path hash mode override."""
|
||||
cursor = await db.conn.execute(
|
||||
"UPDATE channels SET path_hash_mode_override = ? WHERE key = ?",
|
||||
(path_hash_mode_override, key.upper()),
|
||||
)
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
async def mark_all_read(timestamp: int) -> None:
|
||||
"""Mark all channels as read at the given timestamp."""
|
||||
|
||||
+96
-61
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
@@ -12,6 +13,8 @@ from app.models import (
|
||||
)
|
||||
from app.path_utils import first_hop_hex, normalize_contact_route, normalize_route_override
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AmbiguousPublicKeyPrefixError(ValueError):
|
||||
"""Raised when a public key prefix matches multiple contacts."""
|
||||
@@ -167,6 +170,7 @@ class ContactRepository:
|
||||
lon=row["lon"],
|
||||
last_seen=row["last_seen"],
|
||||
on_radio=bool(row["on_radio"]),
|
||||
favorite=bool(row["favorite"]) if "favorite" in available_columns else False,
|
||||
last_contacted=row["last_contacted"],
|
||||
last_read_at=row["last_read_at"],
|
||||
first_seen=row["first_seen"],
|
||||
@@ -389,15 +393,30 @@ class ContactRepository:
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def get_favorites() -> list[Contact]:
|
||||
"""Return all contacts marked as favorite."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT * FROM contacts WHERE favorite = 1 AND LENGTH(public_key) = 64"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [ContactRepository._row_to_contact(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def set_favorite(public_key: str, value: bool) -> None:
|
||||
"""Set or clear the favorite flag for a contact."""
|
||||
await db.conn.execute(
|
||||
"UPDATE contacts SET favorite = ? WHERE public_key = ?",
|
||||
(1 if value else 0, public_key.lower()),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def delete(public_key: str) -> None:
|
||||
normalized = public_key.lower()
|
||||
await db.conn.execute(
|
||||
"DELETE FROM contact_name_history WHERE public_key = ?", (normalized,)
|
||||
)
|
||||
await db.conn.execute(
|
||||
"DELETE FROM contact_advert_paths WHERE public_key = ?", (normalized,)
|
||||
)
|
||||
# contact_name_history and contact_advert_paths cascade via FK.
|
||||
# Messages are intentionally preserved so history re-surfaces
|
||||
# if the contact is re-added later.
|
||||
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (normalized,))
|
||||
await db.conn.commit()
|
||||
|
||||
@@ -484,7 +503,6 @@ class ContactRepository:
|
||||
return []
|
||||
|
||||
promoted_keys: list[str] = []
|
||||
full_exists = await ContactRepository.get_by_key(normalized_full_key) is not None
|
||||
|
||||
for row in rows:
|
||||
old_key = row["public_key"]
|
||||
@@ -501,60 +519,70 @@ class ContactRepository:
|
||||
(old_key,),
|
||||
)
|
||||
match_row = await match_cursor.fetchone()
|
||||
if (match_row["match_count"] if match_row is not None else 0) != 1:
|
||||
match_count = match_row["match_count"] if match_row is not None else 0
|
||||
if match_count != 1:
|
||||
logger.warning(
|
||||
"Skipping prefix promotion for %s: %d full-key contacts match (expected 1)",
|
||||
old_key,
|
||||
match_count,
|
||||
)
|
||||
continue
|
||||
|
||||
await migrate_child_rows(old_key, normalized_full_key)
|
||||
|
||||
if full_exists:
|
||||
await db.conn.execute(
|
||||
"""
|
||||
UPDATE contacts
|
||||
SET last_seen = CASE
|
||||
WHEN contacts.last_seen IS NULL THEN ?
|
||||
WHEN ? IS NULL THEN contacts.last_seen
|
||||
WHEN ? > contacts.last_seen THEN ?
|
||||
ELSE contacts.last_seen
|
||||
END,
|
||||
last_contacted = CASE
|
||||
WHEN contacts.last_contacted IS NULL THEN ?
|
||||
WHEN ? IS NULL THEN contacts.last_contacted
|
||||
WHEN ? > contacts.last_contacted THEN ?
|
||||
ELSE contacts.last_contacted
|
||||
END,
|
||||
first_seen = CASE
|
||||
WHEN contacts.first_seen IS NULL THEN ?
|
||||
WHEN ? IS NULL THEN contacts.first_seen
|
||||
WHEN ? < contacts.first_seen THEN ?
|
||||
ELSE contacts.first_seen
|
||||
END,
|
||||
last_read_at = COALESCE(contacts.last_read_at, ?)
|
||||
WHERE public_key = ?
|
||||
""",
|
||||
(
|
||||
row["last_seen"],
|
||||
row["last_seen"],
|
||||
row["last_seen"],
|
||||
row["last_seen"],
|
||||
row["last_contacted"],
|
||||
row["last_contacted"],
|
||||
row["last_contacted"],
|
||||
row["last_contacted"],
|
||||
row["first_seen"],
|
||||
row["first_seen"],
|
||||
row["first_seen"],
|
||||
row["first_seen"],
|
||||
row["last_read_at"],
|
||||
normalized_full_key,
|
||||
),
|
||||
)
|
||||
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (old_key,))
|
||||
else:
|
||||
await db.conn.execute(
|
||||
"UPDATE contacts SET public_key = ? WHERE public_key = ?",
|
||||
(normalized_full_key, old_key),
|
||||
)
|
||||
full_exists = True
|
||||
# Merge timestamp metadata from the old prefix contact into the
|
||||
# full-key contact (which all callers guarantee already exists),
|
||||
# then delete the prefix placeholder.
|
||||
await db.conn.execute(
|
||||
"""
|
||||
UPDATE contacts
|
||||
SET last_seen = CASE
|
||||
WHEN contacts.last_seen IS NULL THEN ?
|
||||
WHEN ? IS NULL THEN contacts.last_seen
|
||||
WHEN ? > contacts.last_seen THEN ?
|
||||
ELSE contacts.last_seen
|
||||
END,
|
||||
last_contacted = CASE
|
||||
WHEN contacts.last_contacted IS NULL THEN ?
|
||||
WHEN ? IS NULL THEN contacts.last_contacted
|
||||
WHEN ? > contacts.last_contacted THEN ?
|
||||
ELSE contacts.last_contacted
|
||||
END,
|
||||
first_seen = CASE
|
||||
WHEN contacts.first_seen IS NULL THEN ?
|
||||
WHEN ? IS NULL THEN contacts.first_seen
|
||||
WHEN ? < contacts.first_seen THEN ?
|
||||
ELSE contacts.first_seen
|
||||
END,
|
||||
last_read_at = CASE
|
||||
WHEN contacts.last_read_at IS NULL THEN ?
|
||||
WHEN ? IS NULL THEN contacts.last_read_at
|
||||
WHEN ? > contacts.last_read_at THEN ?
|
||||
ELSE contacts.last_read_at
|
||||
END
|
||||
WHERE public_key = ?
|
||||
""",
|
||||
(
|
||||
row["last_seen"],
|
||||
row["last_seen"],
|
||||
row["last_seen"],
|
||||
row["last_seen"],
|
||||
row["last_contacted"],
|
||||
row["last_contacted"],
|
||||
row["last_contacted"],
|
||||
row["last_contacted"],
|
||||
row["first_seen"],
|
||||
row["first_seen"],
|
||||
row["first_seen"],
|
||||
row["first_seen"],
|
||||
row["last_read_at"],
|
||||
row["last_read_at"],
|
||||
row["last_read_at"],
|
||||
row["last_read_at"],
|
||||
normalized_full_key,
|
||||
),
|
||||
)
|
||||
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (old_key,))
|
||||
|
||||
promoted_keys.append(old_key)
|
||||
|
||||
@@ -664,9 +692,18 @@ class ContactAdvertPathRepository:
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT public_key, path_hex, path_len, first_seen, last_seen, heard_count
|
||||
FROM contact_advert_paths
|
||||
FROM (
|
||||
SELECT *,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY public_key
|
||||
ORDER BY last_seen DESC, heard_count DESC, path_len ASC, path_hex ASC
|
||||
) AS rn
|
||||
FROM contact_advert_paths
|
||||
)
|
||||
WHERE rn <= ?
|
||||
ORDER BY public_key ASC, last_seen DESC, heard_count DESC, path_len ASC, path_hex ASC
|
||||
"""
|
||||
""",
|
||||
(limit_per_contact,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
@@ -677,8 +714,6 @@ class ContactAdvertPathRepository:
|
||||
if paths is None:
|
||||
paths = []
|
||||
grouped[key] = paths
|
||||
if len(paths) >= limit_per_contact:
|
||||
continue
|
||||
paths.append(ContactAdvertPathRepository._row_to_path(row))
|
||||
|
||||
return [
|
||||
|
||||
+58
-21
@@ -29,8 +29,7 @@ class MessageRepository:
|
||||
def _contact_activity_filter(public_key: str) -> tuple[str, list[Any]]:
|
||||
lower_key = public_key.lower()
|
||||
return (
|
||||
"((type = 'PRIV' AND LOWER(conversation_key) = ?)"
|
||||
" OR (type = 'CHAN' AND LOWER(sender_key) = ?))",
|
||||
"((type = 'PRIV' AND conversation_key = ?) OR (type = 'CHAN' AND sender_key = ?))",
|
||||
[lower_key, lower_key],
|
||||
)
|
||||
|
||||
@@ -58,6 +57,8 @@ class MessageRepository:
|
||||
sender_timestamp: int | None = None,
|
||||
path: str | None = None,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
txt_type: int = 0,
|
||||
signature: str | None = None,
|
||||
outgoing: bool = False,
|
||||
@@ -79,8 +80,15 @@ class MessageRepository:
|
||||
entry: dict = {"path": path, "received_at": received_at}
|
||||
if path_len is not None:
|
||||
entry["path_len"] = path_len
|
||||
if rssi is not None:
|
||||
entry["rssi"] = rssi
|
||||
if snr is not None:
|
||||
entry["snr"] = snr
|
||||
paths_json = json.dumps([entry])
|
||||
|
||||
# Normalize sender_key to lowercase so queries can match without LOWER().
|
||||
normalized_sender_key = sender_key.lower() if sender_key else sender_key
|
||||
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO messages (type, conversation_key, text, sender_timestamp,
|
||||
@@ -99,7 +107,7 @@ class MessageRepository:
|
||||
signature,
|
||||
outgoing,
|
||||
sender_name,
|
||||
sender_key,
|
||||
normalized_sender_key,
|
||||
),
|
||||
)
|
||||
await db.conn.commit()
|
||||
@@ -114,6 +122,8 @@ class MessageRepository:
|
||||
path: str,
|
||||
received_at: int | None = None,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
) -> list[MessagePath]:
|
||||
"""Add a new path to an existing message.
|
||||
|
||||
@@ -127,6 +137,10 @@ class MessageRepository:
|
||||
entry: dict = {"path": path, "received_at": ts}
|
||||
if path_len is not None:
|
||||
entry["path_len"] = path_len
|
||||
if rssi is not None:
|
||||
entry["rssi"] = rssi
|
||||
if snr is not None:
|
||||
entry["snr"] = snr
|
||||
new_entry = json.dumps(entry)
|
||||
await db.conn.execute(
|
||||
"""UPDATE messages SET paths = json_insert(
|
||||
@@ -158,7 +172,11 @@ class MessageRepository:
|
||||
"""
|
||||
lower_key = full_key.lower()
|
||||
cursor = await db.conn.execute(
|
||||
"""UPDATE messages SET conversation_key = ?
|
||||
"""UPDATE messages SET conversation_key = ?,
|
||||
sender_key = CASE
|
||||
WHEN sender_key IS NOT NULL AND length(sender_key) < 64
|
||||
AND ? LIKE sender_key || '%'
|
||||
THEN ? ELSE sender_key END
|
||||
WHERE type = 'PRIV' AND length(conversation_key) < 64
|
||||
AND ? LIKE conversation_key || '%'
|
||||
AND (
|
||||
@@ -166,7 +184,7 @@ class MessageRepository:
|
||||
WHERE length(public_key) = 64
|
||||
AND public_key LIKE messages.conversation_key || '%'
|
||||
) = 1""",
|
||||
(lower_key, lower_key),
|
||||
(lower_key, lower_key, lower_key, lower_key),
|
||||
)
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount
|
||||
@@ -255,10 +273,10 @@ class MessageRepository:
|
||||
|
||||
if MessageRepository._looks_like_hex_prefix(value):
|
||||
if len(value) == 32:
|
||||
clause += " OR UPPER(messages.conversation_key) = ?"
|
||||
clause += " OR messages.conversation_key = ?"
|
||||
params.append(value.upper())
|
||||
else:
|
||||
clause += " OR UPPER(messages.conversation_key) LIKE ? ESCAPE '\\'"
|
||||
clause += " OR messages.conversation_key LIKE ? ESCAPE '\\'"
|
||||
params.append(f"{MessageRepository._escape_like(value.upper())}%")
|
||||
|
||||
clause += "))"
|
||||
@@ -277,13 +295,13 @@ class MessageRepository:
|
||||
priv_key_clause: str
|
||||
chan_key_clause: str
|
||||
if len(value) == 64:
|
||||
priv_key_clause = "LOWER(messages.conversation_key) = ?"
|
||||
chan_key_clause = "LOWER(sender_key) = ?"
|
||||
priv_key_clause = "messages.conversation_key = ?"
|
||||
chan_key_clause = "sender_key = ?"
|
||||
params.extend([lower_value, lower_value])
|
||||
else:
|
||||
escaped_prefix = f"{MessageRepository._escape_like(lower_value)}%"
|
||||
priv_key_clause = "LOWER(messages.conversation_key) LIKE ? ESCAPE '\\'"
|
||||
chan_key_clause = "LOWER(sender_key) LIKE ? ESCAPE '\\'"
|
||||
priv_key_clause = "messages.conversation_key LIKE ? ESCAPE '\\'"
|
||||
chan_key_clause = "sender_key LIKE ? ESCAPE '\\'"
|
||||
params.extend([escaped_prefix, escaped_prefix])
|
||||
|
||||
clause += (
|
||||
@@ -307,12 +325,12 @@ class MessageRepository:
|
||||
if blocked_keys:
|
||||
placeholders = ",".join("?" for _ in blocked_keys)
|
||||
blocked_matchers.append(
|
||||
f"({prefix}type = 'PRIV' AND LOWER({prefix}conversation_key) IN ({placeholders}))"
|
||||
f"({prefix}type = 'PRIV' AND {prefix}conversation_key IN ({placeholders}))"
|
||||
)
|
||||
params.extend(blocked_keys)
|
||||
blocked_matchers.append(
|
||||
f"({prefix}type = 'CHAN' AND {prefix}sender_key IS NOT NULL"
|
||||
f" AND LOWER({prefix}sender_key) IN ({placeholders}))"
|
||||
f" AND {prefix}sender_key IN ({placeholders}))"
|
||||
)
|
||||
params.extend(blocked_keys)
|
||||
|
||||
@@ -379,9 +397,9 @@ class MessageRepository:
|
||||
query = (
|
||||
f"SELECT {MessageRepository._message_select('messages')} FROM messages "
|
||||
"LEFT JOIN contacts ON messages.type = 'PRIV' "
|
||||
"AND LOWER(messages.conversation_key) = LOWER(contacts.public_key) "
|
||||
"AND messages.conversation_key = contacts.public_key "
|
||||
"LEFT JOIN channels ON messages.type = 'CHAN' "
|
||||
"AND UPPER(messages.conversation_key) = UPPER(channels.key) "
|
||||
"AND messages.conversation_key = channels.key "
|
||||
"WHERE 1=1"
|
||||
)
|
||||
params: list[Any] = []
|
||||
@@ -539,10 +557,11 @@ class MessageRepository:
|
||||
@staticmethod
|
||||
async def increment_ack_count(message_id: int) -> int:
|
||||
"""Increment ack count and return the new value."""
|
||||
await db.conn.execute("UPDATE messages SET acked = acked + 1 WHERE id = ?", (message_id,))
|
||||
await db.conn.commit()
|
||||
cursor = await db.conn.execute("SELECT acked FROM messages WHERE id = ?", (message_id,))
|
||||
cursor = await db.conn.execute(
|
||||
"UPDATE messages SET acked = acked + 1 WHERE id = ? RETURNING acked", (message_id,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
await db.conn.commit()
|
||||
return row["acked"] if row else 1
|
||||
|
||||
@staticmethod
|
||||
@@ -572,6 +591,9 @@ class MessageRepository:
|
||||
@staticmethod
|
||||
async def delete_by_id(message_id: int) -> None:
|
||||
"""Delete a message row by ID."""
|
||||
await db.conn.execute(
|
||||
"UPDATE raw_packets SET message_id = NULL WHERE message_id = ?", (message_id,)
|
||||
)
|
||||
await db.conn.execute("DELETE FROM messages WHERE id = ?", (message_id,))
|
||||
await db.conn.commit()
|
||||
|
||||
@@ -666,7 +688,7 @@ class MessageRepository:
|
||||
ELSE 0
|
||||
END) > 0 as has_mention
|
||||
FROM messages m
|
||||
JOIN contacts ct ON m.conversation_key = ct.public_key
|
||||
LEFT JOIN contacts ct ON m.conversation_key = ct.public_key
|
||||
WHERE m.type = 'PRIV' AND m.outgoing = 0
|
||||
AND m.received_at > COALESCE(ct.last_read_at, 0)
|
||||
{blocked_sql}
|
||||
@@ -777,12 +799,14 @@ class MessageRepository:
|
||||
|
||||
@staticmethod
|
||||
async def get_channel_stats(conversation_key: str) -> dict:
|
||||
"""Get channel message statistics: time-windowed counts, first message, unique senders, top senders.
|
||||
"""Get channel message statistics: time-windowed counts, first message, unique senders, top senders, path hash widths.
|
||||
|
||||
Returns a dict with message_counts, first_message_at, unique_sender_count, top_senders_24h.
|
||||
Returns a dict with message_counts, first_message_at, unique_sender_count, top_senders_24h, path_hash_width_24h.
|
||||
"""
|
||||
import time as _time
|
||||
|
||||
from app.path_utils import bucket_path_hash_widths
|
||||
|
||||
now = int(_time.time())
|
||||
t_1h = now - 3600
|
||||
t_24h = now - 86400
|
||||
@@ -834,11 +858,24 @@ class MessageRepository:
|
||||
for r in top_rows
|
||||
]
|
||||
|
||||
# Path hash width distribution for last 24h (in-Python parse of raw packet envelopes)
|
||||
cursor3 = await db.conn.execute(
|
||||
"""
|
||||
SELECT rp.data FROM raw_packets rp
|
||||
JOIN messages m ON rp.message_id = m.id
|
||||
WHERE m.type = 'CHAN' AND m.conversation_key = ?
|
||||
AND rp.timestamp >= ?
|
||||
""",
|
||||
(conversation_key, t_24h),
|
||||
)
|
||||
path_hash_width_24h = await bucket_path_hash_widths(cursor3)
|
||||
|
||||
return {
|
||||
"message_counts": message_counts,
|
||||
"first_message_at": row["first_message_at"],
|
||||
"unique_sender_count": row["unique_sender_count"] or 0,
|
||||
"top_senders_24h": top_senders,
|
||||
"path_hash_width_24h": path_hash_width_24h,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
import sqlite3
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
from hashlib import sha256
|
||||
|
||||
from app.database import db
|
||||
@@ -8,6 +8,8 @@ from app.decoder import PayloadType, extract_payload, get_packet_payload_type
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
UNDECRYPTED_PACKET_BATCH_SIZE = 500
|
||||
|
||||
|
||||
class RawPacketRepository:
|
||||
@staticmethod
|
||||
@@ -32,46 +34,23 @@ class RawPacketRepository:
|
||||
# For malformed packets, hash the full data
|
||||
payload_hash = sha256(data).digest()
|
||||
|
||||
# Check if this payload already exists
|
||||
cursor = await db.conn.execute(
|
||||
"INSERT OR IGNORE INTO raw_packets (timestamp, data, payload_hash) VALUES (?, ?, ?)",
|
||||
(ts, data, payload_hash),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
if cursor.rowcount > 0:
|
||||
assert cursor.lastrowid is not None
|
||||
return (cursor.lastrowid, True)
|
||||
|
||||
# Duplicate payload — look up the existing row.
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT id FROM raw_packets WHERE payload_hash = ?", (payload_hash,)
|
||||
)
|
||||
existing = await cursor.fetchone()
|
||||
|
||||
if existing:
|
||||
# Duplicate - return existing packet ID
|
||||
logger.debug(
|
||||
"Duplicate payload detected (hash=%s..., existing_id=%d)",
|
||||
payload_hash.hex()[:12],
|
||||
existing["id"],
|
||||
)
|
||||
return (existing["id"], False)
|
||||
|
||||
# New packet - insert with hash
|
||||
try:
|
||||
cursor = await db.conn.execute(
|
||||
"INSERT INTO raw_packets (timestamp, data, payload_hash) VALUES (?, ?, ?)",
|
||||
(ts, data, payload_hash),
|
||||
)
|
||||
await db.conn.commit()
|
||||
assert cursor.lastrowid is not None # INSERT always returns a row ID
|
||||
return (cursor.lastrowid, True)
|
||||
except sqlite3.IntegrityError:
|
||||
# Race condition: another insert with same payload_hash happened between
|
||||
# our SELECT and INSERT. This is expected for duplicate packets arriving
|
||||
# close together. Query again to get the existing ID.
|
||||
logger.debug(
|
||||
"Duplicate packet detected via race condition (payload_hash=%s), dropping",
|
||||
payload_hash.hex()[:16],
|
||||
)
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT id FROM raw_packets WHERE payload_hash = ?", (payload_hash,)
|
||||
)
|
||||
existing = await cursor.fetchone()
|
||||
if existing:
|
||||
return (existing["id"], False)
|
||||
# This shouldn't happen, but if it does, re-raise
|
||||
raise
|
||||
assert existing is not None
|
||||
return (existing["id"], False)
|
||||
|
||||
@staticmethod
|
||||
async def get_undecrypted_count() -> int:
|
||||
@@ -92,13 +71,56 @@ class RawPacketRepository:
|
||||
return row["oldest"] if row and row["oldest"] is not None else None
|
||||
|
||||
@staticmethod
|
||||
async def get_all_undecrypted() -> list[tuple[int, bytes, int]]:
|
||||
"""Get all undecrypted packets as (id, data, timestamp) tuples."""
|
||||
async def stream_all_undecrypted(
|
||||
batch_size: int = UNDECRYPTED_PACKET_BATCH_SIZE,
|
||||
) -> AsyncIterator[tuple[int, bytes, int]]:
|
||||
"""Yield all undecrypted packets as (id, data, timestamp) in bounded batches."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT id, data, timestamp FROM raw_packets WHERE message_id IS NULL ORDER BY timestamp ASC"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [(row["id"], bytes(row["data"]), row["timestamp"]) for row in rows]
|
||||
try:
|
||||
while True:
|
||||
rows = await cursor.fetchmany(batch_size)
|
||||
if not rows:
|
||||
break
|
||||
for row in rows:
|
||||
yield (row["id"], bytes(row["data"]), row["timestamp"])
|
||||
finally:
|
||||
await cursor.close()
|
||||
|
||||
@staticmethod
|
||||
async def stream_undecrypted_text_messages(
|
||||
batch_size: int = UNDECRYPTED_PACKET_BATCH_SIZE,
|
||||
) -> AsyncIterator[tuple[int, bytes, int]]:
|
||||
"""Yield undecrypted TEXT_MESSAGE packets in bounded-size batches."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT id, data, timestamp FROM raw_packets WHERE message_id IS NULL ORDER BY timestamp ASC"
|
||||
)
|
||||
try:
|
||||
while True:
|
||||
rows = await cursor.fetchmany(batch_size)
|
||||
if not rows:
|
||||
break
|
||||
|
||||
for row in rows:
|
||||
data = bytes(row["data"])
|
||||
payload_type = get_packet_payload_type(data)
|
||||
if payload_type == PayloadType.TEXT_MESSAGE:
|
||||
yield (row["id"], data, row["timestamp"])
|
||||
finally:
|
||||
await cursor.close()
|
||||
|
||||
@staticmethod
|
||||
async def count_undecrypted_text_messages(
|
||||
batch_size: int = UNDECRYPTED_PACKET_BATCH_SIZE,
|
||||
) -> int:
|
||||
"""Count undecrypted TEXT_MESSAGE packets without materializing them all."""
|
||||
count = 0
|
||||
async for _packet in RawPacketRepository.stream_undecrypted_text_messages(
|
||||
batch_size=batch_size
|
||||
):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
async def mark_decrypted(packet_id: int, message_id: int) -> None:
|
||||
@@ -150,25 +172,3 @@ class RawPacketRepository:
|
||||
cursor = await db.conn.execute("DELETE FROM raw_packets WHERE message_id IS NOT NULL")
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
@staticmethod
|
||||
async def get_undecrypted_text_messages() -> list[tuple[int, bytes, int]]:
|
||||
"""Get all undecrypted TEXT_MESSAGE packets as (id, data, timestamp) tuples.
|
||||
|
||||
Filters raw packets to only include those with PayloadType.TEXT_MESSAGE (0x02).
|
||||
These are direct messages that can be decrypted with contact ECDH keys.
|
||||
"""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT id, data, timestamp FROM raw_packets WHERE message_id IS NULL ORDER BY timestamp ASC"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
# Filter for TEXT_MESSAGE packets
|
||||
result = []
|
||||
for row in rows:
|
||||
data = bytes(row["data"])
|
||||
payload_type = get_packet_payload_type(data)
|
||||
if payload_type == PayloadType.TEXT_MESSAGE:
|
||||
result.append((row["id"], data, row["timestamp"]))
|
||||
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
from app.database import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Maximum age for telemetry history entries (30 days)
|
||||
_MAX_AGE_SECONDS = 30 * 86400
|
||||
|
||||
# Maximum entries to keep per repeater (sanity cap)
|
||||
_MAX_ENTRIES_PER_REPEATER = 1000
|
||||
|
||||
|
||||
class RepeaterTelemetryRepository:
|
||||
@staticmethod
|
||||
async def record(
|
||||
public_key: str,
|
||||
timestamp: int,
|
||||
data: dict,
|
||||
) -> None:
|
||||
"""Insert a telemetry history row and prune stale entries."""
|
||||
await db.conn.execute(
|
||||
"""
|
||||
INSERT INTO repeater_telemetry_history
|
||||
(public_key, timestamp, data)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(public_key, timestamp, json.dumps(data)),
|
||||
)
|
||||
|
||||
# Prune entries older than 30 days
|
||||
cutoff = int(time.time()) - _MAX_AGE_SECONDS
|
||||
await db.conn.execute(
|
||||
"DELETE FROM repeater_telemetry_history WHERE public_key = ? AND timestamp < ?",
|
||||
(public_key, cutoff),
|
||||
)
|
||||
|
||||
# Cap at _MAX_ENTRIES_PER_REPEATER (keep newest)
|
||||
await db.conn.execute(
|
||||
"""
|
||||
DELETE FROM repeater_telemetry_history
|
||||
WHERE public_key = ? AND id NOT IN (
|
||||
SELECT id FROM repeater_telemetry_history
|
||||
WHERE public_key = ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
)
|
||||
""",
|
||||
(public_key, public_key, _MAX_ENTRIES_PER_REPEATER),
|
||||
)
|
||||
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def get_history(public_key: str, since_timestamp: int) -> list[dict]:
|
||||
"""Return telemetry rows for a repeater since a given timestamp, ordered ASC."""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT timestamp, data
|
||||
FROM repeater_telemetry_history
|
||||
WHERE public_key = ? AND timestamp >= ?
|
||||
ORDER BY timestamp ASC
|
||||
""",
|
||||
(public_key, since_timestamp),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [
|
||||
{
|
||||
"timestamp": row["timestamp"],
|
||||
"data": json.loads(row["data"]),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
+111
-161
@@ -1,17 +1,19 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Literal
|
||||
from typing import Any
|
||||
|
||||
from app.database import db
|
||||
from app.models import AppSettings, Favorite
|
||||
from app.path_utils import parse_packet_envelope
|
||||
from app.models import AppSettings
|
||||
from app.path_utils import bucket_path_hash_widths
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SECONDS_1H = 3600
|
||||
SECONDS_24H = 86400
|
||||
SECONDS_72H = 259200
|
||||
SECONDS_7D = 604800
|
||||
RAW_PACKET_STATS_BATCH_SIZE = 500
|
||||
|
||||
|
||||
class AppSettingsRepository:
|
||||
@@ -25,10 +27,11 @@ class AppSettingsRepository:
|
||||
"""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert,
|
||||
sidebar_sort_order, last_message_times, preferences_migrated,
|
||||
SELECT max_radio_contacts, auto_decrypt_dm_on_advert,
|
||||
last_message_times,
|
||||
advert_interval, last_advert_time, flood_scope,
|
||||
blocked_keys, blocked_names
|
||||
blocked_keys, blocked_names, discovery_blocked_types,
|
||||
tracked_telemetry_repeaters, auto_resend_channel
|
||||
FROM app_settings WHERE id = 1
|
||||
"""
|
||||
)
|
||||
@@ -38,20 +41,6 @@ class AppSettingsRepository:
|
||||
# Should not happen after migration, but handle gracefully
|
||||
return AppSettings()
|
||||
|
||||
# Parse favorites JSON
|
||||
favorites = []
|
||||
if row["favorites"]:
|
||||
try:
|
||||
favorites_data = json.loads(row["favorites"])
|
||||
favorites = [Favorite(**f) for f in favorites_data]
|
||||
except (json.JSONDecodeError, TypeError, KeyError) as e:
|
||||
logger.warning(
|
||||
"Failed to parse favorites JSON, using empty list: %s (data=%r)",
|
||||
e,
|
||||
row["favorites"][:100] if row["favorites"] else None,
|
||||
)
|
||||
favorites = []
|
||||
|
||||
# Parse last_message_times JSON
|
||||
last_message_times: dict[str, int] = {}
|
||||
if row["last_message_times"]:
|
||||
@@ -80,38 +69,56 @@ class AppSettingsRepository:
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
blocked_names = []
|
||||
|
||||
# Validate sidebar_sort_order (fallback to "recent" if invalid)
|
||||
sort_order = row["sidebar_sort_order"]
|
||||
if sort_order not in ("recent", "alpha"):
|
||||
sort_order = "recent"
|
||||
# Parse discovery_blocked_types JSON
|
||||
discovery_blocked_types: list[int] = []
|
||||
if row["discovery_blocked_types"]:
|
||||
try:
|
||||
discovery_blocked_types = json.loads(row["discovery_blocked_types"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
discovery_blocked_types = []
|
||||
|
||||
# Parse tracked_telemetry_repeaters JSON
|
||||
tracked_telemetry_repeaters: list[str] = []
|
||||
try:
|
||||
raw_tracked = row["tracked_telemetry_repeaters"]
|
||||
if raw_tracked:
|
||||
tracked_telemetry_repeaters = json.loads(raw_tracked)
|
||||
except (json.JSONDecodeError, TypeError, KeyError):
|
||||
tracked_telemetry_repeaters = []
|
||||
|
||||
# Parse auto_resend_channel boolean
|
||||
try:
|
||||
auto_resend_channel = bool(row["auto_resend_channel"])
|
||||
except (KeyError, TypeError):
|
||||
auto_resend_channel = False
|
||||
|
||||
return AppSettings(
|
||||
max_radio_contacts=row["max_radio_contacts"],
|
||||
favorites=favorites,
|
||||
auto_decrypt_dm_on_advert=bool(row["auto_decrypt_dm_on_advert"]),
|
||||
sidebar_sort_order=sort_order,
|
||||
last_message_times=last_message_times,
|
||||
preferences_migrated=bool(row["preferences_migrated"]),
|
||||
advert_interval=row["advert_interval"] or 0,
|
||||
last_advert_time=row["last_advert_time"] or 0,
|
||||
flood_scope=row["flood_scope"] or "",
|
||||
blocked_keys=blocked_keys,
|
||||
blocked_names=blocked_names,
|
||||
discovery_blocked_types=discovery_blocked_types,
|
||||
tracked_telemetry_repeaters=tracked_telemetry_repeaters,
|
||||
auto_resend_channel=auto_resend_channel,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def update(
|
||||
max_radio_contacts: int | None = None,
|
||||
favorites: list[Favorite] | None = None,
|
||||
auto_decrypt_dm_on_advert: bool | None = None,
|
||||
sidebar_sort_order: str | None = None,
|
||||
last_message_times: dict[str, int] | None = None,
|
||||
preferences_migrated: bool | None = None,
|
||||
advert_interval: int | None = None,
|
||||
last_advert_time: int | None = None,
|
||||
flood_scope: str | None = None,
|
||||
blocked_keys: list[str] | None = None,
|
||||
blocked_names: list[str] | None = None,
|
||||
discovery_blocked_types: list[int] | None = None,
|
||||
tracked_telemetry_repeaters: list[str] | None = None,
|
||||
auto_resend_channel: bool | None = None,
|
||||
) -> AppSettings:
|
||||
"""Update app settings. Only provided fields are updated."""
|
||||
updates = []
|
||||
@@ -121,27 +128,14 @@ class AppSettingsRepository:
|
||||
updates.append("max_radio_contacts = ?")
|
||||
params.append(max_radio_contacts)
|
||||
|
||||
if favorites is not None:
|
||||
updates.append("favorites = ?")
|
||||
favorites_json = json.dumps([f.model_dump() for f in favorites])
|
||||
params.append(favorites_json)
|
||||
|
||||
if auto_decrypt_dm_on_advert is not None:
|
||||
updates.append("auto_decrypt_dm_on_advert = ?")
|
||||
params.append(1 if auto_decrypt_dm_on_advert else 0)
|
||||
|
||||
if sidebar_sort_order is not None:
|
||||
updates.append("sidebar_sort_order = ?")
|
||||
params.append(sidebar_sort_order)
|
||||
|
||||
if last_message_times is not None:
|
||||
updates.append("last_message_times = ?")
|
||||
params.append(json.dumps(last_message_times))
|
||||
|
||||
if preferences_migrated is not None:
|
||||
updates.append("preferences_migrated = ?")
|
||||
params.append(1 if preferences_migrated else 0)
|
||||
|
||||
if advert_interval is not None:
|
||||
updates.append("advert_interval = ?")
|
||||
params.append(advert_interval)
|
||||
@@ -162,6 +156,18 @@ class AppSettingsRepository:
|
||||
updates.append("blocked_names = ?")
|
||||
params.append(json.dumps(blocked_names))
|
||||
|
||||
if discovery_blocked_types is not None:
|
||||
updates.append("discovery_blocked_types = ?")
|
||||
params.append(json.dumps(discovery_blocked_types))
|
||||
|
||||
if tracked_telemetry_repeaters is not None:
|
||||
updates.append("tracked_telemetry_repeaters = ?")
|
||||
params.append(json.dumps(tracked_telemetry_repeaters))
|
||||
|
||||
if auto_resend_channel is not None:
|
||||
updates.append("auto_resend_channel = ?")
|
||||
params.append(1 if auto_resend_channel else 0)
|
||||
|
||||
if updates:
|
||||
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
|
||||
await db.conn.execute(query, params)
|
||||
@@ -169,27 +175,6 @@ class AppSettingsRepository:
|
||||
|
||||
return await AppSettingsRepository.get()
|
||||
|
||||
@staticmethod
|
||||
async def add_favorite(fav_type: Literal["channel", "contact"], fav_id: str) -> AppSettings:
|
||||
"""Add a favorite, avoiding duplicates."""
|
||||
settings = await AppSettingsRepository.get()
|
||||
|
||||
# Check if already favorited
|
||||
if any(f.type == fav_type and f.id == fav_id for f in settings.favorites):
|
||||
return settings
|
||||
|
||||
new_favorites = settings.favorites + [Favorite(type=fav_type, id=fav_id)]
|
||||
return await AppSettingsRepository.update(favorites=new_favorites)
|
||||
|
||||
@staticmethod
|
||||
async def remove_favorite(fav_type: Literal["channel", "contact"], fav_id: str) -> AppSettings:
|
||||
"""Remove a favorite."""
|
||||
settings = await AppSettingsRepository.get()
|
||||
new_favorites = [
|
||||
f for f in settings.favorites if not (f.type == fav_type and f.id == fav_id)
|
||||
]
|
||||
return await AppSettingsRepository.update(favorites=new_favorites)
|
||||
|
||||
@staticmethod
|
||||
async def toggle_blocked_key(key: str) -> AppSettings:
|
||||
"""Toggle a public key in the blocked list. Keys are normalized to lowercase."""
|
||||
@@ -211,41 +196,28 @@ class AppSettingsRepository:
|
||||
new_names = settings.blocked_names + [name]
|
||||
return await AppSettingsRepository.update(blocked_names=new_names)
|
||||
|
||||
@staticmethod
|
||||
async def migrate_preferences_from_frontend(
|
||||
favorites: list[dict],
|
||||
sort_order: str,
|
||||
last_message_times: dict[str, int],
|
||||
) -> tuple[AppSettings, bool]:
|
||||
"""Migrate all preferences from frontend localStorage.
|
||||
|
||||
This is a one-time migration. If already migrated, returns current settings
|
||||
without overwriting. Returns (settings, did_migrate) tuple.
|
||||
"""
|
||||
settings = await AppSettingsRepository.get()
|
||||
|
||||
if settings.preferences_migrated:
|
||||
# Already migrated, don't overwrite
|
||||
return settings, False
|
||||
|
||||
# Convert frontend favorites format to Favorite objects
|
||||
new_favorites = []
|
||||
for f in favorites:
|
||||
if f.get("type") in ("channel", "contact") and f.get("id"):
|
||||
new_favorites.append(Favorite(type=f["type"], id=f["id"]))
|
||||
|
||||
# Update with migrated preferences and mark as migrated
|
||||
settings = await AppSettingsRepository.update(
|
||||
favorites=new_favorites,
|
||||
sidebar_sort_order=sort_order if sort_order in ("recent", "alpha") else "recent",
|
||||
last_message_times=last_message_times,
|
||||
preferences_migrated=True,
|
||||
)
|
||||
|
||||
return settings, True
|
||||
|
||||
|
||||
class StatisticsRepository:
|
||||
@staticmethod
|
||||
async def get_database_message_totals() -> dict[str, int]:
|
||||
"""Return message totals needed by lightweight debug surfaces."""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
SUM(CASE WHEN type = 'PRIV' THEN 1 ELSE 0 END) AS total_dms,
|
||||
SUM(CASE WHEN type = 'CHAN' THEN 1 ELSE 0 END) AS total_channel_messages,
|
||||
SUM(CASE WHEN outgoing = 1 THEN 1 ELSE 0 END) AS total_outgoing
|
||||
FROM messages
|
||||
"""
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
assert row is not None
|
||||
return {
|
||||
"total_dms": row["total_dms"] or 0,
|
||||
"total_channel_messages": row["total_channel_messages"] or 0,
|
||||
"total_outgoing": row["total_outgoing"] or 0,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def _activity_counts(*, contact_type: int, exclude: bool = False) -> dict[str, int]:
|
||||
"""Get time-windowed counts for contacts/repeaters heard."""
|
||||
@@ -272,17 +244,26 @@ class StatisticsRepository:
|
||||
|
||||
@staticmethod
|
||||
async def _known_channels_active() -> dict[str, int]:
|
||||
"""Count distinct known channel keys with channel traffic in each time window."""
|
||||
"""Count known channel keys with any traffic in each time window.
|
||||
|
||||
Channel keys are stored canonically as uppercase hex, so we can avoid
|
||||
the old UPPER(...) join and aggregate per known channel directly.
|
||||
"""
|
||||
now = int(time.time())
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
WITH known AS (
|
||||
SELECT conversation_key, MAX(received_at) AS last_received_at
|
||||
FROM messages
|
||||
WHERE type = 'CHAN'
|
||||
AND conversation_key IN (SELECT key FROM channels)
|
||||
GROUP BY conversation_key
|
||||
)
|
||||
SELECT
|
||||
COUNT(DISTINCT CASE WHEN m.received_at >= ? THEN m.conversation_key END) AS last_hour,
|
||||
COUNT(DISTINCT CASE WHEN m.received_at >= ? THEN m.conversation_key END) AS last_24_hours,
|
||||
COUNT(DISTINCT CASE WHEN m.received_at >= ? THEN m.conversation_key END) AS last_week
|
||||
FROM messages m
|
||||
INNER JOIN channels c ON UPPER(m.conversation_key) = UPPER(c.key)
|
||||
WHERE m.type = 'CHAN'
|
||||
SUM(CASE WHEN last_received_at >= ? THEN 1 ELSE 0 END) AS last_hour,
|
||||
SUM(CASE WHEN last_received_at >= ? THEN 1 ELSE 0 END) AS last_24_hours,
|
||||
SUM(CASE WHEN last_received_at >= ? THEN 1 ELSE 0 END) AS last_week
|
||||
FROM known
|
||||
""",
|
||||
(now - SECONDS_1H, now - SECONDS_24H, now - SECONDS_7D),
|
||||
)
|
||||
@@ -294,6 +275,25 @@ class StatisticsRepository:
|
||||
"last_week": row["last_week"] or 0,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def _packets_per_hour_72h() -> list[dict[str, int]]:
|
||||
"""Return packet counts bucketed by hour for the last 72 hours."""
|
||||
now = int(time.time())
|
||||
cutoff = now - SECONDS_72H
|
||||
# Bucket timestamps to the start of each hour
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT (timestamp / 3600) * 3600 AS hour_ts, COUNT(*) AS count
|
||||
FROM raw_packets
|
||||
WHERE timestamp >= ?
|
||||
GROUP BY hour_ts
|
||||
ORDER BY hour_ts
|
||||
""",
|
||||
(cutoff,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [{"timestamp": row["hour_ts"], "count": row["count"]} for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def _path_hash_width_24h() -> dict[str, int | float]:
|
||||
"""Count parsed raw packets from the last 24h by hop hash width."""
|
||||
@@ -302,44 +302,7 @@ class StatisticsRepository:
|
||||
"SELECT data FROM raw_packets WHERE timestamp >= ?",
|
||||
(now - SECONDS_24H,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
single_byte = 0
|
||||
double_byte = 0
|
||||
triple_byte = 0
|
||||
|
||||
for row in rows:
|
||||
envelope = parse_packet_envelope(bytes(row["data"]))
|
||||
if envelope is None:
|
||||
continue
|
||||
if envelope.hash_size == 1:
|
||||
single_byte += 1
|
||||
elif envelope.hash_size == 2:
|
||||
double_byte += 1
|
||||
elif envelope.hash_size == 3:
|
||||
triple_byte += 1
|
||||
|
||||
total_packets = single_byte + double_byte + triple_byte
|
||||
if total_packets == 0:
|
||||
return {
|
||||
"total_packets": 0,
|
||||
"single_byte": 0,
|
||||
"double_byte": 0,
|
||||
"triple_byte": 0,
|
||||
"single_byte_pct": 0.0,
|
||||
"double_byte_pct": 0.0,
|
||||
"triple_byte_pct": 0.0,
|
||||
}
|
||||
|
||||
return {
|
||||
"total_packets": total_packets,
|
||||
"single_byte": single_byte,
|
||||
"double_byte": double_byte,
|
||||
"triple_byte": triple_byte,
|
||||
"single_byte_pct": (single_byte / total_packets) * 100,
|
||||
"double_byte_pct": (double_byte / total_packets) * 100,
|
||||
"triple_byte_pct": (triple_byte / total_packets) * 100,
|
||||
}
|
||||
return await bucket_path_hash_widths(cursor, batch_size=RAW_PACKET_STATS_BATCH_SIZE)
|
||||
|
||||
@staticmethod
|
||||
async def get_all() -> dict:
|
||||
@@ -400,28 +363,14 @@ class StatisticsRepository:
|
||||
decrypted_packets = pkt_row["decrypted"] or 0
|
||||
undecrypted_packets = total_packets - decrypted_packets
|
||||
|
||||
# Message type counts
|
||||
cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM messages WHERE type = 'PRIV'")
|
||||
row = await cursor.fetchone()
|
||||
assert row is not None
|
||||
total_dms: int = row["cnt"]
|
||||
|
||||
cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM messages WHERE type = 'CHAN'")
|
||||
row = await cursor.fetchone()
|
||||
assert row is not None
|
||||
total_channel_messages: int = row["cnt"]
|
||||
|
||||
# Outgoing count
|
||||
cursor = await db.conn.execute("SELECT COUNT(*) AS cnt FROM messages WHERE outgoing = 1")
|
||||
row = await cursor.fetchone()
|
||||
assert row is not None
|
||||
total_outgoing: int = row["cnt"]
|
||||
message_totals = await StatisticsRepository.get_database_message_totals()
|
||||
|
||||
# Activity windows
|
||||
contacts_heard = await StatisticsRepository._activity_counts(contact_type=2, exclude=True)
|
||||
repeaters_heard = await StatisticsRepository._activity_counts(contact_type=2)
|
||||
known_channels_active = await StatisticsRepository._known_channels_active()
|
||||
path_hash_width_24h = await StatisticsRepository._path_hash_width_24h()
|
||||
packets_per_hour_72h = await StatisticsRepository._packets_per_hour_72h()
|
||||
|
||||
return {
|
||||
"busiest_channels_24h": busiest_channels_24h,
|
||||
@@ -431,11 +380,12 @@ class StatisticsRepository:
|
||||
"total_packets": total_packets,
|
||||
"decrypted_packets": decrypted_packets,
|
||||
"undecrypted_packets": undecrypted_packets,
|
||||
"total_dms": total_dms,
|
||||
"total_channel_messages": total_channel_messages,
|
||||
"total_outgoing": total_outgoing,
|
||||
"total_dms": message_totals["total_dms"],
|
||||
"total_channel_messages": message_totals["total_channel_messages"],
|
||||
"total_outgoing": message_totals["total_outgoing"],
|
||||
"contacts_heard": contacts_heard,
|
||||
"repeaters_heard": repeaters_heard,
|
||||
"known_channels_active": known_channels_active,
|
||||
"path_hash_width_24h": path_hash_width_24h,
|
||||
"packets_per_hour_72h": packets_per_hour_72h,
|
||||
}
|
||||
|
||||
+260
-47
@@ -1,7 +1,8 @@
|
||||
import logging
|
||||
import re
|
||||
from hashlib import sha256
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Response, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.channel_constants import (
|
||||
@@ -10,10 +11,12 @@ from app.channel_constants import (
|
||||
is_public_channel_key,
|
||||
is_public_channel_name,
|
||||
)
|
||||
from app.decoder import parse_packet, try_decrypt_packet_with_channel_key
|
||||
from app.models import Channel, ChannelDetail, ChannelMessageCounts, ChannelTopSender
|
||||
from app.packet_processor import create_message_from_decrypted
|
||||
from app.region_scope import normalize_region_scope
|
||||
from app.repository import ChannelRepository, MessageRepository
|
||||
from app.websocket import broadcast_event
|
||||
from app.repository import ChannelRepository, MessageRepository, RawPacketRepository
|
||||
from app.websocket import broadcast_event, broadcast_success
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/channels", tags=["channels"])
|
||||
@@ -31,12 +34,166 @@ class CreateChannelRequest(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class BulkCreateHashtagChannelsRequest(BaseModel):
|
||||
channel_names: list[str] = Field(
|
||||
min_length=1,
|
||||
description="List of hashtag room names. Leading # is optional per entry.",
|
||||
)
|
||||
try_historical: bool = Field(
|
||||
default=False,
|
||||
description="Attempt one background historical decrypt sweep for the newly added rooms.",
|
||||
)
|
||||
|
||||
|
||||
class BulkCreateHashtagChannelsResponse(BaseModel):
|
||||
created_channels: list[Channel]
|
||||
existing_count: int
|
||||
invalid_names: list[str]
|
||||
decrypt_started: bool = False
|
||||
decrypt_total_packets: int = 0
|
||||
message: str
|
||||
|
||||
|
||||
class ChannelFloodScopeOverrideRequest(BaseModel):
|
||||
flood_scope_override: str = Field(
|
||||
description="Blank clears the override; non-empty values temporarily override flood scope"
|
||||
)
|
||||
|
||||
|
||||
class ChannelPathHashModeOverrideRequest(BaseModel):
|
||||
path_hash_mode_override: int | None = Field(
|
||||
default=None,
|
||||
ge=0,
|
||||
le=2,
|
||||
description="Path hash mode override (0=1-byte, 1=2-byte, 2=3-byte, null = use radio default)",
|
||||
)
|
||||
|
||||
|
||||
def _derive_channel_identity(
|
||||
requested_name: str,
|
||||
request_key: str | None = None,
|
||||
) -> tuple[str, str, bool]:
|
||||
is_hashtag = requested_name.startswith("#")
|
||||
|
||||
if is_public_channel_name(requested_name):
|
||||
if request_key:
|
||||
try:
|
||||
key_bytes = bytes.fromhex(request_key)
|
||||
if len(key_bytes) != 16:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Channel key must be exactly 16 bytes (32 hex chars)",
|
||||
)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
|
||||
if key_bytes.hex().upper() != PUBLIC_CHANNEL_KEY:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f'"{PUBLIC_CHANNEL_NAME}" must use the canonical Public key',
|
||||
)
|
||||
return PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME, False
|
||||
|
||||
if request_key and not is_hashtag:
|
||||
try:
|
||||
key_bytes = bytes.fromhex(request_key)
|
||||
if len(key_bytes) != 16:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Channel key must be exactly 16 bytes (32 hex chars)"
|
||||
)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
|
||||
key_hex = key_bytes.hex().upper()
|
||||
if is_public_channel_key(key_hex):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f'The canonical Public key may only be used for "{PUBLIC_CHANNEL_NAME}"',
|
||||
)
|
||||
return key_hex, requested_name, False
|
||||
|
||||
key_bytes = sha256(requested_name.encode("utf-8")).digest()[:16]
|
||||
return key_bytes.hex().upper(), requested_name, is_hashtag
|
||||
|
||||
|
||||
def _normalize_bulk_hashtag_name(name: str) -> str | None:
|
||||
trimmed = name.strip()
|
||||
if not trimmed:
|
||||
return None
|
||||
normalized = trimmed.lstrip("#").strip()
|
||||
if not normalized:
|
||||
return None
|
||||
if len(normalized) > 31:
|
||||
return None
|
||||
if not re.fullmatch(r"[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*", normalized):
|
||||
return None
|
||||
return f"#{normalized}"
|
||||
|
||||
|
||||
async def _run_historical_channel_decryption_for_channels(
|
||||
channels: list[tuple[bytes, str, str]],
|
||||
) -> None:
|
||||
total = await RawPacketRepository.get_undecrypted_count()
|
||||
decrypted_count = 0
|
||||
matched_channel_names: set[str] = set()
|
||||
|
||||
if total == 0:
|
||||
logger.info("No undecrypted packets to process for bulk channel decrypt")
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Starting bulk historical channel decryption of %d packets across %d channels",
|
||||
total,
|
||||
len(channels),
|
||||
)
|
||||
|
||||
async for (
|
||||
packet_id,
|
||||
packet_data,
|
||||
packet_timestamp,
|
||||
) in RawPacketRepository.stream_all_undecrypted():
|
||||
packet_info = parse_packet(packet_data)
|
||||
path_hex = packet_info.path.hex() if packet_info else None
|
||||
path_len = packet_info.path_length if packet_info else None
|
||||
|
||||
for channel_key_bytes, channel_key_hex, channel_name in channels:
|
||||
result = try_decrypt_packet_with_channel_key(packet_data, channel_key_bytes)
|
||||
if result is None:
|
||||
continue
|
||||
|
||||
msg_id = await create_message_from_decrypted(
|
||||
packet_id=packet_id,
|
||||
channel_key=channel_key_hex,
|
||||
channel_name=channel_name,
|
||||
sender=result.sender,
|
||||
message_text=result.message,
|
||||
timestamp=result.timestamp,
|
||||
received_at=packet_timestamp,
|
||||
path=path_hex,
|
||||
path_len=path_len,
|
||||
realtime=False,
|
||||
)
|
||||
if msg_id is not None:
|
||||
decrypted_count += 1
|
||||
matched_channel_names.add(channel_name)
|
||||
break
|
||||
|
||||
logger.info(
|
||||
"Bulk historical channel decryption complete: %d/%d packets decrypted across %d channels",
|
||||
decrypted_count,
|
||||
total,
|
||||
len(matched_channel_names),
|
||||
)
|
||||
|
||||
if decrypted_count > 0:
|
||||
broadcast_success(
|
||||
"Bulk historical decrypt complete",
|
||||
(
|
||||
f"Decrypted {decrypted_count} message{'s' if decrypted_count != 1 else ''} "
|
||||
f"across {len(matched_channel_names)} room"
|
||||
f"{'s' if len(matched_channel_names) != 1 else ''}"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[Channel])
|
||||
async def list_channels() -> list[Channel]:
|
||||
"""List all channels from the database."""
|
||||
@@ -58,6 +215,7 @@ async def get_channel_detail(key: str) -> ChannelDetail:
|
||||
first_message_at=stats["first_message_at"],
|
||||
unique_sender_count=stats["unique_sender_count"],
|
||||
top_senders_24h=[ChannelTopSender(**s) for s in stats["top_senders_24h"]],
|
||||
path_hash_width_24h=stats["path_hash_width_24h"],
|
||||
)
|
||||
|
||||
|
||||
@@ -69,50 +227,7 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
|
||||
automatically when sending a message (see messages.py send_channel_message).
|
||||
"""
|
||||
requested_name = request.name
|
||||
is_hashtag = requested_name.startswith("#")
|
||||
|
||||
# Reserve the canonical Public channel so it cannot drift to another key,
|
||||
# and the well-known Public key cannot be renamed to something else.
|
||||
if is_public_channel_name(requested_name):
|
||||
if request.key:
|
||||
try:
|
||||
key_bytes = bytes.fromhex(request.key)
|
||||
if len(key_bytes) != 16:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Channel key must be exactly 16 bytes (32 hex chars)",
|
||||
)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
|
||||
if key_bytes.hex().upper() != PUBLIC_CHANNEL_KEY:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f'"{PUBLIC_CHANNEL_NAME}" must use the canonical Public key',
|
||||
)
|
||||
key_hex = PUBLIC_CHANNEL_KEY
|
||||
channel_name = PUBLIC_CHANNEL_NAME
|
||||
is_hashtag = False
|
||||
elif request.key and not is_hashtag:
|
||||
try:
|
||||
key_bytes = bytes.fromhex(request.key)
|
||||
if len(key_bytes) != 16:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Channel key must be exactly 16 bytes (32 hex chars)"
|
||||
)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
|
||||
key_hex = key_bytes.hex().upper()
|
||||
if is_public_channel_key(key_hex):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f'The canonical Public key may only be used for "{PUBLIC_CHANNEL_NAME}"',
|
||||
)
|
||||
channel_name = requested_name
|
||||
else:
|
||||
# Derive key from name hash (same as meshcore library does)
|
||||
key_bytes = sha256(requested_name.encode("utf-8")).digest()[:16]
|
||||
key_hex = key_bytes.hex().upper()
|
||||
channel_name = requested_name
|
||||
key_hex, channel_name, is_hashtag = _derive_channel_identity(requested_name, request.key)
|
||||
|
||||
logger.info("Creating channel %s: %s (hashtag=%s)", key_hex, channel_name, is_hashtag)
|
||||
|
||||
@@ -132,6 +247,81 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
|
||||
return stored
|
||||
|
||||
|
||||
@router.post("/bulk-hashtag", response_model=BulkCreateHashtagChannelsResponse)
|
||||
async def bulk_create_hashtag_channels(
|
||||
request: BulkCreateHashtagChannelsRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
response: Response,
|
||||
) -> BulkCreateHashtagChannelsResponse:
|
||||
created_channels: list[Channel] = []
|
||||
existing_count = 0
|
||||
invalid_names: list[str] = []
|
||||
decrypt_started = False
|
||||
decrypt_total_packets = 0
|
||||
decrypt_targets: list[tuple[bytes, str, str]] = []
|
||||
|
||||
for raw_name in request.channel_names:
|
||||
normalized_name = _normalize_bulk_hashtag_name(raw_name)
|
||||
if normalized_name is None:
|
||||
invalid_names.append(raw_name)
|
||||
continue
|
||||
|
||||
key_hex, channel_name, is_hashtag = _derive_channel_identity(normalized_name)
|
||||
existing = await ChannelRepository.get_by_key(key_hex)
|
||||
if existing is not None:
|
||||
existing_count += 1
|
||||
continue
|
||||
|
||||
await ChannelRepository.upsert(
|
||||
key=key_hex,
|
||||
name=channel_name,
|
||||
is_hashtag=is_hashtag,
|
||||
on_radio=False,
|
||||
)
|
||||
stored = await ChannelRepository.get_by_key(key_hex)
|
||||
if stored is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Channel was created but could not be reloaded",
|
||||
)
|
||||
|
||||
created_channels.append(stored)
|
||||
decrypt_targets.append((bytes.fromhex(stored.key), stored.key, stored.name))
|
||||
_broadcast_channel_update(stored)
|
||||
|
||||
if request.try_historical and decrypt_targets:
|
||||
decrypt_total_packets = await RawPacketRepository.get_undecrypted_count()
|
||||
if decrypt_total_packets > 0:
|
||||
background_tasks.add_task(
|
||||
_run_historical_channel_decryption_for_channels, decrypt_targets
|
||||
)
|
||||
decrypt_started = True
|
||||
response.status_code = status.HTTP_202_ACCEPTED
|
||||
|
||||
message = (
|
||||
f"Created {len(created_channels)} room{'s' if len(created_channels) != 1 else ''}"
|
||||
if created_channels
|
||||
else "No new rooms were added"
|
||||
)
|
||||
if request.try_historical and decrypt_targets:
|
||||
if decrypt_started:
|
||||
message += (
|
||||
f" and started background decrypt of {decrypt_total_packets} packet"
|
||||
f"{'s' if decrypt_total_packets != 1 else ''}"
|
||||
)
|
||||
else:
|
||||
message += "; no undecrypted packets were available"
|
||||
|
||||
return BulkCreateHashtagChannelsResponse(
|
||||
created_channels=created_channels,
|
||||
existing_count=existing_count,
|
||||
invalid_names=invalid_names,
|
||||
decrypt_started=decrypt_started,
|
||||
decrypt_total_packets=decrypt_total_packets,
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{key}/mark-read")
|
||||
async def mark_channel_read(key: str) -> dict:
|
||||
"""Mark a channel as read (update last_read_at timestamp)."""
|
||||
@@ -168,6 +358,29 @@ async def set_channel_flood_scope_override(
|
||||
return refreshed
|
||||
|
||||
|
||||
@router.post("/{key}/path-hash-mode-override", response_model=Channel)
|
||||
async def set_channel_path_hash_mode_override(
|
||||
key: str, request: ChannelPathHashModeOverrideRequest
|
||||
) -> Channel:
|
||||
"""Set or clear a per-channel path hash mode override."""
|
||||
channel = await ChannelRepository.get_by_key(key)
|
||||
if not channel:
|
||||
raise HTTPException(status_code=404, detail="Channel not found")
|
||||
|
||||
updated = await ChannelRepository.update_path_hash_mode_override(
|
||||
channel.key, request.path_hash_mode_override
|
||||
)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=500, detail="Failed to update path-hash-mode override")
|
||||
|
||||
refreshed = await ChannelRepository.get_by_key(channel.key)
|
||||
if refreshed is None:
|
||||
raise HTTPException(status_code=500, detail="Channel disappeared after update")
|
||||
|
||||
broadcast_event("channel", refreshed.model_dump())
|
||||
return refreshed
|
||||
|
||||
|
||||
@router.delete("/{key}")
|
||||
async def delete_channel(key: str) -> dict:
|
||||
"""Delete a channel from the database by key.
|
||||
|
||||
+66
-12
@@ -1,12 +1,13 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from contextlib import suppress
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
|
||||
from meshcore import EventType
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.models import (
|
||||
Contact,
|
||||
ContactActiveRoom,
|
||||
@@ -31,7 +32,7 @@ from app.repository import (
|
||||
)
|
||||
from app.services.contact_reconciliation import (
|
||||
promote_prefix_contacts_for_contact,
|
||||
reconcile_contact_messages,
|
||||
record_contact_name_and_reconcile,
|
||||
)
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
@@ -40,6 +41,10 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/contacts", tags=["contacts"])
|
||||
|
||||
|
||||
TRACE_HASH_BYTES = 4
|
||||
TRACE_FLAGS_4BYTE = 2
|
||||
|
||||
|
||||
def _ambiguous_contact_detail(err: AmbiguousPublicKeyPrefixError) -> str:
|
||||
sample = ", ".join(key[:12] for key in err.matches[:2])
|
||||
return (
|
||||
@@ -273,12 +278,18 @@ async def create_contact(
|
||||
# Check if contact already exists
|
||||
existing = await ContactRepository.get_by_key(request.public_key)
|
||||
if existing:
|
||||
# Update name if provided
|
||||
# Update name if provided and record name history
|
||||
if request.name:
|
||||
await ContactRepository.upsert(existing.to_upsert(name=request.name))
|
||||
refreshed = await ContactRepository.get_by_key(request.public_key)
|
||||
if refreshed is not None:
|
||||
existing = refreshed
|
||||
await record_contact_name_and_reconcile(
|
||||
public_key=request.public_key,
|
||||
contact_name=request.name,
|
||||
timestamp=int(time.time()),
|
||||
log=logger,
|
||||
)
|
||||
|
||||
promoted_keys = await promote_prefix_contacts_for_contact(
|
||||
public_key=request.public_key,
|
||||
@@ -313,9 +324,10 @@ async def create_contact(
|
||||
log=logger,
|
||||
)
|
||||
|
||||
await reconcile_contact_messages(
|
||||
await record_contact_name_and_reconcile(
|
||||
public_key=lower_key,
|
||||
contact_name=request.name,
|
||||
timestamp=int(time.time()),
|
||||
log=logger,
|
||||
)
|
||||
|
||||
@@ -343,6 +355,44 @@ async def mark_contact_read(public_key: str) -> dict:
|
||||
return {"status": "ok", "public_key": contact.public_key}
|
||||
|
||||
|
||||
class BulkDeleteRequest(BaseModel):
|
||||
public_keys: list[str] = Field(description="Public keys to delete")
|
||||
|
||||
|
||||
@router.post("/bulk-delete")
|
||||
async def bulk_delete_contacts(request: BulkDeleteRequest) -> dict:
|
||||
"""Delete multiple contacts from the database (and radio if present)."""
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
# Resolve all contacts first
|
||||
contacts_to_delete: list[Contact] = []
|
||||
for key in request.public_keys:
|
||||
contact = await ContactRepository.get_by_key(key.lower())
|
||||
if contact:
|
||||
contacts_to_delete.append(contact)
|
||||
|
||||
# Remove from radio in a single locked operation (blocks until radio is free)
|
||||
if radio_manager.is_connected and contacts_to_delete:
|
||||
try:
|
||||
async with radio_manager.radio_operation("bulk_delete_contacts_from_radio") as mc:
|
||||
for contact in contacts_to_delete:
|
||||
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
|
||||
if radio_contact:
|
||||
await mc.commands.remove_contact(radio_contact)
|
||||
except Exception as e:
|
||||
logger.warning("Radio removal during bulk delete failed: %s", e)
|
||||
|
||||
# Delete from database and broadcast events
|
||||
deleted = 0
|
||||
for contact in contacts_to_delete:
|
||||
await ContactRepository.delete(contact.public_key)
|
||||
broadcast_event("contact_deleted", {"public_key": contact.public_key})
|
||||
deleted += 1
|
||||
|
||||
logger.info("Bulk deleted %d/%d contacts", deleted, len(request.public_keys))
|
||||
return {"deleted": deleted}
|
||||
|
||||
|
||||
@router.delete("/{public_key}")
|
||||
async def delete_contact(public_key: str) -> dict:
|
||||
"""Delete a contact from the database (and radio if present)."""
|
||||
@@ -373,17 +423,17 @@ async def delete_contact(public_key: str) -> dict:
|
||||
async def request_trace(public_key: str) -> TraceResponse:
|
||||
"""Send a single-hop trace to a contact and wait for the result.
|
||||
|
||||
The trace path contains the contact's 1-byte pubkey hash as the sole hop
|
||||
(no intermediate repeaters). The radio firmware requires at least one
|
||||
node in the path.
|
||||
The trace path contains the contact's 4-byte pubkey hash as the sole hop
|
||||
(no intermediate repeaters). This uses TRACE's dedicated width flags rather
|
||||
than the radio's normal path_hash_mode setting.
|
||||
"""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
|
||||
tag = random.randint(1, 0xFFFFFFFF)
|
||||
# First 2 hex chars of pubkey = 1-byte hash used by the trace protocol
|
||||
contact_hash = contact.public_key[:2]
|
||||
# Use a 4-byte contact hash for low-collision direct trace targeting.
|
||||
contact_hash = contact.public_key[: TRACE_HASH_BYTES * 2]
|
||||
|
||||
# Trace does not need auto-fetch suspension: response arrives as TRACE_DATA
|
||||
# from the reader loop, not via get_msg().
|
||||
@@ -394,7 +444,11 @@ async def request_trace(public_key: str) -> TraceResponse:
|
||||
logger.info(
|
||||
"Sending trace to %s (tag=%d, hash=%s)", contact.public_key[:12], tag, contact_hash
|
||||
)
|
||||
result = await mc.commands.send_trace(path=contact_hash, tag=tag)
|
||||
result = await mc.commands.send_trace(
|
||||
path=contact_hash,
|
||||
tag=tag,
|
||||
flags=TRACE_FLAGS_4BYTE,
|
||||
)
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to send trace: {result.payload}")
|
||||
@@ -432,7 +486,7 @@ async def request_trace(public_key: str) -> TraceResponse:
|
||||
@router.post("/{public_key}/path-discovery", response_model=PathDiscoveryResponse)
|
||||
async def request_path_discovery(public_key: str) -> PathDiscoveryResponse:
|
||||
"""Discover the current forward and return paths to a known contact."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
pubkey_prefix = contact.public_key[:12]
|
||||
|
||||
+144
-25
@@ -1,17 +1,21 @@
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import struct
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter
|
||||
from meshcore import EventType
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.config import get_recent_log_lines, settings
|
||||
from app.models import AppSettings
|
||||
from app.radio_sync import get_contacts_selected_for_radio_sync, get_radio_channel_limit
|
||||
from app.repository import MessageRepository, StatisticsRepository
|
||||
from app.routers.health import HealthResponse, build_health_data
|
||||
from app.repository import AppSettingsRepository, MessageRepository, StatisticsRepository
|
||||
from app.routers.health import FanoutStatusResponse, build_health_data
|
||||
from app.services.radio_runtime import radio_runtime
|
||||
from app.version_info import get_app_build_info, git_output
|
||||
|
||||
@@ -34,6 +38,13 @@ LOG_COPY_BOUNDARY_PREFIX = [
|
||||
]
|
||||
|
||||
|
||||
class DebugSystemInfo(BaseModel):
|
||||
os: str
|
||||
arch: str
|
||||
arch_bits: int
|
||||
total_ram_mb: int
|
||||
|
||||
|
||||
class DebugApplicationInfo(BaseModel):
|
||||
version: str
|
||||
version_source: str
|
||||
@@ -50,8 +61,6 @@ class DebugRuntimeInfo(BaseModel):
|
||||
setup_in_progress: bool
|
||||
setup_complete: bool
|
||||
channels_with_incoming_messages: int
|
||||
max_channels: int
|
||||
path_hash_mode: int
|
||||
path_hash_mode_supported: bool
|
||||
channel_slot_reuse_enabled: bool
|
||||
channel_send_cache_capacity: int
|
||||
@@ -78,7 +87,6 @@ class DebugChannelAudit(BaseModel):
|
||||
class DebugRadioProbe(BaseModel):
|
||||
performed: bool
|
||||
errors: list[str] = Field(default_factory=list)
|
||||
multi_acks_enabled: bool | None = None
|
||||
self_info: dict[str, Any] | None = None
|
||||
device_info: dict[str, Any] | None = None
|
||||
stats_core: dict[str, Any] | None = None
|
||||
@@ -93,16 +101,53 @@ class DebugDatabaseInfo(BaseModel):
|
||||
total_outgoing: int
|
||||
|
||||
|
||||
class DebugHealthSummary(BaseModel):
|
||||
radio_state: str
|
||||
database_size_mb: float
|
||||
oldest_undecrypted_timestamp: int | None
|
||||
fanouts_with_errors: dict[str, FanoutStatusResponse] = Field(default_factory=dict)
|
||||
bots_disabled_source: Literal["env", "until_restart"] | None = None
|
||||
basic_auth_enabled: bool = False
|
||||
|
||||
|
||||
class DebugAppSettings(BaseModel):
|
||||
max_radio_contacts: int
|
||||
auto_decrypt_dm_on_advert: bool
|
||||
advert_interval: int
|
||||
flood_scope: str
|
||||
blocked_keys_count: int
|
||||
blocked_names_count: int
|
||||
|
||||
|
||||
class DebugSnapshotResponse(BaseModel):
|
||||
captured_at: str
|
||||
system: DebugSystemInfo
|
||||
application: DebugApplicationInfo
|
||||
health: HealthResponse
|
||||
health: DebugHealthSummary
|
||||
settings: DebugAppSettings
|
||||
runtime: DebugRuntimeInfo
|
||||
database: DebugDatabaseInfo
|
||||
radio_probe: DebugRadioProbe
|
||||
logs: list[str]
|
||||
|
||||
|
||||
def _build_system_info() -> DebugSystemInfo:
|
||||
try:
|
||||
# os.sysconf is available on Linux/macOS
|
||||
page_size = os.sysconf("SC_PAGE_SIZE")
|
||||
page_count = os.sysconf("SC_PHYS_PAGES")
|
||||
total_ram_mb = (page_size * page_count) // (1024 * 1024)
|
||||
except (AttributeError, ValueError, OSError):
|
||||
total_ram_mb = 0
|
||||
|
||||
return DebugSystemInfo(
|
||||
os=f"{platform.system()} {platform.release()}",
|
||||
arch=platform.machine(),
|
||||
arch_bits=struct.calcsize("P") * 8,
|
||||
total_ram_mb=total_ram_mb,
|
||||
)
|
||||
|
||||
|
||||
def _build_application_info() -> DebugApplicationInfo:
|
||||
build_info = get_app_build_info()
|
||||
dirty_output = git_output("status", "--porcelain")
|
||||
@@ -158,6 +203,68 @@ def _coerce_live_max_channels(device_info: dict[str, Any] | None) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
def _build_debug_app_settings(app_settings: AppSettings) -> DebugAppSettings:
|
||||
return DebugAppSettings(
|
||||
max_radio_contacts=app_settings.max_radio_contacts,
|
||||
auto_decrypt_dm_on_advert=app_settings.auto_decrypt_dm_on_advert,
|
||||
advert_interval=app_settings.advert_interval,
|
||||
flood_scope=app_settings.flood_scope,
|
||||
blocked_keys_count=len(app_settings.blocked_keys),
|
||||
blocked_names_count=len(app_settings.blocked_names),
|
||||
)
|
||||
|
||||
|
||||
def _derive_debug_radio_state(
|
||||
*,
|
||||
radio_connected: bool,
|
||||
connection_desired: bool,
|
||||
setup_in_progress: bool,
|
||||
setup_complete: bool,
|
||||
is_reconnecting: bool,
|
||||
) -> str:
|
||||
if not connection_desired:
|
||||
return "paused"
|
||||
if radio_connected and (setup_in_progress or not setup_complete):
|
||||
return "initializing"
|
||||
if radio_connected:
|
||||
return "connected"
|
||||
if is_reconnecting:
|
||||
return "connecting"
|
||||
return "disconnected"
|
||||
|
||||
|
||||
def _build_debug_health_summary(
|
||||
health_data: dict[str, Any], *, radio_state: str
|
||||
) -> DebugHealthSummary:
|
||||
def _fanout_last_error(status: Any) -> str | None:
|
||||
if isinstance(status, dict):
|
||||
value = status.get("last_error")
|
||||
else:
|
||||
value = getattr(status, "last_error", None)
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
fanouts_with_errors = {
|
||||
config_id: status
|
||||
for config_id, status in health_data["fanout_statuses"].items()
|
||||
if _fanout_last_error(status)
|
||||
}
|
||||
return DebugHealthSummary(
|
||||
radio_state=radio_state,
|
||||
database_size_mb=health_data["database_size_mb"],
|
||||
oldest_undecrypted_timestamp=health_data["oldest_undecrypted_timestamp"],
|
||||
fanouts_with_errors=fanouts_with_errors,
|
||||
bots_disabled_source=health_data["bots_disabled_source"],
|
||||
basic_auth_enabled=health_data["basic_auth_enabled"],
|
||||
)
|
||||
|
||||
|
||||
def _sanitize_radio_probe_self_info(self_info: dict[str, Any] | None) -> dict[str, Any]:
|
||||
sanitized = dict(self_info or {})
|
||||
sanitized.pop("adv_lat", None)
|
||||
sanitized.pop("adv_lon", None)
|
||||
return sanitized
|
||||
|
||||
|
||||
async def _build_contact_audit(
|
||||
observed_contacts_payload: dict[str, dict[str, Any]],
|
||||
) -> DebugContactAudit:
|
||||
@@ -242,10 +349,7 @@ async def _probe_radio() -> DebugRadioProbe:
|
||||
return DebugRadioProbe(
|
||||
performed=True,
|
||||
errors=errors,
|
||||
multi_acks_enabled=bool(mc.self_info.get("multi_acks", 0))
|
||||
if mc.self_info is not None
|
||||
else None,
|
||||
self_info=dict(mc.self_info or {}),
|
||||
self_info=_sanitize_radio_probe_self_info(mc.self_info),
|
||||
device_info=device_info,
|
||||
stats_core=stats_core,
|
||||
stats_radio=stats_radio,
|
||||
@@ -264,24 +368,39 @@ async def _probe_radio() -> DebugRadioProbe:
|
||||
@router.get("/debug", response_model=DebugSnapshotResponse)
|
||||
async def debug_support_snapshot() -> DebugSnapshotResponse:
|
||||
"""Return a support/debug snapshot with recent logs and live radio state."""
|
||||
health_data = await build_health_data(radio_runtime.is_connected, radio_runtime.connection_info)
|
||||
statistics = await StatisticsRepository.get_all()
|
||||
connection_info = radio_runtime.connection_info
|
||||
connection_desired = radio_runtime.connection_desired
|
||||
setup_in_progress = radio_runtime.is_setup_in_progress
|
||||
setup_complete = radio_runtime.is_setup_complete
|
||||
radio_connected = radio_runtime.is_connected
|
||||
is_reconnecting = getattr(radio_runtime, "is_reconnecting", False)
|
||||
|
||||
health_data = await build_health_data(radio_connected, connection_info)
|
||||
app_settings = await AppSettingsRepository.get()
|
||||
message_totals = await StatisticsRepository.get_database_message_totals()
|
||||
radio_probe = await _probe_radio()
|
||||
channels_with_incoming_messages = (
|
||||
await MessageRepository.count_channels_with_incoming_messages()
|
||||
)
|
||||
radio_state = _derive_debug_radio_state(
|
||||
radio_connected=radio_connected,
|
||||
connection_desired=connection_desired,
|
||||
setup_in_progress=setup_in_progress,
|
||||
setup_complete=setup_complete,
|
||||
is_reconnecting=is_reconnecting,
|
||||
)
|
||||
return DebugSnapshotResponse(
|
||||
captured_at=datetime.now(timezone.utc).isoformat(),
|
||||
captured_at=datetime.now(UTC).isoformat(),
|
||||
system=_build_system_info(),
|
||||
application=_build_application_info(),
|
||||
health=HealthResponse(**health_data),
|
||||
health=_build_debug_health_summary(health_data, radio_state=radio_state),
|
||||
settings=_build_debug_app_settings(app_settings),
|
||||
runtime=DebugRuntimeInfo(
|
||||
connection_info=radio_runtime.connection_info,
|
||||
connection_desired=radio_runtime.connection_desired,
|
||||
setup_in_progress=radio_runtime.is_setup_in_progress,
|
||||
setup_complete=radio_runtime.is_setup_complete,
|
||||
connection_info=connection_info,
|
||||
connection_desired=connection_desired,
|
||||
setup_in_progress=setup_in_progress,
|
||||
setup_complete=setup_complete,
|
||||
channels_with_incoming_messages=channels_with_incoming_messages,
|
||||
max_channels=radio_runtime.max_channels,
|
||||
path_hash_mode=radio_runtime.path_hash_mode,
|
||||
path_hash_mode_supported=radio_runtime.path_hash_mode_supported,
|
||||
channel_slot_reuse_enabled=radio_runtime.channel_slot_reuse_enabled(),
|
||||
channel_send_cache_capacity=radio_runtime.get_channel_send_cache_capacity(),
|
||||
@@ -291,9 +410,9 @@ async def debug_support_snapshot() -> DebugSnapshotResponse:
|
||||
},
|
||||
),
|
||||
database=DebugDatabaseInfo(
|
||||
total_dms=statistics["total_dms"],
|
||||
total_channel_messages=statistics["total_channel_messages"],
|
||||
total_outgoing=statistics["total_outgoing"],
|
||||
total_dms=message_totals["total_dms"],
|
||||
total_channel_messages=message_totals["total_channel_messages"],
|
||||
total_outgoing=message_totals["total_outgoing"],
|
||||
),
|
||||
radio_probe=radio_probe,
|
||||
logs=[*LOG_COPY_BOUNDARY_PREFIX, *get_recent_log_lines(limit=1000)],
|
||||
|
||||
@@ -7,6 +7,7 @@ from pydantic import BaseModel, Field
|
||||
from app.config import settings
|
||||
from app.repository import RawPacketRepository
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
from app.services.radio_stats import get_latest_radio_stats
|
||||
from app.version_info import get_app_build_info
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
@@ -32,6 +33,28 @@ class FanoutStatusResponse(BaseModel):
|
||||
last_error: str | None = None
|
||||
|
||||
|
||||
class RadioStatsSnapshot(BaseModel):
|
||||
"""Latest cached stats from the local radio's periodic 60s poll."""
|
||||
|
||||
timestamp: int | None = None
|
||||
# Core stats
|
||||
battery_mv: int | None = None
|
||||
uptime_secs: int | None = None
|
||||
# Radio stats
|
||||
noise_floor: int | None = None
|
||||
last_rssi: int | None = None
|
||||
last_snr: float | None = None
|
||||
tx_air_secs: int | None = None
|
||||
rx_air_secs: int | None = None
|
||||
# Packet stats
|
||||
packets_recv: int | None = None
|
||||
packets_sent: int | None = None
|
||||
flood_tx: int | None = None
|
||||
direct_tx: int | None = None
|
||||
flood_rx: int | None = None
|
||||
direct_rx: int | None = None
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
radio_connected: bool
|
||||
@@ -40,6 +63,7 @@ class HealthResponse(BaseModel):
|
||||
connection_info: str | None
|
||||
app_info: AppInfoResponse | None = None
|
||||
radio_device_info: RadioDeviceInfoResponse | None = None
|
||||
radio_stats: RadioStatsSnapshot | None = None
|
||||
database_size_mb: float
|
||||
oldest_undecrypted_timestamp: int | None
|
||||
fanout_statuses: dict[str, FanoutStatusResponse] = Field(default_factory=dict)
|
||||
@@ -122,6 +146,28 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
|
||||
"max_channels": getattr(radio_manager, "max_channels", None),
|
||||
}
|
||||
|
||||
# Local radio stats from the 60s background sampler
|
||||
raw_stats = get_latest_radio_stats()
|
||||
radio_stats = None
|
||||
if raw_stats:
|
||||
packets = raw_stats.get("packets") or {}
|
||||
radio_stats = {
|
||||
"timestamp": raw_stats.get("timestamp"),
|
||||
"battery_mv": raw_stats.get("battery_mv"),
|
||||
"uptime_secs": raw_stats.get("uptime_secs"),
|
||||
"noise_floor": raw_stats.get("noise_floor"),
|
||||
"last_rssi": raw_stats.get("last_rssi"),
|
||||
"last_snr": raw_stats.get("last_snr"),
|
||||
"tx_air_secs": raw_stats.get("tx_air_secs"),
|
||||
"rx_air_secs": raw_stats.get("rx_air_secs"),
|
||||
"packets_recv": packets.get("recv"),
|
||||
"packets_sent": packets.get("sent"),
|
||||
"flood_tx": packets.get("flood_tx"),
|
||||
"direct_tx": packets.get("direct_tx"),
|
||||
"flood_rx": packets.get("flood_rx"),
|
||||
"direct_rx": packets.get("direct_rx"),
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "ok" if radio_connected and not radio_initializing else "degraded",
|
||||
"radio_connected": radio_connected,
|
||||
@@ -133,6 +179,7 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
|
||||
"commit_hash": app_build_info.commit_hash,
|
||||
},
|
||||
"radio_device_info": radio_device_info,
|
||||
"radio_stats": radio_stats,
|
||||
"database_size_mb": db_size_mb,
|
||||
"oldest_undecrypted_timestamp": oldest_ts,
|
||||
"fanout_statuses": fanout_statuses,
|
||||
|
||||
@@ -3,7 +3,6 @@ import time
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.event_handlers import track_pending_ack
|
||||
from app.models import (
|
||||
Message,
|
||||
@@ -89,7 +88,7 @@ async def list_messages(
|
||||
@router.post("/direct", response_model=Message)
|
||||
async def send_direct_message(request: SendDirectMessageRequest) -> Message:
|
||||
"""Send a direct message to a contact."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
|
||||
# First check our database for the contact
|
||||
from app.repository import ContactRepository
|
||||
@@ -136,7 +135,7 @@ TEMP_RADIO_SLOT = 0
|
||||
@router.post("/channel", response_model=Message)
|
||||
async def send_channel_message(request: SendChannelMessageRequest) -> Message:
|
||||
"""Send a message to a channel."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
|
||||
# Get channel info from our database
|
||||
from app.repository import ChannelRepository
|
||||
@@ -189,7 +188,7 @@ async def resend_channel_message(
|
||||
When new_timestamp=True: resend with a fresh timestamp so repeaters treat it as a
|
||||
new packet. Creates a new message row in the database. No time window restriction.
|
||||
"""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
|
||||
from app.repository import ChannelRepository
|
||||
|
||||
|
||||
@@ -49,8 +49,7 @@ async def _run_historical_channel_decryption(
|
||||
channel_key_bytes: bytes, channel_key_hex: str, display_name: str | None = None
|
||||
) -> None:
|
||||
"""Background task to decrypt historical packets with a channel key."""
|
||||
packets = await RawPacketRepository.get_all_undecrypted()
|
||||
total = len(packets)
|
||||
total = await RawPacketRepository.get_undecrypted_count()
|
||||
decrypted_count = 0
|
||||
|
||||
if total == 0:
|
||||
@@ -59,7 +58,11 @@ async def _run_historical_channel_decryption(
|
||||
|
||||
logger.info("Starting historical channel decryption of %d packets", total)
|
||||
|
||||
for packet_id, packet_data, packet_timestamp in packets:
|
||||
async for (
|
||||
packet_id,
|
||||
packet_data,
|
||||
packet_timestamp,
|
||||
) in RawPacketRepository.stream_all_undecrypted():
|
||||
result = try_decrypt_packet_with_channel_key(packet_data, channel_key_bytes)
|
||||
|
||||
if result is not None:
|
||||
@@ -210,8 +213,7 @@ async def decrypt_historical_packets(
|
||||
except ValueError:
|
||||
raise _bad_request("Invalid hex string for contact public key") from None
|
||||
|
||||
packets = await RawPacketRepository.get_undecrypted_text_messages()
|
||||
count = len(packets)
|
||||
count = await RawPacketRepository.count_undecrypted_text_messages()
|
||||
if count == 0:
|
||||
return DecryptResult(
|
||||
started=False,
|
||||
|
||||
+240
-11
@@ -2,22 +2,32 @@ import asyncio
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from typing import Literal, TypeAlias
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from meshcore import EventType
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.models import (
|
||||
CONTACT_TYPE_REPEATER,
|
||||
ContactUpsert,
|
||||
RadioDiscoveryRequest,
|
||||
RadioDiscoveryResponse,
|
||||
RadioDiscoveryResult,
|
||||
RadioTraceHopRequest,
|
||||
RadioTraceNode,
|
||||
RadioTraceRequest,
|
||||
RadioTraceResponse,
|
||||
)
|
||||
from app.radio_sync import send_advertisement as do_send_advertisement
|
||||
from app.radio_sync import sync_radio_time
|
||||
from app.repository import ContactRepository
|
||||
from app.routers.server_control import _monotonic
|
||||
from app.services.contact_reconciliation import (
|
||||
promote_prefix_contacts_for_contact,
|
||||
reconcile_contact_messages,
|
||||
)
|
||||
from app.services.radio_commands import (
|
||||
KeystoreRefreshError,
|
||||
PathHashModeUnsupportedError,
|
||||
@@ -44,6 +54,12 @@ _DISCOVERY_NODE_TYPES: dict[int, DiscoveryNodeType] = {
|
||||
2: "repeater",
|
||||
4: "sensor",
|
||||
}
|
||||
TRACE_WAIT_TIMEOUT_SECONDS = 45.0
|
||||
TRACE_DEFAULT_TIMEOUT_SECONDS = 15.0
|
||||
TRACE_TIMEOUT_MIN_SECONDS = 5.0
|
||||
TRACE_TIMEOUT_MAX_SECONDS = 30.0
|
||||
TRACE_TIMEOUT_MARGIN = 1.2
|
||||
TRACE_HASH_FLAGS = {1: 0, 2: 1, 4: 2}
|
||||
|
||||
|
||||
async def _prepare_connected(*, broadcast_on_success: bool) -> bool:
|
||||
@@ -120,10 +136,6 @@ class RadioAdvertiseRequest(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
def _monotonic() -> float:
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
def _better_signal(first: float | None, second: float | None) -> float | None:
|
||||
if first is None:
|
||||
return second
|
||||
@@ -197,15 +209,132 @@ async def _persist_new_discovery_contacts(results: list[RadioDiscoveryResult]) -
|
||||
on_radio=False,
|
||||
)
|
||||
await ContactRepository.upsert(contact)
|
||||
promoted_keys = await promote_prefix_contacts_for_contact(
|
||||
public_key=result.public_key,
|
||||
log=logger,
|
||||
)
|
||||
await reconcile_contact_messages(
|
||||
public_key=result.public_key,
|
||||
contact_name=result.name,
|
||||
log=logger,
|
||||
)
|
||||
created = await ContactRepository.get_by_key(result.public_key)
|
||||
if created is not None:
|
||||
broadcast_event("contact", created.model_dump())
|
||||
for old_key in promoted_keys:
|
||||
broadcast_event(
|
||||
"contact_resolved",
|
||||
{"previous_public_key": old_key, "contact": created.model_dump()},
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
async def get_radio_config() -> RadioConfigResponse:
|
||||
"""Get the current radio configuration."""
|
||||
mc = require_connected()
|
||||
mc = radio_manager.require_connected()
|
||||
|
||||
info = mc.self_info
|
||||
if not info:
|
||||
@@ -237,7 +366,7 @@ async def get_radio_config() -> RadioConfigResponse:
|
||||
@router.patch("/config", response_model=RadioConfigResponse)
|
||||
async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
|
||||
"""Update radio configuration. Only provided fields will be updated."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
|
||||
async with radio_manager.radio_operation("update_radio_config") as mc:
|
||||
try:
|
||||
@@ -259,7 +388,7 @@ async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
|
||||
@router.put("/private-key")
|
||||
async def set_private_key(update: PrivateKeyUpdate) -> dict:
|
||||
"""Set the radio's private key. This is write-only."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
|
||||
try:
|
||||
key_bytes = bytes.fromhex(update.private_key)
|
||||
@@ -293,7 +422,7 @@ async def send_advertisement(request: RadioAdvertiseRequest | None = None) -> di
|
||||
Returns:
|
||||
status: "ok" if sent successfully
|
||||
"""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
mode: RadioAdvertMode = request.mode if request is not None else "flood"
|
||||
|
||||
logger.info("Sending %s advertisement", mode.replace("_", "-"))
|
||||
@@ -309,7 +438,7 @@ async def send_advertisement(request: RadioAdvertiseRequest | None = None) -> di
|
||||
@router.post("/discover", response_model=RadioDiscoveryResponse)
|
||||
async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryResponse:
|
||||
"""Run a short node-discovery sweep from the local radio."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
|
||||
target_bits = _DISCOVERY_TARGET_BITS[request.target]
|
||||
tag = random.randint(1, 0xFFFFFFFF)
|
||||
@@ -344,7 +473,7 @@ async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryRespons
|
||||
break
|
||||
try:
|
||||
event = await asyncio.wait_for(events.get(), timeout=remaining)
|
||||
except asyncio.TimeoutError:
|
||||
except TimeoutError:
|
||||
break
|
||||
|
||||
merged = _merge_discovery_result(
|
||||
@@ -365,6 +494,7 @@ async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryRespons
|
||||
),
|
||||
)
|
||||
await _persist_new_discovery_contacts(results)
|
||||
await _attach_known_names(results)
|
||||
return RadioDiscoveryResponse(
|
||||
target=request.target,
|
||||
duration_seconds=DISCOVERY_WINDOW_SECONDS,
|
||||
@@ -372,6 +502,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."""
|
||||
radio_manager.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 TimeoutError as exc:
|
||||
raise HTTPException(status_code=504, detail="No trace response heard") from exc
|
||||
finally:
|
||||
if not response_task.done():
|
||||
response_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await response_task
|
||||
|
||||
if event is None:
|
||||
raise HTTPException(status_code=504, detail="No trace response heard")
|
||||
|
||||
payload = event.payload if isinstance(event.payload, dict) else {}
|
||||
path_len = payload.get("path_len")
|
||||
if not isinstance(path_len, int):
|
||||
raise HTTPException(status_code=500, detail="Trace response was malformed")
|
||||
|
||||
raw_path = payload.get("path")
|
||||
path_nodes = raw_path if isinstance(raw_path, list) else []
|
||||
final_local_node = (
|
||||
path_nodes[-1]
|
||||
if path_nodes
|
||||
and isinstance(path_nodes[-1], dict)
|
||||
and not isinstance(path_nodes[-1].get("hash"), str)
|
||||
else None
|
||||
)
|
||||
hashed_nodes = path_nodes[:-1] if final_local_node is not None else path_nodes
|
||||
|
||||
if len(hashed_nodes) < len(trace_nodes):
|
||||
raise HTTPException(status_code=500, detail="Trace response was incomplete")
|
||||
|
||||
nodes: list[RadioTraceNode] = []
|
||||
for index, trace_node in enumerate(trace_nodes):
|
||||
observed = hashed_nodes[index] if index < len(hashed_nodes) else {}
|
||||
observed_hash = observed.get("hash") if isinstance(observed, dict) else None
|
||||
observed_snr = observed.get("snr") if isinstance(observed, dict) else None
|
||||
nodes.append(
|
||||
RadioTraceNode(
|
||||
role=trace_node.role,
|
||||
public_key=trace_node.public_key,
|
||||
name=trace_node.name,
|
||||
observed_hash=(
|
||||
observed_hash if isinstance(observed_hash, str) else trace_node.observed_hash
|
||||
),
|
||||
snr=float(observed_snr) if isinstance(observed_snr, (int, float)) else None,
|
||||
)
|
||||
)
|
||||
|
||||
terminal_snr_value = final_local_node.get("snr") if isinstance(final_local_node, dict) else None
|
||||
nodes.append(
|
||||
RadioTraceNode(
|
||||
role="local",
|
||||
public_key=local_public_key,
|
||||
name=local_name if isinstance(local_name, str) and local_name else None,
|
||||
observed_hash=None,
|
||||
snr=float(terminal_snr_value) if isinstance(terminal_snr_value, (int, float)) else None,
|
||||
)
|
||||
)
|
||||
|
||||
return RadioTraceResponse(
|
||||
path_len=path_len,
|
||||
timeout_seconds=timeout_seconds,
|
||||
nodes=nodes,
|
||||
)
|
||||
|
||||
|
||||
async def _attempt_reconnect() -> dict:
|
||||
"""Shared reconnection logic for reboot and reconnect endpoints."""
|
||||
radio_manager.resume_connection()
|
||||
|
||||
+64
-76
@@ -1,11 +1,9 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from meshcore import EventType
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.models import (
|
||||
CONTACT_TYPE_REPEATER,
|
||||
AclEntry,
|
||||
@@ -24,22 +22,18 @@ from app.models import (
|
||||
RepeaterOwnerInfoResponse,
|
||||
RepeaterRadioSettingsResponse,
|
||||
RepeaterStatusResponse,
|
||||
TelemetryHistoryEntry,
|
||||
)
|
||||
from app.repository import ContactRepository
|
||||
from app.repository import ContactRepository, RepeaterTelemetryRepository
|
||||
from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404
|
||||
from app.routers.server_control import (
|
||||
_monotonic,
|
||||
batch_cli_fetch,
|
||||
extract_response_text,
|
||||
prepare_authenticated_contact_connection,
|
||||
require_server_capable_contact,
|
||||
send_contact_cli_command,
|
||||
)
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from meshcore.events import Event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ACL permission level names
|
||||
@@ -53,62 +47,6 @@ router = APIRouter(prefix="/contacts", tags=["repeaters"])
|
||||
REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
|
||||
def _extract_response_text(event) -> str:
|
||||
return extract_response_text(event)
|
||||
|
||||
|
||||
async def _fetch_repeater_response(
|
||||
mc,
|
||||
target_pubkey_prefix: str,
|
||||
timeout: float = 20.0,
|
||||
) -> "Event | None":
|
||||
deadline = _monotonic() + timeout
|
||||
|
||||
while _monotonic() < deadline:
|
||||
try:
|
||||
result = await mc.commands.get_msg(timeout=2.0)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
except Exception as exc:
|
||||
logger.debug("get_msg() exception: %s", exc)
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
|
||||
if result.type == EventType.NO_MORE_MSGS:
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
logger.debug("get_msg() error: %s", result.payload)
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
|
||||
if result.type == EventType.CONTACT_MSG_RECV:
|
||||
msg_prefix = result.payload.get("pubkey_prefix", "")
|
||||
txt_type = result.payload.get("txt_type", 0)
|
||||
if msg_prefix == target_pubkey_prefix and txt_type == 1:
|
||||
return result
|
||||
logger.debug(
|
||||
"Skipping non-target message (from=%s, txt_type=%d) while waiting for %s",
|
||||
msg_prefix,
|
||||
txt_type,
|
||||
target_pubkey_prefix,
|
||||
)
|
||||
continue
|
||||
|
||||
if result.type == EventType.CHANNEL_MSG_RECV:
|
||||
logger.debug(
|
||||
"Skipping channel message (channel_idx=%s) during repeater fetch",
|
||||
result.payload.get("channel_idx"),
|
||||
)
|
||||
continue
|
||||
|
||||
logger.debug("Unexpected event type %s during repeater fetch, skipping", result.type)
|
||||
|
||||
logger.warning("No CLI response from repeater %s within %.1fs", target_pubkey_prefix, timeout)
|
||||
return None
|
||||
|
||||
|
||||
async def prepare_repeater_connection(mc, contact: Contact, password: str) -> RepeaterLoginResponse:
|
||||
return await prepare_authenticated_contact_connection(
|
||||
mc,
|
||||
@@ -137,7 +75,7 @@ def _require_repeater(contact: Contact) -> None:
|
||||
@router.post("/{public_key}/repeater/login", response_model=RepeaterLoginResponse)
|
||||
async def repeater_login(public_key: str, request: RepeaterLoginRequest) -> RepeaterLoginResponse:
|
||||
"""Attempt repeater login and report whether auth was confirmed."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
@@ -152,7 +90,7 @@ async def repeater_login(public_key: str, request: RepeaterLoginRequest) -> Repe
|
||||
@router.post("/{public_key}/repeater/status", response_model=RepeaterStatusResponse)
|
||||
async def repeater_status(public_key: str) -> RepeaterStatusResponse:
|
||||
"""Fetch status telemetry from a repeater (single attempt, 10s timeout)."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
@@ -167,7 +105,7 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
|
||||
if status is None:
|
||||
raise HTTPException(status_code=504, detail="No status response from repeater")
|
||||
|
||||
return RepeaterStatusResponse(
|
||||
response = RepeaterStatusResponse(
|
||||
battery_volts=status.get("bat", 0) / 1000.0,
|
||||
tx_queue_len=status.get("tx_queue_len", 0),
|
||||
noise_floor_dbm=status.get("noise_floor", 0),
|
||||
@@ -187,11 +125,61 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
|
||||
full_events=status.get("full_evts", 0),
|
||||
)
|
||||
|
||||
# Record to telemetry history as a JSON blob (best-effort)
|
||||
now = int(time.time())
|
||||
status_dict = response.model_dump(exclude={"telemetry_history"})
|
||||
try:
|
||||
await RepeaterTelemetryRepository.record(
|
||||
public_key=contact.public_key,
|
||||
timestamp=now,
|
||||
data=status_dict,
|
||||
)
|
||||
|
||||
# Dispatch to fanout modules (e.g. HA MQTT discovery)
|
||||
from app.fanout.manager import fanout_manager
|
||||
|
||||
asyncio.create_task(
|
||||
fanout_manager.broadcast_telemetry(
|
||||
{
|
||||
"public_key": contact.public_key,
|
||||
"name": contact.name or contact.public_key[:12],
|
||||
"timestamp": now,
|
||||
**status_dict,
|
||||
}
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to record telemetry history: %s", e)
|
||||
|
||||
# Fetch recent history and embed in response
|
||||
try:
|
||||
since = now - 30 * 86400 # last 30 days
|
||||
rows = await RepeaterTelemetryRepository.get_history(contact.public_key, since)
|
||||
response.telemetry_history = [TelemetryHistoryEntry(**row) for row in rows]
|
||||
except Exception as e:
|
||||
logger.warning("Failed to fetch telemetry history: %s", e)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{public_key}/repeater/telemetry-history",
|
||||
response_model=list[TelemetryHistoryEntry],
|
||||
)
|
||||
async def repeater_telemetry_history(public_key: str) -> list[TelemetryHistoryEntry]:
|
||||
"""Return stored telemetry history for a repeater (read-only, no radio access)."""
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
since = int(time.time()) - 30 * 86400
|
||||
rows = await RepeaterTelemetryRepository.get_history(contact.public_key, since)
|
||||
return [TelemetryHistoryEntry(**row) for row in rows]
|
||||
|
||||
|
||||
@router.post("/{public_key}/repeater/lpp-telemetry", response_model=RepeaterLppTelemetryResponse)
|
||||
async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
|
||||
"""Fetch CayenneLPP sensor telemetry from a repeater (single attempt, 10s timeout)."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
@@ -220,7 +208,7 @@ async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryRespons
|
||||
@router.post("/{public_key}/repeater/neighbors", response_model=RepeaterNeighborsResponse)
|
||||
async def repeater_neighbors(public_key: str) -> RepeaterNeighborsResponse:
|
||||
"""Fetch neighbors from a repeater (single attempt, 10s timeout)."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
@@ -254,7 +242,7 @@ async def repeater_neighbors(public_key: str) -> RepeaterNeighborsResponse:
|
||||
@router.post("/{public_key}/repeater/acl", response_model=RepeaterAclResponse)
|
||||
async def repeater_acl(public_key: str) -> RepeaterAclResponse:
|
||||
"""Fetch ACL from a repeater (single attempt, 10s timeout)."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
@@ -295,7 +283,7 @@ async def _batch_cli_fetch(
|
||||
@router.post("/{public_key}/repeater/node-info", response_model=RepeaterNodeInfoResponse)
|
||||
async def repeater_node_info(public_key: str) -> RepeaterNodeInfoResponse:
|
||||
"""Fetch repeater identity/location info via a small CLI batch."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
@@ -315,7 +303,7 @@ async def repeater_node_info(public_key: str) -> RepeaterNodeInfoResponse:
|
||||
@router.post("/{public_key}/repeater/radio-settings", response_model=RepeaterRadioSettingsResponse)
|
||||
async def repeater_radio_settings(public_key: str) -> RepeaterRadioSettingsResponse:
|
||||
"""Fetch radio settings from a repeater via radio/config CLI commands."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
@@ -339,7 +327,7 @@ async def repeater_radio_settings(public_key: str) -> RepeaterRadioSettingsRespo
|
||||
)
|
||||
async def repeater_advert_intervals(public_key: str) -> RepeaterAdvertIntervalsResponse:
|
||||
"""Fetch advertisement intervals from a repeater via CLI commands."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
@@ -357,7 +345,7 @@ async def repeater_advert_intervals(public_key: str) -> RepeaterAdvertIntervalsR
|
||||
@router.post("/{public_key}/repeater/owner-info", response_model=RepeaterOwnerInfoResponse)
|
||||
async def repeater_owner_info(public_key: str) -> RepeaterOwnerInfoResponse:
|
||||
"""Fetch owner info and guest password from a repeater via CLI commands."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
@@ -375,7 +363,7 @@ async def repeater_owner_info(public_key: str) -> RepeaterOwnerInfoResponse:
|
||||
@router.post("/{public_key}/command", response_model=CommandResponse)
|
||||
async def send_repeater_command(public_key: str, request: CommandRequest) -> CommandResponse:
|
||||
"""Send a CLI command to a repeater or room server."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
require_server_capable_contact(contact)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.models import (
|
||||
CONTACT_TYPE_ROOM,
|
||||
AclEntry,
|
||||
@@ -28,7 +27,7 @@ def _require_room(contact) -> None:
|
||||
@router.post("/{public_key}/room/login", response_model=RepeaterLoginResponse)
|
||||
async def room_login(public_key: str, request: RepeaterLoginRequest) -> RepeaterLoginResponse:
|
||||
"""Attempt room-server login and report whether auth was confirmed."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_room(contact)
|
||||
|
||||
@@ -48,7 +47,7 @@ async def room_login(public_key: str, request: RepeaterLoginRequest) -> Repeater
|
||||
@router.post("/{public_key}/room/status", response_model=RepeaterStatusResponse)
|
||||
async def room_status(public_key: str) -> RepeaterStatusResponse:
|
||||
"""Fetch status telemetry from a room server."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_room(contact)
|
||||
|
||||
@@ -85,7 +84,7 @@ async def room_status(public_key: str) -> RepeaterStatusResponse:
|
||||
@router.post("/{public_key}/room/lpp-telemetry", response_model=RepeaterLppTelemetryResponse)
|
||||
async def room_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
|
||||
"""Fetch CayenneLPP telemetry from a room server."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_room(contact)
|
||||
|
||||
@@ -114,7 +113,7 @@ async def room_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
|
||||
@router.post("/{public_key}/room/acl", response_model=RepeaterAclResponse)
|
||||
async def room_acl(public_key: str) -> RepeaterAclResponse:
|
||||
"""Fetch ACL entries from a room server."""
|
||||
require_connected()
|
||||
radio_manager.require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_room(contact)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.models import (
|
||||
Contact,
|
||||
RepeaterLoginResponse,
|
||||
)
|
||||
from app.radio_sync import _store_pending_channel_message, _store_pending_direct_message
|
||||
from app.routers.contacts import _ensure_on_radio
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
@@ -93,7 +94,7 @@ async def fetch_contact_cli_response(
|
||||
while _monotonic() < deadline:
|
||||
try:
|
||||
result = await mc.commands.get_msg(timeout=2.0)
|
||||
except asyncio.TimeoutError:
|
||||
except TimeoutError:
|
||||
continue
|
||||
except Exception as exc:
|
||||
logger.debug("get_msg() exception: %s", exc)
|
||||
@@ -115,18 +116,20 @@ async def fetch_contact_cli_response(
|
||||
if msg_prefix == target_pubkey_prefix and txt_type == 1:
|
||||
return result
|
||||
logger.debug(
|
||||
"Skipping non-target message (from=%s, txt_type=%d) while waiting for %s",
|
||||
"Storing non-target DM (from=%s, txt_type=%d) consumed while waiting for %s",
|
||||
msg_prefix,
|
||||
txt_type,
|
||||
target_pubkey_prefix,
|
||||
)
|
||||
await _store_pending_direct_message(result)
|
||||
continue
|
||||
|
||||
if result.type == EventType.CHANNEL_MSG_RECV:
|
||||
logger.debug(
|
||||
"Skipping channel message (channel_idx=%s) during CLI fetch",
|
||||
"Storing channel message (channel_idx=%s) consumed during CLI fetch",
|
||||
result.payload.get("channel_idx"),
|
||||
)
|
||||
await _store_pending_channel_message(mc, result.payload)
|
||||
continue
|
||||
|
||||
logger.debug("Unexpected event type %s during CLI fetch, skipping", result.type)
|
||||
@@ -193,7 +196,7 @@ async def prepare_authenticated_contact_connection(
|
||||
login_future,
|
||||
timeout=response_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
"No login response from %s %s within %.1fs",
|
||||
contact_label,
|
||||
@@ -227,20 +230,27 @@ async def batch_cli_fetch(
|
||||
operation_name: str,
|
||||
commands: list[tuple[str, str]],
|
||||
) -> dict[str, str | None]:
|
||||
"""Send a batch of CLI commands to a server-capable contact and collect responses."""
|
||||
"""Send a batch of CLI commands to a server-capable contact and collect responses.
|
||||
|
||||
Each command acquires and releases the radio lock independently so that
|
||||
other operations (sends, syncs) can slip in between commands.
|
||||
"""
|
||||
results: dict[str, str | None] = {field: None for _, field in commands}
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
operation_name,
|
||||
pause_polling=True,
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
await _ensure_on_radio(mc, contact)
|
||||
await asyncio.sleep(1.0)
|
||||
for index, (cmd, field) in enumerate(commands):
|
||||
if index > 0:
|
||||
# Yield briefly so queued operations can acquire the lock.
|
||||
await asyncio.sleep(0.25)
|
||||
|
||||
for index, (cmd, field) in enumerate(commands):
|
||||
if index > 0:
|
||||
await asyncio.sleep(1.0)
|
||||
async with radio_manager.radio_operation(
|
||||
operation_name,
|
||||
pause_polling=True,
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
# Re-ensure contact is loaded each iteration; another operation
|
||||
# may have evicted it while we didn't hold the lock.
|
||||
await _ensure_on_radio(mc, contact)
|
||||
await asyncio.sleep(1.0) # settle after add_contact
|
||||
|
||||
send_result = await mc.commands.send_cmd(contact.public_key, cmd)
|
||||
if send_result.type == EventType.ERROR:
|
||||
|
||||
+107
-75
@@ -2,16 +2,18 @@ import asyncio
|
||||
import logging
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.models import AppSettings
|
||||
from app.models import CONTACT_TYPE_REPEATER, AppSettings
|
||||
from app.region_scope import normalize_region_scope
|
||||
from app.repository import AppSettingsRepository
|
||||
from app.repository import AppSettingsRepository, ChannelRepository, ContactRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
MAX_TRACKED_TELEMETRY_REPEATERS = 8
|
||||
|
||||
|
||||
class AppSettingsUpdate(BaseModel):
|
||||
max_radio_contacts: int | None = Field(
|
||||
@@ -27,10 +29,6 @@ class AppSettingsUpdate(BaseModel):
|
||||
default=None,
|
||||
description="Whether to attempt historical DM decryption on new contact advertisement",
|
||||
)
|
||||
sidebar_sort_order: Literal["recent", "alpha"] | None = Field(
|
||||
default=None,
|
||||
description="Sidebar sort order: 'recent' or 'alpha'",
|
||||
)
|
||||
advert_interval: int | None = Field(
|
||||
default=None,
|
||||
ge=0,
|
||||
@@ -48,6 +46,17 @@ class AppSettingsUpdate(BaseModel):
|
||||
default=None,
|
||||
description="Display names whose messages are hidden from the UI",
|
||||
)
|
||||
discovery_blocked_types: list[int] | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Contact type codes (1=Client, 2=Repeater, 3=Room, 4=Sensor) whose "
|
||||
"advertisements should not create new contacts"
|
||||
),
|
||||
)
|
||||
auto_resend_channel: bool | None = Field(
|
||||
default=None,
|
||||
description="Auto-resend channel messages once if no echo heard within 2 seconds",
|
||||
)
|
||||
|
||||
|
||||
class BlockKeyRequest(BaseModel):
|
||||
@@ -63,24 +72,23 @@ class FavoriteRequest(BaseModel):
|
||||
id: str = Field(description="Channel key or contact public key")
|
||||
|
||||
|
||||
class MigratePreferencesRequest(BaseModel):
|
||||
favorites: list[FavoriteRequest] = Field(
|
||||
default_factory=list,
|
||||
description="List of favorites from localStorage",
|
||||
)
|
||||
sort_order: str = Field(
|
||||
default="recent",
|
||||
description="Sort order preference from localStorage",
|
||||
)
|
||||
last_message_times: dict[str, int] = Field(
|
||||
default_factory=dict,
|
||||
description="Map of conversation state keys to timestamps from localStorage",
|
||||
)
|
||||
class FavoriteToggleResponse(BaseModel):
|
||||
type: Literal["channel", "contact"]
|
||||
id: str
|
||||
favorite: bool
|
||||
|
||||
|
||||
class MigratePreferencesResponse(BaseModel):
|
||||
migrated: bool = Field(description="Whether migration occurred (false if already migrated)")
|
||||
settings: AppSettings = Field(description="Current settings after migration attempt")
|
||||
class TrackedTelemetryRequest(BaseModel):
|
||||
public_key: str = Field(description="Public key of the repeater to toggle tracking")
|
||||
|
||||
|
||||
class TrackedTelemetryResponse(BaseModel):
|
||||
tracked_telemetry_repeaters: list[str] = Field(
|
||||
description="Current list of tracked repeater public keys"
|
||||
)
|
||||
names: dict[str, str] = Field(
|
||||
description="Map of public key to display name for tracked repeaters"
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=AppSettings)
|
||||
@@ -104,10 +112,6 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
|
||||
logger.info("Updating auto_decrypt_dm_on_advert to %s", update.auto_decrypt_dm_on_advert)
|
||||
kwargs["auto_decrypt_dm_on_advert"] = update.auto_decrypt_dm_on_advert
|
||||
|
||||
if update.sidebar_sort_order is not None:
|
||||
logger.info("Updating sidebar_sort_order to %s", update.sidebar_sort_order)
|
||||
kwargs["sidebar_sort_order"] = update.sidebar_sort_order
|
||||
|
||||
if update.advert_interval is not None:
|
||||
# Enforce minimum 1-hour interval; 0 means disabled
|
||||
interval = update.advert_interval
|
||||
@@ -122,6 +126,16 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
|
||||
if update.blocked_names is not None:
|
||||
kwargs["blocked_names"] = update.blocked_names
|
||||
|
||||
# Discovery blocked types
|
||||
if update.discovery_blocked_types is not None:
|
||||
# Only allow valid contact type codes (1-4)
|
||||
valid = [t for t in update.discovery_blocked_types if t in (1, 2, 3, 4)]
|
||||
kwargs["discovery_blocked_types"] = sorted(set(valid))
|
||||
|
||||
# Auto-resend channel
|
||||
if update.auto_resend_channel is not None:
|
||||
kwargs["auto_resend_channel"] = update.auto_resend_channel
|
||||
|
||||
# Flood scope
|
||||
flood_scope_changed = False
|
||||
if update.flood_scope is not None:
|
||||
@@ -149,27 +163,30 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
|
||||
return await AppSettingsRepository.get()
|
||||
|
||||
|
||||
@router.post("/favorites/toggle", response_model=AppSettings)
|
||||
async def toggle_favorite(request: FavoriteRequest) -> AppSettings:
|
||||
@router.post("/favorites/toggle", response_model=FavoriteToggleResponse)
|
||||
async def toggle_favorite(request: FavoriteRequest) -> FavoriteToggleResponse:
|
||||
"""Toggle a conversation's favorite status."""
|
||||
settings = await AppSettingsRepository.get()
|
||||
is_favorited = any(f.type == request.type and f.id == request.id for f in settings.favorites)
|
||||
if request.type == "contact":
|
||||
contact = await ContactRepository.get_by_key(request.id)
|
||||
if not contact:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
new_value = not contact.favorite
|
||||
await ContactRepository.set_favorite(request.id, new_value)
|
||||
logger.info("%s contact favorite: %s", "Added" if new_value else "Removed", request.id[:12])
|
||||
# When newly favorited, load to radio immediately for DM ACK support
|
||||
if new_value:
|
||||
from app.radio_sync import ensure_contact_on_radio
|
||||
|
||||
if is_favorited:
|
||||
logger.info("Removing favorite: %s %s", request.type, request.id[:12])
|
||||
result = await AppSettingsRepository.remove_favorite(request.type, request.id)
|
||||
asyncio.create_task(ensure_contact_on_radio(request.id, force=True))
|
||||
else:
|
||||
logger.info("Adding favorite: %s %s", request.type, request.id[:12])
|
||||
result = await AppSettingsRepository.add_favorite(request.type, request.id)
|
||||
channel = await ChannelRepository.get_by_key(request.id)
|
||||
if not channel:
|
||||
raise HTTPException(status_code=404, detail="Channel not found")
|
||||
new_value = not channel.favorite
|
||||
await ChannelRepository.set_favorite(request.id, new_value)
|
||||
logger.info("%s channel favorite: %s", "Added" if new_value else "Removed", request.id[:12])
|
||||
|
||||
# When a contact is newly favorited, load just that contact to the radio
|
||||
# immediately so DM ACK support does not wait for the next maintenance cycle.
|
||||
if request.type == "contact" and not is_favorited:
|
||||
from app.radio_sync import ensure_contact_on_radio
|
||||
|
||||
asyncio.create_task(ensure_contact_on_radio(request.id, force=True))
|
||||
|
||||
return result
|
||||
return FavoriteToggleResponse(type=request.type, id=request.id, favorite=new_value)
|
||||
|
||||
|
||||
@router.post("/blocked-keys/toggle", response_model=AppSettings)
|
||||
@@ -186,41 +203,56 @@ async def toggle_blocked_name(request: BlockNameRequest) -> AppSettings:
|
||||
return await AppSettingsRepository.toggle_blocked_name(request.name)
|
||||
|
||||
|
||||
@router.post("/migrate", response_model=MigratePreferencesResponse)
|
||||
async def migrate_preferences(request: MigratePreferencesRequest) -> MigratePreferencesResponse:
|
||||
"""Migrate all preferences from frontend localStorage to database.
|
||||
@router.post("/tracked-telemetry/toggle", response_model=TrackedTelemetryResponse)
|
||||
async def toggle_tracked_telemetry(request: TrackedTelemetryRequest) -> TrackedTelemetryResponse:
|
||||
"""Toggle periodic telemetry collection for a repeater.
|
||||
|
||||
This is a one-time migration. If preferences have already been migrated,
|
||||
this endpoint will not overwrite them and will return migrated=false.
|
||||
|
||||
Call this on frontend startup to ensure preferences are moved to the database.
|
||||
After successful migration, the frontend should clear localStorage preferences.
|
||||
|
||||
Migrates:
|
||||
- favorites (remoteterm-favorites)
|
||||
- sort_order (remoteterm-sortOrder)
|
||||
- last_message_times (remoteterm-lastMessageTime)
|
||||
Max 8 repeaters may be tracked. Returns 409 if the limit is reached and
|
||||
the requested repeater is not already tracked.
|
||||
"""
|
||||
# Convert to dict format for the repository method
|
||||
frontend_favorites = [{"type": f.type, "id": f.id} for f in request.favorites]
|
||||
key = request.public_key.lower()
|
||||
settings = await AppSettingsRepository.get()
|
||||
current = settings.tracked_telemetry_repeaters
|
||||
|
||||
settings, did_migrate = await AppSettingsRepository.migrate_preferences_from_frontend(
|
||||
favorites=frontend_favorites,
|
||||
sort_order=request.sort_order,
|
||||
last_message_times=request.last_message_times,
|
||||
)
|
||||
async def _resolve_names(keys: list[str]) -> dict[str, str]:
|
||||
names: dict[str, str] = {}
|
||||
for k in keys:
|
||||
contact = await ContactRepository.get_by_key(k)
|
||||
names[k] = contact.name if contact and contact.name else k[:12]
|
||||
return names
|
||||
|
||||
if did_migrate:
|
||||
logger.info(
|
||||
"Migrated preferences from frontend: %d favorites, sort_order=%s, %d message times",
|
||||
len(frontend_favorites),
|
||||
request.sort_order,
|
||||
len(request.last_message_times),
|
||||
if key in current:
|
||||
# Remove
|
||||
new_list = [k for k in current if k != key]
|
||||
logger.info("Removing repeater %s from tracked telemetry", key[:12])
|
||||
await AppSettingsRepository.update(tracked_telemetry_repeaters=new_list)
|
||||
return TrackedTelemetryResponse(
|
||||
tracked_telemetry_repeaters=new_list,
|
||||
names=await _resolve_names(new_list),
|
||||
)
|
||||
else:
|
||||
logger.debug("Preferences already migrated, skipping")
|
||||
|
||||
return MigratePreferencesResponse(
|
||||
migrated=did_migrate,
|
||||
settings=settings,
|
||||
# Validate it's a repeater
|
||||
contact = await ContactRepository.get_by_key(key)
|
||||
if not contact:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
if contact.type != CONTACT_TYPE_REPEATER:
|
||||
raise HTTPException(status_code=400, detail="Contact is not a repeater")
|
||||
|
||||
if len(current) >= MAX_TRACKED_TELEMETRY_REPEATERS:
|
||||
names = await _resolve_names(current)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": f"Limit of {MAX_TRACKED_TELEMETRY_REPEATERS} tracked repeaters reached",
|
||||
"tracked_telemetry_repeaters": current,
|
||||
"names": names,
|
||||
},
|
||||
)
|
||||
|
||||
new_list = current + [key]
|
||||
logger.info("Adding repeater %s to tracked telemetry", key[:12])
|
||||
await AppSettingsRepository.update(tracked_telemetry_repeaters=new_list)
|
||||
return TrackedTelemetryResponse(
|
||||
tracked_telemetry_repeaters=new_list,
|
||||
names=await _resolve_names(new_list),
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ from fastapi import APIRouter
|
||||
|
||||
from app.models import StatisticsResponse
|
||||
from app.repository import StatisticsRepository
|
||||
from app.services.radio_stats import get_noise_floor_history
|
||||
|
||||
router = APIRouter(prefix="/statistics", tags=["statistics"])
|
||||
|
||||
@@ -9,4 +10,5 @@ router = APIRouter(prefix="/statistics", tags=["statistics"])
|
||||
@router.get("", response_model=StatisticsResponse)
|
||||
async def get_statistics() -> StatisticsResponse:
|
||||
data = await StatisticsRepository.get_all()
|
||||
data["noise_floor_24h"] = get_noise_floor_history()
|
||||
return StatisticsResponse(**data)
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
"""Shared direct-message ACK application logic."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from app.services import dm_ack_tracker
|
||||
from app.services.messages import increment_ack_and_broadcast
|
||||
|
||||
BroadcastFn = Callable[..., Any]
|
||||
from app.services.messages import BroadcastFn, increment_ack_and_broadcast
|
||||
|
||||
|
||||
async def apply_dm_ack_code(ack_code: str, *, broadcast_fn: BroadcastFn) -> bool:
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.models import CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM, Contact, ContactUpsert, Message
|
||||
from app.repository import (
|
||||
@@ -14,6 +13,7 @@ from app.repository import (
|
||||
)
|
||||
from app.services.contact_reconciliation import claim_prefix_messages_for_contact
|
||||
from app.services.messages import (
|
||||
BroadcastFn,
|
||||
broadcast_message,
|
||||
build_message_model,
|
||||
build_message_paths,
|
||||
@@ -27,8 +27,6 @@ if TYPE_CHECKING:
|
||||
from app.decoder import DecryptedDirectMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BroadcastFn = Callable[..., Any]
|
||||
_decrypted_dm_store_lock = asyncio.Lock()
|
||||
|
||||
|
||||
@@ -144,6 +142,8 @@ async def _store_direct_message(
|
||||
received_at: int,
|
||||
path: str | None,
|
||||
path_len: int | None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
outgoing: bool,
|
||||
txt_type: int,
|
||||
signature: str | None,
|
||||
@@ -170,6 +170,8 @@ async def _store_direct_message(
|
||||
path=path,
|
||||
received_at=received_at,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
return None
|
||||
@@ -189,6 +191,8 @@ async def _store_direct_message(
|
||||
path=path,
|
||||
received_at=received_at,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
return None
|
||||
@@ -201,6 +205,8 @@ async def _store_direct_message(
|
||||
received_at=received_at,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
txt_type=txt_type,
|
||||
signature=signature,
|
||||
outgoing=outgoing,
|
||||
@@ -218,6 +224,8 @@ async def _store_direct_message(
|
||||
path=path,
|
||||
received_at=received_at,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
return None
|
||||
@@ -232,7 +240,7 @@ async def _store_direct_message(
|
||||
text=text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
paths=build_message_paths(path, received_at, path_len),
|
||||
paths=build_message_paths(path, received_at, path_len, rssi=rssi, snr=snr),
|
||||
txt_type=txt_type,
|
||||
signature=signature,
|
||||
sender_key=sender_key,
|
||||
@@ -261,6 +269,8 @@ async def ingest_decrypted_direct_message(
|
||||
received_at: int | None = None,
|
||||
path: str | None = None,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
outgoing: bool = False,
|
||||
realtime: bool = True,
|
||||
broadcast_fn: BroadcastFn,
|
||||
@@ -311,6 +321,8 @@ async def ingest_decrypted_direct_message(
|
||||
received_at=received,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
outgoing=outgoing,
|
||||
txt_type=decrypted.txt_type,
|
||||
signature=signature,
|
||||
|
||||
+229
-30
@@ -2,6 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time as _time
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
@@ -9,10 +10,17 @@ from fastapi import HTTPException
|
||||
from meshcore import EventType
|
||||
|
||||
from app.models import ResendChannelMessageResponse
|
||||
from app.radio import RadioOperationBusyError
|
||||
from app.region_scope import normalize_region_scope
|
||||
from app.repository import AppSettingsRepository, ContactRepository, MessageRepository
|
||||
from app.repository import (
|
||||
AppSettingsRepository,
|
||||
ChannelRepository,
|
||||
ContactRepository,
|
||||
MessageRepository,
|
||||
)
|
||||
from app.services import dm_ack_tracker
|
||||
from app.services.messages import (
|
||||
BroadcastFn,
|
||||
broadcast_message,
|
||||
build_stored_outgoing_channel_message,
|
||||
create_outgoing_channel_message,
|
||||
@@ -26,13 +34,20 @@ NO_RADIO_RESPONSE_AFTER_SEND_DETAIL = (
|
||||
"Send command was issued to the radio, but no response was heard back. "
|
||||
"The message may or may not have sent successfully."
|
||||
)
|
||||
|
||||
BroadcastFn = Callable[..., Any]
|
||||
TrackAckFn = Callable[[str, int, int], bool]
|
||||
NowFn = Callable[[], float]
|
||||
OutgoingReservationKey = tuple[str, str, str]
|
||||
RetryTaskScheduler = Callable[[Any], Any]
|
||||
|
||||
# Channel echo watchdog: delay before checking for echoes
|
||||
ECHO_WATCHDOG_DELAY_SECONDS = 2.0
|
||||
|
||||
# Byte-perfect resend window (must match router's RESEND_WINDOW_SECONDS)
|
||||
RESEND_WINDOW_SECONDS = 30
|
||||
|
||||
# Temp radio slot used by the router for channel sends
|
||||
WATCHDOG_TEMP_RADIO_SLOT = 0
|
||||
|
||||
_pending_outgoing_timestamp_reservations: dict[OutgoingReservationKey, set[int]] = {}
|
||||
_outgoing_timestamp_reservations_lock = asyncio.Lock()
|
||||
|
||||
@@ -122,7 +137,7 @@ async def send_channel_message_with_effective_scope(
|
||||
error_broadcast_fn: BroadcastFn,
|
||||
app_settings_repository=AppSettingsRepository,
|
||||
) -> Any:
|
||||
"""Send a channel message, temporarily overriding flood scope when configured."""
|
||||
"""Send a channel message, temporarily overriding flood scope and/or path hash mode."""
|
||||
override_scope = normalize_region_scope(channel.flood_scope_override)
|
||||
baseline_scope = ""
|
||||
|
||||
@@ -151,6 +166,36 @@ async def send_channel_message_with_effective_scope(
|
||||
),
|
||||
)
|
||||
|
||||
# Path hash mode per-channel override
|
||||
override_phm = channel.path_hash_mode_override
|
||||
baseline_phm = radio_manager.path_hash_mode
|
||||
apply_phm = (
|
||||
override_phm is not None
|
||||
and radio_manager.path_hash_mode_supported
|
||||
and override_phm != baseline_phm
|
||||
)
|
||||
|
||||
if apply_phm:
|
||||
logger.info(
|
||||
"Temporarily applying channel path_hash_mode override for %s: %d",
|
||||
channel.name,
|
||||
override_phm,
|
||||
)
|
||||
phm_result = await mc.commands.set_path_hash_mode(override_phm)
|
||||
if phm_result is not None and phm_result.type == EventType.ERROR:
|
||||
logger.warning(
|
||||
"Failed to apply channel path_hash_mode override for %s: %s",
|
||||
channel.name,
|
||||
phm_result.payload,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=(
|
||||
f"Failed to apply path hash mode override before {action_label}: "
|
||||
f"{phm_result.payload}"
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
channel_slot, needs_configure, evicted_channel_key = radio_manager.plan_channel_send_slot(
|
||||
channel_key,
|
||||
@@ -219,38 +264,83 @@ async def send_channel_message_with_effective_scope(
|
||||
return send_result
|
||||
finally:
|
||||
if override_scope and override_scope != baseline_scope:
|
||||
try:
|
||||
restore_result = await mc.commands.set_flood_scope(
|
||||
baseline_scope if baseline_scope else ""
|
||||
)
|
||||
if restore_result is not None and restore_result.type == EventType.ERROR:
|
||||
logger.error(
|
||||
"Failed to restore baseline flood_scope after sending to %s: %s",
|
||||
restored = False
|
||||
for attempt in range(3):
|
||||
try:
|
||||
restore_result = await mc.commands.set_flood_scope(
|
||||
baseline_scope if baseline_scope else ""
|
||||
)
|
||||
if restore_result is not None and restore_result.type == EventType.ERROR:
|
||||
logger.warning(
|
||||
"Attempt %d/3: failed to restore flood_scope after sending to %s: %s",
|
||||
attempt + 1,
|
||||
channel.name,
|
||||
restore_result.payload,
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"Restored baseline flood_scope after channel send: %r",
|
||||
baseline_scope or "(disabled)",
|
||||
)
|
||||
restored = True
|
||||
break
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Attempt %d/3: exception restoring flood_scope after sending to %s",
|
||||
attempt + 1,
|
||||
channel.name,
|
||||
restore_result.payload,
|
||||
)
|
||||
error_broadcast_fn(
|
||||
"Regional override restore failed",
|
||||
(
|
||||
f"Sent to {channel.name}, but restoring flood scope failed. "
|
||||
"The radio may still be region-scoped. Consider rebooting the radio."
|
||||
),
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"Restored baseline flood_scope after channel send: %r",
|
||||
baseline_scope or "(disabled)",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to restore baseline flood_scope after sending to %s",
|
||||
if not restored:
|
||||
logger.error(
|
||||
"All 3 attempts to restore flood_scope failed for %s",
|
||||
channel.name,
|
||||
)
|
||||
error_broadcast_fn(
|
||||
"Regional override restore failed",
|
||||
(
|
||||
f"Sent to {channel.name}, but restoring flood scope failed. "
|
||||
"The radio may still be region-scoped. Consider rebooting the radio."
|
||||
f"Sent to {channel.name}, but restoring flood scope failed "
|
||||
f"after 3 attempts. The radio may still be region-scoped. "
|
||||
f"Consider rebooting the radio."
|
||||
),
|
||||
)
|
||||
|
||||
if apply_phm:
|
||||
restored = False
|
||||
for attempt in range(3):
|
||||
try:
|
||||
restore_phm = await mc.commands.set_path_hash_mode(baseline_phm)
|
||||
if restore_phm is not None and restore_phm.type == EventType.ERROR:
|
||||
logger.warning(
|
||||
"Attempt %d/3: failed to restore path_hash_mode after sending to %s: %s",
|
||||
attempt + 1,
|
||||
channel.name,
|
||||
restore_phm.payload,
|
||||
)
|
||||
else:
|
||||
radio_manager.path_hash_mode = baseline_phm
|
||||
logger.debug(
|
||||
"Restored baseline path_hash_mode after channel send: %d",
|
||||
baseline_phm,
|
||||
)
|
||||
restored = True
|
||||
break
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Attempt %d/3: exception restoring path_hash_mode after sending to %s",
|
||||
attempt + 1,
|
||||
channel.name,
|
||||
)
|
||||
if not restored:
|
||||
logger.error(
|
||||
"All 3 attempts to restore path_hash_mode failed for %s",
|
||||
channel.name,
|
||||
)
|
||||
error_broadcast_fn(
|
||||
"Path hash mode restore failed",
|
||||
(
|
||||
f"Sent to {channel.name}, but restoring path hash mode failed "
|
||||
f"after 3 attempts. The radio is still using a non-default hop "
|
||||
f"width. Set it back manually in Radio settings."
|
||||
),
|
||||
)
|
||||
|
||||
@@ -336,7 +426,8 @@ async def _retry_direct_message_until_acked(
|
||||
message_repository,
|
||||
) -> None:
|
||||
next_wait_timeout_ms = wait_timeout_ms
|
||||
for attempt in range(1, DM_SEND_MAX_ATTEMPTS):
|
||||
attempt = 1
|
||||
while attempt < DM_SEND_MAX_ATTEMPTS:
|
||||
await sleep_fn((next_wait_timeout_ms / 1000) * DM_RETRY_WAIT_MARGIN)
|
||||
if await _is_message_acked(message_id=message_id, message_repository=message_repository):
|
||||
return
|
||||
@@ -378,6 +469,14 @@ async def _retry_direct_message_until_acked(
|
||||
timestamp=sender_timestamp,
|
||||
attempt=attempt,
|
||||
)
|
||||
except RadioOperationBusyError:
|
||||
logger.debug(
|
||||
"Radio busy during DM retry attempt %d/%d for %s, will retry without consuming attempt",
|
||||
attempt + 1,
|
||||
DM_SEND_MAX_ATTEMPTS,
|
||||
contact.public_key[:12],
|
||||
)
|
||||
continue
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Background DM retry attempt %d/%d failed for %s",
|
||||
@@ -385,6 +484,7 @@ async def _retry_direct_message_until_acked(
|
||||
DM_SEND_MAX_ATTEMPTS,
|
||||
contact.public_key[:12],
|
||||
)
|
||||
attempt += 1
|
||||
continue
|
||||
|
||||
if result is None:
|
||||
@@ -394,6 +494,7 @@ async def _retry_direct_message_until_acked(
|
||||
DM_SEND_MAX_ATTEMPTS,
|
||||
contact.public_key[:12],
|
||||
)
|
||||
attempt += 1
|
||||
continue
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
@@ -404,6 +505,7 @@ async def _retry_direct_message_until_acked(
|
||||
contact.public_key[:12],
|
||||
result.payload,
|
||||
)
|
||||
attempt += 1
|
||||
continue
|
||||
|
||||
if await _is_message_acked(message_id=message_id, message_repository=message_repository):
|
||||
@@ -431,6 +533,8 @@ async def _retry_direct_message_until_acked(
|
||||
if ack_count > 0:
|
||||
return
|
||||
|
||||
attempt += 1
|
||||
|
||||
|
||||
async def send_direct_message_to_contact(
|
||||
*,
|
||||
@@ -550,6 +654,85 @@ async def send_direct_message_to_contact(
|
||||
return message
|
||||
|
||||
|
||||
async def _channel_echo_watchdog(
|
||||
message_id: int,
|
||||
radio_manager,
|
||||
broadcast_fn: BroadcastFn,
|
||||
error_broadcast_fn: BroadcastFn,
|
||||
) -> None:
|
||||
"""One-shot watchdog: if no echo heard after delay, attempt one byte-perfect resend.
|
||||
|
||||
Spawned as a fire-and-forget task after a channel send when auto_resend_channel is enabled.
|
||||
Uses non-blocking radio lock so it never stalls user actions.
|
||||
"""
|
||||
try:
|
||||
await asyncio.sleep(ECHO_WATCHDOG_DELAY_SECONDS)
|
||||
|
||||
msg = await MessageRepository.get_by_id(message_id)
|
||||
if not msg:
|
||||
return
|
||||
if msg.acked > 0:
|
||||
logger.debug(
|
||||
"Echo watchdog: message %d already has %d echo(s), skipping", message_id, msg.acked
|
||||
)
|
||||
return
|
||||
if msg.sender_timestamp is None:
|
||||
return
|
||||
|
||||
elapsed = int(_time.time()) - msg.sender_timestamp
|
||||
if elapsed > RESEND_WINDOW_SECONDS:
|
||||
logger.debug(
|
||||
"Echo watchdog: message %d outside resend window (%ds)", message_id, elapsed
|
||||
)
|
||||
return
|
||||
|
||||
channel = await ChannelRepository.get_by_key(msg.conversation_key)
|
||||
if not channel:
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Echo watchdog: no echo for message %d after %.0fs, attempting byte-perfect resend",
|
||||
message_id,
|
||||
ECHO_WATCHDOG_DELAY_SECONDS,
|
||||
)
|
||||
|
||||
try:
|
||||
key_bytes = bytes.fromhex(msg.conversation_key)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
timestamp_bytes = msg.sender_timestamp.to_bytes(4, "little")
|
||||
|
||||
# Strip sender name prefix to get the raw text for the radio
|
||||
async with radio_manager.radio_operation("echo_watchdog_resend", blocking=False) as mc:
|
||||
radio_name = mc.self_info.get("name", "") if mc.self_info else ""
|
||||
text_to_send = msg.text
|
||||
if radio_name and text_to_send.startswith(f"{radio_name}: "):
|
||||
text_to_send = text_to_send[len(f"{radio_name}: ") :]
|
||||
|
||||
result = await send_channel_message_with_effective_scope(
|
||||
mc=mc,
|
||||
channel=channel,
|
||||
channel_key=msg.conversation_key,
|
||||
key_bytes=key_bytes,
|
||||
text=text_to_send,
|
||||
timestamp_bytes=timestamp_bytes,
|
||||
action_label="echo watchdog resend",
|
||||
radio_manager=radio_manager,
|
||||
temp_radio_slot=WATCHDOG_TEMP_RADIO_SLOT,
|
||||
error_broadcast_fn=error_broadcast_fn,
|
||||
)
|
||||
if result is not None and result.type != EventType.ERROR:
|
||||
logger.info("Echo watchdog: resent message %d successfully", message_id)
|
||||
else:
|
||||
logger.debug("Echo watchdog: resend got no/error result for message %d", message_id)
|
||||
|
||||
except RadioOperationBusyError:
|
||||
logger.debug("Echo watchdog: radio busy, skipping resend for message %d", message_id)
|
||||
except Exception:
|
||||
logger.debug("Echo watchdog: resend failed for message %d", message_id, exc_info=True)
|
||||
|
||||
|
||||
async def send_channel_message_to_channel(
|
||||
*,
|
||||
channel,
|
||||
@@ -658,6 +841,22 @@ async def send_channel_message_to_channel(
|
||||
message_repository=message_repository,
|
||||
)
|
||||
broadcast_message(message=outgoing_message, broadcast_fn=broadcast_fn)
|
||||
|
||||
# Spawn echo watchdog if auto-resend is enabled
|
||||
try:
|
||||
settings = await AppSettingsRepository.get()
|
||||
if settings.auto_resend_channel:
|
||||
asyncio.create_task(
|
||||
_channel_echo_watchdog(
|
||||
message_id=outgoing_message.id,
|
||||
radio_manager=radio_manager,
|
||||
broadcast_fn=broadcast_fn,
|
||||
error_broadcast_fn=error_broadcast_fn,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass # Never let watchdog setup failure break the send
|
||||
|
||||
return outgoing_message
|
||||
|
||||
|
||||
|
||||
@@ -37,10 +37,16 @@ def build_message_paths(
|
||||
path: str | None,
|
||||
received_at: int,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
) -> list[MessagePath] | None:
|
||||
"""Build the single-path list used by message payloads."""
|
||||
return (
|
||||
[MessagePath(path=path or "", received_at=received_at, path_len=path_len)]
|
||||
[
|
||||
MessagePath(
|
||||
path=path or "", received_at=received_at, path_len=path_len, rssi=rssi, snr=snr
|
||||
)
|
||||
]
|
||||
if path is not None
|
||||
else None
|
||||
)
|
||||
@@ -166,6 +172,8 @@ async def reconcile_duplicate_message(
|
||||
path: str | None,
|
||||
received_at: int,
|
||||
path_len: int | None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
broadcast_fn: BroadcastFn,
|
||||
) -> None:
|
||||
logger.debug(
|
||||
@@ -177,7 +185,9 @@ async def reconcile_duplicate_message(
|
||||
)
|
||||
|
||||
if path is not None:
|
||||
paths = await MessageRepository.add_path(existing_msg.id, path, received_at, path_len)
|
||||
paths = await MessageRepository.add_path(
|
||||
existing_msg.id, path, received_at, path_len, rssi=rssi, snr=snr
|
||||
)
|
||||
else:
|
||||
paths = existing_msg.paths or []
|
||||
|
||||
@@ -214,6 +224,8 @@ async def handle_duplicate_message(
|
||||
path: str | None,
|
||||
received_at: int,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
broadcast_fn: BroadcastFn,
|
||||
) -> None:
|
||||
"""Handle a duplicate message by updating paths/acks on the existing record."""
|
||||
@@ -239,6 +251,8 @@ async def handle_duplicate_message(
|
||||
path=path,
|
||||
received_at=received_at,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
|
||||
@@ -253,6 +267,8 @@ async def create_message_from_decrypted(
|
||||
received_at: int | None = None,
|
||||
path: str | None = None,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
channel_name: str | None = None,
|
||||
realtime: bool = True,
|
||||
broadcast_fn: BroadcastFn,
|
||||
@@ -276,6 +292,8 @@ async def create_message_from_decrypted(
|
||||
received_at=received,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
sender_name=sender,
|
||||
sender_key=resolved_sender_key,
|
||||
)
|
||||
@@ -291,6 +309,8 @@ async def create_message_from_decrypted(
|
||||
path=path,
|
||||
received_at=received,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
return None
|
||||
@@ -312,7 +332,7 @@ async def create_message_from_decrypted(
|
||||
text=text,
|
||||
sender_timestamp=timestamp,
|
||||
received_at=received,
|
||||
paths=build_message_paths(path, received, path_len),
|
||||
paths=build_message_paths(path, received, path_len, rssi=rssi, snr=snr),
|
||||
sender_name=sender,
|
||||
sender_key=resolved_sender_key,
|
||||
channel_name=channel_name,
|
||||
@@ -334,6 +354,8 @@ async def create_dm_message_from_decrypted(
|
||||
received_at: int | None = None,
|
||||
path: str | None = None,
|
||||
path_len: int | None = None,
|
||||
rssi: int | None = None,
|
||||
snr: float | None = None,
|
||||
outgoing: bool = False,
|
||||
realtime: bool = True,
|
||||
broadcast_fn: BroadcastFn,
|
||||
@@ -348,6 +370,8 @@ async def create_dm_message_from_decrypted(
|
||||
received_at=received_at,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
outgoing=outgoing,
|
||||
realtime=realtime,
|
||||
broadcast_fn=broadcast_fn,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -33,6 +33,7 @@ async def run_post_connect_setup(radio_manager) -> None:
|
||||
start_message_polling,
|
||||
start_periodic_advert,
|
||||
start_periodic_sync,
|
||||
start_telemetry_collect,
|
||||
sync_and_offload_all,
|
||||
sync_radio_time,
|
||||
)
|
||||
@@ -192,7 +193,7 @@ async def run_post_connect_setup(radio_manager) -> None:
|
||||
logger.info(
|
||||
"Radio clock at connect: epoch=%d utc=%s",
|
||||
radio_time,
|
||||
datetime.fromtimestamp(radio_time, timezone.utc).strftime(
|
||||
datetime.fromtimestamp(radio_time, UTC).strftime(
|
||||
"%Y-%m-%d %H:%M:%S UTC"
|
||||
),
|
||||
)
|
||||
@@ -204,35 +205,51 @@ async def run_post_connect_setup(radio_manager) -> None:
|
||||
finally:
|
||||
reader.handle_rx = _original_handle_rx
|
||||
|
||||
# Sync contacts/channels from radio to DB and clear radio
|
||||
logger.info("Syncing and offloading radio data...")
|
||||
result = await sync_and_offload_all(mc)
|
||||
logger.info("Sync complete: %s", result)
|
||||
from app.config import settings as app_settings_config
|
||||
|
||||
# Send advertisement to announce our presence (if enabled and not throttled)
|
||||
if await send_advertisement(mc):
|
||||
logger.info("Advertisement sent")
|
||||
if app_settings_config.skip_post_connect_sync:
|
||||
logger.info(
|
||||
"Skipping sync/offload/advert/drain (MESHCORE_SKIP_POST_CONNECT_SYNC)"
|
||||
)
|
||||
else:
|
||||
logger.debug("Advertisement skipped (disabled or throttled)")
|
||||
# Sync contacts/channels from radio to DB and clear radio
|
||||
logger.info("Syncing and offloading radio data...")
|
||||
result = await sync_and_offload_all(mc)
|
||||
c = result.get("contacts", {})
|
||||
ch = result.get("channels", {})
|
||||
logger.info(
|
||||
"Sync complete: %d contacts synced, %d channels synced, %d channels cleared",
|
||||
c.get("synced", 0),
|
||||
ch.get("synced", 0),
|
||||
ch.get("cleared", 0),
|
||||
)
|
||||
|
||||
# Drain any messages that were queued before we connected.
|
||||
# This must happen BEFORE starting auto-fetch, otherwise both
|
||||
# compete on get_msg() with interleaved radio I/O.
|
||||
drained = await drain_pending_messages(mc)
|
||||
if drained > 0:
|
||||
logger.info("Drained %d pending message(s)", drained)
|
||||
radio_manager.clear_pending_message_channel_slots()
|
||||
# Send advertisement to announce our presence (if enabled and not throttled)
|
||||
if await send_advertisement(mc):
|
||||
logger.info("Advertisement sent")
|
||||
else:
|
||||
logger.debug("Advertisement skipped (disabled or throttled)")
|
||||
|
||||
# Drain any messages that were queued before we connected.
|
||||
# This must happen BEFORE starting auto-fetch, otherwise both
|
||||
# compete on get_msg() with interleaved radio I/O.
|
||||
drained = await drain_pending_messages(mc)
|
||||
if drained > 0:
|
||||
logger.info("Drained %d pending message(s)", drained)
|
||||
radio_manager.clear_pending_message_channel_slots()
|
||||
|
||||
await mc.start_auto_message_fetching()
|
||||
logger.info("Auto message fetching started")
|
||||
finally:
|
||||
radio_manager._release_operation_lock("post_connect_setup")
|
||||
|
||||
# Start background tasks AFTER releasing the operation lock.
|
||||
# These tasks acquire their own locks when they need radio access.
|
||||
start_periodic_sync()
|
||||
start_periodic_advert()
|
||||
start_message_polling()
|
||||
if not app_settings_config.skip_post_connect_sync:
|
||||
# Start background tasks AFTER releasing the operation lock.
|
||||
# These tasks acquire their own locks when they need radio access.
|
||||
start_periodic_sync()
|
||||
start_periodic_advert()
|
||||
start_message_polling()
|
||||
start_telemetry_collect()
|
||||
|
||||
radio_manager._setup_complete = True
|
||||
finally:
|
||||
@@ -257,7 +274,7 @@ async def prepare_connected_radio(radio_manager, *, broadcast_on_success: bool =
|
||||
try:
|
||||
await radio_manager.post_connect_setup()
|
||||
break
|
||||
except asyncio.TimeoutError as exc:
|
||||
except TimeoutError as exc:
|
||||
if attempt < POST_CONNECT_SETUP_MAX_ATTEMPTS:
|
||||
logger.warning(
|
||||
"Post-connect setup timed out after %ds on attempt %d/%d; retrying once",
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""In-memory local-radio stats sampling.
|
||||
|
||||
A single 60s loop fetches core, radio, and packet stats from the connected
|
||||
radio in one radio-lock acquisition. The noise-floor 24h history deque is
|
||||
maintained as a side effect.
|
||||
|
||||
After each sample the loop:
|
||||
1. Broadcasts a WS ``health`` frame so frontend dashboards refresh.
|
||||
2. Dispatches a ``broadcast_health_fanout`` event carrying the full stats
|
||||
snapshot plus radio identity, so fanout modules (e.g. HA MQTT) can
|
||||
publish sensor state without a second radio poll.
|
||||
|
||||
Consumers:
|
||||
- GET /api/health → get_latest_radio_stats() (battery, uptime, etc.)
|
||||
- GET /api/statistics → get_noise_floor_history() (24h noise-floor chart)
|
||||
- Fanout on_health → _build_fanout_payload() (identity + stats)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from collections import deque
|
||||
from typing import Any
|
||||
|
||||
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__)
|
||||
|
||||
STATS_SAMPLE_INTERVAL_SECONDS = 60
|
||||
NOISE_FLOOR_WINDOW_SECONDS = 24 * 60 * 60
|
||||
MAX_NOISE_FLOOR_SAMPLES = 1500 # 24h at 60s intervals = 1440
|
||||
|
||||
_stats_task: asyncio.Task | None = None
|
||||
_noise_floor_samples: deque[tuple[int, int]] = deque(maxlen=MAX_NOISE_FLOOR_SAMPLES)
|
||||
_latest_stats: dict[str, Any] = {}
|
||||
|
||||
|
||||
async def _sample_all_stats() -> dict[str, Any]:
|
||||
"""Fetch core, radio, and packet stats in one radio operation.
|
||||
|
||||
Returns the snapshot dict (may be empty if the radio is disconnected or
|
||||
all commands errored).
|
||||
"""
|
||||
if not radio_manager.is_connected:
|
||||
return {}
|
||||
|
||||
try:
|
||||
async with radio_manager.radio_operation("radio_stats_sample", blocking=False) as mc:
|
||||
core_event = await mc.commands.get_stats_core()
|
||||
radio_event = await mc.commands.get_stats_radio()
|
||||
packet_event = await mc.commands.get_stats_packets()
|
||||
except (RadioDisconnectedError, RadioOperationBusyError):
|
||||
return {}
|
||||
except Exception as exc:
|
||||
logger.debug("Radio stats sampling failed: %s", exc)
|
||||
return {}
|
||||
|
||||
now = int(time.time())
|
||||
snapshot: dict[str, Any] = {"timestamp": now}
|
||||
|
||||
if getattr(core_event, "type", None) == EventType.STATS_CORE:
|
||||
snapshot.update(core_event.payload)
|
||||
|
||||
if getattr(radio_event, "type", None) == EventType.STATS_RADIO:
|
||||
snapshot.update(radio_event.payload)
|
||||
noise_floor = radio_event.payload.get("noise_floor")
|
||||
if isinstance(noise_floor, int):
|
||||
_noise_floor_samples.append((now, noise_floor))
|
||||
|
||||
if getattr(packet_event, "type", None) == EventType.STATS_PACKETS:
|
||||
snapshot["packets"] = packet_event.payload
|
||||
|
||||
has_any_data = len(snapshot) > 1
|
||||
return snapshot if has_any_data else {}
|
||||
|
||||
|
||||
def _build_fanout_payload(stats: dict[str, Any]) -> dict:
|
||||
"""Build the health fanout payload from a stats snapshot + radio identity.
|
||||
|
||||
Includes radio identity (public_key, name), connection state, and the
|
||||
full stats snapshot so fanout modules can publish rich sensor data
|
||||
without a second radio poll.
|
||||
"""
|
||||
mc = radio_manager.meshcore
|
||||
self_info = mc.self_info if mc else None
|
||||
|
||||
payload: dict = {
|
||||
"connected": radio_manager.is_connected,
|
||||
"connection_info": radio_manager.connection_info,
|
||||
"public_key": (self_info.get("public_key") or None) if self_info else None,
|
||||
"name": (self_info.get("name") or None) if self_info else None,
|
||||
}
|
||||
|
||||
if stats:
|
||||
payload["noise_floor_dbm"] = stats.get("noise_floor")
|
||||
payload["battery_mv"] = stats.get("battery_mv")
|
||||
payload["uptime_secs"] = stats.get("uptime_secs")
|
||||
payload["last_rssi"] = stats.get("last_rssi")
|
||||
payload["last_snr"] = stats.get("last_snr")
|
||||
payload["tx_air_secs"] = stats.get("tx_air_secs")
|
||||
payload["rx_air_secs"] = stats.get("rx_air_secs")
|
||||
packets = stats.get("packets") or {}
|
||||
payload["packets_recv"] = packets.get("recv")
|
||||
payload["packets_sent"] = packets.get("sent")
|
||||
payload["flood_tx"] = packets.get("flood_tx")
|
||||
payload["direct_tx"] = packets.get("direct_tx")
|
||||
payload["flood_rx"] = packets.get("flood_rx")
|
||||
payload["direct_rx"] = packets.get("direct_rx")
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
async def _stats_sampling_loop() -> None:
|
||||
global _latest_stats
|
||||
while True:
|
||||
try:
|
||||
snapshot = await _sample_all_stats()
|
||||
if snapshot:
|
||||
_latest_stats = snapshot
|
||||
elif not radio_manager.is_connected:
|
||||
_latest_stats = {}
|
||||
from app.websocket import broadcast_health
|
||||
|
||||
broadcast_health(radio_manager.is_connected, radio_manager.connection_info)
|
||||
|
||||
# Dispatch enriched health snapshot to fanout modules
|
||||
from app.fanout.manager import fanout_manager
|
||||
|
||||
await fanout_manager.broadcast_health_fanout(_build_fanout_payload(snapshot))
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Radio stats sampling loop error")
|
||||
|
||||
try:
|
||||
await asyncio.sleep(STATS_SAMPLE_INTERVAL_SECONDS)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def start_radio_stats_sampling() -> None:
|
||||
"""Start the periodic radio stats background task."""
|
||||
global _stats_task
|
||||
if _stats_task is not None and not _stats_task.done():
|
||||
return
|
||||
_stats_task = asyncio.create_task(_stats_sampling_loop())
|
||||
|
||||
|
||||
async def stop_radio_stats_sampling() -> None:
|
||||
"""Stop the periodic radio stats background task."""
|
||||
global _stats_task
|
||||
if _stats_task is None:
|
||||
return
|
||||
if not _stats_task.done():
|
||||
_stats_task.cancel()
|
||||
try:
|
||||
await _stats_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
_stats_task = None
|
||||
|
||||
|
||||
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
|
||||
|
||||
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": STATS_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,
|
||||
"samples": samples,
|
||||
}
|
||||
|
||||
|
||||
def get_latest_radio_stats() -> dict[str, Any]:
|
||||
"""Return the most recent radio stats snapshot (for health endpoint)."""
|
||||
return dict(_latest_stats)
|
||||
+1
-2
@@ -13,13 +13,12 @@ import importlib.metadata
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tomllib
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import tomllib
|
||||
|
||||
RELEASE_BUILD_INFO_FILENAME = "build_info.json"
|
||||
PROJECT_NAME = "remoteterm-meshcore"
|
||||
|
||||
|
||||
+3
-4
@@ -43,9 +43,6 @@ class WebSocketManager:
|
||||
3. Send to all clients concurrently with timeout
|
||||
4. Re-acquire lock to clean up disconnected clients
|
||||
"""
|
||||
if not self.active_connections:
|
||||
return
|
||||
|
||||
message = dump_ws_event(event_type, data)
|
||||
|
||||
# Copy connection list under lock to avoid holding lock during I/O
|
||||
@@ -62,7 +59,7 @@ class WebSocketManager:
|
||||
try:
|
||||
# Timeout prevents blocking on slow/unresponsive clients
|
||||
await asyncio.wait_for(connection.send_text(message), timeout=SEND_TIMEOUT_SECONDS)
|
||||
except asyncio.TimeoutError:
|
||||
except TimeoutError:
|
||||
logger.debug("Timeout sending to WebSocket client, marking disconnected")
|
||||
disconnected.append(connection)
|
||||
except Exception as e:
|
||||
@@ -113,6 +110,8 @@ def broadcast_event(event_type: str, data: dict, *, realtime: bool = True) -> No
|
||||
asyncio.create_task(fanout_manager.broadcast_message(data))
|
||||
elif event_type == "raw_packet":
|
||||
asyncio.create_task(fanout_manager.broadcast_raw(data))
|
||||
elif event_type == "contact":
|
||||
asyncio.create_task(fanout_manager.broadcast_contact(data))
|
||||
|
||||
|
||||
def broadcast_error(message: str, details: str | None = None) -> None:
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 109 KiB |
@@ -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: 5000
|
||||
|
||||
# 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
|
||||
+65
-12
@@ -39,6 +39,8 @@ frontend/src/
|
||||
├── index.css # Global styles/utilities
|
||||
├── styles.css # Additional global app styles
|
||||
├── themes.css # Color theme definitions
|
||||
├── contexts/
|
||||
│ └── DistanceUnitContext.tsx # Browser-local distance-unit context/provider
|
||||
├── lib/
|
||||
│ └── utils.ts # cn() — clsx + tailwind-merge helper
|
||||
├── hooks/
|
||||
@@ -53,10 +55,14 @@ frontend/src/
|
||||
│ ├── useRadioControl.ts # Radio health/config state, reconnection, mesh discovery sweeps
|
||||
│ ├── useAppSettings.ts # Settings, favorites, preferences migration
|
||||
│ ├── useConversationRouter.ts # URL hash → active conversation routing
|
||||
│ └── useContactsAndChannels.ts # Contact/channel loading, creation, deletion
|
||||
│ ├── useContactsAndChannels.ts # Contact/channel loading, creation, deletion
|
||||
│ ├── useBrowserNotifications.ts # Per-conversation browser notification preferences + dispatch
|
||||
│ ├── useFaviconBadge.ts # Browser tab unread badge state
|
||||
│ ├── useRawPacketStatsSession.ts # Session-scoped packet-feed stats history
|
||||
│ └── useRememberedServerPassword.ts # Browser-local repeater/room password persistence
|
||||
├── components/
|
||||
│ ├── AppShell.tsx # App-shell layout: status, sidebar, search/settings panes, cracker, modals
|
||||
│ ├── ConversationPane.tsx # Active conversation surface selection (map/raw/repeater/chat/empty)
|
||||
│ ├── AppShell.tsx # App-shell layout: status, sidebar, search/settings panes, cracker, modals, security warning
|
||||
│ ├── ConversationPane.tsx # Active conversation surface selection (map/raw/trace/repeater/room/chat/empty)
|
||||
│ ├── visualizer/
|
||||
│ │ ├── useVisualizerData3D.ts # Packet→graph data pipeline, repeat aggregation, simulation state
|
||||
│ │ ├── useVisualizer3DScene.ts # Three.js scene lifecycle, buffers, hover/pin interaction
|
||||
@@ -73,14 +79,18 @@ frontend/src/
|
||||
│ ├── pubkey.ts # getContactDisplayName (12-char prefix fallback)
|
||||
│ ├── contactAvatar.ts # Avatar color derivation from public key
|
||||
│ ├── rawPacketIdentity.ts # observation_id vs id dedup helpers
|
||||
│ ├── rawPacketStats.ts # Session packet stats windows, rankings, and coverage helpers
|
||||
│ ├── regionScope.ts # Regional flood-scope label/normalization helpers
|
||||
│ ├── visualizerUtils.ts # 3D visualizer node types, colors, particles
|
||||
│ ├── visualizerSettings.ts # LocalStorage persistence for visualizer options
|
||||
│ ├── a11y.ts # Keyboard accessibility helper
|
||||
│ ├── distanceUnits.ts # Browser-local distance unit persistence/helpers
|
||||
│ ├── lastViewedConversation.ts # localStorage for last-viewed conversation
|
||||
│ ├── contactMerge.ts # Merge WS contact updates into list
|
||||
│ ├── localLabel.ts # Local label (text + color) in localStorage
|
||||
│ ├── radioPresets.ts # LoRa radio preset configurations
|
||||
│ ├── publicChannel.ts # Public-channel resolution helpers for routing/hash defaults
|
||||
│ ├── fontScale.ts # Browser-local relative font scale persistence/application
|
||||
│ └── theme.ts # Theme switching helpers
|
||||
├── components/
|
||||
│ ├── StatusBar.tsx
|
||||
@@ -91,8 +101,12 @@ frontend/src/
|
||||
│ ├── NewMessageModal.tsx
|
||||
│ ├── SearchView.tsx # Full-text message search pane
|
||||
│ ├── SettingsModal.tsx # Layout shell — delegates to settings/ sections
|
||||
│ ├── SecurityWarningModal.tsx # Startup warning for trusted-network / bot execution posture
|
||||
│ ├── RawPacketList.tsx
|
||||
│ ├── RawPacketFeedView.tsx # Live raw packet feed + session stats drawer
|
||||
│ ├── RawPacketDetailModal.tsx # On-demand packet inspector dialog
|
||||
│ ├── MapView.tsx
|
||||
│ ├── TracePane.tsx # Multi-hop route trace builder/results view
|
||||
│ ├── VisualizerView.tsx
|
||||
│ ├── PacketVisualizer3D.tsx
|
||||
│ ├── PathModal.tsx
|
||||
@@ -102,15 +116,20 @@ frontend/src/
|
||||
│ ├── ContactAvatar.tsx
|
||||
│ ├── ContactInfoPane.tsx # Contact detail sheet (stats, name history, paths)
|
||||
│ ├── ContactStatusInfo.tsx # Contact status info component
|
||||
│ ├── ContactPathDiscoveryModal.tsx # Forward/return path discovery dialog
|
||||
│ ├── ContactRoutingOverrideModal.tsx # Manual direct-route override editor
|
||||
│ ├── RepeaterDashboard.tsx # Layout shell — delegates to repeater/ panes
|
||||
│ ├── RepeaterLogin.tsx # Repeater login form (password + guest)
|
||||
│ ├── RoomServerPanel.tsx # Room-server auth gate + status banner ahead of room chat
|
||||
│ ├── ServerLoginStatusBanner.tsx # Shared repeater/room login state banner
|
||||
│ ├── ChannelInfoPane.tsx # Channel detail sheet (stats, top senders)
|
||||
│ ├── ChannelFloodScopeOverrideModal.tsx # Per-channel flood-scope override editor
|
||||
│ ├── DirectTraceIcon.tsx # Shared direct-trace glyph used in header/dashboard
|
||||
│ ├── NeighborsMiniMap.tsx # Leaflet mini-map for repeater neighbor locations
|
||||
│ ├── settings/
|
||||
│ │ ├── settingsConstants.ts # Settings section type, ordering, labels
|
||||
│ │ ├── SettingsRadioSection.tsx # Name, keys, advert interval, max contacts, radio preset, freq/bw/sf/cr, txPower, lat/lon, reboot, mesh discovery
|
||||
│ │ ├── SettingsLocalSection.tsx # Browser-local settings: theme, local label, reopen last conversation
|
||||
│ │ ├── SettingsLocalSection.tsx # Browser-local settings: theme, relative font scale, local label, reopen last conversation
|
||||
│ │ ├── SettingsFanoutSection.tsx # Fanout integrations: MQTT, bots, config CRUD
|
||||
│ │ ├── SettingsDatabaseSection.tsx # DB size, cleanup, auto-decrypt, local label
|
||||
│ │ ├── SettingsStatisticsSection.tsx # Read-only mesh network stats
|
||||
@@ -130,12 +149,13 @@ frontend/src/
|
||||
│ └── ui/ # shadcn/ui primitives
|
||||
├── types/
|
||||
│ └── d3-force-3d.d.ts # Type declarations for d3-force-3d
|
||||
└── test/
|
||||
└── test/ # Representative frontend test suites (not an exhaustive listing)
|
||||
├── setup.ts
|
||||
├── fixtures/websocket_events.json
|
||||
├── api.test.ts
|
||||
├── appFavorites.test.tsx
|
||||
├── appStartupHash.test.tsx
|
||||
├── conversationPane.test.tsx
|
||||
├── contactAvatar.test.ts
|
||||
├── contactInfoPane.test.tsx
|
||||
├── integration.test.ts
|
||||
@@ -146,18 +166,23 @@ frontend/src/
|
||||
├── rawPacketList.test.tsx
|
||||
├── pathUtils.test.ts
|
||||
├── prefetch.test.ts
|
||||
├── rawPacketDetailModal.test.tsx
|
||||
├── rawPacketFeedView.test.tsx
|
||||
├── radioPresets.test.ts
|
||||
├── rawPacketIdentity.test.ts
|
||||
├── repeaterDashboard.test.tsx
|
||||
├── repeaterFormatters.test.ts
|
||||
├── repeaterLogin.test.tsx
|
||||
├── repeaterMessageParsing.test.ts
|
||||
├── roomServerPanel.test.tsx
|
||||
├── securityWarningModal.test.tsx
|
||||
├── localLabel.test.ts
|
||||
├── messageInput.test.tsx
|
||||
├── newMessageModal.test.tsx
|
||||
├── settingsModal.test.tsx
|
||||
├── sidebar.test.tsx
|
||||
├── statusBar.test.tsx
|
||||
├── tracePane.test.tsx
|
||||
├── unreadCounts.test.ts
|
||||
├── urlHash.test.ts
|
||||
├── appSearchJump.test.tsx
|
||||
@@ -169,12 +194,17 @@ frontend/src/
|
||||
├── useConversationMessages.race.test.ts
|
||||
├── useConversationNavigation.test.ts
|
||||
├── useAppShell.test.ts
|
||||
├── useBrowserNotifications.test.ts
|
||||
├── useFaviconBadge.test.ts
|
||||
├── useRepeaterDashboard.test.ts
|
||||
├── useRememberedServerPassword.test.ts
|
||||
├── useContactsAndChannels.test.ts
|
||||
├── useRealtimeAppState.test.ts
|
||||
├── useUnreadCounts.test.ts
|
||||
├── useWebSocket.dispatch.test.ts
|
||||
├── useWebSocket.lifecycle.test.ts
|
||||
├── rawPacketStats.test.ts
|
||||
├── fontScale.test.ts
|
||||
└── wsEvents.test.ts
|
||||
|
||||
```
|
||||
@@ -190,6 +220,7 @@ frontend/src/
|
||||
- search/settings surface switching
|
||||
- global cracker mount/focus behavior
|
||||
- new-message modal and info panes
|
||||
- trusted-network `SecurityWarningModal`
|
||||
|
||||
High-level state is delegated to hooks:
|
||||
- `useAppShell`: app-shell view state (settings section, sidebar, cracker, new-message modal)
|
||||
@@ -211,7 +242,9 @@ High-level state is delegated to hooks:
|
||||
- map view
|
||||
- visualizer
|
||||
- raw packet feed
|
||||
- trace view
|
||||
- repeater dashboard
|
||||
- room-server auth/status gate before room chat
|
||||
- normal chat chrome (`ChatHeader` + `MessageList` + `MessageInput`)
|
||||
|
||||
### Initial load + realtime
|
||||
@@ -272,12 +305,16 @@ Supported routes:
|
||||
- `#map/focus/{pubkey_or_prefix}`
|
||||
- `#visualizer`
|
||||
- `#search`
|
||||
- `#trace`
|
||||
- `#settings/{section}`
|
||||
- `#channel/{channelKey}`
|
||||
- `#channel/{channelKey}/{label}`
|
||||
- `#contact/{publicKey}`
|
||||
- `#contact/{publicKey}/{label}`
|
||||
|
||||
Legacy name-based hashes are still accepted for compatibility.
|
||||
Where `{section}` is one of `radio`, `local`, `fanout`, `database`, `statistics`, or `about`.
|
||||
|
||||
Legacy name-based channel/contact hashes are still accepted for compatibility.
|
||||
|
||||
## Conversation State Keys (`utils/conversationState.ts`)
|
||||
|
||||
@@ -311,17 +348,15 @@ LocalStorage migration helpers for favorites; canonical favorites are server-sid
|
||||
|
||||
`AppSettings` currently includes:
|
||||
- `max_radio_contacts`
|
||||
- `favorites`
|
||||
- `auto_decrypt_dm_on_advert`
|
||||
- `sidebar_sort_order`
|
||||
- `last_message_times`
|
||||
- `preferences_migrated`
|
||||
- `advert_interval`
|
||||
- `last_advert_time`
|
||||
- `flood_scope`
|
||||
- `blocked_keys`, `blocked_names`
|
||||
- `blocked_keys`, `blocked_names`, `discovery_blocked_types`
|
||||
- `tracked_telemetry_repeaters`
|
||||
- `auto_resend_channel`
|
||||
|
||||
The backend still carries `sidebar_sort_order` for compatibility and old preference migration, but the current sidebar UI stores sort order per section (`Channels`, `Contacts`, `Repeaters`) in frontend localStorage rather than treating it as one global server-backed setting.
|
||||
|
||||
Note: MQTT, bot, and community MQTT settings were migrated to the `fanout_configs` table (managed via `/api/fanout`). They are no longer part of `AppSettings`.
|
||||
|
||||
@@ -377,6 +412,12 @@ For repeater contacts (`type=2`), `ConversationPane.tsx` renders `RepeaterDashbo
|
||||
|
||||
All state is managed by `useRepeaterDashboard` hook. State resets on conversation change.
|
||||
|
||||
## Room Server Panel
|
||||
|
||||
For room contacts (`type=3`), `ConversationPane.tsx` keeps the normal chat surface but inserts `RoomServerPanel` above it. That panel handles room-server login/status messaging and gates room chat behind the room-authenticated state when required.
|
||||
|
||||
`ServerLoginStatusBanner` is shared between repeater and room login surfaces for inline status/error display.
|
||||
|
||||
## Message Search Pane
|
||||
|
||||
The `SearchView` component (`components/SearchView.tsx`) provides full-text search across all DMs and channel messages. Key behaviors:
|
||||
@@ -393,6 +434,18 @@ The `SearchView` component (`components/SearchView.tsx`) provides full-text sear
|
||||
UI styling is mostly utility-class driven (Tailwind-style classes in JSX) plus shared globals in `index.css` and `styles.css`.
|
||||
Do not rely on old class-only layout assumptions.
|
||||
|
||||
### Canonical style reference
|
||||
|
||||
`SettingsLocalSection.tsx` contains a **ThemePreview** component with a collapsible "Canonical style reference" section. This is the authoritative catalog of text sizes, button variants, badge patterns, and interactive elements used throughout the app. **When adding or modifying UI, match the patterns shown there rather than inventing new ones.**
|
||||
|
||||
Key conventions documented in the reference:
|
||||
|
||||
- **Text sizes** use `rem`-based Tailwind values so they scale with the user's font-size slider. Do not use hard-locked `px` values (e.g., `text-[10px]`). The canonical sizes are `text-[0.625rem]` (10px), `text-[0.6875rem]` (11px), `text-[0.8125rem]` (13px), plus standard Tailwind `text-xs`/`text-sm`/`text-base`/`text-lg`/`text-xl`.
|
||||
- **Section labels** use `text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium`.
|
||||
- **Buttons** use the shadcn `<Button>` component. Semantic color overrides (danger, warning, success) use `variant="outline"` with `className="border-{color}/50 text-{color} hover:bg-{color}/10"`.
|
||||
- **Badges/tags** use `text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded` with `bg-muted` (neutral) or `bg-primary/10` (active).
|
||||
- **Clickable text** (copy-to-clipboard, navigational links) uses `role="button" tabIndex={0}` with `cursor-pointer hover:text-primary transition-colors`.
|
||||
|
||||
## Security Posture (intentional)
|
||||
|
||||
- No authentication UI.
|
||||
@@ -404,7 +457,7 @@ Do not rely on old class-only layout assumptions.
|
||||
Run all quality checks (backend + frontend) from the repo root:
|
||||
|
||||
```bash
|
||||
./scripts/all_quality.sh
|
||||
./scripts/quality/all_quality.sh
|
||||
```
|
||||
|
||||
Or run frontend checks individually:
|
||||
|
||||
+12
-12
@@ -9,11 +9,11 @@
|
||||
<meta name="theme-color" content="#111419" />
|
||||
<meta name="description" content="Web interface for MeshCore mesh radio networks. Send and receive messages, manage contacts and channels, and configure your radio." />
|
||||
<title>RemoteTerm for MeshCore</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="./favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="shortcut icon" href="./favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="./apple-touch-icon.png" />
|
||||
<link rel="manifest" href="./site.webmanifest" />
|
||||
<script>
|
||||
// Start critical data fetches before React/Vite JS loads.
|
||||
// Must be in <head> BEFORE the module script so the browser queues these
|
||||
@@ -42,17 +42,17 @@
|
||||
});
|
||||
};
|
||||
window.__prefetch = {
|
||||
config: fetchJsonOrThrow('/api/radio/config'),
|
||||
settings: fetchJsonOrThrow('/api/settings'),
|
||||
channels: fetchJsonOrThrow('/api/channels'),
|
||||
contacts: fetchJsonOrThrow('/api/contacts?limit=1000&offset=0'),
|
||||
unreads: fetchJsonOrThrow('/api/read-state/unreads'),
|
||||
undecryptedCount: fetchJsonOrThrow('/api/packets/undecrypted/count'),
|
||||
config: fetchJsonOrThrow('./api/radio/config'),
|
||||
settings: fetchJsonOrThrow('./api/settings'),
|
||||
channels: fetchJsonOrThrow('./api/channels'),
|
||||
contacts: fetchJsonOrThrow('./api/contacts?limit=1000&offset=0'),
|
||||
unreads: fetchJsonOrThrow('./api/read-state/unreads'),
|
||||
undecryptedCount: fetchJsonOrThrow('./api/packets/undecrypted/count'),
|
||||
};
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script type="module" src="./src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Generated
+401
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"version": "3.6.1",
|
||||
"version": "3.8.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"version": "3.6.1",
|
||||
"version": "3.8.0",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
@@ -20,6 +20,7 @@
|
||||
"@uiw/react-codemirror": "^4.25.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"d3-force": "^3.0.0",
|
||||
"d3-force-3d": "^3.0.6",
|
||||
"leaflet": "^1.9.4",
|
||||
@@ -30,6 +31,7 @@
|
||||
"react-dom": "^18.3.1",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-swipeable": "^7.0.2",
|
||||
"recharts": "^3.8.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
@@ -2057,6 +2059,42 @@
|
||||
"react-dom": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.0.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||
"version": "11.1.4",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
|
||||
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -2414,6 +2452,18 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
@@ -2564,6 +2614,24 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-force": {
|
||||
"version": "3.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
|
||||
@@ -2571,6 +2639,51 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -2663,6 +2776,12 @@
|
||||
"meshoptimizer": "~0.22.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/webxr": {
|
||||
"version": "0.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||
@@ -3569,6 +3688,22 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/cmdk": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
|
||||
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "^1.1.1",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-id": "^1.1.0",
|
||||
"@radix-ui/react-primitive": "^2.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19 || ^19.0.0-rc",
|
||||
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/codemirror": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
|
||||
@@ -3712,12 +3847,33 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-binarytree": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz",
|
||||
"integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dispatch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||
@@ -3727,6 +3883,15 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-force": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
|
||||
@@ -3757,12 +3922,42 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-octree": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz",
|
||||
"integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-quadtree": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
|
||||
@@ -3772,6 +3967,58 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
@@ -3820,6 +4067,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deep-eql": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
||||
@@ -3974,6 +4227,16 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.45.1",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
|
||||
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||
@@ -4216,6 +4479,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||
@@ -4618,6 +4887,16 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -4655,6 +4934,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
@@ -5599,7 +5887,6 @@
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
@@ -5617,6 +5904,29 @@
|
||||
"react-dom": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
@@ -5726,6 +6036,36 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.8.1",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
|
||||
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"www"
|
||||
],
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^10.1.1",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.1.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||
@@ -5740,6 +6080,27 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
@@ -6134,6 +6495,12 @@
|
||||
"integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
@@ -6448,12 +6815,43 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"private": true,
|
||||
"version": "3.6.2",
|
||||
"version": "3.11.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -28,6 +28,7 @@
|
||||
"@uiw/react-codemirror": "^4.25.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"d3-force": "^3.0.0",
|
||||
"d3-force-3d": "^3.0.6",
|
||||
"leaflet": "^1.9.4",
|
||||
@@ -38,6 +39,7 @@
|
||||
"react-dom": "^18.3.1",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-swipeable": "^7.0.2",
|
||||
"recharts": "^3.8.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
|
||||
+157
-14
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useCallback, useRef, useState, useMemo } from 'react';
|
||||
import { useEffect, useCallback, useRef, useState, useMemo, type MouseEvent } from 'react';
|
||||
import { api } from './api';
|
||||
import { takePrefetchOrFetch } from './prefetch';
|
||||
import { useWebSocket } from './useWebSocket';
|
||||
@@ -18,19 +18,27 @@ import {
|
||||
useUnreadTitle,
|
||||
useRawPacketStatsSession,
|
||||
} from './hooks';
|
||||
import { toast } from './components/ui/sonner';
|
||||
import { AppShell } from './components/AppShell';
|
||||
import type { MessageInputHandle } from './components/MessageInput';
|
||||
import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
|
||||
import { messageContainsMention } from './utils/messageParser';
|
||||
import { getStateKey } from './utils/conversationState';
|
||||
import type { Conversation, Message, RawPacket } from './types';
|
||||
import { CONTACT_TYPE_ROOM } from './types';
|
||||
import type { BulkCreateHashtagChannelsResult, Conversation, Message, RawPacket } from './types';
|
||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from './types';
|
||||
import { shouldAutoFocusInput } from './utils/autoFocusInput';
|
||||
|
||||
interface ChannelUnreadMarker {
|
||||
channelId: string;
|
||||
lastReadAt: number | null;
|
||||
}
|
||||
|
||||
interface NewMessagePrefillRequest {
|
||||
tab: 'hashtag';
|
||||
hashtagName: string;
|
||||
nonce: number;
|
||||
}
|
||||
|
||||
interface UnreadBoundaryBackfillParams {
|
||||
activeConversation: Conversation | null;
|
||||
unreadMarker: ChannelUnreadMarker | null;
|
||||
@@ -77,6 +85,11 @@ export function App() {
|
||||
const messageInputRef = useRef<MessageInputHandle>(null);
|
||||
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
|
||||
const [channelUnreadMarker, setChannelUnreadMarker] = useState<ChannelUnreadMarker | null>(null);
|
||||
const [newMessagePrefillRequest, setNewMessagePrefillRequest] =
|
||||
useState<NewMessagePrefillRequest | null>(null);
|
||||
const [showBulkAddChannelTab, setShowBulkAddChannelTab] = useState(false);
|
||||
const [bulkAddResult, setBulkAddResult] = useState<BulkCreateHashtagChannelsResult | null>(null);
|
||||
const [repeaterAutoLoginKey, setRepeaterAutoLoginKey] = useState<string | null>(null);
|
||||
const [visibilityVersion, setVisibilityVersion] = useState(0);
|
||||
const lastUnreadBackfillAttemptRef = useRef<string | null>(null);
|
||||
const {
|
||||
@@ -103,8 +116,8 @@ export function App() {
|
||||
setDistanceUnit,
|
||||
handleCloseSettingsView,
|
||||
handleToggleSettingsView,
|
||||
handleOpenNewMessage,
|
||||
handleCloseNewMessage,
|
||||
handleOpenNewMessage: openNewMessageModal,
|
||||
handleCloseNewMessage: closeNewMessageModal,
|
||||
handleToggleCracker,
|
||||
} = useAppShell();
|
||||
|
||||
@@ -140,12 +153,11 @@ export function App() {
|
||||
|
||||
const {
|
||||
appSettings,
|
||||
favorites,
|
||||
fetchAppSettings,
|
||||
handleSaveAppSettings,
|
||||
handleToggleFavorite,
|
||||
handleToggleBlockedKey,
|
||||
handleToggleBlockedName,
|
||||
handleToggleTrackedTelemetry,
|
||||
} = useAppSettings();
|
||||
|
||||
// Keep user's name in ref for mention detection in WebSocket callback
|
||||
@@ -182,6 +194,7 @@ export function App() {
|
||||
handleCreateContact,
|
||||
handleCreateChannel,
|
||||
handleCreateHashtagChannel,
|
||||
handleBulkCreateHashtagChannels,
|
||||
handleDeleteChannel,
|
||||
handleDeleteContact,
|
||||
} = useContactsAndChannels({
|
||||
@@ -192,6 +205,38 @@ export function App() {
|
||||
removeConversationMessagesRef.current(conversationId),
|
||||
});
|
||||
|
||||
const handleToggleFavorite = useCallback(
|
||||
async (type: 'channel' | 'contact', id: string) => {
|
||||
// Optimistically toggle the favorite flag
|
||||
if (type === 'contact') {
|
||||
setContacts((prev) =>
|
||||
prev.map((c) => (c.public_key === id ? { ...c, favorite: !c.favorite } : c))
|
||||
);
|
||||
} else {
|
||||
setChannels((prev) =>
|
||||
prev.map((c) => (c.key === id ? { ...c, favorite: !c.favorite } : c))
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await api.toggleFavorite(type, id);
|
||||
} catch {
|
||||
// Revert on failure
|
||||
if (type === 'contact') {
|
||||
setContacts((prev) =>
|
||||
prev.map((c) => (c.public_key === id ? { ...c, favorite: !c.favorite } : c))
|
||||
);
|
||||
} else {
|
||||
setChannels((prev) =>
|
||||
prev.map((c) => (c.key === id ? { ...c, favorite: !c.favorite } : c))
|
||||
);
|
||||
}
|
||||
toast.error('Failed to update favorite');
|
||||
}
|
||||
},
|
||||
[setContacts, setChannels]
|
||||
);
|
||||
|
||||
// useConversationRouter is called second — it receives channels/contacts as inputs
|
||||
const {
|
||||
activeConversation,
|
||||
@@ -252,6 +297,21 @@ export function App() {
|
||||
} = useConversationMessages(activeConversation, targetMessageId);
|
||||
removeConversationMessagesRef.current = removeConversationMessages;
|
||||
|
||||
// Auto-focus the message input on conversation change (desktop only by default)
|
||||
useEffect(() => {
|
||||
if (!activeConversation) return;
|
||||
if (activeConversation.type !== 'channel' && activeConversation.type !== 'contact') return;
|
||||
// Repeaters show a login form, not a message input
|
||||
if (activeConversation.type === 'contact') {
|
||||
const contact = contacts.find((c) => c.public_key === activeConversation.id);
|
||||
if (contact?.type === CONTACT_TYPE_REPEATER) return;
|
||||
}
|
||||
if (!shouldAutoFocusInput()) return;
|
||||
// Defer to let the input mount/render first
|
||||
const raf = requestAnimationFrame(() => messageInputRef.current?.focus?.());
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [activeConversation?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 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.
|
||||
@@ -274,11 +334,12 @@ export function App() {
|
||||
unreadLastReadAts,
|
||||
recordMessageEvent,
|
||||
renameConversationState,
|
||||
removeConversationState,
|
||||
markAllRead,
|
||||
refreshUnreads,
|
||||
} = useUnreadCounts(channels, contacts, activeConversation);
|
||||
useFaviconBadge(unreadCounts, mentions, favorites);
|
||||
useUnreadTitle(unreadCounts, favorites);
|
||||
useFaviconBadge(unreadCounts, mentions, channels);
|
||||
useUnreadTitle(unreadCounts, contacts, channels);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeConversation?.type !== 'channel') {
|
||||
@@ -349,6 +410,7 @@ export function App() {
|
||||
observeMessage,
|
||||
recordMessageEvent,
|
||||
renameConversationState,
|
||||
removeConversationState,
|
||||
checkMention,
|
||||
pendingDeleteFallbackRef,
|
||||
setActiveConversation,
|
||||
@@ -384,6 +446,7 @@ export function App() {
|
||||
handleSendMessage,
|
||||
handleResendChannelMessage,
|
||||
handleSetChannelFloodScopeOverride,
|
||||
handleSetChannelPathHashModeOverride,
|
||||
handleSenderClick,
|
||||
handleTrace,
|
||||
handlePathDiscovery,
|
||||
@@ -411,6 +474,64 @@ export function App() {
|
||||
[fetchUndecryptedCount, setChannels]
|
||||
);
|
||||
|
||||
const handleRepeaterAutoLogin = useCallback(
|
||||
(publicKey: string, displayName: string) => {
|
||||
handleSelectConversationWithTargetReset({
|
||||
type: 'contact',
|
||||
id: publicKey,
|
||||
name: displayName,
|
||||
});
|
||||
setRepeaterAutoLoginKey(publicKey);
|
||||
},
|
||||
[handleSelectConversationWithTargetReset]
|
||||
);
|
||||
|
||||
const handleOpenNewMessage = useCallback(
|
||||
(event?: MouseEvent<HTMLButtonElement>) => {
|
||||
setNewMessagePrefillRequest(null);
|
||||
setShowBulkAddChannelTab(event?.altKey === true);
|
||||
openNewMessageModal();
|
||||
},
|
||||
[openNewMessageModal]
|
||||
);
|
||||
|
||||
const handleCloseNewMessage = useCallback(() => {
|
||||
setNewMessagePrefillRequest(null);
|
||||
setShowBulkAddChannelTab(false);
|
||||
closeNewMessageModal();
|
||||
}, [closeNewMessageModal]);
|
||||
|
||||
const handleCloseBulkAddResults = useCallback(() => {
|
||||
setBulkAddResult(null);
|
||||
}, []);
|
||||
|
||||
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,
|
||||
}));
|
||||
setShowBulkAddChannelTab(false);
|
||||
openNewMessageModal();
|
||||
},
|
||||
[channels, handleNavigateToChannel, openNewMessageModal]
|
||||
);
|
||||
|
||||
const handleBulkAddChannels = useCallback(
|
||||
async (channelNames: string[], tryHistorical: boolean) => {
|
||||
const result = await handleBulkCreateHashtagChannels(channelNames, tryHistorical);
|
||||
setBulkAddResult(result);
|
||||
},
|
||||
[handleBulkCreateHashtagChannels]
|
||||
);
|
||||
|
||||
const statusProps = {
|
||||
health,
|
||||
config,
|
||||
@@ -430,9 +551,12 @@ export function App() {
|
||||
onMarkAllRead: () => {
|
||||
void markAllRead();
|
||||
},
|
||||
favorites,
|
||||
legacySortOrder: appSettings?.sidebar_sort_order,
|
||||
isConversationNotificationsEnabled,
|
||||
blockedKeys: appSettings?.blocked_keys ?? [],
|
||||
blockedNames: appSettings?.blocked_names ?? [],
|
||||
};
|
||||
const bulkAddChannelResultModalProps = {
|
||||
result: bulkAddResult,
|
||||
};
|
||||
const conversationPaneProps = {
|
||||
activeConversation,
|
||||
@@ -442,8 +566,8 @@ export function App() {
|
||||
rawPacketStatsSession,
|
||||
config,
|
||||
health,
|
||||
favorites,
|
||||
messages: sortedMessages,
|
||||
preSorted: activeContactIsRoom,
|
||||
messagesLoading,
|
||||
loadingOlder,
|
||||
hasOlderMessages,
|
||||
@@ -457,14 +581,17 @@ export function App() {
|
||||
loadingNewer,
|
||||
messageInputRef,
|
||||
onTrace: handleTrace,
|
||||
onRunTracePath: api.requestRadioTrace,
|
||||
onPathDiscovery: handlePathDiscovery,
|
||||
onToggleFavorite: handleToggleFavorite,
|
||||
onDeleteContact: handleDeleteContact,
|
||||
onDeleteChannel: handleDeleteChannel,
|
||||
onSetChannelFloodScopeOverride: handleSetChannelFloodScopeOverride,
|
||||
onSetChannelPathHashModeOverride: handleSetChannelPathHashModeOverride,
|
||||
onOpenContactInfo: handleOpenContactInfo,
|
||||
onOpenChannelInfo: handleOpenChannelInfo,
|
||||
onSenderClick: handleSenderClick,
|
||||
onChannelReferenceClick: handleChannelReferenceClick,
|
||||
onLoadOlder: fetchOlderMessages,
|
||||
onResendChannelMessage: handleResendChannelMessage,
|
||||
onTargetReached: () => setTargetMessageId(null),
|
||||
@@ -487,6 +614,10 @@ export function App() {
|
||||
);
|
||||
}
|
||||
},
|
||||
trackedTelemetryRepeaters: appSettings?.tracked_telemetry_repeaters ?? [],
|
||||
onToggleTrackedTelemetry: handleToggleTrackedTelemetry,
|
||||
repeaterAutoLoginKey,
|
||||
onClearRepeaterAutoLogin: () => setRepeaterAutoLoginKey(null),
|
||||
};
|
||||
const searchProps = {
|
||||
contacts,
|
||||
@@ -515,6 +646,13 @@ export function App() {
|
||||
blockedNames: appSettings?.blocked_names,
|
||||
onToggleBlockedKey: handleBlockKey,
|
||||
onToggleBlockedName: handleBlockName,
|
||||
contacts,
|
||||
onBulkDeleteContacts: (deletedKeys: string[]) => {
|
||||
const keySet = new Set(deletedKeys.map((k) => k.toLowerCase()));
|
||||
setContacts((prev) => prev.filter((c) => !keySet.has(c.public_key.toLowerCase())));
|
||||
},
|
||||
trackedTelemetryRepeaters: appSettings?.tracked_telemetry_repeaters ?? [],
|
||||
onToggleTrackedTelemetry: handleToggleTrackedTelemetry,
|
||||
};
|
||||
const crackerProps = {
|
||||
packets: rawPackets,
|
||||
@@ -523,9 +661,12 @@ export function App() {
|
||||
};
|
||||
const newMessageModalProps = {
|
||||
undecryptedCount,
|
||||
showBulkAddChannelTab,
|
||||
prefillRequest: newMessagePrefillRequest,
|
||||
onCreateContact: handleCreateContact,
|
||||
onCreateChannel: handleCreateChannel,
|
||||
onCreateHashtagChannel: handleCreateHashtagChannel,
|
||||
onBulkAddHashtagChannels: handleBulkAddChannels,
|
||||
};
|
||||
const contactInfoPaneProps = {
|
||||
contactKey: infoPaneContactKey,
|
||||
@@ -533,7 +674,6 @@ export function App() {
|
||||
onClose: handleCloseContactInfo,
|
||||
contacts,
|
||||
config,
|
||||
favorites,
|
||||
onToggleFavorite: handleToggleFavorite,
|
||||
onNavigateToChannel: handleNavigateToChannel,
|
||||
onSearchMessagesByKey: (publicKey: string) => {
|
||||
@@ -551,7 +691,6 @@ export function App() {
|
||||
channelKey: infoPaneChannelKey,
|
||||
onClose: handleCloseChannelInfo,
|
||||
channels,
|
||||
favorites,
|
||||
onToggleFavorite: handleToggleFavorite,
|
||||
};
|
||||
|
||||
@@ -589,6 +728,7 @@ export function App() {
|
||||
<AppShell
|
||||
localLabel={localLabel}
|
||||
showNewMessage={showNewMessage}
|
||||
showBulkAddResults={bulkAddResult !== null}
|
||||
showSettings={showSettings}
|
||||
settingsSection={settingsSection}
|
||||
sidebarOpen={sidebarOpen}
|
||||
@@ -599,6 +739,7 @@ export function App() {
|
||||
onToggleSettingsView={handleToggleSettingsView}
|
||||
onCloseSettingsView={handleCloseSettingsView}
|
||||
onCloseNewMessage={handleCloseNewMessage}
|
||||
onCloseBulkAddResults={handleCloseBulkAddResults}
|
||||
onLocalLabelChange={setLocalLabel}
|
||||
statusProps={statusProps}
|
||||
sidebarProps={sidebarProps}
|
||||
@@ -607,8 +748,10 @@ export function App() {
|
||||
settingsProps={settingsProps}
|
||||
crackerProps={crackerProps}
|
||||
newMessageModalProps={newMessageModalProps}
|
||||
bulkAddChannelResultModalProps={bulkAddChannelResultModalProps}
|
||||
contactInfoPaneProps={contactInfoPaneProps}
|
||||
channelInfoPaneProps={channelInfoPaneProps}
|
||||
onRepeaterAutoLogin={handleRepeaterAutoLogin}
|
||||
/>
|
||||
</DistanceUnitProvider>
|
||||
);
|
||||
|
||||
+42
-14
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
AppSettings,
|
||||
AppSettingsUpdate,
|
||||
BulkCreateHashtagChannelsResult,
|
||||
Channel,
|
||||
ChannelDetail,
|
||||
CommandResponse,
|
||||
@@ -8,18 +9,17 @@ import type {
|
||||
ContactAnalytics,
|
||||
ContactAdvertPathSummary,
|
||||
FanoutConfig,
|
||||
Favorite,
|
||||
HealthStatus,
|
||||
MaintenanceResult,
|
||||
Message,
|
||||
MessagesAroundResponse,
|
||||
MigratePreferencesRequest,
|
||||
MigratePreferencesResponse,
|
||||
RawPacket,
|
||||
RadioAdvertMode,
|
||||
RadioConfig,
|
||||
RadioConfigUpdate,
|
||||
RadioDiscoveryResponse,
|
||||
RadioTraceHopRequest,
|
||||
RadioTraceResponse,
|
||||
RadioDiscoveryTarget,
|
||||
PathDiscoveryResponse,
|
||||
ResendChannelMessageResponse,
|
||||
@@ -32,12 +32,14 @@ import type {
|
||||
RepeaterOwnerInfoResponse,
|
||||
RepeaterRadioSettingsResponse,
|
||||
RepeaterStatusResponse,
|
||||
TelemetryHistoryEntry,
|
||||
TrackedTelemetryResponse,
|
||||
StatisticsResponse,
|
||||
TraceResponse,
|
||||
UnreadCounts,
|
||||
} from './types';
|
||||
|
||||
const API_BASE = '/api';
|
||||
const API_BASE = './api';
|
||||
|
||||
async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const hasBody = options?.body !== undefined;
|
||||
@@ -107,6 +109,11 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ target }),
|
||||
}),
|
||||
requestRadioTrace: (hopHashBytes: 1 | 2 | 4, hops: RadioTraceHopRequest[]) =>
|
||||
fetchJson<RadioTraceResponse>('/radio/trace', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ hop_hash_bytes: hopHashBytes, hops }),
|
||||
}),
|
||||
rebootRadio: () =>
|
||||
fetchJson<{ status: string; message: string }>('/radio/reboot', {
|
||||
method: 'POST',
|
||||
@@ -130,16 +137,24 @@ export const api = {
|
||||
fetchJson<ContactAdvertPathSummary[]>(
|
||||
`/contacts/repeaters/advert-paths?limit_per_repeater=${limitPerRepeater}`
|
||||
),
|
||||
getContactAnalytics: (params: { publicKey?: string; name?: string }) => {
|
||||
getContactAnalytics: (params: { publicKey?: string; name?: string }, signal?: AbortSignal) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params.publicKey) searchParams.set('public_key', params.publicKey);
|
||||
if (params.name) searchParams.set('name', params.name);
|
||||
return fetchJson<ContactAnalytics>(`/contacts/analytics?${searchParams.toString()}`);
|
||||
return fetchJson<ContactAnalytics>(`/contacts/analytics?${searchParams.toString()}`, {
|
||||
signal,
|
||||
});
|
||||
},
|
||||
deleteContact: (publicKey: string) =>
|
||||
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
bulkDeleteContacts: (publicKeys: string[]) =>
|
||||
fetchJson<{ deleted: number }>('/contacts/bulk-delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ public_keys: publicKeys }),
|
||||
}),
|
||||
createContact: (publicKey: string, name?: string, tryHistorical?: boolean) =>
|
||||
fetchJson<Contact>('/contacts', {
|
||||
method: 'POST',
|
||||
@@ -175,6 +190,11 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, key }),
|
||||
}),
|
||||
bulkCreateHashtagChannels: (channelNames: string[], tryHistorical?: boolean) =>
|
||||
fetchJson<BulkCreateHashtagChannelsResult>('/channels/bulk-hashtag', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ channel_names: channelNames, try_historical: tryHistorical }),
|
||||
}),
|
||||
deleteChannel: (key: string) =>
|
||||
fetchJson<{ status: string }>(`/channels/${key}`, { method: 'DELETE' }),
|
||||
getChannelDetail: (key: string) => fetchJson<ChannelDetail>(`/channels/${key}/detail`),
|
||||
@@ -188,6 +208,12 @@ export const api = {
|
||||
body: JSON.stringify({ flood_scope_override: floodScopeOverride }),
|
||||
}),
|
||||
|
||||
setChannelPathHashModeOverride: (key: string, pathHashModeOverride: number | null) =>
|
||||
fetchJson<Channel>(`/channels/${key}/path-hash-mode-override`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path_hash_mode_override: pathHashModeOverride }),
|
||||
}),
|
||||
|
||||
// Messages
|
||||
getMessages: (
|
||||
params?: {
|
||||
@@ -299,18 +325,18 @@ export const api = {
|
||||
body: JSON.stringify({ name }),
|
||||
}),
|
||||
|
||||
// Favorites
|
||||
toggleFavorite: (type: Favorite['type'], id: string) =>
|
||||
fetchJson<AppSettings>('/settings/favorites/toggle', {
|
||||
// Tracked telemetry
|
||||
toggleTrackedTelemetry: (publicKey: string) =>
|
||||
fetchJson<TrackedTelemetryResponse>('/settings/tracked-telemetry/toggle', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ type, id }),
|
||||
body: JSON.stringify({ public_key: publicKey }),
|
||||
}),
|
||||
|
||||
// Preferences migration (one-time, from localStorage to database)
|
||||
migratePreferences: (request: MigratePreferencesRequest) =>
|
||||
fetchJson<MigratePreferencesResponse>('/settings/migrate', {
|
||||
// Favorites
|
||||
toggleFavorite: (type: 'channel' | 'contact', id: string) =>
|
||||
fetchJson<{ type: string; id: string; favorite: boolean }>('/settings/favorites/toggle', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
body: JSON.stringify({ type, id }),
|
||||
}),
|
||||
|
||||
// Fanout
|
||||
@@ -393,6 +419,8 @@ export const api = {
|
||||
fetchJson<RepeaterLppTelemetryResponse>(`/contacts/${publicKey}/repeater/lpp-telemetry`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
repeaterTelemetryHistory: (publicKey: string) =>
|
||||
fetchJson<TelemetryHistoryEntry[]>(`/contacts/${publicKey}/repeater/telemetry-history`),
|
||||
roomLogin: (publicKey: string, password: string) =>
|
||||
fetchJson<RepeaterLoginResponse>(`/contacts/${publicKey}/room/login`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { lazy, Suspense, useRef, type ComponentProps } from 'react';
|
||||
import { lazy, Suspense, useCallback, useRef, type ComponentProps } from 'react';
|
||||
import { useSwipeable } from 'react-swipeable';
|
||||
|
||||
import { StatusBar } from './StatusBar';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { ConversationPane } from './ConversationPane';
|
||||
import { NewMessageModal } from './NewMessageModal';
|
||||
import { BulkAddChannelResultModal } from './BulkAddChannelResultModal';
|
||||
import { ContactInfoPane } from './ContactInfoPane';
|
||||
import { ChannelInfoPane } from './ChannelInfoPane';
|
||||
import { CommandPalette } from './CommandPalette';
|
||||
import { SecurityWarningModal } from './SecurityWarningModal';
|
||||
import { Toaster } from './ui/sonner';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
|
||||
@@ -33,12 +35,17 @@ const SearchView = lazy(() => import('./SearchView').then((m) => ({ default: m.S
|
||||
type SidebarProps = ComponentProps<typeof Sidebar>;
|
||||
type ConversationPaneProps = ComponentProps<typeof ConversationPane>;
|
||||
type NewMessageModalProps = Omit<ComponentProps<typeof NewMessageModal>, 'open' | 'onClose'>;
|
||||
type BulkAddChannelResultModalProps = Omit<
|
||||
ComponentProps<typeof BulkAddChannelResultModal>,
|
||||
'open' | 'onClose'
|
||||
>;
|
||||
type ContactInfoPaneProps = ComponentProps<typeof ContactInfoPane>;
|
||||
type ChannelInfoPaneProps = ComponentProps<typeof ChannelInfoPane>;
|
||||
|
||||
interface AppShellProps {
|
||||
localLabel: LocalLabel;
|
||||
showNewMessage: boolean;
|
||||
showBulkAddResults: boolean;
|
||||
showSettings: boolean;
|
||||
settingsSection: SettingsSection;
|
||||
sidebarOpen: boolean;
|
||||
@@ -50,6 +57,7 @@ interface AppShellProps {
|
||||
onToggleSettingsView: () => void;
|
||||
onCloseSettingsView: () => void;
|
||||
onCloseNewMessage: () => void;
|
||||
onCloseBulkAddResults: () => void;
|
||||
onLocalLabelChange: (label: LocalLabel) => void;
|
||||
statusProps: Pick<ComponentProps<typeof StatusBar>, 'health' | 'config'>;
|
||||
sidebarProps: SidebarProps;
|
||||
@@ -61,13 +69,16 @@ interface AppShellProps {
|
||||
>;
|
||||
crackerProps: Omit<CrackerPanelProps, 'visible' | 'onRunningChange'>;
|
||||
newMessageModalProps: NewMessageModalProps;
|
||||
bulkAddChannelResultModalProps: BulkAddChannelResultModalProps;
|
||||
contactInfoPaneProps: ContactInfoPaneProps;
|
||||
channelInfoPaneProps: ChannelInfoPaneProps;
|
||||
onRepeaterAutoLogin: (publicKey: string, displayName: string) => void;
|
||||
}
|
||||
|
||||
export function AppShell({
|
||||
localLabel,
|
||||
showNewMessage,
|
||||
showBulkAddResults,
|
||||
showSettings,
|
||||
settingsSection,
|
||||
sidebarOpen,
|
||||
@@ -79,6 +90,7 @@ export function AppShell({
|
||||
onToggleSettingsView,
|
||||
onCloseSettingsView,
|
||||
onCloseNewMessage,
|
||||
onCloseBulkAddResults,
|
||||
onLocalLabelChange,
|
||||
statusProps,
|
||||
sidebarProps,
|
||||
@@ -87,8 +99,10 @@ export function AppShell({
|
||||
settingsProps,
|
||||
crackerProps,
|
||||
newMessageModalProps,
|
||||
bulkAddChannelResultModalProps,
|
||||
contactInfoPaneProps,
|
||||
channelInfoPaneProps,
|
||||
onRepeaterAutoLogin,
|
||||
}: AppShellProps) {
|
||||
const swipeHandlers = useSwipeable({
|
||||
onSwipedRight: ({ initial }) => {
|
||||
@@ -108,6 +122,14 @@ export function AppShell({
|
||||
preventScrollOnSwipe: false,
|
||||
});
|
||||
|
||||
const handleOpenSettings = useCallback(
|
||||
(section: SettingsSection) => {
|
||||
onSettingsSectionChange(section);
|
||||
if (!showSettings) onToggleSettingsView();
|
||||
},
|
||||
[onSettingsSectionChange, onToggleSettingsView, showSettings]
|
||||
);
|
||||
|
||||
const searchMounted = useRef(false);
|
||||
if (conversationPaneProps.activeConversation?.type === 'search') {
|
||||
searchMounted.current = true;
|
||||
@@ -124,7 +146,7 @@ export function AppShell({
|
||||
aria-label="Settings"
|
||||
>
|
||||
<div className="flex justify-between items-center px-3 py-2.5 border-b border-border">
|
||||
<h2 className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
<h2 className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Settings
|
||||
</h2>
|
||||
<button
|
||||
@@ -147,7 +169,7 @@ export function AppShell({
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 text-left text-[13px] border-l-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'w-full px-3 py-2 text-left text-[0.8125rem] border-l-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset disabled:cursor-not-allowed disabled:opacity-50',
|
||||
!disabled && 'hover:bg-accent',
|
||||
settingsSection === section && !disabled && 'bg-accent border-l-primary'
|
||||
)}
|
||||
@@ -288,7 +310,7 @@ export function AppShell({
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
Loading cracker...
|
||||
Loading channel finder...
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -306,7 +328,19 @@ export function AppShell({
|
||||
open={showNewMessage}
|
||||
onClose={onCloseNewMessage}
|
||||
/>
|
||||
<BulkAddChannelResultModal
|
||||
{...bulkAddChannelResultModalProps}
|
||||
open={showBulkAddResults}
|
||||
onClose={onCloseBulkAddResults}
|
||||
/>
|
||||
|
||||
<CommandPalette
|
||||
contacts={sidebarProps.contacts}
|
||||
channels={sidebarProps.channels}
|
||||
onSelectConversation={sidebarProps.onSelectConversation}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
onRepeaterAutoLogin={onRepeaterAutoLogin}
|
||||
/>
|
||||
<SecurityWarningModal health={statusProps.health} />
|
||||
<ContactInfoPane {...contactInfoPaneProps} />
|
||||
<ChannelInfoPane {...channelInfoPaneProps} />
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { BulkCreateHashtagChannelsResult, Channel } from '../types';
|
||||
import { getConversationHash } from '../utils/urlHash';
|
||||
import { Button } from './ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
|
||||
interface BulkAddChannelResultModalProps {
|
||||
open: boolean;
|
||||
result: BulkCreateHashtagChannelsResult | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function getChannelHref(channel: Channel): string {
|
||||
const hash = getConversationHash({
|
||||
type: 'channel',
|
||||
id: channel.key,
|
||||
name: channel.name,
|
||||
});
|
||||
if (typeof window === 'undefined') {
|
||||
return hash;
|
||||
}
|
||||
return `${window.location.origin}${window.location.pathname}${hash}`;
|
||||
}
|
||||
|
||||
export function BulkAddChannelResultModal({
|
||||
open,
|
||||
result,
|
||||
onClose,
|
||||
}: BulkAddChannelResultModalProps) {
|
||||
const createdChannels = result?.created_channels ?? [];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="sm:max-w-[520px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Bulk Add Complete</DialogTitle>
|
||||
<DialogDescription>
|
||||
{result?.message ?? 'Review the newly added rooms below.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{result && (
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2">
|
||||
<div className="text-[0.625rem] uppercase tracking-wider font-medium text-muted-foreground">
|
||||
Created
|
||||
</div>
|
||||
<div className="mt-1 font-medium">{createdChannels.length}</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2">
|
||||
<div className="text-[0.625rem] uppercase tracking-wider font-medium text-muted-foreground">
|
||||
Already Present
|
||||
</div>
|
||||
<div className="mt-1 font-medium">{result.existing_count}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{createdChannels.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ctrl+click any room to open it in a new tab.
|
||||
</p>
|
||||
<div className="max-h-64 overflow-y-auto rounded-md border border-border/70">
|
||||
<ul className="divide-y divide-border/70">
|
||||
{createdChannels.map((channel) => (
|
||||
<li key={channel.key}>
|
||||
<a
|
||||
href={getChannelHref(channel)}
|
||||
className="block px-3 py-2 text-sm text-primary hover:bg-accent hover:text-primary"
|
||||
>
|
||||
{channel.name}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No new rooms were added.</p>
|
||||
)}
|
||||
|
||||
{result && result.invalid_names.length > 0 && (
|
||||
<div className="rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
|
||||
Ignored invalid room names: {result.invalid_names.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,17 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip as RechartsTooltip } from 'recharts';
|
||||
import { Star } from 'lucide-react';
|
||||
import { api } from '../api';
|
||||
import { formatTime } from '../utils/messageParser';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
|
||||
import { toast } from './ui/sonner';
|
||||
import type { Channel, ChannelDetail, Favorite } from '../types';
|
||||
import type { Channel, ChannelDetail, PathHashWidthStats } from '../types';
|
||||
|
||||
interface ChannelInfoPaneProps {
|
||||
channelKey: string | null;
|
||||
onClose: () => void;
|
||||
channels: Channel[];
|
||||
favorites: Favorite[];
|
||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
||||
}
|
||||
|
||||
@@ -20,7 +19,6 @@ export function ChannelInfoPane({
|
||||
channelKey,
|
||||
onClose,
|
||||
channels,
|
||||
favorites,
|
||||
onToggleFavorite,
|
||||
}: ChannelInfoPaneProps) {
|
||||
const [detail, setDetail] = useState<ChannelDetail | null>(null);
|
||||
@@ -106,11 +104,11 @@ export function ChannelInfoPane({
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
|
||||
<span className="text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
|
||||
{channel.is_hashtag ? 'Hashtag' : 'Private Key'}
|
||||
</span>
|
||||
{channel.on_radio && (
|
||||
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">
|
||||
<span className="text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">
|
||||
On Radio
|
||||
</span>
|
||||
)}
|
||||
@@ -124,7 +122,7 @@ export function ChannelInfoPane({
|
||||
className="text-sm flex items-center gap-2 hover:text-primary transition-colors"
|
||||
onClick={() => onToggleFavorite('channel', channel.key)}
|
||||
>
|
||||
{isFavorite(favorites, 'channel', channel.key) ? (
|
||||
{channel.favorite ? (
|
||||
<>
|
||||
<Star className="h-4.5 w-4.5 fill-current text-favorite" aria-hidden="true" />
|
||||
<span>Remove from favorites</span>
|
||||
@@ -179,6 +177,14 @@ export function ChannelInfoPane({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hop Byte Widths (24h) */}
|
||||
{detail && detail.path_hash_width_24h.total_packets > 0 && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Hop Byte Widths (24h)</SectionLabel>
|
||||
<HopWidthChart stats={detail.path_hash_width_24h} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Senders 24h */}
|
||||
{detail && detail.top_senders_24h.length > 0 && (
|
||||
<div className="px-5 py-3">
|
||||
@@ -212,7 +218,7 @@ export function ChannelInfoPane({
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<h3 className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium mb-1.5">
|
||||
<h3 className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium mb-1.5">
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
@@ -226,3 +232,82 @@ function InfoItem({ label, value }: { label: string; value: string }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const HOP_WIDTH_SEGMENTS = [
|
||||
{ key: 'single_byte', label: '1-byte', color: '#22c55e' },
|
||||
{ key: 'double_byte', label: '2-byte', color: '#0ea5e9' },
|
||||
{ key: 'triple_byte', label: '3-byte', color: '#8b5cf6' },
|
||||
] as const;
|
||||
|
||||
const TOOLTIP_STYLE = {
|
||||
contentStyle: {
|
||||
backgroundColor: 'hsl(var(--popover))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
fontSize: '11px',
|
||||
color: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
} as const;
|
||||
|
||||
function HopWidthChart({ stats }: { stats: PathHashWidthStats }) {
|
||||
const data = useMemo(
|
||||
() =>
|
||||
HOP_WIDTH_SEGMENTS.map(({ key, label, color }) => ({
|
||||
name: label,
|
||||
value: stats[key] as number,
|
||||
color,
|
||||
})).filter((d) => d.value > 0),
|
||||
[stats]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0" style={{ width: 90, height: 90 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="value"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={22}
|
||||
outerRadius={40}
|
||||
strokeWidth={1.5}
|
||||
stroke="hsl(var(--background))"
|
||||
>
|
||||
{data.map((d) => (
|
||||
<Cell key={d.name} fill={d.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<RechartsTooltip
|
||||
{...TOOLTIP_STYLE}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
formatter={(value: any, name: any) => {
|
||||
const v = typeof value === 'number' ? value : Number(value);
|
||||
return [`${v.toLocaleString()} pkt${v !== 1 ? 's' : ''}`, name];
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-1">
|
||||
{data.map((d) => (
|
||||
<div key={d.name} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: d.color }}
|
||||
/>
|
||||
<span className="text-[0.6875rem] text-muted-foreground flex-1">{d.name}</span>
|
||||
<span className="text-[0.6875rem] font-medium tabular-nums">
|
||||
{d.value.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<p className="text-[0.625rem] text-muted-foreground pt-0.5">
|
||||
{stats.total_packets.toLocaleString()} total
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Button } from './ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { Label } from './ui/label';
|
||||
|
||||
const PATH_HASH_MODE_LABELS: Record<number, string> = {
|
||||
0: '1-byte',
|
||||
1: '2-byte',
|
||||
2: '3-byte',
|
||||
};
|
||||
|
||||
interface ChannelPathHashModeOverrideModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
channelName: string;
|
||||
currentOverride: number | null;
|
||||
radioDefault: number;
|
||||
onSetOverride: (value: number | null) => void;
|
||||
}
|
||||
|
||||
export function ChannelPathHashModeOverrideModal({
|
||||
open,
|
||||
onClose,
|
||||
channelName,
|
||||
currentOverride,
|
||||
radioDefault,
|
||||
onSetOverride,
|
||||
}: ChannelPathHashModeOverrideModalProps) {
|
||||
const [selected, setSelected] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelected(currentOverride);
|
||||
}
|
||||
}, [currentOverride, open]);
|
||||
|
||||
const radioDefaultLabel = PATH_HASH_MODE_LABELS[radioDefault] ?? `${radioDefault}`;
|
||||
|
||||
const options: { value: number | null; label: string; description: string }[] = [
|
||||
{
|
||||
value: null,
|
||||
label: `Radio default (${radioDefaultLabel})`,
|
||||
description: 'Use the radio-wide path hash mode setting',
|
||||
},
|
||||
{
|
||||
value: 0,
|
||||
label: '1-byte hop identifiers',
|
||||
description: 'Least repeater disambiguation, up to 63 hops',
|
||||
},
|
||||
{
|
||||
value: 1,
|
||||
label: '2-byte hop identifiers',
|
||||
description: 'Better repeater disambiguation, up to 32 hops',
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
label: '3-byte hop identifiers',
|
||||
description: 'Best repeater disambiguation, up to 21 hops',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="sm:max-w-[520px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Path Hop Width Override</DialogTitle>
|
||||
<DialogDescription>
|
||||
Override the path hash mode for this channel. Wider hop identifiers improve repeater
|
||||
disambiguation but extend send time and will prevent users on old (<1.14) firmware
|
||||
from receiving the message.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border border-border bg-muted/20 p-3 text-sm">
|
||||
<div className="font-medium">{channelName}</div>
|
||||
<div className="mt-1 text-muted-foreground">
|
||||
Current override:{' '}
|
||||
{currentOverride != null
|
||||
? (PATH_HASH_MODE_LABELS[currentOverride] ?? `mode ${currentOverride}`)
|
||||
: `none (using radio default: ${radioDefaultLabel})`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Hop width for this channel</Label>
|
||||
<div className="space-y-1.5">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={String(opt.value)}
|
||||
type="button"
|
||||
className={`w-full rounded-md border px-3 py-2 text-left text-sm transition-colors ${
|
||||
selected === opt.value
|
||||
? 'border-primary bg-primary/10 text-foreground'
|
||||
: 'border-border hover:bg-accent'
|
||||
}`}
|
||||
onClick={() => setSelected(opt.value)}
|
||||
>
|
||||
<div className="font-medium">{opt.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{opt.description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:block sm:space-x-0">
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
onSetOverride(selected);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{selected == null
|
||||
? `Use radio default for ${channelName}`
|
||||
: `Use ${PATH_HASH_MODE_LABELS[selected]} hops for ${channelName}`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Bell, Globe2, Info, Route, Star, Trash2 } from 'lucide-react';
|
||||
import { Bell, ChevronsLeftRight, Globe2, Info, Route, Star, Trash2 } from 'lucide-react';
|
||||
import { toast } from './ui/sonner';
|
||||
import { DirectTraceIcon } from './DirectTraceIcon';
|
||||
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
|
||||
import { ChannelFloodScopeOverrideModal } from './ChannelFloodScopeOverrideModal';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
import { ChannelPathHashModeOverrideModal } from './ChannelPathHashModeOverrideModal';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { isPublicChannelKey } from '../utils/publicChannel';
|
||||
import { stripRegionScopePrefix } from '../utils/regionScope';
|
||||
@@ -12,14 +12,7 @@ import { isPrefixOnlyContact } from '../utils/pubkey';
|
||||
import { cn } from '../lib/utils';
|
||||
import { ContactAvatar } from './ContactAvatar';
|
||||
import { ContactStatusInfo } from './ContactStatusInfo';
|
||||
import type {
|
||||
Channel,
|
||||
Contact,
|
||||
Conversation,
|
||||
Favorite,
|
||||
PathDiscoveryResponse,
|
||||
RadioConfig,
|
||||
} from '../types';
|
||||
import type { Channel, Contact, Conversation, PathDiscoveryResponse, RadioConfig } from '../types';
|
||||
import { CONTACT_TYPE_ROOM } from '../types';
|
||||
|
||||
interface ChatHeaderProps {
|
||||
@@ -27,7 +20,6 @@ interface ChatHeaderProps {
|
||||
contacts: Contact[];
|
||||
channels: Channel[];
|
||||
config: RadioConfig | null;
|
||||
favorites: Favorite[];
|
||||
notificationsSupported: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
notificationsPermission: NotificationPermission | 'unsupported';
|
||||
@@ -36,6 +28,7 @@ interface ChatHeaderProps {
|
||||
onToggleNotifications: () => void;
|
||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
||||
onSetChannelFloodScopeOverride?: (key: string, floodScopeOverride: string) => void;
|
||||
onSetChannelPathHashModeOverride?: (key: string, pathHashModeOverride: number | null) => void;
|
||||
onDeleteChannel: (key: string) => void;
|
||||
onDeleteContact: (publicKey: string) => void;
|
||||
onOpenContactInfo?: (publicKey: string) => void;
|
||||
@@ -47,7 +40,6 @@ export function ChatHeader({
|
||||
contacts,
|
||||
channels,
|
||||
config,
|
||||
favorites,
|
||||
notificationsSupported,
|
||||
notificationsEnabled,
|
||||
notificationsPermission,
|
||||
@@ -56,6 +48,7 @@ export function ChatHeader({
|
||||
onToggleNotifications,
|
||||
onToggleFavorite,
|
||||
onSetChannelFloodScopeOverride,
|
||||
onSetChannelPathHashModeOverride,
|
||||
onDeleteChannel,
|
||||
onDeleteContact,
|
||||
onOpenContactInfo,
|
||||
@@ -64,11 +57,13 @@ export function ChatHeader({
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
|
||||
const [channelOverrideOpen, setChannelOverrideOpen] = useState(false);
|
||||
const [pathHashModeOverrideOpen, setPathHashModeOverrideOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setShowKey(false);
|
||||
setPathDiscoveryOpen(false);
|
||||
setChannelOverrideOpen(false);
|
||||
setPathHashModeOverrideOpen(false);
|
||||
}, [conversation.id]);
|
||||
|
||||
const activeChannel =
|
||||
@@ -81,6 +76,12 @@ export function ChatHeader({
|
||||
? stripRegionScopePrefix(activeFloodScopeOverride)
|
||||
: null;
|
||||
const activeFloodScopeDisplay = activeFloodScopeOverride ? activeFloodScopeOverride : null;
|
||||
const activePathHashModeOverride =
|
||||
conversation.type === 'channel' ? (activeChannel?.path_hash_mode_override ?? null) : null;
|
||||
const showPathHashModeOverride =
|
||||
conversation.type === 'channel' &&
|
||||
onSetChannelPathHashModeOverride &&
|
||||
config?.path_hash_mode_supported;
|
||||
const isPrivateChannel = conversation.type === 'channel' && !activeChannel?.is_hashtag;
|
||||
const activeContact =
|
||||
conversation.type === 'contact'
|
||||
@@ -94,12 +95,18 @@ export function ChatHeader({
|
||||
const titleClickable =
|
||||
(conversation.type === 'contact' && onOpenContactInfo) ||
|
||||
(conversation.type === 'channel' && onOpenChannelInfo);
|
||||
const isFav =
|
||||
conversation.type === 'contact'
|
||||
? (activeContact?.favorite ?? false)
|
||||
: conversation.type === 'channel'
|
||||
? (activeChannel?.favorite ?? false)
|
||||
: false;
|
||||
const favoriteTitle =
|
||||
conversation.type === 'contact'
|
||||
? isFavorite(favorites, 'contact', conversation.id)
|
||||
? isFav
|
||||
? 'Remove from favorites. Favorite contacts stay loaded on the radio for ACK support.'
|
||||
: 'Add to favorites. Favorite contacts stay loaded on the radio for ACK support.'
|
||||
: isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id)
|
||||
: isFav
|
||||
? 'Remove from favorites'
|
||||
: 'Add to favorites';
|
||||
|
||||
@@ -108,6 +115,11 @@ export function ChatHeader({
|
||||
setChannelOverrideOpen(true);
|
||||
};
|
||||
|
||||
const handleEditPathHashModeOverride = () => {
|
||||
if (conversation.type !== 'channel' || !onSetChannelPathHashModeOverride) return;
|
||||
setPathHashModeOverrideOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenConversationInfo = () => {
|
||||
if (conversation.type === 'contact' && onOpenContactInfo) {
|
||||
onOpenContactInfo(conversation.id);
|
||||
@@ -182,7 +194,7 @@ export function ChatHeader({
|
||||
</h2>
|
||||
{isPrivateChannel && !showKey ? (
|
||||
<button
|
||||
className="min-w-0 flex-shrink text-[11px] font-mono text-muted-foreground transition-colors hover:text-primary"
|
||||
className="min-w-0 flex-shrink text-[0.6875rem] font-mono text-muted-foreground transition-colors hover:text-primary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowKey(true);
|
||||
@@ -193,7 +205,7 @@ export function ChatHeader({
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary"
|
||||
className="min-w-0 flex-1 truncate font-mono text-[0.6875rem] text-muted-foreground transition-colors hover:text-primary"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
@@ -228,7 +240,7 @@ export function ChatHeader({
|
||||
className="h-3.5 w-3.5 flex-shrink-0 text-[hsl(var(--region-override))]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="min-w-0 truncate text-[11px] font-medium text-[hsl(var(--region-override))]">
|
||||
<span className="min-w-0 truncate text-[0.6875rem] font-medium text-[hsl(var(--region-override))]">
|
||||
{activeFloodScopeDisplay}
|
||||
</span>
|
||||
</button>
|
||||
@@ -237,7 +249,7 @@ export function ChatHeader({
|
||||
</span>
|
||||
</span>
|
||||
{conversation.type === 'contact' && activeContact && (
|
||||
<div className="col-span-2 row-start-2 min-w-0 text-[11px] text-muted-foreground min-[1100px]:col-span-1 min-[1100px]:col-start-2 min-[1100px]:row-start-1">
|
||||
<div className="col-span-2 row-start-2 min-w-0 text-[0.6875rem] text-muted-foreground min-[1100px]:col-span-1 min-[1100px]:col-start-2 min-[1100px]:row-start-1">
|
||||
<ContactStatusInfo
|
||||
contact={activeContact}
|
||||
ourLat={config?.lat ?? null}
|
||||
@@ -268,7 +280,7 @@ export function ChatHeader({
|
||||
title={
|
||||
activeContactIsPrefixOnly
|
||||
? 'Direct Trace unavailable until the full contact key is known'
|
||||
: 'Direct Trace. Send a zero-hop packet to this contact and display out and back SNR'
|
||||
: 'Direct Trace. Send a direct trace probe to this contact and display out and back SNR'
|
||||
}
|
||||
aria-label="Direct Trace"
|
||||
disabled={activeContactIsPrefixOnly}
|
||||
@@ -299,7 +311,7 @@ export function ChatHeader({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{notificationsEnabled && (
|
||||
<span className="hidden md:inline text-[11px] font-medium text-status-connected">
|
||||
<span className="hidden md:inline text-[0.6875rem] font-medium text-status-connected">
|
||||
Notifications On
|
||||
</span>
|
||||
)}
|
||||
@@ -317,12 +329,25 @@ export function ChatHeader({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{activeFloodScopeDisplay && (
|
||||
<span className="hidden text-[11px] font-medium text-[hsl(var(--region-override))] sm:inline">
|
||||
<span className="hidden text-[0.6875rem] font-medium text-[hsl(var(--region-override))] sm:inline">
|
||||
{activeFloodScopeDisplay}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{showPathHashModeOverride && (
|
||||
<button
|
||||
className="flex shrink-0 items-center gap-1 rounded px-1 py-1 text-lg leading-none transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={handleEditPathHashModeOverride}
|
||||
title="Set path hop width override"
|
||||
aria-label="Set path hop width override"
|
||||
>
|
||||
<ChevronsLeftRight
|
||||
className={`h-4 w-4 ${activePathHashModeOverride != null ? 'text-status-connected' : 'text-muted-foreground'}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{(conversation.type === 'channel' || conversation.type === 'contact') && (
|
||||
<button
|
||||
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
@@ -330,13 +355,9 @@ export function ChatHeader({
|
||||
onToggleFavorite(conversation.type as 'channel' | 'contact', conversation.id)
|
||||
}
|
||||
title={favoriteTitle}
|
||||
aria-label={
|
||||
isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id)
|
||||
? 'Remove from favorites'
|
||||
: 'Add to favorites'
|
||||
}
|
||||
aria-label={isFav ? 'Remove from favorites' : 'Add to favorites'}
|
||||
>
|
||||
{isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id) ? (
|
||||
{isFav ? (
|
||||
<Star className="h-4 w-4 fill-current text-favorite" aria-hidden="true" />
|
||||
) : (
|
||||
<Star className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
@@ -379,6 +400,16 @@ export function ChatHeader({
|
||||
onSetOverride={(value) => onSetChannelFloodScopeOverride(conversation.id, value)}
|
||||
/>
|
||||
)}
|
||||
{showPathHashModeOverride && (
|
||||
<ChannelPathHashModeOverrideModal
|
||||
open={pathHashModeOverrideOpen}
|
||||
onClose={() => setPathHashModeOverrideOpen(false)}
|
||||
channelName={conversation.name}
|
||||
currentOverride={activePathHashModeOverride}
|
||||
radioDefault={config?.path_hash_mode ?? 0}
|
||||
onSetOverride={(value) => onSetChannelPathHashModeOverride(conversation.id, value)}
|
||||
/>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Hash,
|
||||
Map,
|
||||
MessageSquare,
|
||||
Network,
|
||||
Radio,
|
||||
Route,
|
||||
Search,
|
||||
Star,
|
||||
User,
|
||||
Waypoints,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from './ui/command';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from './ui/dialog';
|
||||
import { getContactDisplayName } from '../utils/pubkey';
|
||||
import {
|
||||
SETTINGS_SECTION_LABELS,
|
||||
SETTINGS_SECTION_ORDER,
|
||||
SETTINGS_SECTION_ICONS,
|
||||
type SettingsSection,
|
||||
} from './settings/settingsConstants';
|
||||
import type { Channel, Contact, Conversation } from '../types';
|
||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
|
||||
|
||||
const MAX_PER_GROUP = 8;
|
||||
|
||||
interface CommandPaletteProps {
|
||||
contacts: Contact[];
|
||||
channels: Channel[];
|
||||
onSelectConversation: (conv: Conversation) => void;
|
||||
onOpenSettings: (section: SettingsSection) => void;
|
||||
onRepeaterAutoLogin: (publicKey: string, displayName: string) => void;
|
||||
}
|
||||
|
||||
interface Searchable {
|
||||
searchText: string;
|
||||
}
|
||||
|
||||
interface SearchableContact extends Searchable {
|
||||
contact: Contact;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
interface SearchableChannel extends Searchable {
|
||||
channel: Channel;
|
||||
}
|
||||
|
||||
interface ToolItem extends Searchable {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
type: 'raw' | 'map' | 'visualizer' | 'search' | 'trace';
|
||||
}
|
||||
|
||||
interface SettingItem extends Searchable {
|
||||
section: SettingsSection;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
const TOOL_ITEMS: ToolItem[] = [
|
||||
{ id: 'raw', name: 'Raw Packet Feed', icon: Radio, type: 'raw', searchText: 'raw packet feed' },
|
||||
{ id: 'map', name: 'Map View', icon: Map, type: 'map', searchText: 'map view' },
|
||||
{
|
||||
id: 'visualizer',
|
||||
name: 'Network Visualizer',
|
||||
icon: Network,
|
||||
type: 'visualizer',
|
||||
searchText: 'network visualizer',
|
||||
},
|
||||
{
|
||||
id: 'search',
|
||||
name: 'Message Search',
|
||||
icon: Search,
|
||||
type: 'search',
|
||||
searchText: 'message search',
|
||||
},
|
||||
{ id: 'trace', name: 'Route Trace', icon: Route, type: 'trace', searchText: 'route trace' },
|
||||
];
|
||||
|
||||
const SETTING_ITEMS: SettingItem[] = SETTINGS_SECTION_ORDER.map((section) => ({
|
||||
section,
|
||||
label: SETTINGS_SECTION_LABELS[section],
|
||||
icon: SETTINGS_SECTION_ICONS[section],
|
||||
searchText: `settings ${SETTINGS_SECTION_LABELS[section]}`.toLowerCase(),
|
||||
}));
|
||||
|
||||
function fuzzyMatch(text: string, query: string): boolean {
|
||||
let qi = 0;
|
||||
for (let ti = 0; ti < text.length && qi < query.length; ti++) {
|
||||
if (text[ti] === query[qi]) qi++;
|
||||
}
|
||||
return qi === query.length;
|
||||
}
|
||||
|
||||
function filterList<T extends Searchable>(items: T[], query: string): T[] {
|
||||
if (!query) return items.slice(0, MAX_PER_GROUP);
|
||||
const results: T[] = [];
|
||||
for (const item of items) {
|
||||
if (fuzzyMatch(item.searchText, query)) {
|
||||
results.push(item);
|
||||
if (results.length >= MAX_PER_GROUP) break;
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export function CommandPalette({
|
||||
contacts,
|
||||
channels,
|
||||
onSelectConversation,
|
||||
onOpenSettings,
|
||||
onRepeaterAutoLogin,
|
||||
}: CommandPaletteProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
setOpen((prev) => !prev);
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, []);
|
||||
|
||||
const select = useCallback((action: () => void) => {
|
||||
setOpen(false);
|
||||
action();
|
||||
}, []);
|
||||
|
||||
const {
|
||||
favContacts,
|
||||
favRepeaters,
|
||||
regularContacts,
|
||||
repeaters,
|
||||
rooms,
|
||||
favChannels,
|
||||
regularChannels,
|
||||
} = useMemo(() => {
|
||||
const fc: SearchableContact[] = [];
|
||||
const fr: SearchableContact[] = [];
|
||||
const rc: SearchableContact[] = [];
|
||||
const rp: SearchableContact[] = [];
|
||||
const rm: SearchableContact[] = [];
|
||||
for (const c of contacts) {
|
||||
const displayName = getContactDisplayName(c.name, c.public_key, c.last_advert);
|
||||
const entry: SearchableContact = {
|
||||
contact: c,
|
||||
displayName,
|
||||
searchText: `${displayName} ${c.public_key}`.toLowerCase(),
|
||||
};
|
||||
if (c.type === CONTACT_TYPE_REPEATER) {
|
||||
(c.favorite ? fr : rp).push(entry);
|
||||
} else if (c.type === CONTACT_TYPE_ROOM) {
|
||||
rm.push(entry);
|
||||
} else {
|
||||
(c.favorite ? fc : rc).push(entry);
|
||||
}
|
||||
}
|
||||
const fch: SearchableChannel[] = [];
|
||||
const rch: SearchableChannel[] = [];
|
||||
for (const ch of channels) {
|
||||
const entry: SearchableChannel = {
|
||||
channel: ch,
|
||||
searchText: `${ch.name} ${ch.key}`.toLowerCase(),
|
||||
};
|
||||
(ch.favorite ? fch : rch).push(entry);
|
||||
}
|
||||
return {
|
||||
favContacts: fc,
|
||||
favRepeaters: fr,
|
||||
regularContacts: rc,
|
||||
repeaters: rp,
|
||||
rooms: rm,
|
||||
favChannels: fch,
|
||||
regularChannels: rch,
|
||||
};
|
||||
}, [contacts, channels]);
|
||||
|
||||
const lq = query.toLowerCase();
|
||||
const fTools = filterList(TOOL_ITEMS, lq);
|
||||
const fSettings = filterList(SETTING_ITEMS, lq);
|
||||
const fFavContacts = filterList(favContacts, lq);
|
||||
const fFavRepeaters = filterList(favRepeaters, lq);
|
||||
const fFavChannels = filterList(favChannels, lq);
|
||||
const fContacts = filterList(regularContacts, lq);
|
||||
const fRepeaters = filterList(repeaters, lq);
|
||||
const fRooms = filterList(rooms, lq);
|
||||
const fChannels = filterList(regularChannels, lq);
|
||||
|
||||
const totalResults =
|
||||
fTools.length +
|
||||
fSettings.length +
|
||||
fFavContacts.length +
|
||||
fFavRepeaters.length +
|
||||
fFavChannels.length +
|
||||
fContacts.length +
|
||||
fRepeaters.length +
|
||||
fRooms.length +
|
||||
fChannels.length;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) setQuery('');
|
||||
}}
|
||||
>
|
||||
<DialogContent className="overflow-hidden p-0 shadow-lg" hideCloseButton>
|
||||
<DialogTitle className="sr-only">Command palette</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Search for conversations, settings, and tools
|
||||
</DialogDescription>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput placeholder="Jump to..." value={query} onValueChange={setQuery} />
|
||||
<CommandList>
|
||||
{totalResults === 0 && <CommandEmpty>No results found.</CommandEmpty>}
|
||||
|
||||
{fTools.length > 0 && (
|
||||
<CommandGroup heading="Tools">
|
||||
{fTools.map((tool) => (
|
||||
<CommandItem
|
||||
key={tool.id}
|
||||
onSelect={() =>
|
||||
select(() =>
|
||||
onSelectConversation({ type: tool.type, id: tool.id, name: tool.name })
|
||||
)
|
||||
}
|
||||
>
|
||||
<tool.icon className="text-muted-foreground" />
|
||||
<span>{tool.name}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{fSettings.length > 0 && (
|
||||
<CommandGroup heading="Settings">
|
||||
{fSettings.map((item) => (
|
||||
<CommandItem
|
||||
key={item.section}
|
||||
onSelect={() => select(() => onOpenSettings(item.section))}
|
||||
>
|
||||
<item.icon className="text-muted-foreground" />
|
||||
<span>{item.label}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{fFavContacts.length > 0 && (
|
||||
<ContactGroup
|
||||
heading="Favorite Contacts"
|
||||
items={fFavContacts}
|
||||
icon={User}
|
||||
onSelect={select}
|
||||
onSelectConversation={onSelectConversation}
|
||||
showStar
|
||||
/>
|
||||
)}
|
||||
|
||||
{fFavRepeaters.length > 0 && (
|
||||
<RepeaterGroup
|
||||
heading="Favorite Repeaters"
|
||||
items={fFavRepeaters}
|
||||
onSelect={select}
|
||||
onSelectConversation={onSelectConversation}
|
||||
onRepeaterAutoLogin={onRepeaterAutoLogin}
|
||||
showStar
|
||||
/>
|
||||
)}
|
||||
|
||||
{fFavChannels.length > 0 && (
|
||||
<CommandGroup heading="Favorite Channels">
|
||||
{fFavChannels.map(({ channel: ch }) => (
|
||||
<CommandItem
|
||||
key={ch.key}
|
||||
onSelect={() =>
|
||||
select(() =>
|
||||
onSelectConversation({ type: 'channel', id: ch.key, name: ch.name })
|
||||
)
|
||||
}
|
||||
>
|
||||
<Hash className="text-muted-foreground" />
|
||||
<span>{ch.name}</span>
|
||||
<Star className="ml-auto h-3 w-3 text-favorite" />
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{fContacts.length > 0 && (
|
||||
<ContactGroup
|
||||
heading="Contacts"
|
||||
items={fContacts}
|
||||
icon={User}
|
||||
onSelect={select}
|
||||
onSelectConversation={onSelectConversation}
|
||||
/>
|
||||
)}
|
||||
|
||||
{fRepeaters.length > 0 && (
|
||||
<RepeaterGroup
|
||||
heading="Repeaters"
|
||||
items={fRepeaters}
|
||||
onSelect={select}
|
||||
onSelectConversation={onSelectConversation}
|
||||
onRepeaterAutoLogin={onRepeaterAutoLogin}
|
||||
/>
|
||||
)}
|
||||
|
||||
{fRooms.length > 0 && (
|
||||
<ContactGroup
|
||||
heading="Rooms"
|
||||
items={fRooms}
|
||||
icon={MessageSquare}
|
||||
onSelect={select}
|
||||
onSelectConversation={onSelectConversation}
|
||||
/>
|
||||
)}
|
||||
|
||||
{fChannels.length > 0 && (
|
||||
<CommandGroup heading="Channels">
|
||||
{fChannels.map(({ channel: ch }) => (
|
||||
<CommandItem
|
||||
key={ch.key}
|
||||
onSelect={() =>
|
||||
select(() =>
|
||||
onSelectConversation({ type: 'channel', id: ch.key, name: ch.name })
|
||||
)
|
||||
}
|
||||
>
|
||||
<Hash className="text-muted-foreground" />
|
||||
<span>{ch.name}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ContactGroup({
|
||||
heading,
|
||||
items,
|
||||
icon: Icon,
|
||||
showStar,
|
||||
onSelect,
|
||||
onSelectConversation,
|
||||
}: {
|
||||
heading: string;
|
||||
items: SearchableContact[];
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
showStar?: boolean;
|
||||
onSelect: (action: () => void) => void;
|
||||
onSelectConversation: (conv: Conversation) => void;
|
||||
}) {
|
||||
return (
|
||||
<CommandGroup heading={heading}>
|
||||
{items.map(({ contact: c, displayName }) => (
|
||||
<CommandItem
|
||||
key={c.public_key}
|
||||
onSelect={() =>
|
||||
onSelect(() =>
|
||||
onSelectConversation({ type: 'contact', id: c.public_key, name: displayName })
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="text-muted-foreground" />
|
||||
<span>{displayName}</span>
|
||||
{showStar && <Star className="ml-auto h-3 w-3 text-favorite" />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function RepeaterGroup({
|
||||
heading,
|
||||
items,
|
||||
showStar,
|
||||
onSelect,
|
||||
onSelectConversation,
|
||||
onRepeaterAutoLogin,
|
||||
}: {
|
||||
heading: string;
|
||||
items: SearchableContact[];
|
||||
showStar?: boolean;
|
||||
onSelect: (action: () => void) => void;
|
||||
onSelectConversation: (conv: Conversation) => void;
|
||||
onRepeaterAutoLogin: (publicKey: string, displayName: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<CommandGroup heading={heading}>
|
||||
{items.flatMap(({ contact: c, displayName }) => [
|
||||
<CommandItem
|
||||
key={c.public_key}
|
||||
onSelect={() =>
|
||||
onSelect(() =>
|
||||
onSelectConversation({ type: 'contact', id: c.public_key, name: displayName })
|
||||
)
|
||||
}
|
||||
>
|
||||
<Waypoints className="text-muted-foreground" />
|
||||
<span>{displayName}</span>
|
||||
{showStar && <Star className="ml-auto h-3 w-3 text-favorite" />}
|
||||
</CommandItem>,
|
||||
<CommandItem
|
||||
key={`${c.public_key}-acl`}
|
||||
onSelect={() => onSelect(() => onRepeaterAutoLogin(c.public_key, displayName))}
|
||||
>
|
||||
<Waypoints className="text-muted-foreground" />
|
||||
<span>
|
||||
{displayName} <span className="text-muted-foreground">(ACL login + load all)</span>
|
||||
</span>
|
||||
</CommandItem>,
|
||||
])}
|
||||
</CommandGroup>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,16 @@
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
import { type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
import { Ban, Search, Star } from 'lucide-react';
|
||||
import { api } from '../api';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip as RechartsTooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
import { api, isAbortError } from '../api';
|
||||
import { formatTime } from '../utils/messageParser';
|
||||
import {
|
||||
getContactDisplayName,
|
||||
@@ -19,19 +29,18 @@ import {
|
||||
} from '../utils/pathUtils';
|
||||
import { isPublicChannelKey } from '../utils/publicChannel';
|
||||
import { getMapFocusHash } from '../utils/urlHash';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { ContactAvatar } from './ContactAvatar';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
|
||||
import { toast } from './ui/sonner';
|
||||
import { useDistanceUnit } from '../contexts/DistanceUnitContext';
|
||||
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||
import type {
|
||||
Contact,
|
||||
ContactActiveRoom,
|
||||
ContactAnalytics,
|
||||
ContactAnalyticsHourlyBucket,
|
||||
ContactAnalyticsWeeklyBucket,
|
||||
Favorite,
|
||||
RadioConfig,
|
||||
} from '../types';
|
||||
|
||||
@@ -56,7 +65,6 @@ interface ContactInfoPaneProps {
|
||||
onClose: () => void;
|
||||
contacts: Contact[];
|
||||
config: RadioConfig | null;
|
||||
favorites: Favorite[];
|
||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
||||
onNavigateToChannel?: (channelKey: string) => void;
|
||||
onSearchMessagesByKey?: (publicKey: string) => void;
|
||||
@@ -73,7 +81,6 @@ export function ContactInfoPane({
|
||||
onClose,
|
||||
contacts,
|
||||
config,
|
||||
favorites,
|
||||
onToggleFavorite,
|
||||
onNavigateToChannel,
|
||||
onSearchMessagesByKey,
|
||||
@@ -100,29 +107,29 @@ export function ContactInfoPane({
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
setAnalytics(null);
|
||||
setLoading(true);
|
||||
const request =
|
||||
isNameOnly && nameOnlyValue
|
||||
? api.getContactAnalytics({ name: nameOnlyValue })
|
||||
: api.getContactAnalytics({ publicKey: contactKey });
|
||||
? api.getContactAnalytics({ name: nameOnlyValue }, controller.signal)
|
||||
: api.getContactAnalytics({ publicKey: contactKey }, controller.signal);
|
||||
|
||||
request
|
||||
.then((data) => {
|
||||
if (!cancelled) setAnalytics(data);
|
||||
if (!controller.signal.aborted) setAnalytics(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
if (!isAbortError(err)) {
|
||||
console.error('Failed to fetch contact analytics:', err);
|
||||
toast.error('Failed to load contact info');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [contactKey, isNameOnly, nameOnlyValue]);
|
||||
|
||||
@@ -148,6 +155,7 @@ export function ContactInfoPane({
|
||||
contact !== null &&
|
||||
!isPrefixOnlyResolvedContact &&
|
||||
isUnknownFullKeyContact(contact.public_key, contact.last_advert);
|
||||
const isRepeater = contact?.type === CONTACT_TYPE_REPEATER;
|
||||
|
||||
return (
|
||||
<Sheet open={contactKey !== null} onOpenChange={(open) => !open && onClose()}>
|
||||
@@ -280,7 +288,7 @@ export function ContactInfoPane({
|
||||
{contact.public_key}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
|
||||
<span className="text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
|
||||
{CONTACT_TYPE_LABELS[contact.type] ?? 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -372,7 +380,7 @@ export function ContactInfoPane({
|
||||
onClick={() => onToggleFavorite('contact', contact.public_key)}
|
||||
title="Favorite contacts stay loaded on the radio for ACK support"
|
||||
>
|
||||
{isFavorite(favorites, 'contact', contact.public_key) ? (
|
||||
{contact.favorite ? (
|
||||
<>
|
||||
<Star className="h-4.5 w-4.5 fill-current text-favorite" aria-hidden="true" />
|
||||
<span>Remove from favorites</span>
|
||||
@@ -430,7 +438,7 @@ export function ContactInfoPane({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onSearchMessagesByKey && (
|
||||
{!isRepeater && onSearchMessagesByKey && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<button
|
||||
type="button"
|
||||
@@ -443,40 +451,60 @@ export function ContactInfoPane({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Nearest Repeaters */}
|
||||
{analytics && analytics.nearest_repeaters.length > 0 && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Nearest Repeaters</SectionLabel>
|
||||
<div className="space-y-1">
|
||||
{analytics.nearest_repeaters.map((r) => (
|
||||
<div key={r.public_key} className="flex justify-between items-center text-sm">
|
||||
<span className="truncate">{r.name || r.public_key.slice(0, 12)}</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||
{r.path_len === 0
|
||||
? 'direct'
|
||||
: `${r.path_len} hop${r.path_len > 1 ? 's' : ''}`}{' '}
|
||||
· {r.heard_count}x
|
||||
</span>
|
||||
{/* Nearest Repeaters (Hops) — last 7 days only */}
|
||||
{analytics &&
|
||||
(() => {
|
||||
const sevenDaysAgo = Math.floor(Date.now() / 1000) - 7 * 86400;
|
||||
const recent = analytics.nearest_repeaters.filter(
|
||||
(r) => r.last_seen >= sevenDaysAgo
|
||||
);
|
||||
if (recent.length === 0) return null;
|
||||
return (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Nearest Repeaters — Hops (last 7 days)</SectionLabel>
|
||||
<div className="space-y-1">
|
||||
{recent.map((r) => (
|
||||
<div
|
||||
key={r.public_key}
|
||||
className="flex justify-between items-center text-sm"
|
||||
>
|
||||
<span className="truncate">{r.name || r.public_key.slice(0, 12)}</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||
{r.path_len === 0
|
||||
? 'direct'
|
||||
: `${r.path_len} hop${r.path_len > 1 ? 's' : ''}`}{' '}
|
||||
· {r.heard_count}x
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Geographically nearest repeaters (repeaters only) */}
|
||||
{isRepeater && contact && isValidLocation(contact.lat, contact.lon) && (
|
||||
<NearbyRepeatersSection
|
||||
contact={contact}
|
||||
contacts={contacts}
|
||||
distanceUnit={distanceUnit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Advert Paths */}
|
||||
{analytics && analytics.advert_paths.length > 0 && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Recent Advert Paths</SectionLabel>
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-1.5">
|
||||
{analytics.advert_paths.map((p) => (
|
||||
<div
|
||||
key={p.path + p.first_seen}
|
||||
className="flex justify-between items-center text-sm"
|
||||
className="flex justify-between items-start gap-2 text-sm"
|
||||
>
|
||||
<span className="font-mono text-xs truncate">
|
||||
<span className="font-mono text-xs break-all">
|
||||
{p.path ? parsePathHops(p.path, p.path_len).join(' → ') : '(direct)'}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0">
|
||||
{p.heard_count}x · {formatTime(p.last_seen)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -508,17 +536,21 @@ export function ContactInfoPane({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MessageStatsSection
|
||||
dmMessageCount={analytics?.dm_message_count ?? 0}
|
||||
channelMessageCount={analytics?.channel_message_count ?? 0}
|
||||
/>
|
||||
{!isRepeater && (
|
||||
<>
|
||||
<MessageStatsSection
|
||||
dmMessageCount={analytics?.dm_message_count ?? 0}
|
||||
channelMessageCount={analytics?.channel_message_count ?? 0}
|
||||
/>
|
||||
|
||||
<ActivityChartsSection analytics={analytics} />
|
||||
<ActivityChartsSection analytics={analytics} />
|
||||
|
||||
<MostActiveChannelsSection
|
||||
channels={analytics?.most_active_rooms ?? []}
|
||||
onNavigateToChannel={onNavigateToChannel}
|
||||
/>
|
||||
<MostActiveChannelsSection
|
||||
channels={analytics?.most_active_rooms ?? []}
|
||||
onNavigateToChannel={onNavigateToChannel}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
@@ -532,7 +564,7 @@ export function ContactInfoPane({
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<h3 className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium mb-1.5">
|
||||
<h3 className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium mb-1.5">
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
@@ -650,20 +682,18 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu
|
||||
{hasHourlyActivity && (
|
||||
<div>
|
||||
<SectionLabel>Messages Per Hour</SectionLabel>
|
||||
<ChartLegend
|
||||
items={[
|
||||
{ label: 'Last 24h', color: '#2563eb' },
|
||||
{ label: '7-day avg', color: '#ea580c' },
|
||||
{ label: 'All-time avg', color: '#64748b' },
|
||||
]}
|
||||
/>
|
||||
<ActivityLineChart
|
||||
ariaLabel="Messages per hour"
|
||||
points={analytics.hourly_activity}
|
||||
series={[
|
||||
{ key: 'last_24h_count', color: '#2563eb' },
|
||||
{ key: 'last_week_average', color: '#ea580c' },
|
||||
{ key: 'all_time_average', color: '#64748b' },
|
||||
{ key: 'last_24h_count', color: '#2563eb', label: 'Last 24h' },
|
||||
{ key: 'last_week_average', color: '#ea580c', label: '7-day avg' },
|
||||
{ key: 'all_time_average', color: '#64748b', label: 'All-time avg' },
|
||||
]}
|
||||
legendItems={[
|
||||
{ label: 'Last 24h', color: '#2563eb' },
|
||||
{ label: '7-day avg', color: '#ea580c' },
|
||||
{ label: 'All-time avg', color: '#64748b' },
|
||||
]}
|
||||
valueFormatter={(value) => value.toFixed(value % 1 === 0 ? 0 : 1)}
|
||||
tickFormatter={(bucket) =>
|
||||
@@ -683,7 +713,7 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu
|
||||
<ActivityLineChart
|
||||
ariaLabel="Messages per week"
|
||||
points={analytics.weekly_activity}
|
||||
series={[{ key: 'message_count', color: '#16a34a' }]}
|
||||
series={[{ key: 'message_count', color: '#16a34a', label: 'Messages' }]}
|
||||
valueFormatter={(value) => value.toFixed(0)}
|
||||
tickFormatter={(bucket) =>
|
||||
new Date(bucket.bucket_start * 1000).toLocaleDateString([], {
|
||||
@@ -695,7 +725,7 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
<p className="text-[0.6875rem] text-muted-foreground">
|
||||
Hourly lines compare the last 24 hours against 7-day and all-time averages for the same hour
|
||||
slots.
|
||||
{!analytics.includes_direct_messages &&
|
||||
@@ -705,133 +735,169 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu
|
||||
);
|
||||
}
|
||||
|
||||
function ChartLegend({ items }: { items: Array<{ label: string; color: string }> }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 mb-2 text-[11px] text-muted-foreground">
|
||||
{items.map((item) => (
|
||||
<span key={item.label} className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: item.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{item.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const TOOLTIP_STYLE = {
|
||||
contentStyle: {
|
||||
backgroundColor: 'hsl(var(--popover))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
fontSize: '11px',
|
||||
color: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
itemStyle: { color: 'hsl(var(--popover-foreground))' },
|
||||
labelStyle: { color: 'hsl(var(--muted-foreground))' },
|
||||
} as const;
|
||||
|
||||
function ActivityLineChart<T extends ContactAnalyticsHourlyBucket | ContactAnalyticsWeeklyBucket>({
|
||||
ariaLabel,
|
||||
points,
|
||||
series,
|
||||
legendItems,
|
||||
tickFormatter,
|
||||
valueFormatter,
|
||||
}: {
|
||||
ariaLabel: string;
|
||||
points: T[];
|
||||
series: Array<{ key: keyof T; color: string }>;
|
||||
series: Array<{ key: keyof T; color: string; label?: string }>;
|
||||
legendItems?: Array<{ label: string; color: string }>;
|
||||
tickFormatter: (point: T) => string;
|
||||
valueFormatter: (value: number) => string;
|
||||
}) {
|
||||
const width = 320;
|
||||
const height = 132;
|
||||
const padding = { top: 8, right: 8, bottom: 24, left: 32 };
|
||||
const plotWidth = width - padding.left - padding.right;
|
||||
const plotHeight = height - padding.top - padding.bottom;
|
||||
const allValues = points.flatMap((point) =>
|
||||
series.map((entry) => {
|
||||
const value = point[entry.key];
|
||||
return typeof value === 'number' ? value : 0;
|
||||
})
|
||||
);
|
||||
const maxValue = Math.max(1, ...allValues);
|
||||
const tickIndices = Array.from(
|
||||
new Set([
|
||||
0,
|
||||
Math.floor((points.length - 1) / 3),
|
||||
Math.floor(((points.length - 1) * 2) / 3),
|
||||
points.length - 1,
|
||||
])
|
||||
);
|
||||
const data = points.map((point, i) => {
|
||||
const entry: Record<string, string | number> = { idx: i, tick: tickFormatter(point) };
|
||||
for (const s of series) {
|
||||
const raw = point[s.key];
|
||||
entry[String(s.key)] = typeof raw === 'number' ? raw : 0;
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
|
||||
const buildPolyline = (key: keyof T) =>
|
||||
points
|
||||
.map((point, index) => {
|
||||
const rawValue = point[key];
|
||||
const value = typeof rawValue === 'number' ? rawValue : 0;
|
||||
const x =
|
||||
padding.left + (points.length === 1 ? 0 : (index / (points.length - 1)) * plotWidth);
|
||||
const y = padding.top + plotHeight - (value / maxValue) * plotHeight;
|
||||
return `${x},${y}`;
|
||||
})
|
||||
.join(' ');
|
||||
const tickCount = Math.min(5, points.length);
|
||||
const tickIndices: number[] = [];
|
||||
if (points.length > 1) {
|
||||
for (let i = 0; i < tickCount; i++) {
|
||||
tickIndices.push(Math.round((i / (tickCount - 1)) * (points.length - 1)));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="w-full h-auto"
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{[0, 0.5, 1].map((ratio) => {
|
||||
const y = padding.top + plotHeight - ratio * plotHeight;
|
||||
const value = maxValue * ratio;
|
||||
return (
|
||||
<g key={ratio}>
|
||||
<line
|
||||
x1={padding.left}
|
||||
x2={width - padding.right}
|
||||
y1={y}
|
||||
y2={y}
|
||||
stroke="hsl(var(--border))"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<text
|
||||
x={padding.left - 6}
|
||||
y={y + 4}
|
||||
fontSize="10"
|
||||
textAnchor="end"
|
||||
fill="hsl(var(--muted-foreground))"
|
||||
>
|
||||
{valueFormatter(value)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{series.map((entry) => (
|
||||
<polyline
|
||||
key={String(entry.key)}
|
||||
fill="none"
|
||||
stroke={entry.color}
|
||||
strokeWidth="2"
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
points={buildPolyline(entry.key)}
|
||||
<div role="img" aria-label={ariaLabel}>
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<LineChart data={data} margin={{ top: 4, right: 4, bottom: 0, left: -16 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="idx"
|
||||
type="number"
|
||||
domain={[0, Math.max(1, points.length - 1)]}
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
ticks={tickIndices}
|
||||
tickFormatter={(idx) => String(data[idx]?.tick ?? '')}
|
||||
/>
|
||||
))}
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(v) => valueFormatter(v)}
|
||||
width={40}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
{...TOOLTIP_STYLE}
|
||||
cursor={{
|
||||
stroke: 'hsl(var(--muted-foreground))',
|
||||
strokeWidth: 1,
|
||||
strokeDasharray: '3 3',
|
||||
}}
|
||||
labelFormatter={(idx) => String(data[Number(idx)]?.tick ?? '')}
|
||||
formatter={(value, name) => {
|
||||
const match = series.find((s) => String(s.key) === name);
|
||||
return [valueFormatter(Number(value)), match?.label ?? String(name)];
|
||||
}}
|
||||
/>
|
||||
{legendItems && (
|
||||
<Legend
|
||||
content={() => (
|
||||
<div className="flex flex-wrap justify-center gap-x-3 gap-y-1 mt-1 text-[0.6875rem] 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>
|
||||
);
|
||||
}
|
||||
|
||||
{tickIndices.map((index) => {
|
||||
const point = points[index];
|
||||
const x =
|
||||
padding.left + (points.length === 1 ? 0 : (index / (points.length - 1)) * plotWidth);
|
||||
return (
|
||||
<text
|
||||
key={`${ariaLabel}-${point.bucket_start}`}
|
||||
x={x}
|
||||
y={height - 6}
|
||||
fontSize="10"
|
||||
textAnchor={index === 0 ? 'start' : index === points.length - 1 ? 'end' : 'middle'}
|
||||
fill="hsl(var(--muted-foreground))"
|
||||
>
|
||||
{tickFormatter(point)}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
function NearbyRepeatersSection({
|
||||
contact,
|
||||
contacts,
|
||||
distanceUnit,
|
||||
}: {
|
||||
contact: Contact;
|
||||
contacts: Contact[];
|
||||
distanceUnit: import('../utils/distanceUnits').DistanceUnit;
|
||||
}) {
|
||||
const nearby = useMemo(() => {
|
||||
const sevenDaysAgo = Math.floor(Date.now() / 1000) - 7 * 86400;
|
||||
const results: Array<{ name: string; publicKey: string; distance: number }> = [];
|
||||
for (const other of contacts) {
|
||||
const heardAt = Math.max(other.last_seen ?? 0, other.last_advert ?? 0);
|
||||
if (
|
||||
other.public_key === contact.public_key ||
|
||||
other.type !== CONTACT_TYPE_REPEATER ||
|
||||
!isValidLocation(other.lat, other.lon) ||
|
||||
heardAt < sevenDaysAgo
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const dist = calculateDistance(contact.lat, contact.lon, other.lat, other.lon);
|
||||
if (dist !== null) {
|
||||
results.push({
|
||||
name: getContactDisplayName(other.name, other.public_key, other.last_advert),
|
||||
publicKey: other.public_key,
|
||||
distance: dist,
|
||||
});
|
||||
}
|
||||
}
|
||||
results.sort((a, b) => a.distance - b.distance);
|
||||
return results.slice(0, 5);
|
||||
}, [contact.public_key, contact.lat, contact.lon, contacts]);
|
||||
|
||||
if (nearby.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Nearest Repeaters — Geo (last 7 days)</SectionLabel>
|
||||
<div className="space-y-1">
|
||||
{nearby.map((r) => (
|
||||
<div key={r.publicKey} className="flex justify-between items-center text-sm">
|
||||
<span className="truncate">{r.name}</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||
{formatDistance(r.distance, distanceUnit)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,12 +74,12 @@ function RouteCard({
|
||||
<div className="rounded-md border border-border bg-muted/20 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h4 className="text-sm font-semibold">{label}</h4>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
<span className="text-[0.6875rem] text-muted-foreground">
|
||||
{formatRouteLabel(route.path_len, true)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm">{chain}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-muted-foreground">
|
||||
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[0.6875rem] text-muted-foreground">
|
||||
<span>Raw: {rawPath}</span>
|
||||
<span>{formatPathHashMode(route.path_hash_mode)}</span>
|
||||
</div>
|
||||
|
||||
@@ -5,16 +5,18 @@ import { MessageInput, type MessageInputHandle } from './MessageInput';
|
||||
import { MessageList } from './MessageList';
|
||||
import { RawPacketFeedView } from './RawPacketFeedView';
|
||||
import { RoomServerPanel } from './RoomServerPanel';
|
||||
import { TracePane } from './TracePane';
|
||||
import type {
|
||||
Channel,
|
||||
Contact,
|
||||
Conversation,
|
||||
Favorite,
|
||||
HealthStatus,
|
||||
Message,
|
||||
PathDiscoveryResponse,
|
||||
RawPacket,
|
||||
RadioConfig,
|
||||
RadioTraceHopRequest,
|
||||
RadioTraceResponse,
|
||||
} from '../types';
|
||||
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
|
||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
|
||||
@@ -39,8 +41,8 @@ interface ConversationPaneProps {
|
||||
notificationsSupported: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
notificationsPermission: NotificationPermission | 'unsupported';
|
||||
favorites: Favorite[];
|
||||
messages: Message[];
|
||||
preSorted?: boolean;
|
||||
messagesLoading: boolean;
|
||||
loadingOlder: boolean;
|
||||
hasOlderMessages: boolean;
|
||||
@@ -50,14 +52,23 @@ interface ConversationPaneProps {
|
||||
loadingNewer: boolean;
|
||||
messageInputRef: Ref<MessageInputHandle>;
|
||||
onTrace: () => Promise<void>;
|
||||
onRunTracePath: (
|
||||
hopHashBytes: 1 | 2 | 4,
|
||||
hops: RadioTraceHopRequest[]
|
||||
) => Promise<RadioTraceResponse>;
|
||||
onPathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
|
||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => Promise<void>;
|
||||
onDeleteContact: (publicKey: string) => Promise<void>;
|
||||
onDeleteChannel: (key: string) => Promise<void>;
|
||||
onSetChannelFloodScopeOverride: (channelKey: string, floodScopeOverride: string) => Promise<void>;
|
||||
onSetChannelPathHashModeOverride?: (
|
||||
channelKey: string,
|
||||
pathHashModeOverride: number | null
|
||||
) => Promise<void>;
|
||||
onOpenContactInfo: (publicKey: string, fromChannel?: boolean) => void;
|
||||
onOpenChannelInfo: (channelKey: string) => void;
|
||||
onSenderClick: (sender: string) => void;
|
||||
onChannelReferenceClick?: (channelName: string) => void;
|
||||
onLoadOlder: () => Promise<void>;
|
||||
onResendChannelMessage: (messageId: number, newTimestamp?: boolean) => Promise<void>;
|
||||
onTargetReached: () => void;
|
||||
@@ -66,6 +77,10 @@ interface ConversationPaneProps {
|
||||
onDismissUnreadMarker: () => void;
|
||||
onSendMessage: (text: string) => Promise<void>;
|
||||
onToggleNotifications: () => void;
|
||||
trackedTelemetryRepeaters: string[];
|
||||
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
|
||||
repeaterAutoLoginKey: string | null;
|
||||
onClearRepeaterAutoLogin: () => void;
|
||||
}
|
||||
|
||||
function LoadingPane({ label }: { label: string }) {
|
||||
@@ -104,8 +119,8 @@ export function ConversationPane({
|
||||
notificationsSupported,
|
||||
notificationsEnabled,
|
||||
notificationsPermission,
|
||||
favorites,
|
||||
messages,
|
||||
preSorted,
|
||||
messagesLoading,
|
||||
loadingOlder,
|
||||
hasOlderMessages,
|
||||
@@ -115,14 +130,17 @@ export function ConversationPane({
|
||||
loadingNewer,
|
||||
messageInputRef,
|
||||
onTrace,
|
||||
onRunTracePath,
|
||||
onPathDiscovery,
|
||||
onToggleFavorite,
|
||||
onDeleteContact,
|
||||
onDeleteChannel,
|
||||
onSetChannelFloodScopeOverride,
|
||||
onSetChannelPathHashModeOverride,
|
||||
onOpenContactInfo,
|
||||
onOpenChannelInfo,
|
||||
onSenderClick,
|
||||
onChannelReferenceClick,
|
||||
onLoadOlder,
|
||||
onResendChannelMessage,
|
||||
onTargetReached,
|
||||
@@ -131,6 +149,10 @@ export function ConversationPane({
|
||||
onDismissUnreadMarker,
|
||||
onSendMessage,
|
||||
onToggleNotifications,
|
||||
trackedTelemetryRepeaters,
|
||||
onToggleTrackedTelemetry,
|
||||
repeaterAutoLoginKey,
|
||||
onClearRepeaterAutoLogin,
|
||||
}: ConversationPaneProps) {
|
||||
const [roomAuthenticated, setRoomAuthenticated] = useState(false);
|
||||
const activeContactIsRepeater = useMemo(() => {
|
||||
@@ -170,7 +192,12 @@ export function ConversationPane({
|
||||
</h2>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Suspense fallback={<LoadingPane label="Loading map..." />}>
|
||||
<MapView contacts={contacts} focusedKey={activeConversation.mapFocusKey} />
|
||||
<MapView
|
||||
contacts={contacts}
|
||||
focusedKey={activeConversation.mapFocusKey}
|
||||
rawPackets={rawPackets}
|
||||
config={config}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</>
|
||||
@@ -200,6 +227,10 @@ export function ConversationPane({
|
||||
return null;
|
||||
}
|
||||
|
||||
if (activeConversation.type === 'trace') {
|
||||
return <TracePane contacts={contacts} config={config} onRunTracePath={onRunTracePath} />;
|
||||
}
|
||||
|
||||
if (activeContactIsRepeater) {
|
||||
return (
|
||||
<Suspense fallback={<LoadingPane label="Loading dashboard..." />}>
|
||||
@@ -207,7 +238,6 @@ export function ConversationPane({
|
||||
key={activeConversation.id}
|
||||
conversation={activeConversation}
|
||||
contacts={contacts}
|
||||
favorites={favorites}
|
||||
notificationsSupported={notificationsSupported}
|
||||
notificationsEnabled={notificationsEnabled}
|
||||
notificationsPermission={notificationsPermission}
|
||||
@@ -219,6 +249,11 @@ export function ConversationPane({
|
||||
onToggleNotifications={onToggleNotifications}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onDeleteContact={onDeleteContact}
|
||||
onOpenContactInfo={onOpenContactInfo}
|
||||
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
|
||||
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
|
||||
autoLoginAndLoadAll={repeaterAutoLoginKey === activeConversation.id}
|
||||
onAutoLoginConsumed={onClearRepeaterAutoLogin}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
@@ -233,7 +268,6 @@ export function ConversationPane({
|
||||
contacts={contacts}
|
||||
channels={channels}
|
||||
config={config}
|
||||
favorites={favorites}
|
||||
notificationsSupported={notificationsSupported}
|
||||
notificationsEnabled={notificationsEnabled}
|
||||
notificationsPermission={notificationsPermission}
|
||||
@@ -242,6 +276,7 @@ export function ConversationPane({
|
||||
onToggleNotifications={onToggleNotifications}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onSetChannelFloodScopeOverride={onSetChannelFloodScopeOverride}
|
||||
onSetChannelPathHashModeOverride={onSetChannelPathHashModeOverride}
|
||||
onDeleteChannel={onDeleteChannel}
|
||||
onDeleteContact={onDeleteContact}
|
||||
onOpenContactInfo={onOpenContactInfo}
|
||||
@@ -260,6 +295,7 @@ export function ConversationPane({
|
||||
<MessageList
|
||||
key={activeConversation.id}
|
||||
messages={messages}
|
||||
preSorted={preSorted}
|
||||
contacts={contacts}
|
||||
channels={channels}
|
||||
loading={messagesLoading}
|
||||
@@ -272,6 +308,7 @@ export function ConversationPane({
|
||||
activeConversation.type === 'channel' ? onDismissUnreadMarker : undefined
|
||||
}
|
||||
onSenderClick={activeConversation.type === 'channel' ? onSenderClick : undefined}
|
||||
onChannelReferenceClick={onChannelReferenceClick}
|
||||
onLoadOlder={onLoadOlder}
|
||||
onResendChannelMessage={
|
||||
activeConversation.type === 'channel' ? onResendChannelMessage : undefined
|
||||
|
||||
@@ -39,6 +39,7 @@ export function CrackerPanel({
|
||||
}: CrackerPanelProps) {
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [maxLength, setMaxLength] = useState(6);
|
||||
const [maxLengthInput, setMaxLengthInput] = useState('6');
|
||||
const [retryFailedAtNextLength, setRetryFailedAtNextLength] = useState(false);
|
||||
const [decryptHistorical, setDecryptHistorical] = useState(true);
|
||||
const [turboMode, setTurboMode] = useState(false);
|
||||
@@ -97,7 +98,7 @@ export function CrackerPanel({
|
||||
.catch((err) => {
|
||||
console.error('Failed to load wordlist:', err);
|
||||
toast.error('Failed to load wordlist', {
|
||||
description: 'Cracking will not be available',
|
||||
description: 'Channel finder will not be available',
|
||||
});
|
||||
});
|
||||
}, [visible, wordlistLoaded]);
|
||||
@@ -127,8 +128,9 @@ export function CrackerPanel({
|
||||
}, [existingChannelKeys]);
|
||||
|
||||
// Filter packets to only undecrypted GROUP_TEXT
|
||||
const undecryptedGroupText = packets.filter(
|
||||
(p) => p.payload_type === 'GROUP_TEXT' && !p.decrypted
|
||||
const undecryptedGroupText = useMemo(
|
||||
() => packets.filter((p) => p.payload_type === 'GROUP_TEXT' && !p.decrypted),
|
||||
[packets]
|
||||
);
|
||||
|
||||
// Update queue when packets change (deduplicated by payload)
|
||||
@@ -191,6 +193,10 @@ export function CrackerPanel({
|
||||
maxLengthRef.current = maxLength;
|
||||
}, [maxLength]);
|
||||
|
||||
useEffect(() => {
|
||||
setMaxLengthInput(String(maxLength));
|
||||
}, [maxLength]);
|
||||
|
||||
useEffect(() => {
|
||||
decryptHistoricalRef.current = decryptHistorical;
|
||||
}, [decryptHistorical]);
|
||||
@@ -350,7 +356,7 @@ export function CrackerPanel({
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create channel or decrypt historical:', err);
|
||||
toast.error('Failed to save cracked channel', {
|
||||
toast.error('Failed to save found channel', {
|
||||
description:
|
||||
err instanceof Error ? err.message : 'Channel discovered but could not be saved',
|
||||
});
|
||||
@@ -403,7 +409,10 @@ export function CrackerPanel({
|
||||
const handleStart = () => {
|
||||
if (!gpuAvailable) {
|
||||
toast.error('WebGPU not available', {
|
||||
description: 'Cracking requires Chrome 113+ or Edge 113+ with WebGPU support.',
|
||||
description:
|
||||
typeof window !== 'undefined' && !window.isSecureContext
|
||||
? 'WebGPU requires HTTPS when not on localhost. Set up a certificate or configure your browser to treat this origin as secure.'
|
||||
: 'Channel finder requires Chrome 113+ or Edge 113+ with WebGPU support.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -434,8 +443,25 @@ export function CrackerPanel({
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={maxLength}
|
||||
onChange={(e) => setMaxLength(Math.min(10, Math.max(1, parseInt(e.target.value) || 6)))}
|
||||
value={maxLengthInput}
|
||||
onChange={(e) => {
|
||||
const nextValue = e.target.value;
|
||||
setMaxLengthInput(nextValue);
|
||||
if (nextValue === '') return;
|
||||
const parsed = Number.parseInt(nextValue, 10);
|
||||
if (Number.isNaN(parsed)) return;
|
||||
setMaxLength(Math.min(10, Math.max(1, parsed)));
|
||||
}}
|
||||
onBlur={() => {
|
||||
const parsed = Number.parseInt(maxLengthInput, 10);
|
||||
const nextValue = Number.isNaN(parsed)
|
||||
? maxLength
|
||||
: Math.min(10, Math.max(1, parsed));
|
||||
setMaxLengthInput(String(nextValue));
|
||||
if (nextValue !== maxLength) {
|
||||
setMaxLength(nextValue);
|
||||
}
|
||||
}}
|
||||
className="w-14 px-2 py-1 text-sm bg-muted border border-border rounded"
|
||||
/>
|
||||
</div>
|
||||
@@ -514,7 +540,7 @@ export function CrackerPanel({
|
||||
Pending: <span className="text-foreground font-medium">{pendingCount}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
Cracked: <span className="text-success font-medium">{crackedCount}</span>
|
||||
Found: <span className="text-success font-medium">{crackedCount}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
Failed: <span className="text-destructive font-medium">{failedCount}</span>
|
||||
@@ -558,7 +584,7 @@ export function CrackerPanel({
|
||||
aria-valuenow={Math.round(progress.percent)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label="Cracking progress"
|
||||
aria-label="Channel finder progress"
|
||||
>
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-200"
|
||||
@@ -570,8 +596,26 @@ export function CrackerPanel({
|
||||
|
||||
{/* GPU status */}
|
||||
{gpuAvailable === false && (
|
||||
<div className="text-sm text-destructive" role="alert">
|
||||
WebGPU not available. Cracking requires Chrome 113+ or Edge 113+.
|
||||
<div className="text-sm text-destructive space-y-1.5" role="alert">
|
||||
<p>WebGPU not available.</p>
|
||||
{typeof window !== 'undefined' && !window.isSecureContext ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-2.5 text-xs text-destructive/90">
|
||||
<p className="font-medium mb-1">WebGPU requires HTTPS when not on localhost.</p>
|
||||
<p>To enable it:</p>
|
||||
<ul className="list-disc ml-4 mt-1 space-y-0.5">
|
||||
<li>
|
||||
Set up a TLS certificate (see the HTTPS section of README_ADVANCED.md, or re-run
|
||||
the Docker setup script which can generate one automatically)
|
||||
</li>
|
||||
<li>
|
||||
Or configure your browser to treat this origin as secure (sometimes called
|
||||
“insecure origins treated as secure” in browser flags)
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<p>Channel finder requires Chrome 113+ or Edge 113+ with WebGPU support.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!wordlistLoaded && gpuAvailable !== false && (
|
||||
@@ -580,10 +624,10 @@ export function CrackerPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cracked channels list */}
|
||||
{/* Found channels list */}
|
||||
{crackedChannels.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Cracked Channels:</div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Found Channels:</div>
|
||||
<div className="space-y-1">
|
||||
{crackedChannels.map((channel, i) => (
|
||||
<div
|
||||
@@ -607,8 +651,8 @@ export function CrackerPanel({
|
||||
force payloads as they arrive, testing channel names up to the specified length to discover
|
||||
active channels on the local mesh (GroupText packets may not be hashtag messages; we have no
|
||||
way of knowing but try as if they are).
|
||||
<strong> Retry failed at n+1</strong> will let the cracker return to the failed queue and
|
||||
pick up messages it couldn't crack, attempting them at one longer length.
|
||||
<strong> Retry failed at n+1</strong> will return to the failed queue and pick up messages
|
||||
it couldn't find a key for, attempting them at one longer length.
|
||||
<strong> Try word pairs</strong> will also try every combination of two dictionary words
|
||||
concatenated together (e.g. "hello" + "world" = "#helloworld") after the single-word
|
||||
dictionary pass; this can substantially increase search time and also result in
|
||||
@@ -616,7 +660,7 @@ export function CrackerPanel({
|
||||
<strong> Decrypt historical</strong> will run an async job on any channel name it finds to
|
||||
see if any historically captured packets will decrypt with that key.
|
||||
<strong> Turbo mode</strong> will push your GPU to the max (target dispatch time of 10s) and
|
||||
may allow accelerated cracking and/or system instability.
|
||||
may allow accelerated searching and/or system instability.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,47 @@
|
||||
import { Fragment, useEffect, useState, useMemo, useRef, useCallback } from 'react';
|
||||
import { MapContainer, TileLayer, CircleMarker, Popup, useMap } from 'react-leaflet';
|
||||
import { MapContainer, TileLayer, CircleMarker, Popup, useMap, Polyline } from 'react-leaflet';
|
||||
import type { LatLngBoundsExpression, CircleMarker as LeafletCircleMarker } from 'leaflet';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import type { Contact } from '../types';
|
||||
import type { Contact, RadioConfig, RawPacket } from '../types';
|
||||
import { formatTime } from '../utils/messageParser';
|
||||
import { isValidLocation } from '../utils/pathUtils';
|
||||
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||
import {
|
||||
parsePacket,
|
||||
getPacketLabel,
|
||||
PARTICLE_COLOR_MAP,
|
||||
dedupeConsecutive,
|
||||
} from '../utils/visualizerUtils';
|
||||
import { getRawPacketObservationKey } from '../utils/rawPacketIdentity';
|
||||
|
||||
interface MapViewProps {
|
||||
contacts: Contact[];
|
||||
/** Public key of contact to focus on and open popup */
|
||||
focusedKey?: string | null;
|
||||
rawPackets?: RawPacket[];
|
||||
config?: RadioConfig | null;
|
||||
}
|
||||
|
||||
// --- Tile layer presets ---
|
||||
const TILE_LIGHT = {
|
||||
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
background: '#1a1a2e',
|
||||
};
|
||||
const TILE_DARK = {
|
||||
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
|
||||
attribution:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/">CARTO</a>',
|
||||
background: '#0d0d0d',
|
||||
};
|
||||
|
||||
function getSavedDarkMap(): boolean {
|
||||
try {
|
||||
return localStorage.getItem('remoteterm-dark-map') === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const MAP_RECENCY_COLORS = {
|
||||
@@ -22,7 +53,16 @@ const MAP_RECENCY_COLORS = {
|
||||
const MAP_MARKER_STROKE = '#0f172a';
|
||||
const MAP_REPEATER_RING = '#f8fafc';
|
||||
|
||||
// Calculate marker color based on how recently the contact was heard
|
||||
// --- Packet visualization constants ---
|
||||
const THREE_DAYS_SEC = 3 * 24 * 60 * 60;
|
||||
const PARTICLE_LIFETIME_MS = 3000;
|
||||
const PARTICLE_TAIL_LENGTH = 0.25; // fraction of progress to trail behind
|
||||
const PARTICLE_RADIUS = 8;
|
||||
const PARTICLE_TAIL_WIDTH = 5;
|
||||
const MAX_MAP_PARTICLES = 200;
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function getMarkerColor(lastSeen: number | null | undefined): string {
|
||||
if (lastSeen == null) return MAP_RECENCY_COLORS.old;
|
||||
const now = Date.now() / 1000;
|
||||
@@ -36,7 +76,85 @@ function getMarkerColor(lastSeen: number | null | undefined): string {
|
||||
return MAP_RECENCY_COLORS.old;
|
||||
}
|
||||
|
||||
// Component to handle map bounds fitting
|
||||
/** Resolve a hop token to a single contact with GPS, or null. */
|
||||
function resolveHopToGps(hopToken: string, prefixIndex: Map<string, Contact[]>): Contact | null {
|
||||
const matches = prefixIndex.get(hopToken.toLowerCase());
|
||||
if (!matches || matches.length !== 1) return null;
|
||||
const c = matches[0];
|
||||
return isValidLocation(c.lat, c.lon) ? c : null;
|
||||
}
|
||||
|
||||
/** Resolve a contact by display name (for GroupText senders). */
|
||||
function resolveNameToGps(name: string, nameIndex: Map<string, Contact>): Contact | null {
|
||||
const c = nameIndex.get(name);
|
||||
if (!c) return null;
|
||||
return isValidLocation(c.lat, c.lon) ? c : null;
|
||||
}
|
||||
|
||||
/** Collect public keys of all unambiguously resolved GPS-bearing contacts from a parsed packet. */
|
||||
function resolvePacketContacts(
|
||||
parsed: ReturnType<typeof parsePacket>,
|
||||
prefixIndex: Map<string, Contact[]>,
|
||||
nameIndex: Map<string, Contact>,
|
||||
myLatLon: [number, number] | null,
|
||||
config?: RadioConfig | null
|
||||
): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
if (!parsed) return keys;
|
||||
|
||||
// Source by pubkey prefix
|
||||
const sourcePrefixes = parsed.advertPubkey
|
||||
? [parsed.advertPubkey.slice(0, 12).toLowerCase()]
|
||||
: parsed.srcHash
|
||||
? [parsed.srcHash.toLowerCase()]
|
||||
: [];
|
||||
for (const prefix of sourcePrefixes) {
|
||||
const matches = prefixIndex.get(prefix);
|
||||
if (matches?.length === 1 && isValidLocation(matches[0].lat, matches[0].lon)) {
|
||||
keys.add(matches[0].public_key);
|
||||
}
|
||||
}
|
||||
|
||||
// Source by name (GroupText sender)
|
||||
if (parsed.groupTextSender) {
|
||||
const c = resolveNameToGps(parsed.groupTextSender, nameIndex);
|
||||
if (c) keys.add(c.public_key);
|
||||
}
|
||||
|
||||
// Intermediate hops
|
||||
for (const hop of parsed.pathBytes) {
|
||||
if (hop.length < 4) continue;
|
||||
const matches = prefixIndex.get(hop.toLowerCase());
|
||||
if (matches?.length === 1 && isValidLocation(matches[0].lat, matches[0].lon)) {
|
||||
keys.add(matches[0].public_key);
|
||||
}
|
||||
}
|
||||
|
||||
// Self
|
||||
if (myLatLon && config?.public_key) {
|
||||
keys.add(config.public_key.toLowerCase());
|
||||
}
|
||||
|
||||
// Destination
|
||||
if (parsed.dstHash) {
|
||||
const matches = prefixIndex.get(parsed.dstHash.toLowerCase());
|
||||
if (matches?.length === 1 && isValidLocation(matches[0].lat, matches[0].lon)) {
|
||||
keys.add(matches[0].public_key);
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
interface MapParticle {
|
||||
id: number;
|
||||
path: [number, number][]; // lat/lon waypoints
|
||||
color: string;
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
// --- Map bounds handler ---
|
||||
|
||||
function MapBoundsHandler({
|
||||
contacts,
|
||||
focusedContact,
|
||||
@@ -48,7 +166,6 @@ function MapBoundsHandler({
|
||||
const [hasInitialized, setHasInitialized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// If we have a focused contact, center on it immediately (even if already initialized)
|
||||
if (focusedContact && focusedContact.lat != null && focusedContact.lon != null) {
|
||||
map.setView([focusedContact.lat, focusedContact.lon], 12);
|
||||
setHasInitialized(true);
|
||||
@@ -59,20 +176,17 @@ function MapBoundsHandler({
|
||||
|
||||
const fitToContacts = () => {
|
||||
if (contacts.length === 0) {
|
||||
// No contacts with location - show world view
|
||||
map.setView([20, 0], 2);
|
||||
setHasInitialized(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (contacts.length === 1) {
|
||||
// Single contact - center on it
|
||||
map.setView([contacts[0].lat!, contacts[0].lon!], 10);
|
||||
setHasInitialized(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Multiple contacts - fit bounds
|
||||
const bounds: LatLngBoundsExpression = contacts.map(
|
||||
(c) => [c.lat!, c.lon!] as [number, number]
|
||||
);
|
||||
@@ -80,22 +194,18 @@ function MapBoundsHandler({
|
||||
setHasInitialized(true);
|
||||
};
|
||||
|
||||
// Try geolocation first
|
||||
if ('geolocation' in navigator) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
// Success - center on user location with reasonable zoom
|
||||
map.setView([position.coords.latitude, position.coords.longitude], 8);
|
||||
setHasInitialized(true);
|
||||
},
|
||||
() => {
|
||||
// Geolocation denied/failed - fit to contacts
|
||||
fitToContacts();
|
||||
},
|
||||
{ timeout: 5000, maximumAge: 300000 }
|
||||
);
|
||||
} else {
|
||||
// No geolocation support - fit to contacts
|
||||
fitToContacts();
|
||||
}
|
||||
}, [map, contacts, hasInitialized, focusedContact]);
|
||||
@@ -103,18 +213,404 @@ function MapBoundsHandler({
|
||||
return null;
|
||||
}
|
||||
|
||||
export function MapView({ contacts, focusedKey }: MapViewProps) {
|
||||
const [sevenDaysAgo] = useState(() => Date.now() / 1000 - 7 * 24 * 60 * 60);
|
||||
// --- Canvas particle overlay ---
|
||||
|
||||
// Filter to contacts with GPS coordinates, heard within the last 7 days.
|
||||
// Always include the focused contact so "view on map" links work for older nodes.
|
||||
function ParticleOverlay({ particles }: { particles: MapParticle[] }) {
|
||||
const map = useMap();
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const animRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
const container = map.getContainer();
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.style.position = 'absolute';
|
||||
canvas.style.top = '0';
|
||||
canvas.style.left = '0';
|
||||
canvas.style.pointerEvents = 'none';
|
||||
canvas.style.zIndex = '450'; // above tiles, below popups
|
||||
container.appendChild(canvas);
|
||||
canvasRef.current = canvas;
|
||||
|
||||
const resize = () => {
|
||||
const size = map.getSize();
|
||||
canvas.width = size.x * window.devicePixelRatio;
|
||||
canvas.height = size.y * window.devicePixelRatio;
|
||||
canvas.style.width = `${size.x}px`;
|
||||
canvas.style.height = `${size.y}px`;
|
||||
};
|
||||
resize();
|
||||
map.on('resize', resize);
|
||||
map.on('zoom', resize);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animRef.current);
|
||||
map.off('resize', resize);
|
||||
map.off('zoom', resize);
|
||||
container.removeChild(canvas);
|
||||
canvasRef.current = null;
|
||||
};
|
||||
}, [map]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const draw = () => {
|
||||
const now = Date.now();
|
||||
const dpr = window.devicePixelRatio;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.save();
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
for (const particle of particles) {
|
||||
const elapsed = now - particle.startedAt;
|
||||
if (elapsed < 0 || elapsed > PARTICLE_LIFETIME_MS) continue;
|
||||
const progress = elapsed / PARTICLE_LIFETIME_MS;
|
||||
const path = particle.path;
|
||||
if (path.length < 2) continue;
|
||||
|
||||
// Calculate total path length in pixels for even speed
|
||||
const pixelPath = path.map((ll) => map.latLngToContainerPoint(L.latLng(ll[0], ll[1])));
|
||||
const segLengths: number[] = [];
|
||||
let totalLen = 0;
|
||||
for (let i = 1; i < pixelPath.length; i++) {
|
||||
const dx = pixelPath[i].x - pixelPath[i - 1].x;
|
||||
const dy = pixelPath[i].y - pixelPath[i - 1].y;
|
||||
const len = Math.sqrt(dx * dx + dy * dy);
|
||||
segLengths.push(len);
|
||||
totalLen += len;
|
||||
}
|
||||
if (totalLen === 0) continue;
|
||||
|
||||
// Interpolate head position
|
||||
const headDist = progress * totalLen;
|
||||
const tailDist = Math.max(0, headDist - PARTICLE_TAIL_LENGTH * totalLen);
|
||||
|
||||
const pointAtDist = (d: number): { x: number; y: number } => {
|
||||
let accum = 0;
|
||||
for (let i = 0; i < segLengths.length; i++) {
|
||||
if (accum + segLengths[i] >= d) {
|
||||
const t = segLengths[i] > 0 ? (d - accum) / segLengths[i] : 0;
|
||||
return {
|
||||
x: pixelPath[i].x + (pixelPath[i + 1].x - pixelPath[i].x) * t,
|
||||
y: pixelPath[i].y + (pixelPath[i + 1].y - pixelPath[i].y) * t,
|
||||
};
|
||||
}
|
||||
accum += segLengths[i];
|
||||
}
|
||||
const last = pixelPath[pixelPath.length - 1];
|
||||
return { x: last.x, y: last.y };
|
||||
};
|
||||
|
||||
const head = pointAtDist(headDist);
|
||||
const tail = pointAtDist(tailDist);
|
||||
|
||||
// Draw tail as a gradient line from transparent to opaque
|
||||
const grad = ctx.createLinearGradient(tail.x, tail.y, head.x, head.y);
|
||||
grad.addColorStop(0, particle.color + '00');
|
||||
grad.addColorStop(1, particle.color + 'cc');
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(tail.x, tail.y);
|
||||
|
||||
// Sample intermediate points along the tail for curved paths
|
||||
const steps = 8;
|
||||
for (let s = 1; s <= steps; s++) {
|
||||
const d = tailDist + ((headDist - tailDist) * s) / steps;
|
||||
const pt = pointAtDist(d);
|
||||
ctx.lineTo(pt.x, pt.y);
|
||||
}
|
||||
ctx.strokeStyle = grad;
|
||||
ctx.lineWidth = PARTICLE_TAIL_WIDTH;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.stroke();
|
||||
|
||||
// Draw head blob with glow
|
||||
const fade = progress > 0.8 ? 1 - (progress - 0.8) / 0.2 : 1;
|
||||
const alpha = Math.round(fade * 230)
|
||||
.toString(16)
|
||||
.padStart(2, '0');
|
||||
// Outer glow
|
||||
ctx.beginPath();
|
||||
ctx.arc(head.x, head.y, PARTICLE_RADIUS + 4, 0, Math.PI * 2);
|
||||
ctx.fillStyle =
|
||||
particle.color +
|
||||
Math.round(fade * 40)
|
||||
.toString(16)
|
||||
.padStart(2, '0');
|
||||
ctx.fill();
|
||||
// Core blob
|
||||
ctx.beginPath();
|
||||
ctx.arc(head.x, head.y, PARTICLE_RADIUS, 0, Math.PI * 2);
|
||||
ctx.fillStyle = particle.color + alpha;
|
||||
ctx.shadowColor = particle.color;
|
||||
ctx.shadowBlur = 12 * fade;
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
// Bright center
|
||||
ctx.beginPath();
|
||||
ctx.arc(head.x, head.y, PARTICLE_RADIUS * 0.4, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#ffffff' + alpha;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
animRef.current = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
animRef.current = requestAnimationFrame(draw);
|
||||
return () => cancelAnimationFrame(animRef.current);
|
||||
}, [map, particles]);
|
||||
|
||||
// Redraw on map move/zoom
|
||||
useEffect(() => {
|
||||
const redraw = () => {}; // Animation loop already redraws every frame
|
||||
map.on('move', redraw);
|
||||
map.on('zoom', redraw);
|
||||
return () => {
|
||||
map.off('move', redraw);
|
||||
map.off('zoom', redraw);
|
||||
};
|
||||
}, [map]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- Main component ---
|
||||
|
||||
export function MapView({ contacts, focusedKey, rawPackets, config }: MapViewProps) {
|
||||
const [sevenDaysAgo] = useState(() => Date.now() / 1000 - 7 * 24 * 60 * 60);
|
||||
const [darkMap, setDarkMap] = useState(getSavedDarkMap);
|
||||
const tile = darkMap ? TILE_DARK : TILE_LIGHT;
|
||||
|
||||
// Sync with settings changes from other components
|
||||
useEffect(() => {
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === 'remoteterm-dark-map') setDarkMap(e.newValue === 'true');
|
||||
};
|
||||
window.addEventListener('storage', onStorage);
|
||||
return () => window.removeEventListener('storage', onStorage);
|
||||
}, []);
|
||||
|
||||
const [showPackets, setShowPackets] = useState(false);
|
||||
const [discoveryMode, setDiscoveryMode] = useState(false);
|
||||
const [discoveredKeys, setDiscoveredKeys] = useState<Set<string>>(new Set());
|
||||
const [particles, setParticles] = useState<MapParticle[]>([]);
|
||||
const particleIdRef = useRef(0);
|
||||
const seenObservationsRef = useRef(new Set<string>());
|
||||
|
||||
// Build prefix index and name index for hop resolution
|
||||
const { prefixIndex, nameIndex } = useMemo(() => {
|
||||
const prefix = new Map<string, Contact[]>();
|
||||
const name = new Map<string, Contact>();
|
||||
for (const c of contacts) {
|
||||
const pubkey = c.public_key.toLowerCase();
|
||||
for (let len = 1; len <= 12 && len <= pubkey.length; len++) {
|
||||
const p = pubkey.slice(0, len);
|
||||
const arr = prefix.get(p);
|
||||
if (arr) arr.push(c);
|
||||
else prefix.set(p, [c]);
|
||||
}
|
||||
if (c.name && !name.has(c.name)) name.set(c.name, c);
|
||||
}
|
||||
return { prefixIndex: prefix, nameIndex: name };
|
||||
}, [contacts]);
|
||||
|
||||
// Self GPS
|
||||
const myLatLon = useMemo<[number, number] | null>(() => {
|
||||
if (!config || !isValidLocation(config.lat, config.lon)) return null;
|
||||
return [config.lat, config.lon];
|
||||
}, [config]);
|
||||
|
||||
// Determine time window for packet visualization
|
||||
const threeDaysAgoSec = useMemo(() => Date.now() / 1000 - THREE_DAYS_SEC, []);
|
||||
|
||||
// Filter contacts for map display
|
||||
const mappableContacts = useMemo(() => {
|
||||
if (showPackets && discoveryMode) {
|
||||
// Discovery mode: only show nodes that have appeared in resolved packets
|
||||
return contacts.filter(
|
||||
(c) => isValidLocation(c.lat, c.lon) && discoveredKeys.has(c.public_key)
|
||||
);
|
||||
}
|
||||
if (showPackets) {
|
||||
// Packet mode: show only last 3 days
|
||||
return contacts.filter(
|
||||
(c) =>
|
||||
isValidLocation(c.lat, c.lon) &&
|
||||
(c.public_key === focusedKey || (c.last_seen != null && c.last_seen > threeDaysAgoSec))
|
||||
);
|
||||
}
|
||||
return contacts.filter(
|
||||
(c) =>
|
||||
isValidLocation(c.lat, c.lon) &&
|
||||
(c.public_key === focusedKey || (c.last_seen != null && c.last_seen > sevenDaysAgo))
|
||||
);
|
||||
}, [contacts, focusedKey, sevenDaysAgo]);
|
||||
}, [
|
||||
contacts,
|
||||
focusedKey,
|
||||
sevenDaysAgo,
|
||||
threeDaysAgoSec,
|
||||
showPackets,
|
||||
discoveryMode,
|
||||
discoveredKeys,
|
||||
]);
|
||||
|
||||
// Resolve a path of hop tokens to geographic waypoints (only unambiguous + has GPS)
|
||||
const resolvePacketPath = useCallback(
|
||||
(parsed: ReturnType<typeof parsePacket>): [number, number][] | null => {
|
||||
if (!parsed) return null;
|
||||
|
||||
const waypoints: [number, number][] = [];
|
||||
|
||||
// Source: advertPubkey, srcHash, or groupTextSender resolved by name
|
||||
let sourceContact: Contact | null = null;
|
||||
if (parsed.advertPubkey) {
|
||||
const prefix = parsed.advertPubkey.slice(0, 12).toLowerCase();
|
||||
const matches = prefixIndex.get(prefix);
|
||||
if (matches?.length === 1 && isValidLocation(matches[0].lat, matches[0].lon)) {
|
||||
sourceContact = matches[0];
|
||||
}
|
||||
} else if (parsed.srcHash) {
|
||||
sourceContact = resolveHopToGps(parsed.srcHash, prefixIndex);
|
||||
} else if (parsed.groupTextSender) {
|
||||
sourceContact = resolveNameToGps(parsed.groupTextSender, nameIndex);
|
||||
}
|
||||
|
||||
if (sourceContact) {
|
||||
waypoints.push([sourceContact.lat!, sourceContact.lon!]);
|
||||
}
|
||||
|
||||
// Intermediate hops (path bytes)
|
||||
for (const hop of parsed.pathBytes) {
|
||||
// Only resolve 2+ byte hops (4+ hex chars) to avoid ambiguous 1-byte hops
|
||||
if (hop.length < 4) continue;
|
||||
const contact = resolveHopToGps(hop, prefixIndex);
|
||||
if (contact) {
|
||||
waypoints.push([contact.lat!, contact.lon!]);
|
||||
}
|
||||
}
|
||||
|
||||
// Destination: self (our radio), or dstHash
|
||||
if (myLatLon) {
|
||||
waypoints.push(myLatLon);
|
||||
} else if (parsed.dstHash) {
|
||||
const dest = resolveHopToGps(parsed.dstHash, prefixIndex);
|
||||
if (dest) {
|
||||
waypoints.push([dest.lat!, dest.lon!]);
|
||||
}
|
||||
}
|
||||
|
||||
// Dedupe consecutive identical waypoints
|
||||
const deduped = dedupeConsecutive(waypoints.map((w) => `${w[0]},${w[1]}`));
|
||||
if (deduped.length < 2) return null;
|
||||
|
||||
return deduped.map((s) => {
|
||||
const [lat, lon] = s.split(',').map(Number);
|
||||
return [lat, lon] as [number, number];
|
||||
});
|
||||
},
|
||||
[prefixIndex, nameIndex, myLatLon]
|
||||
);
|
||||
|
||||
// Process new packets into particles and track discovered contacts
|
||||
useEffect(() => {
|
||||
if (!showPackets || !rawPackets?.length) return;
|
||||
|
||||
const now = Date.now();
|
||||
const newParticles: MapParticle[] = [];
|
||||
const newDiscovered = new Set<string>();
|
||||
|
||||
for (const pkt of rawPackets) {
|
||||
// Skip old packets
|
||||
if (pkt.timestamp < threeDaysAgoSec) continue;
|
||||
|
||||
// Deduplicate by observation
|
||||
const obsKey = getRawPacketObservationKey(pkt);
|
||||
if (seenObservationsRef.current.has(obsKey)) continue;
|
||||
|
||||
const parsed = parsePacket(pkt.data);
|
||||
if (!parsed) continue;
|
||||
|
||||
// Discover contacts from this packet regardless of whether a full path resolves
|
||||
const resolvedContacts = resolvePacketContacts(
|
||||
parsed,
|
||||
prefixIndex,
|
||||
nameIndex,
|
||||
myLatLon,
|
||||
config
|
||||
);
|
||||
const path = resolvePacketPath(parsed);
|
||||
|
||||
// Only mark as seen if we got something useful; otherwise a later run
|
||||
// with updated contacts/config can retry this observation.
|
||||
if (resolvedContacts.size === 0 && !path) continue;
|
||||
seenObservationsRef.current.add(obsKey);
|
||||
|
||||
for (const key of resolvedContacts) newDiscovered.add(key);
|
||||
|
||||
if (path) {
|
||||
newParticles.push({
|
||||
id: particleIdRef.current++,
|
||||
path,
|
||||
color: PARTICLE_COLOR_MAP[getPacketLabel(parsed.payloadType)],
|
||||
startedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (newDiscovered.size > 0) {
|
||||
setDiscoveredKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const k of newDiscovered) next.add(k);
|
||||
return next.size !== prev.size ? next : prev;
|
||||
});
|
||||
}
|
||||
|
||||
if (newParticles.length === 0) return;
|
||||
|
||||
setParticles((prev) => {
|
||||
const combined = [...prev, ...newParticles];
|
||||
// Prune expired and cap total
|
||||
const alive = combined.filter((p) => now - p.startedAt < PARTICLE_LIFETIME_MS);
|
||||
return alive.slice(-MAX_MAP_PARTICLES);
|
||||
});
|
||||
}, [
|
||||
rawPackets,
|
||||
showPackets,
|
||||
resolvePacketPath,
|
||||
threeDaysAgoSec,
|
||||
prefixIndex,
|
||||
nameIndex,
|
||||
myLatLon,
|
||||
config,
|
||||
]);
|
||||
|
||||
// Prune expired particles periodically
|
||||
useEffect(() => {
|
||||
if (!showPackets) return;
|
||||
const interval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
setParticles((prev) => prev.filter((p) => now - p.startedAt < PARTICLE_LIFETIME_MS));
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [showPackets]);
|
||||
|
||||
// Reset discovered set when exiting discovery mode
|
||||
useEffect(() => {
|
||||
if (!discoveryMode) setDiscoveredKeys(new Set());
|
||||
}, [discoveryMode]);
|
||||
|
||||
// Clear state when toggling off
|
||||
useEffect(() => {
|
||||
if (!showPackets) {
|
||||
setParticles([]);
|
||||
setDiscoveredKeys(new Set());
|
||||
setDiscoveryMode(false);
|
||||
seenObservationsRef.current.clear();
|
||||
}
|
||||
}, [showPackets]);
|
||||
|
||||
// Find the focused contact by key
|
||||
const focusedContact = useMemo(() => {
|
||||
@@ -124,20 +620,31 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
||||
|
||||
const includesFocusedOutsideWindow =
|
||||
focusedContact != null &&
|
||||
(focusedContact.last_seen == null || focusedContact.last_seen <= sevenDaysAgo);
|
||||
(focusedContact.last_seen == null ||
|
||||
focusedContact.last_seen <= (showPackets ? threeDaysAgoSec : sevenDaysAgo));
|
||||
|
||||
// Track marker refs to open popup programmatically
|
||||
const markerRefs = useRef<Record<string, LeafletCircleMarker | null>>({});
|
||||
|
||||
// Store ref for a marker
|
||||
const setMarkerRef = useCallback((key: string, ref: LeafletCircleMarker | null) => {
|
||||
if (ref === null) {
|
||||
delete markerRefs.current[key];
|
||||
return;
|
||||
}
|
||||
markerRefs.current[key] = ref;
|
||||
}, []);
|
||||
|
||||
// Open popup for focused contact after map is ready
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (focusedContact && markerRefs.current[focusedContact.public_key]) {
|
||||
// Small delay to ensure map has finished rendering
|
||||
const timer = setTimeout(() => {
|
||||
markerRefs.current[focusedContact.public_key]?.openPopup();
|
||||
}, 100);
|
||||
@@ -145,48 +652,104 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
||||
}
|
||||
}, [focusedContact]);
|
||||
|
||||
// Gather unique link paths for static route lines when packet viz is on
|
||||
const routeLines = useMemo(() => {
|
||||
if (!showPackets) return [];
|
||||
const seen = new Set<string>();
|
||||
const lines: { path: [number, number][]; color: string }[] = [];
|
||||
for (const p of particles) {
|
||||
const key = p.path.map((w) => `${w[0]},${w[1]}`).join('|');
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
lines.push({ path: p.path, color: p.color });
|
||||
}
|
||||
return lines;
|
||||
}, [showPackets, particles]);
|
||||
|
||||
const timeWindowLabel = showPackets ? '3 days' : '7 days';
|
||||
const infoLabel =
|
||||
showPackets && discoveryMode
|
||||
? `${mappableContacts.length} node${mappableContacts.length !== 1 ? 's' : ''} discovered from live traffic`
|
||||
: `Showing ${mappableContacts.length} contact${mappableContacts.length !== 1 ? 's' : ''} heard in the last ${timeWindowLabel}${includesFocusedOutsideWindow ? ' plus the focused contact' : ''}`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Info bar */}
|
||||
<div className="px-4 py-2 bg-muted/50 text-xs text-muted-foreground flex items-center justify-between">
|
||||
<span>
|
||||
Showing {mappableContacts.length} contact{mappableContacts.length !== 1 ? 's' : ''} heard
|
||||
in the last 7 days
|
||||
{includesFocusedOutsideWindow ? ' plus the focused contact' : ''}
|
||||
</span>
|
||||
<span>{infoLabel}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: MAP_RECENCY_COLORS.recent }}
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
<1h
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: MAP_RECENCY_COLORS.today }}
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
<1d
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: MAP_RECENCY_COLORS.stale }}
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
<3d
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: MAP_RECENCY_COLORS.old }}
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
older
|
||||
</span>
|
||||
{!showPackets && (
|
||||
<>
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: MAP_RECENCY_COLORS.recent }}
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
<1h
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: MAP_RECENCY_COLORS.today }}
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
<1d
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: MAP_RECENCY_COLORS.stale }}
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
<3d
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: MAP_RECENCY_COLORS.old }}
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
older
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{showPackets && (
|
||||
<>
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: PARTICLE_COLOR_MAP['AD'] }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Ad
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: PARTICLE_COLOR_MAP['GT'] }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Ch
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: PARTICLE_COLOR_MAP['DM'] }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
DM
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: PARTICLE_COLOR_MAP['ACK'] }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
ACK
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full border-2"
|
||||
@@ -195,10 +758,30 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
||||
/>{' '}
|
||||
repeater
|
||||
</span>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer ml-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showPackets}
|
||||
onChange={(e) => setShowPackets(e.target.checked)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="text-[0.6875rem]">Visualize packets</span>
|
||||
</label>
|
||||
{showPackets && (
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={discoveryMode}
|
||||
onChange={(e) => setDiscoveryMode(e.target.checked)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="text-[0.6875rem]">Discover nodes</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map - z-index constrained to stay below modals/sheets */}
|
||||
{/* Map */}
|
||||
<div
|
||||
className="flex-1 relative"
|
||||
style={{ zIndex: 0 }}
|
||||
@@ -209,14 +792,21 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
||||
center={[20, 0]}
|
||||
zoom={2}
|
||||
className="h-full w-full"
|
||||
style={{ background: '#1a1a2e' }}
|
||||
style={{ background: tile.background }}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<TileLayer key={tile.url} attribution={tile.attribution} url={tile.url} />
|
||||
<MapBoundsHandler contacts={mappableContacts} focusedContact={focusedContact} />
|
||||
|
||||
{/* Faint route lines for active packet paths */}
|
||||
{showPackets &&
|
||||
routeLines.map((line, i) => (
|
||||
<Polyline
|
||||
key={i}
|
||||
positions={line.path}
|
||||
pathOptions={{ color: line.color, weight: 1, opacity: 0.15, dashArray: '4 6' }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{mappableContacts.map((contact) => {
|
||||
const isRepeater = contact.type === CONTACT_TYPE_REPEATER;
|
||||
const color = getMarkerColor(contact.last_seen);
|
||||
@@ -261,6 +851,8 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
{showPackets && <ParticleOverlay particles={particles} />}
|
||||
</MapContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -44,6 +44,7 @@ type LimitState = 'normal' | 'warning' | 'danger' | 'error';
|
||||
|
||||
export interface MessageInputHandle {
|
||||
appendText: (text: string) => void;
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(function MessageInput(
|
||||
@@ -60,6 +61,9 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
||||
// Focus the input after appending
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
focus: () => {
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
}));
|
||||
|
||||
// Calculate character limits based on conversation type
|
||||
|
||||
@@ -11,7 +11,11 @@ import {
|
||||
import type { Channel, Contact, Message, MessagePath, RadioConfig, RawPacket } from '../types';
|
||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
|
||||
import { api } from '../api';
|
||||
import { formatTime, parseSenderFromText } from '../utils/messageParser';
|
||||
import {
|
||||
findLinkedChannelReferences,
|
||||
formatTime,
|
||||
parseSenderFromText,
|
||||
} from '../utils/messageParser';
|
||||
import { formatHopCounts, type SenderInfo } from '../utils/pathUtils';
|
||||
import { getDirectContactRoute } from '../utils/pathUtils';
|
||||
import { ContactAvatar } from './ContactAvatar';
|
||||
@@ -33,6 +37,7 @@ interface MessageListProps {
|
||||
onSenderClick?: (sender: string) => void;
|
||||
onLoadOlder?: () => void;
|
||||
onResendChannelMessage?: (messageId: number, newTimestamp?: boolean) => void;
|
||||
onChannelReferenceClick?: (channelName: string) => void;
|
||||
radioName?: string;
|
||||
config?: RadioConfig | null;
|
||||
onOpenContactInfo?: (publicKey: string, fromChannel?: boolean) => void;
|
||||
@@ -42,14 +47,71 @@ interface MessageListProps {
|
||||
loadingNewer?: boolean;
|
||||
onLoadNewer?: () => void;
|
||||
onJumpToBottom?: () => void;
|
||||
preSorted?: boolean;
|
||||
}
|
||||
|
||||
// URL regex for linkifying plain text
|
||||
const URL_PATTERN =
|
||||
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/g;
|
||||
|
||||
// Helper to convert URLs in a plain text string into clickable links
|
||||
function linkifyText(text: string, keyPrefix: string): ReactNode[] {
|
||||
function renderChannelReferences(
|
||||
text: string,
|
||||
keyPrefix: string,
|
||||
onChannelReferenceClick?: (channelName: string) => void
|
||||
): ReactNode[] {
|
||||
const references = findLinkedChannelReferences(text);
|
||||
if (references.length === 0) {
|
||||
return [text];
|
||||
}
|
||||
|
||||
const parts: ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
references.forEach((reference, index) => {
|
||||
if (reference.start > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, reference.start));
|
||||
}
|
||||
|
||||
const className =
|
||||
'rounded px-0.5 font-medium text-primary underline underline-offset-2 transition-colors';
|
||||
if (onChannelReferenceClick) {
|
||||
parts.push(
|
||||
<button
|
||||
key={`${keyPrefix}-channel-${index}`}
|
||||
type="button"
|
||||
className={cn(
|
||||
className,
|
||||
'inline border-0 bg-transparent p-0 align-baseline hover:text-primary/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
||||
)}
|
||||
onClick={() => onChannelReferenceClick(reference.label)}
|
||||
>
|
||||
{reference.label}
|
||||
</button>
|
||||
);
|
||||
} else {
|
||||
parts.push(
|
||||
<span key={`${keyPrefix}-channel-${index}`} className={className}>
|
||||
{reference.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
lastIndex = reference.end;
|
||||
});
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
// Helper to convert URLs and channel references in a plain text string into rich content
|
||||
function linkifyText(
|
||||
text: string,
|
||||
keyPrefix: string,
|
||||
onChannelReferenceClick?: (channelName: string) => void
|
||||
): ReactNode[] {
|
||||
const parts: ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
@@ -58,7 +120,13 @@ function linkifyText(text: string, keyPrefix: string): ReactNode[] {
|
||||
URL_PATTERN.lastIndex = 0;
|
||||
while ((match = URL_PATTERN.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
parts.push(
|
||||
...renderChannelReferences(
|
||||
text.slice(lastIndex, match.index),
|
||||
`${keyPrefix}-text-${keyIndex}`,
|
||||
onChannelReferenceClick
|
||||
)
|
||||
);
|
||||
}
|
||||
parts.push(
|
||||
<a
|
||||
@@ -74,15 +142,27 @@ function linkifyText(text: string, keyPrefix: string): ReactNode[] {
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (lastIndex === 0) return [text];
|
||||
if (lastIndex === 0) {
|
||||
return renderChannelReferences(text, keyPrefix, onChannelReferenceClick);
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
parts.push(
|
||||
...renderChannelReferences(
|
||||
text.slice(lastIndex),
|
||||
`${keyPrefix}-tail`,
|
||||
onChannelReferenceClick
|
||||
)
|
||||
);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
// Helper to render text with highlighted @[Name] mentions and clickable URLs
|
||||
function renderTextWithMentions(text: string, radioName?: string): ReactNode {
|
||||
function renderTextWithMentions(
|
||||
text: string,
|
||||
radioName?: string,
|
||||
onChannelReferenceClick?: (channelName: string) => void
|
||||
): ReactNode {
|
||||
const mentionPattern = /@\[([^\]]+)\]/g;
|
||||
const parts: ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
@@ -92,7 +172,13 @@ function renderTextWithMentions(text: string, radioName?: string): ReactNode {
|
||||
while ((match = mentionPattern.exec(text)) !== null) {
|
||||
// Add text before the match (with linkification)
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(...linkifyText(text.slice(lastIndex, match.index), `pre-${keyIndex}`));
|
||||
parts.push(
|
||||
...linkifyText(
|
||||
text.slice(lastIndex, match.index),
|
||||
`pre-${keyIndex}`,
|
||||
onChannelReferenceClick
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const mentionedName = match[1];
|
||||
@@ -115,7 +201,7 @@ function renderTextWithMentions(text: string, radioName?: string): ReactNode {
|
||||
|
||||
// Add remaining text after last match (with linkification)
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(...linkifyText(text.slice(lastIndex), `post-${keyIndex}`));
|
||||
parts.push(...linkifyText(text.slice(lastIndex), `post-${keyIndex}`, onChannelReferenceClick));
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts : text;
|
||||
@@ -134,8 +220,8 @@ function HopCountBadge({ paths, onClick, variant }: HopCountBadgeProps) {
|
||||
|
||||
const className =
|
||||
variant === 'header'
|
||||
? 'font-normal text-muted-foreground ml-1 text-[11px] cursor-pointer hover:text-primary hover:underline'
|
||||
: 'text-[10px] text-muted-foreground ml-1 cursor-pointer hover:text-primary hover:underline';
|
||||
? 'font-normal text-muted-foreground ml-1 text-[0.6875rem] cursor-pointer hover:text-primary hover:underline'
|
||||
: 'text-[0.625rem] text-muted-foreground ml-1 cursor-pointer hover:text-primary hover:underline';
|
||||
|
||||
return (
|
||||
<span
|
||||
@@ -188,6 +274,7 @@ export function MessageList({
|
||||
onSenderClick,
|
||||
onLoadOlder,
|
||||
onResendChannelMessage,
|
||||
onChannelReferenceClick,
|
||||
radioName,
|
||||
config,
|
||||
onOpenContactInfo,
|
||||
@@ -197,6 +284,7 @@ export function MessageList({
|
||||
loadingNewer = false,
|
||||
onLoadNewer,
|
||||
onJumpToBottom,
|
||||
preSorted = false,
|
||||
}: MessageListProps) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const prevMessagesLengthRef = useRef<number>(0);
|
||||
@@ -212,6 +300,9 @@ export function MessageList({
|
||||
const [resendableIds, setResendableIds] = useState<Set<number>>(new Set());
|
||||
const resendTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
|
||||
const packetCacheRef = useRef<Map<number, RawPacket>>(new Map());
|
||||
const packetSignalOverrideRef = useRef<{ rssi: number | null; snr: number | null } | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [packetInspectorSource, setPacketInspectorSource] = useState<
|
||||
| { kind: 'packet'; packet: RawPacket }
|
||||
| { kind: 'loading'; message: string }
|
||||
@@ -237,6 +328,13 @@ export function MessageList({
|
||||
const prevConvKeyRef = useRef<string | null>(null);
|
||||
|
||||
const handleAnalyzePacket = useCallback(async (message: Message) => {
|
||||
// Extract signal from the first path if available
|
||||
const firstPath = message.paths?.[0];
|
||||
packetSignalOverrideRef.current =
|
||||
firstPath && (firstPath.rssi != null || firstPath.snr != null)
|
||||
? { rssi: firstPath.rssi ?? null, snr: firstPath.snr ?? null }
|
||||
: undefined;
|
||||
|
||||
if (message.packet_id == null) {
|
||||
setPacketInspectorSource({
|
||||
kind: 'unavailable',
|
||||
@@ -373,7 +471,22 @@ export function MessageList({
|
||||
}
|
||||
}
|
||||
|
||||
setResendableIds(newResendable);
|
||||
setResendableIds((prev) => {
|
||||
if (prev.size === newResendable.size) {
|
||||
let changed = false;
|
||||
for (const id of newResendable) {
|
||||
if (!prev.has(id)) {
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!changed) {
|
||||
return prev;
|
||||
}
|
||||
}
|
||||
|
||||
return newResendable;
|
||||
});
|
||||
|
||||
return () => {
|
||||
for (const timer of timers.values()) clearTimeout(timer);
|
||||
@@ -385,8 +498,11 @@ export function MessageList({
|
||||
// Note: Deduplication is handled by useConversationMessages.observeMessage()
|
||||
// and the database UNIQUE constraint on (type, conversation_key, text, sender_timestamp)
|
||||
const sortedMessages = useMemo(
|
||||
() => [...messages].sort((a, b) => a.received_at - b.received_at || a.id - b.id),
|
||||
[messages]
|
||||
() =>
|
||||
preSorted
|
||||
? messages
|
||||
: [...messages].sort((a, b) => a.received_at - b.received_at || a.id - b.id),
|
||||
[messages, preSorted]
|
||||
);
|
||||
const unreadMarkerIndex = useMemo(() => {
|
||||
if (unreadMarkerLastReadAt === undefined) {
|
||||
@@ -859,7 +975,7 @@ export function MessageList({
|
||||
)}
|
||||
>
|
||||
{showAvatar && (
|
||||
<div className="text-[13px] font-semibold text-foreground mb-0.5">
|
||||
<div className="text-[0.8125rem] font-semibold text-foreground mb-0.5">
|
||||
{canClickSender ? (
|
||||
<span
|
||||
className="cursor-pointer hover:text-primary transition-colors"
|
||||
@@ -874,8 +990,8 @@ export function MessageList({
|
||||
) : (
|
||||
displaySender
|
||||
)}
|
||||
<span className="font-normal text-muted-foreground ml-2 text-[11px]">
|
||||
{formatTime(msg.sender_timestamp || msg.received_at)}
|
||||
<span className="font-normal text-muted-foreground ml-2 text-[0.6875rem]">
|
||||
{formatTime(msg.received_at)}
|
||||
</span>
|
||||
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
|
||||
<HopCountBadge
|
||||
@@ -896,14 +1012,14 @@ export function MessageList({
|
||||
<div className="break-words whitespace-pre-wrap">
|
||||
{content.split('\n').map((line, i, arr) => (
|
||||
<span key={i}>
|
||||
{renderTextWithMentions(line, radioName)}
|
||||
{renderTextWithMentions(line, radioName, onChannelReferenceClick)}
|
||||
{i < arr.length - 1 && <br />}
|
||||
</span>
|
||||
))}
|
||||
{!showAvatar && (
|
||||
<>
|
||||
<span className="text-[10px] text-muted-foreground ml-2">
|
||||
{formatTime(msg.sender_timestamp || msg.received_at)}
|
||||
<span className="text-[0.625rem] text-muted-foreground ml-2">
|
||||
{formatTime(msg.received_at)}
|
||||
</span>
|
||||
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
|
||||
<HopCountBadge
|
||||
@@ -1074,12 +1190,18 @@ export function MessageList({
|
||||
{packetInspectorSource && (
|
||||
<RawPacketInspectorDialog
|
||||
open={packetInspectorSource !== null}
|
||||
onOpenChange={(isOpen) => !isOpen && setPacketInspectorSource(null)}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
setPacketInspectorSource(null);
|
||||
packetSignalOverrideRef.current = undefined;
|
||||
}
|
||||
}}
|
||||
channels={channels}
|
||||
source={packetInspectorSource}
|
||||
title="Analyze Packet"
|
||||
description="On-demand raw packet analysis for a message-backed archival packet."
|
||||
notice={ANALYZE_PACKET_NOTICE}
|
||||
signalOverride={packetSignalOverrideRef.current}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,58 +1,156 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Dice5 } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Checkbox } from './ui/checkbox';
|
||||
import { Button } from './ui/button';
|
||||
import { toast } from './ui/sonner';
|
||||
|
||||
type Tab = 'new-contact' | 'new-channel' | 'hashtag';
|
||||
type Tab = 'new-contact' | 'new-channel' | 'hashtag' | 'bulk-hashtag';
|
||||
|
||||
interface BulkParseResult {
|
||||
channelNames: string[];
|
||||
invalidNames: string[];
|
||||
}
|
||||
|
||||
interface NewMessageModalProps {
|
||||
open: boolean;
|
||||
undecryptedCount: number;
|
||||
showBulkAddChannelTab?: boolean;
|
||||
prefillRequest?: {
|
||||
tab: 'hashtag';
|
||||
hashtagName: string;
|
||||
nonce: number;
|
||||
} | null;
|
||||
onClose: () => void;
|
||||
onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>;
|
||||
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
|
||||
onCreateHashtagChannel: (name: string, tryHistorical: boolean) => Promise<void>;
|
||||
onBulkAddHashtagChannels: (channelNames: string[], tryHistorical: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
function validateHashtagName(channelName: string): string | null {
|
||||
if (!channelName) {
|
||||
return 'Channel name is required';
|
||||
}
|
||||
if (!/^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$/.test(channelName)) {
|
||||
return 'Use letters, numbers, and single dashes (no leading/trailing dashes)';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseBulkHashtagNames(rawText: string, permitCapitals: boolean): BulkParseResult {
|
||||
const tokens = rawText
|
||||
.split(/[\s,]+/)
|
||||
.map((token) => token.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const invalidNames: string[] = [];
|
||||
const channelNames: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const token of tokens) {
|
||||
const stripped = token.replace(/^#+/, '');
|
||||
const validationError = validateHashtagName(stripped);
|
||||
if (validationError) {
|
||||
invalidNames.push(token);
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalized = permitCapitals ? stripped : stripped.toLowerCase();
|
||||
const channelName = `#${normalized}`;
|
||||
if (seen.has(channelName)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(channelName);
|
||||
channelNames.push(channelName);
|
||||
}
|
||||
|
||||
return { channelNames, invalidNames };
|
||||
}
|
||||
|
||||
export function NewMessageModal({
|
||||
open,
|
||||
undecryptedCount,
|
||||
showBulkAddChannelTab = false,
|
||||
prefillRequest = null,
|
||||
onClose,
|
||||
onCreateContact,
|
||||
onCreateChannel,
|
||||
onCreateHashtagChannel,
|
||||
onBulkAddHashtagChannels,
|
||||
}: NewMessageModalProps) {
|
||||
const [tab, setTab] = useState<Tab>('new-contact');
|
||||
const [name, setName] = useState('');
|
||||
const [contactKey, setContactKey] = useState('');
|
||||
const [channelKey, setChannelKey] = useState('');
|
||||
const [bulkChannelText, setBulkChannelText] = useState('');
|
||||
const [tryHistorical, setTryHistorical] = useState(false);
|
||||
const [permitCapitals, setPermitCapitals] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const hashtagInputRef = useRef<HTMLInputElement>(null);
|
||||
const bulkTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const resetForm = () => {
|
||||
setName('');
|
||||
setContactKey('');
|
||||
setChannelKey('');
|
||||
setBulkChannelText('');
|
||||
setTryHistorical(false);
|
||||
setPermitCapitals(false);
|
||||
setError('');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (prefillRequest) {
|
||||
setTab(prefillRequest.tab);
|
||||
setName(prefillRequest.hashtagName);
|
||||
setContactKey('');
|
||||
setChannelKey('');
|
||||
setBulkChannelText('');
|
||||
setTryHistorical(false);
|
||||
setPermitCapitals(false);
|
||||
setError('');
|
||||
setLoading(false);
|
||||
requestAnimationFrame(() => {
|
||||
hashtagInputRef.current?.focus();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (showBulkAddChannelTab) {
|
||||
setTab('bulk-hashtag');
|
||||
setName('');
|
||||
setContactKey('');
|
||||
setChannelKey('');
|
||||
setBulkChannelText('');
|
||||
setTryHistorical(false);
|
||||
setPermitCapitals(false);
|
||||
setError('');
|
||||
setLoading(false);
|
||||
requestAnimationFrame(() => {
|
||||
bulkTextareaRef.current?.focus();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setTab('new-contact');
|
||||
}, [open, prefillRequest, showBulkAddChannelTab]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
setError('');
|
||||
setLoading(true);
|
||||
@@ -63,7 +161,6 @@ export function NewMessageModal({
|
||||
setError('Name and public key are required');
|
||||
return;
|
||||
}
|
||||
// handleCreateContact sets activeConversation with the backend-normalized key
|
||||
await onCreateContact(name.trim(), contactKey.trim(), tryHistorical);
|
||||
} else if (tab === 'new-channel') {
|
||||
if (!name.trim() || !channelKey.trim()) {
|
||||
@@ -78,10 +175,24 @@ export function NewMessageModal({
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
// Normalize to lowercase unless user explicitly permits capitals
|
||||
const normalizedName = permitCapitals ? channelName : channelName.toLowerCase();
|
||||
await onCreateHashtagChannel(`#${normalizedName}`, tryHistorical);
|
||||
} else {
|
||||
const { channelNames, invalidNames } = parseBulkHashtagNames(
|
||||
bulkChannelText,
|
||||
permitCapitals
|
||||
);
|
||||
if (channelNames.length === 0) {
|
||||
setError('Enter at least one valid room name');
|
||||
return;
|
||||
}
|
||||
if (invalidNames.length > 0) {
|
||||
setError(`Invalid room names: ${invalidNames.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
await onBulkAddHashtagChannels(channelNames, tryHistorical);
|
||||
}
|
||||
|
||||
resetForm();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
@@ -94,16 +205,6 @@ export function NewMessageModal({
|
||||
}
|
||||
};
|
||||
|
||||
const validateHashtagName = (channelName: string): string | null => {
|
||||
if (!channelName) {
|
||||
return 'Channel name is required';
|
||||
}
|
||||
if (!/^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$/.test(channelName)) {
|
||||
return 'Use letters, numbers, and single dashes (no leading/trailing dashes)';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleCreateAndAddAnother = async () => {
|
||||
setError('');
|
||||
const channelName = name.trim();
|
||||
@@ -115,7 +216,6 @@ export function NewMessageModal({
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Normalize to lowercase unless user explicitly permits capitals
|
||||
const normalizedName = permitCapitals ? channelName : channelName.toLowerCase();
|
||||
await onCreateHashtagChannel(`#${normalizedName}`, tryHistorical);
|
||||
setName('');
|
||||
@@ -142,28 +242,36 @@ export function NewMessageModal({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogContent className="sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Conversation</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{tab === 'new-contact' && 'Add a new contact by entering their name and public key'}
|
||||
{tab === 'new-channel' && 'Create a private channel with a shared encryption key'}
|
||||
{tab === 'hashtag' && 'Join a public hashtag channel'}
|
||||
{tab === 'bulk-hashtag' && 'Paste multiple hashtag rooms to add them in one batch'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(v) => {
|
||||
setTab(v as Tab);
|
||||
onValueChange={(value) => {
|
||||
setTab(value as Tab);
|
||||
resetForm();
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsList
|
||||
className={
|
||||
showBulkAddChannelTab ? 'grid w-full grid-cols-4' : 'grid w-full grid-cols-3'
|
||||
}
|
||||
>
|
||||
<TabsTrigger value="new-contact">Contact</TabsTrigger>
|
||||
<TabsTrigger value="new-channel">Private Channel</TabsTrigger>
|
||||
<TabsTrigger value="hashtag">Hashtag Channel</TabsTrigger>
|
||||
{showBulkAddChannelTab && (
|
||||
<TabsTrigger value="bulk-hashtag">Bulk Add Channel</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="new-contact" className="mt-4 space-y-4">
|
||||
@@ -215,7 +323,7 @@ export function NewMessageModal({
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
const hex = Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
setChannelKey(hex);
|
||||
}}
|
||||
@@ -244,20 +352,55 @@ export function NewMessageModal({
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 space-y-1">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<label className="flex cursor-pointer items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={permitCapitals}
|
||||
onChange={(e) => setPermitCapitals(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-input accent-primary"
|
||||
className="h-4 w-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">Permit capitals in channel key derivation</span>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground pl-7">
|
||||
<p className="pl-7 text-xs text-muted-foreground">
|
||||
Not recommended; most companions normalize to lowercase
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{showBulkAddChannelTab && (
|
||||
<TabsContent value="bulk-hashtag" className="mt-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bulk-hashtag-names">Bulk Add Channel</Label>
|
||||
<textarea
|
||||
ref={bulkTextareaRef}
|
||||
id="bulk-hashtag-names"
|
||||
aria-label="Bulk channel names"
|
||||
value={bulkChannelText}
|
||||
onChange={(e) => setBulkChannelText(e.target.value)}
|
||||
placeholder={'#ops\nmesh-room\nanother-room'}
|
||||
className="min-h-48 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Paste room names separated by lines, spaces, or commas. Leading # marks are
|
||||
stripped automatically.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="flex cursor-pointer items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={permitCapitals}
|
||||
onChange={(e) => setPermitCapitals(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">Permit capitals in channel key derivation</span>
|
||||
</label>
|
||||
<p className="pl-7 text-xs text-muted-foreground">
|
||||
Not recommended; most companions normalize to lowercase
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
{showHistoricalOption && (
|
||||
@@ -265,7 +408,7 @@ export function NewMessageModal({
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<Label
|
||||
htmlFor="try-historical"
|
||||
className="text-sm text-muted-foreground cursor-pointer"
|
||||
className="cursor-pointer text-sm text-muted-foreground"
|
||||
>
|
||||
Try decrypting {undecryptedCount.toLocaleString()} stored packet
|
||||
{undecryptedCount !== 1 ? 's' : ''}
|
||||
@@ -277,7 +420,7 @@ export function NewMessageModal({
|
||||
/>
|
||||
</div>
|
||||
{tryHistorical && (
|
||||
<p className="text-xs text-muted-foreground text-right">
|
||||
<p className="text-right text-xs text-muted-foreground">
|
||||
Messages will stream in as they decrypt in the background
|
||||
</p>
|
||||
)}
|
||||
@@ -306,7 +449,13 @@ export function NewMessageModal({
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleCreate} disabled={loading}>
|
||||
{loading ? 'Creating...' : 'Create'}
|
||||
{loading
|
||||
? tab === 'bulk-hashtag'
|
||||
? 'Adding...'
|
||||
: 'Creating...'
|
||||
: tab === 'bulk-hashtag'
|
||||
? 'Add Channels'
|
||||
: 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -81,13 +81,14 @@ export function PathModal({
|
||||
) : hasSinglePath ? (
|
||||
<>
|
||||
This shows <em>one route</em> that this message traveled through the mesh network.
|
||||
Repeaters may be incorrectly identified due to prefix collisions between heard and
|
||||
non-heard repeater advertisements.
|
||||
Repeater identities are inferred from locally known advert and path data, so some
|
||||
hops may be missing or misidentified when that data is incomplete.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
This message was received via <strong>{paths.length} different routes</strong>.
|
||||
Repeaters may be incorrectly identified due to prefix collisions.
|
||||
Repeater identities are inferred from locally known advert and path data, so some
|
||||
hops may be missing or misidentified when that data is incomplete.
|
||||
</>
|
||||
)}
|
||||
</DialogDescription>
|
||||
@@ -102,14 +103,25 @@ export function PathModal({
|
||||
) : null}
|
||||
|
||||
{/* Raw path summary */}
|
||||
<div className="text-sm">
|
||||
<div className="text-sm space-y-1">
|
||||
{paths.map((p, index) => {
|
||||
const hops = parsePathHops(p.path, p.path_len);
|
||||
const rawPath = hops.length > 0 ? hops.join('->') : 'direct';
|
||||
const hasSignal = p.rssi != null || p.snr != null;
|
||||
return (
|
||||
<div key={index}>
|
||||
<span className="text-foreground/70 font-semibold">Path {index + 1}:</span>{' '}
|
||||
<span className="font-mono text-muted-foreground">{rawPath}</span>
|
||||
<div>
|
||||
<span className="text-foreground/70 font-semibold">Path {index + 1}:</span>{' '}
|
||||
<span className="font-mono text-muted-foreground">{rawPath}</span>
|
||||
</div>
|
||||
{hasSignal && (
|
||||
<div className="text-[0.6875rem] text-muted-foreground ml-4">
|
||||
Last hop (as heard by you):{' '}
|
||||
{p.rssi != null && <span>{p.rssi} dBm RSSI</span>}
|
||||
{p.rssi != null && p.snr != null && <span> · </span>}
|
||||
{p.snr != null && <span>{p.snr.toFixed(1)} dB SNR</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -220,7 +232,7 @@ export function PathModal({
|
||||
>
|
||||
<span className="flex flex-col items-center leading-tight">
|
||||
<span>↻ Resend</span>
|
||||
<span className="text-[10px] font-normal opacity-80">
|
||||
<span className="text-[0.625rem] font-normal opacity-80">
|
||||
Only repeated by new repeaters
|
||||
</span>
|
||||
</span>
|
||||
@@ -236,7 +248,7 @@ export function PathModal({
|
||||
>
|
||||
<span className="flex flex-col items-center leading-tight">
|
||||
<span>↻ Resend as new</span>
|
||||
<span className="text-[10px] font-normal opacity-80">
|
||||
<span className="text-[0.625rem] font-normal opacity-80">
|
||||
Will appear as duplicate to receivers
|
||||
</span>
|
||||
</span>
|
||||
@@ -405,9 +417,12 @@ interface HopNodeProps {
|
||||
distanceUnit: DistanceUnit;
|
||||
}
|
||||
|
||||
const AMBIGUOUS_MATCH_PREVIEW_LIMIT = 3;
|
||||
|
||||
function HopNode({ hop, hopNumber, prevLocation, distanceUnit }: HopNodeProps) {
|
||||
const isAmbiguous = hop.matches.length > 1;
|
||||
const isUnknown = hop.matches.length === 0;
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// Calculate distance from previous location for a contact
|
||||
// Returns null if prev location unknown/ambiguous or contact has no valid location
|
||||
@@ -446,27 +461,38 @@ function HopNode({ hop, hopNumber, prevLocation, distanceUnit }: HopNodeProps) {
|
||||
<div className="font-medium text-muted-foreground"><UNKNOWN></div>
|
||||
) : isAmbiguous ? (
|
||||
<div>
|
||||
{hop.matches.map((contact) => {
|
||||
const dist = getDistanceForContact(contact);
|
||||
const hasLocation = isValidLocation(contact.lat, contact.lon);
|
||||
return (
|
||||
<div key={contact.public_key} className="font-medium truncate">
|
||||
{contact.name || contact.public_key.slice(0, 12)}
|
||||
{dist !== null && (
|
||||
<span className="text-xs text-muted-foreground ml-1">
|
||||
- {formatDistance(dist, distanceUnit)}
|
||||
</span>
|
||||
)}
|
||||
{hasLocation && (
|
||||
<CoordinateLink
|
||||
lat={contact.lat!}
|
||||
lon={contact.lon!}
|
||||
publicKey={contact.public_key}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(expanded ? hop.matches : hop.matches.slice(0, AMBIGUOUS_MATCH_PREVIEW_LIMIT)).map(
|
||||
(contact) => {
|
||||
const dist = getDistanceForContact(contact);
|
||||
const hasLocation = isValidLocation(contact.lat, contact.lon);
|
||||
return (
|
||||
<div key={contact.public_key} className="font-medium truncate">
|
||||
{contact.name || contact.public_key.slice(0, 12)}
|
||||
{dist !== null && (
|
||||
<span className="text-xs text-muted-foreground ml-1">
|
||||
- {formatDistance(dist, distanceUnit)}
|
||||
</span>
|
||||
)}
|
||||
{hasLocation && (
|
||||
<CoordinateLink
|
||||
lat={contact.lat!}
|
||||
lon={contact.lon!}
|
||||
publicKey={contact.public_key}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)}
|
||||
{!expanded && hop.matches.length > AMBIGUOUS_MATCH_PREVIEW_LIMIT && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-primary hover:underline cursor-pointer"
|
||||
onClick={() => setExpanded(true)}
|
||||
>
|
||||
(and {hop.matches.length - AMBIGUOUS_MATCH_PREVIEW_LIMIT} more)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="font-medium truncate">
|
||||
|
||||
@@ -35,6 +35,11 @@ type RawPacketInspectorDialogSource =
|
||||
message: string;
|
||||
};
|
||||
|
||||
interface SignalOverride {
|
||||
rssi: number | null;
|
||||
snr: number | null;
|
||||
}
|
||||
|
||||
interface RawPacketInspectorDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -43,10 +48,12 @@ interface RawPacketInspectorDialogProps {
|
||||
title: string;
|
||||
description: string;
|
||||
notice?: ReactNode;
|
||||
signalOverride?: SignalOverride;
|
||||
}
|
||||
|
||||
interface RawPacketInspectionPanelProps {
|
||||
packet: RawPacket;
|
||||
signalOverride?: SignalOverride;
|
||||
channels: Channel[];
|
||||
}
|
||||
|
||||
@@ -125,15 +132,21 @@ function formatTimestamp(timestamp: number): string {
|
||||
});
|
||||
}
|
||||
|
||||
function formatSignal(packet: RawPacket): string {
|
||||
const parts: string[] = [];
|
||||
if (packet.rssi !== null) {
|
||||
parts.push(`${packet.rssi} dBm RSSI`);
|
||||
}
|
||||
if (packet.snr !== null) {
|
||||
parts.push(`${packet.snr.toFixed(1)} dB SNR`);
|
||||
}
|
||||
return parts.length > 0 ? parts.join(' · ') : 'No signal sample';
|
||||
function formatSignal(
|
||||
packet: RawPacket,
|
||||
signalOverride?: SignalOverride
|
||||
): { lines: string[]; label: string } {
|
||||
const rssi = signalOverride?.rssi ?? packet.rssi;
|
||||
const snr = signalOverride?.snr ?? packet.snr;
|
||||
const lines: string[] = [];
|
||||
if (rssi !== null) lines.push(`${rssi} dBm RSSI`);
|
||||
if (snr !== null) lines.push(`${snr.toFixed(1)} dB SNR`);
|
||||
const isOverride =
|
||||
signalOverride != null && (signalOverride.rssi != null || signalOverride.snr != null);
|
||||
return {
|
||||
lines: lines.length > 0 ? lines : ['No signal sample'],
|
||||
label: isOverride ? 'Last Hop Signal' : 'Signal',
|
||||
};
|
||||
}
|
||||
|
||||
function formatByteRange(field: PacketByteField): string {
|
||||
@@ -312,7 +325,7 @@ function CompactMetaCard({
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border/70 bg-card/70 p-2.5">
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">{label}</div>
|
||||
<div className="text-[0.625rem] uppercase tracking-wider text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 text-sm font-medium leading-tight text-foreground">{primary}</div>
|
||||
{secondary ? (
|
||||
<div className="mt-1 text-xs leading-tight text-muted-foreground">{secondary}</div>
|
||||
@@ -340,7 +353,7 @@ function FullPacketHex({
|
||||
const byteRuns = useMemo(() => buildByteRuns(bytes, byteOwners), [byteOwners, bytes]);
|
||||
|
||||
return (
|
||||
<div className="font-mono text-[15px] leading-7 text-foreground">
|
||||
<div className="font-mono text-[0.9375rem] leading-7 text-foreground">
|
||||
{byteRuns.map((run, index) => {
|
||||
const fieldId = run.fieldId;
|
||||
const palette = fieldId ? colorMap.get(fieldId) : null;
|
||||
@@ -446,7 +459,9 @@ function FieldBox({
|
||||
<div className="flex flex-col items-start gap-2 sm:flex-row sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="text-base font-semibold leading-tight text-foreground">{field.name}</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted-foreground">{formatByteRange(field)}</div>
|
||||
<div className="mt-0.5 text-[0.6875rem] text-muted-foreground">
|
||||
{formatByteRange(field)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -464,7 +479,7 @@ function FieldBox({
|
||||
|
||||
{field.decryptedMessage ? (
|
||||
<div className="mt-2 rounded border border-border/50 bg-background/40 p-2">
|
||||
<div className="text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
|
||||
<div className="text-[0.625rem] uppercase tracking-wider text-muted-foreground">
|
||||
{field.name === 'Ciphertext' ? 'Plaintext' : 'Decoded value'}
|
||||
</div>
|
||||
<PlaintextContent text={field.decryptedMessage} />
|
||||
@@ -486,11 +501,13 @@ function FieldBox({
|
||||
<div className="text-sm font-medium leading-tight text-foreground">
|
||||
{part.field}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted-foreground">Bits {part.bits}</div>
|
||||
<div className="mt-0.5 text-[0.6875rem] text-muted-foreground">
|
||||
Bits {part.bits}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-mono text-sm text-foreground">{part.binary}</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted-foreground">{part.value}</div>
|
||||
<div className="mt-0.5 text-[0.6875rem] text-muted-foreground">{part.value}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -565,7 +582,11 @@ function FieldSection({
|
||||
);
|
||||
}
|
||||
|
||||
export function RawPacketInspectionPanel({ packet, channels }: RawPacketInspectionPanelProps) {
|
||||
export function RawPacketInspectionPanel({
|
||||
packet,
|
||||
channels,
|
||||
signalOverride,
|
||||
}: RawPacketInspectionPanelProps) {
|
||||
const decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]);
|
||||
const groupTextCandidates = useMemo(
|
||||
() => buildGroupTextResolutionCandidates(channels),
|
||||
@@ -598,7 +619,7 @@ export function RawPacketInspectionPanel({ packet, channels }: RawPacketInspecti
|
||||
<section className="rounded-lg border border-border/70 bg-card/70 p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
<div className="text-[0.625rem] uppercase tracking-wider text-muted-foreground">
|
||||
Summary
|
||||
</div>
|
||||
<div className="mt-1 text-base font-semibold leading-tight text-foreground">
|
||||
@@ -611,7 +632,7 @@ export function RawPacketInspectionPanel({ packet, channels }: RawPacketInspecti
|
||||
</div>
|
||||
{packetContext ? (
|
||||
<div className="mt-2 rounded-md border border-border/60 bg-background/35 px-2.5 py-2">
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
<div className="text-[0.625rem] uppercase tracking-wider text-muted-foreground">
|
||||
{packetContext.title}
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-medium leading-tight text-foreground">
|
||||
@@ -637,11 +658,24 @@ export function RawPacketInspectionPanel({ packet, channels }: RawPacketInspecti
|
||||
primary={`${inspection.routeTypeName} · ${inspection.payloadTypeName}`}
|
||||
secondary={`${inspection.payloadVersionName} · ${formatPathMode(inspection.decoded?.pathHashSize, inspection.pathTokens.length)}`}
|
||||
/>
|
||||
<CompactMetaCard
|
||||
label="Signal"
|
||||
primary={formatSignal(packet)}
|
||||
secondary={packetContext ? null : undefined}
|
||||
/>
|
||||
{(() => {
|
||||
const sig = formatSignal(packet, signalOverride);
|
||||
return (
|
||||
<div className="rounded-lg border border-border/70 bg-card/70 p-2.5">
|
||||
<div className="text-[0.625rem] uppercase tracking-wider text-muted-foreground">
|
||||
{sig.label}
|
||||
</div>
|
||||
{sig.lines.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`${i === 0 ? 'mt-1' : 'mt-0.5'} text-sm font-medium leading-tight text-foreground`}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -711,6 +745,7 @@ export function RawPacketInspectorDialog({
|
||||
title,
|
||||
description,
|
||||
notice,
|
||||
signalOverride,
|
||||
}: RawPacketInspectorDialogProps) {
|
||||
const [packetInput, setPacketInput] = useState('');
|
||||
|
||||
@@ -735,7 +770,13 @@ export function RawPacketInspectorDialog({
|
||||
|
||||
let body: ReactNode;
|
||||
if (source.kind === 'packet') {
|
||||
body = <RawPacketInspectionPanel packet={source.packet} channels={channels} />;
|
||||
body = (
|
||||
<RawPacketInspectionPanel
|
||||
packet={source.packet}
|
||||
channels={channels}
|
||||
signalOverride={signalOverride}
|
||||
/>
|
||||
);
|
||||
} else if (source.kind === 'paste') {
|
||||
body = (
|
||||
<>
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip as RechartsTooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
} from 'recharts';
|
||||
|
||||
import { MeshCoreDecoder, Utils } from '@michaelhart/meshcore-decoder';
|
||||
|
||||
import { RawPacketList } from './RawPacketList';
|
||||
import { RawPacketInspectorDialog } from './RawPacketDetailModal';
|
||||
import { Button } from './ui/button';
|
||||
import type { Channel, Contact, RawPacket } from '../types';
|
||||
import {
|
||||
KNOWN_PAYLOAD_TYPES,
|
||||
RAW_PACKET_STATS_WINDOWS,
|
||||
buildRawPacketStatsSnapshot,
|
||||
type NeighborStat,
|
||||
@@ -14,9 +27,26 @@ import {
|
||||
type RawPacketStatsSessionState,
|
||||
type RawPacketStatsWindow,
|
||||
} from '../utils/rawPacketStats';
|
||||
import { createDecoderOptions } from '../utils/rawPacketInspector';
|
||||
import { getContactDisplayName } from '../utils/pubkey';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const KNOWN_PAYLOAD_TYPE_SET = new Set<string>(KNOWN_PAYLOAD_TYPES);
|
||||
|
||||
function getPacketTypeName(
|
||||
packet: RawPacket,
|
||||
decoderOptions?: ReturnType<typeof createDecoderOptions>
|
||||
): string {
|
||||
try {
|
||||
const decoded = MeshCoreDecoder.decode(packet.data, decoderOptions);
|
||||
if (!decoded.isValid) return 'Unknown';
|
||||
const name = Utils.getPayloadTypeName(decoded.payloadType);
|
||||
return KNOWN_PAYLOAD_TYPE_SET.has(name) ? name : 'Unknown';
|
||||
} catch {
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
interface RawPacketFeedViewProps {
|
||||
packets: RawPacket[];
|
||||
rawPacketStatsSession: RawPacketStatsSessionState;
|
||||
@@ -24,6 +54,18 @@ interface RawPacketFeedViewProps {
|
||||
channels: Channel[];
|
||||
}
|
||||
|
||||
const TOOLTIP_STYLE = {
|
||||
contentStyle: {
|
||||
backgroundColor: 'hsl(var(--popover))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
fontSize: '11px',
|
||||
color: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
itemStyle: { color: 'hsl(var(--popover-foreground))' },
|
||||
labelStyle: { color: 'hsl(var(--muted-foreground))' },
|
||||
} as const;
|
||||
|
||||
const WINDOW_LABELS: Record<RawPacketStatsWindow, string> = {
|
||||
'1m': '1 min',
|
||||
'5m': '5 min',
|
||||
@@ -32,13 +74,7 @@ const WINDOW_LABELS: Record<RawPacketStatsWindow, string> = {
|
||||
session: 'Session',
|
||||
};
|
||||
|
||||
const TIMELINE_COLORS = [
|
||||
'bg-sky-500/80',
|
||||
'bg-emerald-500/80',
|
||||
'bg-amber-500/80',
|
||||
'bg-rose-500/80',
|
||||
'bg-violet-500/80',
|
||||
];
|
||||
const TIMELINE_FILL_COLORS = ['#0ea5e9', '#10b981', '#f59e0b', '#f43f5e', '#8b5cf6'];
|
||||
|
||||
function formatTimestamp(timestampMs: number): string {
|
||||
return new Date(timestampMs).toLocaleString([], {
|
||||
@@ -155,24 +191,17 @@ function isNeighborIdentityResolvable(item: NeighborStat, contacts: Contact[]):
|
||||
return resolveContact(item.key, contacts) !== null;
|
||||
}
|
||||
|
||||
function formatStrongestPacketDetail(
|
||||
function formatStrongestNeighborDetail(
|
||||
stats: ReturnType<typeof buildRawPacketStatsSnapshot>,
|
||||
contacts: Contact[]
|
||||
): string | undefined {
|
||||
if (!stats.strongestPacketPayloadType) {
|
||||
const strongestNeighbor = stats.strongestNeighbors[0];
|
||||
if (!strongestNeighbor || strongestNeighbor.bestRssi === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const resolvedLabel =
|
||||
resolveContactLabel(stats.strongestPacketSourceKey, contacts) ??
|
||||
stats.strongestPacketSourceLabel;
|
||||
if (resolvedLabel) {
|
||||
return `${resolvedLabel} · ${stats.strongestPacketPayloadType}`;
|
||||
}
|
||||
if (stats.strongestPacketPayloadType === 'GroupText') {
|
||||
return '<unknown sender> · GroupText';
|
||||
}
|
||||
return stats.strongestPacketPayloadType;
|
||||
const resolvedNeighbor = resolveNeighbor(strongestNeighbor, contacts);
|
||||
return `${formatRssi(resolvedNeighbor.bestRssi)} best heard`;
|
||||
}
|
||||
|
||||
function getCoverageMessage(
|
||||
@@ -202,7 +231,9 @@ function getCoverageMessage(
|
||||
function StatTile({ label, value, detail }: { label: string; value: string; detail?: string }) {
|
||||
return (
|
||||
<div className="break-inside-avoid rounded-lg border border-border/70 bg-card/80 p-3">
|
||||
<div className="text-[11px] uppercase tracking-wide text-muted-foreground">{label}</div>
|
||||
<div className="text-[0.625rem] uppercase tracking-wider font-medium text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-1 text-xl font-semibold tabular-nums text-foreground">{value}</div>
|
||||
{detail ? <div className="mt-1 text-xs text-muted-foreground">{detail}</div> : null}
|
||||
</div>
|
||||
@@ -220,7 +251,13 @@ function RankedBars({
|
||||
emptyLabel: string;
|
||||
formatter?: (item: RankedPacketStat) => string;
|
||||
}) {
|
||||
const maxCount = Math.max(...items.map((item) => item.count), 1);
|
||||
const data = items.map((item) => ({
|
||||
name: item.label,
|
||||
value: item.count,
|
||||
detail: formatter
|
||||
? formatter(item)
|
||||
: `${item.count.toLocaleString()} · ${formatPercent(item.share)}`,
|
||||
}));
|
||||
|
||||
return (
|
||||
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
|
||||
@@ -228,25 +265,36 @@ function RankedBars({
|
||||
{items.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-muted-foreground">{emptyLabel}</p>
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{items.map((item) => (
|
||||
<div key={item.label}>
|
||||
<div className="mb-1 flex items-center justify-between gap-3 text-xs">
|
||||
<span className="truncate text-foreground">{item.label}</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||
{formatter
|
||||
? formatter(item)
|
||||
: `${item.count.toLocaleString()} · ${formatPercent(item.share)}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary/80"
|
||||
style={{ width: `${(item.count / maxCount) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-2">
|
||||
<ResponsiveContainer width="100%" height={items.length * 28 + 8}>
|
||||
<BarChart
|
||||
data={data}
|
||||
layout="vertical"
|
||||
margin={{ top: 0, right: 4, bottom: 0, left: 0 }}
|
||||
barCategoryGap="20%"
|
||||
>
|
||||
<XAxis type="number" hide />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={80}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
{...TOOLTIP_STYLE}
|
||||
cursor={{ fill: 'hsl(var(--muted))', opacity: 0.5 }}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
formatter={(_v: any, _n: any, props: any) => [props.payload.detail, null]}
|
||||
/>
|
||||
<Bar dataKey="value" radius={[0, 4, 4, 0]} maxBarSize={16}>
|
||||
{data.map((_, i) => (
|
||||
<Cell key={i} fill={TIMELINE_FILL_COLORS[i % TIMELINE_FILL_COLORS.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@@ -303,7 +351,7 @@ function NeighborList({
|
||||
: `Last seen ${new Date(item.lastSeen * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`}
|
||||
</div>
|
||||
{!isNeighborIdentityResolvable(item, contacts) ? (
|
||||
<div className="text-[11px] text-warning">Identity not resolvable</div>
|
||||
<div className="text-[0.6875rem] text-warning">Identity not resolvable</div>
|
||||
) : null}
|
||||
</div>
|
||||
{mode !== 'signal' ? (
|
||||
@@ -320,53 +368,66 @@ function NeighborList({
|
||||
}
|
||||
|
||||
function TimelineChart({ bins }: { bins: PacketTimelineBin[] }) {
|
||||
const maxTotal = Math.max(...bins.map((bin) => bin.total), 1);
|
||||
const typeOrder = Array.from(new Set(bins.flatMap((bin) => Object.keys(bin.countsByType)))).slice(
|
||||
0,
|
||||
TIMELINE_COLORS.length
|
||||
TIMELINE_FILL_COLORS.length
|
||||
);
|
||||
|
||||
const data = bins.map((bin) => {
|
||||
const entry: Record<string, string | number> = { label: bin.label };
|
||||
for (const type of typeOrder) {
|
||||
entry[type] = bin.countsByType[type] ?? 0;
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold text-foreground">Traffic Timeline</h3>
|
||||
<div className="flex flex-wrap justify-end gap-2 text-[11px] text-muted-foreground">
|
||||
{typeOrder.map((type, index) => (
|
||||
<div className="flex flex-wrap justify-end gap-2 text-[0.6875rem] text-muted-foreground">
|
||||
{typeOrder.map((type, i) => (
|
||||
<span key={type} className="inline-flex items-center gap-1">
|
||||
<span className={cn('h-2 w-2 rounded-full', TIMELINE_COLORS[index])} />
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: TIMELINE_FILL_COLORS[i] }}
|
||||
/>
|
||||
<span>{type}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-start gap-1">
|
||||
{bins.map((bin, index) => (
|
||||
<div
|
||||
key={`${bin.label}-${index}`}
|
||||
className="flex min-w-0 flex-1 flex-col items-center gap-1"
|
||||
>
|
||||
<div className="flex h-24 w-full items-end overflow-hidden rounded-sm bg-muted/60">
|
||||
<div className="flex h-full w-full flex-col justify-end">
|
||||
{typeOrder.map((type, index) => {
|
||||
const count = bin.countsByType[type] ?? 0;
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<div
|
||||
key={type}
|
||||
className={cn('w-full', TIMELINE_COLORS[index])}
|
||||
style={{
|
||||
height: `${(count / maxTotal) * 100}%`,
|
||||
}}
|
||||
title={`${bin.label}: ${type} ${count.toLocaleString()}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">{bin.label}</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-2">
|
||||
<ResponsiveContainer width="100%" height={110}>
|
||||
<BarChart data={data} margin={{ top: 4, right: 0, bottom: 0, left: -24 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
{...TOOLTIP_STYLE}
|
||||
cursor={{ fill: 'hsl(var(--muted))', opacity: 0.5 }}
|
||||
/>
|
||||
{typeOrder.map((type, i) => (
|
||||
<Bar
|
||||
key={type}
|
||||
dataKey={type}
|
||||
stackId="packets"
|
||||
fill={TIMELINE_FILL_COLORS[i]}
|
||||
radius={i === typeOrder.length - 1 ? [2, 2, 0, 0] : undefined}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -387,6 +448,48 @@ export function RawPacketFeedView({
|
||||
const [nowSec, setNowSec] = useState(() => Math.floor(Date.now() / 1000));
|
||||
const [selectedPacket, setSelectedPacket] = useState<RawPacket | null>(null);
|
||||
const [analyzeModalOpen, setAnalyzeModalOpen] = useState(false);
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
|
||||
const [enabledTypes, setEnabledTypes] = useState<Set<string>>(() => new Set(KNOWN_PAYLOAD_TYPES));
|
||||
|
||||
const decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]);
|
||||
|
||||
const packetsWithTypes = useMemo(
|
||||
() =>
|
||||
packets.map((packet) => ({
|
||||
packet,
|
||||
payloadType: getPacketTypeName(packet, decoderOptions),
|
||||
})),
|
||||
[packets, decoderOptions]
|
||||
);
|
||||
|
||||
const allTypesEnabled = enabledTypes.size === KNOWN_PAYLOAD_TYPES.length;
|
||||
|
||||
const filteredPackets = useMemo(() => {
|
||||
if (allTypesEnabled) return packets;
|
||||
return packetsWithTypes
|
||||
.filter(({ payloadType }) => enabledTypes.has(payloadType))
|
||||
.map(({ packet }) => packet);
|
||||
}, [packetsWithTypes, enabledTypes, packets, allTypesEnabled]);
|
||||
|
||||
const handleToggleAll = () => {
|
||||
setEnabledTypes(allTypesEnabled ? new Set() : new Set(KNOWN_PAYLOAD_TYPES));
|
||||
};
|
||||
|
||||
const handleToggleType = (type: string) => {
|
||||
setEnabledTypes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(type)) {
|
||||
next.delete(type);
|
||||
} else {
|
||||
next.add(type);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleOnly = (type: string) => {
|
||||
setEnabledTypes(new Set([type]));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const interval = window.setInterval(() => {
|
||||
@@ -404,8 +507,13 @@ export function RawPacketFeedView({
|
||||
[nowSec, rawPacketStatsSession, selectedWindow]
|
||||
);
|
||||
const coverageMessage = getCoverageMessage(stats, rawPacketStatsSession);
|
||||
const strongestPacketDetail = useMemo(
|
||||
() => formatStrongestPacketDetail(stats, contacts),
|
||||
const strongestNeighbor = useMemo(() => {
|
||||
const topNeighbor = stats.strongestNeighbors[0];
|
||||
return topNeighbor ? resolveNeighbor(topNeighbor, contacts) : null;
|
||||
}, [contacts, stats]);
|
||||
|
||||
const strongestNeighborDetail = useMemo(
|
||||
() => formatStrongestNeighborDetail(stats, contacts),
|
||||
[contacts, stats]
|
||||
);
|
||||
const strongestNeighbors = useMemo(
|
||||
@@ -422,38 +530,129 @@ export function RawPacketFeedView({
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-border px-4 py-2.5">
|
||||
<div>
|
||||
<h2 className="font-semibold text-base text-foreground">Raw Packet Feed</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Collecting stats since {formatTimestamp(rawPacketStatsSession.sessionStartedAt)}
|
||||
</p>
|
||||
<div className="border-b border-border px-4 py-2.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold text-base text-foreground">Raw Packet Feed</h2>
|
||||
<p className="hidden md:block text-xs text-muted-foreground">
|
||||
Collecting stats since {formatTimestamp(rawPacketStatsSession.sessionStartedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAnalyzeModalOpen(true)}
|
||||
>
|
||||
Analyze Packet
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setStatsOpen((current) => !current)}
|
||||
aria-expanded={statsOpen}
|
||||
>
|
||||
{statsOpen ? (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
)}
|
||||
{statsOpen ? 'Hide Stats' : 'Show Stats'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAnalyzeModalOpen(true)}
|
||||
>
|
||||
Analyze Packet
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setStatsOpen((current) => !current)}
|
||||
aria-expanded={statsOpen}
|
||||
>
|
||||
{statsOpen ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
|
||||
{statsOpen ? 'Hide Stats' : 'Show Stats'}
|
||||
</Button>
|
||||
<p className="md:hidden text-xs text-muted-foreground">
|
||||
Collecting stats since {formatTimestamp(rawPacketStatsSession.sessionStartedAt)}
|
||||
{!mobileFiltersOpen && (
|
||||
<>
|
||||
{' · '}
|
||||
<button
|
||||
type="button"
|
||||
className="text-primary hover:text-primary/80 transition-colors"
|
||||
onClick={() => setMobileFiltersOpen(true)}
|
||||
>
|
||||
Show Filters
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{mobileFiltersOpen && (
|
||||
<div className="mt-1.5 md:hidden flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<label className="flex items-center gap-1 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allTypesEnabled}
|
||||
onChange={handleToggleAll}
|
||||
className="rounded"
|
||||
/>
|
||||
All
|
||||
</label>
|
||||
{KNOWN_PAYLOAD_TYPES.map((type) => (
|
||||
<span key={type} className="inline-flex items-center gap-1 text-xs">
|
||||
<label className="flex items-center gap-1 text-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabledTypes.has(type)}
|
||||
onChange={() => handleToggleType(type)}
|
||||
className="rounded"
|
||||
/>
|
||||
{type}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[0.625rem] text-muted-foreground hover:text-primary transition-colors"
|
||||
onClick={() => handleOnly(type)}
|
||||
>
|
||||
(only)
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-1.5 hidden md:flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<label className="flex items-center gap-1 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allTypesEnabled}
|
||||
onChange={handleToggleAll}
|
||||
className="rounded"
|
||||
/>
|
||||
All
|
||||
</label>
|
||||
{KNOWN_PAYLOAD_TYPES.map((type) => (
|
||||
<span key={type} className="inline-flex items-center gap-1 text-xs">
|
||||
<label className="flex items-center gap-1 text-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabledTypes.has(type)}
|
||||
onChange={() => handleToggleType(type)}
|
||||
className="rounded"
|
||||
/>
|
||||
{type}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[0.625rem] text-muted-foreground hover:text-primary transition-colors"
|
||||
onClick={() => handleOnly(type)}
|
||||
>
|
||||
(only)
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
|
||||
<div className={cn('min-h-0 min-w-0 flex-1', statsOpen && 'md:border-r md:border-border')}>
|
||||
<RawPacketList packets={packets} channels={channels} onPacketClick={setSelectedPacket} />
|
||||
<RawPacketList
|
||||
packets={filteredPackets}
|
||||
channels={channels}
|
||||
onPacketClick={setSelectedPacket}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<aside
|
||||
@@ -469,7 +668,7 @@ export function RawPacketFeedView({
|
||||
<div className="break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||
<div className="text-[0.625rem] uppercase tracking-wider font-medium text-muted-foreground">
|
||||
Coverage
|
||||
</div>
|
||||
<div
|
||||
@@ -532,9 +731,9 @@ export function RawPacketFeedView({
|
||||
detail={`${formatPercent(stats.pathBearingRate)} path-bearing packets`}
|
||||
/>
|
||||
<StatTile
|
||||
label="Best RSSI"
|
||||
value={formatRssi(stats.bestRssi)}
|
||||
detail={strongestPacketDetail ?? 'No signal sample in window'}
|
||||
label="Strongest Neighbor"
|
||||
value={strongestNeighbor?.label ?? '-'}
|
||||
detail={strongestNeighborDetail ?? 'No neighbor RSSI sample in window'}
|
||||
/>
|
||||
<StatTile
|
||||
label="Median RSSI"
|
||||
|
||||
@@ -101,7 +101,7 @@ export function RawPacketList({ packets, channels, onPacketClick }: RawPacketLis
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Route type badge */}
|
||||
<span
|
||||
className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${getRouteTypeColor(decoded.routeType)}`}
|
||||
className={`text-[0.625rem] font-mono px-1.5 py-0.5 rounded ${getRouteTypeColor(decoded.routeType)}`}
|
||||
title={decoded.routeType}
|
||||
>
|
||||
{getRouteTypeLabel(decoded.routeType)}
|
||||
@@ -117,26 +117,29 @@ export function RawPacketList({ packets, channels, onPacketClick }: RawPacketLis
|
||||
|
||||
{/* Summary */}
|
||||
<span
|
||||
className={cn('text-[13px]', packet.decrypted ? 'text-primary' : 'text-foreground')}
|
||||
className={cn(
|
||||
'text-[0.8125rem]',
|
||||
packet.decrypted ? 'text-primary' : 'text-foreground'
|
||||
)}
|
||||
>
|
||||
{decoded.summary}
|
||||
</span>
|
||||
|
||||
{/* Time */}
|
||||
<span className="text-muted-foreground ml-auto text-[12px] tabular-nums">
|
||||
<span className="text-muted-foreground ml-auto text-xs tabular-nums">
|
||||
{formatTime(packet.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Signal info */}
|
||||
{(packet.snr !== null || packet.rssi !== null) && (
|
||||
<div className="text-[11px] text-muted-foreground mt-0.5 tabular-nums">
|
||||
<div className="text-[0.6875rem] text-muted-foreground mt-0.5 tabular-nums">
|
||||
{formatSignalInfo(packet)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Raw hex data (always visible) */}
|
||||
<div className="font-mono text-[10px] break-all text-muted-foreground mt-1.5 p-1.5 bg-background/60 rounded">
|
||||
<div className="font-mono text-[0.625rem] break-all text-muted-foreground mt-1.5 p-1.5 bg-background/60 rounded">
|
||||
{packet.data.toUpperCase()}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { api } from '../api';
|
||||
import { toast } from './ui/sonner';
|
||||
import { Button } from './ui/button';
|
||||
import { Bell, Route, Star, Trash2 } from 'lucide-react';
|
||||
import { Bell, Info, Route, Star, Trash2 } from 'lucide-react';
|
||||
import { DirectTraceIcon } from './DirectTraceIcon';
|
||||
import { RepeaterLogin } from './RepeaterLogin';
|
||||
import { ServerLoginStatusBanner } from './ServerLoginStatusBanner';
|
||||
import { useRememberedServerPassword } from '../hooks/useRememberedServerPassword';
|
||||
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { isValidLocation } from '../utils/pathUtils';
|
||||
import { ContactStatusInfo } from './ContactStatusInfo';
|
||||
import type { Contact, Conversation, Favorite, PathDiscoveryResponse } from '../types';
|
||||
import type { Contact, Conversation, PathDiscoveryResponse, TelemetryHistoryEntry } from '../types';
|
||||
import { cn } from '../lib/utils';
|
||||
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
|
||||
import { NeighborsPane } from './repeater/RepeaterNeighborsPane';
|
||||
@@ -23,6 +23,7 @@ import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane';
|
||||
import { OwnerInfoPane } from './repeater/RepeaterOwnerInfoPane';
|
||||
import { ActionsPane } from './repeater/RepeaterActionsPane';
|
||||
import { ConsolePane } from './repeater/RepeaterConsolePane';
|
||||
import { TelemetryHistoryPane } from './repeater/RepeaterTelemetryHistoryPane';
|
||||
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
|
||||
|
||||
// Re-export for backwards compatibility (used by repeaterFormatters.test.ts)
|
||||
@@ -33,7 +34,6 @@ export { formatDuration, formatClockDrift } from './repeater/repeaterPaneShared'
|
||||
interface RepeaterDashboardProps {
|
||||
conversation: Conversation;
|
||||
contacts: Contact[];
|
||||
favorites: Favorite[];
|
||||
notificationsSupported: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
notificationsPermission: NotificationPermission | 'unsupported';
|
||||
@@ -45,12 +45,16 @@ interface RepeaterDashboardProps {
|
||||
onToggleNotifications: () => void;
|
||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
||||
onDeleteContact: (publicKey: string) => void;
|
||||
onOpenContactInfo?: (publicKey: string) => void;
|
||||
trackedTelemetryRepeaters: string[];
|
||||
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
|
||||
autoLoginAndLoadAll?: boolean;
|
||||
onAutoLoginConsumed?: () => void;
|
||||
}
|
||||
|
||||
export function RepeaterDashboard({
|
||||
conversation,
|
||||
contacts,
|
||||
favorites,
|
||||
notificationsSupported,
|
||||
notificationsEnabled,
|
||||
notificationsPermission,
|
||||
@@ -62,6 +66,11 @@ export function RepeaterDashboard({
|
||||
onToggleNotifications,
|
||||
onToggleFavorite,
|
||||
onDeleteContact,
|
||||
onOpenContactInfo,
|
||||
trackedTelemetryRepeaters,
|
||||
onToggleTrackedTelemetry,
|
||||
autoLoginAndLoadAll,
|
||||
onAutoLoginConsumed,
|
||||
}: RepeaterDashboardProps) {
|
||||
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
|
||||
const contact = contacts.find((c) => c.public_key === conversation.id) ?? null;
|
||||
@@ -88,7 +97,49 @@ export function RepeaterDashboard({
|
||||
const { password, setPassword, rememberPassword, setRememberPassword, persistAfterLogin } =
|
||||
useRememberedServerPassword('repeater', conversation.id);
|
||||
|
||||
const isFav = isFavorite(favorites, 'contact', conversation.id);
|
||||
// Telemetry history: preload from stored data, refresh from live status
|
||||
const [telemetryHistory, setTelemetryHistory] = useState<TelemetryHistoryEntry[]>([]);
|
||||
const telemetryHistorySourceRef = useRef<'none' | 'preload' | 'live'>('none');
|
||||
const telemetryHistoryRequestRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
telemetryHistoryRequestRef.current += 1;
|
||||
telemetryHistorySourceRef.current = 'none';
|
||||
setTelemetryHistory([]);
|
||||
|
||||
if (!loggedIn) return;
|
||||
|
||||
const requestId = telemetryHistoryRequestRef.current;
|
||||
api
|
||||
.repeaterTelemetryHistory(conversation.id)
|
||||
.then((history) => {
|
||||
if (telemetryHistoryRequestRef.current !== requestId) return;
|
||||
if (telemetryHistorySourceRef.current === 'live') return;
|
||||
telemetryHistorySourceRef.current = 'preload';
|
||||
setTelemetryHistory(history);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [loggedIn, conversation.id]);
|
||||
|
||||
// When a live status fetch returns embedded telemetry_history, replace local state
|
||||
useEffect(() => {
|
||||
const liveHistory = paneData.status?.telemetry_history;
|
||||
if (!liveHistory) return;
|
||||
telemetryHistorySourceRef.current = 'live';
|
||||
setTelemetryHistory(liveHistory);
|
||||
}, [paneData.status?.telemetry_history]);
|
||||
|
||||
// Command palette "ACL login + load all" auto-action
|
||||
const autoLoginConsumedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!autoLoginAndLoadAll || autoLoginConsumedRef.current) return;
|
||||
autoLoginConsumedRef.current = true;
|
||||
onAutoLoginConsumed?.();
|
||||
void loginAsGuest().then(() => loadAll());
|
||||
}, [autoLoginAndLoadAll, onAutoLoginConsumed, loginAsGuest, loadAll]);
|
||||
|
||||
const isFav = contact?.favorite ?? false;
|
||||
|
||||
const handleRepeaterLogin = async (nextPassword: string) => {
|
||||
await login(nextPassword);
|
||||
persistAfterLogin(nextPassword);
|
||||
@@ -115,11 +166,26 @@ export function RepeaterDashboard({
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<span className="flex min-w-0 flex-1 items-baseline gap-2">
|
||||
<span className="min-w-0 flex-shrink truncate font-semibold text-base">
|
||||
{conversation.name}
|
||||
</span>
|
||||
<h2 className="min-w-0 flex-shrink font-semibold text-base">
|
||||
{onOpenContactInfo ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex max-w-full min-w-0 items-center gap-1.5 overflow-hidden rounded-sm text-left transition-colors hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={`View info for ${conversation.name}`}
|
||||
onClick={() => onOpenContactInfo(conversation.id)}
|
||||
>
|
||||
<span className="truncate">{conversation.name}</span>
|
||||
<Info
|
||||
className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span className="truncate">{conversation.name}</span>
|
||||
)}
|
||||
</h2>
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary"
|
||||
className="min-w-0 flex-1 truncate font-mono text-[0.6875rem] text-muted-foreground transition-colors hover:text-primary"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
@@ -135,7 +201,7 @@ export function RepeaterDashboard({
|
||||
</span>
|
||||
</span>
|
||||
{contact && (
|
||||
<div className="col-span-2 row-start-2 min-w-0 text-[11px] text-muted-foreground min-[1100px]:col-span-1 min-[1100px]:col-start-2 min-[1100px]:row-start-1">
|
||||
<div className="col-span-2 row-start-2 min-w-0 text-[0.6875rem] text-muted-foreground min-[1100px]:col-span-1 min-[1100px]:col-start-2 min-[1100px]:row-start-1">
|
||||
<ContactStatusInfo contact={contact} ourLat={radioLat} ourLon={radioLon} />
|
||||
</div>
|
||||
)}
|
||||
@@ -146,7 +212,7 @@ export function RepeaterDashboard({
|
||||
size="sm"
|
||||
onClick={loadAll}
|
||||
disabled={anyLoading}
|
||||
className="h-7 px-2 text-[11px] leading-none border-success text-success hover:bg-success/10 hover:text-success sm:h-8 sm:px-3 sm:text-xs"
|
||||
className="h-7 px-2 text-[0.6875rem] leading-none border-success text-success hover:bg-success/10 hover:text-success sm:h-8 sm:px-3 sm:text-xs"
|
||||
>
|
||||
{anyLoading ? 'Loading...' : 'Load All'}
|
||||
</Button>
|
||||
@@ -192,7 +258,7 @@ export function RepeaterDashboard({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{notificationsEnabled && (
|
||||
<span className="hidden md:inline text-[11px] font-medium text-status-connected">
|
||||
<span className="hidden md:inline text-[0.6875rem] font-medium text-status-connected">
|
||||
Notifications On
|
||||
</span>
|
||||
)}
|
||||
@@ -336,6 +402,15 @@ export function RepeaterDashboard({
|
||||
loading={consoleLoading}
|
||||
onSend={sendConsoleCommand}
|
||||
/>
|
||||
|
||||
{/* Telemetry history chart — full width, below console */}
|
||||
<TelemetryHistoryPane
|
||||
entries={telemetryHistory}
|
||||
publicKey={conversation.id}
|
||||
contacts={contacts}
|
||||
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
|
||||
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, type FormEvent } from 'react';
|
||||
import { Input } from './ui/input';
|
||||
import { Button } from './ui/button';
|
||||
import { Checkbox } from './ui/checkbox';
|
||||
import { shouldAutoFocusInput } from '../utils/autoFocusInput';
|
||||
|
||||
interface RepeaterLoginProps {
|
||||
repeaterName: string;
|
||||
@@ -64,7 +65,7 @@ export function RepeaterLogin({
|
||||
placeholder={passwordPlaceholder}
|
||||
aria-label="Repeater password"
|
||||
disabled={loading}
|
||||
autoFocus
|
||||
autoFocus={shouldAutoFocusInput()}
|
||||
/>
|
||||
|
||||
<label
|
||||
|
||||
@@ -174,7 +174,11 @@ export function SearchView({
|
||||
api
|
||||
.getMessages({ q: debouncedQuery, limit: SEARCH_PAGE_SIZE, offset }, controller.signal)
|
||||
.then((data) => {
|
||||
setResults((prev) => [...prev, ...(data as SearchResult[])]);
|
||||
setResults((prev) => {
|
||||
const existingIds = new Set(prev.map((r) => r.id));
|
||||
const unique = (data as SearchResult[]).filter((r) => !existingIds.has(r.id));
|
||||
return [...prev, ...unique];
|
||||
});
|
||||
setHasMore(data.length >= SEARCH_PAGE_SIZE);
|
||||
setOffset((prev) => prev + data.length);
|
||||
})
|
||||
@@ -286,7 +290,7 @@ export function SearchView({
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-medium px-1.5 py-0.5 rounded',
|
||||
'text-[0.625rem] font-medium px-1.5 py-0.5 rounded',
|
||||
result.type === 'CHAN'
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-secondary text-secondary-foreground'
|
||||
@@ -294,12 +298,12 @@ export function SearchView({
|
||||
>
|
||||
{typeBadge}
|
||||
</span>
|
||||
<span className="text-[12px] font-medium text-foreground truncate">{convName}</span>
|
||||
<span className="text-[11px] text-muted-foreground ml-auto flex-shrink-0">
|
||||
<span className="text-xs font-medium text-foreground truncate">{convName}</span>
|
||||
<span className="text-[0.6875rem] text-muted-foreground ml-auto flex-shrink-0">
|
||||
{formatTime(result.received_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[13px] text-foreground/80 line-clamp-2 break-words">
|
||||
<div className="text-[0.8125rem] text-foreground/80 line-clamp-2 break-words">
|
||||
{result.sender_name && !result.outgoing && (
|
||||
<span className="text-muted-foreground">{result.sender_name}: </span>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, type ReactNode } from 'react';
|
||||
import type {
|
||||
AppSettings,
|
||||
AppSettingsUpdate,
|
||||
Contact,
|
||||
HealthStatus,
|
||||
RadioAdvertMode,
|
||||
RadioConfig,
|
||||
@@ -47,6 +48,10 @@ interface SettingsModalBaseProps {
|
||||
blockedNames?: string[];
|
||||
onToggleBlockedKey?: (key: string) => void;
|
||||
onToggleBlockedName?: (name: string) => void;
|
||||
contacts?: Contact[];
|
||||
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
|
||||
trackedTelemetryRepeaters?: string[];
|
||||
onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export type SettingsModalProps = SettingsModalBaseProps &
|
||||
@@ -80,6 +85,10 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
blockedNames,
|
||||
onToggleBlockedKey,
|
||||
onToggleBlockedName,
|
||||
contacts,
|
||||
onBulkDeleteContacts,
|
||||
trackedTelemetryRepeaters,
|
||||
onToggleTrackedTelemetry,
|
||||
} = props;
|
||||
const externalSidebarNav = props.externalSidebarNav === true;
|
||||
const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined;
|
||||
@@ -239,6 +248,10 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
blockedNames={blockedNames}
|
||||
onToggleBlockedKey={onToggleBlockedKey}
|
||||
onToggleBlockedName={onToggleBlockedName}
|
||||
contacts={contacts}
|
||||
onBulkDeleteContacts={onBulkDeleteContacts}
|
||||
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
|
||||
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
|
||||
className={sectionContentClass}
|
||||
/>
|
||||
) : (
|
||||
|
||||
+105
-128
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Bell,
|
||||
Cable,
|
||||
ChartNetwork,
|
||||
CheckCheck,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
@@ -9,7 +11,6 @@ import {
|
||||
Map,
|
||||
Search as SearchIcon,
|
||||
SquarePen,
|
||||
Waypoints,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
@@ -18,7 +19,6 @@ import {
|
||||
type Contact,
|
||||
type Channel,
|
||||
type Conversation,
|
||||
type Favorite,
|
||||
} from '../types';
|
||||
import {
|
||||
buildSidebarSectionSortOrders,
|
||||
@@ -35,7 +35,6 @@ import { isPublicChannelKey } from '../utils/publicChannel';
|
||||
import { getContactDisplayName } from '../utils/pubkey';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { ContactAvatar } from './ContactAvatar';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
import { Input } from './ui/input';
|
||||
import { Button } from './ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -96,7 +95,7 @@ interface SidebarProps {
|
||||
channels: Channel[];
|
||||
activeConversation: Conversation | null;
|
||||
onSelectConversation: (conversation: Conversation) => void;
|
||||
onNewMessage: () => void;
|
||||
onNewMessage: (event?: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
lastMessageTimes: ConversationTimes;
|
||||
unreadCounts: Record<string, number>;
|
||||
/** Tracks which conversations have unread messages that mention the user */
|
||||
@@ -105,35 +104,19 @@ interface SidebarProps {
|
||||
crackerRunning: boolean;
|
||||
onToggleCracker: () => void;
|
||||
onMarkAllRead: () => void;
|
||||
favorites: Favorite[];
|
||||
/** Legacy global sort order, used only to seed per-section local preferences. */
|
||||
legacySortOrder?: SortOrder;
|
||||
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
|
||||
blockedKeys?: string[];
|
||||
blockedNames?: string[];
|
||||
}
|
||||
|
||||
type InitialSectionSortState = {
|
||||
orders: SidebarSectionSortOrders;
|
||||
source: 'section' | 'legacy' | 'none';
|
||||
};
|
||||
|
||||
function loadInitialSectionSortOrders(): InitialSectionSortState {
|
||||
function loadInitialSectionSortOrders(): SidebarSectionSortOrders {
|
||||
const storedOrders = loadLocalStorageSidebarSectionSortOrders();
|
||||
if (storedOrders) {
|
||||
return { orders: storedOrders, source: 'section' };
|
||||
}
|
||||
if (storedOrders) return storedOrders;
|
||||
|
||||
const legacyOrder = loadLegacyLocalStorageSortOrder();
|
||||
if (legacyOrder) {
|
||||
return {
|
||||
orders: buildSidebarSectionSortOrders(legacyOrder),
|
||||
source: 'legacy',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
orders: buildSidebarSectionSortOrders(),
|
||||
source: 'none',
|
||||
};
|
||||
const orders = buildSidebarSectionSortOrders(legacyOrder ?? undefined);
|
||||
saveLocalStorageSidebarSectionSortOrders(orders);
|
||||
return orders;
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
@@ -149,13 +132,20 @@ export function Sidebar({
|
||||
crackerRunning,
|
||||
onToggleCracker,
|
||||
onMarkAllRead,
|
||||
favorites,
|
||||
legacySortOrder,
|
||||
isConversationNotificationsEnabled,
|
||||
blockedKeys = [],
|
||||
blockedNames = [],
|
||||
}: SidebarProps) {
|
||||
const isContactBlocked = useCallback(
|
||||
(c: Contact) =>
|
||||
blockedKeys.includes(c.public_key.toLowerCase()) ||
|
||||
(c.name != null && blockedNames.includes(c.name)),
|
||||
[blockedKeys, blockedNames]
|
||||
);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const initialSectionSortState = useMemo(loadInitialSectionSortOrders, []);
|
||||
const [sectionSortOrders, setSectionSortOrders] = useState(initialSectionSortState.orders);
|
||||
const initialSectionSortOrders = useMemo(loadInitialSectionSortOrders, []);
|
||||
const [sectionSortOrders, setSectionSortOrders] = useState(initialSectionSortOrders);
|
||||
const initialCollapsedState = useMemo(loadCollapsedState, []);
|
||||
const [toolsCollapsed, setToolsCollapsed] = useState(initialCollapsedState.tools);
|
||||
const [favoritesCollapsed, setFavoritesCollapsed] = useState(initialCollapsedState.favorites);
|
||||
@@ -164,29 +154,12 @@ export function Sidebar({
|
||||
const [roomsCollapsed, setRoomsCollapsed] = useState(initialCollapsedState.rooms);
|
||||
const [repeatersCollapsed, setRepeatersCollapsed] = useState(initialCollapsedState.repeaters);
|
||||
const collapseSnapshotRef = useRef<CollapseState | null>(null);
|
||||
const sectionSortSourceRef = useRef(initialSectionSortState.source);
|
||||
|
||||
useEffect(() => {
|
||||
if (sectionSortSourceRef.current === 'legacy') {
|
||||
saveLocalStorageSidebarSectionSortOrders(sectionSortOrders);
|
||||
sectionSortSourceRef.current = 'section';
|
||||
return;
|
||||
}
|
||||
|
||||
if (sectionSortSourceRef.current !== 'none' || legacySortOrder === undefined) return;
|
||||
|
||||
const seededOrders = buildSidebarSectionSortOrders(legacySortOrder);
|
||||
setSectionSortOrders(seededOrders);
|
||||
saveLocalStorageSidebarSectionSortOrders(seededOrders);
|
||||
sectionSortSourceRef.current = 'section';
|
||||
}, [legacySortOrder, sectionSortOrders]);
|
||||
|
||||
const handleSortToggle = (section: SidebarSortableSection) => {
|
||||
setSectionSortOrders((prev) => {
|
||||
const nextOrder = prev[section] === 'alpha' ? 'recent' : 'alpha';
|
||||
const updated = { ...prev, [section]: nextOrder };
|
||||
saveLocalStorageSidebarSectionSortOrders(updated);
|
||||
sectionSortSourceRef.current = 'section';
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
@@ -197,7 +170,7 @@ export function Sidebar({
|
||||
};
|
||||
|
||||
const isActive = (
|
||||
type: 'contact' | 'channel' | 'raw' | 'map' | 'visualizer' | 'search',
|
||||
type: 'contact' | 'channel' | 'raw' | 'map' | 'visualizer' | 'search' | 'trace',
|
||||
id: string
|
||||
) => activeConversation?.type === type && activeConversation?.id === id;
|
||||
|
||||
@@ -397,38 +370,32 @@ export function Sidebar({
|
||||
[sortedChannels, query]
|
||||
);
|
||||
|
||||
const filteredNonRepeaterContacts = useMemo(
|
||||
() =>
|
||||
query
|
||||
? sortedNonRepeaterContacts.filter(
|
||||
(c) =>
|
||||
c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
||||
)
|
||||
: sortedNonRepeaterContacts,
|
||||
[sortedNonRepeaterContacts, query]
|
||||
);
|
||||
const filteredNonRepeaterContacts = useMemo(() => {
|
||||
const visible = sortedNonRepeaterContacts.filter((c) => !isContactBlocked(c));
|
||||
return query
|
||||
? visible.filter(
|
||||
(c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
||||
)
|
||||
: visible;
|
||||
}, [sortedNonRepeaterContacts, query, isContactBlocked]);
|
||||
|
||||
const filteredRooms = useMemo(
|
||||
() =>
|
||||
query
|
||||
? sortedRooms.filter(
|
||||
(c) =>
|
||||
c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
||||
)
|
||||
: sortedRooms,
|
||||
[sortedRooms, query]
|
||||
);
|
||||
const filteredRooms = useMemo(() => {
|
||||
const visible = sortedRooms.filter((c) => !isContactBlocked(c));
|
||||
return query
|
||||
? visible.filter(
|
||||
(c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
||||
)
|
||||
: visible;
|
||||
}, [sortedRooms, query, isContactBlocked]);
|
||||
|
||||
const filteredRepeaters = useMemo(
|
||||
() =>
|
||||
query
|
||||
? sortedRepeaters.filter(
|
||||
(c) =>
|
||||
c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
||||
)
|
||||
: sortedRepeaters,
|
||||
[sortedRepeaters, query]
|
||||
);
|
||||
const filteredRepeaters = useMemo(() => {
|
||||
const visible = sortedRepeaters.filter((c) => !isContactBlocked(c));
|
||||
return query
|
||||
? visible.filter(
|
||||
(c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
||||
)
|
||||
: visible;
|
||||
}, [sortedRepeaters, query, isContactBlocked]);
|
||||
|
||||
// Expand sections while searching; restore prior collapse state when search ends.
|
||||
useEffect(() => {
|
||||
@@ -517,22 +484,16 @@ export function Sidebar({
|
||||
nonFavoriteRooms,
|
||||
nonFavoriteRepeaters,
|
||||
} = useMemo(() => {
|
||||
const favChannels = filteredChannels.filter((c) => isFavorite(favorites, 'channel', c.key));
|
||||
const favChannels = filteredChannels.filter((c) => c.favorite);
|
||||
const favContacts = [
|
||||
...filteredNonRepeaterContacts,
|
||||
...filteredRooms,
|
||||
...filteredRepeaters,
|
||||
].filter((c) => isFavorite(favorites, 'contact', c.public_key));
|
||||
const nonFavChannels = filteredChannels.filter((c) => !isFavorite(favorites, 'channel', c.key));
|
||||
const nonFavContacts = filteredNonRepeaterContacts.filter(
|
||||
(c) => !isFavorite(favorites, 'contact', c.public_key)
|
||||
);
|
||||
const nonFavRooms = filteredRooms.filter(
|
||||
(c) => !isFavorite(favorites, 'contact', c.public_key)
|
||||
);
|
||||
const nonFavRepeaters = filteredRepeaters.filter(
|
||||
(c) => !isFavorite(favorites, 'contact', c.public_key)
|
||||
);
|
||||
].filter((c) => c.favorite);
|
||||
const nonFavChannels = filteredChannels.filter((c) => !c.favorite);
|
||||
const nonFavContacts = filteredNonRepeaterContacts.filter((c) => !c.favorite);
|
||||
const nonFavRooms = filteredRooms.filter((c) => !c.favorite);
|
||||
const nonFavRepeaters = filteredRepeaters.filter((c) => !c.favorite);
|
||||
|
||||
const items: FavoriteItem[] = [
|
||||
...favChannels.map((channel) => ({ type: 'channel' as const, channel })),
|
||||
@@ -551,7 +512,6 @@ export function Sidebar({
|
||||
filteredNonRepeaterContacts,
|
||||
filteredRooms,
|
||||
filteredRepeaters,
|
||||
favorites,
|
||||
sectionSortOrders.favorites,
|
||||
sortFavoriteItemsByOrder,
|
||||
]);
|
||||
@@ -613,7 +573,7 @@ export function Sidebar({
|
||||
contactType={row.contact.type}
|
||||
/>
|
||||
)}
|
||||
<span className="name flex-1 truncate text-[13px]">{row.name}</span>
|
||||
<span className="name flex-1 truncate text-[0.8125rem]">{row.name}</span>
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
{row.notificationsEnabled && (
|
||||
<span aria-label="Notifications enabled" title="Notifications enabled">
|
||||
@@ -623,7 +583,7 @@ export function Sidebar({
|
||||
{row.unreadCount > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
|
||||
'text-[0.625rem] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
|
||||
highlightUnread
|
||||
? 'bg-badge-mention text-badge-mention-foreground'
|
||||
: 'bg-badge-unread/90 text-badge-unread-foreground'
|
||||
@@ -653,8 +613,9 @@ export function Sidebar({
|
||||
}) => (
|
||||
<div
|
||||
key={key}
|
||||
data-active={active ? 'true' : undefined}
|
||||
className={cn(
|
||||
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
'sidebar-action-row px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[0.8125rem] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
active && 'bg-accent border-l-primary'
|
||||
)}
|
||||
role="button"
|
||||
@@ -663,10 +624,10 @@ export function Sidebar({
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="sidebar-tool-icon text-muted-foreground" aria-hidden="true">
|
||||
<span className="sidebar-tool-icon" aria-hidden="true">
|
||||
{icon}
|
||||
</span>
|
||||
<span className="sidebar-tool-label flex-1 truncate text-muted-foreground">{label}</span>
|
||||
<span className="sidebar-tool-label flex-1 truncate">{label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -721,7 +682,7 @@ export function Sidebar({
|
||||
renderSidebarActionRow({
|
||||
key: 'tool-visualizer',
|
||||
active: isActive('visualizer', 'visualizer'),
|
||||
icon: <Waypoints className="h-4 w-4" />,
|
||||
icon: <ChartNetwork className="h-4 w-4" />,
|
||||
label: 'Mesh Visualizer',
|
||||
onClick: () =>
|
||||
handleSelectConversation({
|
||||
@@ -730,6 +691,18 @@ export function Sidebar({
|
||||
name: 'Mesh Visualizer',
|
||||
}),
|
||||
}),
|
||||
renderSidebarActionRow({
|
||||
key: 'tool-trace',
|
||||
active: isActive('trace', 'trace'),
|
||||
icon: <Cable className="h-4 w-4" />,
|
||||
label: 'Trace',
|
||||
onClick: () =>
|
||||
handleSelectConversation({
|
||||
type: 'trace',
|
||||
id: 'trace',
|
||||
name: 'Trace',
|
||||
}),
|
||||
}),
|
||||
renderSidebarActionRow({
|
||||
key: 'tool-search',
|
||||
active: isActive('search', 'search'),
|
||||
@@ -751,7 +724,7 @@ export function Sidebar({
|
||||
{showCracker ? 'Hide' : 'Show'} Channel Finder
|
||||
<span
|
||||
className={cn(
|
||||
'ml-1 text-[11px]',
|
||||
'ml-1 text-[0.6875rem]',
|
||||
crackerRunning ? 'text-primary' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
@@ -779,7 +752,7 @@ export function Sidebar({
|
||||
<div className="flex justify-between items-center px-3 py-2 pt-3.5">
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded',
|
||||
'flex items-center gap-1.5 text-[0.625rem] uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded',
|
||||
isSearching && 'cursor-default'
|
||||
)}
|
||||
aria-expanded={!effectiveCollapsed}
|
||||
@@ -799,7 +772,7 @@ export function Sidebar({
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
{sortSection && sectionSortOrder && (
|
||||
<button
|
||||
className="bg-transparent text-muted-foreground/60 px-1 py-0.5 text-[10px] rounded hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="bg-transparent text-muted-foreground/60 px-1 py-0.5 text-[0.625rem] rounded hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => handleSortToggle(sortSection)}
|
||||
aria-label={
|
||||
sectionSortOrder === 'alpha'
|
||||
@@ -818,7 +791,7 @@ export function Sidebar({
|
||||
{unreadCount > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-medium px-1.5 py-0.5 rounded-full',
|
||||
'text-[0.625rem] font-medium px-1.5 py-0.5 rounded-full',
|
||||
highlightUnread
|
||||
? 'bg-badge-mention text-badge-mention-foreground'
|
||||
: 'bg-secondary text-muted-foreground'
|
||||
@@ -840,41 +813,45 @@ export function Sidebar({
|
||||
aria-label="Conversations"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search channels/contacts..."
|
||||
aria-label="Search conversations"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className={cn('h-7 text-[13px] bg-background/50', searchQuery ? 'pr-8' : 'pr-3')}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded"
|
||||
onClick={() => setSearchQuery('')}
|
||||
title="Clear search"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-3 py-2 border-b border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onNewMessage}
|
||||
title="New Message"
|
||||
aria-label="New message"
|
||||
className="h-7 w-7 shrink-0 p-0 text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Add channel or contact"
|
||||
aria-label="Add channel or contact"
|
||||
className="h-8 w-full justify-start gap-2 border-primary/20 bg-primary/5 px-3 text-[0.8125rem] text-primary hover:bg-primary/10 hover:text-primary"
|
||||
>
|
||||
<SquarePen className="h-4 w-4" />
|
||||
<span>Add Channel/Contact</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto [contain:layout_paint]">
|
||||
<div className="px-3 py-2 border-b border-border/60">
|
||||
<div className="relative min-w-0">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search channels/contacts..."
|
||||
aria-label="Search conversations"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className={cn('h-7 text-[0.8125rem] bg-background/50', searchQuery ? 'pr-8' : 'pr-3')}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded"
|
||||
onClick={() => setSearchQuery('')}
|
||||
title="Clear search"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tools */}
|
||||
{toolRows.length > 0 && (
|
||||
<>
|
||||
@@ -886,7 +863,7 @@ export function Sidebar({
|
||||
{/* Mark All Read */}
|
||||
{!query && Object.values(unreadCounts).some((c) => c > 0) && (
|
||||
<div
|
||||
className="px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[0.8125rem] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Menu, Moon, Sun } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
BatteryFull,
|
||||
BatteryLow,
|
||||
BatteryMedium,
|
||||
BatteryWarning,
|
||||
Menu,
|
||||
Moon,
|
||||
Sun,
|
||||
} from 'lucide-react';
|
||||
import type { HealthStatus, RadioConfig } from '../types';
|
||||
import { api } from '../api';
|
||||
import { toast } from './ui/sonner';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { applyTheme, getSavedTheme, THEME_CHANGE_EVENT } from '../utils/theme';
|
||||
import {
|
||||
BATTERY_DISPLAY_CHANGE_EVENT,
|
||||
getShowBatteryPercent,
|
||||
getShowBatteryVoltage,
|
||||
mvToPercent,
|
||||
} from '../utils/batteryDisplay';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface StatusBarProps {
|
||||
@@ -22,6 +36,35 @@ export function StatusBar({
|
||||
onSettingsClick,
|
||||
onMenuClick,
|
||||
}: StatusBarProps) {
|
||||
const [showBatteryPercent, setShowBatteryPercent] = useState(getShowBatteryPercent);
|
||||
const [showBatteryVoltage, setShowBatteryVoltage] = useState(getShowBatteryVoltage);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
setShowBatteryPercent(getShowBatteryPercent());
|
||||
setShowBatteryVoltage(getShowBatteryVoltage());
|
||||
};
|
||||
window.addEventListener(BATTERY_DISPLAY_CHANGE_EVENT, handler);
|
||||
return () => window.removeEventListener(BATTERY_DISPLAY_CHANGE_EVENT, handler);
|
||||
}, []);
|
||||
|
||||
const batteryMv = health?.radio_stats?.battery_mv;
|
||||
const batteryInfo = useMemo(() => {
|
||||
if ((!showBatteryPercent && !showBatteryVoltage) || !batteryMv || batteryMv <= 0) return null;
|
||||
const pct = mvToPercent(batteryMv);
|
||||
const Icon =
|
||||
pct >= 80 ? BatteryFull : pct >= 40 ? BatteryMedium : pct >= 15 ? BatteryLow : BatteryWarning;
|
||||
const color =
|
||||
pct >= 40 ? 'text-status-connected' : pct >= 15 ? 'text-warning' : 'text-destructive';
|
||||
const label =
|
||||
showBatteryPercent && showBatteryVoltage
|
||||
? `${pct}% (${batteryMv}mV)`
|
||||
: showBatteryPercent
|
||||
? `${pct}%`
|
||||
: `${batteryMv}mV`;
|
||||
return { pct, Icon, color, label, mv: batteryMv };
|
||||
}, [batteryMv, showBatteryPercent, showBatteryVoltage]);
|
||||
|
||||
const radioState =
|
||||
health?.radio_state ??
|
||||
(health?.radio_initializing
|
||||
@@ -119,11 +162,23 @@ export function StatusBar({
|
||||
<span className="hidden lg:inline text-muted-foreground">{statusLabel}</span>
|
||||
</div>
|
||||
|
||||
{connected && batteryInfo && (
|
||||
<div
|
||||
className={cn('flex items-center gap-1', batteryInfo.color)}
|
||||
title={`Battery: ${batteryInfo.pct}% (${(batteryInfo.mv / 1000).toFixed(2)}V)`}
|
||||
role="status"
|
||||
aria-label={`Battery ${batteryInfo.pct} percent`}
|
||||
>
|
||||
<batteryInfo.Icon className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="hidden sm:inline text-[0.6875rem]">{batteryInfo.label}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config && (
|
||||
<div className="hidden lg:flex items-center gap-2 text-muted-foreground">
|
||||
<span className="text-foreground font-medium">{config.name || 'Unnamed'}</span>
|
||||
<span
|
||||
className="font-mono text-[11px] text-muted-foreground cursor-pointer hover:text-primary transition-colors"
|
||||
className="font-mono text-[0.6875rem] text-muted-foreground cursor-pointer hover:text-primary transition-colors"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
|
||||
@@ -0,0 +1,870 @@
|
||||
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, formatDistance, isValidLocation } from '../utils/pathUtils';
|
||||
import { useDistanceUnit } from '../contexts/DistanceUnitContext';
|
||||
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;
|
||||
|
||||
const RECENT_TRACES_KEY = 'remoteterm-recent-traces';
|
||||
const MAX_RECENT_TRACES = 5;
|
||||
|
||||
interface SavedTraceHop {
|
||||
kind: 'repeater' | 'custom';
|
||||
publicKey?: string;
|
||||
hopHex?: string;
|
||||
hopBytes?: CustomHopBytes;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
interface SavedTrace {
|
||||
hops: SavedTraceHop[];
|
||||
ranAt: number;
|
||||
}
|
||||
|
||||
function loadRecentTraces(): SavedTrace[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(RECENT_TRACES_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.slice(0, MAX_RECENT_TRACES) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveRecentTrace(trace: SavedTrace): void {
|
||||
try {
|
||||
const existing = loadRecentTraces();
|
||||
// Dedupe by hop signature
|
||||
const sig = trace.hops.map((h) => h.publicKey ?? h.hopHex ?? '').join(',');
|
||||
const deduped = existing.filter(
|
||||
(t) => t.hops.map((h) => h.publicKey ?? h.hopHex ?? '').join(',') !== sig
|
||||
);
|
||||
const updated = [trace, ...deduped].slice(0, MAX_RECENT_TRACES);
|
||||
localStorage.setItem(RECENT_TRACES_KEY, JSON.stringify(updated));
|
||||
} catch {
|
||||
// localStorage may be disabled
|
||||
}
|
||||
}
|
||||
|
||||
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-[0.6875rem] 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-[0.6875rem] text-muted-foreground">{meta}</div> : null}
|
||||
{note ? <div className="mt-1 text-[0.6875rem] text-muted-foreground">{note}</div> : null}
|
||||
</div>
|
||||
{snr ? (
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="text-[0.6875rem] 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 { distanceUnit } = useDistanceUnit();
|
||||
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 [recentTraces, setRecentTraces] = useState<SavedTrace[]>(loadRecentTraces);
|
||||
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 handleLoadRecentTrace = async (trace: SavedTrace) => {
|
||||
const hops: TraceDraftHop[] = trace.hops.map((h, i) => {
|
||||
if (h.kind === 'repeater' && h.publicKey) {
|
||||
return {
|
||||
id: nextDraftHopId('repeater', i),
|
||||
kind: 'repeater' as const,
|
||||
publicKey: h.publicKey,
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: nextDraftHopId('custom', i),
|
||||
kind: 'custom' as const,
|
||||
hopHex: h.hopHex ?? '',
|
||||
hopBytes: h.hopBytes ?? (1 as CustomHopBytes),
|
||||
};
|
||||
});
|
||||
setDraftHops(hops);
|
||||
|
||||
// Determine hop hash bytes from the loaded hops
|
||||
const customHop = hops.find((h) => h.kind === 'custom');
|
||||
const hopHashBytes: CustomHopBytes = customHop?.hopBytes ?? 4;
|
||||
|
||||
// Run the trace immediately
|
||||
const runToken = activeRunTokenRef.current + 1;
|
||||
activeRunTokenRef.current = runToken;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
const traceResult = await onRunTracePath(
|
||||
hopHashBytes,
|
||||
hops.map((hop) =>
|
||||
hop.kind === 'repeater' ? { public_key: hop.publicKey } : { hop_hex: hop.hopHex }
|
||||
)
|
||||
);
|
||||
if (activeRunTokenRef.current !== runToken) return;
|
||||
setResult(traceResult);
|
||||
|
||||
// Re-save to bump this trace to the top of recents
|
||||
const savedTrace: SavedTrace = { hops: trace.hops, ranAt: Date.now() };
|
||||
saveRecentTrace(savedTrace);
|
||||
setRecentTraces(loadRecentTraces());
|
||||
} catch (err) {
|
||||
if (activeRunTokenRef.current !== runToken) return;
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
if (activeRunTokenRef.current === runToken) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
// Persist to recent traces
|
||||
const savedHops: SavedTraceHop[] = draftHops.map((hop) => {
|
||||
if (hop.kind === 'repeater') {
|
||||
const c = repeatersByKey.get(hop.publicKey);
|
||||
return {
|
||||
kind: 'repeater',
|
||||
publicKey: hop.publicKey,
|
||||
displayName: getContactDisplayName(c?.name, hop.publicKey, c?.last_advert ?? null),
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: 'custom',
|
||||
hopHex: hop.hopHex,
|
||||
hopBytes: hop.hopBytes,
|
||||
displayName: `${hop.hopHex.toUpperCase()} (${hop.hopBytes}B)`,
|
||||
};
|
||||
});
|
||||
const trace: SavedTrace = { hops: savedHops, ranAt: Date.now() };
|
||||
saveRecentTrace(trace);
|
||||
setRecentTraces(loadRecentTraces());
|
||||
} 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-[0.6875rem] 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-[0.6875rem] text-muted-foreground">
|
||||
{formatDistance(distanceKm, distanceUnit)} away
|
||||
</div>
|
||||
) : null}
|
||||
{selectedCount > 0 ? (
|
||||
<div className="mt-1 text-[0.6875rem] 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>
|
||||
{recentTraces.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium mb-1">
|
||||
Rerun a recent trace:
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{recentTraces.map((trace, i) => {
|
||||
const label = trace.hops
|
||||
.map((h) => {
|
||||
if (h.kind === 'repeater' && h.publicKey) {
|
||||
const shortKey = h.publicKey.slice(0, 12);
|
||||
return h.displayName !== shortKey
|
||||
? `${h.displayName} (${shortKey})`
|
||||
: shortKey;
|
||||
}
|
||||
return h.displayName;
|
||||
})
|
||||
.join(' → ');
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className="rounded-md border border-border px-2.5 py-1.5 text-xs hover:bg-accent transition-colors truncate max-w-full disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={loading}
|
||||
onClick={() => handleLoadRecentTrace(trace)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,8 @@ export function ConsolePane({
|
||||
}) {
|
||||
const [input, setInput] = useState('');
|
||||
const outputRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const prevLoadingRef = useRef(loading);
|
||||
|
||||
// Auto-scroll to bottom on new entries
|
||||
useEffect(() => {
|
||||
@@ -21,6 +23,14 @@ export function ConsolePane({
|
||||
}
|
||||
}, [history]);
|
||||
|
||||
// Refocus input after command completes
|
||||
useEffect(() => {
|
||||
if (prevLoadingRef.current && !loading) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
prevLoadingRef.current = loading;
|
||||
}, [loading]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -59,6 +69,7 @@ export function ConsolePane({
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="flex gap-2 p-2 border-t border-border">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
name="console-input"
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip as RechartsTooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '../ui/button';
|
||||
import { Separator } from '../ui/separator';
|
||||
import type { TelemetryHistoryEntry, Contact } from '../../types';
|
||||
|
||||
const MAX_TRACKED = 8;
|
||||
|
||||
type Metric = 'battery_volts' | 'noise_floor_dbm' | 'packets' | 'uptime_seconds';
|
||||
|
||||
const METRIC_CONFIG: Record<Metric, { label: string; unit: string; color: string }> = {
|
||||
battery_volts: { label: 'Voltage', unit: 'V', color: '#22c55e' },
|
||||
noise_floor_dbm: { label: 'Noise Floor', unit: 'dBm', color: '#8b5cf6' },
|
||||
packets: { label: 'Packets', unit: '', color: '#0ea5e9' },
|
||||
uptime_seconds: { label: 'Uptime', unit: 's', color: '#f59e0b' },
|
||||
};
|
||||
|
||||
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).toLocaleString([], {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
|
||||
if (seconds < 86400) return `${(seconds / 3600).toFixed(1)}h`;
|
||||
return `${(seconds / 86400).toFixed(1)}d`;
|
||||
}
|
||||
|
||||
interface TelemetryHistoryPaneProps {
|
||||
entries: TelemetryHistoryEntry[];
|
||||
publicKey: string;
|
||||
contacts: Contact[];
|
||||
trackedTelemetryRepeaters: string[];
|
||||
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function TelemetryHistoryPane({
|
||||
entries,
|
||||
publicKey,
|
||||
contacts,
|
||||
trackedTelemetryRepeaters,
|
||||
onToggleTrackedTelemetry,
|
||||
}: TelemetryHistoryPaneProps) {
|
||||
const [metric, setMetric] = useState<Metric>('battery_volts');
|
||||
const [toggling, setToggling] = useState(false);
|
||||
|
||||
const isTracked = trackedTelemetryRepeaters.includes(publicKey);
|
||||
const slotsFull = trackedTelemetryRepeaters.length >= MAX_TRACKED && !isTracked;
|
||||
|
||||
const config = METRIC_CONFIG[metric];
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
return entries.map((e) => {
|
||||
const d = e.data;
|
||||
return {
|
||||
timestamp: e.timestamp,
|
||||
battery_volts: d.battery_volts,
|
||||
noise_floor_dbm: d.noise_floor_dbm,
|
||||
packets_received: d.packets_received,
|
||||
packets_sent: d.packets_sent,
|
||||
uptime_seconds: d.uptime_seconds,
|
||||
};
|
||||
});
|
||||
}, [entries]);
|
||||
|
||||
const dataKeys = metric === 'packets' ? ['packets_received', 'packets_sent'] : [metric];
|
||||
|
||||
const yDomain = useMemo<[number, number] | undefined>(() => {
|
||||
if (metric !== 'battery_volts' || chartData.length === 0) return undefined;
|
||||
const values = chartData.map((d) => d.battery_volts).filter((v) => v != null) as number[];
|
||||
if (values.length === 0) return [3, 5];
|
||||
const lo = Math.min(...values);
|
||||
const hi = Math.max(...values);
|
||||
return [Math.min(3, Math.floor(lo) - 1), Math.max(5, Math.ceil(hi) + 1)];
|
||||
}, [metric, chartData]);
|
||||
|
||||
const handleToggle = async () => {
|
||||
setToggling(true);
|
||||
try {
|
||||
await onToggleTrackedTelemetry(publicKey);
|
||||
} finally {
|
||||
setToggling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const trackedNames = useMemo(() => {
|
||||
if (!slotsFull) return [];
|
||||
return trackedTelemetryRepeaters.map((key) => {
|
||||
const contact = contacts.find((c) => c.public_key === key);
|
||||
return { key, name: contact?.name ?? key.slice(0, 12) };
|
||||
});
|
||||
}, [slotsFull, trackedTelemetryRepeaters, contacts]);
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium">Telemetry History</h3>
|
||||
{entries.length > 0 && (
|
||||
<span className="text-[0.625rem] text-muted-foreground">{entries.length} samples</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
{/* Explanation + tracking toggle */}
|
||||
<div className="mb-3 space-y-3">
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Any time repeater telemetry is fetched, the metrics are stored for 30 days (or 1,000
|
||||
samples, whichever comes first). This telemetry is stored on normal interactive fetches
|
||||
via the repeater pane, API calls to the endpoint (
|
||||
<code className="text-[0.6875rem]">POST /api/contacts/<key>/repeater/status</code>
|
||||
), or when the repeater is opted into interval telemetry polling, in which case the
|
||||
repeater will be polled for metrics every 8 hours. You can see which repeaters are opted
|
||||
into this flow in the{' '}
|
||||
<a
|
||||
href="#settings/database"
|
||||
className="underline text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
Database & Messaging
|
||||
</a>{' '}
|
||||
settings pane. A maximum of {MAX_TRACKED} repeaters may be opted into this for the sake
|
||||
of keeping mesh congestion reasonable.
|
||||
</p>
|
||||
|
||||
{isTracked ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleToggle}
|
||||
disabled={toggling}
|
||||
className="border-destructive/50 text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
{toggling ? 'Updating...' : 'Remove Repeater from Interval Metrics Tracking'}
|
||||
</Button>
|
||||
) : slotsFull ? (
|
||||
<div className="space-y-2">
|
||||
<Button variant="outline" disabled>
|
||||
Tracking Full ({trackedTelemetryRepeaters.length}/{MAX_TRACKED} slots used)
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Disable tracking on another repeater to free a slot:{' '}
|
||||
{trackedNames.map((t) => t.name).join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleToggle}
|
||||
disabled={toggling}
|
||||
className="border-green-600/50 text-green-600 hover:bg-green-600/10"
|
||||
>
|
||||
{toggling ? 'Updating...' : 'Opt Repeater into 8hr Interval Metrics Tracking'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator className="mb-3" />
|
||||
|
||||
{/* Metric selector */}
|
||||
<div className="flex gap-1 mb-2">
|
||||
{(Object.keys(METRIC_CONFIG) as Metric[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setMetric(m)}
|
||||
className={cn(
|
||||
'text-[0.6875rem] px-2 py-0.5 rounded transition-colors',
|
||||
metric === m
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
{METRIC_CONFIG[m].label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No history yet. Fetch status above to record data points.
|
||||
</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<AreaChart data={chartData} margin={{ top: 4, right: 4, bottom: 0, left: -8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
type="number"
|
||||
domain={['dataMin', 'dataMax']}
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={formatTime}
|
||||
/>
|
||||
<YAxis
|
||||
domain={yDomain}
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(v) => (metric === 'uptime_seconds' ? formatUptime(v) : `${v}`)}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
{...TOOLTIP_STYLE}
|
||||
cursor={{
|
||||
stroke: 'hsl(var(--muted-foreground))',
|
||||
strokeWidth: 1,
|
||||
strokeDasharray: '3 3',
|
||||
}}
|
||||
labelFormatter={(ts) => formatTime(Number(ts))}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
formatter={(value: any, name: any) => {
|
||||
const numVal = typeof value === 'number' ? value : Number(value);
|
||||
const display = metric === 'uptime_seconds' ? formatUptime(numVal) : `${value}`;
|
||||
const suffix =
|
||||
metric === 'uptime_seconds' ? '' : config.unit ? ` ${config.unit}` : '';
|
||||
const label =
|
||||
metric === 'packets'
|
||||
? name === 'packets_received'
|
||||
? 'Received'
|
||||
: 'Sent'
|
||||
: config.label;
|
||||
return [`${display}${suffix}`, label];
|
||||
}}
|
||||
/>
|
||||
{dataKeys.map((key, i) => (
|
||||
<Area
|
||||
key={key}
|
||||
type="linear"
|
||||
dataKey={key}
|
||||
stroke={metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color}
|
||||
fill={metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color}
|
||||
fillOpacity={0.15}
|
||||
strokeWidth={1.5}
|
||||
dot={{
|
||||
r: 4,
|
||||
fill: metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color,
|
||||
strokeWidth: 1.5,
|
||||
stroke: 'hsl(var(--popover))',
|
||||
}}
|
||||
activeDot={{
|
||||
r: 6,
|
||||
fill: metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color,
|
||||
strokeWidth: 2,
|
||||
stroke: 'hsl(var(--popover))',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -141,10 +141,10 @@ export function RepeaterPane({
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-medium">{title}</h3>
|
||||
{headerNote && <p className="text-[11px] text-muted-foreground">{headerNote}</p>}
|
||||
{headerNote && <p className="text-[0.6875rem] text-muted-foreground">{headerNote}</p>}
|
||||
{fetchedAt && (
|
||||
<p
|
||||
className="text-[11px] text-muted-foreground"
|
||||
className="text-[0.6875rem] text-muted-foreground"
|
||||
title={new Date(fetchedAt).toLocaleString()}
|
||||
>
|
||||
Fetched {formatFetchedTime(fetchedAt)} ({formatFetchedRelative(fetchedAt)})
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { api } from '../../api';
|
||||
import { getContactDisplayName } from '../../utils/pubkey';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '../ui/dialog';
|
||||
import { toast } from '../ui/sonner';
|
||||
import type { Contact } from '../../types';
|
||||
|
||||
const CONTACT_TYPE_LABELS: Record<number, string> = {
|
||||
0: 'Unknown',
|
||||
1: 'Client',
|
||||
2: 'Repeater',
|
||||
3: 'Room',
|
||||
4: 'Sensor',
|
||||
};
|
||||
|
||||
function formatDate(ts: number): string {
|
||||
return new Date(ts * 1000).toLocaleDateString([], {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatDateISO(ts: number): string {
|
||||
return new Date(ts * 1000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function datetimeToUnix(datetimeStr: string): number {
|
||||
const d = new Date(datetimeStr);
|
||||
return Math.floor(d.getTime() / 1000);
|
||||
}
|
||||
|
||||
interface BulkDeleteContactsModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
contacts: Contact[];
|
||||
onDeleted: (deletedKeys: string[]) => void;
|
||||
}
|
||||
|
||||
export function BulkDeleteContactsModal({
|
||||
open,
|
||||
onClose,
|
||||
contacts,
|
||||
onDeleted,
|
||||
}: BulkDeleteContactsModalProps) {
|
||||
const [step, setStep] = useState<'select' | 'confirm'>('select');
|
||||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState<number | 'all'>('all');
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const lastClickedKeyRef = useRef<string | null>(null);
|
||||
|
||||
const resetAndClose = useCallback(() => {
|
||||
setStep('select');
|
||||
setSelectedKeys(new Set());
|
||||
setStartDate('');
|
||||
setEndDate('');
|
||||
setTypeFilter('all');
|
||||
lastClickedKeyRef.current = null;
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const filteredContacts = useMemo(() => {
|
||||
let list = [...contacts].sort((a, b) => (b.first_seen ?? 0) - (a.first_seen ?? 0));
|
||||
if (typeFilter !== 'all') {
|
||||
list = list.filter((c) => c.type === typeFilter);
|
||||
}
|
||||
if (startDate) {
|
||||
const start = datetimeToUnix(startDate);
|
||||
list = list.filter((c) => (c.first_seen ?? 0) >= start);
|
||||
}
|
||||
if (endDate) {
|
||||
const end = datetimeToUnix(endDate);
|
||||
list = list.filter((c) => (c.first_seen ?? 0) <= end);
|
||||
}
|
||||
return list;
|
||||
}, [contacts, typeFilter, startDate, endDate]);
|
||||
|
||||
const handleToggle = (key: string, shiftKey: boolean) => {
|
||||
if (shiftKey && lastClickedKeyRef.current && lastClickedKeyRef.current !== key) {
|
||||
const keys = filteredContacts.map((c) => c.public_key);
|
||||
const lastIdx = keys.indexOf(lastClickedKeyRef.current);
|
||||
const curIdx = keys.indexOf(key);
|
||||
if (lastIdx >= 0 && curIdx >= 0) {
|
||||
const from = Math.min(lastIdx, curIdx);
|
||||
const to = Math.max(lastIdx, curIdx);
|
||||
const rangeKeys = keys.slice(from, to + 1);
|
||||
setSelectedKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const k of rangeKeys) next.add(k);
|
||||
return next;
|
||||
});
|
||||
lastClickedKeyRef.current = key;
|
||||
return;
|
||||
}
|
||||
}
|
||||
setSelectedKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
lastClickedKeyRef.current = key;
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
setSelectedKeys(new Set(filteredContacts.map((c) => c.public_key)));
|
||||
};
|
||||
|
||||
const handleSelectNone = () => {
|
||||
setSelectedKeys(new Set());
|
||||
};
|
||||
|
||||
const selectedContacts = useMemo(
|
||||
() => contacts.filter((c) => selectedKeys.has(c.public_key)),
|
||||
[contacts, selectedKeys]
|
||||
);
|
||||
|
||||
const contactCount = selectedContacts.filter((c) => c.type === 1 || c.type === 0).length;
|
||||
const repeaterCount = selectedContacts.filter((c) => c.type === 2).length;
|
||||
const roomCount = selectedContacts.filter((c) => c.type === 3).length;
|
||||
const sensorCount = selectedContacts.filter((c) => c.type === 4).length;
|
||||
|
||||
const firstSeenDates = selectedContacts.map((c) => c.first_seen ?? 0).filter((t) => t > 0);
|
||||
const minDate =
|
||||
firstSeenDates.length > 0 ? formatDateISO(Math.min(...firstSeenDates)) : 'unknown';
|
||||
const maxDate =
|
||||
firstSeenDates.length > 0 ? formatDateISO(Math.max(...firstSeenDates)) : 'unknown';
|
||||
|
||||
const handleDelete = async () => {
|
||||
setDeleting(true);
|
||||
try {
|
||||
const keysToDelete = [...selectedKeys];
|
||||
const result = await api.bulkDeleteContacts(keysToDelete);
|
||||
toast.success(`Deleted ${result.deleted} contact${result.deleted === 1 ? '' : 's'}`);
|
||||
onDeleted(keysToDelete);
|
||||
resetAndClose();
|
||||
} catch (err) {
|
||||
console.error('Bulk delete failed:', err);
|
||||
toast.error('Bulk delete failed', {
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && resetAndClose()}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[85dvh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{step === 'select' ? 'Bulk Delete Contacts' : 'Confirm Deletion'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === 'select'
|
||||
? 'Select contacts to delete. Message history will be preserved and accessible if a contact is re-added, but will no longer appear in the sidebar.'
|
||||
: 'Review the contacts that will be permanently deleted.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{step === 'select' && (
|
||||
<>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-muted-foreground">Show</label>
|
||||
<select
|
||||
value={typeFilter === 'all' ? 'all' : String(typeFilter)}
|
||||
onChange={(e) =>
|
||||
setTypeFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))
|
||||
}
|
||||
className="block h-8 rounded-md border border-input bg-background px-2 text-sm"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="1">Clients</option>
|
||||
<option value="2">Repeaters</option>
|
||||
<option value="3">Room Servers</option>
|
||||
<option value="4">Sensors</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-muted-foreground">Created after</label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="w-48 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-muted-foreground">Created before</label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="w-48 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleSelectAll}>
|
||||
Select all
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleSelectNone}>
|
||||
Select none
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{filteredContacts.length} contact{filteredContacts.length === 1 ? '' : 's'} shown
|
||||
{(startDate || endDate) && ' (filtered)'}
|
||||
{' · '}
|
||||
{selectedKeys.size} selected
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0 border border-border rounded-md">
|
||||
{filteredContacts.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
No contacts match the selected date range.
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-muted/90 backdrop-blur-sm">
|
||||
<tr className="text-left text-xs text-muted-foreground">
|
||||
<th className="px-3 py-1.5 w-8" />
|
||||
<th className="px-3 py-1.5">Name</th>
|
||||
<th className="px-3 py-1.5 hidden sm:table-cell">Type</th>
|
||||
<th className="px-3 py-1.5">Key</th>
|
||||
<th className="px-3 py-1.5 hidden sm:table-cell">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredContacts.map((c) => (
|
||||
<tr
|
||||
key={c.public_key}
|
||||
className="border-t border-border hover:bg-accent/50 cursor-pointer"
|
||||
onClick={(e) => handleToggle(c.public_key, e.shiftKey)}
|
||||
>
|
||||
<td className="px-3 py-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedKeys.has(c.public_key)}
|
||||
onChange={(e) =>
|
||||
handleToggle(
|
||||
c.public_key,
|
||||
e.nativeEvent instanceof MouseEvent && e.nativeEvent.shiftKey
|
||||
)
|
||||
}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 truncate max-w-[10rem]">
|
||||
{getContactDisplayName(c.name, c.public_key, c.last_advert)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
|
||||
{CONTACT_TYPE_LABELS[c.type] ?? 'Unknown'}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 font-mono text-xs text-muted-foreground truncate max-w-[8rem]">
|
||||
{c.public_key.slice(0, 12)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
|
||||
{c.first_seen ? formatDate(c.first_seen) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="secondary" onClick={resetAndClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-warning text-warning hover:bg-warning/10 hover:text-warning"
|
||||
disabled={selectedKeys.size === 0}
|
||||
onClick={() => setStep('confirm')}
|
||||
>
|
||||
Proceed to confirmation ({selectedKeys.size})
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'confirm' && (
|
||||
<>
|
||||
<div className="flex-1 overflow-y-auto min-h-0 border border-border rounded-md">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-muted/90 backdrop-blur-sm">
|
||||
<tr className="text-left text-xs text-muted-foreground">
|
||||
<th className="px-3 py-1.5">Name</th>
|
||||
<th className="px-3 py-1.5">Type</th>
|
||||
<th className="px-3 py-1.5">Key</th>
|
||||
<th className="px-3 py-1.5 hidden sm:table-cell">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{selectedContacts.map((c) => (
|
||||
<tr key={c.public_key} className="border-t border-border">
|
||||
<td className="px-3 py-1.5 truncate max-w-[12rem]">
|
||||
{getContactDisplayName(c.name, c.public_key, c.last_advert)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-xs text-muted-foreground">
|
||||
{CONTACT_TYPE_LABELS[c.type] ?? 'Unknown'}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 font-mono text-xs text-muted-foreground truncate max-w-[8rem]">
|
||||
{c.public_key.slice(0, 12)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
|
||||
{c.first_seen ? formatDate(c.first_seen) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 pt-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full h-auto py-3 text-wrap"
|
||||
disabled={deleting}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{deleting
|
||||
? 'Deleting...'
|
||||
: `I confirm permanent, irrevocable deletion of all listed nodes above, totalling ${[
|
||||
contactCount > 0 && `${contactCount} contact${contactCount === 1 ? '' : 's'}`,
|
||||
repeaterCount > 0 &&
|
||||
`${repeaterCount} repeater${repeaterCount === 1 ? '' : 's'}`,
|
||||
roomCount > 0 && `${roomCount} room${roomCount === 1 ? '' : 's'}`,
|
||||
sensorCount > 0 && `${sensorCount} sensor${sensorCount === 1 ? '' : 's'}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ')}, spanning creation dates from ${minDate} to ${maxDate}`}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setStep('select')} disabled={deleting}>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -131,7 +131,7 @@ export function SettingsAboutSection({
|
||||
|
||||
<div className="text-center">
|
||||
<a
|
||||
href="/api/debug"
|
||||
href="./api/debug"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-muted-foreground hover:text-primary hover:underline"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Button } from '../ui/button';
|
||||
@@ -6,7 +6,14 @@ import { Separator } from '../ui/separator';
|
||||
import { toast } from '../ui/sonner';
|
||||
import { api } from '../../api';
|
||||
import { formatTime } from '../../utils/messageParser';
|
||||
import type { AppSettings, AppSettingsUpdate, HealthStatus } from '../../types';
|
||||
import { BulkDeleteContactsModal } from './BulkDeleteContactsModal';
|
||||
import type {
|
||||
AppSettings,
|
||||
AppSettingsUpdate,
|
||||
Contact,
|
||||
HealthStatus,
|
||||
TelemetryHistoryEntry,
|
||||
} from '../../types';
|
||||
|
||||
export function SettingsDatabaseSection({
|
||||
appSettings,
|
||||
@@ -17,6 +24,10 @@ export function SettingsDatabaseSection({
|
||||
blockedNames = [],
|
||||
onToggleBlockedKey,
|
||||
onToggleBlockedName,
|
||||
contacts = [],
|
||||
onBulkDeleteContacts,
|
||||
trackedTelemetryRepeaters = [],
|
||||
onToggleTrackedTelemetry,
|
||||
className,
|
||||
}: {
|
||||
appSettings: AppSettings;
|
||||
@@ -27,20 +38,51 @@ export function SettingsDatabaseSection({
|
||||
blockedNames?: string[];
|
||||
onToggleBlockedKey?: (key: string) => void;
|
||||
onToggleBlockedName?: (name: string) => void;
|
||||
contacts?: Contact[];
|
||||
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
|
||||
trackedTelemetryRepeaters?: string[];
|
||||
onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>;
|
||||
className?: string;
|
||||
}) {
|
||||
const [retentionDays, setRetentionDays] = useState('14');
|
||||
const [cleaning, setCleaning] = useState(false);
|
||||
const [purgingDecryptedRaw, setPurgingDecryptedRaw] = useState(false);
|
||||
const [autoDecryptOnAdvert, setAutoDecryptOnAdvert] = useState(false);
|
||||
const [discoveryBlockedTypes, setDiscoveryBlockedTypes] = useState<number[]>([]);
|
||||
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
|
||||
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [latestTelemetry, setLatestTelemetry] = useState<
|
||||
Record<string, TelemetryHistoryEntry | null>
|
||||
>({});
|
||||
const telemetryFetchedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert);
|
||||
setDiscoveryBlockedTypes(appSettings.discovery_blocked_types ?? []);
|
||||
}, [appSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (trackedTelemetryRepeaters.length === 0 || telemetryFetchedRef.current) return;
|
||||
telemetryFetchedRef.current = true;
|
||||
let cancelled = false;
|
||||
const fetches = trackedTelemetryRepeaters.map((key) =>
|
||||
api.repeaterTelemetryHistory(key).then(
|
||||
(history) => [key, history.length > 0 ? history[history.length - 1] : null] as const,
|
||||
() => [key, null] as const
|
||||
)
|
||||
);
|
||||
Promise.all(fetches).then((entries) => {
|
||||
if (cancelled) return;
|
||||
setLatestTelemetry(Object.fromEntries(entries));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [trackedTelemetryRepeaters]);
|
||||
|
||||
const handleCleanup = async () => {
|
||||
const days = parseInt(retentionDays, 10);
|
||||
if (isNaN(days) || days < 1) {
|
||||
@@ -92,7 +134,15 @@ export function SettingsDatabaseSection({
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await onSaveAppSettings({ auto_decrypt_dm_on_advert: autoDecryptOnAdvert });
|
||||
const update: AppSettingsUpdate = { auto_decrypt_dm_on_advert: autoDecryptOnAdvert };
|
||||
const currentBlocked = appSettings.discovery_blocked_types ?? [];
|
||||
if (
|
||||
discoveryBlockedTypes.length !== currentBlocked.length ||
|
||||
discoveryBlockedTypes.some((t) => !currentBlocked.includes(t))
|
||||
) {
|
||||
update.discovery_blocked_types = discoveryBlockedTypes;
|
||||
}
|
||||
await onSaveAppSettings(update);
|
||||
toast.success('Database settings saved');
|
||||
} catch (err) {
|
||||
console.error('Failed to save database settings:', err);
|
||||
@@ -105,93 +155,93 @@ export function SettingsDatabaseSection({
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* ── Database Overview ── */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Database size</span>
|
||||
<span className="font-medium">{health?.database_size_mb ?? '?'} MB</span>
|
||||
</div>
|
||||
|
||||
{health?.oldest_undecrypted_timestamp ? (
|
||||
<Label className="text-base">Database Overview</Label>
|
||||
<div className="rounded-md border border-border bg-muted/30 p-3 space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Oldest undecrypted packet</span>
|
||||
<span className="font-medium">
|
||||
{formatTime(health.oldest_undecrypted_timestamp)}
|
||||
<span className="text-muted-foreground ml-1">
|
||||
({Math.floor((Date.now() / 1000 - health.oldest_undecrypted_timestamp) / 86400)}{' '}
|
||||
days old)
|
||||
<span className="text-sm">Database size</span>
|
||||
<span className="text-sm font-semibold">{health?.database_size_mb ?? '?'} MB</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm">Oldest undecrypted packet</span>
|
||||
{health?.oldest_undecrypted_timestamp ? (
|
||||
<span className="text-sm font-semibold">
|
||||
{formatTime(health.oldest_undecrypted_timestamp)}
|
||||
<span className="font-normal text-muted-foreground ml-1">
|
||||
({Math.floor((Date.now() / 1000 - health.oldest_undecrypted_timestamp) / 86400)}{' '}
|
||||
days)
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">None</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Oldest undecrypted packet</span>
|
||||
<span className="text-muted-foreground">None</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>Delete Undecrypted Packets</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Permanently deletes stored raw packets containing DMs and channel messages that have not
|
||||
yet been decrypted. These packets are retained in case you later obtain the correct key —
|
||||
once deleted, these messages can never be recovered or decrypted.
|
||||
</p>
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="retention-days" className="text-xs">
|
||||
Older than (days)
|
||||
</Label>
|
||||
<Input
|
||||
id="retention-days"
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
value={retentionDays}
|
||||
onChange={(e) => setRetentionDays(e.target.value)}
|
||||
className="w-24"
|
||||
/>
|
||||
{/* ── Storage Cleanup ── */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base">Storage Cleanup</Label>
|
||||
|
||||
<div className="rounded-md border border-border p-3 space-y-2">
|
||||
<Label className="text-sm">Delete Undecrypted Packets</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Permanently deletes stored raw packets that have not yet been decrypted. These are
|
||||
retained in case you later obtain the correct key — once deleted, these messages can
|
||||
never be recovered.
|
||||
</p>
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="retention-days" className="text-xs text-muted-foreground">
|
||||
Older than (days)
|
||||
</Label>
|
||||
<Input
|
||||
id="retention-days"
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
value={retentionDays}
|
||||
onChange={(e) => setRetentionDays(e.target.value)}
|
||||
className="w-24"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCleanup}
|
||||
disabled={cleaning}
|
||||
className="border-destructive/50 text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
{cleaning ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-border p-3 space-y-2">
|
||||
<Label className="text-sm">Purge Archival Raw Packets</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Deletes the raw packet bytes behind messages that are already decrypted and visible in
|
||||
chat. This frees space but removes packet-analysis availability for those messages. It
|
||||
does not affect displayed messages or future decryption.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCleanup}
|
||||
disabled={cleaning}
|
||||
className="border-destructive/50 text-destructive hover:bg-destructive/10"
|
||||
onClick={handlePurgeDecryptedRawPackets}
|
||||
disabled={purgingDecryptedRaw}
|
||||
className="w-full border-warning/50 text-warning hover:bg-warning/10"
|
||||
>
|
||||
{cleaning ? 'Deleting...' : 'Permanently Delete'}
|
||||
{purgingDecryptedRaw ? 'Purging...' : 'Purge Archival Packets'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── DM Decryption ── */}
|
||||
<div className="space-y-3">
|
||||
<Label>Purge Archival Raw Packets</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Deletes archival copies of raw packet bytes for messages that are already decrypted and
|
||||
visible in your chat history.{' '}
|
||||
<em className="text-muted-foreground/80">
|
||||
This will not affect any displayed messages or your ability to do historical decryption,
|
||||
but it will remove packet-analysis availability for those historical messages.
|
||||
</em>{' '}
|
||||
The raw bytes are only useful for manual packet analysis.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handlePurgeDecryptedRawPackets}
|
||||
disabled={purgingDecryptedRaw}
|
||||
className="w-full border-warning/50 text-warning hover:bg-warning/10"
|
||||
>
|
||||
{purgingDecryptedRaw ? 'Purging Archival Raw Packets...' : 'Purge Archival Raw Packets'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>DM Decryption</Label>
|
||||
<Label className="text-base">DM Decryption</Label>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -209,15 +259,150 @@ export function SettingsDatabaseSection({
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Tracked Repeater Telemetry ── */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base">Tracked Repeater Telemetry</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Repeaters opted into automatic telemetry collection are polled every 8 hours. Up to 8
|
||||
repeaters may be tracked at a time ({trackedTelemetryRepeaters.length} / 8 slots used).
|
||||
</p>
|
||||
|
||||
{trackedTelemetryRepeaters.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No repeaters are being tracked. Enable tracking from a repeater's dashboard.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{trackedTelemetryRepeaters.map((key) => {
|
||||
const contact = contacts.find((c) => c.public_key === key);
|
||||
const displayName = contact?.name ?? key.slice(0, 12);
|
||||
const snap = latestTelemetry[key];
|
||||
const d = snap?.data;
|
||||
return (
|
||||
<div key={key} className="rounded-md border border-border px-3 py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm truncate block">{displayName}</span>
|
||||
<span className="text-[0.625rem] text-muted-foreground font-mono">
|
||||
{key.slice(0, 12)}
|
||||
</span>
|
||||
</div>
|
||||
{onToggleTrackedTelemetry && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onToggleTrackedTelemetry(key)}
|
||||
className="h-7 text-xs flex-shrink-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{d ? (
|
||||
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-[0.625rem] text-muted-foreground">
|
||||
<span>{d.battery_volts?.toFixed(2)}V</span>
|
||||
<span>noise {d.noise_floor_dbm} dBm</span>
|
||||
<span>
|
||||
rx {d.packets_received != null ? d.packets_received.toLocaleString() : '?'}
|
||||
</span>
|
||||
<span>
|
||||
tx {d.packets_sent != null ? d.packets_sent.toLocaleString() : '?'}
|
||||
</span>
|
||||
<span className="ml-auto">checked {formatTime(snap.timestamp)}</span>
|
||||
</div>
|
||||
) : snap === null ? (
|
||||
<div className="mt-1 text-[0.625rem] text-muted-foreground italic">
|
||||
No telemetry recorded yet
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-destructive" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={handleSave} disabled={busy} className="w-full">
|
||||
{busy ? 'Saving...' : 'Save Settings'}
|
||||
</Button>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Contact Management ── */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-base">Contact Management</Label>
|
||||
</div>
|
||||
|
||||
{/* Block discovery of new node types */}
|
||||
<div className="space-y-3">
|
||||
<Label>Block Discovery of New Node Types</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Checked types will be ignored when heard via advertisement. Existing contacts of these
|
||||
types are still updated. This does not affect contacts added manually or via DM.
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{(
|
||||
[
|
||||
[1, 'Block clients'],
|
||||
[2, 'Block repeaters'],
|
||||
[3, 'Block room servers'],
|
||||
[4, 'Block sensors'],
|
||||
] as const
|
||||
).map(([typeCode, label]) => {
|
||||
const checked = discoveryBlockedTypes.includes(typeCode);
|
||||
return (
|
||||
<label key={typeCode} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() =>
|
||||
setDiscoveryBlockedTypes((prev) =>
|
||||
checked ? prev.filter((t) => t !== typeCode) : [...prev, typeCode]
|
||||
)
|
||||
}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{discoveryBlockedTypes.length > 0 && (
|
||||
<p className="text-xs text-warning">
|
||||
New{' '}
|
||||
{discoveryBlockedTypes
|
||||
.map((t) =>
|
||||
t === 1 ? 'clients' : t === 2 ? 'repeaters' : t === 3 ? 'room servers' : 'sensors'
|
||||
)
|
||||
.join(', ')}{' '}
|
||||
heard via advertisement will not be added to your contact list.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Blocked contacts list */}
|
||||
<div className="space-y-3">
|
||||
<Label>Blocked Contacts</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Blocking only hides messages from the UI. MQTT forwarding and bot responses are not
|
||||
affected. Messages are still stored and will reappear if unblocked.
|
||||
Blocked contacts are hidden from the sidebar. Blocking only hides messages from the UI —
|
||||
MQTT forwarding and bot responses are not affected. Messages are still stored and will
|
||||
reappear if unblocked.
|
||||
</p>
|
||||
|
||||
{blockedKeys.length === 0 && blockedNames.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">No blocked contacts</p>
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No blocked contacts. Block contacts from their info pane, viewed by clicking their
|
||||
avatar in any channel, or their name within the top status bar with the conversation
|
||||
open.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{blockedKeys.length > 0 && (
|
||||
@@ -268,15 +453,25 @@ export function SettingsDatabaseSection({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-destructive" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<Separator />
|
||||
|
||||
<Button onClick={handleSave} disabled={busy} className="w-full">
|
||||
{busy ? 'Saving...' : 'Save Settings'}
|
||||
</Button>
|
||||
{/* Bulk delete */}
|
||||
<div className="space-y-3">
|
||||
<Label>Bulk Delete Contacts</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Remove multiple contacts or repeaters at once. Useful for cleaning up spam or unwanted
|
||||
nodes. Message history will be preserved.
|
||||
</p>
|
||||
<Button variant="outline" className="w-full" onClick={() => setBulkDeleteOpen(true)}>
|
||||
Open Bulk Delete
|
||||
</Button>
|
||||
<BulkDeleteContactsModal
|
||||
open={bulkDeleteOpen}
|
||||
onClose={() => setBulkDeleteOpen(false)}
|
||||
contacts={contacts}
|
||||
onDeleted={(keys) => onBulkDeleteContacts?.(keys)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -316,6 +316,80 @@ const CREATE_INTEGRATION_DEFINITIONS_BY_VALUE = Object.fromEntries(
|
||||
CREATE_INTEGRATION_DEFINITIONS.map((definition) => [definition.value, definition])
|
||||
) as Record<DraftType, CreateIntegrationDefinition>;
|
||||
|
||||
function getNumberInputValue(value: unknown, fallback: number): string | number {
|
||||
if (value === '') return '';
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function getOptionalNumberInputValue(value: unknown): string | number {
|
||||
if (value === '') return '';
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
return '';
|
||||
}
|
||||
|
||||
function parseIntegerInputValue(value: string): number | string {
|
||||
if (value === '') return '';
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isNaN(parsed) ? value : parsed;
|
||||
}
|
||||
|
||||
function parseFloatInputValue(value: string): number | string {
|
||||
if (value === '') return '';
|
||||
const parsed = Number.parseFloat(value);
|
||||
return Number.isNaN(parsed) ? value : parsed;
|
||||
}
|
||||
|
||||
function normalizeIntegrationConfigForSave(
|
||||
configType: string,
|
||||
config: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const normalized = { ...config };
|
||||
|
||||
if (configType === 'mqtt_private') {
|
||||
const port = normalized.broker_port;
|
||||
if (port === '' || port === undefined || port === null) {
|
||||
normalized.broker_port = 1883;
|
||||
} else if (typeof port === 'string') {
|
||||
const parsed = Number.parseInt(port, 10);
|
||||
normalized.broker_port = Number.isNaN(parsed) ? 1883 : parsed;
|
||||
}
|
||||
|
||||
const topicPrefix = String(normalized.topic_prefix ?? '').trim();
|
||||
normalized.topic_prefix = topicPrefix || 'meshcore';
|
||||
}
|
||||
|
||||
if (configType === 'mqtt_community') {
|
||||
const brokerHost = String(normalized.broker_host ?? '').trim();
|
||||
normalized.broker_host = brokerHost || DEFAULT_COMMUNITY_BROKER_HOST;
|
||||
|
||||
const port = normalized.broker_port;
|
||||
if (port === '' || port === undefined || port === null) {
|
||||
normalized.broker_port = DEFAULT_COMMUNITY_BROKER_PORT;
|
||||
} else if (typeof port === 'string') {
|
||||
const parsed = Number.parseInt(port, 10);
|
||||
normalized.broker_port = Number.isNaN(parsed) ? DEFAULT_COMMUNITY_BROKER_PORT : parsed;
|
||||
}
|
||||
|
||||
const topicTemplate = String(normalized.topic_template ?? '').trim();
|
||||
normalized.topic_template = topicTemplate || DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE;
|
||||
}
|
||||
|
||||
if (configType === 'map_upload') {
|
||||
const radius = normalized.geofence_radius_km;
|
||||
if (radius === '' || radius === undefined || radius === null) {
|
||||
normalized.geofence_radius_km = 0;
|
||||
} else if (typeof radius === 'string') {
|
||||
const parsed = Number.parseFloat(radius);
|
||||
normalized.geofence_radius_km = Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isDraftType(value: string): value is DraftType {
|
||||
return value in CREATE_INTEGRATION_DEFINITIONS_BY_VALUE;
|
||||
}
|
||||
@@ -338,7 +412,7 @@ function normalizeDraftConfig(draftType: DraftType, config: Record<string, unkno
|
||||
throw new Error('MeshRank packet topic is required');
|
||||
}
|
||||
|
||||
return {
|
||||
return normalizeIntegrationConfigForSave('mqtt_community', {
|
||||
...config,
|
||||
broker_host: DEFAULT_MESHRANK_BROKER_HOST,
|
||||
broker_port: DEFAULT_MESHRANK_BROKER_PORT,
|
||||
@@ -352,7 +426,7 @@ function normalizeDraftConfig(draftType: DraftType, config: Record<string, unkno
|
||||
topic_template: topicTemplate,
|
||||
username: '',
|
||||
password: '',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (draftType === 'mqtt_community_letsmesh_us' || draftType === 'mqtt_community_letsmesh_eu') {
|
||||
@@ -360,7 +434,7 @@ function normalizeDraftConfig(draftType: DraftType, config: Record<string, unkno
|
||||
draftType === 'mqtt_community_letsmesh_eu'
|
||||
? DEFAULT_COMMUNITY_BROKER_HOST_EU
|
||||
: DEFAULT_COMMUNITY_BROKER_HOST;
|
||||
return {
|
||||
return normalizeIntegrationConfigForSave('mqtt_community', {
|
||||
...config,
|
||||
broker_host: brokerHost,
|
||||
broker_port: DEFAULT_COMMUNITY_BROKER_PORT,
|
||||
@@ -372,10 +446,13 @@ function normalizeDraftConfig(draftType: DraftType, config: Record<string, unkno
|
||||
topic_template: (config.topic_template as string) || DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE,
|
||||
username: '',
|
||||
password: '',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return config;
|
||||
return normalizeIntegrationConfigForSave(
|
||||
getCreateIntegrationDefinition(draftType).savedType,
|
||||
config
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDraftScope(draftType: DraftType, scope: Record<string, unknown>) {
|
||||
@@ -460,7 +537,7 @@ function CreateIntegrationDialog({
|
||||
<div className="space-y-4">
|
||||
{sectionedOptions.map((group) => (
|
||||
<div key={group.section} className="space-y-1.5">
|
||||
<div className="px-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
<div className="px-2 text-[0.6875rem] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{group.section}
|
||||
</div>
|
||||
{group.options.map((option) => {
|
||||
@@ -500,7 +577,7 @@ function CreateIntegrationDialog({
|
||||
{selectedOption ? (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{selectedOption.section}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">{selectedOption.label}</h3>
|
||||
@@ -566,16 +643,20 @@ function formatPrivateTopicSummary(config: Record<string, unknown>) {
|
||||
return `${prefix}/dm:<pubkey>, ${prefix}/gm:<channel>, ${prefix}/raw/...`;
|
||||
}
|
||||
|
||||
function formatAppriseTargets(urls: string | undefined, maxLength = 80) {
|
||||
function censorAppriseUrl(url: string): string {
|
||||
const protoMatch = url.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//);
|
||||
if (protoMatch) return `${protoMatch[0]}********`;
|
||||
return '********';
|
||||
}
|
||||
|
||||
function formatAppriseTargets(urls: string | undefined) {
|
||||
const targets = (urls || '')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
if (targets.length === 0) return 'No targets configured';
|
||||
|
||||
const joined = targets.join(', ');
|
||||
if (joined.length <= maxLength) return joined;
|
||||
return `${joined.slice(0, maxLength - 3)}...`;
|
||||
return targets.map(censorAppriseUrl).join(', ');
|
||||
}
|
||||
|
||||
function formatSqsQueueSummary(config: Record<string, unknown>) {
|
||||
@@ -649,9 +730,9 @@ function MqttPrivateConfigEditor({
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
value={(config.broker_port as number) || 1883}
|
||||
value={getNumberInputValue(config.broker_port, 1883)}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, broker_port: parseInt(e.target.value, 10) || 1883 })
|
||||
onChange({ ...config, broker_port: parseIntegerInputValue(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -709,7 +790,8 @@ function MqttPrivateConfigEditor({
|
||||
<Input
|
||||
id="fanout-mqtt-prefix"
|
||||
type="text"
|
||||
value={(config.topic_prefix as string) || 'meshcore'}
|
||||
placeholder="meshcore"
|
||||
value={(config.topic_prefix as string | undefined) ?? ''}
|
||||
onChange={(e) => onChange({ ...config, topic_prefix: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
@@ -745,7 +827,7 @@ function MqttCommunityConfigEditor({
|
||||
id="fanout-comm-host"
|
||||
type="text"
|
||||
placeholder={DEFAULT_COMMUNITY_BROKER_HOST}
|
||||
value={(config.broker_host as string) || DEFAULT_COMMUNITY_BROKER_HOST}
|
||||
value={(config.broker_host as string | undefined) ?? ''}
|
||||
onChange={(e) => onChange({ ...config, broker_host: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
@@ -756,11 +838,11 @@ function MqttCommunityConfigEditor({
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
value={(config.broker_port as number) || DEFAULT_COMMUNITY_BROKER_PORT}
|
||||
value={getNumberInputValue(config.broker_port, DEFAULT_COMMUNITY_BROKER_PORT)}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
broker_port: parseInt(e.target.value, 10) || DEFAULT_COMMUNITY_BROKER_PORT,
|
||||
broker_port: parseIntegerInputValue(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
@@ -895,7 +977,8 @@ function MqttCommunityConfigEditor({
|
||||
<Input
|
||||
id="fanout-comm-topic-template"
|
||||
type="text"
|
||||
value={(config.topic_template as string) || DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE}
|
||||
placeholder={DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE}
|
||||
value={(config.topic_template as string | undefined) ?? ''}
|
||||
onChange={(e) => onChange({ ...config, topic_template: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -1215,11 +1298,11 @@ function MapUploadConfigEditor({
|
||||
min="0"
|
||||
step="any"
|
||||
placeholder="e.g. 100"
|
||||
value={(config.geofence_radius_km as number | undefined) ?? ''}
|
||||
value={getOptionalNumberInputValue(config.geofence_radius_km)}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
geofence_radius_km: e.target.value === '' ? 0 : parseFloat(e.target.value),
|
||||
geofence_radius_km: parseFloatInputValue(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
@@ -1583,7 +1666,8 @@ function AppriseConfigEditor({
|
||||
rows={4}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
One URL per line. All URLs receive every matched notification.
|
||||
One URL per line. All URLs receive every matched notification. For Matrix room version 12
|
||||
(servername-less room IDs), append <code>?hsreq=no</code> to the URL.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1997,9 +2081,10 @@ export function SettingsFanoutSection({
|
||||
if (!currentEditingId) {
|
||||
throw new Error('Missing fanout config id for update');
|
||||
}
|
||||
const editingType = configs.find((cfg) => cfg.id === currentEditingId)?.type ?? '';
|
||||
const update: Record<string, unknown> = {
|
||||
name: editName,
|
||||
config: editConfig,
|
||||
config: normalizeIntegrationConfigForSave(editingType, editConfig),
|
||||
scope: editScope,
|
||||
};
|
||||
if (enabled !== undefined) update.enabled = enabled;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import { Logs, MessageSquare } from 'lucide-react';
|
||||
import { ChevronRight, Logs, MessageSquare, Send, Settings } from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Separator } from '../ui/separator';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { ContactAvatar } from '../ContactAvatar';
|
||||
import {
|
||||
captureLastViewedConversationFromHash,
|
||||
@@ -17,6 +19,22 @@ import {
|
||||
setSavedDistanceUnit,
|
||||
} from '../../utils/distanceUnits';
|
||||
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
|
||||
import {
|
||||
DEFAULT_FONT_SCALE,
|
||||
FONT_SCALE_SLIDER_STEP,
|
||||
MAX_FONT_SCALE,
|
||||
MIN_FONT_SCALE,
|
||||
getSavedFontScale,
|
||||
setSavedFontScale,
|
||||
} from '../../utils/fontScale';
|
||||
import { getAutoFocusInputEnabled, setAutoFocusInputEnabled } from '../../utils/autoFocusInput';
|
||||
import {
|
||||
BATTERY_DISPLAY_CHANGE_EVENT,
|
||||
getShowBatteryPercent,
|
||||
setShowBatteryPercent as saveBatteryPercent,
|
||||
getShowBatteryVoltage,
|
||||
setShowBatteryVoltage as saveBatteryVoltage,
|
||||
} from '../../utils/batteryDisplay';
|
||||
|
||||
export function SettingsLocalSection({
|
||||
onLocalLabelChange,
|
||||
@@ -29,8 +47,41 @@ export function SettingsLocalSection({
|
||||
const [reopenLastConversation, setReopenLastConversation] = useState(
|
||||
getReopenLastConversationEnabled
|
||||
);
|
||||
const [darkMap, setDarkMap] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem('remoteterm-dark-map') === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const [localLabelText, setLocalLabelText] = useState(() => getLocalLabel().text);
|
||||
const [localLabelColor, setLocalLabelColor] = useState(() => getLocalLabel().color);
|
||||
const [autoFocusInput, setAutoFocusInput] = useState(getAutoFocusInputEnabled);
|
||||
const [batteryPercent, setBatteryPercent] = useState(getShowBatteryPercent);
|
||||
const [batteryVoltage, setBatteryVoltage] = useState(getShowBatteryVoltage);
|
||||
const [fontScale, setFontScale] = useState(getSavedFontScale);
|
||||
const [fontScaleSlider, setFontScaleSlider] = useState(getSavedFontScale);
|
||||
const [fontScaleInput, setFontScaleInput] = useState(() => String(getSavedFontScale()));
|
||||
|
||||
const commitFontScale = (nextScale: number) => {
|
||||
const normalized = setSavedFontScale(nextScale);
|
||||
setFontScale(normalized);
|
||||
setFontScaleSlider(normalized);
|
||||
setFontScaleInput(String(normalized));
|
||||
};
|
||||
|
||||
const restoreFontScaleInput = () => {
|
||||
setFontScaleInput(String(fontScale));
|
||||
};
|
||||
|
||||
const handleSliderChange = (nextScale: number) => {
|
||||
setFontScaleSlider(nextScale);
|
||||
setFontScaleInput(String(nextScale));
|
||||
};
|
||||
|
||||
const handleSliderCommit = (nextScale: number) => {
|
||||
commitFontScale(nextScale);
|
||||
};
|
||||
|
||||
const handleToggleReopenLastConversation = (enabled: boolean) => {
|
||||
setReopenLastConversation(enabled);
|
||||
@@ -114,20 +165,172 @@ export function SettingsLocalSection({
|
||||
|
||||
<Separator />
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reopenLastConversation}
|
||||
onChange={(e) => handleToggleReopenLastConversation(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">Reopen to last viewed channel/conversation</span>
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
<Label>UI Tweaks</Label>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reopenLastConversation}
|
||||
onChange={(e) => handleToggleReopenLastConversation(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">Reopen to last viewed channel/conversation</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={darkMap}
|
||||
onChange={(e) => {
|
||||
const v = e.target.checked;
|
||||
setDarkMap(v);
|
||||
try {
|
||||
localStorage.setItem('remoteterm-dark-map', String(v));
|
||||
} catch {
|
||||
// localStorage may be disabled
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">Dark mode map tiles</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoFocusInput}
|
||||
onChange={(e) => {
|
||||
const v = e.target.checked;
|
||||
setAutoFocusInput(v);
|
||||
setAutoFocusInputEnabled(v);
|
||||
}}
|
||||
className="w-4 h-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">Auto-focus input on conversation load (desktop only)</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={batteryPercent}
|
||||
onChange={(e) => {
|
||||
const v = e.target.checked;
|
||||
setBatteryPercent(v);
|
||||
saveBatteryPercent(v);
|
||||
window.dispatchEvent(new Event(BATTERY_DISPLAY_CHANGE_EVENT));
|
||||
}}
|
||||
className="w-4 h-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">Show battery percentage in status bar</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={batteryVoltage}
|
||||
onChange={(e) => {
|
||||
const v = e.target.checked;
|
||||
setBatteryVoltage(v);
|
||||
saveBatteryVoltage(v);
|
||||
window.dispatchEvent(new Event(BATTERY_DISPLAY_CHANGE_EVENT));
|
||||
}}
|
||||
className="w-4 h-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">Show battery voltage in status bar</span>
|
||||
</label>
|
||||
|
||||
{(batteryPercent || batteryVoltage) && (
|
||||
<p className="text-xs text-muted-foreground ml-7">
|
||||
Battery data updates every 60 seconds and may take up to a minute to appear after
|
||||
connecting.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemePreview({ className }: { className?: string }) {
|
||||
const [showStyleRef, setShowStyleRef] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border border-border bg-card p-3 ${className ?? ''}`}>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
@@ -161,7 +364,7 @@ function ThemePreview({ className }: { className?: string }) {
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-md border border-border bg-background p-2">
|
||||
<p className="mb-2 text-[11px] font-medium text-muted-foreground">Sidebar preview</p>
|
||||
<p className="mb-2 text-[0.6875rem] font-medium text-muted-foreground">Sidebar preview</p>
|
||||
<div className="space-y-1">
|
||||
<PreviewSidebarRow
|
||||
active
|
||||
@@ -179,7 +382,7 @@ function ThemePreview({ className }: { className?: string }) {
|
||||
leading={<ContactAvatar name="Alice" publicKey={'ab'.repeat(32)} size={24} />}
|
||||
label="Alice"
|
||||
badge={
|
||||
<span className="rounded-full bg-badge-unread/90 px-1.5 py-0.5 text-[10px] font-semibold text-badge-unread-foreground">
|
||||
<span className="rounded-full bg-badge-unread/90 px-1.5 py-0.5 text-[0.625rem] font-semibold text-badge-unread-foreground">
|
||||
3
|
||||
</span>
|
||||
}
|
||||
@@ -188,13 +391,267 @@ function ThemePreview({ className }: { className?: string }) {
|
||||
leading={<ContactAvatar name="Mesh Ops" publicKey={'cd'.repeat(32)} size={24} />}
|
||||
label="Mesh Ops"
|
||||
badge={
|
||||
<span className="rounded-full bg-badge-mention px-1.5 py-0.5 text-[10px] font-semibold text-badge-mention-foreground">
|
||||
<span className="rounded-full bg-badge-mention px-1.5 py-0.5 text-[0.625rem] font-semibold text-badge-mention-foreground">
|
||||
@2
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Style Reference (collapsible) ── */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowStyleRef((v) => !v)}
|
||||
className="mt-4 flex w-full items-center gap-1.5 text-[0.6875rem] font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn('h-3.5 w-3.5 transition-transform', showStyleRef && 'rotate-90')}
|
||||
/>
|
||||
Canonical style reference
|
||||
</button>
|
||||
|
||||
{showStyleRef && (
|
||||
<>
|
||||
{/* ── Text Hierarchy ── */}
|
||||
<PreviewSection title="Text hierarchy">
|
||||
<div className="space-y-2">
|
||||
<PreviewTextRow
|
||||
classes="text-xl font-semibold"
|
||||
label="text-xl font-semibold"
|
||||
desc="Hero / large data"
|
||||
/>
|
||||
<PreviewTextRow
|
||||
classes="text-lg font-semibold"
|
||||
label="text-lg font-semibold"
|
||||
desc="Sheet / dialog title"
|
||||
/>
|
||||
<PreviewTextRow
|
||||
classes="text-base font-semibold"
|
||||
label="text-base font-semibold"
|
||||
desc="Section title"
|
||||
/>
|
||||
<PreviewTextRow classes="text-sm" label="text-sm" desc="Body text, form labels" />
|
||||
<PreviewTextRow
|
||||
classes="text-xs text-muted-foreground"
|
||||
label="text-xs text-muted-foreground"
|
||||
desc="Helper text"
|
||||
/>
|
||||
<PreviewTextRow
|
||||
classes="text-[0.6875rem] text-muted-foreground"
|
||||
label="text-[0.6875rem] text-muted-foreground"
|
||||
desc="Metadata, timestamps"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Section Label
|
||||
</p>
|
||||
<p className="text-[0.625rem] text-muted-foreground/60 mt-0.5">
|
||||
text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</PreviewSection>
|
||||
|
||||
{/* ── Mono Text ── */}
|
||||
<PreviewSection title="Mono text">
|
||||
<div className="space-y-1.5">
|
||||
<div>
|
||||
<p className="text-xs font-mono text-muted-foreground">
|
||||
a1b2c3d4e5f6...7890abcdef01
|
||||
</p>
|
||||
<p className="text-[0.625rem] text-muted-foreground/60">
|
||||
text-xs font-mono — keys, identifiers
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[0.6875rem] font-mono">1h 23m 45s uptime</p>
|
||||
<p className="text-[0.625rem] text-muted-foreground/60">
|
||||
text-[0.6875rem] font-mono — metadata mono
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-mono">$ req_status_sync 0xA1B2...</p>
|
||||
<p className="text-[0.625rem] text-muted-foreground/60">
|
||||
text-sm font-mono — console / code
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</PreviewSection>
|
||||
|
||||
{/* ── Badges ── */}
|
||||
<PreviewSection title="Badges and tags">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
|
||||
Hashtag
|
||||
</span>
|
||||
<span className="text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
|
||||
Repeater
|
||||
</span>
|
||||
<span className="text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">
|
||||
On Radio
|
||||
</span>
|
||||
<span className="rounded-full bg-badge-unread/90 px-1.5 py-0.5 text-[0.625rem] font-semibold text-badge-unread-foreground">
|
||||
3
|
||||
</span>
|
||||
<span className="rounded-full bg-badge-mention px-1.5 py-0.5 text-[0.625rem] font-semibold text-badge-mention-foreground">
|
||||
@2
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[0.625rem] text-muted-foreground/60 mt-1.5">
|
||||
Muted: bg-muted · Primary: bg-primary/10 · Unread/Mention: bg-badge-*
|
||||
</p>
|
||||
</PreviewSection>
|
||||
|
||||
{/* ── Buttons ── */}
|
||||
<PreviewSection title="Buttons">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-[0.625rem] text-muted-foreground/60 mb-1.5">
|
||||
Standard variants (size sm)
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Button size="sm">Default</Button>
|
||||
<Button size="sm" variant="outline">
|
||||
Outline
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary">
|
||||
Secondary
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive">
|
||||
Destructive
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost">
|
||||
Ghost
|
||||
</Button>
|
||||
<Button size="icon" variant="outline">
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button size="icon" variant="outline">
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[0.625rem] text-muted-foreground/60 mb-1.5">
|
||||
Semantic outline variants
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-destructive/50 text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
Danger
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-warning/50 text-warning hover:bg-warning/10"
|
||||
>
|
||||
Warning
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-green-600/50 text-green-600 hover:bg-green-600/10"
|
||||
>
|
||||
Success
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[0.625rem] text-muted-foreground/60 mb-1.5">
|
||||
Metric selector pills
|
||||
</p>
|
||||
<div className="flex gap-1">
|
||||
{['Voltage', 'Noise Floor', 'Packets'].map((label, i) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
className={cn(
|
||||
'text-[0.6875rem] px-2 py-0.5 rounded transition-colors',
|
||||
i === 0
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PreviewSection>
|
||||
|
||||
{/* ── Clickable Text ── */}
|
||||
<PreviewSection title="Clickable text">
|
||||
<div className="space-y-1.5">
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="text-xs font-mono text-muted-foreground cursor-pointer hover:text-primary transition-colors block"
|
||||
>
|
||||
a1b2c3d4e5f6 (click to copy)
|
||||
</span>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="text-sm cursor-pointer underline underline-offset-2 decoration-muted-foreground/50 hover:text-primary transition-colors"
|
||||
>
|
||||
Underlined navigational link
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[0.625rem] text-muted-foreground/60 mt-1.5">
|
||||
cursor-pointer hover:text-primary transition-colors — use role="button" +
|
||||
tabIndex
|
||||
</p>
|
||||
</PreviewSection>
|
||||
|
||||
{/* ── Inline Alerts ── */}
|
||||
<PreviewSection title="Inline alerts">
|
||||
<div className="space-y-1.5">
|
||||
<div className="rounded-md border border-info/30 bg-info/10 px-3 py-2 text-xs text-info">
|
||||
Info: channel slot cache refreshed from radio.
|
||||
</div>
|
||||
<div className="rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-xs text-warning">
|
||||
Warning: radio clock skew detected.
|
||||
</div>
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
Error: post-connect setup timed out. Reboot the radio and restart.
|
||||
</div>
|
||||
</div>
|
||||
</PreviewSection>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewSection({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mt-4 rounded-md border border-border bg-background p-2">
|
||||
<p className="mb-2 text-[0.6875rem] font-medium text-muted-foreground">{title}</p>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewTextRow({
|
||||
classes,
|
||||
label,
|
||||
desc,
|
||||
}: {
|
||||
classes: string;
|
||||
label: string;
|
||||
desc: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<p className={classes}>Sample text at this size</p>
|
||||
<p className="text-[0.625rem] text-muted-foreground/60">
|
||||
{label} — {desc}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -217,7 +674,7 @@ function PreviewMessage({
|
||||
return (
|
||||
<div className={`flex ${alignRight ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-[85%] ${alignRight ? 'items-end' : 'items-start'} flex flex-col`}>
|
||||
<span className="mb-1 text-[11px] text-muted-foreground">{sender}</span>
|
||||
<span className="mb-1 text-[0.6875rem] text-muted-foreground">{sender}</span>
|
||||
<div className={`rounded-2xl px-3 py-2 text-sm break-words ${bubbleClassName}`}>{text}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -237,17 +694,20 @@ function PreviewSidebarRow({
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-md border-l-2 px-3 py-2 text-[13px] ${
|
||||
data-active={active ? 'true' : undefined}
|
||||
className={`sidebar-action-row flex items-center gap-2 rounded-md border-l-2 px-3 py-2 text-[0.8125rem] ${
|
||||
active ? 'border-l-primary bg-accent text-foreground' : 'border-l-transparent'
|
||||
}`}
|
||||
>
|
||||
{leading}
|
||||
<span className={`min-w-0 flex-1 truncate ${active ? 'font-medium' : 'text-foreground'}`}>
|
||||
<span className="sidebar-tool-icon" aria-hidden="true">
|
||||
{leading}
|
||||
</span>
|
||||
<span className={`sidebar-tool-label min-w-0 flex-1 truncate ${active ? 'font-medium' : ''}`}>
|
||||
{label}
|
||||
</span>
|
||||
{badge}
|
||||
{!badge && (
|
||||
<span className="text-muted-foreground" aria-hidden="true">
|
||||
<span className="sidebar-tool-icon" aria-hidden="true">
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user