mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-10 07:15:09 +02:00
Compare commits
71 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 |
@@ -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
|
||||
@@ -209,6 +209,7 @@ 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)
|
||||
│ ├── build/
|
||||
│ │ ├── collect_licenses.sh # Gather third-party license attributions
|
||||
@@ -216,7 +217,8 @@ This message-layer echo/path handling is independent of raw-packet storage dedup
|
||||
│ ├── 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
|
||||
│ │ ├── 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
|
||||
@@ -371,7 +373,7 @@ 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) |
|
||||
@@ -478,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`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `flood_scope`, `blocked_keys`, `blocked_names`, and `discovery_blocked_types`. `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`.
|
||||
**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.
|
||||
|
||||
|
||||
+44
-7
@@ -1,3 +1,44 @@
|
||||
## [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
|
||||
@@ -115,7 +156,7 @@
|
||||
* 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: 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
|
||||
@@ -142,7 +183,7 @@
|
||||
## [3.3.0] - 2026-03-13
|
||||
|
||||
* Feature: Use dashed lines to show collapsed ambiguous router results
|
||||
* Feature: Jump to unred
|
||||
* Feature: Jump to unread
|
||||
* Feature: Local channel management to prevent need to reload channel every time
|
||||
* Feature: Debug endpoint
|
||||
* Feature: Force-singleton channel management
|
||||
@@ -205,7 +246,7 @@
|
||||
* 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: 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
|
||||
@@ -222,10 +263,6 @@
|
||||
|
||||
* 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
|
||||
|
||||
+98
-4
@@ -70,17 +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
|
||||
npm install
|
||||
npx playwright test # headless
|
||||
npx playwright test --headed # you can probably guess
|
||||
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
|
||||
|
||||
+33
-2
@@ -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>
|
||||
|
||||
@@ -12,25 +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.
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
@@ -71,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.**
|
||||
|
||||
@@ -89,10 +82,10 @@ Access the app at http://localhost:8000.
|
||||
|
||||
Source checkouts expect a normal frontend build in `frontend/dist`.
|
||||
|
||||
> [!NOTE]
|
||||
> [!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`.
|
||||
|
||||
> [!TIP]
|
||||
> [!NOTE]
|
||||
> On Linux, you can also install RemoteTerm as a persistent `systemd` service that starts on boot and restarts automatically on failure:
|
||||
>
|
||||
> ```bash
|
||||
@@ -101,7 +94,7 @@ Source checkouts expect a normal frontend build in `frontend/dist`.
|
||||
>
|
||||
> For the full service workflow and post-install operations, see [README_ADVANCED.md](README_ADVANCED.md).
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -123,6 +116,8 @@ cp docker-compose.example.yml docker-compose.yml
|
||||
bash scripts/setup/install_docker.sh
|
||||
```
|
||||
|
||||
> 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.
|
||||
@@ -142,6 +137,8 @@ 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
|
||||
@@ -168,6 +165,29 @@ To stop:
|
||||
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.
|
||||
@@ -206,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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
+11
-7
@@ -40,7 +40,7 @@ app/
|
||||
│ ├── contact_reconciliation.py # Prefix-claim, sender-key backfill, name-history wiring
|
||||
│ ├── radio_lifecycle.py # Post-connect setup and reconnect/setup helpers
|
||||
│ ├── radio_commands.py # Radio config/private-key command workflows
|
||||
│ ├── radio_noise_floor.py # In-memory local radio noise-floor sampling/history
|
||||
│ ├── radio_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
|
||||
@@ -161,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`)
|
||||
|
||||
@@ -244,7 +246,7 @@ 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
|
||||
@@ -286,6 +288,8 @@ Main tables:
|
||||
- `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:
|
||||
@@ -301,14 +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`
|
||||
- `last_message_times`
|
||||
- `preferences_migrated`
|
||||
- `advert_interval`
|
||||
- `last_advert_time`
|
||||
- `flood_scope`
|
||||
- `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).
|
||||
|
||||
|
||||
+5
-3
@@ -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 (
|
||||
@@ -37,7 +38,8 @@ CREATE TABLE IF NOT EXISTS channels (
|
||||
on_radio INTEGER DEFAULT 0,
|
||||
flood_scope_override TEXT,
|
||||
path_hash_mode_override INTEGER,
|
||||
last_read_at INTEGER
|
||||
last_read_at INTEGER,
|
||||
favorite INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
@@ -134,7 +136,7 @@ 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))
|
||||
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
|
||||
|
||||
+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)
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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()):
|
||||
|
||||
+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."
|
||||
)
|
||||
|
||||
+40
-4
@@ -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
|
||||
|
||||
@@ -40,8 +76,8 @@ from app.routers import (
|
||||
ws,
|
||||
)
|
||||
from app.security import add_optional_basic_auth_middleware
|
||||
from app.services.radio_noise_floor import start_noise_floor_sampling, stop_noise_floor_sampling
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
from app.services.radio_stats import start_radio_stats_sampling, stop_radio_stats_sampling
|
||||
from app.version_info import get_app_build_info
|
||||
|
||||
setup_logging()
|
||||
@@ -72,7 +108,7 @@ async def lifespan(app: FastAPI):
|
||||
from app.radio_sync import ensure_default_channels
|
||||
|
||||
await ensure_default_channels()
|
||||
await start_noise_floor_sampling()
|
||||
await start_radio_stats_sampling()
|
||||
|
||||
# Always start connection monitor (even if initial connection failed)
|
||||
await radio_manager.start_connection_monitor()
|
||||
@@ -101,7 +137,7 @@ async def lifespan(app: FastAPI):
|
||||
await radio_manager.stop_connection_monitor()
|
||||
await stop_background_contact_reconciliation()
|
||||
await stop_message_polling()
|
||||
await stop_noise_floor_sampling()
|
||||
await stop_radio_stats_sampling()
|
||||
await stop_periodic_advert()
|
||||
await stop_periodic_sync()
|
||||
await stop_telemetry_collect()
|
||||
|
||||
@@ -413,6 +413,18 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
|
||||
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)
|
||||
@@ -3213,3 +3225,129 @@ async def _migrate_054_auto_resend_channel(conn: aiosqlite.Connection) -> None:
|
||||
"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()
|
||||
|
||||
+29
-17
@@ -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
|
||||
@@ -326,6 +345,7 @@ class Channel(BaseModel):
|
||||
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):
|
||||
@@ -756,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."""
|
||||
|
||||
@@ -790,9 +803,6 @@ 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=True,
|
||||
description="Whether to attempt historical DM decryption on new contact advertisement",
|
||||
@@ -867,13 +877,14 @@ class NoiseFloorHistoryStats(BaseModel):
|
||||
latest_timestamp: int | None = Field(
|
||||
default=None, description="Unix timestamp of the most recent sample"
|
||||
)
|
||||
supported: bool | None = Field(
|
||||
default=None,
|
||||
description="Whether the connected radio appears to support radio stats sampling",
|
||||
)
|
||||
samples: list[NoiseFloorSample] = Field(default_factory=list)
|
||||
|
||||
|
||||
class 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):
|
||||
busiest_channels_24h: list[BusyChannel]
|
||||
contact_count: int
|
||||
@@ -889,6 +900,7 @@ 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
|
||||
|
||||
|
||||
|
||||
+24
-3
@@ -39,6 +39,7 @@ from app.repository import (
|
||||
ChannelRepository,
|
||||
ContactAdvertPathRepository,
|
||||
ContactRepository,
|
||||
MessageRepository,
|
||||
RawPacketRepository,
|
||||
)
|
||||
from app.services.contact_reconciliation import (
|
||||
@@ -645,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 "",
|
||||
)
|
||||
@@ -664,7 +685,7 @@ async def _process_direct_message(
|
||||
path_len=packet_info.path_length if packet_info else None,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
outgoing=is_outgoing,
|
||||
outgoing=effective_outgoing,
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
+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()
|
||||
|
||||
+49
-31
@@ -21,7 +21,7 @@ from meshcore import EventType, MeshCore
|
||||
from app.channel_constants import PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME
|
||||
from app.config import settings
|
||||
from app.event_handlers import cleanup_expired_acks, on_contact_message
|
||||
from app.models import Contact, ContactUpsert
|
||||
from app.models import _VALID_CONTACT_TYPES, Contact, ContactUpsert
|
||||
from app.radio import RadioOperationBusyError
|
||||
from app.repository import (
|
||||
AmbiguousPublicKeyPrefixError,
|
||||
@@ -307,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)
|
||||
@@ -428,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.
|
||||
@@ -481,7 +480,7 @@ async def drain_pending_messages(mc: MeshCore) -> int:
|
||||
# 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)
|
||||
@@ -519,7 +518,7 @@ async def poll_for_messages(mc: MeshCore) -> int:
|
||||
# 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)
|
||||
@@ -944,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
|
||||
@@ -1057,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(
|
||||
@@ -1071,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)
|
||||
@@ -1283,26 +1302,9 @@ async def get_contacts_selected_for_radio_sync() -> list[Contact]:
|
||||
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
|
||||
@@ -1583,9 +1585,10 @@ async def _collect_repeater_telemetry(mc: MeshCore, contact: Contact) -> bool:
|
||||
}
|
||||
|
||||
try:
|
||||
timestamp = int(time.time())
|
||||
await RepeaterTelemetryRepository.record(
|
||||
public_key=contact.public_key,
|
||||
timestamp=int(time.time()),
|
||||
timestamp=timestamp,
|
||||
data=data,
|
||||
)
|
||||
logger.info(
|
||||
@@ -1593,6 +1596,21 @@ async def _collect_repeater_telemetry(mc: MeshCore, contact: Contact) -> bool:
|
||||
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(
|
||||
|
||||
@@ -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, path_hash_mode_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 = ?
|
||||
""",
|
||||
@@ -42,6 +42,7 @@ class ChannelRepository:
|
||||
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
|
||||
|
||||
@@ -49,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, path_hash_mode_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
|
||||
"""
|
||||
@@ -64,10 +65,21 @@ class ChannelRepository:
|
||||
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 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(
|
||||
"UPDATE channels SET favorite = ? WHERE key = ?",
|
||||
(1 if value else 0, key.upper()),
|
||||
)
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
async def delete(key: str) -> None:
|
||||
"""Delete a channel by key."""
|
||||
|
||||
@@ -170,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"],
|
||||
@@ -392,6 +393,24 @@ 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()
|
||||
@@ -673,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()
|
||||
|
||||
@@ -686,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 [
|
||||
|
||||
@@ -557,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
|
||||
|
||||
+25
-45
@@ -1,16 +1,17 @@
|
||||
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.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
|
||||
|
||||
@@ -26,7 +27,7 @@ class AppSettingsRepository:
|
||||
"""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert,
|
||||
SELECT max_radio_contacts, auto_decrypt_dm_on_advert,
|
||||
last_message_times,
|
||||
advert_interval, last_advert_time, flood_scope,
|
||||
blocked_keys, blocked_names, discovery_blocked_types,
|
||||
@@ -40,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"]:
|
||||
@@ -107,7 +94,6 @@ class AppSettingsRepository:
|
||||
|
||||
return AppSettings(
|
||||
max_radio_contacts=row["max_radio_contacts"],
|
||||
favorites=favorites,
|
||||
auto_decrypt_dm_on_advert=bool(row["auto_decrypt_dm_on_advert"]),
|
||||
last_message_times=last_message_times,
|
||||
advert_interval=row["advert_interval"] or 0,
|
||||
@@ -123,7 +109,6 @@ class AppSettingsRepository:
|
||||
@staticmethod
|
||||
async def update(
|
||||
max_radio_contacts: int | None = None,
|
||||
favorites: list[Favorite] | None = None,
|
||||
auto_decrypt_dm_on_advert: bool | None = None,
|
||||
last_message_times: dict[str, int] | None = None,
|
||||
advert_interval: int | None = None,
|
||||
@@ -143,11 +128,6 @@ 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)
|
||||
@@ -195,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."""
|
||||
@@ -316,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."""
|
||||
@@ -392,6 +370,7 @@ class StatisticsRepository:
|
||||
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,
|
||||
@@ -408,4 +387,5 @@ class StatisticsRepository:
|
||||
"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,
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import os
|
||||
import platform
|
||||
import struct
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter
|
||||
@@ -390,7 +390,7 @@ async def debug_support_snapshot() -> DebugSnapshotResponse:
|
||||
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=_build_debug_health_summary(health_data, radio_state=radio_state),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -473,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(
|
||||
@@ -536,7 +536,7 @@ async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse:
|
||||
timeout_seconds = _trace_timeout_seconds(send_result)
|
||||
try:
|
||||
event = await asyncio.wait_for(response_task, timeout=timeout_seconds)
|
||||
except asyncio.TimeoutError as exc:
|
||||
except TimeoutError as exc:
|
||||
raise HTTPException(status_code=504, detail="No trace response heard") from exc
|
||||
finally:
|
||||
if not response_task.done():
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
|
||||
@@ -133,6 +134,20 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
|
||||
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)
|
||||
|
||||
|
||||
@@ -94,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)
|
||||
@@ -196,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,
|
||||
|
||||
+27
-18
@@ -7,7 +7,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.models import CONTACT_TYPE_REPEATER, AppSettings
|
||||
from app.region_scope import normalize_region_scope
|
||||
from app.repository import AppSettingsRepository, ContactRepository
|
||||
from app.repository import AppSettingsRepository, ChannelRepository, ContactRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
@@ -72,6 +72,12 @@ class FavoriteRequest(BaseModel):
|
||||
id: str = Field(description="Channel key or contact public key")
|
||||
|
||||
|
||||
class FavoriteToggleResponse(BaseModel):
|
||||
type: Literal["channel", "contact"]
|
||||
id: str
|
||||
favorite: bool
|
||||
|
||||
|
||||
class TrackedTelemetryRequest(BaseModel):
|
||||
public_key: str = Field(description="Public key of the repeater to toggle tracking")
|
||||
|
||||
@@ -157,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)
|
||||
|
||||
@@ -2,7 +2,7 @@ from fastapi import APIRouter
|
||||
|
||||
from app.models import StatisticsResponse
|
||||
from app.repository import StatisticsRepository
|
||||
from app.services.radio_noise_floor import get_noise_floor_history
|
||||
from app.services.radio_stats import get_noise_floor_history
|
||||
|
||||
router = APIRouter(prefix="/statistics", tags=["statistics"])
|
||||
|
||||
@@ -10,5 +10,5 @@ router = APIRouter(prefix="/statistics", tags=["statistics"])
|
||||
@router.get("", response_model=StatisticsResponse)
|
||||
async def get_statistics() -> StatisticsResponse:
|
||||
data = await StatisticsRepository.get_all()
|
||||
data["noise_floor_24h"] = await get_noise_floor_history()
|
||||
data["noise_floor_24h"] = get_noise_floor_history()
|
||||
return StatisticsResponse(**data)
|
||||
|
||||
@@ -264,38 +264,43 @@ 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."
|
||||
),
|
||||
)
|
||||
|
||||
@@ -421,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
|
||||
@@ -463,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",
|
||||
@@ -470,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:
|
||||
@@ -479,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:
|
||||
@@ -489,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):
|
||||
@@ -516,6 +533,8 @@ async def _retry_direct_message_until_acked(
|
||||
if ack_count > 0:
|
||||
return
|
||||
|
||||
attempt += 1
|
||||
|
||||
|
||||
async def send_direct_message_to_contact(
|
||||
*,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -193,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"
|
||||
),
|
||||
)
|
||||
@@ -215,7 +215,14 @@ async def run_post_connect_setup(radio_manager) -> None:
|
||||
# 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)
|
||||
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),
|
||||
)
|
||||
|
||||
# Send advertisement to announce our presence (if enabled and not throttled)
|
||||
if await send_advertisement(mc):
|
||||
@@ -267,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",
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
"""In-memory local-radio noise floor history sampling."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from collections import deque
|
||||
|
||||
from meshcore import EventType
|
||||
|
||||
from app.radio import RadioDisconnectedError, RadioOperationBusyError
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NOISE_FLOOR_SAMPLE_INTERVAL_SECONDS = 300
|
||||
NOISE_FLOOR_WINDOW_SECONDS = 24 * 60 * 60
|
||||
MAX_NOISE_FLOOR_SAMPLES = 300
|
||||
|
||||
_noise_floor_task: asyncio.Task | None = None
|
||||
_noise_floor_samples: deque[tuple[int, int]] = deque(maxlen=MAX_NOISE_FLOOR_SAMPLES)
|
||||
_noise_floor_supported: bool | None = None
|
||||
_samples_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def _append_sample(timestamp: int, noise_floor_dbm: int) -> None:
|
||||
async with _samples_lock:
|
||||
_noise_floor_samples.append((timestamp, noise_floor_dbm))
|
||||
|
||||
|
||||
async def sample_noise_floor_once(*, blocking: bool = False) -> None:
|
||||
"""Fetch the current radio noise floor once and record it when available."""
|
||||
global _noise_floor_supported
|
||||
|
||||
if not radio_manager.is_connected:
|
||||
return
|
||||
|
||||
try:
|
||||
async with radio_manager.radio_operation("noise_floor_sample", blocking=blocking) as mc:
|
||||
event = await mc.commands.get_stats_radio()
|
||||
except (RadioDisconnectedError, RadioOperationBusyError):
|
||||
return
|
||||
except Exception as exc:
|
||||
logger.debug("Noise floor sampling failed: %s", exc)
|
||||
return
|
||||
|
||||
if event.type == EventType.ERROR:
|
||||
_noise_floor_supported = False
|
||||
return
|
||||
|
||||
if event.type != EventType.STATS_RADIO:
|
||||
return
|
||||
|
||||
noise_floor = event.payload.get("noise_floor")
|
||||
if not isinstance(noise_floor, int):
|
||||
return
|
||||
|
||||
_noise_floor_supported = True
|
||||
await _append_sample(int(time.time()), noise_floor)
|
||||
|
||||
|
||||
async def _noise_floor_sampling_loop() -> None:
|
||||
while True:
|
||||
try:
|
||||
await sample_noise_floor_once()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Noise floor sampling loop crashed during sample")
|
||||
|
||||
try:
|
||||
await asyncio.sleep(NOISE_FLOOR_SAMPLE_INTERVAL_SECONDS)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
|
||||
|
||||
async def start_noise_floor_sampling() -> None:
|
||||
global _noise_floor_task
|
||||
if _noise_floor_task is not None and not _noise_floor_task.done():
|
||||
return
|
||||
_noise_floor_task = asyncio.create_task(_noise_floor_sampling_loop())
|
||||
|
||||
|
||||
async def stop_noise_floor_sampling() -> None:
|
||||
global _noise_floor_task
|
||||
if _noise_floor_task is None:
|
||||
return
|
||||
if not _noise_floor_task.done():
|
||||
_noise_floor_task.cancel()
|
||||
try:
|
||||
await _noise_floor_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
_noise_floor_task = None
|
||||
|
||||
|
||||
async def get_noise_floor_history() -> dict:
|
||||
"""Return the current 24-hour in-memory noise floor history snapshot."""
|
||||
now = int(time.time())
|
||||
cutoff = now - NOISE_FLOOR_WINDOW_SECONDS
|
||||
|
||||
async with _samples_lock:
|
||||
samples = [
|
||||
{"timestamp": timestamp, "noise_floor_dbm": noise_floor_dbm}
|
||||
for timestamp, noise_floor_dbm in _noise_floor_samples
|
||||
if timestamp >= cutoff
|
||||
]
|
||||
|
||||
latest = samples[-1] if samples else None
|
||||
oldest_timestamp = samples[0]["timestamp"] if samples else None
|
||||
coverage_seconds = 0 if oldest_timestamp is None else max(0, now - oldest_timestamp)
|
||||
|
||||
return {
|
||||
"sample_interval_seconds": NOISE_FLOOR_SAMPLE_INTERVAL_SECONDS,
|
||||
"coverage_seconds": coverage_seconds,
|
||||
"latest_noise_floor_dbm": latest["noise_floor_dbm"] if latest else None,
|
||||
"latest_timestamp": latest["timestamp"] if latest else None,
|
||||
"supported": _noise_floor_supported,
|
||||
"samples": samples,
|
||||
}
|
||||
@@ -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
-1
@@ -59,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:
|
||||
@@ -110,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:
|
||||
|
||||
@@ -31,7 +31,7 @@ services:
|
||||
|
||||
# TCP
|
||||
# MESHCORE_TCP_HOST: 192.168.1.100
|
||||
# MESHCORE_TCP_PORT: 4000
|
||||
# MESHCORE_TCP_PORT: 5000
|
||||
|
||||
# BLE
|
||||
# BLE in Docker usually needs additional manual compose changes such as
|
||||
|
||||
+2
-2
@@ -348,14 +348,14 @@ LocalStorage migration helpers for favorites; canonical favorites are server-sid
|
||||
|
||||
`AppSettings` currently includes:
|
||||
- `max_radio_contacts`
|
||||
- `favorites`
|
||||
- `auto_decrypt_dm_on_advert`
|
||||
- `last_message_times`
|
||||
- `preferences_migrated`
|
||||
- `advert_interval`
|
||||
- `last_advert_time`
|
||||
- `flood_scope`
|
||||
- `blocked_keys`, `blocked_names`, `discovery_blocked_types`
|
||||
- `tracked_telemetry_repeaters`
|
||||
- `auto_resend_channel`
|
||||
|
||||
|
||||
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`.
|
||||
|
||||
+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
+19
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"version": "3.6.3",
|
||||
"version": "3.8.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"version": "3.6.3",
|
||||
"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",
|
||||
@@ -3687,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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"private": true,
|
||||
"version": "3.8.0",
|
||||
"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",
|
||||
|
||||
+68
-9
@@ -18,13 +18,15 @@ 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 { BulkCreateHashtagChannelsResult, Conversation, Message, RawPacket } from './types';
|
||||
import { CONTACT_TYPE_ROOM } from './types';
|
||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from './types';
|
||||
import { shouldAutoFocusInput } from './utils/autoFocusInput';
|
||||
|
||||
interface ChannelUnreadMarker {
|
||||
channelId: string;
|
||||
@@ -87,6 +89,7 @@ export function App() {
|
||||
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 {
|
||||
@@ -150,10 +153,8 @@ export function App() {
|
||||
|
||||
const {
|
||||
appSettings,
|
||||
favorites,
|
||||
fetchAppSettings,
|
||||
handleSaveAppSettings,
|
||||
handleToggleFavorite,
|
||||
handleToggleBlockedKey,
|
||||
handleToggleBlockedName,
|
||||
handleToggleTrackedTelemetry,
|
||||
@@ -204,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,
|
||||
@@ -264,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.
|
||||
@@ -290,8 +338,8 @@ export function App() {
|
||||
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') {
|
||||
@@ -426,6 +474,18 @@ 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);
|
||||
@@ -491,7 +551,6 @@ export function App() {
|
||||
onMarkAllRead: () => {
|
||||
void markAllRead();
|
||||
},
|
||||
favorites,
|
||||
isConversationNotificationsEnabled,
|
||||
blockedKeys: appSettings?.blocked_keys ?? [],
|
||||
blockedNames: appSettings?.blocked_names ?? [],
|
||||
@@ -507,7 +566,6 @@ export function App() {
|
||||
rawPacketStatsSession,
|
||||
config,
|
||||
health,
|
||||
favorites,
|
||||
messages: sortedMessages,
|
||||
preSorted: activeContactIsRoom,
|
||||
messagesLoading,
|
||||
@@ -558,6 +616,8 @@ export function App() {
|
||||
},
|
||||
trackedTelemetryRepeaters: appSettings?.tracked_telemetry_repeaters ?? [],
|
||||
onToggleTrackedTelemetry: handleToggleTrackedTelemetry,
|
||||
repeaterAutoLoginKey,
|
||||
onClearRepeaterAutoLogin: () => setRepeaterAutoLoginKey(null),
|
||||
};
|
||||
const searchProps = {
|
||||
contacts,
|
||||
@@ -614,7 +674,6 @@ export function App() {
|
||||
onClose: handleCloseContactInfo,
|
||||
contacts,
|
||||
config,
|
||||
favorites,
|
||||
onToggleFavorite: handleToggleFavorite,
|
||||
onNavigateToChannel: handleNavigateToChannel,
|
||||
onSearchMessagesByKey: (publicKey: string) => {
|
||||
@@ -632,7 +691,6 @@ export function App() {
|
||||
channelKey: infoPaneChannelKey,
|
||||
onClose: handleCloseChannelInfo,
|
||||
channels,
|
||||
favorites,
|
||||
onToggleFavorite: handleToggleFavorite,
|
||||
};
|
||||
|
||||
@@ -693,6 +751,7 @@ export function App() {
|
||||
bulkAddChannelResultModalProps={bulkAddChannelResultModalProps}
|
||||
contactInfoPaneProps={contactInfoPaneProps}
|
||||
channelInfoPaneProps={channelInfoPaneProps}
|
||||
onRepeaterAutoLogin={handleRepeaterAutoLogin}
|
||||
/>
|
||||
</DistanceUnitProvider>
|
||||
);
|
||||
|
||||
+3
-4
@@ -9,7 +9,6 @@ import type {
|
||||
ContactAnalytics,
|
||||
ContactAdvertPathSummary,
|
||||
FanoutConfig,
|
||||
Favorite,
|
||||
HealthStatus,
|
||||
MaintenanceResult,
|
||||
Message,
|
||||
@@ -40,7 +39,7 @@ import type {
|
||||
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;
|
||||
@@ -334,8 +333,8 @@ export const api = {
|
||||
}),
|
||||
|
||||
// Favorites
|
||||
toggleFavorite: (type: Favorite['type'], id: string) =>
|
||||
fetchJson<AppSettings>('/settings/favorites/toggle', {
|
||||
toggleFavorite: (type: 'channel' | 'contact', id: string) =>
|
||||
fetchJson<{ type: string; id: string; favorite: boolean }>('/settings/favorites/toggle', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ type, id }),
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
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';
|
||||
@@ -8,6 +8,7 @@ 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';
|
||||
@@ -71,6 +72,7 @@ interface AppShellProps {
|
||||
bulkAddChannelResultModalProps: BulkAddChannelResultModalProps;
|
||||
contactInfoPaneProps: ContactInfoPaneProps;
|
||||
channelInfoPaneProps: ChannelInfoPaneProps;
|
||||
onRepeaterAutoLogin: (publicKey: string, displayName: string) => void;
|
||||
}
|
||||
|
||||
export function AppShell({
|
||||
@@ -100,6 +102,7 @@ export function AppShell({
|
||||
bulkAddChannelResultModalProps,
|
||||
contactInfoPaneProps,
|
||||
channelInfoPaneProps,
|
||||
onRepeaterAutoLogin,
|
||||
}: AppShellProps) {
|
||||
const swipeHandlers = useSwipeable({
|
||||
onSwipedRight: ({ initial }) => {
|
||||
@@ -119,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;
|
||||
@@ -299,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>
|
||||
}
|
||||
>
|
||||
@@ -323,6 +334,13 @@ export function AppShell({
|
||||
onClose={onCloseBulkAddResults}
|
||||
/>
|
||||
|
||||
<CommandPalette
|
||||
contacts={sidebarProps.contacts}
|
||||
channels={sidebarProps.channels}
|
||||
onSelectConversation={sidebarProps.onSelectConversation}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
onRepeaterAutoLogin={onRepeaterAutoLogin}
|
||||
/>
|
||||
<SecurityWarningModal health={statusProps.health} />
|
||||
<ContactInfoPane {...contactInfoPaneProps} />
|
||||
<ChannelInfoPane {...channelInfoPaneProps} />
|
||||
|
||||
@@ -3,17 +3,15 @@ import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip as RechartsTooltip }
|
||||
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, PathHashWidthStats } 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;
|
||||
}
|
||||
|
||||
@@ -21,7 +19,6 @@ export function ChannelInfoPane({
|
||||
channelKey,
|
||||
onClose,
|
||||
channels,
|
||||
favorites,
|
||||
onToggleFavorite,
|
||||
}: ChannelInfoPaneProps) {
|
||||
const [detail, setDetail] = useState<ChannelDetail | null>(null);
|
||||
@@ -125,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>
|
||||
|
||||
@@ -53,17 +53,17 @@ export function ChannelPathHashModeOverrideModal({
|
||||
{
|
||||
value: 0,
|
||||
label: '1-byte hop identifiers',
|
||||
description: 'Shortest paths, least repeater disambiguation',
|
||||
description: 'Least repeater disambiguation, up to 63 hops',
|
||||
},
|
||||
{
|
||||
value: 1,
|
||||
label: '2-byte hop identifiers',
|
||||
description: 'Better repeater disambiguation',
|
||||
description: 'Better repeater disambiguation, up to 32 hops',
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
label: '3-byte hop identifiers',
|
||||
description: 'Best repeater disambiguation, longest paths',
|
||||
description: 'Best repeater disambiguation, up to 21 hops',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { DirectTraceIcon } from './DirectTraceIcon';
|
||||
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
|
||||
import { ChannelFloodScopeOverrideModal } from './ChannelFloodScopeOverrideModal';
|
||||
import { ChannelPathHashModeOverrideModal } from './ChannelPathHashModeOverrideModal';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { isPublicChannelKey } from '../utils/publicChannel';
|
||||
import { stripRegionScopePrefix } from '../utils/regionScope';
|
||||
@@ -13,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 {
|
||||
@@ -28,7 +20,6 @@ interface ChatHeaderProps {
|
||||
contacts: Contact[];
|
||||
channels: Channel[];
|
||||
config: RadioConfig | null;
|
||||
favorites: Favorite[];
|
||||
notificationsSupported: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
notificationsPermission: NotificationPermission | 'unsupported';
|
||||
@@ -49,7 +40,6 @@ export function ChatHeader({
|
||||
contacts,
|
||||
channels,
|
||||
config,
|
||||
favorites,
|
||||
notificationsSupported,
|
||||
notificationsEnabled,
|
||||
notificationsPermission,
|
||||
@@ -105,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';
|
||||
|
||||
@@ -359,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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -29,7 +29,6 @@ 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';
|
||||
@@ -42,7 +41,6 @@ import type {
|
||||
ContactAnalytics,
|
||||
ContactAnalyticsHourlyBucket,
|
||||
ContactAnalyticsWeeklyBucket,
|
||||
Favorite,
|
||||
RadioConfig,
|
||||
} from '../types';
|
||||
|
||||
@@ -67,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;
|
||||
@@ -84,7 +81,6 @@ export function ContactInfoPane({
|
||||
onClose,
|
||||
contacts,
|
||||
config,
|
||||
favorites,
|
||||
onToggleFavorite,
|
||||
onNavigateToChannel,
|
||||
onSearchMessagesByKey,
|
||||
@@ -384,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>
|
||||
|
||||
@@ -10,7 +10,6 @@ import type {
|
||||
Channel,
|
||||
Contact,
|
||||
Conversation,
|
||||
Favorite,
|
||||
HealthStatus,
|
||||
Message,
|
||||
PathDiscoveryResponse,
|
||||
@@ -42,7 +41,6 @@ interface ConversationPaneProps {
|
||||
notificationsSupported: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
notificationsPermission: NotificationPermission | 'unsupported';
|
||||
favorites: Favorite[];
|
||||
messages: Message[];
|
||||
preSorted?: boolean;
|
||||
messagesLoading: boolean;
|
||||
@@ -81,6 +79,8 @@ interface ConversationPaneProps {
|
||||
onToggleNotifications: () => void;
|
||||
trackedTelemetryRepeaters: string[];
|
||||
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
|
||||
repeaterAutoLoginKey: string | null;
|
||||
onClearRepeaterAutoLogin: () => void;
|
||||
}
|
||||
|
||||
function LoadingPane({ label }: { label: string }) {
|
||||
@@ -119,7 +119,6 @@ export function ConversationPane({
|
||||
notificationsSupported,
|
||||
notificationsEnabled,
|
||||
notificationsPermission,
|
||||
favorites,
|
||||
messages,
|
||||
preSorted,
|
||||
messagesLoading,
|
||||
@@ -152,6 +151,8 @@ export function ConversationPane({
|
||||
onToggleNotifications,
|
||||
trackedTelemetryRepeaters,
|
||||
onToggleTrackedTelemetry,
|
||||
repeaterAutoLoginKey,
|
||||
onClearRepeaterAutoLogin,
|
||||
}: ConversationPaneProps) {
|
||||
const [roomAuthenticated, setRoomAuthenticated] = useState(false);
|
||||
const activeContactIsRepeater = useMemo(() => {
|
||||
@@ -237,7 +238,6 @@ export function ConversationPane({
|
||||
key={activeConversation.id}
|
||||
conversation={activeConversation}
|
||||
contacts={contacts}
|
||||
favorites={favorites}
|
||||
notificationsSupported={notificationsSupported}
|
||||
notificationsEnabled={notificationsEnabled}
|
||||
notificationsPermission={notificationsPermission}
|
||||
@@ -252,6 +252,8 @@ export function ConversationPane({
|
||||
onOpenContactInfo={onOpenContactInfo}
|
||||
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
|
||||
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
|
||||
autoLoginAndLoadAll={repeaterAutoLoginKey === activeConversation.id}
|
||||
onAutoLoginConsumed={onClearRepeaterAutoLogin}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
@@ -266,7 +268,6 @@ export function ConversationPane({
|
||||
contacts={contacts}
|
||||
channels={channels}
|
||||
config={config}
|
||||
favorites={favorites}
|
||||
notificationsSupported={notificationsSupported}
|
||||
notificationsEnabled={notificationsEnabled}
|
||||
notificationsPermission={notificationsPermission}
|
||||
|
||||
@@ -98,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]);
|
||||
@@ -356,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',
|
||||
});
|
||||
@@ -409,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;
|
||||
}
|
||||
@@ -537,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>
|
||||
@@ -581,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"
|
||||
@@ -593,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 && (
|
||||
@@ -603,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
|
||||
@@ -630,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
|
||||
@@ -639,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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -991,7 +991,7 @@ export function MessageList({
|
||||
displaySender
|
||||
)}
|
||||
<span className="font-normal text-muted-foreground ml-2 text-[0.6875rem]">
|
||||
{formatTime(msg.sender_timestamp || msg.received_at)}
|
||||
{formatTime(msg.received_at)}
|
||||
</span>
|
||||
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
|
||||
<HopCountBadge
|
||||
@@ -1019,7 +1019,7 @@ export function MessageList({
|
||||
{!showAvatar && (
|
||||
<>
|
||||
<span className="text-[0.625rem] text-muted-foreground ml-2">
|
||||
{formatTime(msg.sender_timestamp || msg.received_at)}
|
||||
{formatTime(msg.received_at)}
|
||||
</span>
|
||||
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
|
||||
<HopCountBadge
|
||||
|
||||
@@ -11,11 +11,14 @@ import {
|
||||
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,
|
||||
@@ -24,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;
|
||||
@@ -428,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(() => {
|
||||
@@ -468,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
|
||||
|
||||
@@ -9,17 +9,10 @@ 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,
|
||||
TelemetryHistoryEntry,
|
||||
} 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';
|
||||
@@ -41,7 +34,6 @@ export { formatDuration, formatClockDrift } from './repeater/repeaterPaneShared'
|
||||
interface RepeaterDashboardProps {
|
||||
conversation: Conversation;
|
||||
contacts: Contact[];
|
||||
favorites: Favorite[];
|
||||
notificationsSupported: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
notificationsPermission: NotificationPermission | 'unsupported';
|
||||
@@ -56,12 +48,13 @@ interface RepeaterDashboardProps {
|
||||
onOpenContactInfo?: (publicKey: string) => void;
|
||||
trackedTelemetryRepeaters: string[];
|
||||
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
|
||||
autoLoginAndLoadAll?: boolean;
|
||||
onAutoLoginConsumed?: () => void;
|
||||
}
|
||||
|
||||
export function RepeaterDashboard({
|
||||
conversation,
|
||||
contacts,
|
||||
favorites,
|
||||
notificationsSupported,
|
||||
notificationsEnabled,
|
||||
notificationsPermission,
|
||||
@@ -76,6 +69,8 @@ export function RepeaterDashboard({
|
||||
onOpenContactInfo,
|
||||
trackedTelemetryRepeaters,
|
||||
onToggleTrackedTelemetry,
|
||||
autoLoginAndLoadAll,
|
||||
onAutoLoginConsumed,
|
||||
}: RepeaterDashboardProps) {
|
||||
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
|
||||
const contact = contacts.find((c) => c.public_key === conversation.id) ?? null;
|
||||
@@ -134,7 +129,16 @@ export function RepeaterDashboard({
|
||||
setTelemetryHistory(liveHistory);
|
||||
}, [paneData.status?.telemetry_history]);
|
||||
|
||||
const isFav = isFavorite(favorites, 'contact', conversation.id);
|
||||
// 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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
type Contact,
|
||||
type Channel,
|
||||
type Conversation,
|
||||
type Favorite,
|
||||
} from '../types';
|
||||
import {
|
||||
buildSidebarSectionSortOrders,
|
||||
@@ -36,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';
|
||||
@@ -106,7 +104,6 @@ interface SidebarProps {
|
||||
crackerRunning: boolean;
|
||||
onToggleCracker: () => void;
|
||||
onMarkAllRead: () => void;
|
||||
favorites: Favorite[];
|
||||
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
|
||||
blockedKeys?: string[];
|
||||
blockedNames?: string[];
|
||||
@@ -135,7 +132,6 @@ export function Sidebar({
|
||||
crackerRunning,
|
||||
onToggleCracker,
|
||||
onMarkAllRead,
|
||||
favorites,
|
||||
isConversationNotificationsEnabled,
|
||||
blockedKeys = [],
|
||||
blockedNames = [],
|
||||
@@ -488,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 })),
|
||||
@@ -522,7 +512,6 @@ export function Sidebar({
|
||||
filteredNonRepeaterContacts,
|
||||
filteredRooms,
|
||||
filteredRepeaters,
|
||||
favorites,
|
||||
sectionSortOrders.favorites,
|
||||
sortFavoriteItemsByOrder,
|
||||
]);
|
||||
|
||||
@@ -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,6 +162,18 @@ 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>
|
||||
|
||||
@@ -9,7 +9,8 @@ import type {
|
||||
RadioTraceResponse,
|
||||
} from '../types';
|
||||
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||
import { calculateDistance, isValidLocation } from '../utils/pathUtils';
|
||||
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';
|
||||
@@ -28,6 +29,48 @@ 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 };
|
||||
@@ -144,6 +187,7 @@ function TraceNodeRow({
|
||||
}
|
||||
|
||||
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[]>([]);
|
||||
@@ -154,6 +198,7 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
|
||||
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(() => {
|
||||
@@ -272,6 +317,56 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
|
||||
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;
|
||||
@@ -292,6 +387,27 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
|
||||
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;
|
||||
@@ -422,7 +538,7 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
|
||||
</div>
|
||||
{sortMode === 'distance' && distanceKm !== null ? (
|
||||
<div className="mt-1 text-[0.6875rem] text-muted-foreground">
|
||||
{distanceKm.toFixed(1)} km away
|
||||
{formatDistance(distanceKm, distanceUnit)} away
|
||||
</div>
|
||||
) : null}
|
||||
{selectedCount > 0 ? (
|
||||
@@ -453,6 +569,39 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
|
||||
<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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -90,6 +90,15 @@ export function TelemetryHistoryPane({
|
||||
|
||||
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 {
|
||||
@@ -208,6 +217,7 @@ export function TelemetryHistoryPane({
|
||||
tickFormatter={formatTime}
|
||||
/>
|
||||
<YAxis
|
||||
domain={yDomain}
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
|
||||
@@ -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';
|
||||
@@ -7,7 +7,13 @@ import { toast } from '../ui/sonner';
|
||||
import { api } from '../../api';
|
||||
import { formatTime } from '../../utils/messageParser';
|
||||
import { BulkDeleteContactsModal } from './BulkDeleteContactsModal';
|
||||
import type { AppSettings, AppSettingsUpdate, Contact, HealthStatus } from '../../types';
|
||||
import type {
|
||||
AppSettings,
|
||||
AppSettingsUpdate,
|
||||
Contact,
|
||||
HealthStatus,
|
||||
TelemetryHistoryEntry,
|
||||
} from '../../types';
|
||||
|
||||
export function SettingsDatabaseSection({
|
||||
appSettings,
|
||||
@@ -48,11 +54,35 @@ export function SettingsDatabaseSection({
|
||||
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) {
|
||||
@@ -242,28 +272,49 @@ export function SettingsDatabaseSection({
|
||||
No repeaters are being tracked. Enable tracking from a repeater's dashboard.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<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="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 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>
|
||||
{onToggleTrackedTelemetry && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onToggleTrackedTelemetry(key)}
|
||||
className="h-7 text-xs flex-shrink-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1666,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>
|
||||
|
||||
|
||||
@@ -27,6 +27,14 @@ import {
|
||||
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,
|
||||
@@ -48,6 +56,9 @@ export function SettingsLocalSection({
|
||||
});
|
||||
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()));
|
||||
@@ -129,85 +140,6 @@ export function SettingsLocalSection({
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="font-scale-input">Relative Font Size</Label>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<input
|
||||
type="range"
|
||||
min={MIN_FONT_SCALE}
|
||||
max={MAX_FONT_SCALE}
|
||||
step={FONT_SCALE_SLIDER_STEP}
|
||||
value={fontScaleSlider}
|
||||
onChange={(event) => handleSliderChange(Number(event.target.value))}
|
||||
onMouseUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
|
||||
onTouchEnd={(event) => handleSliderCommit(Number(event.currentTarget.value))}
|
||||
onKeyUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
|
||||
onBlur={(event) => handleSliderCommit(Number(event.currentTarget.value))}
|
||||
aria-label="Relative font size slider"
|
||||
className="w-full accent-primary sm:flex-1"
|
||||
/>
|
||||
<div className="flex items-center gap-2 sm:w-40">
|
||||
<Input
|
||||
id="font-scale-input"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={MIN_FONT_SCALE}
|
||||
max={MAX_FONT_SCALE}
|
||||
step="any"
|
||||
value={fontScaleInput}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setFontScaleInput(nextValue);
|
||||
|
||||
if (nextValue === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.validity.valid && Number.isFinite(event.target.valueAsNumber)) {
|
||||
commitFontScale(event.target.valueAsNumber);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
const parsed = Number.parseFloat(fontScaleInput);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
restoreFontScaleInput();
|
||||
return;
|
||||
}
|
||||
commitFontScale(parsed);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter') {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const parsed = Number.parseFloat(fontScaleInput);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
restoreFontScaleInput();
|
||||
return;
|
||||
}
|
||||
commitFontScale(parsed);
|
||||
}}
|
||||
aria-label="Relative font size percentage"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">%</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => commitFontScale(DEFAULT_FONT_SCALE)}
|
||||
className="inline-flex h-9 items-center justify-center rounded-md border border-input px-3 text-sm font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={fontScale === DEFAULT_FONT_SCALE}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Scales the app's typography for this browser only. The slider moves in 5% steps; the
|
||||
number field accepts any value from 25% to 400%.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="distance-units">Distance Units</Label>
|
||||
<select
|
||||
@@ -233,33 +165,165 @@ 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={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={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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -570,9 +570,9 @@ export function SettingsRadioSection({
|
||||
onChange={(e) => setPathHashMode(e.target.value)}
|
||||
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
>
|
||||
<option value="0">1 byte (default)</option>
|
||||
<option value="1">2 bytes</option>
|
||||
<option value="2">3 bytes</option>
|
||||
<option value="0">1 byte — up to 63 hops (default)</option>
|
||||
<option value="1">2 bytes — up to 32 hops</option>
|
||||
<option value="2">3 bytes — up to 21 hops</option>
|
||||
</select>
|
||||
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-xs text-warning">
|
||||
<p className="font-semibold mb-1">Compatibility Warning</p>
|
||||
|
||||
@@ -42,6 +42,87 @@ function formatTime(ts: number): string {
|
||||
});
|
||||
}
|
||||
|
||||
function formatDateTime(ts: number): string {
|
||||
const d = new Date(ts * 1000);
|
||||
return (
|
||||
d.toLocaleDateString([], { month: 'short', day: 'numeric' }) +
|
||||
' ' +
|
||||
d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
);
|
||||
}
|
||||
|
||||
function PacketsPerHourChart({ buckets }: { buckets: { timestamp: number; count: number }[] }) {
|
||||
// Fill gaps so hours with zero packets still appear on the chart
|
||||
const filled: { timestamp: number; count: number }[] = [];
|
||||
if (buckets.length > 0) {
|
||||
const first = buckets[0].timestamp;
|
||||
const last = buckets[buckets.length - 1].timestamp;
|
||||
const byTs = new Map(buckets.map((b) => [b.timestamp, b.count]));
|
||||
for (let ts = first; ts <= last; ts += 3600) {
|
||||
filled.push({ timestamp: ts, count: byTs.get(ts) ?? 0 });
|
||||
}
|
||||
}
|
||||
|
||||
const data = filled.map((b, i) => ({
|
||||
idx: i,
|
||||
label: formatDateTime(b.timestamp),
|
||||
count: b.count,
|
||||
}));
|
||||
|
||||
// Show ~6 evenly-spaced tick labels
|
||||
const tickCount = Math.min(6, data.length);
|
||||
const tickIndices: number[] = [];
|
||||
if (data.length > 1) {
|
||||
for (let i = 0; i < tickCount; i++) {
|
||||
tickIndices.push(Math.round((i / (tickCount - 1)) * (data.length - 1)));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<AreaChart data={data} margin={{ top: 4, right: 4, bottom: 0, left: -8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="idx"
|
||||
type="number"
|
||||
domain={[0, data.length - 1]}
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
ticks={tickIndices}
|
||||
tickFormatter={(idx) => data[idx]?.label ?? ''}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
{...TOOLTIP_STYLE}
|
||||
cursor={{
|
||||
stroke: 'hsl(var(--muted-foreground))',
|
||||
strokeWidth: 1,
|
||||
strokeDasharray: '3 3',
|
||||
}}
|
||||
labelFormatter={(idx) => data[Number(idx)]?.label ?? ''}
|
||||
formatter={(value) => [`${Number(value).toLocaleString()} packets`, 'Count']}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke="#0ea5e9"
|
||||
fill="#0ea5e9"
|
||||
fillOpacity={0.15}
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
activeDot={{ r: 4, fill: '#0ea5e9', strokeWidth: 2, stroke: 'hsl(var(--popover))' }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function NoiseFloorChart({
|
||||
samples,
|
||||
}: {
|
||||
@@ -241,6 +322,17 @@ export function SettingsStatisticsSection({ className }: { className?: string })
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Packets per Hour (72h) */}
|
||||
{stats.packets_per_hour_72h?.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Packets per Hour (72h)</h4>
|
||||
<PacketsPerHourChart buckets={stats.packets_per_hour_72h} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Path Hash Width */}
|
||||
@@ -355,7 +447,7 @@ export function SettingsStatisticsSection({ className }: { className?: string })
|
||||
)}
|
||||
|
||||
{/* Noise Floor */}
|
||||
{stats.noise_floor_24h.supported !== false && (
|
||||
{stats.noise_floor_24h && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
@@ -376,14 +468,14 @@ export function SettingsStatisticsSection({ className }: { className?: string })
|
||||
<NoiseFloorChart samples={stats.noise_floor_24h.samples} />
|
||||
) : stats.noise_floor_24h.samples.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No noise floor samples collected yet. Samples are collected every five minutes,
|
||||
and retained until server restart.
|
||||
No noise floor samples collected yet. Samples are collected every minute and
|
||||
retained until server restart.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Only one sample so far ({stats.noise_floor_24h.samples[0].noise_floor_dbm} dBm).
|
||||
More data needed for a chart. Samples are collected every five minutes, and
|
||||
retained until server restart.
|
||||
More data needed for a chart. Samples are collected every minute and retained
|
||||
until server restart.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Command as CommandPrimitive } from 'cmdk';
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog';
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Command.displayName = CommandPrimitive.displayName;
|
||||
|
||||
function CommandDialog({ children, ...props }: React.ComponentProps<typeof Dialog>) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<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 className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandList.displayName = CommandPrimitive.List.displayName;
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
|
||||
));
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-[0.625rem] [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-wider [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 h-px bg-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
||||
|
||||
function CommandShortcut({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) {
|
||||
return (
|
||||
<span
|
||||
className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
CommandShortcut.displayName = 'CommandShortcut';
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
};
|
||||
@@ -3,16 +3,11 @@ import { api } from '../api';
|
||||
import { takePrefetchOrFetch } from '../prefetch';
|
||||
import { toast } from '../components/ui/sonner';
|
||||
import { initLastMessageTimes } from '../utils/conversationState';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
import type { AppSettings, AppSettingsUpdate, Favorite } from '../types';
|
||||
import type { AppSettings, AppSettingsUpdate } from '../types';
|
||||
|
||||
export function useAppSettings() {
|
||||
const [appSettings, setAppSettings] = useState<AppSettings | null>(null);
|
||||
|
||||
// Stable empty array prevents a new reference every render when there are none.
|
||||
const emptyFavorites = useRef<Favorite[]>([]).current;
|
||||
const favorites: Favorite[] = appSettings?.favorites ?? emptyFavorites;
|
||||
|
||||
// One-time migration guard
|
||||
const hasMigratedRef = useRef(false);
|
||||
|
||||
@@ -85,32 +80,6 @@ export function useAppSettings() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleToggleFavorite = useCallback(async (type: 'channel' | 'contact', id: string) => {
|
||||
setAppSettings((prev) => {
|
||||
if (!prev) return prev;
|
||||
const currentFavorites = prev.favorites ?? [];
|
||||
const wasFavorited = isFavorite(currentFavorites, type, id);
|
||||
const optimisticFavorites = wasFavorited
|
||||
? currentFavorites.filter((f) => !(f.type === type && f.id === id))
|
||||
: [...currentFavorites, { type, id }];
|
||||
return { ...prev, favorites: optimisticFavorites };
|
||||
});
|
||||
|
||||
try {
|
||||
const updatedSettings = await api.toggleFavorite(type, id);
|
||||
setAppSettings(updatedSettings);
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle favorite:', err);
|
||||
try {
|
||||
const settings = await api.getSettings();
|
||||
setAppSettings(settings);
|
||||
} catch {
|
||||
// If refetch also fails, leave optimistic state
|
||||
}
|
||||
toast.error('Failed to update favorite');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleToggleTrackedTelemetry = useCallback(async (publicKey: string) => {
|
||||
const key = publicKey.toLowerCase();
|
||||
setAppSettings((prev) => {
|
||||
@@ -151,7 +120,7 @@ export function useAppSettings() {
|
||||
hasMigratedRef.current = true;
|
||||
|
||||
const FAVORITES_KEY = 'remoteterm-favorites';
|
||||
let localFavorites: Favorite[] = [];
|
||||
let localFavorites: Array<{ type: 'channel' | 'contact'; id: string }> = [];
|
||||
try {
|
||||
const stored = localStorage.getItem(FAVORITES_KEY);
|
||||
if (stored) localFavorites = JSON.parse(stored);
|
||||
@@ -161,25 +130,26 @@ export function useAppSettings() {
|
||||
if (localFavorites.length === 0) return;
|
||||
|
||||
const migrate = async () => {
|
||||
try {
|
||||
for (const f of localFavorites) {
|
||||
let migrated = 0;
|
||||
for (const f of localFavorites) {
|
||||
try {
|
||||
await api.toggleFavorite(f.type, f.id);
|
||||
migrated++;
|
||||
} catch {
|
||||
// Entity may have been deleted; skip and continue
|
||||
}
|
||||
localStorage.removeItem(FAVORITES_KEY);
|
||||
await fetchAppSettings();
|
||||
} catch (err) {
|
||||
console.error('Failed to migrate legacy favorites:', err);
|
||||
}
|
||||
localStorage.removeItem(FAVORITES_KEY);
|
||||
// Reload so contacts/channels pick up the new favorite flags
|
||||
if (migrated > 0) window.location.reload();
|
||||
};
|
||||
migrate();
|
||||
}, [appSettings, fetchAppSettings]);
|
||||
}, [appSettings]);
|
||||
|
||||
return {
|
||||
appSettings,
|
||||
favorites,
|
||||
fetchAppSettings,
|
||||
handleSaveAppSettings,
|
||||
handleToggleFavorite,
|
||||
handleToggleBlockedKey,
|
||||
handleToggleBlockedName,
|
||||
handleToggleTrackedTelemetry,
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Message } from '../types';
|
||||
import { getStateKey } from '../utils/conversationState';
|
||||
|
||||
const STORAGE_KEY = 'meshcore_browser_notifications_enabled_by_conversation';
|
||||
const NOTIFICATION_ICON_PATH = '/favicon-256x256.png';
|
||||
const NOTIFICATION_ICON_PATH = './favicon-256x256.png';
|
||||
|
||||
type NotificationPermissionState = NotificationPermission | 'unsupported';
|
||||
type ConversationNotificationMap = Record<string, boolean>;
|
||||
|
||||
@@ -372,6 +372,8 @@ export function useConversationMessages(
|
||||
const olderAbortControllerRef = useRef<AbortController | null>(null);
|
||||
const newerAbortControllerRef = useRef<AbortController | null>(null);
|
||||
const fetchingConversationIdRef = useRef<string | null>(null);
|
||||
const activeConversationRef = useRef(activeConversation);
|
||||
activeConversationRef.current = activeConversation;
|
||||
const latestReconcileRequestIdRef = useRef(0);
|
||||
const pendingReconnectReconcileRef = useRef(false);
|
||||
const messagesRef = useRef<Message[]>([]);
|
||||
@@ -664,9 +666,11 @@ export function useConversationMessages(
|
||||
}, [activeConversation]);
|
||||
|
||||
const reconcileOnReconnect = useCallback(() => {
|
||||
if (!isMessageConversation(activeConversation)) {
|
||||
return;
|
||||
}
|
||||
// Read the current conversation from the ref rather than closing over
|
||||
// activeConversation, so that a conversation switch during WS reconnect
|
||||
// targets the right conversation instead of a stale capture.
|
||||
const current = activeConversationRef.current;
|
||||
if (!isMessageConversation(current)) return;
|
||||
|
||||
if (hasNewerMessagesRef.current) {
|
||||
pendingReconnectReconcileRef.current = true;
|
||||
@@ -677,8 +681,8 @@ export function useConversationMessages(
|
||||
const controller = new AbortController();
|
||||
const requestId = latestReconcileRequestIdRef.current + 1;
|
||||
latestReconcileRequestIdRef.current = requestId;
|
||||
reconcileFromBackend(activeConversation, controller.signal, requestId);
|
||||
}, [activeConversation, reconcileFromBackend]);
|
||||
reconcileFromBackend(current, controller.signal, requestId);
|
||||
}, [reconcileFromBackend]);
|
||||
|
||||
useEffect(() => {
|
||||
if (abortControllerRef.current) {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import type { Favorite } from '../types';
|
||||
import type { Channel, Contact } from '../types';
|
||||
import { getStateKey } from '../utils/conversationState';
|
||||
|
||||
const APP_TITLE = 'RemoteTerm for MeshCore';
|
||||
const UNREAD_APP_TITLE = 'RemoteTerm';
|
||||
const BASE_FAVICON_PATH = '/favicon.svg';
|
||||
const BASE_FAVICON_PATH = './favicon.svg';
|
||||
const GREEN_BADGE_FILL = '#16a34a';
|
||||
const RED_BADGE_FILL = '#dc2626';
|
||||
const BADGE_CENTER = 750;
|
||||
@@ -25,12 +25,11 @@ function getUnreadDirectMessageCount(unreadCounts: Record<string, number>): numb
|
||||
|
||||
function getUnreadFavoriteChannelCount(
|
||||
unreadCounts: Record<string, number>,
|
||||
favorites: Favorite[]
|
||||
channels: Channel[]
|
||||
): number {
|
||||
return favorites.reduce(
|
||||
(sum, favorite) =>
|
||||
sum +
|
||||
(favorite.type === 'channel' ? unreadCounts[getStateKey('channel', favorite.id)] || 0 : 0),
|
||||
return channels.reduce(
|
||||
(sum, channel) =>
|
||||
sum + (channel.favorite ? unreadCounts[getStateKey('channel', channel.key)] || 0 : 0),
|
||||
0
|
||||
);
|
||||
}
|
||||
@@ -41,19 +40,29 @@ export function getTotalUnreadCount(unreadCounts: Record<string, number>): numbe
|
||||
|
||||
export function getFavoriteUnreadCount(
|
||||
unreadCounts: Record<string, number>,
|
||||
favorites: Favorite[]
|
||||
contacts: Contact[],
|
||||
channels: Channel[]
|
||||
): number {
|
||||
return favorites.reduce((sum, favorite) => {
|
||||
const stateKey = getStateKey(favorite.type, favorite.id);
|
||||
return sum + (unreadCounts[stateKey] || 0);
|
||||
}, 0);
|
||||
let sum = 0;
|
||||
for (const contact of contacts) {
|
||||
if (contact.favorite) {
|
||||
sum += unreadCounts[getStateKey('contact', contact.public_key)] || 0;
|
||||
}
|
||||
}
|
||||
for (const channel of channels) {
|
||||
if (channel.favorite) {
|
||||
sum += unreadCounts[getStateKey('channel', channel.key)] || 0;
|
||||
}
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
export function getUnreadTitle(
|
||||
unreadCounts: Record<string, number>,
|
||||
favorites: Favorite[]
|
||||
contacts: Contact[],
|
||||
channels: Channel[]
|
||||
): string {
|
||||
const unreadCount = getFavoriteUnreadCount(unreadCounts, favorites);
|
||||
const unreadCount = getFavoriteUnreadCount(unreadCounts, contacts, channels);
|
||||
if (unreadCount <= 0) {
|
||||
return APP_TITLE;
|
||||
}
|
||||
@@ -65,13 +74,13 @@ export function getUnreadTitle(
|
||||
export function deriveFaviconBadgeState(
|
||||
unreadCounts: Record<string, number>,
|
||||
mentions: Record<string, boolean>,
|
||||
favorites: Favorite[]
|
||||
channels: Channel[]
|
||||
): FaviconBadgeState {
|
||||
if (Object.values(mentions).some(Boolean) || getUnreadDirectMessageCount(unreadCounts) > 0) {
|
||||
return 'red';
|
||||
}
|
||||
|
||||
if (getUnreadFavoriteChannelCount(unreadCounts, favorites) > 0) {
|
||||
if (getUnreadFavoriteChannelCount(unreadCounts, channels) > 0) {
|
||||
return 'green';
|
||||
}
|
||||
|
||||
@@ -128,8 +137,15 @@ function applyFaviconHref(href: string): void {
|
||||
upsertFaviconLinks('shortcut icon', href);
|
||||
}
|
||||
|
||||
export function useUnreadTitle(unreadCounts: Record<string, number>, favorites: Favorite[]): void {
|
||||
const title = useMemo(() => getUnreadTitle(unreadCounts, favorites), [favorites, unreadCounts]);
|
||||
export function useUnreadTitle(
|
||||
unreadCounts: Record<string, number>,
|
||||
contacts: Contact[],
|
||||
channels: Channel[]
|
||||
): void {
|
||||
const title = useMemo(
|
||||
() => getUnreadTitle(unreadCounts, contacts, channels),
|
||||
[contacts, channels, unreadCounts]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = title;
|
||||
@@ -143,12 +159,12 @@ export function useUnreadTitle(unreadCounts: Record<string, number>, favorites:
|
||||
export function useFaviconBadge(
|
||||
unreadCounts: Record<string, number>,
|
||||
mentions: Record<string, boolean>,
|
||||
favorites: Favorite[]
|
||||
channels: Channel[]
|
||||
): void {
|
||||
const objectUrlRef = useRef<string | null>(null);
|
||||
const badgeState = useMemo(
|
||||
() => deriveFaviconBadgeState(unreadCounts, mentions, favorites),
|
||||
[favorites, mentions, unreadCounts]
|
||||
() => deriveFaviconBadgeState(unreadCounts, mentions, channels),
|
||||
[channels, mentions, unreadCounts]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -105,7 +105,7 @@ describe('fetchJson (via api methods)', () => {
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const [url] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/contacts?limit=100&offset=0');
|
||||
expect(url).toBe('./api/contacts?limit=100&offset=0');
|
||||
});
|
||||
|
||||
it('builds repeater advert path endpoint query', async () => {
|
||||
@@ -118,7 +118,7 @@ describe('fetchJson (via api methods)', () => {
|
||||
await api.getRepeaterAdvertPaths(12);
|
||||
|
||||
const [url] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/contacts/repeaters/advert-paths?limit_per_repeater=12');
|
||||
expect(url).toBe('./api/contacts/repeaters/advert-paths?limit_per_repeater=12');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -238,7 +238,7 @@ describe('fetchJson (via api methods)', () => {
|
||||
await api.sendDirectMessage('abc123', 'hello');
|
||||
|
||||
const [url, options] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/messages/direct');
|
||||
expect(url).toBe('./api/messages/direct');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(JSON.parse(options.body)).toEqual({
|
||||
destination: 'abc123',
|
||||
@@ -256,7 +256,7 @@ describe('fetchJson (via api methods)', () => {
|
||||
await api.updateRadioConfig({ name: 'NewName' });
|
||||
|
||||
const [url, options] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/radio/config');
|
||||
expect(url).toBe('./api/radio/config');
|
||||
expect(options.method).toBe('PATCH');
|
||||
expect(JSON.parse(options.body)).toEqual({ name: 'NewName' });
|
||||
});
|
||||
@@ -271,7 +271,7 @@ describe('fetchJson (via api methods)', () => {
|
||||
await api.setPrivateKey('my-secret-key');
|
||||
|
||||
const [url, options] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/radio/private-key');
|
||||
expect(url).toBe('./api/radio/private-key');
|
||||
expect(options.method).toBe('PUT');
|
||||
expect(JSON.parse(options.body)).toEqual({ private_key: 'my-secret-key' });
|
||||
});
|
||||
@@ -286,7 +286,7 @@ describe('fetchJson (via api methods)', () => {
|
||||
await api.discoverMesh('repeaters');
|
||||
|
||||
const [url, options] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/radio/discover');
|
||||
expect(url).toBe('./api/radio/discover');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(JSON.parse(options.body)).toEqual({ target: 'repeaters' });
|
||||
});
|
||||
@@ -301,7 +301,7 @@ describe('fetchJson (via api methods)', () => {
|
||||
await api.deleteContact('pubkey123');
|
||||
|
||||
const [url, options] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/contacts/pubkey123');
|
||||
expect(url).toBe('./api/contacts/pubkey123');
|
||||
expect(options.method).toBe('DELETE');
|
||||
});
|
||||
|
||||
@@ -315,7 +315,7 @@ describe('fetchJson (via api methods)', () => {
|
||||
await api.sendAdvertisement();
|
||||
|
||||
const [url, options] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/radio/advertise');
|
||||
expect(url).toBe('./api/radio/advertise');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(options.body).toBe(JSON.stringify({ mode: 'flood' }));
|
||||
});
|
||||
@@ -330,7 +330,7 @@ describe('fetchJson (via api methods)', () => {
|
||||
await api.sendAdvertisement('zero_hop');
|
||||
|
||||
const [url, options] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/radio/advertise');
|
||||
expect(url).toBe('./api/radio/advertise');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(options.body).toBe(JSON.stringify({ mode: 'zero_hop' }));
|
||||
});
|
||||
@@ -383,7 +383,7 @@ describe('fetchJson (via api methods)', () => {
|
||||
});
|
||||
|
||||
const [url] = mockFetch.mock.calls[0];
|
||||
expect(url).toContain('/api/messages?');
|
||||
expect(url).toContain('./api/messages?');
|
||||
expect(url).toContain('limit=50');
|
||||
expect(url).toContain('offset=10');
|
||||
expect(url).toContain('type=PRIV');
|
||||
@@ -402,7 +402,7 @@ describe('fetchJson (via api methods)', () => {
|
||||
await api.getMessages();
|
||||
|
||||
const [url] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/messages');
|
||||
expect(url).toBe('./api/messages');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -187,7 +187,6 @@ const baseConfig = {
|
||||
|
||||
const baseSettings = {
|
||||
max_radio_contacts: 200,
|
||||
favorites: [] as Array<{ type: 'channel' | 'contact'; id: string }>,
|
||||
auto_decrypt_dm_on_advert: false,
|
||||
last_message_times: {},
|
||||
|
||||
@@ -204,6 +203,7 @@ const publicChannel = {
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
};
|
||||
|
||||
describe('App favorite toggle flow', () => {
|
||||
@@ -216,8 +216,9 @@ describe('App favorite toggle flow', () => {
|
||||
mocks.api.getChannels.mockResolvedValue([publicChannel]);
|
||||
mocks.api.getContacts.mockResolvedValue([]);
|
||||
mocks.api.toggleFavorite.mockResolvedValue({
|
||||
...baseSettings,
|
||||
favorites: [{ type: 'channel', id: publicChannel.key }],
|
||||
type: 'channel',
|
||||
id: publicChannel.key,
|
||||
favorite: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -239,11 +240,8 @@ describe('App favorite toggle flow', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('rolls back favorite state by refetching settings on toggle failure', async () => {
|
||||
it('rolls back favorite state on toggle failure', async () => {
|
||||
mocks.api.toggleFavorite.mockRejectedValue(new Error('toggle failed'));
|
||||
mocks.api.getSettings
|
||||
.mockResolvedValueOnce({ ...baseSettings }) // initial load
|
||||
.mockResolvedValueOnce({ ...baseSettings }); // rollback refetch
|
||||
|
||||
render(<App />);
|
||||
|
||||
@@ -257,10 +255,6 @@ describe('App favorite toggle flow', () => {
|
||||
expect(mocks.api.toggleFavorite).toHaveBeenCalledWith('channel', publicChannel.key);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.api.getSettings).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.toast.error).toHaveBeenCalledWith('Failed to update favorite');
|
||||
});
|
||||
|
||||
@@ -215,7 +215,6 @@ describe('App search jump target handling', () => {
|
||||
});
|
||||
mocks.api.getSettings.mockResolvedValue({
|
||||
max_radio_contacts: 200,
|
||||
favorites: [],
|
||||
auto_decrypt_dm_on_advert: false,
|
||||
last_message_times: {},
|
||||
|
||||
@@ -230,6 +229,7 @@ describe('App search jump target handling', () => {
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
},
|
||||
]);
|
||||
mocks.api.getContacts.mockResolvedValue([]);
|
||||
|
||||
@@ -145,6 +145,7 @@ const publicChannel = {
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
};
|
||||
|
||||
describe('App startup hash resolution', () => {
|
||||
@@ -166,7 +167,6 @@ describe('App startup hash resolution', () => {
|
||||
});
|
||||
mocks.api.getSettings.mockResolvedValue({
|
||||
max_radio_contacts: 200,
|
||||
favorites: [],
|
||||
auto_decrypt_dm_on_advert: false,
|
||||
last_message_times: {},
|
||||
|
||||
@@ -247,6 +247,7 @@ describe('App startup hash resolution', () => {
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
};
|
||||
|
||||
window.location.hash = '';
|
||||
@@ -278,6 +279,7 @@ describe('App startup hash resolution', () => {
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
};
|
||||
|
||||
window.location.hash = '';
|
||||
@@ -308,6 +310,7 @@ describe('App startup hash resolution', () => {
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
};
|
||||
|
||||
window.location.hash = '';
|
||||
@@ -345,6 +348,7 @@ describe('App startup hash resolution', () => {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mvToPercent, formatBatteryLabel } from '../utils/batteryDisplay';
|
||||
|
||||
describe('mvToPercent', () => {
|
||||
it('clamps to 100 above table ceiling', () => {
|
||||
expect(mvToPercent(4500)).toBe(100);
|
||||
expect(mvToPercent(4190)).toBe(100);
|
||||
});
|
||||
|
||||
it('clamps to 0 below table floor', () => {
|
||||
expect(mvToPercent(3100)).toBe(0);
|
||||
expect(mvToPercent(2800)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns exact table values at boundaries', () => {
|
||||
expect(mvToPercent(4050)).toBe(90);
|
||||
expect(mvToPercent(3630)).toBe(40);
|
||||
});
|
||||
|
||||
it('interpolates between table entries', () => {
|
||||
// Midpoint between 3630 (40%) and 3720 (50%) = 3675 → ~45%
|
||||
const mid = mvToPercent(3675);
|
||||
expect(mid).toBeGreaterThan(40);
|
||||
expect(mid).toBeLessThan(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatBatteryLabel', () => {
|
||||
it('returns null when both toggles are off', () => {
|
||||
expect(formatBatteryLabel(4050, false, false)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns percentage only', () => {
|
||||
expect(formatBatteryLabel(4050, true, false)).toBe('90%');
|
||||
});
|
||||
|
||||
it('returns voltage only', () => {
|
||||
expect(formatBatteryLabel(4050, false, true)).toBe('4050mV');
|
||||
});
|
||||
|
||||
it('returns combined when both enabled', () => {
|
||||
expect(formatBatteryLabel(4050, true, true)).toBe('90% (4050mV)');
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ describe('BulkAddChannelResultModal', () => {
|
||||
is_hashtag: true,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
key: 'BB'.repeat(16),
|
||||
@@ -24,6 +25,7 @@ describe('BulkAddChannelResultModal', () => {
|
||||
is_hashtag: true,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
},
|
||||
],
|
||||
existing_count: 3,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ChannelInfoPane } from '../components/ChannelInfoPane';
|
||||
import type { Channel, ChannelDetail, Favorite } from '../types';
|
||||
import type { Channel, ChannelDetail } from '../types';
|
||||
|
||||
// Mock the api module
|
||||
vi.mock('../api', () => ({
|
||||
@@ -15,7 +15,7 @@ import { api } from '../api';
|
||||
const mockGetChannelDetail = vi.mocked(api.getChannelDetail);
|
||||
|
||||
function makeChannel(key: string, name: string, isHashtag: boolean): Channel {
|
||||
return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null };
|
||||
return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null, favorite: false };
|
||||
}
|
||||
|
||||
function makeDetail(channel: Channel): ChannelDetail {
|
||||
@@ -41,7 +41,6 @@ const noop = () => {};
|
||||
|
||||
const baseProps = {
|
||||
onClose: noop,
|
||||
favorites: [] as Favorite[],
|
||||
onToggleFavorite: noop,
|
||||
};
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ChatHeader } from '../components/ChatHeader';
|
||||
import type { Channel, Contact, Conversation, Favorite, PathDiscoveryResponse } from '../types';
|
||||
import type { Channel, Contact, Conversation, PathDiscoveryResponse } from '../types';
|
||||
import { CONTACT_TYPE_ROOM } from '../types';
|
||||
import { PUBLIC_CHANNEL_KEY } from '../utils/publicChannel';
|
||||
|
||||
function makeChannel(key: string, name: string, isHashtag: boolean): Channel {
|
||||
return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null };
|
||||
return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null, favorite: false };
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
@@ -15,7 +15,6 @@ const noop = () => {};
|
||||
const baseProps = {
|
||||
contacts: [],
|
||||
config: null,
|
||||
favorites: [] as Favorite[],
|
||||
notificationsSupported: true,
|
||||
notificationsEnabled: false,
|
||||
notificationsPermission: 'granted' as const,
|
||||
@@ -186,6 +185,7 @@ describe('ChatHeader key visibility', () => {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
@@ -237,6 +237,7 @@ describe('ChatHeader key visibility', () => {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
@@ -286,6 +287,7 @@ describe('ChatHeader key visibility', () => {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
|
||||
@@ -47,6 +47,7 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
|
||||
lon: null,
|
||||
last_seen: 1700000000,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: 1699990000,
|
||||
@@ -90,7 +91,6 @@ const baseProps = {
|
||||
onClose: () => {},
|
||||
contacts: [] as Contact[],
|
||||
config: null,
|
||||
favorites: [],
|
||||
onToggleFavorite: () => {},
|
||||
onSearchMessagesByKey: vi.fn(),
|
||||
onSearchMessagesByName: vi.fn(),
|
||||
|
||||
@@ -3,15 +3,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ConversationPane } from '../components/ConversationPane';
|
||||
import type {
|
||||
Channel,
|
||||
Contact,
|
||||
Conversation,
|
||||
Favorite,
|
||||
HealthStatus,
|
||||
Message,
|
||||
RadioConfig,
|
||||
} from '../types';
|
||||
import type { Channel, Contact, Conversation, HealthStatus, Message, RadioConfig } from '../types';
|
||||
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -97,6 +89,7 @@ const channel: Channel = {
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
};
|
||||
|
||||
const message: Message = {
|
||||
@@ -134,7 +127,6 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
|
||||
notificationsSupported: true,
|
||||
notificationsEnabled: false,
|
||||
notificationsPermission: 'granted' as const,
|
||||
favorites: [] as Favorite[],
|
||||
messages: [message],
|
||||
messagesLoading: false,
|
||||
loadingOlder: false,
|
||||
@@ -166,6 +158,8 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
|
||||
onToggleNotifications: vi.fn(),
|
||||
trackedTelemetryRepeaters: [],
|
||||
onToggleTrackedTelemetry: vi.fn(async () => {}),
|
||||
repeaterAutoLoginKey: null,
|
||||
onClearRepeaterAutoLogin: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -205,6 +199,7 @@ describe('ConversationPane', () => {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
@@ -278,6 +273,7 @@ describe('ConversationPane', () => {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
@@ -372,6 +368,7 @@ describe('ConversationPane', () => {
|
||||
lon: null,
|
||||
last_seen: 1700000000,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: 1700000000,
|
||||
last_read_at: null,
|
||||
first_seen: 1700000000,
|
||||
@@ -408,6 +405,7 @@ describe('ConversationPane', () => {
|
||||
lon: null,
|
||||
last_seen: 1700000000,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: 1700000000,
|
||||
last_read_at: null,
|
||||
first_seen: 1700000000,
|
||||
|
||||
@@ -278,6 +278,7 @@ function makeContact(overrides: Partial<Contact> = {}): Contact {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: true,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
|
||||
@@ -40,6 +40,7 @@ describe('MapView', () => {
|
||||
lon: -74,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
@@ -74,6 +75,7 @@ describe('MapView', () => {
|
||||
lon: -73,
|
||||
last_seen: Math.floor(Date.now() / 1000) - 7 * 24 * 60 * 60 + 60,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
|
||||
@@ -95,6 +95,7 @@ describe('MessageList channel sender rendering', () => {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
|
||||
@@ -9,7 +9,7 @@ import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
findLinkedChannelReferences,
|
||||
formatTime,
|
||||
isValidLinkedChannelName,
|
||||
HASHTAG_CHANNEL_NAME_PATTERN,
|
||||
parseSenderFromText,
|
||||
} from '../utils/messageParser';
|
||||
|
||||
@@ -103,16 +103,16 @@ describe('formatTime', () => {
|
||||
|
||||
describe('linked channel references', () => {
|
||||
it('accepts lowercase alphanumeric names with single dashes', () => {
|
||||
expect(isValidLinkedChannelName('ops')).toBe(true);
|
||||
expect(isValidLinkedChannelName('ops-1')).toBe(true);
|
||||
expect(isValidLinkedChannelName('1-2-3')).toBe(true);
|
||||
expect(HASHTAG_CHANNEL_NAME_PATTERN.test('ops')).toBe(true);
|
||||
expect(HASHTAG_CHANNEL_NAME_PATTERN.test('ops-1')).toBe(true);
|
||||
expect(HASHTAG_CHANNEL_NAME_PATTERN.test('1-2-3')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects uppercase, leading or trailing dashes, and repeated dashes', () => {
|
||||
expect(isValidLinkedChannelName('Ops')).toBe(false);
|
||||
expect(isValidLinkedChannelName('-ops')).toBe(false);
|
||||
expect(isValidLinkedChannelName('ops-')).toBe(false);
|
||||
expect(isValidLinkedChannelName('ops--room')).toBe(false);
|
||||
expect(HASHTAG_CHANNEL_NAME_PATTERN.test('Ops')).toBe(false);
|
||||
expect(HASHTAG_CHANNEL_NAME_PATTERN.test('-ops')).toBe(false);
|
||||
expect(HASHTAG_CHANNEL_NAME_PATTERN.test('ops-')).toBe(false);
|
||||
expect(HASHTAG_CHANNEL_NAME_PATTERN.test('ops--room')).toBe(false);
|
||||
});
|
||||
|
||||
it('finds standalone linked channel references in message text', () => {
|
||||
|
||||
@@ -62,6 +62,7 @@ function createContact(publicKey: string, name: string, type = 1): Contact {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
|
||||
@@ -30,6 +30,7 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
|
||||
@@ -23,6 +23,7 @@ const BOT_CHANNEL: Channel = {
|
||||
is_hashtag: true,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
};
|
||||
|
||||
const BOT_PACKET: RawPacket = {
|
||||
|
||||
@@ -14,6 +14,7 @@ const TEST_CHANNEL: Channel = {
|
||||
is_hashtag: true,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
};
|
||||
|
||||
const COLLIDING_TEST_CHANNEL: Channel = {
|
||||
@@ -87,6 +88,7 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { RepeaterDashboard } from '../components/RepeaterDashboard';
|
||||
import type { UseRepeaterDashboardResult } from '../hooks/useRepeaterDashboard';
|
||||
import type { Contact, Conversation, Favorite } from '../types';
|
||||
import type { Contact, Conversation } from '../types';
|
||||
|
||||
// Mock the hook — typed as mutable version of the return type
|
||||
const mockHook: {
|
||||
@@ -99,18 +99,16 @@ const contacts: Contact[] = [
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
},
|
||||
];
|
||||
|
||||
const favorites: Favorite[] = [];
|
||||
|
||||
const defaultProps = {
|
||||
conversation,
|
||||
contacts,
|
||||
favorites,
|
||||
notificationsSupported: true,
|
||||
notificationsEnabled: false,
|
||||
notificationsPermission: 'granted' as const,
|
||||
@@ -337,6 +335,7 @@ describe('RepeaterDashboard', () => {
|
||||
lon: 115.87,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
@@ -397,6 +396,7 @@ describe('RepeaterDashboard', () => {
|
||||
lon: 115.87,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
|
||||
@@ -40,6 +40,7 @@ const roomContact: Contact = {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
|
||||
@@ -35,7 +35,14 @@ function createSearchResult(overrides: Partial<Message> = {}): Message {
|
||||
const defaultProps = {
|
||||
contacts: [],
|
||||
channels: [
|
||||
{ key: 'ABC123', name: 'Public', is_hashtag: true, on_radio: false, last_read_at: null },
|
||||
{
|
||||
key: 'ABC123',
|
||||
name: 'Public',
|
||||
is_hashtag: true,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
},
|
||||
],
|
||||
onNavigateToMessage: vi.fn(),
|
||||
};
|
||||
@@ -239,6 +246,7 @@ describe('SearchView', () => {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
first_seen: null,
|
||||
last_read_at: null,
|
||||
|
||||
@@ -25,7 +25,7 @@ describe('SettingsAboutSection', () => {
|
||||
);
|
||||
|
||||
const link = screen.getByRole('link', { name: /Open debug support snapshot/i });
|
||||
expect(link).toHaveAttribute('href', '/api/debug');
|
||||
expect(link).toHaveAttribute('href', './api/debug');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user