forked from iarv/potato-mesh
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 858e9fa189 | |||
| 6fd8e5ad12 | |||
| 09fbc32e48 | |||
| 4591d5acd6 | |||
| 6c711f80b4 | |||
| e61e701240 | |||
| 42f4e80a26 | |||
| 4dc03f33ca | |||
| 5572c6cd12 | |||
| 4f7e66de82 | |||
| c1898037c0 | |||
| efc5f64279 | |||
| 636a203254 | |||
| 2e78fa7a3a | |||
| e74f985630 | |||
| e4facd7f26 | |||
| f533362f8a | |||
| 175a8f368f | |||
| 872bcbd529 | |||
| 8811f71e53 | |||
| fec649a159 | |||
| 9e3f481401 | |||
| 1a497864a7 |
@@ -43,7 +43,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
service: [web, ingestor]
|
||||
service: [web, ingestor, matrix-bridge]
|
||||
architecture:
|
||||
- { name: linux-amd64, platform: linux/amd64, label: "Linux x86_64", os: linux, architecture: amd64 }
|
||||
- { name: linux-arm64, platform: linux/arm64, label: "Linux ARM64", os: linux, architecture: arm64 }
|
||||
@@ -109,8 +109,8 @@ jobs:
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./${{ matrix.service == 'web' && 'web/Dockerfile' || 'data/Dockerfile' }}
|
||||
target: production
|
||||
file: ${{ matrix.service == 'web' && './web/Dockerfile' || matrix.service == 'ingestor' && './data/Dockerfile' || './matrix/Dockerfile' }}
|
||||
target: ${{ matrix.service == 'matrix-bridge' && 'runtime' || 'production' }}
|
||||
platforms: ${{ matrix.architecture.platform }}
|
||||
push: true
|
||||
tags: |
|
||||
@@ -119,12 +119,12 @@ jobs:
|
||||
${{ steps.tagging.outputs.include_latest == 'true' && format('{0}/{1}-{2}-{3}:latest', env.REGISTRY, env.IMAGE_PREFIX, matrix.service, matrix.architecture.name) || '' }}
|
||||
labels: |
|
||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||
org.opencontainers.image.description=PotatoMesh ${{ matrix.service == 'web' && 'Web Application' || 'Python Ingestor' }} for ${{ matrix.architecture.label }}
|
||||
org.opencontainers.image.description=PotatoMesh ${{ matrix.service == 'web' && 'Web Application' || matrix.service == 'ingestor' && 'Python Ingestor' || 'Matrix Bridge' }} for ${{ matrix.architecture.label }}
|
||||
org.opencontainers.image.licenses=Apache-2.0
|
||||
org.opencontainers.image.version=${{ steps.version.outputs.version }}
|
||||
org.opencontainers.image.created=${{ github.event.head_commit.timestamp }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
org.opencontainers.image.title=PotatoMesh ${{ matrix.service == 'web' && 'Web' || 'Ingestor' }} (${{ matrix.architecture.label }})
|
||||
org.opencontainers.image.title=PotatoMesh ${{ matrix.service == 'web' && 'Web' || matrix.service == 'ingestor' && 'Ingestor' || 'Matrix Bridge' }} (${{ matrix.architecture.label }})
|
||||
org.opencontainers.image.vendor=PotatoMesh
|
||||
org.opencontainers.image.architecture=${{ matrix.architecture.architecture }}
|
||||
org.opencontainers.image.os=${{ matrix.architecture.os }}
|
||||
@@ -208,6 +208,19 @@ jobs:
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Determine tagging strategy
|
||||
id: tagging
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
|
||||
if echo "$VERSION" | grep -E -- '-(rc|beta|alpha|dev)'; then
|
||||
INCLUDE_LATEST=false
|
||||
else
|
||||
INCLUDE_LATEST=true
|
||||
fi
|
||||
|
||||
echo "include_latest=$INCLUDE_LATEST" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Publish release summary
|
||||
run: |
|
||||
echo "## 🚀 PotatoMesh Images Published to GHCR" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -234,4 +247,13 @@ jobs:
|
||||
echo "- \`${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-ingestor-linux-armv7:latest\` - Linux ARMv7" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# Matrix bridge images
|
||||
echo "### 🧩 Matrix Bridge" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${{ steps.tagging.outputs.include_latest }}" = "true" ]; then
|
||||
echo "- \`${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-matrix-bridge-linux-amd64:latest\` - Linux x86_64" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-matrix-bridge-linux-arm64:latest\` - Linux ARM64" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-matrix-bridge-linux-armv7:latest\` - Linux ARMv7" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Copyright © 2025-26 l5yth & contributors
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
name: Nix
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
flake-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Install Nix
|
||||
uses: cachix/install-nix-action@v30
|
||||
with:
|
||||
extra_nix_config: |
|
||||
experimental-features = nix-command flakes
|
||||
- name: Run flake checks
|
||||
run: nix flake check
|
||||
@@ -17,11 +17,15 @@ The repository splits runtime and ingestion logic. `web/` holds the Sinatra dash
|
||||
|
||||
`data/` hosts the Python Meshtastic ingestor plus migrations and CLI scripts. API fixtures and end-to-end harnesses live in `tests/`. Dockerfiles and compose files support containerized workflows.
|
||||
|
||||
`matrix/` contains the Rust Matrix bridge; build with `cargo build --release` or `docker build -f matrix/Dockerfile .`, and keep bridge config under `matrix/Config.toml` when running locally.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
Run dependency installs inside `web/`: `bundle install` for gems and `npm ci` for JavaScript tooling. Start the app with `cd web && API_TOKEN=dev ./app.sh` for local work or `bundle exec rackup -p 41447` when integrating elsewhere.
|
||||
|
||||
Prep ingestion with `python -m venv .venv && pip install -r data/requirements.txt`; `./data/mesh.sh` streams from live radios. `docker-compose -f docker-compose.dev.yml up` brings up the full stack.
|
||||
|
||||
Container images publish via `.github/workflows/docker.yml` as `potato-mesh-{service}-linux-$arch` (`web`, `ingestor`, `matrix-bridge`), using the Dockerfiles in `web/`, `data/`, and `matrix/`.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
Use two-space indentation for Ruby and keep `# frozen_string_literal: true` at the top of new files. Keep Ruby classes/modules in `CamelCase`, filenames in `snake_case.rb`, and feature specs in `*_spec.rb`.
|
||||
|
||||
|
||||
@@ -1,5 +1,67 @@
|
||||
# CHANGELOG
|
||||
|
||||
## v0.5.7
|
||||
|
||||
* Data: track ingestors heartbeat by @l5yth in <https://github.com/l5yth/potato-mesh/pull/549>
|
||||
* Harden instance selector navigation URLs by @l5yth in <https://github.com/l5yth/potato-mesh/pull/550>
|
||||
* Data: hide channels that have been flag for ignoring by @l5yth in <https://github.com/l5yth/potato-mesh/pull/548>
|
||||
* Web: fix limit when counting remote nodes by @l5yth in <https://github.com/l5yth/potato-mesh/pull/547>
|
||||
* Web: improve instances map and table view by @l5yth in <https://github.com/l5yth/potato-mesh/pull/546>
|
||||
* Web: fix traces submission with optional fields on udp by @l5yth in <https://github.com/l5yth/potato-mesh/pull/545>
|
||||
* Chore: bump version to 0.5.7 by @l5yth in <https://github.com/l5yth/potato-mesh/pull/542>
|
||||
* Handle zero telemetry aggregates by @l5yth in <https://github.com/l5yth/potato-mesh/pull/538>
|
||||
* Web: fix telemetry api to return current in amperes by @l5yth in <https://github.com/l5yth/potato-mesh/pull/541>
|
||||
* Web: fix traces rendering by @l5yth in <https://github.com/l5yth/potato-mesh/pull/535>
|
||||
* Normalize numeric node roles to canonical labels by @l5yth in <https://github.com/l5yth/potato-mesh/pull/539>
|
||||
* Use INSTANCE_DOMAIN env for ingestor by @l5yth in <https://github.com/l5yth/potato-mesh/pull/536>
|
||||
* Web: further refine the federation page by @l5yth in <https://github.com/l5yth/potato-mesh/pull/534>
|
||||
* Add Federation Map by @apo-mak in <https://github.com/l5yth/potato-mesh/pull/532>
|
||||
* Add contact link to the instance data by @apo-mak in <https://github.com/l5yth/potato-mesh/pull/533>
|
||||
* Matrix: create potato-matrix-bridge by @l5yth in <https://github.com/l5yth/potato-mesh/pull/528>
|
||||
|
||||
## v0.5.6
|
||||
|
||||
* Web: display sats in view by @l5yth in <https://github.com/l5yth/potato-mesh/pull/523>
|
||||
* Web: display air quality in separate chart by @l5yth in <https://github.com/l5yth/potato-mesh/pull/521>
|
||||
* Ci: Add macOS and Ubuntu builds to Flutter workflow by @l5yth in <https://github.com/l5yth/potato-mesh/pull/519>
|
||||
* Web: add current to charts by @l5yth in <https://github.com/l5yth/potato-mesh/pull/520>
|
||||
* App: fix notification icon by @l5yth in <https://github.com/l5yth/potato-mesh/pull/518>
|
||||
* Spec: update test fixtures by @l5yth in <https://github.com/l5yth/potato-mesh/pull/517>
|
||||
* App: generate proper icons by @l5yth in <https://github.com/l5yth/potato-mesh/pull/516>
|
||||
* Web: fix favicon by @l5yth in <https://github.com/l5yth/potato-mesh/pull/515>
|
||||
* Web: add ?since= parameter to api/messages by @l5yth in <https://github.com/l5yth/potato-mesh/pull/512>
|
||||
* App: implement notifications by @l5yth in <https://github.com/l5yth/potato-mesh/pull/511>
|
||||
* App: add theme selector by @l5yth in <https://github.com/l5yth/potato-mesh/pull/507>
|
||||
* App: further harden refresh logic and prefer local first by @l5yth in <https://github.com/l5yth/potato-mesh/pull/506>
|
||||
* Ci: fix app artifacts for tags by @l5yth in <https://github.com/l5yth/potato-mesh/pull/504>
|
||||
* Ci: build app artifacts for tags by @l5yth in <https://github.com/l5yth/potato-mesh/pull/503>
|
||||
* App: add persistance by @l5yth in <https://github.com/l5yth/potato-mesh/pull/501>
|
||||
* App: instance and chat mvp by @l5yth in <https://github.com/l5yth/potato-mesh/pull/498>
|
||||
* App: add instance selector to settings by @l5yth in <https://github.com/l5yth/potato-mesh/pull/497>
|
||||
* App: add scaffholding gitignore by @l5yth in <https://github.com/l5yth/potato-mesh/pull/496>
|
||||
* Handle reaction app packets without reply id by @l5yth in <https://github.com/l5yth/potato-mesh/pull/495>
|
||||
* Render reaction multiplier counts by @l5yth in <https://github.com/l5yth/potato-mesh/pull/494>
|
||||
* Add comprehensive tests for Flutter reader by @l5yth in <https://github.com/l5yth/potato-mesh/pull/491>
|
||||
* Map numeric role ids to canonical Meshtastic roles by @l5yth in <https://github.com/l5yth/potato-mesh/pull/489>
|
||||
* Update node detail hydration for traces by @l5yth in <https://github.com/l5yth/potato-mesh/pull/490>
|
||||
* Add mobile Flutter CI workflow by @l5yth in <https://github.com/l5yth/potato-mesh/pull/488>
|
||||
* Align OCI labels in docker workflow by @l5yth in <https://github.com/l5yth/potato-mesh/pull/487>
|
||||
* Add Meshtastic reader Flutter app by @l5yth in <https://github.com/l5yth/potato-mesh/pull/483>
|
||||
* Handle pre-release Docker tagging by @l5yth in <https://github.com/l5yth/potato-mesh/pull/486>
|
||||
* Web: remove range from charts labels by @l5yth in <https://github.com/l5yth/potato-mesh/pull/485>
|
||||
* Floor override frequencies to MHz integers by @l5yth in <https://github.com/l5yth/potato-mesh/pull/476>
|
||||
* Prevent message ids from being treated as node identifiers by @l5yth in <https://github.com/l5yth/potato-mesh/pull/475>
|
||||
* Fix 1 after emojis in reply. by @Alexkurd in <https://github.com/l5yth/potato-mesh/pull/464>
|
||||
* Add frequency and preset to node table by @l5yth in <https://github.com/l5yth/potato-mesh/pull/472>
|
||||
* Subscribe to traceroute app pubsub topic by @l5yth in <https://github.com/l5yth/potato-mesh/pull/471>
|
||||
* Aggregate telemetry over the last 7 days by @l5yth in <https://github.com/l5yth/potato-mesh/pull/470>
|
||||
* Address missing id field ingestor bug by @l5yth in <https://github.com/l5yth/potato-mesh/pull/469>
|
||||
* Merge secondary channels by name by @l5yth in <https://github.com/l5yth/potato-mesh/pull/468>
|
||||
* Rate limit host device telemetry by @l5yth in <https://github.com/l5yth/potato-mesh/pull/467>
|
||||
* Add traceroutes to frontend by @l5yth in <https://github.com/l5yth/potato-mesh/pull/466>
|
||||
* Feat: implement traceroute app packet handling across the stack by @l5yth in <https://github.com/l5yth/potato-mesh/pull/463>
|
||||
* Bump version and update changelog by @l5yth in <https://github.com/l5yth/potato-mesh/pull/462>
|
||||
|
||||
## v0.5.5
|
||||
|
||||
* Added comprehensive helper unit tests by @l5yth in <https://github.com/l5yth/potato-mesh/pull/457>
|
||||
|
||||
@@ -53,6 +53,7 @@ Additional environment variables are optional:
|
||||
| `MAP_ZOOM` | _unset_ | Fixed Leaflet zoom (disables the auto-fit checkbox when set). |
|
||||
| `MAX_DISTANCE` | `42` | Maximum relationship distance (km) before edges are hidden. |
|
||||
| `DEBUG` | `0` | Enables verbose logging across services when set to `1`. |
|
||||
| `ALLOWED_CHANNELS` | _unset_ | Comma-separated channel names the ingestor accepts; other channels are skipped before hidden filters. |
|
||||
| `HIDDEN_CHANNELS` | _unset_ | Comma-separated channel names the ingestor skips when forwarding packets. |
|
||||
| `FEDERATION` | `1` | Controls whether the instance announces itself and crawls peers (`1`) or stays isolated (`0`). |
|
||||
| `PRIVATE` | `0` | Restricts public visibility and disables chat/message endpoints when set to `1`. |
|
||||
|
||||
@@ -7,13 +7,20 @@
|
||||
[](https://github.com/l5yth/potato-mesh/issues)
|
||||
[](https://matrix.to/#/#potatomesh:dod.ngo)
|
||||
|
||||
A federated Meshtastic-powered node dashboard for your local community. _No MQTT clutter, just local LoRa aether._
|
||||
A federated, Meshtastic-powered node dashboard for your local community.
|
||||
_No MQTT clutter, just local LoRa aether._
|
||||
|
||||
* Web app with chat window and map view showing nodes, neighbors, telemetry, and messages.
|
||||
* API to POST (authenticated) and to GET nodes and messages.
|
||||
* Shows new node notifications (first seen) in chat.
|
||||
* Web dashboard with chat window and map view showing nodes, positions, neighbors,
|
||||
trace routes, telemetry, and messages.
|
||||
* API to POST (authenticated) and to GET nodes, messages, and telemetry.
|
||||
* Shows new node notifications (first seen) and telemetry logs in chat.
|
||||
* Allows searching and filtering for nodes in map and table view.
|
||||
* Federated: _automatically_ froms a federation with other communities running
|
||||
Potato Mesh!
|
||||
* Supplemental Python ingestor to feed the POST APIs of the Web app with data remotely.
|
||||
* Supports multiple ingestors per instance.
|
||||
* Matrix bridge that posts Meshtastic messages to a defined matrix channel (no
|
||||
radio required).
|
||||
* Mobile app to _read_ messages on your local aether (no radio required).
|
||||
|
||||
Live demo for Berlin #MediumFast: [potatomesh.net](https://potatomesh.net)
|
||||
@@ -58,6 +65,7 @@ RACK_ENV="production" \
|
||||
APP_ENV="production" \
|
||||
API_TOKEN="SuperSecureTokenReally" \
|
||||
INSTANCE_DOMAIN="https://potatomesh.net" \
|
||||
MAP_CENTER="53.55,13.42" \
|
||||
exec ruby app.rb -p 41447 -o 0.0.0.0
|
||||
```
|
||||
|
||||
@@ -68,6 +76,7 @@ exec ruby app.rb -p 41447 -o 0.0.0.0
|
||||
* Provide a strong `API_TOKEN` value to authorize POST requests against the API.
|
||||
* Configure `INSTANCE_DOMAIN` with the public URL of your deployment so vanity
|
||||
links and generated metadata resolve correctly.
|
||||
* Don't forget to set a `MAP_CENTER` to point to your local region.
|
||||
|
||||
The web app can be configured with environment variables (defaults shown):
|
||||
|
||||
@@ -83,6 +92,7 @@ The web app can be configured with environment variables (defaults shown):
|
||||
| `MAP_ZOOM` | _unset_ | Fixed Leaflet zoom applied on first load; disables auto-fit when provided. |
|
||||
| `MAX_DISTANCE` | `42` | Maximum distance (km) before node relationships are hidden on the map. |
|
||||
| `DEBUG` | `0` | Set to `1` for verbose logging in the web and ingestor services. |
|
||||
| `ALLOWED_CHANNELS` | _unset_ | Comma-separated channel names the ingestor accepts; when set, all other channels are skipped before hidden filters. |
|
||||
| `HIDDEN_CHANNELS` | _unset_ | Comma-separated channel names the ingestor will ignore when forwarding packets. |
|
||||
| `FEDERATION` | `1` | Set to `1` to announce your instance and crawl peers, or `0` to disable federation. Private mode overrides this. |
|
||||
| `PRIVATE` | `0` | Set to `1` to hide the chat UI, disable message APIs, and exclude hidden clients from public listings. |
|
||||
@@ -134,7 +144,9 @@ The web app contains an API:
|
||||
* GET `/api/messages?limit=100&encrypted=false&since=0` - returns the latest 100 messages newer than the provided unix timestamp (defaults to `since=0` to return full history; disabled when `PRIVATE=1`)
|
||||
* GET `/api/telemetry?limit=100` - returns the latest 100 telemetry data
|
||||
* GET `/api/neighbors?limit=100` - returns the latest 100 neighbor tuples
|
||||
* GET `/api/traces?limit=100` - returns the latest 100 trace-routes caught
|
||||
* GET `/api/instances` - returns known potato-mesh instances in other locations
|
||||
* GET `/api/ingestors` - returns active potato-mesh python ingestors that feed data
|
||||
* GET `/metrics`- metrics for the prometheus endpoint
|
||||
* GET `/version`- information about the potato-mesh instance
|
||||
* POST `/api/nodes` - upserts nodes provided as JSON object mapping node ids to node data (requires `Authorization: Bearer <API_TOKEN>`)
|
||||
@@ -142,6 +154,7 @@ The web app contains an API:
|
||||
* POST `/api/messages` - appends messages provided as a JSON object or array (requires `Authorization: Bearer <API_TOKEN>`; disabled when `PRIVATE=1`)
|
||||
* POST `/api/telemetry` - appends telemetry provided as a JSON object or array (requires `Authorization: Bearer <API_TOKEN>`)
|
||||
* POST `/api/neighbors` - appends neighbor tuples provided as a JSON object or array (requires `Authorization: Bearer <API_TOKEN>`)
|
||||
* POST `/api/traces` - appends caught traces routes provided as a JSON object or array (requires `Authorization: Bearer <API_TOKEN>`)
|
||||
|
||||
The `API_TOKEN` environment variable must be set to a non-empty value and match the token supplied in the `Authorization` header for `POST` requests.
|
||||
|
||||
@@ -189,13 +202,52 @@ Run the script with `INSTANCE_DOMAIN` and `API_TOKEN` to keep updating
|
||||
node records and parsing new incoming messages. Enable debug output with `DEBUG=1`,
|
||||
specify the connection target with `CONNECTION` (default `/dev/ttyACM0`) or set it to
|
||||
an IP address (for example `192.168.1.20:4403`) to use the Meshtastic TCP
|
||||
interface. `CONNECTION` also accepts Bluetooth device addresses (e.g.,
|
||||
`ED:4D:9E:95:CF:60`) and the script attempts a BLE connection if available. The
|
||||
ingestor will still honor the legacy `POTATOMESH_INSTANCE` variable when
|
||||
`INSTANCE_DOMAIN` is unset to ease upgrades from earlier deployments. To keep
|
||||
private channels out of the web UI, set `HIDDEN_CHANNELS` to a comma-separated
|
||||
list of channel names (for example `HIDDEN_CHANNELS="Secret,Ops"`); packets on
|
||||
those channels are discarded instead of being sent to `/api/messages`.
|
||||
interface. `CONNECTION` also accepts Bluetooth device addresses in MAC format (e.g.,
|
||||
`ED:4D:9E:95:CF:60`) or UUID format for macOS (e.g., `C0AEA92F-045E-9B82-C9A6-A1FD822B3A9E`)
|
||||
and the script attempts a BLE connection if available. To keep
|
||||
ingestion limited, set `ALLOWED_CHANNELS` to a comma-separated whitelist (for
|
||||
example `ALLOWED_CHANNELS="Chat,Ops"`); packets on other channels are discarded.
|
||||
Use `HIDDEN_CHANNELS` to block specific channels from the web UI even when they
|
||||
appear in the allowlist.
|
||||
|
||||
## Nix
|
||||
|
||||
For the dev shell, run:
|
||||
|
||||
```bash
|
||||
nix develop
|
||||
```
|
||||
|
||||
The shell provides Ruby plus the Python ingestor dependencies (including `meshtastic`
|
||||
and `protobuf`). To sanity-check that the ingestor starts, run `python -m data.mesh`
|
||||
with the usual environment variables (`INSTANCE_DOMAIN`, `API_TOKEN`, `CONNECTION`).
|
||||
|
||||
To run the packaged apps directly:
|
||||
|
||||
```bash
|
||||
nix run .#web
|
||||
nix run .#ingestor
|
||||
```
|
||||
|
||||
Minimal NixOS module snippet:
|
||||
|
||||
```nix
|
||||
services.potato-mesh = {
|
||||
enable = true;
|
||||
apiTokenFile = config.sops.secrets.potato-mesh-api-token.path;
|
||||
dataDir = "/var/lib/potato-mesh";
|
||||
port = 41447;
|
||||
instanceDomain = "https://mesh.me";
|
||||
siteName = "Nix Mesh";
|
||||
contactLink = "homeserver.mx";
|
||||
mapCenter = "28.96,-13.56";
|
||||
frequency = "868MHz";
|
||||
ingestor = {
|
||||
enable = true;
|
||||
connection = "192.168.X.Y:4403";
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
@@ -205,12 +257,19 @@ Docker images are published on Github for each release:
|
||||
docker pull ghcr.io/l5yth/potato-mesh/web:latest # newest release
|
||||
docker pull ghcr.io/l5yth/potato-mesh/web:v0.5.5 # pinned historical release
|
||||
docker pull ghcr.io/l5yth/potato-mesh/ingestor:latest
|
||||
docker pull ghcr.io/l5yth/potato-mesh/matrix-bridge:latest
|
||||
```
|
||||
|
||||
Feel free to run the [configure.sh](./configure.sh) script to set up your
|
||||
environment. See the [Docker guide](DOCKER.md) for more details and custom
|
||||
deployment instructions.
|
||||
|
||||
## Matrix Bridge
|
||||
|
||||
A matrix bridge is currently being worked on. It requests messages from a configured
|
||||
potato-mesh instance and forwards it to a specified matrix channel; see
|
||||
[matrix/README.md](./matrix/README.md).
|
||||
|
||||
## Mobile App
|
||||
|
||||
A mobile _reader_ app is currently being worked on. Stay tuned for releases and updates.
|
||||
|
||||
@@ -15,11 +15,11 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.5.7</string>
|
||||
<string>0.5.9</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.5.7</string>
|
||||
<string>0.5.9</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>14.0</string>
|
||||
</dict>
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: potato_mesh_reader
|
||||
description: Meshtastic Reader — read-only view for PotatoMesh messages.
|
||||
publish_to: "none"
|
||||
version: 0.5.7
|
||||
version: 0.5.9
|
||||
|
||||
environment:
|
||||
sdk: ">=3.4.0 <4.0.0"
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// Copyright © 2025-26 l5yth & contributors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:potato_mesh_reader/main.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('BootstrapProgress renders stage, counts, and detail', () {
|
||||
const progress = BootstrapProgress(
|
||||
stage: 'Downloading',
|
||||
current: 2,
|
||||
total: 5,
|
||||
detail: 'instances',
|
||||
);
|
||||
|
||||
expect(progress.label, 'Downloading 2/5 • instances');
|
||||
|
||||
const fallback = BootstrapProgress(stage: 'Starting');
|
||||
expect(fallback.label, 'Starting');
|
||||
});
|
||||
|
||||
test('InstanceVersion summary prefers populated fields', () {
|
||||
const populated = InstanceVersion(
|
||||
name: 'BerlinMesh',
|
||||
channel: '#MediumFast',
|
||||
frequency: '868MHz',
|
||||
instanceDomain: 'potatomesh.net',
|
||||
);
|
||||
expect(populated.summary, 'BerlinMesh · #MediumFast · 868MHz');
|
||||
|
||||
const minimal = InstanceVersion(
|
||||
name: '',
|
||||
channel: null,
|
||||
frequency: null,
|
||||
instanceDomain: null,
|
||||
);
|
||||
expect(minimal.summary, 'Unknown');
|
||||
});
|
||||
|
||||
test('sortMessagesByRxTime keeps unknown timestamps in place', () {
|
||||
MeshMessage buildMessage({
|
||||
required int id,
|
||||
required String text,
|
||||
required String rxIso,
|
||||
DateTime? rxTime,
|
||||
}) {
|
||||
return MeshMessage(
|
||||
id: id,
|
||||
rxTime: rxTime,
|
||||
rxIso: rxIso,
|
||||
fromId: '!$id',
|
||||
nodeId: '!$id',
|
||||
toId: '^',
|
||||
channelName: '#general',
|
||||
channel: 1,
|
||||
portnum: 'TEXT',
|
||||
text: text,
|
||||
rssi: -50,
|
||||
snr: 1.0,
|
||||
hopLimit: 1,
|
||||
);
|
||||
}
|
||||
|
||||
final withTime = buildMessage(
|
||||
id: 2,
|
||||
rxTime: DateTime.utc(2024, 1, 1, 12, 1),
|
||||
rxIso: '2024-01-01T12:01:00Z',
|
||||
text: 'timed',
|
||||
);
|
||||
final withoutTime = buildMessage(
|
||||
id: 1,
|
||||
rxTime: null,
|
||||
rxIso: 'unknown',
|
||||
text: 'unknown',
|
||||
);
|
||||
final laterTime = buildMessage(
|
||||
id: 3,
|
||||
rxTime: DateTime.utc(2024, 1, 1, 12, 5),
|
||||
rxIso: '2024-01-01T12:05:00Z',
|
||||
text: 'later',
|
||||
);
|
||||
|
||||
final sorted = sortMessagesByRxTime([withoutTime, laterTime, withTime]);
|
||||
|
||||
expect(sorted.first.id, withoutTime.id,
|
||||
reason: 'messages without rxTime should retain position');
|
||||
expect(sorted[1].id, withTime.id,
|
||||
reason: 'messages with timestamps should be ordered chronologically');
|
||||
expect(sorted.last.id, laterTime.id);
|
||||
});
|
||||
|
||||
testWidgets('LoadingScreen displays progress label and icon', (tester) async {
|
||||
const screen = LoadingScreen(
|
||||
progress: BootstrapProgress(stage: 'Fetching'),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(const MaterialApp(home: screen));
|
||||
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
expect(find.text('Fetching'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('PotatoMesh'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('LoadingScreen surfaces errors', (tester) async {
|
||||
const screen = LoadingScreen(
|
||||
progress: BootstrapProgress(stage: 'Loading'),
|
||||
error: 'boom',
|
||||
);
|
||||
|
||||
await tester.pumpWidget(const MaterialApp(home: screen));
|
||||
|
||||
expect(find.textContaining('Failed to load: boom'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -77,6 +77,7 @@ FREQUENCY=$(grep "^FREQUENCY=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' ||
|
||||
FEDERATION=$(grep "^FEDERATION=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "1")
|
||||
PRIVATE=$(grep "^PRIVATE=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "0")
|
||||
HIDDEN_CHANNELS=$(grep "^HIDDEN_CHANNELS=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "")
|
||||
ALLOWED_CHANNELS=$(grep "^ALLOWED_CHANNELS=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "")
|
||||
MAP_CENTER=$(grep "^MAP_CENTER=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "38.761944,-27.090833")
|
||||
MAP_ZOOM=$(grep "^MAP_ZOOM=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "")
|
||||
MAX_DISTANCE=$(grep "^MAX_DISTANCE=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "42")
|
||||
@@ -127,6 +128,9 @@ echo "-------------------"
|
||||
echo "Private mode hides public mesh messages from unauthenticated visitors."
|
||||
echo "Set to 1 to hide public feeds or 0 to keep them visible."
|
||||
read_with_default "Enable private mode (1=yes, 0=no)" "$PRIVATE" PRIVATE
|
||||
echo "Provide a comma-separated whitelist of channel names to ingest (optional)."
|
||||
echo "When set, only listed channels are ingested unless explicitly hidden below."
|
||||
read_with_default "Allowed channels" "$ALLOWED_CHANNELS" ALLOWED_CHANNELS
|
||||
echo "Provide a comma-separated list of channel names to hide from the web UI (optional)."
|
||||
read_with_default "Hidden channels" "$HIDDEN_CHANNELS" HIDDEN_CHANNELS
|
||||
|
||||
@@ -199,6 +203,11 @@ update_env "POTATOMESH_IMAGE_TAG" "$POTATOMESH_IMAGE_TAG"
|
||||
update_env "FEDERATION" "$FEDERATION"
|
||||
update_env "PRIVATE" "$PRIVATE"
|
||||
update_env "CONNECTION" "$CONNECTION"
|
||||
if [ -n "$ALLOWED_CHANNELS" ]; then
|
||||
update_env "ALLOWED_CHANNELS" "\"$ALLOWED_CHANNELS\""
|
||||
else
|
||||
sed -i.bak '/^ALLOWED_CHANNELS=.*/d' .env
|
||||
fi
|
||||
if [ -n "$HIDDEN_CHANNELS" ]; then
|
||||
update_env "HIDDEN_CHANNELS" "\"$HIDDEN_CHANNELS\""
|
||||
else
|
||||
@@ -252,6 +261,7 @@ echo " API Token: ${API_TOKEN:0:8}..."
|
||||
echo " Docker Image Arch: $POTATOMESH_IMAGE_ARCH"
|
||||
echo " Docker Image Tag: $POTATOMESH_IMAGE_TAG"
|
||||
echo " Private Mode: ${PRIVATE}"
|
||||
echo " Allowed Channels: ${ALLOWED_CHANNELS:-'All'}"
|
||||
echo " Hidden Channels: ${HIDDEN_CHANNELS:-'None'}"
|
||||
echo " Instance Domain: ${INSTANCE_DOMAIN:-'Auto-detected'}"
|
||||
if [ "${FEDERATION:-1}" = "0" ]; then
|
||||
|
||||
@@ -50,6 +50,8 @@ USER potatomesh
|
||||
ENV CONNECTION=/dev/ttyACM0 \
|
||||
CHANNEL_INDEX=0 \
|
||||
DEBUG=0 \
|
||||
ALLOWED_CHANNELS="" \
|
||||
HIDDEN_CHANNELS="" \
|
||||
INSTANCE_DOMAIN="" \
|
||||
API_TOKEN=""
|
||||
|
||||
@@ -75,6 +77,8 @@ USER ContainerUser
|
||||
ENV CONNECTION=/dev/ttyACM0 \
|
||||
CHANNEL_INDEX=0 \
|
||||
DEBUG=0 \
|
||||
ALLOWED_CHANNELS="" \
|
||||
HIDDEN_CHANNELS="" \
|
||||
INSTANCE_DOMAIN="" \
|
||||
API_TOKEN=""
|
||||
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ The ``data.mesh`` module exposes helpers for reading Meshtastic node and
|
||||
message information before forwarding it to the accompanying web application.
|
||||
"""
|
||||
|
||||
VERSION = "0.5.7"
|
||||
VERSION = "0.5.9"
|
||||
"""Semantic version identifier shared with the dashboard and front-end."""
|
||||
|
||||
__version__ = VERSION
|
||||
|
||||
@@ -70,6 +70,7 @@ _CONFIG_ATTRS = {
|
||||
"DEBUG",
|
||||
"INSTANCE",
|
||||
"API_TOKEN",
|
||||
"ALLOWED_CHANNELS",
|
||||
"HIDDEN_CHANNELS",
|
||||
"LORA_FREQ",
|
||||
"MODEM_PRESET",
|
||||
|
||||
@@ -228,6 +228,33 @@ def hidden_channel_names() -> tuple[str, ...]:
|
||||
return tuple(getattr(config, "HIDDEN_CHANNELS", ()))
|
||||
|
||||
|
||||
def allowed_channel_names() -> tuple[str, ...]:
|
||||
"""Return the configured set of explicitly allowed channel names."""
|
||||
|
||||
return tuple(getattr(config, "ALLOWED_CHANNELS", ()))
|
||||
|
||||
|
||||
def is_allowed_channel(channel_name_value: str | None) -> bool:
|
||||
"""Return ``True`` when ``channel_name_value`` is permitted by policy."""
|
||||
|
||||
allowed = getattr(config, "ALLOWED_CHANNELS", ())
|
||||
if not allowed:
|
||||
return True
|
||||
|
||||
if channel_name_value is None:
|
||||
return False
|
||||
|
||||
normalized = channel_name_value.strip()
|
||||
if not normalized:
|
||||
return False
|
||||
|
||||
normalized_casefold = normalized.casefold()
|
||||
for allowed_name in allowed:
|
||||
if normalized_casefold == allowed_name.casefold():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_hidden_channel(channel_name_value: str | None) -> bool:
|
||||
"""Return ``True`` when ``channel_name_value`` is configured as hidden."""
|
||||
|
||||
@@ -255,7 +282,9 @@ __all__ = [
|
||||
"capture_from_interface",
|
||||
"channel_mappings",
|
||||
"channel_name",
|
||||
"allowed_channel_names",
|
||||
"hidden_channel_names",
|
||||
"is_allowed_channel",
|
||||
"is_hidden_channel",
|
||||
"_reset_channel_cache",
|
||||
]
|
||||
|
||||
@@ -66,8 +66,8 @@ CHANNEL_INDEX = int(os.environ.get("CHANNEL_INDEX", str(DEFAULT_CHANNEL_INDEX)))
|
||||
DEBUG = os.environ.get("DEBUG") == "1"
|
||||
|
||||
|
||||
def _parse_hidden_channels(raw_value: str | None) -> tuple[str, ...]:
|
||||
"""Normalise a comma-separated list of hidden channel names.
|
||||
def _parse_channel_names(raw_value: str | None) -> tuple[str, ...]:
|
||||
"""Normalise a comma-separated list of channel names.
|
||||
|
||||
Parameters:
|
||||
raw_value: Raw environment string containing channel names separated by
|
||||
@@ -96,9 +96,18 @@ def _parse_hidden_channels(raw_value: str | None) -> tuple[str, ...]:
|
||||
return tuple(normalized_entries)
|
||||
|
||||
|
||||
def _parse_hidden_channels(raw_value: str | None) -> tuple[str, ...]:
|
||||
"""Compatibility wrapper that parses hidden channel names."""
|
||||
|
||||
return _parse_channel_names(raw_value)
|
||||
|
||||
|
||||
HIDDEN_CHANNELS = _parse_hidden_channels(os.environ.get("HIDDEN_CHANNELS"))
|
||||
"""Channel names configured to be ignored by the ingestor."""
|
||||
|
||||
ALLOWED_CHANNELS = _parse_channel_names(os.environ.get("ALLOWED_CHANNELS"))
|
||||
"""Explicitly permitted channel names; when set, other channels are ignored."""
|
||||
|
||||
|
||||
def _resolve_instance_domain() -> str:
|
||||
"""Resolve the configured instance domain from the environment.
|
||||
@@ -183,6 +192,7 @@ __all__ = [
|
||||
"CHANNEL_INDEX",
|
||||
"DEBUG",
|
||||
"HIDDEN_CHANNELS",
|
||||
"ALLOWED_CHANNELS",
|
||||
"INSTANCE",
|
||||
"API_TOKEN",
|
||||
"ENERGY_SAVING",
|
||||
|
||||
@@ -100,6 +100,41 @@ from .serialization import (
|
||||
)
|
||||
|
||||
|
||||
def _portnum_candidates(name: str) -> set[int]:
|
||||
"""Return Meshtastic port number candidates for ``name``.
|
||||
|
||||
Parameters:
|
||||
name: Port name to look up in Meshtastic ``PortNum`` enums.
|
||||
|
||||
Returns:
|
||||
Set of integer port numbers resolved from Meshtastic modules.
|
||||
"""
|
||||
|
||||
candidates: set[int] = set()
|
||||
for module_name in (
|
||||
"meshtastic.portnums_pb2",
|
||||
"meshtastic.protobuf.portnums_pb2",
|
||||
):
|
||||
module = sys.modules.get(module_name)
|
||||
if module is None:
|
||||
with contextlib.suppress(ModuleNotFoundError):
|
||||
module = importlib.import_module(module_name)
|
||||
if module is None:
|
||||
continue
|
||||
portnum_enum = getattr(module, "PortNum", None)
|
||||
value_lookup = getattr(portnum_enum, "Value", None) if portnum_enum else None
|
||||
if callable(value_lookup):
|
||||
with contextlib.suppress(Exception):
|
||||
candidate = _coerce_int(value_lookup(name))
|
||||
if candidate is not None:
|
||||
candidates.add(candidate)
|
||||
constant_value = getattr(module, name, None)
|
||||
candidate = _coerce_int(constant_value)
|
||||
if candidate is not None:
|
||||
candidates.add(candidate)
|
||||
return candidates
|
||||
|
||||
|
||||
def register_host_node_id(node_id: str | None) -> None:
|
||||
"""Record the canonical identifier for the connected host device.
|
||||
|
||||
@@ -1280,28 +1315,7 @@ def store_packet_dict(packet: Mapping) -> None:
|
||||
traceroute_section = (
|
||||
decoded.get("traceroute") if isinstance(decoded, Mapping) else None
|
||||
)
|
||||
traceroute_port_ints: set[int] = set()
|
||||
for module_name in (
|
||||
"meshtastic.portnums_pb2",
|
||||
"meshtastic.protobuf.portnums_pb2",
|
||||
):
|
||||
module = sys.modules.get(module_name)
|
||||
if module is None:
|
||||
with contextlib.suppress(ModuleNotFoundError):
|
||||
module = importlib.import_module(module_name)
|
||||
if module is None:
|
||||
continue
|
||||
portnum_enum = getattr(module, "PortNum", None)
|
||||
value_lookup = getattr(portnum_enum, "Value", None) if portnum_enum else None
|
||||
if callable(value_lookup):
|
||||
with contextlib.suppress(Exception):
|
||||
candidate = _coerce_int(value_lookup("TRACEROUTE_APP"))
|
||||
if candidate is not None:
|
||||
traceroute_port_ints.add(candidate)
|
||||
constant_value = getattr(module, "TRACEROUTE_APP", None)
|
||||
candidate = _coerce_int(constant_value)
|
||||
if candidate is not None:
|
||||
traceroute_port_ints.add(candidate)
|
||||
traceroute_port_ints = _portnum_candidates("TRACEROUTE_APP")
|
||||
|
||||
if (
|
||||
portnum == "TRACEROUTE_APP"
|
||||
@@ -1359,36 +1373,43 @@ def store_packet_dict(packet: Mapping) -> None:
|
||||
if emoji_text:
|
||||
emoji = emoji_text
|
||||
|
||||
allowed_port_values = {"1", "TEXT_MESSAGE_APP", "REACTION_APP"}
|
||||
routing_section = decoded.get("routing") if isinstance(decoded, Mapping) else None
|
||||
routing_port_candidates = _portnum_candidates("ROUTING_APP")
|
||||
if text is None and (
|
||||
portnum == "ROUTING_APP"
|
||||
or (portnum_int is not None and portnum_int in routing_port_candidates)
|
||||
or isinstance(routing_section, Mapping)
|
||||
):
|
||||
routing_payload = _first(decoded, "payload", "data", default=None)
|
||||
if routing_payload is not None:
|
||||
if isinstance(routing_payload, bytes):
|
||||
text = base64.b64encode(routing_payload).decode("ascii")
|
||||
elif isinstance(routing_payload, str):
|
||||
text = routing_payload
|
||||
else:
|
||||
try:
|
||||
text = json.dumps(routing_payload, ensure_ascii=True)
|
||||
except TypeError:
|
||||
text = str(routing_payload)
|
||||
if isinstance(text, str):
|
||||
text = text.strip() or None
|
||||
|
||||
allowed_port_values = {"1", "TEXT_MESSAGE_APP", "REACTION_APP", "ROUTING_APP"}
|
||||
allowed_port_ints = {1}
|
||||
|
||||
reaction_port_candidates: set[int] = set()
|
||||
for module_name in (
|
||||
"meshtastic.portnums_pb2",
|
||||
"meshtastic.protobuf.portnums_pb2",
|
||||
):
|
||||
module = sys.modules.get(module_name)
|
||||
if module is None:
|
||||
with contextlib.suppress(ModuleNotFoundError):
|
||||
module = importlib.import_module(module_name)
|
||||
if module is None:
|
||||
continue
|
||||
portnum_enum = getattr(module, "PortNum", None)
|
||||
value_lookup = getattr(portnum_enum, "Value", None) if portnum_enum else None
|
||||
if callable(value_lookup):
|
||||
with contextlib.suppress(Exception):
|
||||
candidate = _coerce_int(value_lookup("REACTION_APP"))
|
||||
if candidate is not None:
|
||||
reaction_port_candidates.add(candidate)
|
||||
constant_value = getattr(module, "REACTION_APP", None)
|
||||
candidate = _coerce_int(constant_value)
|
||||
if candidate is not None:
|
||||
reaction_port_candidates.add(candidate)
|
||||
|
||||
reaction_port_candidates = _portnum_candidates("REACTION_APP")
|
||||
for candidate in reaction_port_candidates:
|
||||
allowed_port_ints.add(candidate)
|
||||
allowed_port_values.add(str(candidate))
|
||||
|
||||
for candidate in routing_port_candidates:
|
||||
allowed_port_ints.add(candidate)
|
||||
allowed_port_values.add(str(candidate))
|
||||
|
||||
if isinstance(routing_section, Mapping) and portnum_int is not None:
|
||||
allowed_port_ints.add(portnum_int)
|
||||
allowed_port_values.add(str(portnum_int))
|
||||
|
||||
is_reaction_packet = portnum == "REACTION_APP" or (
|
||||
reply_id is not None and emoji is not None
|
||||
)
|
||||
@@ -1461,6 +1482,18 @@ def store_packet_dict(packet: Mapping) -> None:
|
||||
_record_ignored_packet(packet, reason="skipped-direct-message")
|
||||
return
|
||||
|
||||
if not channels.is_allowed_channel(channel_name_value):
|
||||
_record_ignored_packet(packet, reason="disallowed-channel")
|
||||
if config.DEBUG:
|
||||
config._debug_log(
|
||||
"Ignored packet on disallowed channel",
|
||||
context="handlers.store_packet_dict",
|
||||
channel=channel,
|
||||
channel_name=channel_name_value,
|
||||
allowed_channels=channels.allowed_channel_names(),
|
||||
)
|
||||
return
|
||||
|
||||
if channels.is_hidden_channel(channel_name_value):
|
||||
_record_ignored_packet(packet, reason="hidden-channel")
|
||||
if config.DEBUG:
|
||||
|
||||
@@ -628,7 +628,13 @@ _DEFAULT_SERIAL_PATTERNS = (
|
||||
"/dev/cu.usbserial*",
|
||||
)
|
||||
|
||||
_BLE_ADDRESS_RE = re.compile(r"^(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$")
|
||||
# Support both MAC addresses (Linux/Windows) and UUIDs (macOS)
|
||||
_BLE_ADDRESS_RE = re.compile(
|
||||
r"^(?:"
|
||||
r"(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}|" # MAC address format
|
||||
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" # UUID format
|
||||
r")$"
|
||||
)
|
||||
|
||||
|
||||
class _DummySerialInterface:
|
||||
@@ -642,13 +648,13 @@ class _DummySerialInterface:
|
||||
|
||||
|
||||
def _parse_ble_target(value: str) -> str | None:
|
||||
"""Return an uppercase BLE MAC address when ``value`` matches the format.
|
||||
"""Return a normalized BLE address (MAC or UUID) when ``value`` matches the format.
|
||||
|
||||
Parameters:
|
||||
value: User-provided target string.
|
||||
|
||||
Returns:
|
||||
The normalised MAC address or ``None`` when validation fails.
|
||||
The normalised MAC address or UUID, or ``None`` when validation fails.
|
||||
"""
|
||||
|
||||
if not value:
|
||||
@@ -772,10 +778,13 @@ def _create_serial_interface(port: str) -> tuple[object, str]:
|
||||
return _DummySerialInterface(), "mock"
|
||||
ble_target = _parse_ble_target(port_value)
|
||||
if ble_target:
|
||||
# Determine if it's a MAC address or UUID
|
||||
address_type = "MAC" if ":" in ble_target else "UUID"
|
||||
config._debug_log(
|
||||
"Using BLE interface",
|
||||
context="interfaces.ble",
|
||||
address=ble_target,
|
||||
address_type=address_type,
|
||||
)
|
||||
return _load_ble_interface()(address=ble_target), ble_target
|
||||
network_target = _parse_network_target(port_value)
|
||||
|
||||
@@ -49,6 +49,7 @@ x-ingestor-base: &ingestor-base
|
||||
environment:
|
||||
CONNECTION: ${CONNECTION:-/dev/ttyACM0}
|
||||
CHANNEL_INDEX: ${CHANNEL_INDEX:-0}
|
||||
ALLOWED_CHANNELS: ${ALLOWED_CHANNELS:-""}
|
||||
HIDDEN_CHANNELS: ${HIDDEN_CHANNELS:-""}
|
||||
API_TOKEN: ${API_TOKEN}
|
||||
INSTANCE_DOMAIN: ${INSTANCE_DOMAIN}
|
||||
@@ -76,6 +77,21 @@ x-ingestor-base: &ingestor-base
|
||||
memory: 128M
|
||||
cpus: '0.1'
|
||||
|
||||
x-matrix-bridge-base: &matrix-bridge-base
|
||||
image: ghcr.io/l5yth/potato-mesh-matrix-bridge-${POTATOMESH_IMAGE_ARCH:-linux-amd64}:${POTATOMESH_IMAGE_TAG:-latest}
|
||||
volumes:
|
||||
- potatomesh_matrix_bridge_state:/app
|
||||
- ./matrix/Config.toml:/app/Config.toml:ro
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 128M
|
||||
cpus: '0.1'
|
||||
reservations:
|
||||
memory: 64M
|
||||
cpus: '0.05'
|
||||
|
||||
services:
|
||||
web:
|
||||
<<: *web-base
|
||||
@@ -109,6 +125,24 @@ services:
|
||||
profiles:
|
||||
- bridge
|
||||
|
||||
matrix-bridge:
|
||||
<<: *matrix-bridge-base
|
||||
network_mode: host
|
||||
depends_on:
|
||||
- web
|
||||
extra_hosts:
|
||||
- "web:127.0.0.1"
|
||||
|
||||
matrix-bridge-bridge:
|
||||
<<: *matrix-bridge-base
|
||||
container_name: potatomesh-matrix-bridge
|
||||
networks:
|
||||
- potatomesh-network
|
||||
depends_on:
|
||||
- web-bridge
|
||||
profiles:
|
||||
- bridge
|
||||
|
||||
volumes:
|
||||
potatomesh_data:
|
||||
driver: local
|
||||
@@ -116,6 +150,8 @@ volumes:
|
||||
driver: local
|
||||
potatomesh_logs:
|
||||
driver: local
|
||||
potatomesh_matrix_bridge_state:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
potatomesh-network:
|
||||
|
||||
Generated
+61
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1766070988,
|
||||
"narHash": "sha256-G/WVghka6c4bAzMhTwT2vjLccg/awmHkdKSd2JrycLc=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "c6245e83d836d0433170a16eb185cefe0572f8b8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
{
|
||||
description = "PotatoMesh - A federated, Meshtastic-powered node dashboard";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
|
||||
# Python environment for the ingestor
|
||||
pythonEnv = pkgs.python3.withPackages (ps: with ps; [
|
||||
meshtastic
|
||||
protobuf
|
||||
requests
|
||||
]);
|
||||
|
||||
# Web app wrapper script
|
||||
webApp = pkgs.writeShellApplication {
|
||||
name = "potato-mesh-web";
|
||||
runtimeInputs = [ pkgs.ruby pkgs.bundler pkgs.sqlite pkgs.git pkgs.gnumake pkgs.gcc ];
|
||||
text = ''
|
||||
if [ -n "''${XDG_DATA_HOME:-}" ]; then
|
||||
BASEDIR="$XDG_DATA_HOME"
|
||||
else
|
||||
BASEDIR="$HOME/.local/share/potato-mesh"
|
||||
fi
|
||||
WORKDIR="$BASEDIR/web"
|
||||
mkdir -p "$WORKDIR"
|
||||
|
||||
# Copy app files if not present or outdated
|
||||
APP_SRC="${./web}"
|
||||
DATA_SRC="${./data}"
|
||||
if [ ! -f "$WORKDIR/.installed" ] || [ "$APP_SRC" != "$(cat "$WORKDIR/.src_path" 2>/dev/null)" ]; then
|
||||
# Copy web app
|
||||
cp -rT "$APP_SRC" "$WORKDIR/"
|
||||
chmod -R u+w "$WORKDIR"
|
||||
# Copy data directory (contains SQL schemas)
|
||||
mkdir -p "$BASEDIR/data"
|
||||
cp -rT "$DATA_SRC" "$BASEDIR/data/"
|
||||
chmod -R u+w "$BASEDIR/data"
|
||||
echo "$APP_SRC" > "$WORKDIR/.src_path"
|
||||
rm -f "$WORKDIR/.installed"
|
||||
fi
|
||||
|
||||
cd "$WORKDIR"
|
||||
|
||||
# Install gems if needed
|
||||
if [ ! -f ".installed" ]; then
|
||||
bundle config set --local path 'vendor/bundle'
|
||||
bundle install
|
||||
touch .installed
|
||||
fi
|
||||
|
||||
exec bundle exec ruby app.rb -p "''${PORT:-41447}" -o "''${HOST:-0.0.0.0}"
|
||||
'';
|
||||
};
|
||||
|
||||
# Ingestor wrapper script
|
||||
ingestor = pkgs.writeShellApplication {
|
||||
name = "potato-mesh-ingestor";
|
||||
runtimeInputs = [ pythonEnv ];
|
||||
text = ''
|
||||
# The ingestor needs to run from parent directory with data/ folder
|
||||
if [ -n "''${XDG_DATA_HOME:-}" ]; then
|
||||
BASEDIR="$XDG_DATA_HOME"
|
||||
else
|
||||
BASEDIR="$HOME/.local/share/potato-mesh"
|
||||
fi
|
||||
if [ ! -d "$BASEDIR/data" ]; then
|
||||
mkdir -p "$BASEDIR"
|
||||
cp -rT "${./data}" "$BASEDIR/data/"
|
||||
chmod -R u+w "$BASEDIR/data"
|
||||
fi
|
||||
cd "$BASEDIR"
|
||||
exec python -m data.mesh
|
||||
'';
|
||||
};
|
||||
|
||||
in {
|
||||
packages = {
|
||||
web = webApp;
|
||||
ingestor = ingestor;
|
||||
default = webApp;
|
||||
};
|
||||
|
||||
apps = {
|
||||
web = {
|
||||
type = "app";
|
||||
program = "${webApp}/bin/potato-mesh-web";
|
||||
};
|
||||
ingestor = {
|
||||
type = "app";
|
||||
program = "${ingestor}/bin/potato-mesh-ingestor";
|
||||
};
|
||||
default = self.apps.${system}.web;
|
||||
};
|
||||
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = [
|
||||
pkgs.ruby
|
||||
pkgs.bundler
|
||||
pythonEnv
|
||||
pkgs.sqlite
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
echo "PotatoMesh development shell"
|
||||
echo " - Ruby: $(ruby --version)"
|
||||
echo " - Python: $(python --version)"
|
||||
echo ""
|
||||
echo "To run the web app: cd web && bundle install && ./app.sh"
|
||||
echo "To run the ingestor: cd data && python mesh.py"
|
||||
'';
|
||||
};
|
||||
|
||||
checks.potato-mesh-nixos = pkgs.testers.nixosTest {
|
||||
name = "potato-mesh-data-dir";
|
||||
nodes.machine = { lib, ... }: {
|
||||
imports = [ self.nixosModules.default ];
|
||||
services.potato-mesh = {
|
||||
enable = true;
|
||||
apiToken = "test-token";
|
||||
dataDir = "/var/lib/potato-mesh";
|
||||
ingestor.enable = true;
|
||||
};
|
||||
systemd.services.potato-mesh-ingestor.wantedBy = lib.mkForce [];
|
||||
};
|
||||
testScript = ''
|
||||
machine.start
|
||||
machine.succeed("grep -q 'XDG_DATA_HOME=/var/lib/potato-mesh' /etc/systemd/system/potato-mesh-web.service")
|
||||
machine.succeed("grep -q 'XDG_DATA_HOME=/var/lib/potato-mesh' /etc/systemd/system/potato-mesh-ingestor.service")
|
||||
machine.succeed("grep -q 'WorkingDirectory=/var/lib/potato-mesh' /etc/systemd/system/potato-mesh-web.service")
|
||||
machine.succeed("grep -q 'WorkingDirectory=/var/lib/potato-mesh' /etc/systemd/system/potato-mesh-ingestor.service")
|
||||
'';
|
||||
};
|
||||
}
|
||||
) // {
|
||||
# NixOS module
|
||||
nixosModules.default = { config, lib, pkgs, ... }:
|
||||
let
|
||||
cfg = config.services.potato-mesh;
|
||||
in {
|
||||
options.services.potato-mesh = {
|
||||
enable = lib.mkEnableOption "PotatoMesh web dashboard";
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = self.packages.${pkgs.system}.web;
|
||||
description = "The potato-mesh web package to use";
|
||||
};
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 41447;
|
||||
description = "Port to listen on";
|
||||
};
|
||||
|
||||
host = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "0.0.0.0";
|
||||
description = "Host to bind to";
|
||||
};
|
||||
|
||||
apiToken = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = "Shared secret that authorizes ingestors and API clients making POST requests. Warning: visible in nix store. Prefer apiTokenFile for production.";
|
||||
};
|
||||
|
||||
apiTokenFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
default = null;
|
||||
description = "File containing API_TOKEN=<secret> (recommended for production)";
|
||||
};
|
||||
|
||||
instanceDomain = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = "Public hostname used for metadata, federation, and generated API links";
|
||||
};
|
||||
|
||||
siteName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "PotatoMesh Demo";
|
||||
description = "Title and header displayed in the UI";
|
||||
};
|
||||
|
||||
channel = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "#LongFast";
|
||||
description = "Default channel name displayed in the UI";
|
||||
};
|
||||
|
||||
frequency = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "915MHz";
|
||||
description = "Default frequency description displayed in the UI";
|
||||
};
|
||||
|
||||
contactLink = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "#potatomesh:dod.ngo";
|
||||
description = "Chat link or Matrix alias rendered in the footer and overlays";
|
||||
};
|
||||
|
||||
mapCenter = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "38.761944,-27.090833";
|
||||
description = "Latitude and longitude that centre the map on load";
|
||||
};
|
||||
|
||||
mapZoom = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.int;
|
||||
default = null;
|
||||
description = "Fixed Leaflet zoom applied on first load; disables auto-fit when provided";
|
||||
};
|
||||
|
||||
maxDistance = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
default = 42;
|
||||
description = "Maximum distance (km) before node relationships are hidden on the map";
|
||||
};
|
||||
|
||||
debug = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Enable verbose logging";
|
||||
};
|
||||
|
||||
allowedChannels = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = "Comma-separated channel names the ingestor accepts";
|
||||
};
|
||||
|
||||
hiddenChannels = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = "Comma-separated channel names the ingestor will ignore";
|
||||
};
|
||||
|
||||
federation = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Announce instance and crawl peers";
|
||||
};
|
||||
|
||||
private = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Hide chat UI, disable message APIs, and exclude hidden clients from public listings";
|
||||
};
|
||||
|
||||
dataDir = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
default = "/var/lib/potato-mesh";
|
||||
description = "Directory to store database and configuration";
|
||||
};
|
||||
|
||||
user = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "potato-mesh";
|
||||
description = "User to run the service as";
|
||||
};
|
||||
|
||||
group = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "potato-mesh";
|
||||
description = "Group to run the service as";
|
||||
};
|
||||
|
||||
# Ingestor options
|
||||
ingestor = {
|
||||
enable = lib.mkEnableOption "PotatoMesh Python ingestor";
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = self.packages.${pkgs.system}.ingestor;
|
||||
description = "The potato-mesh ingestor package to use";
|
||||
};
|
||||
|
||||
connection = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "/dev/ttyACM0";
|
||||
description = "Connection target: serial port, IP:port for TCP, or Bluetooth address for BLE";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
users.users.${cfg.user} = {
|
||||
isSystemUser = true;
|
||||
group = cfg.group;
|
||||
home = cfg.dataDir;
|
||||
createHome = true;
|
||||
};
|
||||
|
||||
users.groups.${cfg.group} = {};
|
||||
|
||||
systemd.services.potato-mesh-web = {
|
||||
description = "PotatoMesh Web Dashboard";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network.target" ];
|
||||
|
||||
environment = {
|
||||
RACK_ENV = "production";
|
||||
APP_ENV = "production";
|
||||
PORT = toString cfg.port;
|
||||
HOST = cfg.host;
|
||||
SITE_NAME = cfg.siteName;
|
||||
CHANNEL = cfg.channel;
|
||||
FREQUENCY = cfg.frequency;
|
||||
CONTACT_LINK = cfg.contactLink;
|
||||
MAP_CENTER = cfg.mapCenter;
|
||||
MAX_DISTANCE = toString cfg.maxDistance;
|
||||
DEBUG = if cfg.debug then "1" else "0";
|
||||
FEDERATION = if cfg.federation then "1" else "0";
|
||||
PRIVATE = if cfg.private then "1" else "0";
|
||||
XDG_DATA_HOME = cfg.dataDir;
|
||||
XDG_CONFIG_HOME = "${cfg.dataDir}/config";
|
||||
} // lib.optionalAttrs (cfg.instanceDomain != null) {
|
||||
INSTANCE_DOMAIN = cfg.instanceDomain;
|
||||
} // lib.optionalAttrs (cfg.mapZoom != null) {
|
||||
MAP_ZOOM = toString cfg.mapZoom;
|
||||
} // lib.optionalAttrs (cfg.allowedChannels != null) {
|
||||
ALLOWED_CHANNELS = cfg.allowedChannels;
|
||||
} // lib.optionalAttrs (cfg.hiddenChannels != null) {
|
||||
HIDDEN_CHANNELS = cfg.hiddenChannels;
|
||||
} // lib.optionalAttrs (cfg.apiToken != null) {
|
||||
API_TOKEN = cfg.apiToken;
|
||||
};
|
||||
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
User = cfg.user;
|
||||
Group = cfg.group;
|
||||
WorkingDirectory = cfg.dataDir;
|
||||
ExecStart = "${cfg.package}/bin/potato-mesh-web";
|
||||
Restart = "always";
|
||||
RestartSec = 5;
|
||||
} // lib.optionalAttrs (cfg.apiTokenFile != null) {
|
||||
EnvironmentFile = cfg.apiTokenFile;
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services.potato-mesh-ingestor = lib.mkIf cfg.ingestor.enable {
|
||||
description = "PotatoMesh Python Ingestor";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network.target" "potato-mesh-web.service" ];
|
||||
requires = [ "potato-mesh-web.service" ];
|
||||
|
||||
environment = {
|
||||
INSTANCE_DOMAIN = "http://127.0.0.1:${toString cfg.port}";
|
||||
CONNECTION = cfg.ingestor.connection;
|
||||
DEBUG = if cfg.debug then "1" else "0";
|
||||
XDG_DATA_HOME = cfg.dataDir;
|
||||
} // lib.optionalAttrs (cfg.allowedChannels != null) {
|
||||
ALLOWED_CHANNELS = cfg.allowedChannels;
|
||||
} // lib.optionalAttrs (cfg.hiddenChannels != null) {
|
||||
HIDDEN_CHANNELS = cfg.hiddenChannels;
|
||||
} // lib.optionalAttrs (cfg.apiToken != null) {
|
||||
API_TOKEN = cfg.apiToken;
|
||||
};
|
||||
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
User = cfg.user;
|
||||
Group = cfg.group;
|
||||
WorkingDirectory = cfg.dataDir;
|
||||
ExecStart = "${cfg.ingestor.package}/bin/potato-mesh-ingestor";
|
||||
Restart = "always";
|
||||
RestartSec = 10;
|
||||
} // lib.optionalAttrs (cfg.apiTokenFile != null) {
|
||||
EnvironmentFile = cfg.apiTokenFile;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
target/
|
||||
Cargo.lock
|
||||
coverage.lcov
|
||||
bridge_state.json
|
||||
|
||||
Generated
+2117
File diff suppressed because it is too large
Load Diff
+16
-2
@@ -1,6 +1,20 @@
|
||||
# Copyright © 2025-26 l5yth & contributors
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
[package]
|
||||
name = "potatomesh-matrix-bridge"
|
||||
version = "0.5.7"
|
||||
version = "0.5.9"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
@@ -17,4 +31,4 @@ urlencoding = "2"
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
mockito = "1"
|
||||
serial_test = "3"
|
||||
serial_test = "3"
|
||||
|
||||
+7
-6
@@ -1,19 +1,20 @@
|
||||
[potatomesh]
|
||||
# Base URL without trailing slash
|
||||
base_url = "https://potatomesh.net/api"
|
||||
# Base domain (with or without trailing slash)
|
||||
base_url = "https://potatomesh.net"
|
||||
# Poll interval in seconds
|
||||
poll_interval_secs = 60
|
||||
|
||||
[matrix]
|
||||
# Homeserver base URL (client API) without trailing slash
|
||||
homeserver = "https://matrix.example.org"
|
||||
homeserver = "https://matrix.dod.ngo"
|
||||
# Appservice access token (from your registration.yaml)
|
||||
as_token = "YOUR_APPSERVICE_AS_TOKEN"
|
||||
as_token = "INVALID_TOKEN_NOT_WORKING"
|
||||
# Server name (domain) part of Matrix user IDs
|
||||
server_name = "example.org"
|
||||
server_name = "dod.ngo"
|
||||
# Room ID to send into (must be joined by the appservice / puppets)
|
||||
room_id = "!yourroomid:example.org"
|
||||
room_id = "!sXabOBXbVObAlZQEUs:c-base.org" # "#potato-bridge:c-base.org"
|
||||
|
||||
[state]
|
||||
# Where to persist last seen message id (optional but recommended)
|
||||
state_file = "bridge_state.json"
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Copyright © 2025-26 l5yth & contributors
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
FROM rust:1.91-bookworm AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY matrix/Cargo.toml matrix/Cargo.lock ./
|
||||
COPY matrix/src ./src
|
||||
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/usr/local/cargo/git \
|
||||
cargo build --release --locked
|
||||
|
||||
FROM debian:bookworm-slim AS runtime
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates gosu \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN useradd --create-home --uid 10001 --shell /usr/sbin/nologin potatomesh
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/target/release/potatomesh-matrix-bridge /usr/local/bin/potatomesh-matrix-bridge
|
||||
COPY matrix/Config.toml /app/Config.example.toml
|
||||
COPY matrix/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
+46
-16
@@ -4,7 +4,7 @@ A small Rust daemon that bridges **PotatoMesh** LoRa messages into a **Matrix**
|
||||
|
||||
For each PotatoMesh node, the bridge creates (or uses) a **Matrix puppet user**:
|
||||
|
||||
- Matrix localpart: the hex node id (without `!`), e.g. `!67fc83cb` → `@67fc83cb:example.org`
|
||||
- Matrix localpart: `potato_` + the hex node id (without `!`), e.g. `!67fc83cb` → `@potato_67fc83cb:example.org`
|
||||
- Matrix display name: the node’s `long_name` from the PotatoMesh API
|
||||
|
||||
Messages from PotatoMesh are periodically fetched and forwarded to a single Matrix room as those puppet users.
|
||||
@@ -13,10 +13,10 @@ Messages from PotatoMesh are periodically fetched and forwarded to a single Matr
|
||||
|
||||
## Features
|
||||
|
||||
- Polls `https://potatomesh.net/api/messages` (or any configured base URL)
|
||||
- Looks up node metadata via `GET /nodes/{hex}` and caches it
|
||||
- Polls `https://potatomesh.net/api/messages` (deriving `/api` from the configured base domain)
|
||||
- Looks up node metadata via `GET /api/nodes/{hex}` and caches it
|
||||
- One Matrix user per node:
|
||||
- username: hex node id
|
||||
- username: `potato_{hex node id}`
|
||||
- display name: `long_name`
|
||||
- Forwards `TEXT_MESSAGE_APP` messages into a single Matrix room
|
||||
- Persists last-seen message ID to avoid duplicates across restarts
|
||||
@@ -26,12 +26,12 @@ Messages from PotatoMesh are periodically fetched and forwarded to a single Matr
|
||||
## Architecture Overview
|
||||
|
||||
- **PotatoMesh side**
|
||||
- `GET /messages` returns an array of messages
|
||||
- `GET /nodes/{hex}` returns node metadata (including `long_name`)
|
||||
- `GET /api/messages` returns an array of messages
|
||||
- `GET /api/nodes/{hex}` returns node metadata (including `long_name`)
|
||||
|
||||
- **Matrix side**
|
||||
- Uses the Matrix Client-Server API with an **appservice access token**
|
||||
- Impersonates puppet users via `user_id=@{hex}:{server_name}&access_token={as_token}`
|
||||
- Impersonates puppet users via `user_id=@potato_{hex}:{server_name}&access_token={as_token}`
|
||||
- Sends `m.room.message` events into a configured room
|
||||
|
||||
This is **not** a full appservice framework; it just speaks the minimal HTTP needed.
|
||||
@@ -43,11 +43,11 @@ This is **not** a full appservice framework; it just speaks the minimal HTTP nee
|
||||
- Rust (stable) and `cargo`
|
||||
- A Matrix homeserver you control (e.g. Synapse)
|
||||
- An **application service registration** on your homeserver that:
|
||||
- Whitelists the puppet user namespace (e.g. `@[0-9a-f]{8}:example.org`)
|
||||
- Whitelists the puppet user namespace (e.g. `@potato_[0-9a-f]{8}:example.org`)
|
||||
- Provides an `as_token` the bridge can use
|
||||
|
||||
- Network access from the bridge host to:
|
||||
- `https://potatomesh.net/api` (or your configured PotatoMesh API)
|
||||
- `https://potatomesh.net/` (bridge appends `/api`)
|
||||
- Your Matrix homeserver (`https://matrix.example.org`)
|
||||
|
||||
---
|
||||
@@ -60,8 +60,8 @@ Example:
|
||||
|
||||
```toml
|
||||
[potatomesh]
|
||||
# Base URL without trailing slash
|
||||
base_url = "https://potatomesh.net/api"
|
||||
# Base domain (bridge will call {base_url}/api)
|
||||
base_url = "https://potatomesh.net/"
|
||||
# Poll interval in seconds
|
||||
poll_interval_secs = 10
|
||||
|
||||
@@ -84,7 +84,7 @@ state_file = "bridge_state.json"
|
||||
|
||||
The bridge assumes:
|
||||
|
||||
* Messages: `GET {base_url}/messages` → JSON array, for example:
|
||||
* Messages: `GET {base_url}/api/messages` → JSON array, for example:
|
||||
|
||||
```json
|
||||
[
|
||||
@@ -108,7 +108,7 @@ The bridge assumes:
|
||||
]
|
||||
```
|
||||
|
||||
* Nodes: `GET {base_url}/nodes/{hex}` → JSON, for example:
|
||||
* Nodes: `GET {base_url}/api/nodes/{hex}` → JSON, for example:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -122,7 +122,7 @@ The bridge assumes:
|
||||
}
|
||||
```
|
||||
|
||||
Node hex ID is derived from `node_id` by stripping the leading `!` and using the remainder as the Matrix localpart.
|
||||
Node hex ID is derived from `node_id` by stripping the leading `!` and using the remainder inside the puppet localpart prefix (`potato_{hex}`).
|
||||
|
||||
---
|
||||
|
||||
@@ -142,7 +142,7 @@ rate_limited: false
|
||||
namespaces:
|
||||
users:
|
||||
- exclusive: true
|
||||
regex: "@[0-9a-f]{8}:example.org"
|
||||
regex: "@potato_[0-9a-f]{8}:example.org"
|
||||
```
|
||||
|
||||
For this bridge, only the `as_token` and `namespaces.users` actually matter. The bridge does not accept inbound events; it only uses the `as_token` to call the homeserver.
|
||||
@@ -170,6 +170,36 @@ target/release/potatomesh-matrix-bridge
|
||||
|
||||
---
|
||||
|
||||
## Docker
|
||||
|
||||
Build the container from the repo root with the included `matrix/Dockerfile`:
|
||||
|
||||
```bash
|
||||
docker build -f matrix/Dockerfile -t potatomesh-matrix-bridge .
|
||||
```
|
||||
|
||||
Provide your config at `/app/Config.toml` and persist the bridge state file by mounting volumes. Minimal example:
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
-v bridge_state:/app \
|
||||
-v "$(pwd)/matrix/Config.toml:/app/Config.toml:ro" \
|
||||
potatomesh-matrix-bridge
|
||||
```
|
||||
|
||||
If you prefer to isolate the state file from the config, mount it directly instead of the whole `/app` directory:
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
-v bridge_state:/app \
|
||||
-v "$(pwd)/matrix/Config.toml:/app/Config.toml:ro" \
|
||||
potatomesh-matrix-bridge
|
||||
```
|
||||
|
||||
The image ships `Config.example.toml` for reference, but the bridge will exit if `/app/Config.toml` is not provided.
|
||||
|
||||
---
|
||||
|
||||
## Run
|
||||
|
||||
Ensure `Config.toml` is present and valid, then:
|
||||
@@ -193,7 +223,7 @@ The bridge will:
|
||||
3. For each new `TEXT_MESSAGE_APP`:
|
||||
|
||||
* Fetch node info.
|
||||
* Ensure puppet is registered (`@{hex}:{server_name}`).
|
||||
* Ensure puppet is registered (`@potato_{hex}:{server_name}`).
|
||||
* Set puppet display name to `long_name`.
|
||||
* Send a formatted text message into `room_id` as that puppet.
|
||||
* Update and persist `bridge_state.json`.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/bin/sh
|
||||
# Copyright © 2025-26 l5yth & contributors
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
set -e
|
||||
|
||||
# Default state file path from Config.toml unless overridden.
|
||||
STATE_FILE="${STATE_FILE:-/app/bridge_state.json}"
|
||||
STATE_DIR="$(dirname "$STATE_FILE")"
|
||||
|
||||
# Ensure state directory exists and is writable by the non-root user without
|
||||
# touching the read-only config bind mount.
|
||||
if [ ! -d "$STATE_DIR" ]; then
|
||||
mkdir -p "$STATE_DIR"
|
||||
fi
|
||||
|
||||
# Best-effort ownership fix; ignore if the underlying volume is read-only.
|
||||
chown potatomesh:potatomesh "$STATE_DIR" 2>/dev/null || true
|
||||
touch "$STATE_FILE" 2>/dev/null || true
|
||||
chown potatomesh:potatomesh "$STATE_FILE" 2>/dev/null || true
|
||||
|
||||
exec gosu potatomesh potatomesh-matrix-bridge "$@"
|
||||
+18
-4
@@ -1,3 +1,17 @@
|
||||
// Copyright © 2025-26 l5yth & contributors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::{fs, path::Path};
|
||||
|
||||
@@ -53,7 +67,7 @@ mod tests {
|
||||
fn parse_minimal_config_from_toml_str() {
|
||||
let toml_str = r#"
|
||||
[potatomesh]
|
||||
base_url = "https://potatomesh.net/api"
|
||||
base_url = "https://potatomesh.net/"
|
||||
poll_interval_secs = 10
|
||||
|
||||
[matrix]
|
||||
@@ -67,7 +81,7 @@ mod tests {
|
||||
"#;
|
||||
|
||||
let cfg: Config = toml::from_str(toml_str).expect("toml should parse");
|
||||
assert_eq!(cfg.potatomesh.base_url, "https://potatomesh.net/api");
|
||||
assert_eq!(cfg.potatomesh.base_url, "https://potatomesh.net/");
|
||||
assert_eq!(cfg.potatomesh.poll_interval_secs, 10);
|
||||
|
||||
assert_eq!(cfg.matrix.homeserver, "https://matrix.example.org");
|
||||
@@ -88,7 +102,7 @@ mod tests {
|
||||
fn load_from_file_valid_file() {
|
||||
let toml_str = r#"
|
||||
[potatomesh]
|
||||
base_url = "https://potatomesh.net/api"
|
||||
base_url = "https://potatomesh.net/"
|
||||
poll_interval_secs = 10
|
||||
|
||||
[matrix]
|
||||
@@ -120,7 +134,7 @@ mod tests {
|
||||
fn from_default_path_found() {
|
||||
let toml_str = r#"
|
||||
[potatomesh]
|
||||
base_url = "https://potatomesh.net/api"
|
||||
base_url = "https://potatomesh.net/"
|
||||
poll_interval_secs = 10
|
||||
|
||||
[matrix]
|
||||
|
||||
+246
-39
@@ -1,3 +1,17 @@
|
||||
// Copyright © 2025-26 l5yth & contributors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
mod config;
|
||||
mod matrix;
|
||||
mod potatomesh;
|
||||
@@ -10,11 +24,12 @@ use tracing::{error, info};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::matrix::MatrixAppserviceClient;
|
||||
use crate::potatomesh::{PotatoClient, PotatoMessage};
|
||||
use crate::potatomesh::{FetchParams, PotatoClient, PotatoMessage};
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize, Default)]
|
||||
pub struct BridgeState {
|
||||
last_message_id: Option<u64>,
|
||||
last_checked_at: Option<u64>,
|
||||
}
|
||||
|
||||
impl BridgeState {
|
||||
@@ -48,6 +63,92 @@ impl BridgeState {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_fetch_params(state: &BridgeState) -> FetchParams {
|
||||
if state.last_message_id.is_none() {
|
||||
FetchParams {
|
||||
limit: None,
|
||||
since: None,
|
||||
}
|
||||
} else if let Some(ts) = state.last_checked_at {
|
||||
FetchParams {
|
||||
limit: None,
|
||||
since: Some(ts),
|
||||
}
|
||||
} else {
|
||||
FetchParams {
|
||||
limit: Some(10),
|
||||
since: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_checkpoint(state: &mut BridgeState, delivered_all: bool, now_secs: u64) -> bool {
|
||||
if !delivered_all {
|
||||
return false;
|
||||
}
|
||||
|
||||
if state.last_message_id.is_some() {
|
||||
state.last_checked_at = Some(now_secs);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
async fn poll_once(
|
||||
potato: &PotatoClient,
|
||||
matrix: &MatrixAppserviceClient,
|
||||
state: &mut BridgeState,
|
||||
state_path: &str,
|
||||
now_secs: u64,
|
||||
) {
|
||||
let params = build_fetch_params(state);
|
||||
|
||||
match potato.fetch_messages(params).await {
|
||||
Ok(mut msgs) => {
|
||||
// sort by id ascending so we process in order
|
||||
msgs.sort_by_key(|m| m.id);
|
||||
|
||||
let mut delivered_all = true;
|
||||
|
||||
for msg in &msgs {
|
||||
if !state.should_forward(msg) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter to the ports you care about
|
||||
if let Some(port) = &msg.portnum {
|
||||
if port != "TEXT_MESSAGE_APP" {
|
||||
state.update_with(msg);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = handle_message(potato, matrix, state, msg).await {
|
||||
error!("Error handling message {}: {:?}", msg.id, e);
|
||||
delivered_all = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// persist after each processed message
|
||||
if let Err(e) = state.save(state_path) {
|
||||
error!("Error saving state: {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Only advance checkpoint after successful delivery and a known last_message_id.
|
||||
if update_checkpoint(state, delivered_all, now_secs) {
|
||||
if let Err(e) = state.save(state_path) {
|
||||
error!("Error saving state: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error fetching PotatoMesh messages: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// Logging: RUST_LOG=info,bridge=debug,reqwest=warn ...
|
||||
@@ -64,7 +165,9 @@ async fn main() -> Result<()> {
|
||||
|
||||
let http = reqwest::Client::builder().build()?;
|
||||
let potato = PotatoClient::new(http.clone(), cfg.potatomesh.clone());
|
||||
potato.health_check().await?;
|
||||
let matrix = MatrixAppserviceClient::new(http.clone(), cfg.matrix.clone());
|
||||
matrix.health_check().await?;
|
||||
|
||||
let state_path = &cfg.state.state_file;
|
||||
let mut state = BridgeState::load(state_path)?;
|
||||
@@ -73,36 +176,12 @@ async fn main() -> Result<()> {
|
||||
let poll_interval = Duration::from_secs(cfg.potatomesh.poll_interval_secs);
|
||||
|
||||
loop {
|
||||
match potato.fetch_messages().await {
|
||||
Ok(mut msgs) => {
|
||||
// sort by id ascending so we process in order
|
||||
msgs.sort_by_key(|m| m.id);
|
||||
let now_secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
for msg in msgs {
|
||||
if !state.should_forward(&msg) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter to the ports you care about
|
||||
if msg.portnum != "TEXT_MESSAGE_APP" {
|
||||
state.update_with(&msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(e) = handle_message(&potato, &matrix, &mut state, &msg).await {
|
||||
error!("Error handling message {}: {:?}", msg.id, e);
|
||||
}
|
||||
|
||||
// persist after each processed message
|
||||
if let Err(e) = state.save(state_path) {
|
||||
error!("Error saving state: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error fetching PotatoMesh messages: {:?}", e);
|
||||
}
|
||||
}
|
||||
poll_once(&potato, &matrix, &mut state, state_path, now_secs).await;
|
||||
|
||||
sleep(poll_interval).await;
|
||||
}
|
||||
@@ -129,13 +208,19 @@ async fn handle_message(
|
||||
.unwrap_or_else(|| node.long_name.clone());
|
||||
|
||||
let body = format!(
|
||||
"[{short}] {text}\n({from_id} → {to_id}, RSSI {rssi} dB, SNR {snr} dB, {chan}/{preset})",
|
||||
"[{short}] {text}\n({from_id} → {to_id}, {rssi}, {snr}, {chan}/{preset})",
|
||||
short = short,
|
||||
text = msg.text,
|
||||
from_id = msg.from_id,
|
||||
to_id = msg.to_id,
|
||||
rssi = msg.rssi,
|
||||
snr = msg.snr,
|
||||
rssi = msg
|
||||
.rssi
|
||||
.map(|v| format!("RSSI {v} dB"))
|
||||
.unwrap_or_else(|| "RSSI n/a".to_string()),
|
||||
snr = msg
|
||||
.snr
|
||||
.map(|v| format!("SNR {v} dB"))
|
||||
.unwrap_or_else(|| "SNR n/a".to_string()),
|
||||
chan = msg.channel_name,
|
||||
preset = msg.modem_preset,
|
||||
);
|
||||
@@ -161,14 +246,14 @@ mod tests {
|
||||
from_id: "!abcd1234".to_string(),
|
||||
to_id: "^all".to_string(),
|
||||
channel: 1,
|
||||
portnum: "TEXT_MESSAGE_APP".to_string(),
|
||||
portnum: Some("TEXT_MESSAGE_APP".to_string()),
|
||||
text: "Ping".to_string(),
|
||||
rssi: -100,
|
||||
hop_limit: 1,
|
||||
rssi: Some(-100),
|
||||
hop_limit: Some(1),
|
||||
lora_freq: 868,
|
||||
modem_preset: "MediumFast".to_string(),
|
||||
channel_name: "TEST".to_string(),
|
||||
snr: 0.0,
|
||||
snr: Some(0.0),
|
||||
reply_id: None,
|
||||
node_id: "!abcd1234".to_string(),
|
||||
}
|
||||
@@ -209,6 +294,7 @@ mod tests {
|
||||
fn bridge_state_update_is_monotonic() {
|
||||
let mut state = BridgeState {
|
||||
last_message_id: Some(50),
|
||||
last_checked_at: None,
|
||||
};
|
||||
let m = sample_msg(40);
|
||||
|
||||
@@ -225,11 +311,13 @@ mod tests {
|
||||
|
||||
let state = BridgeState {
|
||||
last_message_id: Some(12345),
|
||||
last_checked_at: Some(99),
|
||||
};
|
||||
state.save(path_str).unwrap();
|
||||
|
||||
let loaded_state = BridgeState::load(path_str).unwrap();
|
||||
assert_eq!(loaded_state.last_message_id, Some(12345));
|
||||
assert_eq!(loaded_state.last_checked_at, Some(99));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -240,6 +328,125 @@ mod tests {
|
||||
|
||||
let state = BridgeState::load(path_str).unwrap();
|
||||
assert_eq!(state.last_message_id, None);
|
||||
assert_eq!(state.last_checked_at, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_checkpoint_requires_last_message_id() {
|
||||
let mut state = BridgeState {
|
||||
last_message_id: None,
|
||||
last_checked_at: Some(10),
|
||||
};
|
||||
|
||||
let saved = update_checkpoint(&mut state, true, 123);
|
||||
assert!(!saved);
|
||||
assert_eq!(state.last_checked_at, Some(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_checkpoint_skips_when_not_delivered() {
|
||||
let mut state = BridgeState {
|
||||
last_message_id: Some(5),
|
||||
last_checked_at: Some(10),
|
||||
};
|
||||
|
||||
let saved = update_checkpoint(&mut state, false, 123);
|
||||
assert!(!saved);
|
||||
assert_eq!(state.last_checked_at, Some(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_checkpoint_sets_when_safe() {
|
||||
let mut state = BridgeState {
|
||||
last_message_id: Some(5),
|
||||
last_checked_at: None,
|
||||
};
|
||||
|
||||
let saved = update_checkpoint(&mut state, true, 123);
|
||||
assert!(saved);
|
||||
assert_eq!(state.last_checked_at, Some(123));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_params_respects_missing_last_message_id() {
|
||||
let state = BridgeState {
|
||||
last_message_id: None,
|
||||
last_checked_at: Some(123),
|
||||
};
|
||||
|
||||
let params = build_fetch_params(&state);
|
||||
assert_eq!(params.limit, None);
|
||||
assert_eq!(params.since, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_params_uses_since_when_safe() {
|
||||
let state = BridgeState {
|
||||
last_message_id: Some(1),
|
||||
last_checked_at: Some(123),
|
||||
};
|
||||
|
||||
let params = build_fetch_params(&state);
|
||||
assert_eq!(params.limit, None);
|
||||
assert_eq!(params.since, Some(123));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_params_defaults_to_small_window() {
|
||||
let state = BridgeState {
|
||||
last_message_id: Some(1),
|
||||
last_checked_at: None,
|
||||
};
|
||||
|
||||
let params = build_fetch_params(&state);
|
||||
assert_eq!(params.limit, Some(10));
|
||||
assert_eq!(params.since, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn poll_once_persists_checkpoint_without_messages() {
|
||||
let tmp_dir = tempfile::tempdir().unwrap();
|
||||
let state_path = tmp_dir.path().join("state.json");
|
||||
let state_str = state_path.to_str().unwrap();
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock_msgs = server
|
||||
.mock("GET", "/api/messages")
|
||||
.match_query(mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body("[]")
|
||||
.create();
|
||||
|
||||
let http_client = reqwest::Client::new();
|
||||
let potatomesh_cfg = PotatomeshConfig {
|
||||
base_url: server.url(),
|
||||
poll_interval_secs: 1,
|
||||
};
|
||||
let matrix_cfg = MatrixConfig {
|
||||
homeserver: server.url(),
|
||||
as_token: "AS_TOKEN".to_string(),
|
||||
server_name: "example.org".to_string(),
|
||||
room_id: "!roomid:example.org".to_string(),
|
||||
};
|
||||
|
||||
let potato = PotatoClient::new(http_client.clone(), potatomesh_cfg);
|
||||
let matrix = MatrixAppserviceClient::new(http_client, matrix_cfg);
|
||||
|
||||
let mut state = BridgeState {
|
||||
last_message_id: Some(1),
|
||||
last_checked_at: None,
|
||||
};
|
||||
|
||||
poll_once(&potato, &matrix, &mut state, state_str, 123).await;
|
||||
|
||||
mock_msgs.assert();
|
||||
|
||||
// Should have advanced checkpoint and saved it.
|
||||
assert_eq!(state.last_checked_at, Some(123));
|
||||
let loaded = BridgeState::load(state_str).unwrap();
|
||||
assert_eq!(loaded.last_checked_at, Some(123));
|
||||
assert_eq!(loaded.last_message_id, Some(1));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -258,11 +465,11 @@ mod tests {
|
||||
};
|
||||
|
||||
let node_id = "abcd1234";
|
||||
let user_id = format!("@{}:{}", node_id, matrix_cfg.server_name);
|
||||
let user_id = format!("@potato_{}:{}", node_id, matrix_cfg.server_name);
|
||||
let encoded_user = urlencoding::encode(&user_id);
|
||||
|
||||
let mock_get_node = server
|
||||
.mock("GET", "/nodes/abcd1234")
|
||||
.mock("GET", "/api/nodes/abcd1234")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"node_id": "!abcd1234", "long_name": "Test Node", "short_name": "TN"}"#)
|
||||
|
||||
+69
-6
@@ -1,3 +1,17 @@
|
||||
// Copyright © 2025-26 l5yth & contributors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use serde::Serialize;
|
||||
use std::sync::{
|
||||
atomic::{AtomicU64, Ordering},
|
||||
@@ -27,9 +41,24 @@ impl MatrixAppserviceClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a node_id like "!deadbeef" into Matrix localpart "deadbeef".
|
||||
/// Basic liveness check against the homeserver.
|
||||
pub async fn health_check(&self) -> anyhow::Result<()> {
|
||||
let url = format!("{}/_matrix/client/versions", self.cfg.homeserver);
|
||||
let resp = self.http.get(&url).send().await?;
|
||||
if resp.status().is_success() {
|
||||
tracing::info!("Matrix homeserver healthy at {}", self.cfg.homeserver);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow::anyhow!(
|
||||
"Matrix homeserver versions check failed with status {}",
|
||||
resp.status()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a node_id like "!deadbeef" into Matrix localpart "potato_deadbeef".
|
||||
pub fn localpart_from_node_id(node_id: &str) -> String {
|
||||
node_id.trim_start_matches('!').to_string()
|
||||
format!("potato_{}", node_id.trim_start_matches('!'))
|
||||
}
|
||||
|
||||
/// Build a full Matrix user_id from localpart.
|
||||
@@ -175,11 +204,11 @@ mod tests {
|
||||
fn localpart_strips_bang_correctly() {
|
||||
assert_eq!(
|
||||
MatrixAppserviceClient::localpart_from_node_id("!deadbeef"),
|
||||
"deadbeef"
|
||||
"potato_deadbeef"
|
||||
);
|
||||
assert_eq!(
|
||||
MatrixAppserviceClient::localpart_from_node_id("cafebabe"),
|
||||
"cafebabe"
|
||||
"potato_cafebabe"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -188,8 +217,42 @@ mod tests {
|
||||
let http = reqwest::Client::builder().build().unwrap();
|
||||
let client = MatrixAppserviceClient::new(http, dummy_cfg());
|
||||
|
||||
let uid = client.user_id("deadbeef");
|
||||
assert_eq!(uid, "@deadbeef:example.org");
|
||||
let uid = client.user_id("potato_deadbeef");
|
||||
assert_eq!(uid, "@potato_deadbeef:example.org");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_check_success() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("GET", "/_matrix/client/versions")
|
||||
.with_status(200)
|
||||
.create();
|
||||
|
||||
let mut cfg = dummy_cfg();
|
||||
cfg.homeserver = server.url();
|
||||
let client = MatrixAppserviceClient::new(reqwest::Client::new(), cfg);
|
||||
let result = client.health_check().await;
|
||||
|
||||
mock.assert();
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_check_failure() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("GET", "/_matrix/client/versions")
|
||||
.with_status(500)
|
||||
.create();
|
||||
|
||||
let mut cfg = dummy_cfg();
|
||||
cfg.homeserver = server.url();
|
||||
let client = MatrixAppserviceClient::new(reqwest::Client::new(), cfg);
|
||||
let result = client.health_check().await;
|
||||
|
||||
mock.assert();
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+222
-24
@@ -1,3 +1,17 @@
|
||||
// Copyright © 2025-26 l5yth & contributors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
@@ -14,19 +28,29 @@ pub struct PotatoMessage {
|
||||
pub from_id: String,
|
||||
pub to_id: String,
|
||||
pub channel: u8,
|
||||
pub portnum: String,
|
||||
#[serde(default)]
|
||||
pub portnum: Option<String>,
|
||||
pub text: String,
|
||||
pub rssi: i16,
|
||||
pub hop_limit: u8,
|
||||
#[serde(default)]
|
||||
pub rssi: Option<i16>,
|
||||
#[serde(default)]
|
||||
pub hop_limit: Option<u8>,
|
||||
pub lora_freq: u32,
|
||||
pub modem_preset: String,
|
||||
pub channel_name: String,
|
||||
pub snr: f32,
|
||||
#[serde(default)]
|
||||
pub snr: Option<f32>,
|
||||
#[serde(default)]
|
||||
pub reply_id: Option<u64>,
|
||||
pub node_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct FetchParams {
|
||||
pub limit: Option<u32>,
|
||||
pub since: Option<u64>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct PotatoNode {
|
||||
@@ -67,22 +91,55 @@ impl PotatoClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the API root; accept either a bare domain or one already ending in `/api`.
|
||||
fn api_base(&self) -> String {
|
||||
let trimmed = self.cfg.base_url.trim_end_matches('/');
|
||||
if trimmed.ends_with("/api") {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("{}/api", trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
fn messages_url(&self) -> String {
|
||||
format!("{}/messages", self.cfg.base_url)
|
||||
format!("{}/messages", self.api_base())
|
||||
}
|
||||
|
||||
fn node_url(&self, hex_id: &str) -> String {
|
||||
// e.g. https://potatomesh.net/api/nodes/67fc83cb
|
||||
format!("{}/nodes/{}", self.cfg.base_url, hex_id)
|
||||
format!("{}/nodes/{}", self.api_base(), hex_id)
|
||||
}
|
||||
|
||||
pub async fn fetch_messages(&self) -> anyhow::Result<Vec<PotatoMessage>> {
|
||||
let resp = self
|
||||
.http
|
||||
.get(self.messages_url())
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
/// Basic liveness check against the PotatoMesh API.
|
||||
pub async fn health_check(&self) -> anyhow::Result<()> {
|
||||
let base = self
|
||||
.cfg
|
||||
.base_url
|
||||
.trim_end_matches('/')
|
||||
.trim_end_matches("/api");
|
||||
let url = format!("{}/version", base);
|
||||
let resp = self.http.get(&url).send().await?;
|
||||
if resp.status().is_success() {
|
||||
tracing::info!("PotatoMesh API healthy at {}", self.cfg.base_url);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow::anyhow!(
|
||||
"PotatoMesh health check failed with status {}",
|
||||
resp.status()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn fetch_messages(&self, params: FetchParams) -> anyhow::Result<Vec<PotatoMessage>> {
|
||||
let mut req = self.http.get(self.messages_url());
|
||||
if let Some(limit) = params.limit {
|
||||
req = req.query(&[("limit", limit)]);
|
||||
}
|
||||
if let Some(since) = params.since {
|
||||
req = req.query(&[("since", since)]);
|
||||
}
|
||||
|
||||
let resp = req.send().await?.error_for_status()?;
|
||||
|
||||
let msgs: Vec<PotatoMessage> = resp.json().await?;
|
||||
Ok(msgs)
|
||||
@@ -146,9 +203,38 @@ mod tests {
|
||||
assert_eq!(m.id, 2947676906);
|
||||
assert_eq!(m.from_id, "!da6556d4");
|
||||
assert_eq!(m.node_id, "!06871773");
|
||||
assert_eq!(m.portnum, "TEXT_MESSAGE_APP");
|
||||
assert_eq!(m.portnum.as_deref(), Some("TEXT_MESSAGE_APP"));
|
||||
assert_eq!(m.lora_freq, 868);
|
||||
assert!((m.snr - (-9.0)).abs() < f32::EPSILON);
|
||||
assert!((m.snr.unwrap() - (-9.0)).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_message_with_missing_optional_fields() {
|
||||
let json = r#"
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"rx_time": 0,
|
||||
"rx_iso": "2025-11-27T11:03:56Z",
|
||||
"from_id": "!abcd1234",
|
||||
"to_id": "^all",
|
||||
"channel": 1,
|
||||
"text": "Ping",
|
||||
"lora_freq": 868,
|
||||
"modem_preset": "MediumFast",
|
||||
"channel_name": "TEST",
|
||||
"node_id": "!abcd1234"
|
||||
}
|
||||
]
|
||||
"#;
|
||||
|
||||
let msgs: Vec<PotatoMessage> = serde_json::from_str(json).expect("valid message json");
|
||||
assert_eq!(msgs.len(), 1);
|
||||
let m = &msgs[0];
|
||||
assert!(m.portnum.is_none());
|
||||
assert!(m.rssi.is_none());
|
||||
assert!(m.hop_limit.is_none());
|
||||
assert!(m.snr.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -206,7 +292,29 @@ mod tests {
|
||||
poll_interval_secs: 60,
|
||||
};
|
||||
let client = PotatoClient::new(http_client, config);
|
||||
assert_eq!(client.messages_url(), "http://localhost:8080/messages");
|
||||
assert_eq!(client.messages_url(), "http://localhost:8080/api/messages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_messages_url_with_trailing_slash() {
|
||||
let http_client = reqwest::Client::new();
|
||||
let config = PotatomeshConfig {
|
||||
base_url: "http://localhost:8080/".to_string(),
|
||||
poll_interval_secs: 60,
|
||||
};
|
||||
let client = PotatoClient::new(http_client, config);
|
||||
assert_eq!(client.messages_url(), "http://localhost:8080/api/messages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_messages_url_with_existing_api_suffix() {
|
||||
let http_client = reqwest::Client::new();
|
||||
let config = PotatomeshConfig {
|
||||
base_url: "http://localhost:8080/api/".to_string(),
|
||||
poll_interval_secs: 60,
|
||||
};
|
||||
let client = PotatoClient::new(http_client, config);
|
||||
assert_eq!(client.messages_url(), "http://localhost:8080/api/messages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -219,7 +327,7 @@ mod tests {
|
||||
let client = PotatoClient::new(http_client, config);
|
||||
assert_eq!(
|
||||
client.node_url("!1234"),
|
||||
"http://localhost:8080/nodes/!1234"
|
||||
"http://localhost:8080/api/nodes/!1234"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -227,7 +335,8 @@ mod tests {
|
||||
async fn test_fetch_messages_success() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("GET", "/messages")
|
||||
.mock("GET", "/api/messages")
|
||||
.match_query(mockito::Matcher::Any) // allow optional query params
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
@@ -251,7 +360,7 @@ mod tests {
|
||||
poll_interval_secs: 60,
|
||||
};
|
||||
let client = PotatoClient::new(http_client, config);
|
||||
let result = client.fetch_messages().await;
|
||||
let result = client.fetch_messages(FetchParams::default()).await;
|
||||
|
||||
mock.assert();
|
||||
assert!(result.is_ok());
|
||||
@@ -261,9 +370,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_messages_error() {
|
||||
async fn test_health_check_success() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server.mock("GET", "/messages").with_status(500).create();
|
||||
let mock = server.mock("GET", "/version").with_status(200).create();
|
||||
|
||||
let http_client = reqwest::Client::new();
|
||||
let config = PotatomeshConfig {
|
||||
@@ -271,12 +380,97 @@ mod tests {
|
||||
poll_interval_secs: 60,
|
||||
};
|
||||
let client = PotatoClient::new(http_client, config);
|
||||
let result = client.fetch_messages().await;
|
||||
let result = client.health_check().await;
|
||||
|
||||
mock.assert();
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_health_check_strips_api_suffix() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server.mock("GET", "/version").with_status(200).create();
|
||||
|
||||
let http_client = reqwest::Client::new();
|
||||
let mut base = server.url();
|
||||
base.push_str("/api");
|
||||
let config = PotatomeshConfig {
|
||||
base_url: base,
|
||||
poll_interval_secs: 60,
|
||||
};
|
||||
let client = PotatoClient::new(http_client, config);
|
||||
let result = client.health_check().await;
|
||||
|
||||
mock.assert();
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_health_check_failure() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server.mock("GET", "/version").with_status(500).create();
|
||||
|
||||
let http_client = reqwest::Client::new();
|
||||
let config = PotatomeshConfig {
|
||||
base_url: server.url(),
|
||||
poll_interval_secs: 60,
|
||||
};
|
||||
let client = PotatoClient::new(http_client, config);
|
||||
let result = client.health_check().await;
|
||||
|
||||
mock.assert();
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_messages_error() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("GET", "/api/messages")
|
||||
.match_query(mockito::Matcher::Any)
|
||||
.with_status(500)
|
||||
.create();
|
||||
|
||||
let http_client = reqwest::Client::new();
|
||||
let config = PotatomeshConfig {
|
||||
base_url: server.url(),
|
||||
poll_interval_secs: 60,
|
||||
};
|
||||
let client = PotatoClient::new(http_client, config);
|
||||
let result = client.fetch_messages(FetchParams::default()).await;
|
||||
|
||||
mock.assert();
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_messages_with_limit_and_since() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("GET", "/api/messages")
|
||||
.match_query("limit=10&since=123")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body("[]")
|
||||
.create();
|
||||
|
||||
let http_client = reqwest::Client::new();
|
||||
let config = PotatomeshConfig {
|
||||
base_url: server.url(),
|
||||
poll_interval_secs: 60,
|
||||
};
|
||||
let client = PotatoClient::new(http_client, config);
|
||||
let params = FetchParams {
|
||||
limit: Some(10),
|
||||
since: Some(123),
|
||||
};
|
||||
let result = client.fetch_messages(params).await;
|
||||
|
||||
mock.assert();
|
||||
assert!(result.is_ok());
|
||||
assert!(result.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_node_cache_hit() {
|
||||
let http_client = reqwest::Client::new();
|
||||
@@ -313,7 +507,8 @@ mod tests {
|
||||
async fn test_get_node_cache_miss() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("GET", "/nodes/1234")
|
||||
.mock("GET", "/api/nodes/1234")
|
||||
.match_query(mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
@@ -348,7 +543,10 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_get_node_error() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server.mock("GET", "/nodes/1234").with_status(500).create();
|
||||
let mock = server
|
||||
.mock("GET", "/api/nodes/1234")
|
||||
.with_status(500)
|
||||
.create();
|
||||
|
||||
let http_client = reqwest::Client::new();
|
||||
let config = PotatomeshConfig {
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
# Copyright © 2025-26 l5yth & contributors
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""Unit tests for :mod:`data.mesh_ingestor.daemon`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import threading
|
||||
import types
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from data.mesh_ingestor import daemon
|
||||
|
||||
|
||||
class FakeEvent:
|
||||
"""Test double for :class:`threading.Event` that can auto-set itself."""
|
||||
|
||||
instances: list["FakeEvent"] = []
|
||||
|
||||
def __init__(self, *, auto_set_on_wait: bool = False):
|
||||
self._is_set = False
|
||||
self._auto_set_on_wait = auto_set_on_wait
|
||||
self.wait_calls: list[Any] = []
|
||||
FakeEvent.instances.append(self)
|
||||
|
||||
def set(self) -> None:
|
||||
"""Mark the event as set."""
|
||||
|
||||
self._is_set = True
|
||||
|
||||
def is_set(self) -> bool:
|
||||
"""Return whether the event is currently set."""
|
||||
|
||||
return self._is_set
|
||||
|
||||
def wait(self, timeout: float | None = None) -> bool:
|
||||
"""Record waits and optionally auto-set the flag."""
|
||||
|
||||
self.wait_calls.append(timeout)
|
||||
if self._auto_set_on_wait:
|
||||
self._is_set = True
|
||||
return self._is_set
|
||||
|
||||
|
||||
class AutoSetEvent(FakeEvent):
|
||||
"""Event variant that automatically sets on each wait call."""
|
||||
|
||||
def __init__(self): # noqa: D401 - short initializer docstring handled by class
|
||||
super().__init__(auto_set_on_wait=True)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_fake_events():
|
||||
"""Ensure :class:`FakeEvent` registry is cleared between tests."""
|
||||
|
||||
FakeEvent.instances.clear()
|
||||
yield
|
||||
FakeEvent.instances.clear()
|
||||
|
||||
|
||||
def test_event_wait_default_detection(monkeypatch):
|
||||
"""``_event_wait_allows_default_timeout`` matches defaulted signatures."""
|
||||
|
||||
assert daemon._event_wait_allows_default_timeout() is True
|
||||
|
||||
class _NoDefaultEvent:
|
||||
def wait(self, timeout): # type: ignore[override]
|
||||
return bool(timeout)
|
||||
|
||||
monkeypatch.setattr(
|
||||
daemon, "threading", types.SimpleNamespace(Event=_NoDefaultEvent)
|
||||
)
|
||||
assert daemon._event_wait_allows_default_timeout() is False
|
||||
|
||||
|
||||
def test_subscribe_receive_topics(monkeypatch):
|
||||
"""Subscribing to receive topics returns the exact topic list."""
|
||||
|
||||
subscribed: list[str] = []
|
||||
|
||||
def _record_subscription(_handler, topic):
|
||||
subscribed.append(topic)
|
||||
|
||||
monkeypatch.setattr(
|
||||
daemon, "pub", types.SimpleNamespace(subscribe=_record_subscription)
|
||||
)
|
||||
assert daemon._subscribe_receive_topics() == list(daemon._RECEIVE_TOPICS)
|
||||
assert subscribed == list(daemon._RECEIVE_TOPICS)
|
||||
|
||||
|
||||
def test_node_items_snapshot_handles_mutation(monkeypatch):
|
||||
"""Snapshots tolerate temporary runtime errors while iterating."""
|
||||
|
||||
class MutatingMapping(dict):
|
||||
def __bool__(self):
|
||||
return True
|
||||
|
||||
def items(self): # type: ignore[override]
|
||||
raise RuntimeError("dictionary changed size during iteration")
|
||||
|
||||
monkeypatch.setattr(daemon.time, "sleep", lambda _: None)
|
||||
assert daemon._node_items_snapshot({"a": 1}) == [("a", 1)]
|
||||
assert daemon._node_items_snapshot(MutatingMapping(), retries=1) is None
|
||||
|
||||
class IteratingMapping:
|
||||
def __init__(self):
|
||||
self.calls = 0
|
||||
self._data = {"x": 10, "y": 20}
|
||||
|
||||
def __iter__(self):
|
||||
self.calls += 1
|
||||
if self.calls == 1:
|
||||
raise RuntimeError("dictionary changed size during iteration")
|
||||
return iter(self._data)
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self._data[key]
|
||||
|
||||
mapping = IteratingMapping()
|
||||
assert daemon._node_items_snapshot(mapping, retries=2) == [("x", 10), ("y", 20)]
|
||||
|
||||
|
||||
def test_close_interface_respects_timeout(monkeypatch):
|
||||
"""Long-running close calls emit a timeout debug log."""
|
||||
|
||||
log_calls = []
|
||||
monkeypatch.setattr(daemon.config, "_CLOSE_TIMEOUT_SECS", 0.01)
|
||||
monkeypatch.setattr(
|
||||
daemon.config, "_debug_log", lambda *args, **kwargs: log_calls.append(kwargs)
|
||||
)
|
||||
blocker = threading.Event()
|
||||
|
||||
class SlowInterface:
|
||||
def close(self):
|
||||
blocker.wait(timeout=0.1)
|
||||
|
||||
daemon._close_interface(SlowInterface())
|
||||
assert any("timeout_seconds" in entry for entry in log_calls)
|
||||
|
||||
|
||||
def test_close_interface_immediate_path(monkeypatch):
|
||||
"""A zero timeout calls ``close`` inline without threading."""
|
||||
|
||||
flags = {"called": False}
|
||||
monkeypatch.setattr(daemon.config, "_CLOSE_TIMEOUT_SECS", 0)
|
||||
|
||||
class ImmediateInterface:
|
||||
def close(self):
|
||||
flags["called"] = True
|
||||
|
||||
daemon._close_interface(ImmediateInterface())
|
||||
assert flags["called"] is True
|
||||
|
||||
|
||||
def test_ble_interface_detection():
|
||||
"""Detect BLE module names reliably."""
|
||||
|
||||
class BLE:
|
||||
__module__ = "meshtastic.ble_interface"
|
||||
|
||||
class NonBLE:
|
||||
__module__ = "meshtastic.serial"
|
||||
|
||||
assert daemon._is_ble_interface(BLE()) is True
|
||||
assert daemon._is_ble_interface(NonBLE()) is False
|
||||
assert daemon._is_ble_interface(None) is False
|
||||
|
||||
|
||||
def test_process_ingestor_heartbeat_with_extracted_host(monkeypatch):
|
||||
"""Host id extraction triggers heartbeat announcement flag updates."""
|
||||
|
||||
host_ids: list[str | None] = [None]
|
||||
ingestor_ids: list[str | None] = []
|
||||
queued: list[bool] = []
|
||||
|
||||
monkeypatch.setattr(daemon.handlers, "host_node_id", lambda: host_ids[0])
|
||||
monkeypatch.setattr(
|
||||
daemon.interfaces, "_extract_host_node_id", lambda iface: "!abcd"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
daemon.handlers,
|
||||
"register_host_node_id",
|
||||
lambda node: host_ids.__setitem__(0, node),
|
||||
)
|
||||
monkeypatch.setattr(daemon.ingestors, "set_ingestor_node_id", ingestor_ids.append)
|
||||
monkeypatch.setattr(
|
||||
daemon.ingestors,
|
||||
"queue_ingestor_heartbeat",
|
||||
lambda force: queued.append(force) or True,
|
||||
)
|
||||
|
||||
assert (
|
||||
daemon._process_ingestor_heartbeat(object(), ingestor_announcement_sent=False)
|
||||
is True
|
||||
)
|
||||
assert host_ids[0] == "!abcd"
|
||||
assert ingestor_ids[-1] == "!abcd"
|
||||
assert queued[-1] is True
|
||||
|
||||
monkeypatch.setattr(daemon.handlers, "host_node_id", lambda: "!abcd")
|
||||
monkeypatch.setattr(
|
||||
daemon.ingestors,
|
||||
"queue_ingestor_heartbeat",
|
||||
lambda force: queued.append(force) or False,
|
||||
)
|
||||
assert (
|
||||
daemon._process_ingestor_heartbeat(object(), ingestor_announcement_sent=True)
|
||||
is True
|
||||
)
|
||||
assert queued[-1] is False
|
||||
|
||||
|
||||
def test_connected_state_branches(monkeypatch):
|
||||
"""Connection state resolves across multiple attribute forms."""
|
||||
|
||||
event = threading.Event()
|
||||
event.set()
|
||||
assert daemon._connected_state(event) is True
|
||||
|
||||
class CallableCandidate:
|
||||
def __call__(self):
|
||||
return False
|
||||
|
||||
assert daemon._connected_state(CallableCandidate()) is False
|
||||
|
||||
class BooleanCandidate:
|
||||
def __bool__(self):
|
||||
raise RuntimeError("cannot bool")
|
||||
|
||||
assert daemon._connected_state(BooleanCandidate()) is None
|
||||
|
||||
class HasIsSet:
|
||||
def is_set(self):
|
||||
raise RuntimeError("broken")
|
||||
|
||||
assert daemon._connected_state(HasIsSet()) is None
|
||||
|
||||
|
||||
def _configure_common_defaults(
|
||||
monkeypatch, *, energy_saving: bool = False, inactivity: float = 0.0
|
||||
):
|
||||
"""Set fast configuration defaults shared by daemon integration tests."""
|
||||
|
||||
monkeypatch.setattr(daemon.config, "SNAPSHOT_SECS", 0)
|
||||
monkeypatch.setattr(daemon.config, "_RECONNECT_INITIAL_DELAY_SECS", 0)
|
||||
monkeypatch.setattr(daemon.config, "_RECONNECT_MAX_DELAY_SECS", 0)
|
||||
monkeypatch.setattr(daemon.config, "_CLOSE_TIMEOUT_SECS", 0)
|
||||
monkeypatch.setattr(daemon.config, "ENERGY_SAVING", energy_saving)
|
||||
monkeypatch.setattr(
|
||||
daemon.config, "_ENERGY_ONLINE_DURATION_SECS", 0 if energy_saving else 0.0
|
||||
)
|
||||
monkeypatch.setattr(daemon.config, "_ENERGY_SLEEP_SECS", 0.0)
|
||||
monkeypatch.setattr(daemon.config, "_INGESTOR_HEARTBEAT_SECS", 0)
|
||||
monkeypatch.setattr(daemon.config, "_INACTIVITY_RECONNECT_SECS", inactivity)
|
||||
monkeypatch.setattr(daemon.config, "CONNECTION", "serial0")
|
||||
|
||||
|
||||
class DummyInterface:
|
||||
"""Lightweight mesh interface stand-in used for daemon integration tests."""
|
||||
|
||||
def __init__(self, *, nodes=None, is_connected=True, client_present=True):
|
||||
self.nodes = nodes if nodes is not None else {"!node": {"id": 1}}
|
||||
self.isConnected = is_connected
|
||||
self.client = object() if client_present else None
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
|
||||
def test_main_happy_path(monkeypatch):
|
||||
"""The main loop processes snapshots and heartbeats once before stopping."""
|
||||
|
||||
_configure_common_defaults(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
daemon,
|
||||
"threading",
|
||||
types.SimpleNamespace(
|
||||
Event=AutoSetEvent,
|
||||
current_thread=threading.current_thread,
|
||||
main_thread=threading.main_thread,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
daemon, "pub", types.SimpleNamespace(subscribe=lambda *_args, **_kwargs: None)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
daemon.interfaces,
|
||||
"_create_serial_interface",
|
||||
lambda candidate: (DummyInterface(), candidate),
|
||||
)
|
||||
monkeypatch.setattr(daemon.interfaces, "_ensure_radio_metadata", lambda iface: None)
|
||||
monkeypatch.setattr(
|
||||
daemon.interfaces, "_ensure_channel_metadata", lambda iface: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
daemon.interfaces, "_extract_host_node_id", lambda iface: "!host"
|
||||
)
|
||||
|
||||
host_id = {"value": None}
|
||||
monkeypatch.setattr(
|
||||
daemon.handlers,
|
||||
"register_host_node_id",
|
||||
lambda node: host_id.__setitem__("value", node),
|
||||
)
|
||||
monkeypatch.setattr(daemon.handlers, "host_node_id", lambda: host_id["value"])
|
||||
monkeypatch.setattr(daemon.handlers, "upsert_node", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(daemon.handlers, "last_packet_monotonic", lambda: None)
|
||||
|
||||
heartbeats: list[bool] = []
|
||||
monkeypatch.setattr(
|
||||
daemon.ingestors, "set_ingestor_node_id", lambda *_args, **_kwargs: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
daemon.ingestors,
|
||||
"queue_ingestor_heartbeat",
|
||||
lambda force: heartbeats.append(force) or True,
|
||||
)
|
||||
|
||||
daemon.main()
|
||||
assert heartbeats
|
||||
assert host_id["value"] == "!host"
|
||||
assert FakeEvent.instances and FakeEvent.instances[0].is_set() is True
|
||||
|
||||
|
||||
def test_main_energy_saving_disconnect(monkeypatch):
|
||||
"""Energy saving mode disconnects and sleeps when deadlines expire."""
|
||||
|
||||
_configure_common_defaults(monkeypatch, energy_saving=True)
|
||||
monkeypatch.setattr(
|
||||
daemon,
|
||||
"threading",
|
||||
types.SimpleNamespace(
|
||||
Event=AutoSetEvent,
|
||||
current_thread=threading.current_thread,
|
||||
main_thread=threading.main_thread,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
daemon, "pub", types.SimpleNamespace(subscribe=lambda *_args, **_kwargs: None)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
daemon.interfaces,
|
||||
"_create_serial_interface",
|
||||
lambda candidate: (DummyInterface(), candidate),
|
||||
)
|
||||
monkeypatch.setattr(daemon.interfaces, "_ensure_radio_metadata", lambda iface: None)
|
||||
monkeypatch.setattr(
|
||||
daemon.interfaces, "_ensure_channel_metadata", lambda iface: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
daemon.interfaces, "_extract_host_node_id", lambda iface: "!host"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
daemon.handlers, "register_host_node_id", lambda *_args, **_kwargs: None
|
||||
)
|
||||
monkeypatch.setattr(daemon.handlers, "host_node_id", lambda: "!host")
|
||||
monkeypatch.setattr(daemon.handlers, "upsert_node", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(daemon.handlers, "last_packet_monotonic", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
daemon.ingestors, "set_ingestor_node_id", lambda *_args, **_kwargs: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
daemon.ingestors, "queue_ingestor_heartbeat", lambda *_args, **_kwargs: True
|
||||
)
|
||||
|
||||
daemon.main()
|
||||
assert FakeEvent.instances and FakeEvent.instances[0].is_set() is True
|
||||
|
||||
|
||||
def test_main_inactivity_reconnect(monkeypatch):
|
||||
"""Inactivity triggers reconnect attempts and respects stop events."""
|
||||
|
||||
_configure_common_defaults(monkeypatch, inactivity=0.5)
|
||||
monkeypatch.setattr(
|
||||
daemon,
|
||||
"threading",
|
||||
types.SimpleNamespace(
|
||||
Event=AutoSetEvent,
|
||||
current_thread=threading.current_thread,
|
||||
main_thread=threading.main_thread,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
daemon, "pub", types.SimpleNamespace(subscribe=lambda *_args, **_kwargs: None)
|
||||
)
|
||||
|
||||
interface_cycle = iter(
|
||||
[DummyInterface(is_connected=False), DummyInterface(is_connected=True)]
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
daemon.interfaces,
|
||||
"_create_serial_interface",
|
||||
lambda candidate: (next(interface_cycle), candidate),
|
||||
)
|
||||
monkeypatch.setattr(daemon.interfaces, "_ensure_radio_metadata", lambda iface: None)
|
||||
monkeypatch.setattr(
|
||||
daemon.interfaces, "_ensure_channel_metadata", lambda iface: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
daemon.interfaces, "_extract_host_node_id", lambda iface: "!host"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
daemon.handlers, "register_host_node_id", lambda *_args, **_kwargs: None
|
||||
)
|
||||
monkeypatch.setattr(daemon.handlers, "host_node_id", lambda: "!host")
|
||||
monkeypatch.setattr(daemon.handlers, "upsert_node", lambda *_args, **_kwargs: None)
|
||||
|
||||
monotonic_calls = iter([0.0, 1.0, 2.0, 3.0, 4.0])
|
||||
monkeypatch.setattr(daemon.time, "monotonic", lambda: next(monotonic_calls))
|
||||
monkeypatch.setattr(daemon.handlers, "last_packet_monotonic", lambda: 0.0)
|
||||
monkeypatch.setattr(
|
||||
daemon.ingestors, "set_ingestor_node_id", lambda *_args, **_kwargs: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
daemon.ingestors, "queue_ingestor_heartbeat", lambda *_args, **_kwargs: True
|
||||
)
|
||||
|
||||
daemon.main()
|
||||
assert any(event.is_set() for event in FakeEvent.instances)
|
||||
@@ -285,6 +285,40 @@ def test_instance_domain_infers_scheme_for_hostnames(mesh_module, monkeypatch):
|
||||
mesh_module.INSTANCE = mesh_module.config.INSTANCE
|
||||
|
||||
|
||||
def test_parse_channel_names_applies_allowlist(mesh_module):
|
||||
"""Ensure allowlists reuse the shared channel parser."""
|
||||
|
||||
mesh = mesh_module
|
||||
previous_allowed = mesh.ALLOWED_CHANNELS
|
||||
|
||||
try:
|
||||
parsed = mesh.config._parse_channel_names(" Primary ,Chat ,primary , Ops ")
|
||||
mesh.ALLOWED_CHANNELS = parsed
|
||||
|
||||
assert parsed == ("Primary", "Chat", "Ops")
|
||||
assert mesh.channels.allowed_channel_names() == ("Primary", "Chat", "Ops")
|
||||
assert mesh.channels.is_allowed_channel("chat")
|
||||
assert mesh.channels.is_allowed_channel(" ops ")
|
||||
assert not mesh.channels.is_allowed_channel("unknown")
|
||||
assert not mesh.channels.is_allowed_channel(None)
|
||||
assert mesh.config._parse_channel_names("") == ()
|
||||
finally:
|
||||
mesh.ALLOWED_CHANNELS = previous_allowed
|
||||
|
||||
|
||||
def test_allowed_channel_defaults_allow_all(mesh_module):
|
||||
"""Ensure unset allowlists do not block any channels."""
|
||||
|
||||
mesh = mesh_module
|
||||
previous_allowed = mesh.ALLOWED_CHANNELS
|
||||
|
||||
try:
|
||||
mesh.ALLOWED_CHANNELS = ()
|
||||
assert mesh.channels.is_allowed_channel("Any")
|
||||
finally:
|
||||
mesh.ALLOWED_CHANNELS = previous_allowed
|
||||
|
||||
|
||||
def test_parse_hidden_channels_deduplicates_names(mesh_module):
|
||||
"""Ensure hidden channel parsing strips blanks and deduplicates."""
|
||||
|
||||
@@ -1895,6 +1929,110 @@ def test_store_packet_dict_allows_primary_channel_broadcast(mesh_module, monkeyp
|
||||
assert priority == mesh._MESSAGE_POST_PRIORITY
|
||||
|
||||
|
||||
def test_store_packet_dict_accepts_routing_app_messages(mesh_module, monkeypatch):
|
||||
"""Ensure routing app payloads are treated as message posts."""
|
||||
|
||||
mesh = mesh_module
|
||||
captured = []
|
||||
monkeypatch.setattr(
|
||||
mesh,
|
||||
"_queue_post_json",
|
||||
lambda path, payload, *, priority: captured.append((path, payload, priority)),
|
||||
)
|
||||
|
||||
packet = {
|
||||
"id": 333,
|
||||
"rxTime": 999,
|
||||
"fromId": "!node",
|
||||
"toId": "^all",
|
||||
"channel": 0,
|
||||
"decoded": {"payload": "GAA=", "portnum": "ROUTING_APP"},
|
||||
}
|
||||
|
||||
mesh.store_packet_dict(packet)
|
||||
|
||||
assert captured, "Expected routing packet to be stored"
|
||||
path, payload, priority = captured[0]
|
||||
assert path == "/api/messages"
|
||||
assert payload["portnum"] == "ROUTING_APP"
|
||||
assert payload["text"] == "GAA="
|
||||
assert payload["channel"] == 0
|
||||
assert payload["encrypted"] is None
|
||||
assert priority == mesh._MESSAGE_POST_PRIORITY
|
||||
|
||||
|
||||
def test_store_packet_dict_serializes_routing_payloads(mesh_module, monkeypatch):
|
||||
"""Ensure routing payloads are serialized when text is absent."""
|
||||
|
||||
mesh = mesh_module
|
||||
captured = []
|
||||
monkeypatch.setattr(
|
||||
mesh,
|
||||
"_queue_post_json",
|
||||
lambda path, payload, *, priority: captured.append((path, payload, priority)),
|
||||
)
|
||||
|
||||
packet = {
|
||||
"id": 334,
|
||||
"rxTime": 1000,
|
||||
"fromId": "!node",
|
||||
"toId": "^all",
|
||||
"channel": 0,
|
||||
"decoded": {
|
||||
"payload": b"\x01\x02",
|
||||
"portnum": "ROUTING_APP",
|
||||
},
|
||||
}
|
||||
|
||||
mesh.store_packet_dict(packet)
|
||||
|
||||
assert captured, "Expected routing packet to be stored"
|
||||
_, payload, _ = captured[0]
|
||||
assert payload["text"] == "AQI="
|
||||
|
||||
captured.clear()
|
||||
|
||||
packet["decoded"]["payload"] = {"kind": "ack"}
|
||||
mesh.store_packet_dict(packet)
|
||||
|
||||
assert captured, "Expected routing packet to be stored"
|
||||
_, payload, _ = captured[0]
|
||||
assert payload["text"] == '{"kind": "ack"}'
|
||||
|
||||
captured.clear()
|
||||
|
||||
packet["decoded"]["portnum"] = 7
|
||||
packet["decoded"]["payload"] = b"\x00"
|
||||
packet["decoded"]["routing"] = {"errorReason": "NONE"}
|
||||
mesh.store_packet_dict(packet)
|
||||
|
||||
assert captured, "Expected numeric routing packet to be stored"
|
||||
_, payload, _ = captured[0]
|
||||
assert payload["text"] == "AA=="
|
||||
|
||||
|
||||
def test_portnum_candidates_reads_enum_values(mesh_module, monkeypatch):
|
||||
"""Ensure portnum candidates include enum and constants when available."""
|
||||
|
||||
mesh = mesh_module
|
||||
module_name = "meshtastic.portnums_pb2"
|
||||
|
||||
class DummyPortNum:
|
||||
@staticmethod
|
||||
def Value(name):
|
||||
if name == "ROUTING_APP":
|
||||
return 7
|
||||
raise KeyError(name)
|
||||
|
||||
dummy_module = types.SimpleNamespace(PortNum=DummyPortNum, ROUTING_APP=8)
|
||||
monkeypatch.setitem(sys.modules, module_name, dummy_module)
|
||||
|
||||
candidates = mesh.handlers._portnum_candidates("ROUTING_APP")
|
||||
|
||||
assert 7 in candidates
|
||||
assert 8 in candidates
|
||||
|
||||
|
||||
def test_store_packet_dict_appends_channel_name(mesh_module, monkeypatch, capsys):
|
||||
mesh = mesh_module
|
||||
mesh.channels._reset_channel_cache()
|
||||
@@ -1997,8 +2135,10 @@ def test_store_packet_dict_skips_hidden_channel(mesh_module, monkeypatch, capsys
|
||||
|
||||
previous_debug = mesh.config.DEBUG
|
||||
previous_hidden = mesh.HIDDEN_CHANNELS
|
||||
previous_allowed = mesh.ALLOWED_CHANNELS
|
||||
mesh.config.DEBUG = True
|
||||
mesh.DEBUG = True
|
||||
mesh.ALLOWED_CHANNELS = ("Chat",)
|
||||
mesh.HIDDEN_CHANNELS = ("Chat",)
|
||||
|
||||
try:
|
||||
@@ -2017,6 +2157,77 @@ def test_store_packet_dict_skips_hidden_channel(mesh_module, monkeypatch, capsys
|
||||
assert ignored == ["hidden-channel"]
|
||||
assert "Ignored packet on hidden channel" in capsys.readouterr().out
|
||||
finally:
|
||||
mesh.HIDDEN_CHANNELS = previous_hidden
|
||||
mesh.ALLOWED_CHANNELS = previous_allowed
|
||||
mesh.config.DEBUG = previous_debug
|
||||
mesh.DEBUG = previous_debug
|
||||
|
||||
|
||||
def test_store_packet_dict_skips_disallowed_channel(mesh_module, monkeypatch, capsys):
|
||||
mesh = mesh_module
|
||||
mesh.channels._reset_channel_cache()
|
||||
mesh.config.MODEM_PRESET = None
|
||||
|
||||
class DummyInterface:
|
||||
def __init__(self) -> None:
|
||||
self.localNode = SimpleNamespace(
|
||||
channels=[
|
||||
SimpleNamespace(
|
||||
role=1,
|
||||
settings=SimpleNamespace(name="Primary"),
|
||||
),
|
||||
SimpleNamespace(
|
||||
role=2,
|
||||
index=5,
|
||||
settings=SimpleNamespace(name="Chat"),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
def waitForConfig(self):
|
||||
return None
|
||||
|
||||
mesh.channels.capture_from_interface(DummyInterface())
|
||||
capsys.readouterr()
|
||||
|
||||
captured: list[tuple[str, dict, int]] = []
|
||||
ignored: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
mesh,
|
||||
"_queue_post_json",
|
||||
lambda path, payload, *, priority: captured.append((path, payload, priority)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mesh.handlers,
|
||||
"_record_ignored_packet",
|
||||
lambda packet, *, reason: ignored.append(reason),
|
||||
)
|
||||
|
||||
previous_debug = mesh.config.DEBUG
|
||||
previous_allowed = mesh.ALLOWED_CHANNELS
|
||||
previous_hidden = mesh.HIDDEN_CHANNELS
|
||||
mesh.config.DEBUG = True
|
||||
mesh.DEBUG = True
|
||||
mesh.ALLOWED_CHANNELS = ("Primary",)
|
||||
mesh.HIDDEN_CHANNELS = ()
|
||||
|
||||
try:
|
||||
packet = {
|
||||
"id": "1001",
|
||||
"rxTime": 25_680,
|
||||
"from": "!sender",
|
||||
"to": "^all",
|
||||
"channel": 5,
|
||||
"decoded": {"text": "disallowed msg", "portnum": 1},
|
||||
}
|
||||
|
||||
mesh.store_packet_dict(packet)
|
||||
|
||||
assert captured == []
|
||||
assert ignored == ["disallowed-channel"]
|
||||
assert "Ignored packet on disallowed channel" in capsys.readouterr().out
|
||||
finally:
|
||||
mesh.ALLOWED_CHANNELS = previous_allowed
|
||||
mesh.HIDDEN_CHANNELS = previous_hidden
|
||||
mesh.config.DEBUG = previous_debug
|
||||
mesh.DEBUG = previous_debug
|
||||
@@ -2533,6 +2744,62 @@ def test_parse_ble_target_rejects_invalid_values(mesh_module):
|
||||
assert mesh._parse_ble_target("zz:zz:zz:zz:zz:zz") is None
|
||||
|
||||
|
||||
def test_parse_ble_target_accepts_mac_addresses(mesh_module):
|
||||
"""Test that _parse_ble_target accepts valid MAC address format (Linux/Windows)."""
|
||||
mesh = mesh_module
|
||||
|
||||
# Valid MAC addresses should be accepted and normalized to uppercase
|
||||
assert mesh._parse_ble_target("ED:4D:9E:95:CF:60") == "ED:4D:9E:95:CF:60"
|
||||
assert mesh._parse_ble_target("ed:4d:9e:95:cf:60") == "ED:4D:9E:95:CF:60"
|
||||
assert mesh._parse_ble_target("AA:BB:CC:DD:EE:FF") == "AA:BB:CC:DD:EE:FF"
|
||||
assert mesh._parse_ble_target("00:11:22:33:44:55") == "00:11:22:33:44:55"
|
||||
|
||||
# With whitespace
|
||||
assert mesh._parse_ble_target(" ED:4D:9E:95:CF:60 ") == "ED:4D:9E:95:CF:60"
|
||||
|
||||
# Invalid MAC addresses should be rejected
|
||||
assert mesh._parse_ble_target("ED:4D:9E:95:CF") is None # Too short
|
||||
assert mesh._parse_ble_target("ED:4D:9E:95:CF:60:AB") is None # Too long
|
||||
assert mesh._parse_ble_target("GG:HH:II:JJ:KK:LL") is None # Invalid hex
|
||||
|
||||
|
||||
def test_parse_ble_target_accepts_uuids(mesh_module):
|
||||
"""Test that _parse_ble_target accepts valid UUID format (macOS)."""
|
||||
mesh = mesh_module
|
||||
|
||||
# Valid UUIDs should be accepted and normalized to uppercase
|
||||
assert (
|
||||
mesh._parse_ble_target("C0AEA92F-045E-9B82-C9A6-A1FD822B3A9E")
|
||||
== "C0AEA92F-045E-9B82-C9A6-A1FD822B3A9E"
|
||||
)
|
||||
assert (
|
||||
mesh._parse_ble_target("c0aea92f-045e-9b82-c9a6-a1fd822b3a9e")
|
||||
== "C0AEA92F-045E-9B82-C9A6-A1FD822B3A9E"
|
||||
)
|
||||
assert (
|
||||
mesh._parse_ble_target("12345678-1234-5678-9ABC-DEF012345678")
|
||||
== "12345678-1234-5678-9ABC-DEF012345678"
|
||||
)
|
||||
|
||||
# With whitespace
|
||||
assert (
|
||||
mesh._parse_ble_target(" C0AEA92F-045E-9B82-C9A6-A1FD822B3A9E ")
|
||||
== "C0AEA92F-045E-9B82-C9A6-A1FD822B3A9E"
|
||||
)
|
||||
|
||||
# Invalid UUIDs should be rejected
|
||||
assert mesh._parse_ble_target("C0AEA92F-045E-9B82-C9A6") is None # Too short
|
||||
assert (
|
||||
mesh._parse_ble_target("C0AEA92F-045E-9B82-C9A6-A1FD822B3A9E-EXTRA") is None
|
||||
) # Too long
|
||||
assert (
|
||||
mesh._parse_ble_target("GGGGGGGG-GGGG-GGGG-GGGG-GGGGGGGGGGGG") is None
|
||||
) # Invalid hex
|
||||
assert (
|
||||
mesh._parse_ble_target("C0AEA92F:045E:9B82:C9A6:A1FD822B3A9E") is None
|
||||
) # Wrong separator
|
||||
|
||||
|
||||
def test_parse_network_target_additional_cases(mesh_module):
|
||||
mesh = mesh_module
|
||||
|
||||
|
||||
@@ -110,11 +110,20 @@ module PotatoMesh
|
||||
["!#{canonical_hex}", parsed, short_id]
|
||||
end
|
||||
|
||||
def broadcast_node_ref?(node_ref, fallback_num = nil)
|
||||
return true if fallback_num == 0xFFFFFFFF
|
||||
trimmed = string_or_nil(node_ref)
|
||||
return false unless trimmed
|
||||
normalized = trimmed.delete_prefix("!").strip.downcase
|
||||
normalized == "ffffffff"
|
||||
end
|
||||
|
||||
def ensure_unknown_node(db, node_ref, fallback_num = nil, heard_time: nil)
|
||||
parts = canonical_node_parts(node_ref, fallback_num)
|
||||
return unless parts
|
||||
|
||||
node_id, node_num, short_id = parts
|
||||
return if broadcast_node_ref?(node_id, node_num)
|
||||
|
||||
existing = db.get_first_value(
|
||||
"SELECT 1 FROM nodes WHERE node_id = ? LIMIT 1",
|
||||
@@ -158,7 +167,10 @@ module PotatoMesh
|
||||
node_id = nil
|
||||
|
||||
parts = canonical_node_parts(node_ref, fallback_num)
|
||||
node_id, = parts if parts
|
||||
if parts
|
||||
node_id, node_num = parts
|
||||
return if broadcast_node_ref?(node_id, node_num)
|
||||
end
|
||||
|
||||
unless node_id
|
||||
trimmed = string_or_nil(node_ref)
|
||||
@@ -170,6 +182,7 @@ module PotatoMesh
|
||||
end
|
||||
end
|
||||
|
||||
return if broadcast_node_ref?(node_id, fallback_num)
|
||||
return unless node_id
|
||||
|
||||
updated = false
|
||||
@@ -1427,6 +1440,17 @@ module PotatoMesh
|
||||
source: :message,
|
||||
)
|
||||
|
||||
ensure_unknown_node(db, to_id || raw_to_id, message["to_num"], heard_time: rx_time) if to_id || raw_to_id
|
||||
if to_id || raw_to_id || message.key?("to_num")
|
||||
touch_node_last_seen(
|
||||
db,
|
||||
to_id || raw_to_id || message["to_num"],
|
||||
message["to_num"],
|
||||
rx_time: rx_time,
|
||||
source: :message,
|
||||
)
|
||||
end
|
||||
|
||||
lora_freq = coerce_integer(message["lora_freq"] || message["loraFrequency"])
|
||||
modem_preset = string_or_nil(message["modem_preset"] || message["modemPreset"])
|
||||
channel_name = string_or_nil(message["channel_name"] || message["channelName"])
|
||||
|
||||
@@ -116,6 +116,17 @@ module PotatoMesh
|
||||
coerced
|
||||
end
|
||||
|
||||
# Normalise a caller-supplied timestamp for API pagination windows.
|
||||
#
|
||||
# @param since [Object] requested lower bound expressed as seconds since the epoch.
|
||||
# @param floor [Integer] minimum allowable timestamp used to clamp the value.
|
||||
# @return [Integer] non-negative timestamp greater than or equal to +floor+.
|
||||
def normalize_since_threshold(since, floor: 0)
|
||||
threshold = coerce_integer(since)
|
||||
threshold = 0 if threshold.nil? || threshold.negative?
|
||||
[threshold, floor].max
|
||||
end
|
||||
|
||||
def node_reference_tokens(node_ref)
|
||||
parts = canonical_node_parts(node_ref)
|
||||
canonical_id, numeric_id = parts ? parts[0, 2] : [nil, nil]
|
||||
@@ -198,12 +209,19 @@ module PotatoMesh
|
||||
["(#{clauses.join(" OR ")})", params]
|
||||
end
|
||||
|
||||
def query_nodes(limit, node_ref: nil)
|
||||
# Fetch node state optionally scoped by identifier and timestamp.
|
||||
#
|
||||
# @param limit [Integer] maximum number of rows to return.
|
||||
# @param node_ref [String, Integer, nil] optional node reference to narrow results.
|
||||
# @param since [Integer] unix timestamp threshold applied in addition to the rolling window.
|
||||
# @return [Array<Hash>] compacted node rows suitable for API responses.
|
||||
def query_nodes(limit, node_ref: nil, since: 0)
|
||||
limit = coerce_query_limit(limit)
|
||||
db = open_database(readonly: true)
|
||||
db.results_as_hash = true
|
||||
now = Time.now.to_i
|
||||
min_last_heard = now - PotatoMesh::Config.week_seconds
|
||||
since_threshold = normalize_since_threshold(since, floor: min_last_heard)
|
||||
params = []
|
||||
where_clauses = []
|
||||
|
||||
@@ -214,7 +232,7 @@ module PotatoMesh
|
||||
params.concat(clause.last)
|
||||
else
|
||||
where_clauses << "last_heard >= ?"
|
||||
params << min_last_heard
|
||||
params << since_threshold
|
||||
end
|
||||
|
||||
if private_mode?
|
||||
@@ -242,7 +260,7 @@ module PotatoMesh
|
||||
.map { |value| coerce_integer(value) }
|
||||
.compact
|
||||
.max
|
||||
last_candidate && last_candidate >= min_last_heard
|
||||
last_candidate && last_candidate >= since_threshold
|
||||
end
|
||||
rows.each do |r|
|
||||
r["role"] ||= "CLIENT"
|
||||
@@ -262,12 +280,18 @@ module PotatoMesh
|
||||
db&.close
|
||||
end
|
||||
|
||||
def query_ingestors(limit)
|
||||
# Fetch ingestor heartbeats with optional freshness filtering.
|
||||
#
|
||||
# @param limit [Integer] maximum number of ingestors to return.
|
||||
# @param since [Integer] unix timestamp threshold applied in addition to the rolling window.
|
||||
# @return [Array<Hash>] compacted ingestor rows suitable for API responses.
|
||||
def query_ingestors(limit, since: 0)
|
||||
limit = coerce_query_limit(limit)
|
||||
db = open_database(readonly: true)
|
||||
db.results_as_hash = true
|
||||
now = Time.now.to_i
|
||||
cutoff = now - PotatoMesh::Config.week_seconds
|
||||
since_threshold = normalize_since_threshold(since, floor: cutoff)
|
||||
sql = <<~SQL
|
||||
SELECT node_id, start_time, last_seen_time, version, lora_freq, modem_preset
|
||||
FROM ingestors
|
||||
@@ -276,7 +300,7 @@ module PotatoMesh
|
||||
LIMIT ?
|
||||
SQL
|
||||
|
||||
rows = db.execute(sql, [cutoff, limit])
|
||||
rows = db.execute(sql, [since_threshold, limit])
|
||||
rows.each do |row|
|
||||
row.delete_if { |key, _| key.is_a?(Integer) }
|
||||
start_time = coerce_integer(row["start_time"])
|
||||
@@ -306,8 +330,7 @@ module PotatoMesh
|
||||
# @return [Array<Hash>] compacted message rows safe for API responses.
|
||||
def query_messages(limit, node_ref: nil, include_encrypted: false, since: 0)
|
||||
limit = coerce_query_limit(limit)
|
||||
since_threshold = coerce_integer(since)
|
||||
since_threshold = 0 if since_threshold.nil? || since_threshold.negative?
|
||||
since_threshold = normalize_since_threshold(since, floor: 0)
|
||||
db = open_database(readonly: true)
|
||||
db.results_as_hash = true
|
||||
params = []
|
||||
@@ -385,7 +408,13 @@ module PotatoMesh
|
||||
db&.close
|
||||
end
|
||||
|
||||
def query_positions(limit, node_ref: nil)
|
||||
# Fetch positions optionally scoped by node and timestamp.
|
||||
#
|
||||
# @param limit [Integer] maximum number of rows to return.
|
||||
# @param node_ref [String, Integer, nil] optional node reference to scope results.
|
||||
# @param since [Integer] unix timestamp threshold applied in addition to the rolling window.
|
||||
# @return [Array<Hash>] compacted position rows suitable for API responses.
|
||||
def query_positions(limit, node_ref: nil, since: 0)
|
||||
limit = coerce_query_limit(limit)
|
||||
db = open_database(readonly: true)
|
||||
db.results_as_hash = true
|
||||
@@ -393,8 +422,9 @@ module PotatoMesh
|
||||
where_clauses = []
|
||||
now = Time.now.to_i
|
||||
min_rx_time = now - PotatoMesh::Config.week_seconds
|
||||
since_threshold = normalize_since_threshold(since, floor: min_rx_time)
|
||||
where_clauses << "COALESCE(rx_time, position_time, 0) >= ?"
|
||||
params << min_rx_time
|
||||
params << since_threshold
|
||||
|
||||
if node_ref
|
||||
clause = node_lookup_clause(node_ref, string_columns: ["node_id"], numeric_columns: ["node_num"])
|
||||
@@ -436,7 +466,13 @@ module PotatoMesh
|
||||
db&.close
|
||||
end
|
||||
|
||||
def query_neighbors(limit, node_ref: nil)
|
||||
# Fetch neighbor relationships optionally scoped by node and timestamp.
|
||||
#
|
||||
# @param limit [Integer] maximum number of rows to return.
|
||||
# @param node_ref [String, Integer, nil] optional node reference to scope results.
|
||||
# @param since [Integer] unix timestamp threshold applied in addition to the rolling window.
|
||||
# @return [Array<Hash>] compacted neighbor rows suitable for API responses.
|
||||
def query_neighbors(limit, node_ref: nil, since: 0)
|
||||
limit = coerce_query_limit(limit)
|
||||
db = open_database(readonly: true)
|
||||
db.results_as_hash = true
|
||||
@@ -444,8 +480,9 @@ module PotatoMesh
|
||||
where_clauses = []
|
||||
now = Time.now.to_i
|
||||
min_rx_time = now - PotatoMesh::Config.week_seconds
|
||||
since_threshold = normalize_since_threshold(since, floor: min_rx_time)
|
||||
where_clauses << "COALESCE(rx_time, 0) >= ?"
|
||||
params << min_rx_time
|
||||
params << since_threshold
|
||||
|
||||
if node_ref
|
||||
clause = node_lookup_clause(node_ref, string_columns: ["node_id", "neighbor_id"])
|
||||
@@ -476,7 +513,13 @@ module PotatoMesh
|
||||
db&.close
|
||||
end
|
||||
|
||||
def query_telemetry(limit, node_ref: nil)
|
||||
# Fetch telemetry packets optionally scoped by node and timestamp.
|
||||
#
|
||||
# @param limit [Integer] maximum number of rows to return.
|
||||
# @param node_ref [String, Integer, nil] optional node reference to scope results.
|
||||
# @param since [Integer] unix timestamp threshold applied in addition to the rolling window.
|
||||
# @return [Array<Hash>] compacted telemetry rows suitable for API responses.
|
||||
def query_telemetry(limit, node_ref: nil, since: 0)
|
||||
limit = coerce_query_limit(limit)
|
||||
db = open_database(readonly: true)
|
||||
db.results_as_hash = true
|
||||
@@ -484,8 +527,9 @@ module PotatoMesh
|
||||
where_clauses = []
|
||||
now = Time.now.to_i
|
||||
min_rx_time = now - PotatoMesh::Config.week_seconds
|
||||
since_threshold = normalize_since_threshold(since, floor: min_rx_time)
|
||||
where_clauses << "COALESCE(rx_time, telemetry_time, 0) >= ?"
|
||||
params << min_rx_time
|
||||
params << since_threshold
|
||||
|
||||
if node_ref
|
||||
clause = node_lookup_clause(node_ref, string_columns: ["node_id"], numeric_columns: ["node_num"])
|
||||
@@ -555,7 +599,13 @@ module PotatoMesh
|
||||
db&.close
|
||||
end
|
||||
|
||||
def query_telemetry_buckets(window_seconds:, bucket_seconds:)
|
||||
# Aggregate telemetry metrics into time buckets.
|
||||
#
|
||||
# @param window_seconds [Integer] duration expressed in seconds to include in the query.
|
||||
# @param bucket_seconds [Integer] size of each aggregation bucket in seconds.
|
||||
# @param since [Integer] unix timestamp threshold applied in addition to the requested window.
|
||||
# @return [Array<Hash>] aggregated telemetry metrics grouped by bucket start time.
|
||||
def query_telemetry_buckets(window_seconds:, bucket_seconds:, since: 0)
|
||||
window = coerce_integer(window_seconds) || DEFAULT_TELEMETRY_WINDOW_SECONDS
|
||||
window = DEFAULT_TELEMETRY_WINDOW_SECONDS if window <= 0
|
||||
bucket = coerce_integer(bucket_seconds) || DEFAULT_TELEMETRY_BUCKET_SECONDS
|
||||
@@ -565,6 +615,7 @@ module PotatoMesh
|
||||
db.results_as_hash = true
|
||||
now = Time.now.to_i
|
||||
min_timestamp = now - window
|
||||
since_threshold = normalize_since_threshold(since, floor: min_timestamp)
|
||||
bucket_expression = "((COALESCE(rx_time, telemetry_time) / ?) * ?)"
|
||||
select_clauses = [
|
||||
"#{bucket_expression} AS bucket_start",
|
||||
@@ -590,7 +641,7 @@ module PotatoMesh
|
||||
ORDER BY bucket_start ASC
|
||||
LIMIT ?
|
||||
SQL
|
||||
params = [bucket, bucket, min_timestamp, MAX_QUERY_LIMIT]
|
||||
params = [bucket, bucket, since_threshold, MAX_QUERY_LIMIT]
|
||||
rows = db.execute(sql, params)
|
||||
rows.map do |row|
|
||||
bucket_start = coerce_integer(row["bucket_start"])
|
||||
@@ -670,7 +721,13 @@ module PotatoMesh
|
||||
column
|
||||
end
|
||||
|
||||
def query_traces(limit, node_ref: nil)
|
||||
# Fetch trace records optionally scoped by node and timestamp.
|
||||
#
|
||||
# @param limit [Integer] maximum number of rows to return.
|
||||
# @param node_ref [String, Integer, nil] optional node reference to scope results.
|
||||
# @param since [Integer] unix timestamp threshold applied in addition to the rolling window.
|
||||
# @return [Array<Hash>] compacted trace rows suitable for API responses.
|
||||
def query_traces(limit, node_ref: nil, since: 0)
|
||||
limit = coerce_query_limit(limit)
|
||||
db = open_database(readonly: true)
|
||||
db.results_as_hash = true
|
||||
@@ -678,8 +735,9 @@ module PotatoMesh
|
||||
where_clauses = []
|
||||
now = Time.now.to_i
|
||||
min_rx_time = now - PotatoMesh::Config.week_seconds
|
||||
since_threshold = normalize_since_threshold(since, floor: min_rx_time)
|
||||
where_clauses << "COALESCE(rx_time, 0) >= ?"
|
||||
params << min_rx_time
|
||||
params << since_threshold
|
||||
|
||||
if node_ref
|
||||
tokens = node_reference_tokens(node_ref)
|
||||
|
||||
@@ -64,7 +64,7 @@ module PotatoMesh
|
||||
app.get "/api/nodes" do
|
||||
content_type :json
|
||||
limit = [params["limit"]&.to_i || 200, 1000].min
|
||||
query_nodes(limit).to_json
|
||||
query_nodes(limit, since: params["since"]).to_json
|
||||
end
|
||||
|
||||
app.get "/api/nodes/:id" do
|
||||
@@ -72,7 +72,7 @@ module PotatoMesh
|
||||
node_ref = string_or_nil(params["id"])
|
||||
halt 400, { error: "missing node id" }.to_json unless node_ref
|
||||
limit = [params["limit"]&.to_i || 200, 1000].min
|
||||
rows = query_nodes(limit, node_ref: node_ref)
|
||||
rows = query_nodes(limit, node_ref: node_ref, since: params["since"])
|
||||
halt 404, { error: "not found" }.to_json if rows.empty?
|
||||
rows.first.to_json
|
||||
end
|
||||
@@ -80,7 +80,7 @@ module PotatoMesh
|
||||
app.get "/api/ingestors" do
|
||||
content_type :json
|
||||
limit = coerce_query_limit(params["limit"])
|
||||
query_ingestors(limit).to_json
|
||||
query_ingestors(limit, since: params["since"]).to_json
|
||||
end
|
||||
|
||||
app.get "/api/messages" do
|
||||
@@ -111,7 +111,7 @@ module PotatoMesh
|
||||
app.get "/api/positions" do
|
||||
content_type :json
|
||||
limit = [params["limit"]&.to_i || 200, 1000].min
|
||||
query_positions(limit).to_json
|
||||
query_positions(limit, since: params["since"]).to_json
|
||||
end
|
||||
|
||||
app.get "/api/positions/:id" do
|
||||
@@ -119,13 +119,13 @@ module PotatoMesh
|
||||
node_ref = string_or_nil(params["id"])
|
||||
halt 400, { error: "missing node id" }.to_json unless node_ref
|
||||
limit = [params["limit"]&.to_i || 200, 1000].min
|
||||
query_positions(limit, node_ref: node_ref).to_json
|
||||
query_positions(limit, node_ref: node_ref, since: params["since"]).to_json
|
||||
end
|
||||
|
||||
app.get "/api/neighbors" do
|
||||
content_type :json
|
||||
limit = [params["limit"]&.to_i || 200, 1000].min
|
||||
query_neighbors(limit).to_json
|
||||
query_neighbors(limit, since: params["since"]).to_json
|
||||
end
|
||||
|
||||
app.get "/api/neighbors/:id" do
|
||||
@@ -133,13 +133,13 @@ module PotatoMesh
|
||||
node_ref = string_or_nil(params["id"])
|
||||
halt 400, { error: "missing node id" }.to_json unless node_ref
|
||||
limit = [params["limit"]&.to_i || 200, 1000].min
|
||||
query_neighbors(limit, node_ref: node_ref).to_json
|
||||
query_neighbors(limit, node_ref: node_ref, since: params["since"]).to_json
|
||||
end
|
||||
|
||||
app.get "/api/telemetry" do
|
||||
content_type :json
|
||||
limit = [params["limit"]&.to_i || 200, 1000].min
|
||||
query_telemetry(limit).to_json
|
||||
query_telemetry(limit, since: params["since"]).to_json
|
||||
end
|
||||
|
||||
app.get "/api/telemetry/aggregated" do
|
||||
@@ -170,7 +170,11 @@ module PotatoMesh
|
||||
halt 400, { error: "bucketSeconds too small for requested window" }.to_json
|
||||
end
|
||||
|
||||
query_telemetry_buckets(window_seconds: window_seconds, bucket_seconds: bucket_seconds).to_json
|
||||
query_telemetry_buckets(
|
||||
window_seconds: window_seconds,
|
||||
bucket_seconds: bucket_seconds,
|
||||
since: params["since"],
|
||||
).to_json
|
||||
end
|
||||
|
||||
app.get "/api/telemetry/:id" do
|
||||
@@ -178,13 +182,13 @@ module PotatoMesh
|
||||
node_ref = string_or_nil(params["id"])
|
||||
halt 400, { error: "missing node id" }.to_json unless node_ref
|
||||
limit = [params["limit"]&.to_i || 200, 1000].min
|
||||
query_telemetry(limit, node_ref: node_ref).to_json
|
||||
query_telemetry(limit, node_ref: node_ref, since: params["since"]).to_json
|
||||
end
|
||||
|
||||
app.get "/api/traces" do
|
||||
content_type :json
|
||||
limit = [params["limit"]&.to_i || 200, 1000].min
|
||||
query_traces(limit).to_json
|
||||
query_traces(limit, since: params["since"]).to_json
|
||||
end
|
||||
|
||||
app.get "/api/traces/:id" do
|
||||
@@ -192,7 +196,7 @@ module PotatoMesh
|
||||
node_ref = string_or_nil(params["id"])
|
||||
halt 400, { error: "missing node id" }.to_json unless node_ref
|
||||
limit = [params["limit"]&.to_i || 200, 1000].min
|
||||
query_traces(limit, node_ref: node_ref).to_json
|
||||
query_traces(limit, node_ref: node_ref, since: params["since"]).to_json
|
||||
end
|
||||
|
||||
app.get "/api/instances" do
|
||||
|
||||
@@ -132,6 +132,7 @@ module PotatoMesh
|
||||
raw_private = payload.key?("isPrivate") ? payload["isPrivate"] : payload["is_private"]
|
||||
is_private = coerce_boolean(raw_private)
|
||||
signature = string_or_nil(payload["signature"])
|
||||
contact_link = string_or_nil(payload["contactLink"])
|
||||
|
||||
attributes = {
|
||||
id: id,
|
||||
@@ -145,6 +146,7 @@ module PotatoMesh
|
||||
longitude: longitude,
|
||||
last_update_time: last_update_time,
|
||||
is_private: is_private,
|
||||
contact_link: contact_link,
|
||||
}
|
||||
|
||||
if [attributes[:id], attributes[:domain], attributes[:pubkey], signature, attributes[:last_update_time]].any?(&:nil?)
|
||||
@@ -157,6 +159,10 @@ module PotatoMesh
|
||||
end
|
||||
|
||||
signature_valid = verify_instance_signature(attributes, signature, attributes[:pubkey])
|
||||
if !signature_valid && contact_link
|
||||
stripped_attributes = attributes.merge(contact_link: nil)
|
||||
signature_valid = verify_instance_signature(stripped_attributes, signature, attributes[:pubkey])
|
||||
end
|
||||
# Some remote peers sign payloads using a canonicalised lowercase
|
||||
# domain while still sending a mixed-case domain. Retry signature
|
||||
# verification with the original casing when the first attempt
|
||||
@@ -164,6 +170,10 @@ module PotatoMesh
|
||||
if !signature_valid && raw_domain && normalized_domain && raw_domain.casecmp?(normalized_domain) && raw_domain != normalized_domain
|
||||
alternate_attributes = attributes.merge(domain: raw_domain)
|
||||
signature_valid = verify_instance_signature(alternate_attributes, signature, attributes[:pubkey])
|
||||
if !signature_valid && contact_link
|
||||
stripped_alternate = alternate_attributes.merge(contact_link: nil)
|
||||
signature_valid = verify_instance_signature(stripped_alternate, signature, attributes[:pubkey])
|
||||
end
|
||||
end
|
||||
|
||||
unless signature_valid
|
||||
|
||||
@@ -42,6 +42,7 @@ module PotatoMesh
|
||||
DEFAULT_FEDERATION_WORKER_QUEUE_CAPACITY = 128
|
||||
DEFAULT_FEDERATION_TASK_TIMEOUT_SECONDS = 120
|
||||
DEFAULT_INITIAL_FEDERATION_DELAY_SECONDS = 2
|
||||
DEFAULT_FEDERATION_SEED_DOMAINS = %w[potatomesh.net potatomesh.jmrp.io mesh.qrp.ro].freeze
|
||||
|
||||
# Retrieve the configured API token used for authenticated requests.
|
||||
#
|
||||
@@ -175,7 +176,7 @@ module PotatoMesh
|
||||
#
|
||||
# @return [String] semantic version identifier.
|
||||
def version_fallback
|
||||
"0.5.7"
|
||||
"0.5.9"
|
||||
end
|
||||
|
||||
# Default refresh interval for frontend polling routines.
|
||||
@@ -409,7 +410,7 @@ module PotatoMesh
|
||||
#
|
||||
# @return [Array<String>] list of default seed domains.
|
||||
def federation_seed_domains
|
||||
["potatomesh.net"].freeze
|
||||
DEFAULT_FEDERATION_SEED_DOMAINS
|
||||
end
|
||||
|
||||
# Determine how often we broadcast federation announcements.
|
||||
|
||||
Generated
+12
-2
@@ -1,12 +1,16 @@
|
||||
{
|
||||
"name": "potato-mesh",
|
||||
"version": "0.5.7",
|
||||
"version": "0.5.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "potato-mesh",
|
||||
"version": "0.5.7",
|
||||
"version": "0.5.9",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"uplot": "^1.6.30"
|
||||
},
|
||||
"devDependencies": {
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
@@ -154,6 +158,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/uplot": {
|
||||
"version": "1.6.32",
|
||||
"resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz",
|
||||
"integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/v8-to-istanbul": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
|
||||
|
||||
+5
-1
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"name": "potato-mesh",
|
||||
"version": "0.5.7",
|
||||
"version": "0.5.9",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"postinstall": "node ./scripts/copy-uplot.js",
|
||||
"test": "mkdir -p reports coverage && NODE_V8_COVERAGE=coverage node --test --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=reports/javascript-junit.xml && node ./scripts/export-coverage.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"uplot": "^1.6.30"
|
||||
},
|
||||
"devDependencies": {
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
|
||||
@@ -80,13 +80,19 @@ test('initializeChartsPage renders the telemetry charts when snapshots are avail
|
||||
},
|
||||
]);
|
||||
let receivedOptions = null;
|
||||
const renderCharts = (node, options) => {
|
||||
let mountedModels = null;
|
||||
const createCharts = (node, options) => {
|
||||
receivedOptions = options;
|
||||
return '<section class="node-detail__charts">Charts</section>';
|
||||
return { chartsHtml: '<section class="node-detail__charts">Charts</section>', chartModels: [{ id: 'power' }] };
|
||||
};
|
||||
const result = await initializeChartsPage({ document: documentStub, fetchImpl, renderCharts });
|
||||
const mountCharts = (chartModels, options) => {
|
||||
mountedModels = { chartModels, options };
|
||||
return [];
|
||||
};
|
||||
const result = await initializeChartsPage({ document: documentStub, fetchImpl, createCharts, mountCharts });
|
||||
assert.equal(result, true);
|
||||
assert.equal(container.innerHTML.includes('node-detail__charts'), true);
|
||||
assert.equal(mountedModels.chartModels.length, 1);
|
||||
assert.ok(receivedOptions);
|
||||
assert.equal(receivedOptions.chartOptions.windowMs, 604_800_000);
|
||||
assert.equal(typeof receivedOptions.chartOptions.lineReducer, 'function');
|
||||
@@ -118,8 +124,8 @@ test('initializeChartsPage shows an error message when fetching fails', async ()
|
||||
const fetchImpl = async () => {
|
||||
throw new Error('network');
|
||||
};
|
||||
const renderCharts = () => '<section>unused</section>';
|
||||
const result = await initializeChartsPage({ document: documentStub, fetchImpl, renderCharts });
|
||||
const createCharts = () => ({ chartsHtml: '<section>unused</section>', chartModels: [] });
|
||||
const result = await initializeChartsPage({ document: documentStub, fetchImpl, createCharts });
|
||||
assert.equal(result, false);
|
||||
assert.equal(container.innerHTML.includes('Failed to load telemetry charts.'), true);
|
||||
});
|
||||
@@ -136,8 +142,8 @@ test('initializeChartsPage handles missing containers and empty telemetry snapsh
|
||||
},
|
||||
};
|
||||
const fetchImpl = async () => createResponse(200, []);
|
||||
const renderCharts = () => '';
|
||||
const result = await initializeChartsPage({ document: documentStub, fetchImpl, renderCharts });
|
||||
const createCharts = () => ({ chartsHtml: '', chartModels: [] });
|
||||
const result = await initializeChartsPage({ document: documentStub, fetchImpl, createCharts });
|
||||
assert.equal(result, true);
|
||||
assert.equal(container.innerHTML.includes('Telemetry snapshots are unavailable.'), true);
|
||||
});
|
||||
@@ -155,8 +161,8 @@ test('initializeChartsPage shows a status when rendering produces no markup', as
|
||||
aggregates: { voltage: { avg: 3.9 } },
|
||||
},
|
||||
]);
|
||||
const renderCharts = () => '';
|
||||
const result = await initializeChartsPage({ document: documentStub, fetchImpl, renderCharts });
|
||||
const createCharts = () => ({ chartsHtml: '', chartModels: [] });
|
||||
const result = await initializeChartsPage({ document: documentStub, fetchImpl, createCharts });
|
||||
assert.equal(result, true);
|
||||
assert.equal(container.innerHTML.includes('Telemetry snapshots are unavailable.'), true);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright © 2025-26 l5yth & contributors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { resolveLegendVisibility } from '../map-legend-visibility.js';
|
||||
|
||||
test('resolveLegendVisibility hides when a default collapse is requested', () => {
|
||||
assert.equal(resolveLegendVisibility({ defaultCollapsed: true, mediaQueryMatches: false }), false);
|
||||
assert.equal(resolveLegendVisibility({ defaultCollapsed: true, mediaQueryMatches: true }), false);
|
||||
});
|
||||
|
||||
test('resolveLegendVisibility hides for dashboard and map views', () => {
|
||||
assert.equal(
|
||||
resolveLegendVisibility({ defaultCollapsed: false, mediaQueryMatches: false, viewMode: 'dashboard' }),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
resolveLegendVisibility({ defaultCollapsed: false, mediaQueryMatches: false, viewMode: 'map' }),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveLegendVisibility follows the media query when not forced', () => {
|
||||
assert.equal(resolveLegendVisibility({ defaultCollapsed: false, mediaQueryMatches: false }), true);
|
||||
assert.equal(resolveLegendVisibility({ defaultCollapsed: false, mediaQueryMatches: true }), false);
|
||||
});
|
||||
@@ -111,6 +111,26 @@ test('createNodeDetailOverlayManager renders fetched markup and restores focus',
|
||||
assert.equal(focusTarget.focusCalled, true);
|
||||
});
|
||||
|
||||
test('createNodeDetailOverlayManager mounts telemetry charts for overlay content', async () => {
|
||||
const { document, content } = createOverlayHarness();
|
||||
const chartModels = [{ id: 'power' }];
|
||||
let mountCall = null;
|
||||
const manager = createNodeDetailOverlayManager({
|
||||
document,
|
||||
fetchNodeDetail: async () => ({ html: '<section class="node-detail">Charts</section>', chartModels }),
|
||||
mountCharts: (models, options) => {
|
||||
mountCall = { models, options };
|
||||
return [];
|
||||
},
|
||||
});
|
||||
assert.ok(manager);
|
||||
await manager.open({ nodeId: '!alpha' });
|
||||
assert.equal(content.innerHTML.includes('Charts'), true);
|
||||
assert.ok(mountCall);
|
||||
assert.equal(mountCall.models, chartModels);
|
||||
assert.equal(mountCall.options.root, content);
|
||||
});
|
||||
|
||||
test('createNodeDetailOverlayManager surfaces errors and supports escape closing', async () => {
|
||||
const { document, overlay, content } = createOverlayHarness();
|
||||
const errors = [];
|
||||
|
||||
@@ -47,7 +47,9 @@ const {
|
||||
categoriseNeighbors,
|
||||
renderNeighborGroups,
|
||||
renderSingleNodeTable,
|
||||
createTelemetryCharts,
|
||||
renderTelemetryCharts,
|
||||
buildUPlotChartConfig,
|
||||
renderMessages,
|
||||
renderTraceroutes,
|
||||
renderTracePath,
|
||||
@@ -386,23 +388,93 @@ test('renderTelemetryCharts renders condensed scatter charts when telemetry exis
|
||||
},
|
||||
};
|
||||
const html = renderTelemetryCharts(node, { nowMs });
|
||||
const fmt = new Date(nowMs);
|
||||
const expectedDate = String(fmt.getDate()).padStart(2, '0');
|
||||
assert.equal(html.includes('node-detail__charts'), true);
|
||||
assert.equal(html.includes('Power metrics'), true);
|
||||
assert.equal(html.includes('Environmental telemetry'), true);
|
||||
assert.equal(html.includes('Battery (%)'), true);
|
||||
assert.equal(html.includes('Voltage (V)'), true);
|
||||
assert.equal(html.includes('Current (A)'), true);
|
||||
assert.equal(html.includes('Channel utilization (%)'), true);
|
||||
assert.equal(html.includes('Air util TX (%)'), true);
|
||||
assert.equal(html.includes('Utilization (%)'), true);
|
||||
assert.equal(html.includes('Gas resistance (\u03a9)'), true);
|
||||
assert.equal(html.includes('Air quality'), true);
|
||||
assert.equal(html.includes('IAQ index'), true);
|
||||
assert.equal(html.includes('Temperature (\u00b0C)'), true);
|
||||
assert.equal(html.includes(expectedDate), true);
|
||||
assert.equal(html.includes('node-detail__chart-point'), true);
|
||||
assert.equal(html.includes('node-detail__chart-plot'), true);
|
||||
});
|
||||
|
||||
test('renderTelemetryCharts expands upper bounds when overflow metrics exceed defaults', () => {
|
||||
const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0);
|
||||
const nowSeconds = Math.floor(nowMs / 1000);
|
||||
const node = {
|
||||
rawSources: {
|
||||
telemetry: {
|
||||
snapshots: [
|
||||
{
|
||||
rx_time: nowSeconds - 120,
|
||||
device_metrics: {
|
||||
battery_level: 90,
|
||||
voltage: 7.2,
|
||||
current: 3.6,
|
||||
channel_utilization: 45,
|
||||
air_util_tx: 18,
|
||||
},
|
||||
environment_metrics: {
|
||||
temperature: 45,
|
||||
relative_humidity: 48,
|
||||
barometric_pressure: 1250,
|
||||
gas_resistance: 1200,
|
||||
iaq: 650,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const { chartModels } = createTelemetryCharts(node, { nowMs });
|
||||
const powerChart = chartModels.find(model => model.id === 'power');
|
||||
const environmentChart = chartModels.find(model => model.id === 'environment');
|
||||
const airChart = chartModels.find(model => model.id === 'airQuality');
|
||||
const powerConfig = buildUPlotChartConfig(powerChart);
|
||||
const envConfig = buildUPlotChartConfig(environmentChart);
|
||||
const airConfig = buildUPlotChartConfig(airChart);
|
||||
assert.equal(powerConfig.options.scales.voltage.range()[1], 7.2);
|
||||
assert.equal(powerConfig.options.scales.current.range()[1], 3.6);
|
||||
assert.equal(envConfig.options.scales.temperature.range()[1], 45);
|
||||
assert.equal(airConfig.options.scales.iaq.range()[1], 650);
|
||||
assert.equal(airConfig.options.scales.pressure.range()[1], 1100);
|
||||
});
|
||||
|
||||
test('renderTelemetryCharts keeps default bounds when metrics stay within limits', () => {
|
||||
const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0);
|
||||
const nowSeconds = Math.floor(nowMs / 1000);
|
||||
const node = {
|
||||
rawSources: {
|
||||
telemetry: {
|
||||
snapshots: [
|
||||
{
|
||||
rx_time: nowSeconds - 180,
|
||||
device_metrics: {
|
||||
battery_level: 70,
|
||||
voltage: 4.5,
|
||||
current: 1.5,
|
||||
channel_utilization: 35,
|
||||
air_util_tx: 15,
|
||||
},
|
||||
environment_metrics: {
|
||||
temperature: 25,
|
||||
relative_humidity: 50,
|
||||
barometric_pressure: 1015,
|
||||
gas_resistance: 1500,
|
||||
iaq: 200,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const { chartModels } = createTelemetryCharts(node, { nowMs });
|
||||
const powerChart = chartModels.find(model => model.id === 'power');
|
||||
const environmentChart = chartModels.find(model => model.id === 'environment');
|
||||
const airChart = chartModels.find(model => model.id === 'airQuality');
|
||||
const powerConfig = buildUPlotChartConfig(powerChart);
|
||||
const envConfig = buildUPlotChartConfig(environmentChart);
|
||||
const airConfig = buildUPlotChartConfig(airChart);
|
||||
assert.equal(powerConfig.options.scales.voltage.range()[1], 6);
|
||||
assert.equal(powerConfig.options.scales.current.range()[1], 3);
|
||||
assert.equal(envConfig.options.scales.temperature.range()[1], 40);
|
||||
assert.equal(airConfig.options.scales.iaq.range()[1], 500);
|
||||
});
|
||||
|
||||
test('renderNodeDetailHtml composes the table, neighbors, and messages', () => {
|
||||
@@ -518,17 +590,18 @@ test('fetchNodeDetailHtml renders the node layout for overlays', async () => {
|
||||
neighbors: [],
|
||||
rawSources: { node: { node_id: '!alpha', role: 'CLIENT', short_name: 'ALPH' } },
|
||||
});
|
||||
const html = await fetchNodeDetailHtml(reference, {
|
||||
const result = await fetchNodeDetailHtml(reference, {
|
||||
refreshImpl,
|
||||
fetchImpl,
|
||||
renderShortHtml: short => `<span class="short-name">${short}</span>`,
|
||||
returnState: true,
|
||||
});
|
||||
assert.equal(calledUrls.some(url => url.includes('/api/messages/!alpha')), true);
|
||||
assert.equal(calledUrls.some(url => url.includes('/api/traces/!alpha')), true);
|
||||
assert.equal(html.includes('Example Alpha'), true);
|
||||
assert.equal(html.includes('Overlay hello'), true);
|
||||
assert.equal(html.includes('Traceroutes'), true);
|
||||
assert.equal(html.includes('node-detail__table'), true);
|
||||
assert.equal(result.html.includes('Example Alpha'), true);
|
||||
assert.equal(result.html.includes('Overlay hello'), true);
|
||||
assert.equal(result.html.includes('Traceroutes'), true);
|
||||
assert.equal(result.html.includes('node-detail__table'), true);
|
||||
});
|
||||
|
||||
test('fetchNodeDetailHtml hydrates traceroute nodes with API metadata', async () => {
|
||||
@@ -566,16 +639,17 @@ test('fetchNodeDetailHtml hydrates traceroute nodes with API metadata', async ()
|
||||
rawSources: { node: { node_id: '!origin', role: 'CLIENT', short_name: 'ORIG' } },
|
||||
});
|
||||
|
||||
const html = await fetchNodeDetailHtml(reference, {
|
||||
const result = await fetchNodeDetailHtml(reference, {
|
||||
refreshImpl,
|
||||
fetchImpl,
|
||||
renderShortHtml: short => `<span class="short-name">${short}</span>`,
|
||||
returnState: true,
|
||||
});
|
||||
|
||||
assert.equal(calledUrls.some(url => url.includes('/api/nodes/!relay')), true);
|
||||
assert.equal(calledUrls.some(url => url.includes('/api/nodes/!target')), true);
|
||||
assert.equal(html.includes('RLY1'), true);
|
||||
assert.equal(html.includes('TGT1'), true);
|
||||
assert.equal(result.html.includes('RLY1'), true);
|
||||
assert.equal(result.html.includes('TGT1'), true);
|
||||
});
|
||||
|
||||
test('fetchNodeDetailHtml requires a node identifier reference', async () => {
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
/*
|
||||
* Copyright © 2025-26 l5yth & contributors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { __testUtils } from '../node-page.js';
|
||||
import { buildMovingAverageSeries } from '../charts-page.js';
|
||||
|
||||
const {
|
||||
createTelemetryCharts,
|
||||
buildUPlotChartConfig,
|
||||
mountTelemetryCharts,
|
||||
mountTelemetryChartsWithRetry,
|
||||
} = __testUtils;
|
||||
|
||||
test('uPlot chart config preserves axes, colors, and tick labels for node telemetry', () => {
|
||||
const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0);
|
||||
const nowSeconds = Math.floor(nowMs / 1000);
|
||||
const node = {
|
||||
rawSources: {
|
||||
telemetry: {
|
||||
snapshots: [
|
||||
{
|
||||
rx_time: nowSeconds - 60,
|
||||
device_metrics: {
|
||||
battery_level: 80,
|
||||
voltage: 4.1,
|
||||
current: 0.75,
|
||||
},
|
||||
},
|
||||
{
|
||||
rx_time: nowSeconds - 3_600,
|
||||
device_metrics: {
|
||||
battery_level: 78,
|
||||
voltage: 4.05,
|
||||
current: 0.65,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const { chartModels } = createTelemetryCharts(node, {
|
||||
nowMs,
|
||||
chartOptions: {
|
||||
xAxisTickBuilder: () => [nowMs],
|
||||
xAxisTickFormatter: () => '08',
|
||||
},
|
||||
});
|
||||
const powerChart = chartModels.find(model => model.id === 'power');
|
||||
const { options, data } = buildUPlotChartConfig(powerChart);
|
||||
|
||||
assert.deepEqual(options.scales.battery.range(), [0, 100]);
|
||||
assert.deepEqual(options.scales.voltage.range(), [0, 6]);
|
||||
assert.deepEqual(options.scales.current.range(), [0, 3]);
|
||||
assert.equal(options.series[1].stroke, '#8856a7');
|
||||
assert.equal(options.series[2].stroke, '#9ebcda');
|
||||
assert.equal(options.series[3].stroke, '#3182bd');
|
||||
assert.deepEqual(options.axes[0].values(null, [nowMs]), ['08']);
|
||||
assert.equal(options.axes[0].stroke, '#5c6773');
|
||||
|
||||
assert.deepEqual(data[0].slice(0, 2), [nowMs - 3_600_000, nowMs - 60_000]);
|
||||
assert.deepEqual(data[1].slice(0, 2), [78, 80]);
|
||||
});
|
||||
|
||||
test('uPlot chart config maps moving averages and raw points for aggregated telemetry', () => {
|
||||
const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0);
|
||||
const nowSeconds = Math.floor(nowMs / 1000);
|
||||
const snapshots = [
|
||||
{
|
||||
rx_time: nowSeconds - 3_600,
|
||||
device_metrics: { battery_level: 10 },
|
||||
},
|
||||
{
|
||||
rx_time: nowSeconds - 1_800,
|
||||
device_metrics: { battery_level: 20 },
|
||||
},
|
||||
];
|
||||
const node = { rawSources: { telemetry: { snapshots } } };
|
||||
const { chartModels } = createTelemetryCharts(node, {
|
||||
nowMs,
|
||||
chartOptions: {
|
||||
lineReducer: points => buildMovingAverageSeries(points, 3_600_000),
|
||||
},
|
||||
});
|
||||
const powerChart = chartModels.find(model => model.id === 'power');
|
||||
const { options, data } = buildUPlotChartConfig(powerChart);
|
||||
|
||||
assert.equal(options.series.length, 3);
|
||||
assert.equal(options.series[1].stroke.startsWith('rgba('), true);
|
||||
assert.equal(options.series[2].stroke, '#8856a7');
|
||||
assert.deepEqual(data[1].slice(0, 2), [10, 15]);
|
||||
assert.deepEqual(data[2].slice(0, 2), [10, 20]);
|
||||
});
|
||||
|
||||
test('buildUPlotChartConfig applies axis color overrides', () => {
|
||||
const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0);
|
||||
const nowSeconds = Math.floor(nowMs / 1000);
|
||||
const node = {
|
||||
rawSources: {
|
||||
telemetry: {
|
||||
snapshots: [
|
||||
{
|
||||
rx_time: nowSeconds - 60,
|
||||
device_metrics: { battery_level: 80 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const { chartModels } = createTelemetryCharts(node, { nowMs });
|
||||
const powerChart = chartModels.find(model => model.id === 'power');
|
||||
const { options } = buildUPlotChartConfig(powerChart, {
|
||||
axisColor: '#ffffff',
|
||||
gridColor: '#222222',
|
||||
});
|
||||
assert.equal(options.axes[0].stroke, '#ffffff');
|
||||
assert.equal(options.axes[0].grid.stroke, '#222222');
|
||||
});
|
||||
|
||||
test('environment chart renders humidity axis on the right side', () => {
|
||||
const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0);
|
||||
const nowSeconds = Math.floor(nowMs / 1000);
|
||||
const node = {
|
||||
rawSources: {
|
||||
telemetry: {
|
||||
snapshots: [
|
||||
{
|
||||
rx_time: nowSeconds - 60,
|
||||
environment_metrics: {
|
||||
temperature: 19.5,
|
||||
relative_humidity: 55,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const { chartModels } = createTelemetryCharts(node, { nowMs });
|
||||
const envChart = chartModels.find(model => model.id === 'environment');
|
||||
const { options } = buildUPlotChartConfig(envChart);
|
||||
const humidityAxis = options.axes.find(axis => axis.scale === 'humidity');
|
||||
assert.ok(humidityAxis);
|
||||
assert.equal(humidityAxis.side, 1);
|
||||
assert.equal(humidityAxis.show, true);
|
||||
});
|
||||
|
||||
test('channel utilization chart includes a right-side utilization axis', () => {
|
||||
const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0);
|
||||
const nowSeconds = Math.floor(nowMs / 1000);
|
||||
const node = {
|
||||
rawSources: {
|
||||
telemetry: {
|
||||
snapshots: [
|
||||
{
|
||||
rx_time: nowSeconds - 60,
|
||||
device_metrics: {
|
||||
channel_utilization: 40,
|
||||
air_util_tx: 22,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const { chartModels } = createTelemetryCharts(node, { nowMs });
|
||||
const channelChart = chartModels.find(model => model.id === 'channel');
|
||||
const { options } = buildUPlotChartConfig(channelChart);
|
||||
const rightAxis = options.axes.find(axis => axis.scale === 'channelSecondary');
|
||||
assert.ok(rightAxis);
|
||||
assert.equal(rightAxis.side, 1);
|
||||
assert.equal(rightAxis.show, true);
|
||||
});
|
||||
|
||||
test('createTelemetryCharts returns empty markup when snapshots are missing', () => {
|
||||
const { chartsHtml, chartModels } = createTelemetryCharts({ rawSources: { telemetry: { snapshots: [] } } });
|
||||
assert.equal(chartsHtml, '');
|
||||
assert.equal(chartModels.length, 0);
|
||||
});
|
||||
|
||||
test('mountTelemetryCharts instantiates uPlot for chart containers', () => {
|
||||
const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0);
|
||||
const nowSeconds = Math.floor(nowMs / 1000);
|
||||
const node = {
|
||||
rawSources: {
|
||||
telemetry: {
|
||||
snapshots: [
|
||||
{
|
||||
rx_time: nowSeconds - 60,
|
||||
device_metrics: { battery_level: 80 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const { chartModels } = createTelemetryCharts(node, { nowMs });
|
||||
const [model] = chartModels;
|
||||
const plotRoot = { innerHTML: 'placeholder' };
|
||||
const chartContainer = {
|
||||
querySelector(selector) {
|
||||
return selector === '[data-telemetry-plot]' ? plotRoot : null;
|
||||
},
|
||||
};
|
||||
const root = {
|
||||
querySelector(selector) {
|
||||
return selector === `[data-telemetry-chart-id="${model.id}"]` ? chartContainer : null;
|
||||
},
|
||||
};
|
||||
class UPlotStub {
|
||||
constructor(options, data, container) {
|
||||
this.options = options;
|
||||
this.data = data;
|
||||
this.container = container;
|
||||
}
|
||||
}
|
||||
const instances = mountTelemetryCharts(chartModels, { root, uPlotImpl: UPlotStub });
|
||||
assert.equal(plotRoot.innerHTML, '');
|
||||
assert.equal(instances.length, 1);
|
||||
assert.equal(instances[0].container, plotRoot);
|
||||
});
|
||||
|
||||
test('mountTelemetryCharts responds to window resize events', async () => {
|
||||
const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0);
|
||||
const nowSeconds = Math.floor(nowMs / 1000);
|
||||
const node = {
|
||||
rawSources: {
|
||||
telemetry: {
|
||||
snapshots: [
|
||||
{
|
||||
rx_time: nowSeconds - 60,
|
||||
device_metrics: { battery_level: 80 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const { chartModels } = createTelemetryCharts(node, { nowMs });
|
||||
const [model] = chartModels;
|
||||
const plotRoot = {
|
||||
innerHTML: '',
|
||||
clientWidth: 320,
|
||||
clientHeight: 180,
|
||||
getBoundingClientRect() {
|
||||
return { width: this.clientWidth, height: this.clientHeight };
|
||||
},
|
||||
};
|
||||
const chartContainer = {
|
||||
querySelector(selector) {
|
||||
return selector === '[data-telemetry-plot]' ? plotRoot : null;
|
||||
},
|
||||
};
|
||||
const root = {
|
||||
querySelector(selector) {
|
||||
return selector === `[data-telemetry-chart-id="${model.id}"]` ? chartContainer : null;
|
||||
},
|
||||
};
|
||||
const previousResizeObserver = globalThis.ResizeObserver;
|
||||
const previousAddEventListener = globalThis.addEventListener;
|
||||
let resizeHandler = null;
|
||||
globalThis.ResizeObserver = undefined;
|
||||
globalThis.addEventListener = (event, handler) => {
|
||||
if (event === 'resize') {
|
||||
resizeHandler = handler;
|
||||
}
|
||||
};
|
||||
const sizeCalls = [];
|
||||
class UPlotStub {
|
||||
constructor(options, data, container) {
|
||||
this.options = options;
|
||||
this.data = data;
|
||||
this.container = container;
|
||||
this.root = container;
|
||||
}
|
||||
setSize(size) {
|
||||
sizeCalls.push(size);
|
||||
}
|
||||
}
|
||||
mountTelemetryCharts(chartModels, { root, uPlotImpl: UPlotStub });
|
||||
assert.ok(resizeHandler);
|
||||
plotRoot.clientWidth = 480;
|
||||
plotRoot.clientHeight = 240;
|
||||
resizeHandler();
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
assert.equal(sizeCalls.length >= 1, true);
|
||||
assert.deepEqual(sizeCalls[sizeCalls.length - 1], { width: 480, height: 240 });
|
||||
globalThis.ResizeObserver = previousResizeObserver;
|
||||
globalThis.addEventListener = previousAddEventListener;
|
||||
});
|
||||
|
||||
test('mountTelemetryChartsWithRetry loads uPlot when missing', async () => {
|
||||
const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0);
|
||||
const nowSeconds = Math.floor(nowMs / 1000);
|
||||
const node = {
|
||||
rawSources: {
|
||||
telemetry: {
|
||||
snapshots: [
|
||||
{
|
||||
rx_time: nowSeconds - 60,
|
||||
device_metrics: { battery_level: 80 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const { chartModels } = createTelemetryCharts(node, { nowMs });
|
||||
const [model] = chartModels;
|
||||
const plotRoot = { innerHTML: '', clientWidth: 400, clientHeight: 200 };
|
||||
const chartContainer = {
|
||||
querySelector(selector) {
|
||||
return selector === '[data-telemetry-plot]' ? plotRoot : null;
|
||||
},
|
||||
};
|
||||
const root = {
|
||||
ownerDocument: {
|
||||
body: {},
|
||||
querySelector: () => null,
|
||||
},
|
||||
querySelector(selector) {
|
||||
return selector === `[data-telemetry-chart-id="${model.id}"]` ? chartContainer : null;
|
||||
},
|
||||
};
|
||||
const previousUPlot = globalThis.uPlot;
|
||||
const instances = [];
|
||||
class UPlotStub {
|
||||
constructor(options, data, container) {
|
||||
this.options = options;
|
||||
this.data = data;
|
||||
this.container = container;
|
||||
instances.push(this);
|
||||
}
|
||||
}
|
||||
let loadCalled = false;
|
||||
const loadUPlot = ({ onLoad }) => {
|
||||
loadCalled = true;
|
||||
globalThis.uPlot = UPlotStub;
|
||||
if (typeof onLoad === 'function') {
|
||||
onLoad();
|
||||
}
|
||||
return true;
|
||||
};
|
||||
mountTelemetryChartsWithRetry(chartModels, { root, loadUPlot });
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
assert.equal(loadCalled, true);
|
||||
assert.equal(instances.length, 1);
|
||||
globalThis.uPlot = previousUPlot;
|
||||
});
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { renderTelemetryCharts } from './node-page.js';
|
||||
import { createTelemetryCharts, mountTelemetryChartsWithRetry } from './node-page.js';
|
||||
|
||||
const TELEMETRY_BUCKET_SECONDS = 60 * 60;
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
@@ -193,6 +193,21 @@ export async function fetchAggregatedTelemetry({
|
||||
.filter(snapshot => snapshot != null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and render aggregated telemetry charts.
|
||||
*
|
||||
* @param {{
|
||||
* document?: Document,
|
||||
* rootId?: string,
|
||||
* fetchImpl?: Function,
|
||||
* bucketSeconds?: number,
|
||||
* windowMs?: number,
|
||||
* createCharts?: Function,
|
||||
* mountCharts?: Function,
|
||||
* uPlotImpl?: Function,
|
||||
* }} options Optional overrides for testing.
|
||||
* @returns {Promise<boolean>} ``true`` when charts were rendered successfully.
|
||||
*/
|
||||
export async function initializeChartsPage(options = {}) {
|
||||
const documentRef = options.document ?? globalThis.document;
|
||||
if (!documentRef || typeof documentRef.getElementById !== 'function') {
|
||||
@@ -204,7 +219,8 @@ export async function initializeChartsPage(options = {}) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const renderCharts = typeof options.renderCharts === 'function' ? options.renderCharts : renderTelemetryCharts;
|
||||
const createCharts = typeof options.createCharts === 'function' ? options.createCharts : createTelemetryCharts;
|
||||
const mountCharts = typeof options.mountCharts === 'function' ? options.mountCharts : mountTelemetryChartsWithRetry;
|
||||
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
||||
const bucketSeconds = options.bucketSeconds ?? TELEMETRY_BUCKET_SECONDS;
|
||||
const windowMs = options.windowMs ?? CHART_WINDOW_MS;
|
||||
@@ -218,7 +234,7 @@ export async function initializeChartsPage(options = {}) {
|
||||
return true;
|
||||
}
|
||||
const node = { rawSources: { telemetry: { snapshots } } };
|
||||
const chartsHtml = renderCharts(node, {
|
||||
const chartState = createCharts(node, {
|
||||
nowMs: Date.now(),
|
||||
chartOptions: {
|
||||
windowMs,
|
||||
@@ -228,11 +244,12 @@ export async function initializeChartsPage(options = {}) {
|
||||
lineReducer: points => buildMovingAverageSeries(points, HOUR_MS),
|
||||
},
|
||||
});
|
||||
if (!chartsHtml) {
|
||||
if (!chartState.chartsHtml) {
|
||||
container.innerHTML = renderStatus('Telemetry snapshots are unavailable.');
|
||||
return true;
|
||||
}
|
||||
container.innerHTML = chartsHtml;
|
||||
container.innerHTML = chartState.chartsHtml;
|
||||
mountCharts(chartState.chartModels, { root: container, uPlotImpl: options.uPlotImpl });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to render aggregated telemetry charts', error);
|
||||
|
||||
@@ -18,6 +18,7 @@ import { computeBoundingBox, computeBoundsForPoints, haversineDistanceKm } from
|
||||
import { createMapAutoFitController } from './map-auto-fit-controller.js';
|
||||
import { resolveAutoFitBoundsConfig } from './map-auto-fit-settings.js';
|
||||
import { attachNodeInfoRefreshToMarker, overlayToPopupNode } from './map-marker-node-info.js';
|
||||
import { resolveLegendVisibility } from './map-legend-visibility.js';
|
||||
import { createMapFocusHandler, DEFAULT_NODE_FOCUS_ZOOM } from './nodes-map-focus.js';
|
||||
import { enhanceCoordinateCell } from './nodes-coordinate-links.js';
|
||||
import { createShortInfoOverlayStack } from './short-info-overlay-manager.js';
|
||||
@@ -116,6 +117,7 @@ export function initializeApp(config) {
|
||||
: false;
|
||||
const isDashboardView = bodyClassList ? bodyClassList.contains('view-dashboard') : false;
|
||||
const isChatView = bodyClassList ? bodyClassList.contains('view-chat') : false;
|
||||
const isMapView = bodyClassList ? bodyClassList.contains('view-map') : false;
|
||||
const mapZoomOverride = Number.isFinite(config.mapZoom) ? Number(config.mapZoom) : null;
|
||||
/**
|
||||
* Column sorter configuration for the node table.
|
||||
@@ -435,6 +437,7 @@ export function initializeApp(config) {
|
||||
const mapFullscreenToggle = document.getElementById('mapFullscreenToggle');
|
||||
const fullscreenContainer = mapPanel || mapContainer;
|
||||
const isFederationView = bodyClassList ? bodyClassList.contains('view-federation') : false;
|
||||
const legendDefaultCollapsed = mapPanel ? mapPanel.dataset.legendCollapsed === 'true' : false;
|
||||
let mapStatusEl = null;
|
||||
let map = null;
|
||||
let mapCenterLatLng = null;
|
||||
@@ -1526,8 +1529,14 @@ export function initializeApp(config) {
|
||||
legendToggleControl.addTo(map);
|
||||
|
||||
const legendMediaQuery = window.matchMedia('(max-width: 1024px)');
|
||||
setLegendVisibility(!legendMediaQuery.matches);
|
||||
const initialLegendVisible = resolveLegendVisibility({
|
||||
defaultCollapsed: legendDefaultCollapsed,
|
||||
mediaQueryMatches: legendMediaQuery.matches,
|
||||
viewMode: isDashboardView ? 'dashboard' : (isMapView ? 'map' : undefined)
|
||||
});
|
||||
setLegendVisibility(initialLegendVisible);
|
||||
legendMediaQuery.addEventListener('change', event => {
|
||||
if (legendDefaultCollapsed || isDashboardView || isMapView) return;
|
||||
setLegendVisibility(!event.matches);
|
||||
});
|
||||
} else if (mapContainer && !hasLeaflet) {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright © 2025-26 l5yth & contributors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Resolve the initial visibility of the map legend.
|
||||
*
|
||||
* @param {{ defaultCollapsed: boolean, mediaQueryMatches: boolean, viewMode?: string }} options
|
||||
* @returns {boolean} True when the legend should be visible.
|
||||
*/
|
||||
export function resolveLegendVisibility({ defaultCollapsed, mediaQueryMatches, viewMode }) {
|
||||
if (defaultCollapsed || viewMode === 'dashboard' || viewMode === 'map') return false;
|
||||
return !mediaQueryMatches;
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { fetchNodeDetailHtml } from './node-page.js';
|
||||
import { fetchNodeDetailHtml, mountTelemetryChartsWithRetry } from './node-page.js';
|
||||
|
||||
/**
|
||||
* Escape a string for safe HTML injection.
|
||||
@@ -68,6 +68,9 @@ function hasValidReference(reference) {
|
||||
* fetchImpl?: Function,
|
||||
* refreshImpl?: Function,
|
||||
* renderShortHtml?: Function,
|
||||
* mountCharts?: Function,
|
||||
* uPlotImpl?: Function,
|
||||
* loadUPlot?: Function,
|
||||
* privateMode?: boolean,
|
||||
* logger?: Console
|
||||
* }} [options] Behaviour overrides.
|
||||
@@ -101,6 +104,9 @@ export function createNodeDetailOverlayManager(options = {}) {
|
||||
const fetchImpl = options.fetchImpl;
|
||||
const refreshImpl = options.refreshImpl;
|
||||
const renderShortHtml = options.renderShortHtml;
|
||||
const mountCharts = typeof options.mountCharts === 'function' ? options.mountCharts : mountTelemetryChartsWithRetry;
|
||||
const uPlotImpl = options.uPlotImpl;
|
||||
const loadUPlot = options.loadUPlot;
|
||||
|
||||
let requestToken = 0;
|
||||
let lastTrigger = null;
|
||||
@@ -198,16 +204,21 @@ export function createNodeDetailOverlayManager(options = {}) {
|
||||
}
|
||||
const currentToken = ++requestToken;
|
||||
try {
|
||||
const html = await fetchDetail(reference, {
|
||||
const result = await fetchDetail(reference, {
|
||||
fetchImpl,
|
||||
refreshImpl,
|
||||
renderShortHtml,
|
||||
privateMode,
|
||||
returnState: true,
|
||||
});
|
||||
if (currentToken !== requestToken) {
|
||||
return;
|
||||
}
|
||||
content.innerHTML = html;
|
||||
const resolvedHtml = typeof result === 'string' ? result : result?.html;
|
||||
content.innerHTML = resolvedHtml ?? '';
|
||||
if (result && typeof result === 'object' && Array.isArray(result.chartModels)) {
|
||||
mountCharts(result.chartModels, { root: content, uPlotImpl, loadUPlot });
|
||||
}
|
||||
if (typeof closeButton.focus === 'function') {
|
||||
closeButton.focus();
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
|
||||
max: 6,
|
||||
ticks: 3,
|
||||
color: '#9ebcda',
|
||||
allowUpperOverflow: true,
|
||||
},
|
||||
{
|
||||
id: 'current',
|
||||
@@ -77,6 +78,7 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
|
||||
max: 3,
|
||||
ticks: 3,
|
||||
color: '#3182bd',
|
||||
allowUpperOverflow: true,
|
||||
},
|
||||
],
|
||||
series: [
|
||||
@@ -122,6 +124,15 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
|
||||
ticks: 4,
|
||||
color: '#2ca25f',
|
||||
},
|
||||
{
|
||||
id: 'channelSecondary',
|
||||
position: 'right',
|
||||
label: 'Utilization (%)',
|
||||
min: 0,
|
||||
max: 100,
|
||||
ticks: 4,
|
||||
color: '#2ca25f',
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
@@ -135,7 +146,7 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
|
||||
},
|
||||
{
|
||||
id: 'air',
|
||||
axis: 'channel',
|
||||
axis: 'channelSecondary',
|
||||
color: '#99d8c9',
|
||||
label: 'Air util tx',
|
||||
legend: 'Air util TX (%)',
|
||||
@@ -156,16 +167,17 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
|
||||
max: 40,
|
||||
ticks: 4,
|
||||
color: '#fc8d59',
|
||||
allowUpperOverflow: true,
|
||||
},
|
||||
{
|
||||
id: 'humidity',
|
||||
position: 'left',
|
||||
position: 'right',
|
||||
label: 'Humidity (%)',
|
||||
min: 0,
|
||||
max: 100,
|
||||
ticks: 4,
|
||||
color: '#91bfdb',
|
||||
visible: false,
|
||||
visible: true,
|
||||
},
|
||||
],
|
||||
series: [
|
||||
@@ -220,6 +232,7 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
|
||||
max: 500,
|
||||
ticks: 5,
|
||||
color: '#636363',
|
||||
allowUpperOverflow: true,
|
||||
},
|
||||
],
|
||||
series: [
|
||||
@@ -853,67 +866,6 @@ function createChartDimensions(spec) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the horizontal drawing position for an axis descriptor.
|
||||
*
|
||||
* @param {string} position Axis position keyword.
|
||||
* @param {Object} dims Chart dimensions.
|
||||
* @returns {number} X coordinate for the axis baseline.
|
||||
*/
|
||||
function resolveAxisX(position, dims) {
|
||||
switch (position) {
|
||||
case 'leftSecondary':
|
||||
return dims.margin.left - 32;
|
||||
case 'right':
|
||||
return dims.width - dims.margin.right;
|
||||
case 'rightSecondary':
|
||||
return dims.width - dims.margin.right + 32;
|
||||
case 'left':
|
||||
default:
|
||||
return dims.margin.left;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the X coordinate for a timestamp constrained to the rolling window.
|
||||
*
|
||||
* @param {number} timestamp Timestamp in milliseconds.
|
||||
* @param {number} domainStart Start of the window in milliseconds.
|
||||
* @param {number} domainEnd End of the window in milliseconds.
|
||||
* @param {Object} dims Chart dimensions.
|
||||
* @returns {number} X coordinate inside the SVG viewport.
|
||||
*/
|
||||
function scaleTimestamp(timestamp, domainStart, domainEnd, dims) {
|
||||
const safeStart = Math.min(domainStart, domainEnd);
|
||||
const safeEnd = Math.max(domainStart, domainEnd);
|
||||
const span = Math.max(1, safeEnd - safeStart);
|
||||
const clamped = clamp(timestamp, safeStart, safeEnd);
|
||||
const ratio = (clamped - safeStart) / span;
|
||||
return dims.margin.left + ratio * dims.innerWidth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a value bound to a specific axis into a Y coordinate.
|
||||
*
|
||||
* @param {number} value Series value.
|
||||
* @param {Object} axis Axis descriptor.
|
||||
* @param {Object} dims Chart dimensions.
|
||||
* @returns {number} Y coordinate.
|
||||
*/
|
||||
function scaleValueToAxis(value, axis, dims) {
|
||||
if (!axis) return dims.chartBottom;
|
||||
if (axis.scale === 'log') {
|
||||
const minLog = Math.log10(axis.min);
|
||||
const maxLog = Math.log10(axis.max);
|
||||
const safe = clamp(value, axis.min, axis.max);
|
||||
const ratio = (Math.log10(safe) - minLog) / (maxLog - minLog);
|
||||
return dims.chartBottom - ratio * dims.innerHeight;
|
||||
}
|
||||
const safe = clamp(value, axis.min, axis.max);
|
||||
const ratio = (safe - axis.min) / (axis.max - axis.min || 1);
|
||||
return dims.chartBottom - ratio * dims.innerHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect candidate containers that may hold telemetry values for a snapshot.
|
||||
*
|
||||
@@ -1005,161 +957,98 @@ function buildSeriesPoints(entries, fields, domainStart, domainEnd) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a telemetry series as circles plus an optional translucent guide line.
|
||||
*
|
||||
* @param {Object} seriesConfig Series metadata.
|
||||
* @param {Array<{timestamp: number, value: number}>} points Series points.
|
||||
* @param {Object} axis Axis descriptor.
|
||||
* @param {Object} dims Chart dimensions.
|
||||
* @param {number} domainStart Window start timestamp.
|
||||
* @param {number} domainEnd Window end timestamp.
|
||||
* @returns {string} SVG markup for the series.
|
||||
*/
|
||||
function renderTelemetrySeries(seriesConfig, points, axis, dims, domainStart, domainEnd, { lineReducer } = {}) {
|
||||
if (!Array.isArray(points) || points.length === 0) {
|
||||
return '';
|
||||
}
|
||||
const convertPoint = point => {
|
||||
const cx = scaleTimestamp(point.timestamp, domainStart, domainEnd, dims);
|
||||
const cy = scaleValueToAxis(point.value, axis, dims);
|
||||
return { cx, cy, value: point.value };
|
||||
};
|
||||
const circleEntries = points.map(point => {
|
||||
const coords = convertPoint(point);
|
||||
const tooltip = formatSeriesPointValue(seriesConfig, point.value);
|
||||
const titleMarkup = tooltip ? `<title>${escapeHtml(tooltip)}</title>` : '';
|
||||
return `<circle class="node-detail__chart-point" cx="${coords.cx.toFixed(2)}" cy="${coords.cy.toFixed(2)}" r="3.2" fill="${seriesConfig.color}" aria-hidden="true">${titleMarkup}</circle>`;
|
||||
});
|
||||
const lineSource = typeof lineReducer === 'function' ? lineReducer(points) : points;
|
||||
const linePoints = Array.isArray(lineSource) && lineSource.length > 0 ? lineSource : points;
|
||||
const coordinates = linePoints.map(convertPoint);
|
||||
let line = '';
|
||||
if (coordinates.length > 1) {
|
||||
const path = coordinates
|
||||
.map((coord, idx) => `${idx === 0 ? 'M' : 'L'}${coord.cx.toFixed(2)} ${coord.cy.toFixed(2)}`)
|
||||
.join(' ');
|
||||
line = `<path class="node-detail__chart-trend" d="${path}" fill="none" stroke="${hexToRgba(seriesConfig.color, 0.5)}" stroke-width="1.5" aria-hidden="true"></path>`;
|
||||
}
|
||||
return `${line}${circleEntries.join('')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a vertical axis when visible.
|
||||
* Resolve the effective axis maximum when upper overflow is allowed.
|
||||
*
|
||||
* @param {Object} axis Axis descriptor.
|
||||
* @param {Object} dims Chart dimensions.
|
||||
* @returns {string} SVG markup for the axis or an empty string.
|
||||
* @param {Array<{axisId: string, points: Array<{timestamp: number, value: number}>}>} seriesEntries Series entries.
|
||||
* @returns {number} Effective axis max.
|
||||
*/
|
||||
function renderYAxis(axis, dims) {
|
||||
if (!axis || axis.visible === false) {
|
||||
return '';
|
||||
function resolveAxisMax(axis, seriesEntries) {
|
||||
if (!axis || axis.allowUpperOverflow !== true) {
|
||||
return axis?.max;
|
||||
}
|
||||
const x = resolveAxisX(axis.position, dims);
|
||||
const ticks = axis.scale === 'log'
|
||||
? buildLogTicks(axis.min, axis.max)
|
||||
: buildLinearTicks(axis.min, axis.max, axis.ticks);
|
||||
const tickElements = ticks
|
||||
.map(value => {
|
||||
const y = scaleValueToAxis(value, axis, dims);
|
||||
const tickLength = axis.position === 'left' || axis.position === 'leftSecondary' ? -4 : 4;
|
||||
const textAnchor = axis.position === 'left' || axis.position === 'leftSecondary' ? 'end' : 'start';
|
||||
const textOffset = axis.position === 'left' || axis.position === 'leftSecondary' ? -6 : 6;
|
||||
return `
|
||||
<g class="node-detail__chart-tick" aria-hidden="true">
|
||||
<line x1="${x}" y1="${y.toFixed(2)}" x2="${(x + tickLength).toFixed(2)}" y2="${y.toFixed(2)}"></line>
|
||||
<text x="${(x + textOffset).toFixed(2)}" y="${(y + 3).toFixed(2)}" text-anchor="${textAnchor}" dominant-baseline="middle">${escapeHtml(formatAxisTick(value, axis))}</text>
|
||||
</g>
|
||||
`;
|
||||
})
|
||||
.join('');
|
||||
const labelPadding = axis.position === 'left' || axis.position === 'leftSecondary' ? -56 : 56;
|
||||
const labelX = x + labelPadding;
|
||||
const labelY = (dims.chartTop + dims.chartBottom) / 2;
|
||||
const labelTransform = `rotate(-90 ${labelX.toFixed(2)} ${labelY.toFixed(2)})`;
|
||||
return `
|
||||
<g class="node-detail__chart-axis node-detail__chart-axis--y" aria-hidden="true">
|
||||
<line x1="${x}" y1="${dims.chartTop}" x2="${x}" y2="${dims.chartBottom}"></line>
|
||||
${tickElements}
|
||||
<text class="node-detail__chart-axis-label" x="${labelX.toFixed(2)}" y="${labelY.toFixed(2)}" text-anchor="middle" dominant-baseline="middle" transform="${labelTransform}">${escapeHtml(axis.label)}</text>
|
||||
</g>
|
||||
`;
|
||||
let observedMax = null;
|
||||
for (const entry of seriesEntries) {
|
||||
if (!entry || entry.axisId !== axis.id || !Array.isArray(entry.points)) continue;
|
||||
for (const point of entry.points) {
|
||||
if (!point || !Number.isFinite(point.value)) continue;
|
||||
observedMax = observedMax == null ? point.value : Math.max(observedMax, point.value);
|
||||
}
|
||||
}
|
||||
if (observedMax != null && Number.isFinite(axis.max) && observedMax > axis.max) {
|
||||
return observedMax;
|
||||
}
|
||||
return axis.max;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the horizontal floating seven-day axis with midnight ticks.
|
||||
*
|
||||
* @param {Object} dims Chart dimensions.
|
||||
* @param {number} domainStart Window start timestamp.
|
||||
* @param {number} domainEnd Window end timestamp.
|
||||
* @param {Array<number>} tickTimestamps Midnight tick timestamps.
|
||||
* @returns {string} SVG markup for the X axis.
|
||||
*/
|
||||
function renderXAxis(dims, domainStart, domainEnd, tickTimestamps, { labelFormatter = formatCompactDate } = {}) {
|
||||
const y = dims.chartBottom;
|
||||
const ticks = tickTimestamps
|
||||
.map(ts => {
|
||||
const x = scaleTimestamp(ts, domainStart, domainEnd, dims);
|
||||
const labelY = y + 18;
|
||||
const xStr = x.toFixed(2);
|
||||
const yStr = labelY.toFixed(2);
|
||||
const label = labelFormatter(ts);
|
||||
return `
|
||||
<g class="node-detail__chart-tick" aria-hidden="true">
|
||||
<line class="node-detail__chart-grid-line" x1="${xStr}" y1="${dims.chartTop}" x2="${xStr}" y2="${dims.chartBottom}"></line>
|
||||
<text x="${xStr}" y="${yStr}" text-anchor="end" dominant-baseline="central" transform="rotate(-90 ${xStr} ${yStr})">${escapeHtml(label)}</text>
|
||||
</g>
|
||||
`;
|
||||
})
|
||||
.join('');
|
||||
return `
|
||||
<g class="node-detail__chart-axis node-detail__chart-axis--x" aria-hidden="true">
|
||||
<line x1="${dims.margin.left}" y1="${y}" x2="${dims.width - dims.margin.right}" y2="${y}"></line>
|
||||
${ticks}
|
||||
</g>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single telemetry chart defined by ``spec``.
|
||||
* Build a telemetry chart model from a specification and series entries.
|
||||
*
|
||||
* @param {Object} spec Chart specification.
|
||||
* @param {Array<{timestamp: number, snapshot: Object}>} entries Telemetry entries.
|
||||
* @param {number} nowMs Reference timestamp.
|
||||
* @returns {string} Rendered chart markup or an empty string.
|
||||
* @param {Object} chartOptions Rendering overrides.
|
||||
* @returns {Object|null} Chart model or ``null`` when empty.
|
||||
*/
|
||||
function renderTelemetryChart(spec, entries, nowMs, chartOptions = {}) {
|
||||
function buildTelemetryChartModel(spec, entries, nowMs, chartOptions = {}) {
|
||||
const windowMs = Number.isFinite(chartOptions.windowMs) && chartOptions.windowMs > 0 ? chartOptions.windowMs : TELEMETRY_WINDOW_MS;
|
||||
const timeRangeLabel = stringOrNull(chartOptions.timeRangeLabel) ?? 'Last 7 days';
|
||||
const domainEnd = nowMs;
|
||||
const domainStart = nowMs - windowMs;
|
||||
const dims = createChartDimensions(spec);
|
||||
const axisMap = new Map(spec.axes.map(axis => [axis.id, axis]));
|
||||
const seriesEntries = spec.series
|
||||
.map(series => {
|
||||
const axis = axisMap.get(series.axis);
|
||||
if (!axis) return null;
|
||||
const points = buildSeriesPoints(entries, series.fields, domainStart, domainEnd);
|
||||
if (points.length === 0) return null;
|
||||
return { config: series, axis, points };
|
||||
return { config: series, axisId: series.axis, points };
|
||||
})
|
||||
.filter(entry => entry != null);
|
||||
if (seriesEntries.length === 0) {
|
||||
return '';
|
||||
return null;
|
||||
}
|
||||
const adjustedAxes = spec.axes.map(axis => {
|
||||
const resolvedMax = resolveAxisMax(axis, seriesEntries);
|
||||
if (resolvedMax != null && resolvedMax !== axis.max) {
|
||||
return { ...axis, max: resolvedMax };
|
||||
}
|
||||
return axis;
|
||||
});
|
||||
const axisMap = new Map(adjustedAxes.map(axis => [axis.id, axis]));
|
||||
const plottedSeries = seriesEntries
|
||||
.map(series => {
|
||||
const axis = axisMap.get(series.axisId);
|
||||
if (!axis) return null;
|
||||
return { config: series.config, axis, points: series.points };
|
||||
})
|
||||
.filter(entry => entry != null);
|
||||
if (plottedSeries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const axesMarkup = spec.axes.map(axis => renderYAxis(axis, dims)).join('');
|
||||
const tickBuilder = typeof chartOptions.xAxisTickBuilder === 'function' ? chartOptions.xAxisTickBuilder : buildMidnightTicks;
|
||||
const tickFormatter = typeof chartOptions.xAxisTickFormatter === 'function' ? chartOptions.xAxisTickFormatter : formatCompactDate;
|
||||
const ticks = tickBuilder(nowMs, windowMs);
|
||||
const xAxisMarkup = renderXAxis(dims, domainStart, domainEnd, ticks, { labelFormatter: tickFormatter });
|
||||
return {
|
||||
id: spec.id,
|
||||
title: spec.title,
|
||||
timeRangeLabel,
|
||||
domainStart,
|
||||
domainEnd,
|
||||
dims,
|
||||
axes: adjustedAxes,
|
||||
seriesEntries: plottedSeries,
|
||||
ticks: tickBuilder(nowMs, windowMs),
|
||||
tickFormatter,
|
||||
lineReducer: typeof chartOptions.lineReducer === 'function' ? chartOptions.lineReducer : null,
|
||||
};
|
||||
}
|
||||
|
||||
const seriesMarkup = seriesEntries
|
||||
.map(series =>
|
||||
renderTelemetrySeries(series.config, series.points, series.axis, dims, domainStart, domainEnd, {
|
||||
lineReducer: chartOptions.lineReducer,
|
||||
}),
|
||||
)
|
||||
.join('');
|
||||
const legendItems = seriesEntries
|
||||
/**
|
||||
* Render a telemetry chart container for a chart model.
|
||||
*
|
||||
* @param {Object} model Chart model.
|
||||
* @returns {string} Chart markup.
|
||||
*/
|
||||
function renderTelemetryChartMarkup(model) {
|
||||
const legendItems = model.seriesEntries
|
||||
.map(series => {
|
||||
const legendLabel = stringOrNull(series.config.legend) ?? series.config.label;
|
||||
return `
|
||||
@@ -1173,22 +1062,428 @@ function renderTelemetryChart(spec, entries, nowMs, chartOptions = {}) {
|
||||
const legendMarkup = legendItems
|
||||
? `<div class="node-detail__chart-legend" aria-hidden="true">${legendItems}</div>`
|
||||
: '';
|
||||
const ariaLabel = `${model.title} over last seven days`;
|
||||
return `
|
||||
<figure class="node-detail__chart">
|
||||
<figure class="node-detail__chart" data-telemetry-chart-id="${escapeHtml(model.id)}">
|
||||
<figcaption class="node-detail__chart-header">
|
||||
<h4>${escapeHtml(spec.title)}</h4>
|
||||
<span>${escapeHtml(timeRangeLabel)}</span>
|
||||
<h4>${escapeHtml(model.title)}</h4>
|
||||
<span>${escapeHtml(model.timeRangeLabel)}</span>
|
||||
</figcaption>
|
||||
<svg viewBox="0 0 ${dims.width} ${dims.height}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="${escapeHtml(`${spec.title} over last seven days`)}">
|
||||
${axesMarkup}
|
||||
${xAxisMarkup}
|
||||
${seriesMarkup}
|
||||
</svg>
|
||||
<div class="node-detail__chart-plot" data-telemetry-plot role="img" aria-label="${escapeHtml(ariaLabel)}"></div>
|
||||
${legendMarkup}
|
||||
</figure>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a sorted timestamp index shared across series entries.
|
||||
*
|
||||
* @param {Array<Object>} seriesEntries Plotted series entries.
|
||||
* @param {Function|null} lineReducer Optional line reducer.
|
||||
* @returns {{timestamps: Array<number>, indexByTimestamp: Map<number, number>}} Timestamp index.
|
||||
*/
|
||||
function buildChartTimestampIndex(seriesEntries, lineReducer) {
|
||||
const timestampSet = new Set();
|
||||
for (const entry of seriesEntries) {
|
||||
if (!entry || !Array.isArray(entry.points)) continue;
|
||||
entry.points.forEach(point => {
|
||||
if (point && Number.isFinite(point.timestamp)) {
|
||||
timestampSet.add(point.timestamp);
|
||||
}
|
||||
});
|
||||
if (typeof lineReducer === 'function') {
|
||||
const reduced = lineReducer(entry.points);
|
||||
if (Array.isArray(reduced)) {
|
||||
reduced.forEach(point => {
|
||||
if (point && Number.isFinite(point.timestamp)) {
|
||||
timestampSet.add(point.timestamp);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const timestamps = Array.from(timestampSet).sort((a, b) => a - b);
|
||||
const indexByTimestamp = new Map(timestamps.map((ts, idx) => [ts, idx]));
|
||||
return { timestamps, indexByTimestamp };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a list of points into an aligned values array.
|
||||
*
|
||||
* @param {Array<{timestamp: number, value: number}>} points Series points.
|
||||
* @param {Map<number, number>} indexByTimestamp Timestamp index.
|
||||
* @param {number} length Length of the output array.
|
||||
* @returns {Array<number|null>} Values aligned to timestamps.
|
||||
*/
|
||||
function mapSeriesValues(points, indexByTimestamp, length) {
|
||||
const values = Array.from({ length }, () => null);
|
||||
if (!Array.isArray(points)) {
|
||||
return values;
|
||||
}
|
||||
for (const point of points) {
|
||||
if (!point || !Number.isFinite(point.timestamp)) continue;
|
||||
const idx = indexByTimestamp.get(point.timestamp);
|
||||
if (idx == null) continue;
|
||||
values[idx] = Number.isFinite(point.value) ? point.value : null;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build uPlot series and data arrays for a chart model.
|
||||
*
|
||||
* @param {Object} model Chart model.
|
||||
* @returns {{data: Array<Array<number|null>>, series: Array<Object>}} uPlot data and series config.
|
||||
*/
|
||||
function buildTelemetryChartData(model) {
|
||||
const { timestamps, indexByTimestamp } = buildChartTimestampIndex(model.seriesEntries, model.lineReducer);
|
||||
const data = [timestamps];
|
||||
const series = [{ label: 'Time' }];
|
||||
|
||||
model.seriesEntries.forEach(entry => {
|
||||
const baseConfig = {
|
||||
label: entry.config.label,
|
||||
scale: entry.axis.id,
|
||||
};
|
||||
if (model.lineReducer) {
|
||||
const reducedPoints = model.lineReducer(entry.points);
|
||||
const linePoints = Array.isArray(reducedPoints) && reducedPoints.length > 0 ? reducedPoints : entry.points;
|
||||
const lineValues = mapSeriesValues(linePoints, indexByTimestamp, timestamps.length);
|
||||
series.push({
|
||||
...baseConfig,
|
||||
stroke: hexToRgba(entry.config.color, 0.5),
|
||||
width: 1.5,
|
||||
points: { show: false },
|
||||
});
|
||||
data.push(lineValues);
|
||||
|
||||
const pointValues = mapSeriesValues(entry.points, indexByTimestamp, timestamps.length);
|
||||
series.push({
|
||||
...baseConfig,
|
||||
stroke: entry.config.color,
|
||||
width: 0,
|
||||
points: { show: true, size: 6, width: 1 },
|
||||
});
|
||||
data.push(pointValues);
|
||||
} else {
|
||||
const values = mapSeriesValues(entry.points, indexByTimestamp, timestamps.length);
|
||||
series.push({
|
||||
...baseConfig,
|
||||
stroke: entry.config.color,
|
||||
width: 1.5,
|
||||
points: { show: true, size: 6, width: 1 },
|
||||
});
|
||||
data.push(values);
|
||||
}
|
||||
});
|
||||
|
||||
return { data, series };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build uPlot chart configuration and data for a telemetry chart.
|
||||
*
|
||||
* @param {Object} model Chart model.
|
||||
* @returns {{options: Object, data: Array<Array<number|null>>}} uPlot config and data.
|
||||
*/
|
||||
function buildUPlotChartConfig(model, { width, height, axisColor, gridColor } = {}) {
|
||||
const { data, series } = buildTelemetryChartData(model);
|
||||
const fallbackWidth = Math.round(model.dims.width * 1.8);
|
||||
const resolvedWidth = Number.isFinite(width) && width > 0 ? width : fallbackWidth;
|
||||
const resolvedHeight = Number.isFinite(height) && height > 0 ? height : model.dims.height;
|
||||
const axisStroke = stringOrNull(axisColor) ?? '#5c6773';
|
||||
const gridStroke = stringOrNull(gridColor) ?? 'rgba(12, 15, 18, 0.08)';
|
||||
const axes = [
|
||||
{
|
||||
scale: 'x',
|
||||
side: 2,
|
||||
stroke: axisStroke,
|
||||
grid: { show: true, stroke: gridStroke },
|
||||
splits: () => model.ticks,
|
||||
values: (u, splits) => splits.map(value => model.tickFormatter(value)),
|
||||
},
|
||||
];
|
||||
const scales = {
|
||||
x: {
|
||||
time: true,
|
||||
range: () => [model.domainStart, model.domainEnd],
|
||||
},
|
||||
};
|
||||
|
||||
model.axes.forEach(axis => {
|
||||
const ticks = axis.scale === 'log'
|
||||
? buildLogTicks(axis.min, axis.max)
|
||||
: buildLinearTicks(axis.min, axis.max, axis.ticks);
|
||||
const side = axis.position === 'right' || axis.position === 'rightSecondary' ? 1 : 3;
|
||||
axes.push({
|
||||
scale: axis.id,
|
||||
side,
|
||||
show: axis.visible !== false,
|
||||
stroke: axisStroke,
|
||||
grid: { show: false },
|
||||
label: axis.label,
|
||||
splits: () => ticks,
|
||||
values: (u, splits) => splits.map(value => formatAxisTick(value, axis)),
|
||||
});
|
||||
scales[axis.id] = {
|
||||
distr: axis.scale === 'log' ? 3 : 1,
|
||||
log: axis.scale === 'log' ? 10 : undefined,
|
||||
range: () => [axis.min, axis.max],
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
options: {
|
||||
width: resolvedWidth,
|
||||
height: resolvedHeight,
|
||||
padding: [
|
||||
model.dims.margin.top,
|
||||
model.dims.margin.right,
|
||||
model.dims.margin.bottom,
|
||||
model.dims.margin.left,
|
||||
],
|
||||
legend: { show: false },
|
||||
series,
|
||||
axes,
|
||||
scales,
|
||||
},
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiate uPlot charts for the provided chart models.
|
||||
*
|
||||
* @param {Array<Object>} chartModels Chart models to render.
|
||||
* @param {{root?: ParentNode, uPlotImpl?: Function}} [options] Rendering options.
|
||||
* @returns {Array<Object>} Instantiated uPlot charts.
|
||||
*/
|
||||
export function mountTelemetryCharts(chartModels, { root, uPlotImpl } = {}) {
|
||||
if (!Array.isArray(chartModels) || chartModels.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const host = root ?? globalThis.document;
|
||||
if (!host || typeof host.querySelector !== 'function') {
|
||||
return [];
|
||||
}
|
||||
const uPlotCtor = typeof uPlotImpl === 'function' ? uPlotImpl : globalThis.uPlot;
|
||||
if (typeof uPlotCtor !== 'function') {
|
||||
console.warn('uPlot is unavailable; telemetry charts will not render.');
|
||||
return [];
|
||||
}
|
||||
|
||||
const instances = [];
|
||||
const colorRoot = host?.ownerDocument?.body ?? host?.body ?? globalThis.document?.body ?? null;
|
||||
const axisColor = colorRoot && typeof globalThis.getComputedStyle === 'function'
|
||||
? globalThis.getComputedStyle(colorRoot).getPropertyValue('--muted').trim()
|
||||
: null;
|
||||
const gridColor = colorRoot && typeof globalThis.getComputedStyle === 'function'
|
||||
? globalThis.getComputedStyle(colorRoot).getPropertyValue('--line').trim()
|
||||
: null;
|
||||
chartModels.forEach(model => {
|
||||
const container = host.querySelector(`[data-telemetry-chart-id="${model.id}"]`);
|
||||
if (!container) return;
|
||||
const plotRoot = container.querySelector('[data-telemetry-plot]');
|
||||
if (!plotRoot) return;
|
||||
plotRoot.innerHTML = '';
|
||||
const plotWidth = plotRoot.clientWidth || plotRoot.getBoundingClientRect?.().width;
|
||||
const plotHeight = plotRoot.clientHeight || plotRoot.getBoundingClientRect?.().height;
|
||||
const { options, data } = buildUPlotChartConfig(model, {
|
||||
width: plotWidth ? Math.round(plotWidth) : undefined,
|
||||
height: plotHeight ? Math.round(plotHeight) : undefined,
|
||||
axisColor: axisColor || undefined,
|
||||
gridColor: gridColor || undefined,
|
||||
});
|
||||
const instance = new uPlotCtor(options, data, plotRoot);
|
||||
instance.__potatoMeshRoot = plotRoot;
|
||||
instances.push(instance);
|
||||
});
|
||||
registerTelemetryChartResize(instances);
|
||||
return instances;
|
||||
}
|
||||
|
||||
const telemetryResizeRegistry = new Set();
|
||||
const telemetryResizeObservers = new WeakMap();
|
||||
let telemetryResizeListenerAttached = false;
|
||||
let telemetryResizeDebounceId = null;
|
||||
const TELEMETRY_RESIZE_DEBOUNCE_MS = 120;
|
||||
|
||||
function resizeUPlotInstance(instance) {
|
||||
if (!instance || typeof instance.setSize !== 'function') {
|
||||
return;
|
||||
}
|
||||
const root = instance.__potatoMeshRoot ?? instance.root ?? null;
|
||||
if (!root) return;
|
||||
const rect = typeof root.getBoundingClientRect === 'function' ? root.getBoundingClientRect() : null;
|
||||
const width = Number.isFinite(root.clientWidth) ? root.clientWidth : rect?.width;
|
||||
const height = Number.isFinite(root.clientHeight) ? root.clientHeight : rect?.height;
|
||||
if (!width || !height) return;
|
||||
instance.setSize({ width: Math.round(width), height: Math.round(height) });
|
||||
}
|
||||
|
||||
function registerTelemetryChartResize(instances) {
|
||||
if (!Array.isArray(instances) || instances.length === 0) {
|
||||
return;
|
||||
}
|
||||
const scheduleResize = () => {
|
||||
if (telemetryResizeDebounceId != null) {
|
||||
clearTimeout(telemetryResizeDebounceId);
|
||||
}
|
||||
telemetryResizeDebounceId = setTimeout(() => {
|
||||
telemetryResizeDebounceId = null;
|
||||
telemetryResizeRegistry.forEach(instance => resizeUPlotInstance(instance));
|
||||
}, TELEMETRY_RESIZE_DEBOUNCE_MS);
|
||||
};
|
||||
instances.forEach(instance => {
|
||||
telemetryResizeRegistry.add(instance);
|
||||
resizeUPlotInstance(instance);
|
||||
if (typeof globalThis.ResizeObserver === 'function') {
|
||||
if (telemetryResizeObservers.has(instance)) return;
|
||||
const observer = new globalThis.ResizeObserver(scheduleResize);
|
||||
telemetryResizeObservers.set(instance, observer);
|
||||
const root = instance.__potatoMeshRoot ?? instance.root ?? null;
|
||||
if (root && typeof observer.observe === 'function') {
|
||||
observer.observe(root);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!telemetryResizeListenerAttached && typeof globalThis.addEventListener === 'function') {
|
||||
globalThis.addEventListener('resize', () => {
|
||||
scheduleResize();
|
||||
});
|
||||
telemetryResizeListenerAttached = true;
|
||||
}
|
||||
}
|
||||
|
||||
function defaultLoadUPlot({ documentRef, onLoad }) {
|
||||
if (!documentRef || typeof documentRef.querySelector !== 'function') {
|
||||
return false;
|
||||
}
|
||||
const existing = documentRef.querySelector('script[data-uplot-loader="true"]');
|
||||
if (existing) {
|
||||
if (existing.dataset.loaded === 'true' && typeof onLoad === 'function') {
|
||||
onLoad();
|
||||
} else if (typeof existing.addEventListener === 'function' && typeof onLoad === 'function') {
|
||||
existing.addEventListener('load', onLoad, { once: true });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (typeof documentRef.createElement !== 'function') {
|
||||
return false;
|
||||
}
|
||||
const script = documentRef.createElement('script');
|
||||
script.src = '/assets/vendor/uplot/uPlot.iife.min.js';
|
||||
script.defer = true;
|
||||
script.dataset.uplotLoader = 'true';
|
||||
if (typeof script.addEventListener === 'function') {
|
||||
script.addEventListener('load', () => {
|
||||
script.dataset.loaded = 'true';
|
||||
if (typeof onLoad === 'function') {
|
||||
onLoad();
|
||||
}
|
||||
});
|
||||
}
|
||||
const head = documentRef.head ?? documentRef.body;
|
||||
if (head && typeof head.appendChild === 'function') {
|
||||
head.appendChild(script);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount telemetry charts, retrying briefly if uPlot has not loaded yet.
|
||||
*
|
||||
* @param {Array<Object>} chartModels Chart models to render.
|
||||
* @param {{root?: ParentNode, uPlotImpl?: Function, loadUPlot?: Function}} [options] Rendering options.
|
||||
* @returns {Array<Object>} Instantiated uPlot charts.
|
||||
*/
|
||||
export function mountTelemetryChartsWithRetry(chartModels, { root, uPlotImpl, loadUPlot } = {}) {
|
||||
const instances = mountTelemetryCharts(chartModels, { root, uPlotImpl });
|
||||
if (instances.length > 0 || typeof uPlotImpl === 'function') {
|
||||
return instances;
|
||||
}
|
||||
const host = root ?? globalThis.document;
|
||||
if (!host || typeof host.querySelector !== 'function') {
|
||||
return instances;
|
||||
}
|
||||
let mounted = false;
|
||||
let attempts = 0;
|
||||
const maxAttempts = 10;
|
||||
const retryDelayMs = 50;
|
||||
const retry = () => {
|
||||
if (mounted) return;
|
||||
attempts += 1;
|
||||
const next = mountTelemetryCharts(chartModels, { root, uPlotImpl });
|
||||
if (next.length > 0) {
|
||||
mounted = true;
|
||||
return;
|
||||
}
|
||||
if (attempts >= maxAttempts) {
|
||||
return;
|
||||
}
|
||||
setTimeout(retry, retryDelayMs);
|
||||
};
|
||||
const loadFn = typeof loadUPlot === 'function' ? loadUPlot : defaultLoadUPlot;
|
||||
loadFn({
|
||||
documentRef: host.ownerDocument ?? globalThis.document,
|
||||
onLoad: () => {
|
||||
const next = mountTelemetryCharts(chartModels, { root, uPlotImpl });
|
||||
if (next.length > 0) {
|
||||
mounted = true;
|
||||
}
|
||||
},
|
||||
});
|
||||
setTimeout(retry, 0);
|
||||
return instances;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create chart markup and models for telemetry charts.
|
||||
*
|
||||
* @param {Object} node Normalised node payload.
|
||||
* @param {{ nowMs?: number, chartOptions?: Object }} [options] Rendering options.
|
||||
* @returns {{chartsHtml: string, chartModels: Array<Object>}} Chart markup and models.
|
||||
*/
|
||||
export function createTelemetryCharts(node, { nowMs = Date.now(), chartOptions = {} } = {}) {
|
||||
const telemetrySource = node?.rawSources?.telemetry;
|
||||
const snapshotHistory = Array.isArray(node?.rawSources?.telemetrySnapshots) && node.rawSources.telemetrySnapshots.length > 0
|
||||
? node.rawSources.telemetrySnapshots
|
||||
: null;
|
||||
const aggregatedSnapshots = Array.isArray(telemetrySource?.snapshots)
|
||||
? telemetrySource.snapshots
|
||||
: null;
|
||||
const rawSnapshots = snapshotHistory ?? aggregatedSnapshots;
|
||||
if (!Array.isArray(rawSnapshots) || rawSnapshots.length === 0) {
|
||||
return { chartsHtml: '', chartModels: [] };
|
||||
}
|
||||
const entries = rawSnapshots
|
||||
.map(snapshot => {
|
||||
const timestamp = resolveSnapshotTimestamp(snapshot);
|
||||
if (timestamp == null) return null;
|
||||
return { timestamp, snapshot };
|
||||
})
|
||||
.filter(entry => entry != null && entry.timestamp >= nowMs - TELEMETRY_WINDOW_MS && entry.timestamp <= nowMs)
|
||||
.sort((a, b) => a.timestamp - b.timestamp);
|
||||
if (entries.length === 0) {
|
||||
return { chartsHtml: '', chartModels: [] };
|
||||
}
|
||||
const chartModels = TELEMETRY_CHART_SPECS
|
||||
.map(spec => buildTelemetryChartModel(spec, entries, nowMs, chartOptions))
|
||||
.filter(model => model != null);
|
||||
if (chartModels.length === 0) {
|
||||
return { chartsHtml: '', chartModels: [] };
|
||||
}
|
||||
const chartsHtml = `
|
||||
<section class="node-detail__charts">
|
||||
<div class="node-detail__charts-grid">
|
||||
${chartModels.map(model => renderTelemetryChartMarkup(model)).join('')}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
return { chartsHtml, chartModels };
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the telemetry charts for the supplied node when telemetry snapshots
|
||||
* exist.
|
||||
@@ -1198,41 +1493,7 @@ function renderTelemetryChart(spec, entries, nowMs, chartOptions = {}) {
|
||||
* @returns {string} Chart grid markup or an empty string.
|
||||
*/
|
||||
export function renderTelemetryCharts(node, { nowMs = Date.now(), chartOptions = {} } = {}) {
|
||||
const telemetrySource = node?.rawSources?.telemetry;
|
||||
const snapshotHistory = Array.isArray(node?.rawSources?.telemetrySnapshots) && node.rawSources.telemetrySnapshots.length > 0
|
||||
? node.rawSources.telemetrySnapshots
|
||||
: null;
|
||||
const aggregatedSnapshots = Array.isArray(telemetrySource?.snapshots)
|
||||
? telemetrySource.snapshots
|
||||
: null;
|
||||
const rawSnapshots = snapshotHistory ?? aggregatedSnapshots;
|
||||
if (!Array.isArray(rawSnapshots) || rawSnapshots.length === 0) {
|
||||
return '';
|
||||
}
|
||||
const entries = rawSnapshots
|
||||
.map(snapshot => {
|
||||
const timestamp = resolveSnapshotTimestamp(snapshot);
|
||||
if (timestamp == null) return null;
|
||||
return { timestamp, snapshot };
|
||||
})
|
||||
.filter(entry => entry != null && entry.timestamp >= nowMs - TELEMETRY_WINDOW_MS && entry.timestamp <= nowMs)
|
||||
.sort((a, b) => a.timestamp - b.timestamp);
|
||||
if (entries.length === 0) {
|
||||
return '';
|
||||
}
|
||||
const charts = TELEMETRY_CHART_SPECS
|
||||
.map(spec => renderTelemetryChart(spec, entries, nowMs, chartOptions))
|
||||
.filter(chart => stringOrNull(chart));
|
||||
if (charts.length === 0) {
|
||||
return '';
|
||||
}
|
||||
return `
|
||||
<section class="node-detail__charts">
|
||||
<div class="node-detail__charts-grid">
|
||||
${charts.join('')}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
return createTelemetryCharts(node, { nowMs, chartOptions }).chartsHtml;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2254,6 +2515,7 @@ function renderTraceroutes(traces, renderShortHtml, { roleIndex = null, node = n
|
||||
* messages?: Array<Object>,
|
||||
* traces?: Array<Object>,
|
||||
* renderShortHtml: Function,
|
||||
* chartsHtml?: string,
|
||||
* }} options Rendering options.
|
||||
* @returns {string} HTML fragment representing the detail view.
|
||||
*/
|
||||
@@ -2263,6 +2525,7 @@ function renderNodeDetailHtml(node, {
|
||||
traces = [],
|
||||
renderShortHtml,
|
||||
roleIndex = null,
|
||||
chartsHtml = null,
|
||||
chartNowMs = Date.now(),
|
||||
} = {}) {
|
||||
const roleAwareBadge = renderRoleAwareBadge(renderShortHtml, {
|
||||
@@ -2276,7 +2539,7 @@ function renderNodeDetailHtml(node, {
|
||||
const longName = stringOrNull(node.longName ?? node.long_name);
|
||||
const identifier = stringOrNull(node.nodeId ?? node.node_id);
|
||||
const tableHtml = renderSingleNodeTable(node, renderShortHtml);
|
||||
const chartsHtml = renderTelemetryCharts(node, { nowMs: chartNowMs });
|
||||
const telemetryChartsHtml = stringOrNull(chartsHtml) ?? renderTelemetryCharts(node, { nowMs: chartNowMs });
|
||||
const neighborsHtml = renderNeighborGroups(node, neighbors, renderShortHtml, { roleIndex });
|
||||
const tracesHtml = renderTraceroutes(traces, renderShortHtml, { roleIndex, node });
|
||||
const messagesHtml = renderMessages(messages, renderShortHtml, node);
|
||||
@@ -2302,7 +2565,7 @@ function renderNodeDetailHtml(node, {
|
||||
<header class="node-detail__header">
|
||||
<h2 class="node-detail__title">${badgeHtml}${nameHtml}${identifierHtml}</h2>
|
||||
</header>
|
||||
${chartsHtml ?? ''}
|
||||
${telemetryChartsHtml ?? ''}
|
||||
${tableSection}
|
||||
${contentHtml}
|
||||
`;
|
||||
@@ -2416,15 +2679,17 @@ async function fetchTracesForNode(identifier, { fetchImpl } = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialise the node detail page by hydrating the DOM with fetched data.
|
||||
* Fetch node detail data and render the HTML fragment.
|
||||
*
|
||||
* @param {{
|
||||
* document?: Document,
|
||||
* fetchImpl?: Function,
|
||||
* refreshImpl?: Function,
|
||||
* renderShortHtml?: Function,
|
||||
* chartNowMs?: number,
|
||||
* chartOptions?: Object,
|
||||
* }} options Optional overrides for testing.
|
||||
* @returns {Promise<boolean>} ``true`` when the node was rendered successfully.
|
||||
* @returns {Promise<string|{html: string, chartModels: Array<Object>}>} Rendered markup or chart models when requested.
|
||||
*/
|
||||
export async function fetchNodeDetailHtml(referenceData, options = {}) {
|
||||
if (!referenceData || typeof referenceData !== 'object') {
|
||||
@@ -2454,15 +2719,38 @@ export async function fetchNodeDetailHtml(referenceData, options = {}) {
|
||||
fetchTracesForNode(messageIdentifier, { fetchImpl: options.fetchImpl }),
|
||||
]);
|
||||
const roleIndex = await buildTraceRoleIndex(traces, neighborRoleIndex, { fetchImpl: options.fetchImpl });
|
||||
return renderNodeDetailHtml(node, {
|
||||
const chartNowMs = Number.isFinite(options.chartNowMs) ? options.chartNowMs : Date.now();
|
||||
const chartState = createTelemetryCharts(node, {
|
||||
nowMs: chartNowMs,
|
||||
chartOptions: options.chartOptions ?? {},
|
||||
});
|
||||
const html = renderNodeDetailHtml(node, {
|
||||
neighbors: node.neighbors,
|
||||
messages,
|
||||
traces,
|
||||
renderShortHtml,
|
||||
roleIndex,
|
||||
chartsHtml: chartState.chartsHtml,
|
||||
chartNowMs,
|
||||
});
|
||||
if (options.returnState === true) {
|
||||
return { html, chartModels: chartState.chartModels };
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialise the standalone node detail page and mount telemetry charts.
|
||||
*
|
||||
* @param {{
|
||||
* document?: Document,
|
||||
* fetchImpl?: Function,
|
||||
* refreshImpl?: Function,
|
||||
* renderShortHtml?: Function,
|
||||
* uPlotImpl?: Function,
|
||||
* }} options Optional overrides for testing.
|
||||
* @returns {Promise<boolean>} ``true`` when the node was rendered successfully.
|
||||
*/
|
||||
export async function initializeNodeDetailPage(options = {}) {
|
||||
const documentRef = options.document ?? globalThis.document;
|
||||
if (!documentRef || typeof documentRef.querySelector !== 'function') {
|
||||
@@ -2499,13 +2787,15 @@ export async function initializeNodeDetailPage(options = {}) {
|
||||
const privateMode = (root.dataset?.privateMode ?? '').toLowerCase() === 'true';
|
||||
|
||||
try {
|
||||
const html = await fetchNodeDetailHtml(referenceData, {
|
||||
const result = await fetchNodeDetailHtml(referenceData, {
|
||||
fetchImpl: options.fetchImpl,
|
||||
refreshImpl,
|
||||
renderShortHtml: options.renderShortHtml,
|
||||
privateMode,
|
||||
returnState: true,
|
||||
});
|
||||
root.innerHTML = html;
|
||||
root.innerHTML = result.html;
|
||||
mountTelemetryChartsWithRetry(result.chartModels, { root, uPlotImpl: options.uPlotImpl });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to render node detail page', error);
|
||||
@@ -2542,7 +2832,11 @@ export const __testUtils = {
|
||||
categoriseNeighbors,
|
||||
renderNeighborGroups,
|
||||
renderSingleNodeTable,
|
||||
createTelemetryCharts,
|
||||
renderTelemetryCharts,
|
||||
mountTelemetryCharts,
|
||||
mountTelemetryChartsWithRetry,
|
||||
buildUPlotChartConfig,
|
||||
renderMessages,
|
||||
renderTraceroutes,
|
||||
renderTracePath,
|
||||
|
||||
@@ -994,7 +994,7 @@ body.dark .node-detail-overlay__close:hover {
|
||||
.node-detail__charts-grid {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 640px), 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 1152px), 1fr));
|
||||
}
|
||||
|
||||
.node-detail__chart {
|
||||
@@ -1026,10 +1026,45 @@ body.dark .node-detail-overlay__close:hover {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.node-detail__chart svg {
|
||||
.node-detail__chart-plot {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
height: clamp(240px, 50vw, 360px);
|
||||
max-height: 420px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.node-detail__chart-plot .uplot {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.node-detail__chart-plot .uplot .u-wrap,
|
||||
.node-detail__chart-plot .uplot .u-under,
|
||||
.node-detail__chart-plot .uplot .u-over {
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.node-detail__chart-plot .u-axis,
|
||||
.node-detail__chart-plot .u-axis .u-label,
|
||||
.node-detail__chart-plot .u-axis .u-value,
|
||||
.node-detail__chart-plot .u-axis text,
|
||||
.node-detail__chart-plot .u-axis-label {
|
||||
color: var(--muted) !important;
|
||||
fill: var(--muted) !important;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.node-detail__chart-plot .u-grid {
|
||||
stroke: rgba(12, 15, 18, 0.08);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
body.dark .node-detail__chart-plot .u-grid {
|
||||
stroke: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.node-detail__chart-axis line {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.uplot, .uplot *, .uplot *::before, .uplot *::after {box-sizing: border-box;}.uplot {font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";line-height: 1.5;width: min-content;}.u-title {text-align: center;font-size: 18px;font-weight: bold;}.u-wrap {position: relative;user-select: none;}.u-over, .u-under {position: absolute;}.u-under {overflow: hidden;}.uplot canvas {display: block;position: relative;width: 100%;height: 100%;}.u-axis {position: absolute;}.u-legend {font-size: 14px;margin: auto;text-align: center;}.u-inline {display: block;}.u-inline * {display: inline-block;}.u-inline tr {margin-right: 16px;}.u-legend th {font-weight: 600;}.u-legend th > * {vertical-align: middle;display: inline-block;}.u-legend .u-marker {width: 1em;height: 1em;margin-right: 4px;background-clip: padding-box !important;}.u-inline.u-live th::after {content: ":";vertical-align: middle;}.u-inline:not(.u-live) .u-value {display: none;}.u-series > * {padding: 4px;}.u-series th {cursor: pointer;}.u-legend .u-off > * {opacity: 0.3;}.u-select {background: rgba(0,0,0,0.07);position: absolute;pointer-events: none;}.u-cursor-x, .u-cursor-y {position: absolute;left: 0;top: 0;pointer-events: none;will-change: transform;}.u-hz .u-cursor-x, .u-vt .u-cursor-y {height: 100%;border-right: 1px dashed #607D8B;}.u-hz .u-cursor-y, .u-vt .u-cursor-x {width: 100%;border-bottom: 1px dashed #607D8B;}.u-cursor-pt {position: absolute;top: 0;left: 0;border-radius: 50%;border: 0 solid;pointer-events: none;will-change: transform;/*this has to be !important since we set inline "background" shorthand */background-clip: padding-box !important;}.u-axis.u-off, .u-select.u-off, .u-cursor-x.u-off, .u-cursor-y.u-off, .u-cursor-pt.u-off {display: none;}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright © 2025-26 l5yth & contributors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { mkdir, copyFile, access } from 'node:fs/promises';
|
||||
import { constants as fsConstants } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/**
|
||||
* Resolve an absolute path relative to this script location.
|
||||
*
|
||||
* @param {string[]} segments Path segments to append.
|
||||
* @returns {string} Absolute path resolved from this script.
|
||||
*/
|
||||
function resolvePath(...segments) {
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
return path.resolve(scriptDir, ...segments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the uPlot assets are available within the public asset tree.
|
||||
*
|
||||
* @returns {Promise<void>} Resolves once files have been copied.
|
||||
*/
|
||||
async function copyUPlotAssets() {
|
||||
const sourceDir = resolvePath('..', 'node_modules', 'uplot', 'dist');
|
||||
const targetDir = resolvePath('..', 'public', 'assets', 'vendor', 'uplot');
|
||||
const assets = ['uPlot.iife.min.js', 'uPlot.min.css'];
|
||||
|
||||
await access(sourceDir, fsConstants.R_OK);
|
||||
await mkdir(targetDir, { recursive: true });
|
||||
|
||||
await Promise.all(
|
||||
assets.map(async asset => {
|
||||
const source = path.join(sourceDir, asset);
|
||||
const target = path.join(targetDir, asset);
|
||||
await copyFile(source, target);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await copyUPlotAssets();
|
||||
+419
-3
@@ -1080,7 +1080,8 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
targets = application_class.federation_target_domains("self.mesh")
|
||||
|
||||
expect(targets.first).to eq("potatomesh.net")
|
||||
seed_domains = PotatoMesh::Config.federation_seed_domains.map(&:downcase)
|
||||
expect(targets.first(seed_domains.length)).to eq(seed_domains)
|
||||
expect(targets).to include("remote.mesh")
|
||||
expect(targets).not_to include("self.mesh")
|
||||
end
|
||||
@@ -1090,7 +1091,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
targets = application_class.federation_target_domains("self.mesh")
|
||||
|
||||
expect(targets).to eq(["potatomesh.net"])
|
||||
expect(targets).to eq(PotatoMesh::Config.federation_seed_domains.map(&:downcase))
|
||||
end
|
||||
|
||||
it "ignores remote instances that have not updated within a week" do
|
||||
@@ -1118,7 +1119,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
targets = application_class.federation_target_domains("self.mesh")
|
||||
|
||||
expect(targets).to eq(["potatomesh.net"])
|
||||
expect(targets).to eq(PotatoMesh::Config.federation_seed_domains.map(&:downcase))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1607,6 +1608,161 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
end
|
||||
end
|
||||
|
||||
it "accepts registrations when contactLink is part of the signed payload" do
|
||||
contact_link = "https://example.test/contact"
|
||||
linked_attributes = instance_attributes.merge(contact_link: contact_link)
|
||||
linked_signature_payload = canonical_instance_payload(linked_attributes)
|
||||
linked_signature = Base64.strict_encode64(
|
||||
instance_key.sign(OpenSSL::Digest::SHA256.new, linked_signature_payload),
|
||||
)
|
||||
linked_payload = instance_payload.merge(
|
||||
"contactLink" => contact_link,
|
||||
"signature" => linked_signature,
|
||||
)
|
||||
|
||||
post "/api/instances", linked_payload.to_json, { "CONTENT_TYPE" => "application/json" }
|
||||
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(JSON.parse(last_response.body)).to eq("status" => "registered")
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
db.results_as_hash = true
|
||||
row = db.get_first_row(
|
||||
"SELECT contact_link, signature FROM instances WHERE id = ?",
|
||||
[instance_attributes[:id]],
|
||||
)
|
||||
|
||||
expect(row).not_to be_nil
|
||||
expect(row["contact_link"]).to eq(contact_link)
|
||||
expect(row["signature"]).to eq(linked_signature)
|
||||
end
|
||||
end
|
||||
|
||||
it "accepts instance announcement payloads produced by the application including contactLink" do
|
||||
contact_link = "https://example.test/contact"
|
||||
announcement_attributes = instance_attributes.merge(contact_link: contact_link)
|
||||
announcement_signature = Base64.strict_encode64(
|
||||
instance_key.sign(
|
||||
OpenSSL::Digest::SHA256.new,
|
||||
canonical_instance_payload(announcement_attributes),
|
||||
),
|
||||
)
|
||||
announcement_payload = application_class.instance_announcement_payload(
|
||||
announcement_attributes,
|
||||
announcement_signature,
|
||||
)
|
||||
|
||||
post "/api/instances", announcement_payload.to_json, { "CONTENT_TYPE" => "application/json" }
|
||||
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(JSON.parse(last_response.body)).to eq("status" => "registered")
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
db.results_as_hash = true
|
||||
row = db.get_first_row(
|
||||
"SELECT contact_link, signature FROM instances WHERE id = ?",
|
||||
[instance_attributes[:id]],
|
||||
)
|
||||
|
||||
expect(row).not_to be_nil
|
||||
expect(row["contact_link"]).to eq(contact_link)
|
||||
expect(row["signature"]).to eq(announcement_signature)
|
||||
end
|
||||
end
|
||||
|
||||
it "accepts signatures that omit contactLink for backwards compatibility" do
|
||||
contact_link = "https://legacy.example/contact"
|
||||
legacy_signature_payload = canonical_instance_payload(instance_attributes)
|
||||
legacy_signature = Base64.strict_encode64(
|
||||
instance_key.sign(OpenSSL::Digest::SHA256.new, legacy_signature_payload),
|
||||
)
|
||||
legacy_payload = instance_payload.merge(
|
||||
"contactLink" => contact_link,
|
||||
"signature" => legacy_signature,
|
||||
)
|
||||
|
||||
post "/api/instances", legacy_payload.to_json, { "CONTENT_TYPE" => "application/json" }
|
||||
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(JSON.parse(last_response.body)).to eq("status" => "registered")
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
db.results_as_hash = true
|
||||
row = db.get_first_row(
|
||||
"SELECT contact_link, signature FROM instances WHERE id = ?",
|
||||
[instance_attributes[:id]],
|
||||
)
|
||||
|
||||
expect(row).not_to be_nil
|
||||
expect(row["contact_link"]).to eq(contact_link)
|
||||
expect(row["signature"]).to eq(legacy_signature)
|
||||
end
|
||||
end
|
||||
|
||||
it "accepts mixed-case domains when the signature omits contactLink but the payload includes it" do
|
||||
raw_domain = "Mesh.Example"
|
||||
normalized_domain = raw_domain.downcase
|
||||
contact_link = "https://mixed.example/contact"
|
||||
mixed_attributes = instance_attributes.merge(domain: raw_domain)
|
||||
mixed_signature_payload = canonical_instance_payload(mixed_attributes)
|
||||
mixed_signature = Base64.strict_encode64(
|
||||
instance_key.sign(OpenSSL::Digest::SHA256.new, mixed_signature_payload),
|
||||
)
|
||||
mixed_payload = instance_payload.merge(
|
||||
"domain" => raw_domain,
|
||||
"contactLink" => contact_link,
|
||||
"signature" => mixed_signature,
|
||||
)
|
||||
|
||||
mixed_remote_payload = JSON.generate(
|
||||
{
|
||||
"publicKey" => pubkey,
|
||||
"name" => instance_attributes[:name],
|
||||
"version" => instance_attributes[:version],
|
||||
"domain" => normalized_domain,
|
||||
"lastUpdate" => last_update_time,
|
||||
},
|
||||
sort_keys: true,
|
||||
)
|
||||
|
||||
mixed_document = well_known_document.merge(
|
||||
"domain" => normalized_domain,
|
||||
"signedPayload" => Base64.strict_encode64(mixed_remote_payload),
|
||||
"signature" => Base64.strict_encode64(
|
||||
instance_key.sign(OpenSSL::Digest::SHA256.new, mixed_remote_payload),
|
||||
),
|
||||
)
|
||||
|
||||
allow_any_instance_of(Sinatra::Application).to receive(:fetch_instance_json) do |_instance, host, path|
|
||||
case path
|
||||
when "/.well-known/potato-mesh"
|
||||
[mixed_document, URI("https://#{host}#{path}")]
|
||||
when "/api/nodes"
|
||||
[remote_nodes, URI("https://#{host}#{path}")]
|
||||
else
|
||||
[nil, []]
|
||||
end
|
||||
end
|
||||
|
||||
post "/api/instances", mixed_payload.to_json, { "CONTENT_TYPE" => "application/json" }
|
||||
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(JSON.parse(last_response.body)).to eq("status" => "registered")
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
db.results_as_hash = true
|
||||
row = db.get_first_row(
|
||||
"SELECT domain, contact_link, signature FROM instances WHERE id = ?",
|
||||
[mixed_attributes[:id]],
|
||||
)
|
||||
|
||||
expect(row).not_to be_nil
|
||||
expect(row["domain"]).to eq(normalized_domain)
|
||||
expect(row["contact_link"]).to eq(contact_link)
|
||||
expect(row["signature"]).to eq(mixed_signature)
|
||||
end
|
||||
end
|
||||
|
||||
it "rejects registrations with invalid domains" do
|
||||
invalid_payload = instance_payload.merge("domain" => "mesh-instance")
|
||||
|
||||
@@ -2861,6 +3017,69 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
end
|
||||
end
|
||||
|
||||
it "ignores broadcast identifiers when creating placeholders" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
|
||||
payload = {
|
||||
"id" => 10_002,
|
||||
"rx_time" => reference_time.to_i,
|
||||
"from_id" => "!ffffffff",
|
||||
"channel" => 0,
|
||||
"text" => "broadcast",
|
||||
}
|
||||
|
||||
post "/api/messages", payload.to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
with_db(readonly: true) do |db|
|
||||
count = db.get_first_value("SELECT COUNT(*) FROM nodes WHERE node_id = '!ffffffff'")
|
||||
expect(count).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
it "creates hidden nodes for unseen message participants" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
|
||||
payload = {
|
||||
"id" => 10_001,
|
||||
"rx_time" => reference_time.to_i,
|
||||
"from_id" => "!cafef00d",
|
||||
"to_id" => "!deadbeef",
|
||||
"channel" => 0,
|
||||
"portnum" => "TEXT_MESSAGE_APP",
|
||||
"text" => "Spec participant placeholder",
|
||||
}
|
||||
|
||||
post "/api/messages", payload.to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
db.results_as_hash = true
|
||||
rows = db.execute(
|
||||
<<~SQL,
|
||||
SELECT node_id, num, short_name, long_name, role, last_heard, first_heard
|
||||
FROM nodes
|
||||
WHERE node_id IN ("!cafef00d", "!deadbeef")
|
||||
ORDER BY node_id
|
||||
SQL
|
||||
)
|
||||
|
||||
expect(rows.map { |row| row["node_id"] }).to contain_exactly("!cafef00d", "!deadbeef")
|
||||
rows.each do |row|
|
||||
expect(row["num"]).to be_an(Integer)
|
||||
expect(row["role"]).to eq("CLIENT_HIDDEN")
|
||||
expect(row["short_name"]).to eq(row["node_id"][-4, 4].upcase)
|
||||
expect(row["long_name"]).to eq("Meshtastic #{row["short_name"]}")
|
||||
expect(row["last_heard"]).to eq(reference_time.to_i)
|
||||
expect(row["first_heard"]).to eq(reference_time.to_i)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it "returns 400 when the payload is not valid JSON" do
|
||||
post "/api/messages", "{", auth_headers
|
||||
|
||||
@@ -3951,6 +4170,39 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect(payload["node_id"]).to eq("!fresh-node")
|
||||
end
|
||||
|
||||
it "filters node results using the since parameter for collections and single lookups" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
now = reference_time.to_i
|
||||
older_last_heard = now - 120
|
||||
recent_last_heard = now - 30
|
||||
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
|
||||
["!older-node", "old", "Older", "TBEAM", "CLIENT", 0.0, older_last_heard, older_last_heard],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
|
||||
["!recent-node", "new", "Recent", "TBEAM", "CLIENT", 0.0, recent_last_heard, recent_last_heard],
|
||||
)
|
||||
end
|
||||
|
||||
get "/api/nodes?since=#{recent_last_heard}"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
payload = JSON.parse(last_response.body)
|
||||
expect(payload.map { |row| row["node_id"] }).to eq(["!recent-node"])
|
||||
|
||||
get "/api/nodes/!older-node?since=#{recent_last_heard}"
|
||||
expect(last_response.status).to eq(404)
|
||||
|
||||
get "/api/nodes/!recent-node?since=#{recent_last_heard}"
|
||||
expect(last_response).to be_ok
|
||||
detail = JSON.parse(last_response.body)
|
||||
expect(detail["node_id"]).to eq("!recent-node")
|
||||
end
|
||||
|
||||
it "omits blank values from node responses" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
@@ -4312,6 +4564,37 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect(filtered.map { |row| row["id"] }).to eq([2])
|
||||
end
|
||||
|
||||
it "filters positions using the since parameter for both global and node queries" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
now = reference_time.to_i
|
||||
older_rx = now - 180
|
||||
recent_rx = now - 15
|
||||
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO positions(id, node_id, node_num, rx_time, rx_iso, position_time, latitude, longitude) VALUES(?,?,?,?,?,?,?,?)",
|
||||
[10, "!pos-since", 101, older_rx, Time.at(older_rx).utc.iso8601, older_rx - 5, 52.0, 13.0],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO positions(id, node_id, node_num, rx_time, rx_iso, position_time, latitude, longitude) VALUES(?,?,?,?,?,?,?,?)",
|
||||
[11, "!pos-since", 101, recent_rx, Time.at(recent_rx).utc.iso8601, recent_rx - 5, 53.0, 14.0],
|
||||
)
|
||||
end
|
||||
|
||||
get "/api/positions?since=#{recent_rx}"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
payload = JSON.parse(last_response.body)
|
||||
expect(payload.map { |row| row["id"] }).to eq([11])
|
||||
|
||||
get "/api/positions/!pos-since?since=#{recent_rx}"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
filtered = JSON.parse(last_response.body)
|
||||
expect(filtered.map { |row| row["id"] }).to eq([11])
|
||||
end
|
||||
|
||||
it "omits blank values from position responses" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
@@ -4410,6 +4693,49 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect(filtered.first["rx_time"]).to eq(fresh_rx)
|
||||
end
|
||||
|
||||
it "honours the since parameter for neighbor queries" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
now = reference_time.to_i
|
||||
older_rx = now - 300
|
||||
recent_rx = now - 30
|
||||
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
|
||||
["!origin-since", "orig", "Origin", "TBEAM", "CLIENT", 0.0, now, now],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
|
||||
["!neighbor-old", "oldn", "Neighbor Old", "TBEAM", "CLIENT", 0.0, now, now],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
|
||||
["!neighbor-new", "newn", "Neighbor New", "TBEAM", "CLIENT", 0.0, now, now],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO neighbors(node_id, neighbor_id, snr, rx_time) VALUES(?,?,?,?)",
|
||||
["!origin-since", "!neighbor-old", 1.5, older_rx],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO neighbors(node_id, neighbor_id, snr, rx_time) VALUES(?,?,?,?)",
|
||||
["!origin-since", "!neighbor-new", 7.5, recent_rx],
|
||||
)
|
||||
end
|
||||
|
||||
get "/api/neighbors?since=#{recent_rx}"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
payload = JSON.parse(last_response.body)
|
||||
expect(payload.map { |row| row["neighbor_id"] }).to eq(["!neighbor-new"])
|
||||
|
||||
get "/api/neighbors/!origin-since?since=#{recent_rx}"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
filtered = JSON.parse(last_response.body)
|
||||
expect(filtered.map { |row| row["neighbor_id"] }).to eq(["!neighbor-new"])
|
||||
end
|
||||
|
||||
it "omits blank values from neighbor responses" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
@@ -4540,6 +4866,37 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect(filtered.map { |row| row["id"] }).to eq([2])
|
||||
end
|
||||
|
||||
it "filters telemetry rows using the since parameter for both global and node-scoped queries" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
now = reference_time.to_i
|
||||
older_rx = now - 300
|
||||
recent_rx = now - 60
|
||||
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO telemetry(id, node_id, node_num, rx_time, rx_iso, telemetry_time, battery_level, voltage) VALUES(?,?,?,?,?,?,?,?)",
|
||||
[10, "!tele-since", 21, older_rx, Time.at(older_rx).utc.iso8601, older_rx - 5, 20.0, 3.9],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO telemetry(id, node_id, node_num, rx_time, rx_iso, telemetry_time, battery_level, voltage) VALUES(?,?,?,?,?,?,?,?)",
|
||||
[11, "!tele-since", 21, recent_rx, Time.at(recent_rx).utc.iso8601, recent_rx - 5, 80.0, 4.1],
|
||||
)
|
||||
end
|
||||
|
||||
get "/api/telemetry?since=#{recent_rx}"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
payload = JSON.parse(last_response.body)
|
||||
expect(payload.map { |row| row["id"] }).to eq([11])
|
||||
|
||||
get "/api/telemetry/!tele-since?since=#{recent_rx}"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
filtered = JSON.parse(last_response.body)
|
||||
expect(filtered.map { |row| row["id"] }).to eq([11])
|
||||
end
|
||||
|
||||
it "omits blank values from telemetry responses" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
@@ -4703,6 +5060,34 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect(buckets.first["bucket_seconds"]).to eq(PotatoMesh::App::Queries::DEFAULT_TELEMETRY_BUCKET_SECONDS)
|
||||
end
|
||||
|
||||
it "filters aggregated telemetry buckets using the since parameter" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
now = reference_time.to_i
|
||||
older_rx = now - 1800
|
||||
recent_rx = now - 120
|
||||
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO telemetry(id, node_id, rx_time, rx_iso, telemetry_time, battery_level) VALUES(?,?,?,?,?,?)",
|
||||
[801, "!agg-since", older_rx, Time.at(older_rx).utc.iso8601, older_rx - 30, 30.0],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO telemetry(id, node_id, rx_time, rx_iso, telemetry_time, battery_level) VALUES(?,?,?,?,?,?)",
|
||||
[802, "!agg-since", recent_rx, Time.at(recent_rx).utc.iso8601, recent_rx - 30, 80.0],
|
||||
)
|
||||
end
|
||||
|
||||
get "/api/telemetry/aggregated?windowSeconds=3600&bucketSeconds=300&since=#{recent_rx}"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
buckets = JSON.parse(last_response.body)
|
||||
expect(buckets.length).to eq(1)
|
||||
aggregates = buckets.first.fetch("aggregates")
|
||||
expect(aggregates).to have_key("battery_level")
|
||||
expect_same_value(aggregates.dig("battery_level", "avg"), 80.0)
|
||||
end
|
||||
|
||||
it "omits zero-valued battery and voltage aggregates" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
@@ -4870,6 +5255,37 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
ids = JSON.parse(last_response.body).map { |row| row["id"] }
|
||||
expect(ids).to eq([50_001])
|
||||
end
|
||||
|
||||
it "filters traces using the since parameter for collection and scoped requests" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
now = reference_time.to_i
|
||||
older_rx = now - 300
|
||||
recent_rx = now - 25
|
||||
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO traces(id, src, dest, rx_time, rx_iso) VALUES(?,?,?,?,?)",
|
||||
[60_001, 123, 456, older_rx, Time.at(older_rx).utc.iso8601],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO traces(id, src, dest, rx_time, rx_iso) VALUES(?,?,?,?,?)",
|
||||
[60_002, 123, 456, recent_rx, Time.at(recent_rx).utc.iso8601],
|
||||
)
|
||||
end
|
||||
|
||||
get "/api/traces?since=#{recent_rx}"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
payload = JSON.parse(last_response.body)
|
||||
expect(payload.map { |row| row["id"] }).to eq([60_002])
|
||||
|
||||
get "/api/traces/123?since=#{recent_rx}"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
scoped = JSON.parse(last_response.body)
|
||||
expect(scoped.map { |row| row["id"] }).to eq([60_002])
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /nodes/:id" do
|
||||
|
||||
@@ -61,7 +61,7 @@ RSpec.describe "Ingestor endpoints" do
|
||||
node_id: "!abc12345",
|
||||
start_time: now - 120,
|
||||
last_seen_time: now - 60,
|
||||
version: "0.5.7",
|
||||
version: "0.5.9",
|
||||
lora_freq: 915,
|
||||
modem_preset: "LongFast",
|
||||
}.merge(overrides)
|
||||
@@ -133,7 +133,7 @@ RSpec.describe "Ingestor endpoints" do
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version) VALUES(?,?,?,?)",
|
||||
["!fresh000", now - 100, now - 10, "0.5.7"],
|
||||
["!fresh000", now - 100, now - 10, "0.5.9"],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version) VALUES(?,?,?,?)",
|
||||
@@ -141,7 +141,7 @@ RSpec.describe "Ingestor endpoints" do
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version, lora_freq, modem_preset) VALUES(?,?,?,?,?,?)",
|
||||
["!rich000", now - 200, now - 100, "0.5.8", 915, "MediumFast"],
|
||||
["!rich000", now - 200, now - 100, "0.5.9", 915, "MediumFast"],
|
||||
)
|
||||
end
|
||||
|
||||
@@ -159,6 +159,30 @@ RSpec.describe "Ingestor endpoints" do
|
||||
expect(rich["start_time_iso"]).to be_a(String)
|
||||
expect(rich["last_seen_iso"]).to be_a(String)
|
||||
end
|
||||
|
||||
it "filters ingestors using the since parameter" do
|
||||
frozen_time = Time.at(1_700_000_000)
|
||||
allow(Time).to receive(:now).and_return(frozen_time)
|
||||
now = frozen_time.to_i
|
||||
recent_cutoff = now - 120
|
||||
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version) VALUES(?,?,?,?)",
|
||||
["!old-ingestor", now - 600, now - 300, "0.5.5"],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version) VALUES(?,?,?,?)",
|
||||
["!new-ingestor", now - 60, now - 30, "0.5.9"],
|
||||
)
|
||||
end
|
||||
|
||||
get "/api/ingestors?since=#{recent_cutoff}"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
payload = JSON.parse(last_response.body)
|
||||
expect(payload.map { |entry| entry["node_id"] }).to eq(["!new-ingestor"])
|
||||
end
|
||||
end
|
||||
|
||||
describe "schema migrations" do
|
||||
|
||||
@@ -13,12 +13,14 @@
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<link rel="stylesheet" href="/assets/vendor/uplot/uPlot.min.css" />
|
||||
<script src="/assets/vendor/uplot/uPlot.iife.min.js" defer></script>
|
||||
<section class="charts-page">
|
||||
<header class="charts-page__intro">
|
||||
<h2>Network telemetry trends</h2>
|
||||
<p>Aggregated telemetry snapshots from every node in the past week.</p>
|
||||
</header>
|
||||
<div id="chartsPage" class="charts-page__content">
|
||||
<div id="chartsPage" class="charts-page__content" data-telemetry-root="true">
|
||||
<p class="charts-page__status">Loading aggregated telemetry charts…</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@
|
||||
<% unless private_mode %>
|
||||
<%= erb :"shared/_chat_panel", locals: { full_screen: false } %>
|
||||
<% end %>
|
||||
<%= erb :"shared/_map_panel", locals: { full_screen: false } %>
|
||||
<%= erb :"shared/_map_panel", locals: { full_screen: false, legend_collapsed: true } %>
|
||||
</div>
|
||||
|
||||
<%= erb :"shared/_nodes_table", locals: { full_screen: false } %>
|
||||
|
||||
+1
-1
@@ -14,5 +14,5 @@
|
||||
limitations under the License.
|
||||
-->
|
||||
<section class="full-screen-section full-screen-section--map">
|
||||
<%= erb :"shared/_map_panel", locals: { full_screen: true } %>
|
||||
<%= erb :"shared/_map_panel", locals: { full_screen: true, legend_collapsed: true } %>
|
||||
</section>
|
||||
|
||||
@@ -17,11 +17,14 @@
|
||||
short_display = node_page_short_name || "Loading"
|
||||
long_display = node_page_long_name
|
||||
identifier_display = node_page_identifier || "" %>
|
||||
<link rel="stylesheet" href="/assets/vendor/uplot/uPlot.min.css" />
|
||||
<script src="/assets/vendor/uplot/uPlot.iife.min.js" defer></script>
|
||||
<section
|
||||
id="nodeDetail"
|
||||
class="node-detail"
|
||||
data-node-reference="<%= Rack::Utils.escape_html(reference_json) %>"
|
||||
data-private-mode="<%= private_mode ? "true" : "false" %>"
|
||||
data-telemetry-root="true"
|
||||
>
|
||||
<header class="node-detail__header">
|
||||
<h2 class="node-detail__title">
|
||||
|
||||
@@ -14,8 +14,12 @@
|
||||
limitations under the License.
|
||||
-->
|
||||
<% map_classes = ["map-panel"]
|
||||
map_classes << "map-panel--full" if defined?(full_screen) && full_screen %>
|
||||
<div class="<%= map_classes.join(" ") %>" id="mapPanel">
|
||||
map_classes << "map-panel--full" if defined?(full_screen) && full_screen
|
||||
data_attrs = []
|
||||
if defined?(legend_collapsed)
|
||||
data_attrs << "data-legend-collapsed=\\" #{legend_collapsed ? 'true' : 'false'}\\""
|
||||
end %>
|
||||
<div class="<%= map_classes.join(" ") %>" id="mapPanel" <%= data_attrs.join(" ") %>>
|
||||
<div id="map" role="region" aria-label="Nodes map"></div>
|
||||
<div class="map-toolbar" role="group" aria-label="Map view controls">
|
||||
<button id="mapFullscreenToggle" type="button" aria-pressed="false" aria-label="Enter full screen map view">
|
||||
|
||||
Reference in New Issue
Block a user