From ace03b6807e03ae4927be20d23b00025010023a6 Mon Sep 17 00:00:00 2001 From: Edwin Knuth Date: Mon, 24 Aug 2026 12:34:39 -0700 Subject: [PATCH] Add Reticulum protocol support to the web tier (#888) (#889) Co-authored-by: oregonknuths <159513803+oregonknuths@users.noreply.github.com> --- CHANGELOG.md | 65 +--- SPEC.md | 10 +- data/instances.sql | 1 + .../data_processing/node_writes.rb | 53 +++ .../data_processing/protocol_resolution.rb | 2 +- web/lib/potato_mesh/application/database.rb | 5 + .../application/federation/crawl.rb | 2 + .../federation/instance_records.rb | 7 +- .../application/federation/self_instance.rb | 7 +- web/lib/potato_mesh/application/instances.rb | 10 +- .../application/queries/ingestor_queries.rb | 17 +- .../application/queries/node_queries.rb | 25 +- web/lib/potato_mesh/application/routes/api.rb | 2 +- .../potato_mesh/application/routes/ingest.rb | 5 +- web/public/assets/img/reticulum.svg | 15 + .../js/app/__tests__/federation-page.test.js | 22 +- .../js/app/__tests__/main-filter.test.js | 8 +- .../app/__tests__/main-update-counts.test.js | 225 ++++++++++++- .../app/__tests__/map-activity-card.test.js | 10 +- .../app/__tests__/node-link-helpers.test.js | 8 +- .../js/app/__tests__/protocol-helpers.test.js | 9 +- .../assets/js/app/__tests__/stats.test.js | 36 +- web/public/assets/js/app/federation-page.js | 11 +- web/public/assets/js/app/main.js | 58 +++- .../assets/js/app/main/filter-helpers.js | 14 +- web/public/assets/js/app/map-activity-card.js | 16 +- web/public/assets/js/app/node-rendering.js | 4 +- web/public/assets/js/app/protocol-helpers.js | 37 ++- web/public/assets/js/app/stats.js | 34 +- web/spec/app_spec.rb | 2 +- web/spec/data_processing_spec.rb | 119 ++++++- web/spec/protocol_spec.rb | 2 +- web/spec/queries_spec.rb | 27 +- web/spec/reticulum_spec.rb | 313 ++++++++++++++++++ web/spec/ux_audit_spec.rb | 10 +- web/views/layouts/app.erb | 8 +- web/views/shared/_instances_table.erb | 1 + 37 files changed, 1022 insertions(+), 178 deletions(-) create mode 100644 web/public/assets/img/reticulum.svg create mode 100644 web/spec/reticulum_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 249afaf9..9238bfad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,16 +3,11 @@ # CHANGELOG +### Features +* Data/Web: Reticulum protocol support — `PROTOCOL=reticulum` selects a passive RNS announce listener that ingests `lxmf.delivery`/`nomadnetwork.node` announces as `protocol="reticulum"` nodes; the web app whitelists the protocol end-to-end (ingest + `?protocol=` filter), serves live `reticulum` stats scopes and activity-series keys, signs a live `reticulum_nodes_count` on the federation wire (the v2 canonical already carried the field, so peer signatures are unaffected), and renders reticulum nodes across the UI (icon, filter chip, mesh-activity row, federation column) (SPEC S6/FS2/MA5/MA-F2/F2-2 as amended) + ## v0.7.5 -This is a service release of the radio mesh app-suite `potato-mesh` v0.7.5, focused on transmit consent. A PotatoMesh ingestor is now a **listener by default**: every ingestor-initiated mesh transmission is off unless the operator opts in with `TX_ENABLED=1`, and announcements need a second opt-in (`TX_ANNOUNCE=1`) on top. - -**Breaking for MeshCore operators.** On-air contact telemetry polling — how other nodes' battery and sensor readings reach your dashboard — is a transmission, so it now requires `TX_ENABLED=1`. Set it after upgrading if you want that data. Companion-link reads over USB/BLE to your own radio are not transmissions and are unaffected. - -The `ANNOUNCE` flag introduced in https://github.com/l5yth/potato-mesh/pull/886 never shipped in a release and is superseded by `TX_ANNOUNCE`. - -Demo: - ### Features * Make the activity announcement opt-in via ANNOUNCE (default off) by @seybsen in @@ -29,10 +24,6 @@ Demo: ## v0.7.4 -This is a feature release of the radio mesh app-suite `potato-mesh` v0.7.4, headlined by a frontend design & UX audit remediation (SPEC UX1–UX15, 38 findings) and its Claude-Design follow-up, mesh activity reporting with a map card and packets/hour time-series, and Meshtastic waypoints as a first-class POI layer. Rounded out by a run of frontend performance and asset-caching work. - -Demo: - ### Features * web: remediate frontend design & UX audit by @l5yth in * data,web: mesh activity reporting & announcements by @l5yth in @@ -60,10 +51,6 @@ Demo: ## v0.7.3 -This is a feature release of the radio mesh app-suite `potato-mesh` v0.7.3, focused on the basemap (HOT over CARTO, dark-filtered, with provider blending) and on telemetry breadth: MeshCore RF metrics (RSSI/SNR/hops/path), every telemetry family from both protocols, and per-field telemetry merging in the node table. - -Demo: - ### Features * web: HOT primary basemap (dark-filtered) with per-tile CARTO fallback by @l5yth in * Bump all packages to 0.7.3 by @l5yth in @@ -79,10 +66,6 @@ Demo: ## v0.7.2 -This is a feature release of the radio mesh app-suite `potato-mesh` v0.7.2, introducing the passive UDP transport (Mesh via UDP) so an ingestor no longer monopolizes the radio's single API slot, alongside a security review pass covering dashboard XSS, a private-mode federation gap, and Matrix bridge resilience. - -Demo: - ### Features * feat(ingestor): passive UDP transport (Mesh via UDP), primary-channel only by @tjpaulsondev in @@ -94,10 +77,6 @@ Demo: ## v0.7.1 -This is a service release of the radio mesh app-suite `potato-mesh` v0.7.1, focused on live-update and load performance in the wake of v0.7.0: the pub/sub thread budget no longer blocks the app, bulk collections backfill progressively, and the initial-load module graph was unwound. - -Demo: - ### Features * web: change tiles to carto by @l5yth in * web: progressively backfill all bulk collections by @l5yth in @@ -114,10 +93,6 @@ Demo: ## v0.7.0 -This is a major release of the radio mesh app-suite `potato-mesh` v0.7.0. It carries **breaking API changes** — the stats API reshaped and all APIs moved to consistent camelCase — plus federation signature v2, live updates via pub/sub (`GET /api/events`), uniform pagination across bulk collection APIs, local storage caching, and versioned JS/CSS assets. Node opt-out markers and data retention policies also land here. - -Demo: - ### Features * web: add node opt-out marker and data retention policies by @l5yth in * web: breaking change on stats api by @l5yth in @@ -153,10 +128,6 @@ Demo: ## v0.6.3 -This is a service release of the radio mesh app-suite `potato-mesh` v0.6.3, dominated by a seven-part modularization of the web app and ingestor (data processing, federation, node page, main JS, protocols, interfaces), plus the reworked map spider-net and SEO improvements. - -Demo: - ### Features * web: rework map spider-net feature by @l5yth in * web: add seo improvements by @l5yth in @@ -180,10 +151,6 @@ Demo: ## v0.6.2 -This is a service release of the radio mesh app-suite `potato-mesh` v0.6.2, focused on Meshcore-related fixes, federation accuracy, and bridge coverage. The Matrix bridge now understands Meshcore traffic, and several duplication and classification issues in the web app and ingestor have been tightened up. - -Demo: - ### Features * Matrix: enable meshcore by @l5yth in * Web: show colocated nodes by @l5yth in @@ -199,10 +166,6 @@ Demo: ## v0.6.1 -This is a service release of the radio mesh app-suite `potato-mesh` v0.6.1, focused on Meshcore polish, federation resilience, and ingestor stability in the wake of the v0.6.0 multi-protocol release. - -Demo: - ### Features * Web: per protocol active node counts by @l5yth in * Web: optimize caching by @l5yth in @@ -220,24 +183,6 @@ Demo: ## v0.6.0 -This is a service release of the radio mesh app-suite `potato-mesh` v0.6.0 which introduces new features and overhauls the user interface. The primary notable change is added support for multi-protocol along with an implementation of **Meshcore** in ingestor, web app, and frontend. - -Demo: - -### Meshcore - -To start ingesting Meshcore data to an upgraded potato-mesh web app, simply tell your ingestor to use the `PROTOCOL="meshcore"`. - -### About Pages - -The other notable feature is the removal of the "darkmode" and "info" buttons in favor of customizable markdown pages that allow for more flexibility with regard to custom content (info about presets, contact information, etc.) - see `/pages/*.md` in the web app ([#723](https://github.com/l5yth/potato-mesh/pull/723)). - -### Breaking Variable Changes - -The following deprecated environmental variables have been removed in this release finally ([#704](https://github.com/l5yth/potato-mesh/pull/704)): -* ~~POTATOMESH_INSTANCE~~ - please use `INSTANCE_DOMAIN` -* ~~MESH_SERIAL~~ and ~~PORT~~ - please use `CONNECTION` - ### Features * Web: add markdown static pages by @l5yth in * Data: trace analysus multi ingestor support by @l5yth in @@ -267,10 +212,6 @@ The following deprecated environmental variables have been removed in this relea ## v0.5.12 -This is a service release of the app potato-mesh v0.5.12 which improves performance and stability. - -Notably, the frontend went through some graphical tweaks to prepare for an upcoming multi-protocol release (meshcore, reticulum, etc.). - * Enh: surface meshcore role types (#680) by @l5yth in https://github.com/l5yth/potato-mesh/pull/685 * Chore: refactor codebase before meshcore release by @l5yth in https://github.com/l5yth/potato-mesh/pull/682 * [Meshcore] enh: short name should be 1st 4 hex digits of public key by @benallfree in https://github.com/l5yth/potato-mesh/pull/679 diff --git a/SPEC.md b/SPEC.md index 49dc7176..47f2fe04 100644 --- a/SPEC.md +++ b/SPEC.md @@ -243,7 +243,7 @@ degrade gracefully to their existing node-list fallback). Integrates with | **S3** | **`telemetry` is an umbrella metric.** The `telemetry` count aggregates **positions + telemetry + neighbors + traces** (every non-message, non-nodeinfo packet record), counted by each table's `rx_time`. `messages` counts the `messages` table by `rx_time`; `nodes` counts `nodes` by `last_heard` (unchanged from today). | interview | | **S4** | **Activity windows unchanged.** Every count uses the existing cutoffs — `hour` (3600s), `day` (86 400s), `week` (`week_seconds`), `month` (`four_weeks_seconds`) — so no count can surface activity beyond the 28-day API visibility floor (preserves C4 / `MAX_QUERY_LIMIT` reasoning). | interview + code | | **S5** | **Privacy: messages zeroed in private mode** (Invariant II). When `private_mode?`, every `messages` count (in `total` and all protocol scopes) is **0**, mirroring the `PRIVATE=1` message-API 404 (A2a) so stats never leak message volume that privacy hides. Node counts keep the `CLIENT_HIDDEN` exclusion; **all** metrics honor the node opt-out marker via the per-table opt-out filter (`opt_out_self_filter` for `nodes`; `opt_out_node_id_filter` / `opt_out_node_num_filter` for the message and telemetry-umbrella tables, matching the existing list endpoints). Telemetry/positions/neighbors/traces are not gated by `PRIVATE`, so those counts remain reported. | interview | -| **S6** | **`reticulum` is a forward-looking zero stub.** A `reticulum` scope is always emitted with all-zero counts and an in-code `# stub` comment, so the shape extends to future protocols without another break. It adds **no** ingest path (Invariant I), privileges no protocol (Invariant IV), and does **not** enter `KNOWN_PROTOCOLS` (which still gates the `?protocol=` query param at `meshcore` + `meshtastic`). | interview | +| **S6** | **`reticulum` is a first-class protocol scope.** As first shipped, `reticulum` was a forward-looking zero stub: always emitted with all-zero counts, adding no ingest path and never entering `KNOWN_PROTOCOLS`. **Amended with the Reticulum ingestor** (`PROTOCOL=reticulum`, the passive RNS announce listener): `reticulum` now **enters `KNOWN_PROTOCOLS`** — the `?protocol=` query param and the per-record/wrapper ingest whitelist accept `meshcore` + `meshtastic` + `reticulum` — and the scope carries **live** `WHERE protocol = ?` counts exactly like the other named scopes (S2 applies unchanged). The stub served its purpose: the response shape extended to a live third protocol without another break, and no protocol is privileged (Invariant IV). | interview (amended) | | **S7** | **One-way federation compatibility (new reads old).** Federation consumers (`crawl.rb`) try the new shape first (`total.nodes[window]`, `meshcore.nodes.day`, `meshtastic.nodes.day`) then fall back to the old shape (`active_nodes[window]`, `meshcore.day`, `meshtastic.day`), then to the existing node-list fallback. Detection is **structural** (key presence/shape) — no in-band version field. New instances read both old and new peers; old instances reading a new peer degrade gracefully (the accepted one-way limit). | interview | --- @@ -284,7 +284,7 @@ marker, and one fallback chain (option **U0**). | # | Decision | Source | | --- | --- | --- | | **FS1** | **Snake_case federation wire** via one shared canonicalizer: `public_key` (was `publicKey`/`pubkey`), `last_update` (was `lastUpdate`/`lastUpdateTime`), `is_private`, `contact_link`, `nodes_count`, `meshcore_nodes_count`, `meshtastic_nodes_count`, `reticulum_nodes_count`, `signature_algorithm`, `signed_payload`, `signature_version`. Single-token keys (`id`, `domain`, `name`, `version`, `channel`, `frequency`, `latitude`, `longitude`, `signature`) unchanged. DB columns (`pubkey`, `last_update_time`) stay internal, mapped at the wire boundary. | interview | -| **FS2** | **Every announced count is signed** — the announcement canonical includes `nodes_count`, `meshcore_nodes_count`, `meshtastic_nodes_count`, and a forward-compat `reticulum_nodes_count` (0 until a Reticulum ingestor exists); **no unsigned attribute remains** in the announcement. Counts are still **recomputed** from the peer's live `/api/nodes` on receipt — the signature authenticates the sender's snapshot (integrity), the recompute keeps the displayed figure fresh. | interview | +| **FS2** | **Every announced count is signed** — the announcement canonical includes `nodes_count`, `meshcore_nodes_count`, `meshtastic_nodes_count`, and `reticulum_nodes_count`; **no unsigned attribute remains** in the announcement. `reticulum_nodes_count` shipped as a forward-compat signed constant `0` and, **since the Reticulum ingestor landed, carries the live Reticulum node count**. Because the v2 canonical already included the field, the signature shape is unchanged: pre-Reticulum peers keep signing `reticulum_nodes_count: 0`, those signatures still verify here, and such peers simply relay `0` for a Reticulum-carrying instance until upgraded — a display-only lag, not a verification break. Counts are still **recomputed** from the peer's live `/api/nodes` on receipt — the signature authenticates the sender's snapshot (integrity), the recompute keeps the displayed figure fresh. | interview (amended) | | **FS3** | **`signature_version` is stamped inside the signed canonical** (not only the envelope), so the format cannot be silently downgraded. Current version = `2`; legacy payloads without it are treated as `v1`. | interview | | **FS4** | **Send-snake, accept-both (U0).** Instances sign and send **v2 (snake)**. `verify_instance_signature` and the well-known validator try the **v2 snake** canonical, then fall back to the **v1 camel** canonical, each composed with the existing `contact_link`-strip and domain-casing fallbacks. One canonicalizer + fallback chain is shared by both signers (their field sets differ; the mechanism/casing/marker/fallback are shared). | interview | | **FS5** | **Versioned one-way break** (amends Invariant III, mirrors S7): old peers cannot verify a v2 signature and stop accepting this instance until upgraded (accepted cost); new instances accept old peers' v1 signatures, so a mixed fleet converges. | interview | @@ -1102,7 +1102,7 @@ contradicted. | **MA2** | **Heartbeat carries the per-interval delta.** `POST /api/ingestors` gains an additive optional `packets` field = frames counted since the previous heartbeat (a per-interval **delta**, not a since-boot cumulative), reset to zero each time a heartbeat is queued. Per-interval deltas map one-to-one onto time-series rows and are restart-safe (a reboot simply starts a fresh interval). Absent ⇒ treated as `0`, so pre-feature ingestors and the existing `tests/test_mesh.py` fixtures are unaffected (D8). | interview | | **MA3** | **Per-ingestor activity time-series (the moving-average schema).** A new append-only table `ingestor_activity` (`ingestor_id TEXT`, `at INTEGER`, `packets INTEGER`, `protocol TEXT`, indexed on `at`) records one row per heartbeat delta. This is the schema that makes a packets/hour moving average computable while distinguishing **multiple protocols and multiple ingestors per protocol** (each ingestor's contribution is kept separate rather than pre-summed). The `ingestors` snapshot table (one row per node) is unchanged. Rows are pruned by the existing retention worker (window ≥ the 24 h the announcement needs; sized with the other activity floors). | proposed | | **MA4** | **Aggregation = MAX per protocol; `total` = SUM across protocols (dedup-free "in the air").** The mesh-wide packets/hour for a protocol is `MAX` over that protocol's ingestors of *(that ingestor's total `packets` reported in the last 24 h ÷ 24)*. A single radio can only hear ≤ what is actually transmitted, so the busiest single vantage is the best dedup-free estimate of unique air traffic and can never double-count a frame heard by two radios (true per-frame dedup is impossible anyway — ignored/errored frames carry no id). The **`total`** rate is the **SUM** of the per-protocol rates — different protocols ride different frequencies/channels, so their frames never overlap in the air and add rather than dedup. **Amended pre-release:** `total` first shipped as a MAX over *every* ingestor regardless of protocol, which under-counted it to the single busiest protocol (e.g. meshcore 44 + meshtastic 76 rendered as 76, not 120); corrected to the cross-protocol SUM. The fixed `÷ 24` denominator (not ÷ elapsed) keeps the rate stable and avoids divide-by-small spikes; an ingestor with < 24 h of data simply reads lower and ramps up (moot in practice — the announcement fires only ≥ 24 h after start, MA7). *Accepted limitation:* the per-protocol MAX under-estimates the true union when different radios are busiest in different hours. | interview | -| **MA5** | **Exposure as an additive per-scope `packets` metric.** `GET /api/stats` exposes the MA4 24 h MAX-aggregated moving average under each scope as `.packets.hour` — a `packets` metric carrying a single `hour` window (it is a rate, not a windowed count, so no day/week/month keys), consistent with the S1 `scope → metric → window` layout. `reticulum.packets.hour` is a forward-looking `0` stub (S6); `total.packets.hour` is the **SUM** of the per-protocol rates (MA4). The rest of the S1 tree is untouched, so this needs **no** version bump. The announcement's active-node figure reuses the existing `.nodes.day` count (already deduped by `node_id`); only packets/hour is new. **Amended pre-release:** the field originally shipped (unreleased, on `main`) as a top-level `packets_per_hour: { total, meshcore, meshtastic, reticulum }` map; it is superseded here by the per-scope `packets.hour` form for consistency with the S1 tree. Because it was merged but **not yet in any tagged release** and is **not** on the signed federation wire, the reshape carries **no** version bump, federation-compat fallback, or signature break — only the in-repo `announce.py` dogfeed reader (MA6) and the specs/docs move with it. | proposed (amended) | +| **MA5** | **Exposure as an additive per-scope `packets` metric.** `GET /api/stats` exposes the MA4 24 h MAX-aggregated moving average under each scope as `.packets.hour` — a `packets` metric carrying a single `hour` window (it is a rate, not a windowed count, so no day/week/month keys), consistent with the S1 `scope → metric → window` layout. `reticulum.packets.hour` shipped as a forward-looking `0` stub and is **live** since the Reticulum ingestor landed (the S6 amendment), computed like its siblings; `total.packets.hour` is the **SUM** of the per-protocol rates (MA4). The rest of the S1 tree is untouched, so this needs **no** version bump. The announcement's active-node figure reuses the existing `.nodes.day` count (already deduped by `node_id`); only packets/hour is new. **Amended pre-release:** the field originally shipped (unreleased, on `main`) as a top-level `packets_per_hour: { total, meshcore, meshtastic, reticulum }` map; it is superseded here by the per-scope `packets.hour` form for consistency with the S1 tree. Because it was merged but **not yet in any tagged release** and is **not** on the signed federation wire, the reshape carries **no** version bump, federation-compat fallback, or signature break — only the in-repo `announce.py` dogfeed reader (MA6) and the specs/docs move with it. | proposed (amended) | | **MA6** | **Announcement content, drawn from the instance's own API (dogfeeding).** Each ingestor broadcasts, for its own configured protocol, a single line formatted to that protocol's character limit: `" activity in the last 24h: active nodes, packets/hour. https://"`. `` and `` are fetched **from the target instance** (`GET /api/stats` → `.nodes.day` and `.packets.hour`), never computed from the ingestor's local view — because one ingestor may not see the whole mesh. `` is the configured `INSTANCE_DOMAIN`. Reporting (MA1–MA4) is independent and continues regardless of announcement state. | interview | | **MA7** | **Announcement is quadruple-gated, behind a default-off transmit policy.** An announcement is transmitted only when **all** hold: (a) **`TX_ENABLED=1`** — the master switch for *every* ingestor-initiated mesh transmission (the announcement and the MeshCore on-air telemetry polls alike), **default `0`**; (b) **`TX_ANNOUNCE=1`** — a second, narrower opt-in for the announcement specifically, **default `0`**, because unsolicited traffic on a shared human channel is a distinct decision from polling a node for its battery level; (c) **≥ 24 h have elapsed since ingestor start** — so the first numbers are accurate over a full window and restarts cannot spam the channel; (d) the target instance reports **non-private** — the ingestor GETs `/version` and honors `config.private_mode`, re-checked every cycle, and **fails closed** (skips) on any fetch/parse error, so a privacy signal is never missed (Invariant II). The legacy **`RX_ONLY=1`** remains supported as a **veto** over (a) — a kill switch an operator deliberately engaged is never silently overridden — but is retired from operator-facing documentation, since leaving `TX_ENABLED` unset now expresses the same intent by default. **Amended twice.** MA7 originally made `RX_ONLY` the *single* transmit gate with no separate enable env, leaving the announcement **on by default**; in the field that made every stock ingestor start talking on its community's default channel without its operator having chosen to. It was then reversed to an `ANNOUNCE=1` opt-in — but that left the wider hole unaddressed: MeshCore telemetry polling still transmitted by default, and `RX_ONLY`, the only switch that stopped it, was parsed as an exact `== "1"` string comparison, so `RX_ONLY=true` **failed open** and transmitted anyway. This amendment inverts the whole axis: transmission is off unless asked for, the policy has one representation (`tx_policy.py`) consulted at every transmit site, and every flag parses fail-safe toward silence. | interview (amended ×2) | | **MA8** | **Default channel/scope + 24 h cadence.** The announcement is broadcast on the protocol's **default channel and scope** — Meshtastic channel `CHANNEL_INDEX` (default `0`) via the interface text-send; MeshCore its public/default channel — honoring existing `ALLOWED_CHANNELS`/`HIDDEN_CHANNELS` intent. After the initial ≥ 24 h wait it repeats **every 24 h**, per configured instance domain (an ingestor with several `INSTANCE_DOMAIN` targets announces each with its own numbers and link). TX volume is negligible (~1 frame/day/domain). | proposed | @@ -1136,7 +1136,7 @@ import map. No invariant is contradicted. | # | Decision | Source | | --- | --- | --- | | **MA-F1** | **Placement & source.** A "Mesh activity" card renders as a Leaflet control at the map's **bottom-left** (mirroring the roles legend bottom-right) on the dashboard and `/map`. Its figures come from `GET /api/stats` `.packets.hour` (parsed by `stats.js` into `stats.packets = { total, meshcore, meshtastic }`); on a stats-fetch failure the local-count fallback carries no packets, so the card simply hides. Recommended design option 1a / treatment A ("Signal card"). | interview + design | -| **MA-F2** | **Content.** A pulsing live-dot + "Mesh activity" label; the big tabular **total** packets/h; the placeholder sparkline (MA-F5); and one row per protocol (Meshtastic, then MeshCore) carrying the protocol icon, label, a share-bar sized to the busiest visible protocol, and the tabular rate. **`reticulum` is never rendered** (forward-looking zero stub, S6). | design | +| **MA-F2** | **Content.** A pulsing live-dot + "Mesh activity" label; the big tabular **total** packets/h; the placeholder sparkline (MA-F5); and one row per protocol (Meshtastic, then MeshCore, then Reticulum) carrying the protocol icon, label, a share-bar sized to the busiest visible protocol, and the tabular rate. **Amended with the Reticulum ingestor** (the S6 amendment): `reticulum` was originally never rendered while it was a zero stub; the card now renders a Reticulum row identically to the other two, and the card's existing zero-state rule already keeps an idle protocol row honest. | design (amended) | | **MA-F3** | **Zero-state unmount.** The card is hidden entirely (`.map-activity-card--hidden`, `hidden`, emptied) whenever the visible total is `0` or the payload carries no packet rates — the map's one free corner is never occupied by an empty card. | interview + design | | **MA-F4** | **Toggle-reactive rebasing (Invariant IV parity).** A protocol hidden via the meta-row protocol toggle (the existing `hiddenProtocols` set) drops its row and rebases the displayed total to the **sum of the visible** protocol rates — the same treatment for both protocols, identical to how the node counts already rebase. Toggling re-runs `applyFilter`, which re-renders the card; hiding **all** protocols unmounts it. | interview + code | | **MA-F5** | **Sparkline (superseded by F2-4).** As first shipped in F1 the 24-hour sparkline was a **deterministic placeholder** (`data-placeholder="true"`), never claiming to be live. **Superseded by F2-4:** it now renders the real 24 h `total` series from `GET /api/stats/activity`, and is simply **omitted** when that series is unavailable — the card never shows a fake curve. | interview | @@ -1166,7 +1166,7 @@ contradicted. | # | Decision | Source | | --- | --- | --- | | **F2-1** | **New `GET /api/stats/activity`.** Bucketed packets/hour series over `ingestor_activity`, mirroring `/api/telemetry/aggregated` (window clamped to the 28-day floor, bucket count capped at `MAX_QUERY_LIMIT`, `ApiCache`), but with **snake_case** `window_seconds`/`bucket_seconds` params — the API norm. Returns `[{ bucket_start, bucket_end, total, meshcore, meshtastic }, …]` ascending. The lone camelCase params on `/aggregated` stay the tracked BP9 migration, not folded in here. | interview | -| **F2-2** | **Per-bucket aggregation mirrors MA4.** Within each bucket, per protocol = **MAX** over that protocol's ingestors of their summed packets; `total` = **SUM** across protocols; each ÷ (`bucket_seconds`/3600) to a packets/hour rate. `reticulum` folds into `total` but emits no series (consistent with `query_packets_per_hour`). | interview + code | +| **F2-2** | **Per-bucket aggregation mirrors MA4.** Within each bucket, per protocol = **MAX** over that protocol's ingestors of their summed packets; `total` = **SUM** across protocols; each ÷ (`bucket_seconds`/3600) to a packets/hour rate. **Amended with the Reticulum ingestor** (the S6 amendment): `reticulum` originally folded into `total` without a series key of its own; each bucket now emits a live `reticulum` key alongside `meshcore` and `meshtastic` (consistent with `query_packets_per_hour`, whose reticulum rate went live at the same time). | interview + code (amended) | | **F2-3** | **No new retention.** `ingestor_activity` is already retained a year; the 28-day API clamp is the only ceiling. The card requests **24 h / 1 h** buckets, the `/charts` figure **7 d / 2 h**. | code | | **F2-4** | **Card real sparkline.** The MA-F5 placeholder is replaced by the real 24 h series from `/api/stats/activity`, fetched on a slower cadence with its own short cache (the trend moves slowly — not every refresh). On fetch failure the sparkline is omitted but the live total/rows (from `/api/stats`) still render — the card never regresses to a fake curve. | interview | | **F2-5** | **Charts page — protocol-aware, all-protocol.** A "Mesh activity" figure (packets/h per protocol, 7 d) is added to `/charts` **between** the channel-utilization and environmental figures. The intro no longer names "Meshtastic": the aggregated telemetry (`query_telemetry_buckets`) already carries **all** protocols (MeshCore telemetry included since the telemetry pull shipped) — the prior wording mislabelled all-protocol data. Invariant IV. | interview | diff --git a/data/instances.sql b/data/instances.sql index 5e43d9ef..8004f469 100644 --- a/data/instances.sql +++ b/data/instances.sql @@ -29,6 +29,7 @@ CREATE TABLE IF NOT EXISTS instances ( nodes_count INTEGER, meshcore_nodes_count INTEGER, meshtastic_nodes_count INTEGER, + reticulum_nodes_count INTEGER, contact_link TEXT, signature TEXT ); diff --git a/web/lib/potato_mesh/application/data_processing/node_writes.rb b/web/lib/potato_mesh/application/data_processing/node_writes.rb index 7ea22f81..6cacd620 100644 --- a/web/lib/potato_mesh/application/data_processing/node_writes.rb +++ b/web/lib/potato_mesh/application/data_processing/node_writes.rb @@ -212,6 +212,30 @@ module PotatoMesh nil end + # Decide whether an incoming node record collides with a stored row of a + # different protocol and must therefore be skipped (the cross-protocol + # node-row hijack guard in {#upsert_node}). + # + # A stored +"meshtastic"+ value doubles as the schema default stamped on + # rows ingested before their protocol was known, so a +"meshcore"+ + # record may still reclaim such a row — the established bug #747 + # self-heal that the upsert's +NULLIF(nodes.protocol,'meshtastic')+ + # conflict clause implements. Every other differing pairing between two + # known protocols is a genuine 4-byte id collision across protocols and + # is rejected. + # + # @param stored_protocol [String, nil] protocol currently on the row, or + # nil when no row exists yet. + # @param incoming_protocol [String] resolved protocol of the incoming + # record. + # @return [Boolean] true when the record must be skipped. + def cross_protocol_conflict?(stored_protocol, incoming_protocol) + return false unless KNOWN_PROTOCOLS.include?(stored_protocol) + return false if stored_protocol == incoming_protocol + return false if stored_protocol == "meshtastic" && incoming_protocol == "meshcore" + true + end + # Insert or update a node row from an inbound NodeInfo-style payload. # # Two-phase write. Phase one is the freshness-guarded upsert: a record @@ -268,6 +292,35 @@ module PotatoMesh end node_num = resolve_node_num(node_id, n) + # Cross-protocol node-row hijack guard. +nodes.node_id+ is a global + # TEXT primary key shared by every protocol's id mapping, and both the + # MeshCore and Reticulum mappings truncate a native identifier to its + # first 4 bytes — so two nodes on *different* protocols can collide on + # one +node_id+. Without this guard the colliding record would pass + # the freshness guard below (Reticulum announces stamp a wall-clock + # +lastHeard+) and overwrite the stored row's fields wholesale, with + # the row's protocol either flipped or silently mismatched. Skip such + # records entirely; neither row's data may corrupt the other's. + # Same-protocol prefix collisions remain the accepted MeshCore- + # inherited merge behaviour (see CONTRACTS.md, "Reticulum node id + # mapping"), and a stored default-'meshtastic' row may still be + # reclaimed by a meshcore record (the #747 self-heal preserved by + # +cross_protocol_conflict?+). + stored_protocol = db.get_first_value( + "SELECT protocol FROM nodes WHERE node_id = ? LIMIT 1", + [node_id], + ) + if cross_protocol_conflict?(stored_protocol, protocol) + debug_log( + "Skipped cross-protocol node upsert", + context: "data_processing.upsert_node", + node_id: node_id, + stored_protocol: stored_protocol, + incoming_protocol: protocol, + ) + return + end + # The prometheus helper still receives the raw `pos` so that gauges # not affected by sentinel handling (e.g. precision_bits) keep # updating; the latitude/longitude guards inside +update_prometheus_metrics+ diff --git a/web/lib/potato_mesh/application/data_processing/protocol_resolution.rb b/web/lib/potato_mesh/application/data_processing/protocol_resolution.rb index 28f78224..8899c6bd 100644 --- a/web/lib/potato_mesh/application/data_processing/protocol_resolution.rb +++ b/web/lib/potato_mesh/application/data_processing/protocol_resolution.rb @@ -21,7 +21,7 @@ module PotatoMesh # carry an explicit protocol stamp via {#resolve_record_protocol}; values # outside this set are treated as malformed and fall back to the # ingestor-derived default. - KNOWN_PROTOCOLS = %w[meshtastic meshcore].freeze + KNOWN_PROTOCOLS = %w[meshtastic meshcore reticulum].freeze # Look up the protocol registered by a given ingestor node. # diff --git a/web/lib/potato_mesh/application/database.rb b/web/lib/potato_mesh/application/database.rb index 8095f7b6..865c1ce9 100644 --- a/web/lib/potato_mesh/application/database.rb +++ b/web/lib/potato_mesh/application/database.rb @@ -435,6 +435,11 @@ module PotatoMesh instance_columns << "meshtastic_nodes_count" end + unless instance_columns.include?("reticulum_nodes_count") + db.execute("ALTER TABLE instances ADD COLUMN reticulum_nodes_count INTEGER") + instance_columns << "reticulum_nodes_count" + end + telemetry_tables = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='telemetry'").flatten if telemetry_tables.empty? diff --git a/web/lib/potato_mesh/application/federation/crawl.rb b/web/lib/potato_mesh/application/federation/crawl.rb index c11d3930..d67b9c95 100644 --- a/web/lib/potato_mesh/application/federation/crawl.rb +++ b/web/lib/potato_mesh/application/federation/crawl.rb @@ -338,8 +338,10 @@ module PotatoMesh if stats_payload.is_a?(Hash) mc_day = remote_stats_protocol_day(stats_payload, "meshcore") mt_day = remote_stats_protocol_day(stats_payload, "meshtastic") + rt_day = remote_stats_protocol_day(stats_payload, "reticulum") attributes[:meshcore_nodes_count] = mc_day if mc_day && attributes[:meshcore_nodes_count].nil? attributes[:meshtastic_nodes_count] = mt_day if mt_day && attributes[:meshtastic_nodes_count].nil? + attributes[:reticulum_nodes_count] = rt_day if rt_day && attributes[:reticulum_nodes_count].nil? end nodes_since_path = "/api/nodes?since=#{recent_cutoff}&limit=1000" diff --git a/web/lib/potato_mesh/application/federation/instance_records.rb b/web/lib/potato_mesh/application/federation/instance_records.rb index 04954fef..2d4e0cd7 100644 --- a/web/lib/potato_mesh/application/federation/instance_records.rb +++ b/web/lib/potato_mesh/application/federation/instance_records.rb @@ -58,8 +58,9 @@ module PotatoMesh INSERT INTO instances ( id, domain, pubkey, name, version, channel, frequency, latitude, longitude, last_update_time, is_private, nodes_count, - meshcore_nodes_count, meshtastic_nodes_count, contact_link, signature - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + meshcore_nodes_count, meshtastic_nodes_count, reticulum_nodes_count, + contact_link, signature + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET domain=excluded.domain, pubkey=excluded.pubkey, @@ -74,6 +75,7 @@ module PotatoMesh nodes_count=COALESCE(excluded.nodes_count, instances.nodes_count), meshcore_nodes_count=COALESCE(excluded.meshcore_nodes_count, instances.meshcore_nodes_count), meshtastic_nodes_count=COALESCE(excluded.meshtastic_nodes_count, instances.meshtastic_nodes_count), + reticulum_nodes_count=COALESCE(excluded.reticulum_nodes_count, instances.reticulum_nodes_count), contact_link=excluded.contact_link, signature=excluded.signature SQL @@ -94,6 +96,7 @@ module PotatoMesh nodes_count, coerce_integer(attributes[:meshcore_nodes_count]), coerce_integer(attributes[:meshtastic_nodes_count]), + coerce_integer(attributes[:reticulum_nodes_count]), attributes[:contact_link], signature, ] diff --git a/web/lib/potato_mesh/application/federation/self_instance.rb b/web/lib/potato_mesh/application/federation/self_instance.rb index 33a7e684..2e30f726 100644 --- a/web/lib/potato_mesh/application/federation/self_instance.rb +++ b/web/lib/potato_mesh/application/federation/self_instance.rb @@ -92,6 +92,7 @@ module PotatoMesh nodes_count = active_node_count_since(cutoff, db: db) mc_count = active_node_count_since_for_protocol(cutoff, "meshcore", db: db) mt_count = active_node_count_since_for_protocol(cutoff, "meshtastic", db: db) + rt_count = active_node_count_since_for_protocol(cutoff, "reticulum", db: db) { id: app_constant(:SELF_INSTANCE_ID), domain: domain, @@ -108,9 +109,9 @@ module PotatoMesh nodes_count: nodes_count, meshcore_nodes_count: mc_count, meshtastic_nodes_count: mt_count, - # Forward-compat stub (SPEC FS2): always 0 until a Reticulum ingestor - # exists, but signed now so the shape need not change again. - reticulum_nodes_count: 0, + # Live since the Reticulum ingestor landed (#888); previously a + # signed FS2 forward-compat 0 stub, so the wire shape is unchanged. + reticulum_nodes_count: rt_count, } ensure db&.close diff --git a/web/lib/potato_mesh/application/instances.rb b/web/lib/potato_mesh/application/instances.rb index 15461899..b0e1750c 100644 --- a/web/lib/potato_mesh/application/instances.rb +++ b/web/lib/potato_mesh/application/instances.rb @@ -146,9 +146,10 @@ module PotatoMesh "nodes_count" => coerce_integer(row["nodes_count"]), "meshcore_nodes_count" => coerce_integer(row["meshcore_nodes_count"]), "meshtastic_nodes_count" => coerce_integer(row["meshtastic_nodes_count"]), - # reticulum count has no column (always 0 today); served signed=0 so a - # crawler re-verifying a relayed v2 record rebuilds the same canonical. - "reticulum_nodes_count" => 0, + # NULL column (rows predating the reticulum count column and peers that announced no count) + # serves 0 so a crawler re-verifying a relayed v2 record rebuilds the + # same canonical those senders signed. + "reticulum_nodes_count" => coerce_integer(row["reticulum_nodes_count"]) || 0, "contact_link" => string_or_nil(row["contact_link"]), "signature" => signature, "signature_version" => PotatoMesh::Config.federation_signature_version, @@ -182,7 +183,8 @@ module PotatoMesh sql = <<~SQL SELECT id, domain, pubkey, name, version, channel, frequency, latitude, longitude, last_update_time, is_private, nodes_count, - meshcore_nodes_count, meshtastic_nodes_count, contact_link, signature + meshcore_nodes_count, meshtastic_nodes_count, reticulum_nodes_count, + contact_link, signature FROM instances WHERE domain IS NOT NULL AND TRIM(domain) != '' AND pubkey IS NOT NULL AND TRIM(pubkey) != '' diff --git a/web/lib/potato_mesh/application/queries/ingestor_queries.rb b/web/lib/potato_mesh/application/queries/ingestor_queries.rb index 8059f8d3..6b0a2048 100644 --- a/web/lib/potato_mesh/application/queries/ingestor_queries.rb +++ b/web/lib/potato_mesh/application/queries/ingestor_queries.rb @@ -43,8 +43,8 @@ module PotatoMesh # of the per-protocol rates: different protocols ride different # frequencies/channels, so their frames never overlap in the air and add # rather than dedup (a same-protocol frame heard by two radios is still - # deduped by the per-protocol MAX). +reticulum+ is a forward-looking - # always-zero stub (SPEC S6/MA5). + # deduped by the per-protocol MAX). +reticulum+ went live with the + # Reticulum ingestor (#888); it was a zero stub before that. # # @param now [Integer] reference unix timestamp in seconds. # @param db [SQLite3::Database, nil] optional open database handle to reuse. @@ -83,7 +83,7 @@ module PotatoMesh "total" => rates.values.sum, "meshcore" => rates["meshcore"] || 0, "meshtastic" => rates["meshtastic"] || 0, - "reticulum" => 0, + "reticulum" => rates["reticulum"] || 0, } ensure handle&.close unless db @@ -103,9 +103,9 @@ module PotatoMesh # same way as the live {#query_packets_per_hour} (SPEC MA4): per protocol, # +MAX+ over that protocol's ingestors of their summed packets in the # bucket; +total+ is the +SUM+ across protocols; each is divided by the - # bucket's hour-span to a rate. Only +meshcore+/+meshtastic+ are emitted - # as keys (reticulum still contributes to +total+ but has no series yet, - # mirroring {#query_packets_per_hour}). The window is clamped to the + # bucket's hour-span to a rate. Every known protocol + # (+meshcore+/+meshtastic+/+reticulum+) is emitted as a key, mirroring + # {#query_packets_per_hour}. The window is clamped to the # 28-day visibility floor (C4); the route pre-validates the bucket count # against +MAX_QUERY_LIMIT+ and this query caps the row count too. # @@ -115,8 +115,8 @@ module PotatoMesh # @param now [Integer] reference unix timestamp (exposed for tests). # @param db [SQLite3::Database, nil] optional open handle to reuse. # @return [Array] ascending buckets, each +{ "bucket_start", - # "bucket_end", "total", "meshcore", "meshtastic" }+ with integer - # packets/hour rates. + # "bucket_end", "total", "meshcore", "meshtastic", "reticulum" }+ with + # integer packets/hour rates. def query_activity_buckets(window_seconds:, bucket_seconds:, since: 0, now: Time.now.to_i, db: nil) window = coerce_integer(window_seconds) || DEFAULT_ACTIVITY_WINDOW_SECONDS window = DEFAULT_ACTIVITY_WINDOW_SECONDS if window <= 0 @@ -159,6 +159,7 @@ module PotatoMesh "total" => 0, "meshcore" => 0, "meshtastic" => 0, + "reticulum" => 0, } buckets[bucket_start] = entry order << bucket_start diff --git a/web/lib/potato_mesh/application/queries/node_queries.rb b/web/lib/potato_mesh/application/queries/node_queries.rb index ecb43cfb..b2a6bf85 100644 --- a/web/lib/potato_mesh/application/queries/node_queries.rb +++ b/web/lib/potato_mesh/application/queries/node_queries.rb @@ -318,7 +318,7 @@ module PotatoMesh # Per-protocol scopes counted alongside the unfiltered +total+, paired with # the short column-alias prefix used in the generated SQL. - STATS_PROTOCOL_SCOPES = [["meshcore", "mc"], ["meshtastic", "mt"]].freeze + STATS_PROTOCOL_SCOPES = [["meshcore", "mc"], ["meshtastic", "mt"], ["reticulum", "rt"]].freeze # Return exact activity counts for /api/stats as a scope → metric → window # tree. @@ -326,8 +326,7 @@ module PotatoMesh # The shape is: # # { "total" => { "nodes" => {...}, "messages" => {...}, "telemetry" => {...} }, - # "meshcore" => { ... }, "meshtastic" => { ... }, - # "reticulum" => { ... all zero (stub) } } + # "meshcore" => { ... }, "meshtastic" => { ... }, "reticulum" => { ... } } # # where each metric maps to a +{ "hour", "day", "week", "month" }+ window # hash. +total+ counts every visible row regardless of protocol; the @@ -458,8 +457,8 @@ module PotatoMesh # +p+ (protocol) columns for the visible rows of one metric. # @param projection_params [Array] bind parameters for +projection_sql+. # @param cutoffs [Hash{String => Integer}] window => lower-bound timestamp. - # @return [Hash{String => Hash}] +total+/+meshcore+/+meshtastic+ => window - # counts. + # @return [Hash{String => Hash}] +total+ plus one entry per + # {STATS_PROTOCOL_SCOPES} protocol => window counts. def windowed_protocol_counts(handle, projection_sql:, projection_params:, cutoffs:) selects = [] window_params = [] @@ -487,7 +486,7 @@ module PotatoMesh # Extract one alias prefix's window-count hash from a result row. # # @param row [Hash] row returned by {windowed_protocol_counts}. - # @param prefix [String] column-alias prefix (+total+, +mc+, +mt+). + # @param prefix [String] column-alias prefix (+total+, +mc+, +mt+, +rt+). # @return [Hash{String => Integer}] window => count. def stats_window_hash(row, prefix) STATS_WINDOWS.each_with_object({}) do |window, acc| @@ -496,7 +495,12 @@ module PotatoMesh end # Transpose metric → scope counts into the scope → metric tree returned by - # /api/stats and append the always-zero +reticulum+ stub. + # /api/stats. + # + # +reticulum+ was a forward-looking all-zero stub until the Reticulum + # ingestor landed (#888); it is now a live {STATS_PROTOCOL_SCOPES} + # member counted like the other protocols, so the S6 response shape is + # unchanged — only the values went live. # # @param metrics [Hash{String => Hash}] metric => (scope => window counts). # @return [Hash{String => Hash}] scope => (metric => window counts). @@ -505,13 +509,6 @@ module PotatoMesh (["total"] + STATS_PROTOCOL_SCOPES.map(&:first)).each do |scope| scopes[scope] = metrics.transform_values { |by_scope| by_scope[scope] } end - # reticulum is a forward-looking stub: PotatoMesh has no Reticulum - # ingestor yet, so every count is zero. Emitting the scope now lets the - # response shape absorb the protocol later without another breaking change - # (SPEC S6). - scopes["reticulum"] = metrics.keys.each_with_object({}) do |metric, acc| - acc[metric] = zero_window_counts - end scopes end diff --git a/web/lib/potato_mesh/application/routes/api.rb b/web/lib/potato_mesh/application/routes/api.rb index 5fe9653b..7de6aa04 100644 --- a/web/lib/potato_mesh/application/routes/api.rb +++ b/web/lib/potato_mesh/application/routes/api.rb @@ -20,7 +20,7 @@ module PotatoMesh module Api # Accepted protocol filter values. Unknown values are discarded to # prevent attacker-controlled strings from polluting the cache keyspace. - KNOWN_PROTOCOLS = Set.new(%w[meshcore meshtastic]).freeze + KNOWN_PROTOCOLS = Set.new(%w[meshcore meshtastic reticulum]).freeze # Register read-only API endpoints that expose cached mesh data and # instance metadata. Invoked by Sinatra during extension registration. diff --git a/web/lib/potato_mesh/application/routes/ingest.rb b/web/lib/potato_mesh/application/routes/ingest.rb index eb436353..3dcd72aa 100644 --- a/web/lib/potato_mesh/application/routes/ingest.rb +++ b/web/lib/potato_mesh/application/routes/ingest.rb @@ -196,8 +196,6 @@ module PotatoMesh nodes_count: nodes_count, meshcore_nodes_count: meshcore_nodes_count, meshtastic_nodes_count: meshtastic_nodes_count, - # Carried for v2 signature verification only (no DB column; always 0 - # until a Reticulum ingestor exists). reticulum_nodes_count: reticulum_nodes_count, } @@ -336,6 +334,7 @@ module PotatoMesh total = 0 meshcore = 0 meshtastic = 0 + reticulum = 0 remote_nodes.each do |n| next unless n.is_a?(Hash) ts = coerce_integer(n["lastHeard"] || n["last_heard"]) @@ -344,11 +343,13 @@ module PotatoMesh case (n["protocol"] || n["mesh_protocol"]).to_s.downcase when "meshcore" then meshcore += 1 when "meshtastic" then meshtastic += 1 + when "reticulum" then reticulum += 1 end end attributes[:nodes_count] = total attributes[:meshcore_nodes_count] = meshcore attributes[:meshtastic_nodes_count] = meshtastic + attributes[:reticulum_nodes_count] = reticulum end db = open_database diff --git a/web/public/assets/img/reticulum.svg b/web/public/assets/img/reticulum.svg new file mode 100644 index 00000000..5428e400 --- /dev/null +++ b/web/public/assets/img/reticulum.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/web/public/assets/js/app/__tests__/federation-page.test.js b/web/public/assets/js/app/__tests__/federation-page.test.js index 86fa129f..344af43e 100644 --- a/web/public/assets/js/app/__tests__/federation-page.test.js +++ b/web/public/assets/js/app/__tests__/federation-page.test.js @@ -211,7 +211,10 @@ const fetchImpl = async () => ({ latitude: 10.12345, longitude: -20.98765, lastUpdateTime: Math.floor(Date.now() / 1000) - 90, - nodesCount: 12 + nodesCount: 12, + meshcore_nodes_count: 5, + meshtastic_nodes_count: 3, + reticulum_nodes_count: 4 }, { domain: 'bravo.mesh', @@ -237,6 +240,20 @@ const fetchImpl = async () => ({ assert.match(firstRowHtml, /-20\.98765/); assert.match(firstRowHtml, />12]*> 5]*> 3]*> 4 ({ assert.match(secondRowHtml, /2\.0\.0/); assert.match(secondRowHtml, />2—<\/em> { }); }); +test('normalizeFilterProtocol keeps reticulum in its own bucket', () => { + withApp((t) => { + assert.equal(t.normalizeFilterProtocol('reticulum'), 'reticulum'); + }); +}); + test('normalizeFilterProtocol returns meshtastic for unknown protocol', () => { withApp((t) => { - assert.equal(t.normalizeFilterProtocol('reticulum'), 'meshtastic'); + assert.equal(t.normalizeFilterProtocol('loramesh'), 'meshtastic'); }); }); diff --git a/web/public/assets/js/app/__tests__/main-update-counts.test.js b/web/public/assets/js/app/__tests__/main-update-counts.test.js index 95dd0e94..1eadde25 100644 --- a/web/public/assets/js/app/__tests__/main-update-counts.test.js +++ b/web/public/assets/js/app/__tests__/main-update-counts.test.js @@ -154,20 +154,27 @@ test('updateProtocolToggleCounts is a no-op when the count elements are absent', test('updateProtocolToggleCounts writes the 7-day per-protocol counts onto the toggles', () => { const { testUtils, env, cleanup } = setupAppWithOptions({ - extraElements: ['protocolToggleMeshcoreCount', 'protocolToggleMeshtasticCount'], + extraElements: [ + 'protocolToggleMeshcoreCount', + 'protocolToggleMeshtasticCount', + 'protocolToggleReticulumCount', + ], }); try { const mc = env.document.getElementById('protocolToggleMeshcoreCount'); const mt = env.document.getElementById('protocolToggleMeshtasticCount'); + const rt = env.document.getElementById('protocolToggleReticulumCount'); testUtils.updateProtocolToggleCounts({ - week: 140, + week: 143, meshcore: { hour: 1, day: 120, week: 123, month: 200 }, meshtastic: { hour: 1, day: 20, week: 17, month: 40 }, + reticulum: { hour: 0, day: 2, week: 3, month: 3 }, }); // The toggle count mirrors the legend's 7-day figure so the same protocol // shows the same number in both places. assert.equal(mc.textContent, '123'); assert.equal(mt.textContent, '17'); + assert.equal(rt.textContent, '3'); } finally { cleanup(); } @@ -175,14 +182,20 @@ test('updateProtocolToggleCounts writes the 7-day per-protocol counts onto the t test('updateProtocolToggleCounts defaults missing per-protocol data to zero', () => { const { testUtils, env, cleanup } = setupAppWithOptions({ - extraElements: ['protocolToggleMeshcoreCount', 'protocolToggleMeshtasticCount'], + extraElements: [ + 'protocolToggleMeshcoreCount', + 'protocolToggleMeshtasticCount', + 'protocolToggleReticulumCount', + ], }); try { const mc = env.document.getElementById('protocolToggleMeshcoreCount'); const mt = env.document.getElementById('protocolToggleMeshtasticCount'); + const rt = env.document.getElementById('protocolToggleReticulumCount'); testUtils.updateProtocolToggleCounts({ week: 5 }); assert.equal(mc.textContent, '0'); assert.equal(mt.textContent, '0'); + assert.equal(rt.textContent, '0'); } finally { cleanup(); } @@ -229,6 +242,68 @@ test('updateFooterStats populates the active-stats element when present', () => } }); +// --------------------------------------------------------------------------- +// meta-row protocol toggle click-to-filter (#888 follow-up) +// --------------------------------------------------------------------------- + +test('clicking the reticulum toggle hides reticulum nodes, mirroring the meshcore chip', async () => { + const { testUtils, env, cleanup } = setupAppWithOptions({ + extraElements: ['protocolToggleMeshcore', 'protocolToggleMeshtastic', 'protocolToggleReticulum'], + }); + // Let the click's applyFilter() async fallout (stats refresh) settle while + // the stub environment is still alive, so nothing rejects after cleanup. + const settle = () => new Promise(resolve => setTimeout(resolve, 20)); + try { + const rt = env.document.getElementById('protocolToggleReticulum'); + const clickHandlers = rt._listeners?.get('click') ?? []; + assert.equal(clickHandlers.length, 1, 'the reticulum toggle is wired at init'); + + clickHandlers[0](); + await settle(); + assert.ok(testUtils.hiddenProtocols.has('reticulum'), 'first click hides reticulum'); + assert.equal( + testUtils.matchesProtocolFilter({ node_id: '!a1b2c3d4', protocol: 'reticulum' }), + false, + 'reticulum nodes are filtered out while hidden', + ); + assert.equal( + testUtils.matchesProtocolFilter({ node_id: '!12ab34cd', protocol: 'meshtastic' }), + true, + 'other protocols stay visible', + ); + + clickHandlers[0](); + await settle(); + assert.ok(!testUtils.hiddenProtocols.has('reticulum'), 'second click shows reticulum again'); + assert.equal( + testUtils.matchesProtocolFilter({ node_id: '!a1b2c3d4', protocol: 'reticulum' }), + true, + ); + } finally { + cleanup(); + } +}); + +test('updateMetaProtocolToggleUI stamps pressed state and labels on the reticulum toggle', () => { + const { testUtils, env, cleanup } = setupAppWithOptions({ + extraElements: ['protocolToggleMeshcore', 'protocolToggleMeshtastic', 'protocolToggleReticulum'], + }); + try { + const rt = env.document.getElementById('protocolToggleReticulum'); + testUtils.hiddenProtocols.add('reticulum'); + testUtils.updateMetaProtocolToggleUI(); + assert.equal(rt.getAttribute('aria-pressed'), 'true'); + assert.equal(rt.getAttribute('aria-label'), 'Show Reticulum nodes'); + + testUtils.hiddenProtocols.delete('reticulum'); + testUtils.updateMetaProtocolToggleUI(); + assert.equal(rt.getAttribute('aria-pressed'), 'false'); + assert.equal(rt.getAttribute('aria-label'), 'Hide Reticulum nodes'); + } finally { + cleanup(); + } +}); + // --------------------------------------------------------------------------- // applyProtocolVisibility // --------------------------------------------------------------------------- @@ -290,6 +365,150 @@ test('applyProtocolVisibility shows both columns when both protocols have active } }); +test('applyProtocolVisibility shows all three toggles when all three protocols are active', () => { + const { testUtils, env, cleanup } = setupAppWithOptions({ + extraElements: ['protocolToggleMeshcore', 'protocolToggleMeshtastic', 'protocolToggleReticulum'], + }); + try { + const mc = env.document.getElementById('protocolToggleMeshcore'); + const mt = env.document.getElementById('protocolToggleMeshtastic'); + const rt = env.document.getElementById('protocolToggleReticulum'); + mc.hidden = true; + mt.hidden = true; + rt.hidden = true; + + testUtils.applyProtocolVisibility({ + meshcore: { hour: 1, day: 2, week: 5, month: 10 }, + meshtastic: { hour: 2, day: 3, week: 8, month: 15 }, + reticulum: { hour: 1, day: 3, week: 3, month: 3 }, + }); + + assert.equal(mc.hidden, false, 'meshcore toggle should be visible'); + assert.equal(mt.hidden, false, 'meshtastic toggle should be visible'); + assert.equal(rt.hidden, false, 'reticulum toggle should be visible'); + } finally { + cleanup(); + } +}); + +test('applyProtocolVisibility shows the reticulum toggle alongside one other active protocol', () => { + const { testUtils, env, cleanup } = setupAppWithOptions({ + extraElements: ['protocolToggleMeshcore', 'protocolToggleMeshtastic', 'protocolToggleReticulum'], + }); + try { + const mc = env.document.getElementById('protocolToggleMeshcore'); + const mt = env.document.getElementById('protocolToggleMeshtastic'); + const rt = env.document.getElementById('protocolToggleReticulum'); + + testUtils.applyProtocolVisibility({ + meshcore: { hour: 0, day: 0, week: 0, month: 0 }, + meshtastic: { hour: 2, day: 3, week: 8, month: 15 }, + reticulum: { hour: 1, day: 3, week: 3, month: 3 }, + }); + + assert.equal(mc.hidden, true, 'inactive meshcore toggle should stay hidden'); + assert.equal(mt.hidden, false, 'meshtastic toggle should be visible'); + assert.equal(rt.hidden, false, 'reticulum toggle should be visible'); + } finally { + cleanup(); + } +}); + +test('applyProtocolVisibility hides every toggle when only one protocol is active', () => { + const { testUtils, env, cleanup } = setupAppWithOptions({ + extraElements: ['protocolToggleMeshcore', 'protocolToggleMeshtastic', 'protocolToggleReticulum'], + }); + try { + const mc = env.document.getElementById('protocolToggleMeshcore'); + const mt = env.document.getElementById('protocolToggleMeshtastic'); + const rt = env.document.getElementById('protocolToggleReticulum'); + + // Filtering is pointless with a single protocol — even when that sole + // protocol is reticulum. + testUtils.applyProtocolVisibility({ + meshcore: { hour: 0, day: 0, week: 0, month: 0 }, + meshtastic: { hour: 0, day: 0, week: 0, month: 0 }, + reticulum: { hour: 1, day: 3, week: 3, month: 3 }, + }); + + assert.equal(mc.hidden, true); + assert.equal(mt.hidden, true); + assert.equal(rt.hidden, true); + } finally { + cleanup(); + } +}); + +test('applyProtocolVisibility un-strands a hidden protocol when its chip disappears', async () => { + const { testUtils, env, cleanup } = setupAppWithOptions({ + extraElements: ['protocolToggleMeshcore', 'protocolToggleMeshtastic', 'protocolToggleReticulum'], + }); + // Let the un-strand's applyFilter() async fallout (stats refresh) settle + // while the stub environment is still alive, so nothing rejects after + // cleanup. + const settle = () => new Promise(resolve => setTimeout(resolve, 20)); + try { + const rt = env.document.getElementById('protocolToggleReticulum'); + + // The user toggled reticulum off while its chip was visible... + testUtils.hiddenProtocols.add('reticulum'); + // ...then reticulum activity dropped below the 2-protocol threshold, so + // the chip is hidden by the visibility rule. + testUtils.applyProtocolVisibility({ + meshcore: { hour: 0, day: 0, week: 0, month: 0 }, + meshtastic: { hour: 2, day: 3, week: 8, month: 15 }, + reticulum: { hour: 0, day: 0, week: 0, month: 0 }, + }); + await settle(); + + assert.equal(rt.hidden, true, 'inactive reticulum chip is hidden'); + assert.ok( + !testUtils.hiddenProtocols.has('reticulum'), + 'the chipless protocol leaves hiddenProtocols so its nodes are not stranded invisible', + ); + assert.equal( + testUtils.matchesProtocolFilter({ node_id: '!a1b2c3d4', protocol: 'reticulum' }), + true, + 'reticulum nodes reappear once no chip can un-hide them', + ); + } finally { + cleanup(); + } +}); + +test('applyProtocolVisibility keeps a user-hidden protocol filtered while its chip stays visible', async () => { + const { testUtils, env, cleanup } = setupAppWithOptions({ + extraElements: ['protocolToggleMeshcore', 'protocolToggleMeshtastic', 'protocolToggleReticulum'], + }); + const settle = () => new Promise(resolve => setTimeout(resolve, 20)); + try { + const rt = env.document.getElementById('protocolToggleReticulum'); + + testUtils.hiddenProtocols.add('reticulum'); + // Reticulum stays active alongside meshtastic: the chip remains visible, + // so the user's explicit hide must be preserved. + testUtils.applyProtocolVisibility({ + meshcore: { hour: 0, day: 0, week: 0, month: 0 }, + meshtastic: { hour: 2, day: 3, week: 8, month: 15 }, + reticulum: { hour: 1, day: 3, week: 3, month: 3 }, + }); + await settle(); + + assert.equal(rt.hidden, false, 'active reticulum chip stays visible'); + assert.ok( + testUtils.hiddenProtocols.has('reticulum'), + 'the explicit hide survives while the chip can still undo it', + ); + assert.equal( + testUtils.matchesProtocolFilter({ node_id: '!a1b2c3d4', protocol: 'reticulum' }), + false, + 'reticulum nodes stay filtered out', + ); + } finally { + cleanup(); + } +}); + test('applyProtocolVisibility handles missing per-protocol data gracefully', () => { const { testUtils, cleanup } = setupApp(); try { diff --git a/web/public/assets/js/app/__tests__/map-activity-card.test.js b/web/public/assets/js/app/__tests__/map-activity-card.test.js index eb67ebde..f443c2e7 100644 --- a/web/public/assets/js/app/__tests__/map-activity-card.test.js +++ b/web/public/assets/js/app/__tests__/map-activity-card.test.js @@ -86,8 +86,14 @@ test('buildMeshActivityModel rebases the sparkline with the protocol toggles', ( assert.notEqual(all.spark.line, mtOnly.spark.line); }); -test('buildMeshActivityModel never renders reticulum', () => { - const model = buildMeshActivityModel({ total: 50, meshtastic: 50, reticulum: 999 }, new Set(), null); +test('buildMeshActivityModel renders a live reticulum row (#888)', () => { + const model = buildMeshActivityModel({ total: 80, meshtastic: 50, reticulum: 30 }, new Set(), null); + assert.deepEqual(model.rows.map(row => row.label), ['Meshtastic', 'Reticulum']); + assert.equal(model.total, 80); +}); + +test('buildMeshActivityModel skips unknown protocol keys', () => { + const model = buildMeshActivityModel({ total: 50, meshtastic: 50, loramesh: 999 }, new Set(), null); assert.deepEqual(model.rows.map(row => row.label), ['Meshtastic']); }); diff --git a/web/public/assets/js/app/__tests__/node-link-helpers.test.js b/web/public/assets/js/app/__tests__/node-link-helpers.test.js index 6294c3e2..0284cb61 100644 --- a/web/public/assets/js/app/__tests__/node-link-helpers.test.js +++ b/web/public/assets/js/app/__tests__/node-link-helpers.test.js @@ -121,8 +121,14 @@ test('renderNodeLongNameLink does not prepend icon for "meshcore" protocol', () assert.ok(html.includes('Alice'), 'should still include the name'); }); -test('renderNodeLongNameLink does not prepend icon for unknown protocol', () => { +test('renderNodeLongNameLink prepends the reticulum icon for "reticulum" protocol', () => { const html = renderNodeLongNameLink('Alice', '!abc123', { protocol: 'reticulum' }); + assert.ok(html.includes('reticulum.svg'), 'should include the reticulum icon'); + assert.ok(!html.includes('meshtastic.svg'), 'should not include the meshtastic icon'); +}); + +test('renderNodeLongNameLink does not prepend icon for unknown protocol', () => { + const html = renderNodeLongNameLink('Alice', '!abc123', { protocol: 'loramesh' }); assert.ok(!html.includes('meshtastic.svg'), 'should not include meshtastic icon for unknown protocol'); }); diff --git a/web/public/assets/js/app/__tests__/protocol-helpers.test.js b/web/public/assets/js/app/__tests__/protocol-helpers.test.js index aec49484..7f21df68 100644 --- a/web/public/assets/js/app/__tests__/protocol-helpers.test.js +++ b/web/public/assets/js/app/__tests__/protocol-helpers.test.js @@ -137,7 +137,14 @@ test('protocolIconPrefixHtml — "meshcore" yields meshcore icon prefix', () => assert.ok(result.endsWith(' '), 'prefix must end with a trailing space'); }); +test('protocolIconPrefixHtml — "reticulum" yields reticulum icon prefix', () => { + const result = protocolIconPrefixHtml('reticulum'); + assert.ok(result.includes('reticulum.svg'), '"reticulum" should produce the reticulum icon'); + assert.ok(!result.includes('meshtastic.svg'), '"reticulum" must not produce the meshtastic icon'); + assert.ok(result.endsWith(' '), 'prefix must end with a trailing space'); +}); + test('protocolIconPrefixHtml — unknown protocol yields empty string', () => { - assert.equal(protocolIconPrefixHtml('reticulum'), '', 'unknown protocol should produce no prefix'); + assert.equal(protocolIconPrefixHtml('loramesh'), '', 'unknown protocol should produce no prefix'); assert.equal(protocolIconPrefixHtml('LoRa'), '', 'unknown protocol should produce no prefix'); }); diff --git a/web/public/assets/js/app/__tests__/stats.test.js b/web/public/assets/js/app/__tests__/stats.test.js index 50cc4705..68ec7394 100644 --- a/web/public/assets/js/app/__tests__/stats.test.js +++ b/web/public/assets/js/app/__tests__/stats.test.js @@ -103,7 +103,7 @@ test('computeLocalActiveNodeStats counts nodes exactly at window boundary', () = test('computeLocalActiveNodeStats bins unknown protocols into meshtastic bucket', () => { const nodes = [ - { last_heard: NOW - 100, protocol: 'reticulum' }, + { last_heard: NOW - 100, protocol: 'loramesh' }, { last_heard: NOW - 200, protocol: 'meshcore' }, ]; const result = computeLocalActiveNodeStats(nodes, NOW); @@ -112,6 +112,19 @@ test('computeLocalActiveNodeStats bins unknown protocols into meshtastic bucket' assert.equal(result.meshtastic.hour, 1); }); +test('computeLocalActiveNodeStats counts reticulum in its own bucket', () => { + const nodes = [ + { last_heard: NOW - 100, protocol: 'reticulum' }, + { last_heard: NOW - 200, protocol: 'meshcore' }, + { last_heard: NOW - 300, protocol: 'meshtastic' }, + ]; + const result = computeLocalActiveNodeStats(nodes, NOW); + assert.equal(result.hour, 3); + assert.equal(result.reticulum.hour, 1); + assert.equal(result.meshcore.hour, 1); + assert.equal(result.meshtastic.hour, 1); +}); + // --------------------------------------------------------------------------- // normaliseActiveNodeStatsPayload (0.7.0 scope → metric → window shape) // --------------------------------------------------------------------------- @@ -381,10 +394,10 @@ test('normaliseActivitySeries keeps oldest-first per-protocol rates, clamped and 'nope', // non-object → skipped ]); assert.deepEqual(series, [ - { meshcore: 10, meshtastic: 20 }, - { meshcore: 0, meshtastic: 5 }, - { meshcore: 7, meshtastic: 0 }, - { meshcore: 0, meshtastic: 3 }, + { meshcore: 10, meshtastic: 20, reticulum: 0 }, + { meshcore: 0, meshtastic: 5, reticulum: 0 }, + { meshcore: 7, meshtastic: 0, reticulum: 0 }, + { meshcore: 0, meshtastic: 3, reticulum: 0 }, ]); }); @@ -402,7 +415,10 @@ test('fetchActivitySeries returns the normalised per-protocol series on success' return { ok: true, async json() { return [{ meshcore: 2, meshtastic: 5 }, { meshcore: 3, meshtastic: 8 }]; } }; }; const series = await fetchActivitySeries({ fetchImpl }); - assert.deepEqual(series, [{ meshcore: 2, meshtastic: 5 }, { meshcore: 3, meshtastic: 8 }]); + assert.deepEqual(series, [ + { meshcore: 2, meshtastic: 5, reticulum: 0 }, + { meshcore: 3, meshtastic: 8, reticulum: 0 }, + ]); assert.match(calls[0], /\/api\/stats\/activity\?window_seconds=86400&bucket_seconds=3600/); }); @@ -421,8 +437,8 @@ test('fetchActivitySeries caches the result for repeated calls with the same fet hits += 1; return { ok: true, async json() { return [{ meshcore: 1, meshtastic: 2 }]; } }; }; - assert.deepEqual(await fetchActivitySeries({ fetchImpl }), [{ meshcore: 1, meshtastic: 2 }]); - assert.deepEqual(await fetchActivitySeries({ fetchImpl }), [{ meshcore: 1, meshtastic: 2 }]); + assert.deepEqual(await fetchActivitySeries({ fetchImpl }), [{ meshcore: 1, meshtastic: 2, reticulum: 0 }]); + assert.deepEqual(await fetchActivitySeries({ fetchImpl }), [{ meshcore: 1, meshtastic: 2, reticulum: 0 }]); assert.equal(hits, 1, 'the second call is served from cache'); }); @@ -436,7 +452,7 @@ test('fetchActivitySeries coalesces concurrent calls into one request', async () fetchActivitySeries({ fetchImpl }), fetchActivitySeries({ fetchImpl }), ]); - assert.deepEqual(a, [{ meshcore: 3, meshtastic: 4 }]); - assert.deepEqual(b, [{ meshcore: 3, meshtastic: 4 }]); + assert.deepEqual(a, [{ meshcore: 3, meshtastic: 4, reticulum: 0 }]); + assert.deepEqual(b, [{ meshcore: 3, meshtastic: 4, reticulum: 0 }]); assert.equal(hits, 1, 'concurrent callers share one in-flight request'); }); diff --git a/web/public/assets/js/app/federation-page.js b/web/public/assets/js/app/federation-page.js index bdd9c0ab..04aaa1ed 100644 --- a/web/public/assets/js/app/federation-page.js +++ b/web/public/assets/js/app/federation-page.js @@ -23,7 +23,7 @@ import { import { resolveLegendVisibility } from './map-legend-visibility.js'; import { mergeConfig } from './settings.js'; import { roleColors } from './role-helpers.js'; -import { meshcoreIconHtml, meshtasticIconHtml } from './protocol-helpers.js'; +import { meshcoreIconHtml, meshtasticIconHtml, reticulumIconHtml } from './protocol-helpers.js'; import { createBasemapLayer } from './basemap-config.js'; import { timeAgoSuffixed } from './main/format-utils.js'; import { @@ -427,6 +427,12 @@ export async function initializeFederationPage(options = {}) { hasValue: hasNumberValue, defaultDirection: 'desc' }, + reticulumNodesCount: { + getValue: inst => toFiniteNumber(inst.reticulum_nodes_count ?? inst.reticulumNodesCount), + compare: compareNumber, + hasValue: hasNumberValue, + defaultDirection: 'desc' + }, latitude: { getValue: inst => toFiniteNumber(inst.latitude), compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'asc' }, longitude: { getValue: inst => toFiniteNumber(inst.longitude), compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'asc' }, lastUpdateTime: { @@ -516,6 +522,8 @@ export async function initializeFederationPage(options = {}) { const mcNodesText = mcNodesVal == null ? '—' : `${meshcoreIconHtml()} ${escapeHtml(String(mcNodesVal))}`; const mtNodesVal = toFiniteNumber(instance.meshtastic_nodes_count ?? instance.meshtasticNodesCount); const mtNodesText = mtNodesVal == null ? '—' : `${meshtasticIconHtml()} ${escapeHtml(String(mtNodesVal))}`; + const rtNodesVal = toFiniteNumber(instance.reticulum_nodes_count ?? instance.reticulumNodesCount); + const rtNodesText = rtNodesVal == null ? '—' : `${reticulumIconHtml()} ${escapeHtml(String(rtNodesVal))}`; // Cell order mirrors `_instances_table.erb`'s traveler-first header // order (SPEC UX9/UX12): where + settings + alive lead; coordinates @@ -528,6 +536,7 @@ export async function initializeFederationPage(options = {}) { ${nodesCountText} ${mcNodesText} ${mtNodesText} + ${rtNodesText} ${lastUpdateCellHtml(instance, nowSec)} ${contactHtml || '—'} ${escapeHtml(instance.version || '')} diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js index ac130489..322058a3 100644 --- a/web/public/assets/js/app/main.js +++ b/web/public/assets/js/app/main.js @@ -295,8 +295,10 @@ export function initializeApp(config) { const autorefreshToggle = document.getElementById('autorefreshToggle'); const protocolToggleMeshcore = document.getElementById('protocolToggleMeshcore'); const protocolToggleMeshtastic = document.getElementById('protocolToggleMeshtastic'); + const protocolToggleReticulum = document.getElementById('protocolToggleReticulum'); const protocolToggleMeshcoreCount = document.getElementById('protocolToggleMeshcoreCount'); const protocolToggleMeshtasticCount = document.getElementById('protocolToggleMeshtasticCount'); + const protocolToggleReticulumCount = document.getElementById('protocolToggleReticulumCount'); const filterInput = document.getElementById('filterInput'); const filterClearButton = document.getElementById('filterClear'); const shortInfoTemplate = document.getElementById('shortInfoOverlayTemplate'); @@ -1956,6 +1958,7 @@ export function initializeApp(config) { const toggles = [ { btn: protocolToggleMeshcore, protocol: 'meshcore', name: 'MeshCore' }, { btn: protocolToggleMeshtastic, protocol: 'meshtastic', name: 'Meshtastic' }, + { btn: protocolToggleReticulum, protocol: 'reticulum', name: 'Reticulum' }, ]; toggles.forEach(({ btn, protocol, name }) => { if (!btn) return; @@ -5626,7 +5629,8 @@ export function initializeApp(config) { * {@link hiddenProtocols} set. * * @param {HTMLElement|null} btn Button element. - * @param {string} protocol Protocol token (``'meshcore'`` or ``'meshtastic'``). + * @param {string} protocol Protocol token (``'meshcore'``, ``'meshtastic'``, + * or ``'reticulum'``). * @returns {void} */ function setupMetaProtocolToggle(btn, protocol) { @@ -5644,6 +5648,7 @@ export function initializeApp(config) { } setupMetaProtocolToggle(protocolToggleMeshcore, 'meshcore'); setupMetaProtocolToggle(protocolToggleMeshtastic, 'meshtastic'); + setupMetaProtocolToggle(protocolToggleReticulum, 'reticulum'); /** * Keep the page/tab and header titles at their base text. @@ -5683,9 +5688,9 @@ export function initializeApp(config) { * counts (audit follow-up 04). Uses the same 7-day active figure as the * legend column counts, so the same protocol shows the same number in both * places; the count both labels the otherwise-unnamed icon button and states - * the MeshCore/Meshtastic split. + * the per-protocol split. * - * @param {{meshcore?: {week: number}, meshtastic?: {week: number}}} stats + * @param {{meshcore?: {week: number}, meshtastic?: {week: number}, reticulum?: {week: number}}} stats * Stats from /api/stats. * @returns {void} */ @@ -5696,6 +5701,9 @@ export function initializeApp(config) { if (protocolToggleMeshtasticCount) { protocolToggleMeshtasticCount.textContent = String(stats?.meshtastic?.week ?? 0); } + if (protocolToggleReticulumCount) { + protocolToggleReticulumCount.textContent = String(stats?.reticulum?.week ?? 0); + } } /** @@ -5718,22 +5726,54 @@ export function initializeApp(config) { * Hides the Charts nav link when meshtastic has no active nodes, and hides * legend columns for protocols with zero weekly activity. * - * @param {{meshcore?: {week: number}, meshtastic?: {week: number}}} stats Stats from /api/stats. + * @param {{meshcore?: {week: number}, meshtastic?: {week: number}, reticulum?: {week: number}}} stats Stats from /api/stats. * @returns {void} */ function applyProtocolVisibility(stats) { const meshcoreWeek = stats?.meshcore?.week ?? 0; const meshtasticWeek = stats?.meshtastic?.week ?? 0; + const reticulumWeek = stats?.reticulum?.week ?? 0; // Hide legend columns for protocols with no activity in the past 7 days. if (meshcoreColEl) meshcoreColEl.style.display = meshcoreWeek === 0 ? 'none' : ''; if (meshtasticColEl) meshtasticColEl.style.display = meshtasticWeek === 0 ? 'none' : ''; - // Show protocol toggle buttons only when both protocols have weekly - // activity — filtering is pointless when only one protocol is present. - const bothActive = meshcoreWeek > 0 && meshtasticWeek > 0; - if (protocolToggleMeshcore) protocolToggleMeshcore.hidden = !bothActive; - if (protocolToggleMeshtastic) protocolToggleMeshtastic.hidden = !bothActive; + // Show a protocol's toggle button only when that protocol has weekly + // activity and at least one other protocol does too — filtering is + // pointless when only one protocol is present. (Generalises the original + // two-protocol "both active" rule to the reticulum era, #888.) + const weeks = [meshcoreWeek, meshtasticWeek, reticulumWeek]; + const activeProtocols = weeks.filter(week => week > 0).length; + const toggleHidden = week => !(week > 0 && activeProtocols >= 2); + // When a chip is hidden by the visibility rule, its protocol must also + // leave the hiddenProtocols set: a user who toggled the protocol off and + // then lost the chip (activity dropped below the 2-protocol threshold) + // would otherwise have no control left to bring those nodes back. The + // strand scenario always leaves at least one protocol active, so an + // all-zero payload (an empty instance, or the degraded local fallback + // after a stats-fetch failure) is deliberately excluded — a transient + // stats blank must not wipe the user's explicit toggle state. + let unstranded = false; + for (const [btn, protocol, week] of [ + [protocolToggleMeshcore, 'meshcore', meshcoreWeek], + [protocolToggleMeshtastic, 'meshtastic', meshtasticWeek], + [protocolToggleReticulum, 'reticulum', reticulumWeek], + ]) { + const hidden = toggleHidden(week); + if (btn) btn.hidden = hidden; + if (hidden && activeProtocols > 0 && hiddenProtocols.has(protocol)) { + hiddenProtocols.delete(protocol); + unstranded = true; + } + } + if (unstranded) { + // Re-run the same sync path a chip click uses so the nodes reappear. + // Bounded: the dropped protocols are no longer in the set, so the + // nested applyFilter's stats callback cannot un-strand again. + updateMetaProtocolToggleUI(); + updateLegendRoleFiltersUI(); + applyFilter(); + } // Charts is meshtastic-only; hide the nav link when no meshtastic activity. document.querySelectorAll('a[href="/charts"]').forEach(el => { diff --git a/web/public/assets/js/app/main/filter-helpers.js b/web/public/assets/js/app/main/filter-helpers.js index 3e822dfd..4e258572 100644 --- a/web/public/assets/js/app/main/filter-helpers.js +++ b/web/public/assets/js/app/main/filter-helpers.js @@ -20,20 +20,24 @@ * @module main/filter-helpers */ -import { isMeshcoreProtocol } from '../protocol-helpers.js'; +import { isMeshcoreProtocol, isReticulumProtocol } from '../protocol-helpers.js'; import { getRoleKey } from '../role-helpers.js'; /** * Canonical protocol token for use in compound filter keys. * - * Collapses null/absent/unknown protocol values to ``'meshtastic'`` so that - * pre-protocol legacy records land in the Meshtastic filter bucket. + * Known non-Meshtastic protocols keep their own bucket so that toggling a + * Meshtastic role filter never hides them; null/absent/unknown protocol + * values collapse to ``'meshtastic'`` so pre-protocol legacy records land in + * the Meshtastic filter bucket. * * @param {string|null|undefined} protocol Raw protocol value. - * @returns {'meshtastic'|'meshcore'} Normalised protocol token. + * @returns {'meshtastic'|'meshcore'|'reticulum'} Normalised protocol token. */ export function normalizeFilterProtocol(protocol) { - return isMeshcoreProtocol(protocol) ? 'meshcore' : 'meshtastic'; + if (isMeshcoreProtocol(protocol)) return 'meshcore'; + if (isReticulumProtocol(protocol)) return 'reticulum'; + return 'meshtastic'; } /** diff --git a/web/public/assets/js/app/map-activity-card.js b/web/public/assets/js/app/map-activity-card.js index 05352704..0a094499 100644 --- a/web/public/assets/js/app/map-activity-card.js +++ b/web/public/assets/js/app/map-activity-card.js @@ -25,8 +25,8 @@ * inputs into DOM. * * Behaviour: - * - ``reticulum`` is never rendered — only ``meshtastic`` and ``meshcore`` have - * rows (MA-F2). + * - ``meshtastic``, ``meshcore``, and ``reticulum`` each render a row + * (MA-F2, extended by #888 when the Reticulum ingestor went live). * - The card hides entirely when the visible total is 0 or the payload carries * no packet rates (MA-F3). * - A protocol hidden via the meta-row toggle (``hiddenProtocols``) drops its @@ -44,18 +44,20 @@ * @module map-activity-card */ -import { MESHTASTIC_ICON_SRC, MESHCORE_ICON_SRC } from './protocol-helpers.js'; +import { MESHTASTIC_ICON_SRC, MESHCORE_ICON_SRC, RETICULUM_ICON_SRC } from './protocol-helpers.js'; /** - * Protocols rendered as rows, in display order (Meshtastic above MeshCore). - * ``reticulum`` is deliberately absent — it is a forward-looking zero stub and - * is never shown (SPEC MA-F2 / S6). + * Protocols rendered as rows, in display order (Meshtastic, MeshCore, + * Reticulum). ``reticulum`` went live with the Reticulum ingestor + * (#888); it was a deliberately-hidden zero stub before that (SPEC + * MA-F2 / S6). * * @type {ReadonlyArray<{protocol: string, label: string, iconSrc: string}>} */ const PROTOCOL_ROWS = Object.freeze([ Object.freeze({ protocol: 'meshtastic', label: 'Meshtastic', iconSrc: MESHTASTIC_ICON_SRC }), Object.freeze({ protocol: 'meshcore', label: 'MeshCore', iconSrc: MESHCORE_ICON_SRC }), + Object.freeze({ protocol: 'reticulum', label: 'Reticulum', iconSrc: RETICULUM_ICON_SRC }), ]); /** @@ -143,7 +145,7 @@ function visibleSeriesTotals(series, hidden) { * Derive the render model from the packet rates, hidden-protocol set, and the * sparkline series. * - * Only ``meshtastic``/``meshcore`` are considered (reticulum is never shown); a + * Every {@link PROTOCOL_ROWS} protocol is considered; a * protocol that is hidden or carries no finite rate is dropped. The displayed * total is the sum of the visible protocol rates (so a toggled-off protocol * rebases it, MA-F4), and each row's bar is sized relative to the busiest diff --git a/web/public/assets/js/app/node-rendering.js b/web/public/assets/js/app/node-rendering.js index 2894d04f..965e5e46 100644 --- a/web/public/assets/js/app/node-rendering.js +++ b/web/public/assets/js/app/node-rendering.js @@ -79,8 +79,8 @@ export function canonicalNodeIdentifier(identifier) { /** * Render a linked long name pointing to the node detail view. * - * When ``protocol`` is a known value (``"meshtastic"`` or ``"meshcore"``), - * the matching protocol icon is prepended. Absent or unknown protocol strings + * When ``protocol`` is a known value (``"meshtastic"``, ``"meshcore"``, or + * ``"reticulum"``), the matching protocol icon is prepended. Absent or unknown protocol strings * produce no icon prefix. An anchor element is only emitted when * ``identifier`` resolves to a non-null detail path. * diff --git a/web/public/assets/js/app/protocol-helpers.js b/web/public/assets/js/app/protocol-helpers.js index 7b4c1340..d719050d 100644 --- a/web/public/assets/js/app/protocol-helpers.js +++ b/web/public/assets/js/app/protocol-helpers.js @@ -20,6 +20,9 @@ export const MESHTASTIC_ICON_SRC = '/assets/img/meshtastic.svg'; /** Relative URL of the MeshCore protocol icon asset. */ export const MESHCORE_ICON_SRC = '/assets/img/meshcore.svg'; +/** Relative URL of the Reticulum protocol icon asset. */ +export const RETICULUM_ICON_SRC = '/assets/img/reticulum.svg'; + /** * Return true when the protocol value is explicitly ``"meshtastic"``. * @@ -78,13 +81,38 @@ export function meshcoreIconHtml() { ' aria-hidden="true">'; } +/** + * Return true when the protocol value represents Reticulum. + * + * @param {string|null|undefined} protocol Protocol string from the API. + * @returns {boolean} Whether the protocol is Reticulum. + */ +export function isReticulumProtocol(protocol) { + if (protocol == null) return false; + return String(protocol).trim() === 'reticulum'; +} + +/** + * Build an HTML snippet that renders the Reticulum glyph as an inline icon. + * + * Follows the same sizing and attribute conventions as {@link meshtasticIconHtml} + * so all protocol icons stay visually consistent when placed side-by-side. + * + * @returns {string} HTML string containing an {@code } element. + */ +export function reticulumIconHtml() { + return `'; +} + /** * Build an HTML prefix (protocol icon plus a trailing space) for inline UI. * * Returns the matching icon only when the protocol is positively known: - * ``"meshtastic"`` → Meshtastic icon, ``"meshcore"`` → MeshCore icon. - * Absent, null, or unrecognised protocol strings yield an empty string — - * no default icon is assumed. + * ``"meshtastic"`` → Meshtastic icon, ``"meshcore"`` → MeshCore icon, + * ``"reticulum"`` → Reticulum icon. Absent, null, or unrecognised protocol + * strings yield an empty string — no default icon is assumed. * * @param {string|null|undefined} protocol Protocol string from the API. * @returns {string} HTML fragment safe to concatenate before visible text. @@ -96,6 +124,9 @@ export function protocolIconPrefixHtml(protocol) { if (isMeshtasticProtocol(protocol)) { return `${meshtasticIconHtml()} `; } + if (isReticulumProtocol(protocol)) { + return `${reticulumIconHtml()} `; + } return ''; } diff --git a/web/public/assets/js/app/stats.js b/web/public/assets/js/app/stats.js index 83bddfff..dd3d4861 100644 --- a/web/public/assets/js/app/stats.js +++ b/web/public/assets/js/app/stats.js @@ -27,11 +27,14 @@ /** * Compute active-node counts from a local node array, including per-protocol - * breakdowns for meshcore and meshtastic. + * breakdowns for meshcore, meshtastic, and reticulum. + * + * Unknown/absent protocols count toward the meshtastic bucket so pre-protocol + * legacy records stay visible in the header split. * * @param {Array} nodes Node payloads. * @param {number} nowSeconds Reference timestamp (Unix seconds). - * @returns {{hour: number, day: number, week: number, month: number, sampled: boolean, meshcore?: Object, meshtastic?: Object}} Local count snapshot. + * @returns {{hour: number, day: number, week: number, month: number, sampled: boolean, meshcore?: Object, meshtastic?: Object, reticulum?: Object}} Local count snapshot. */ export function computeLocalActiveNodeStats(nodes, nowSeconds) { const safeNodes = Array.isArray(nodes) ? nodes : []; @@ -45,6 +48,7 @@ export function computeLocalActiveNodeStats(nodes, nowSeconds) { const counts = { sampled: true }; const meshcore = {}; const meshtastic = {}; + const reticulum = {}; for (const window of windows) { const active = safeNodes.filter(node => { const lastHeard = Number(node?.last_heard); @@ -52,10 +56,14 @@ export function computeLocalActiveNodeStats(nodes, nowSeconds) { }); counts[window.key] = active.length; meshcore[window.key] = active.filter(n => n.protocol === 'meshcore').length; - meshtastic[window.key] = active.filter(n => n.protocol !== 'meshcore').length; + reticulum[window.key] = active.filter(n => n.protocol === 'reticulum').length; + meshtastic[window.key] = active.filter( + n => n.protocol !== 'meshcore' && n.protocol !== 'reticulum' + ).length; } counts.meshcore = meshcore; counts.meshtastic = meshtastic; + counts.reticulum = reticulum; return counts; } @@ -86,10 +94,11 @@ function normaliseProtocolBucket(bucket) { * * Returns null when ``total.packets.hour`` is absent or non-finite (e.g. a * pre-MA5 instance), so the mesh-activity card can hide rather than render a - * bogus 0. ``meshcore``/``meshtastic`` are included only when present. + * bogus 0. ``meshcore``/``meshtastic``/``reticulum`` are included only when + * present. * * @param {*} payload Candidate JSON object from the stats endpoint. - * @returns {{total: number, meshcore?: number, meshtastic?: number}|null} Rates or null. + * @returns {{total: number, meshcore?: number, meshtastic?: number, reticulum?: number}|null} Rates or null. */ function normalisePacketsRates(payload) { const readHourRate = scope => { @@ -103,8 +112,10 @@ function normalisePacketsRates(payload) { const rates = { total }; const meshcore = readHourRate('meshcore'); const meshtastic = readHourRate('meshtastic'); + const reticulum = readHourRate('reticulum'); if (meshcore !== null) rates.meshcore = meshcore; if (meshtastic !== null) rates.meshtastic = meshtastic; + if (reticulum !== null) rates.reticulum = reticulum; return rates; } @@ -120,7 +131,7 @@ function normalisePacketsRates(payload) { * current shape is parsed. * * @param {*} payload Candidate JSON object from the stats endpoint. - * @returns {{hour: number, day: number, week: number, month: number, sampled: boolean, meshcore?: Object, meshtastic?: Object}|null} Normalized stats or null. + * @returns {{hour: number, day: number, week: number, month: number, sampled: boolean, meshcore?: Object, meshtastic?: Object, reticulum?: Object}|null} Normalized stats or null. */ export function normaliseActiveNodeStatsPayload(payload) { if (!payload || typeof payload !== 'object') { @@ -136,8 +147,10 @@ export function normaliseActiveNodeStatsPayload(payload) { }; const meshcore = normaliseProtocolBucket(payload.meshcore?.nodes); const meshtastic = normaliseProtocolBucket(payload.meshtastic?.nodes); + const reticulum = normaliseProtocolBucket(payload.reticulum?.nodes); if (meshcore) result.meshcore = meshcore; if (meshtastic) result.meshtastic = meshtastic; + if (reticulum) result.reticulum = reticulum; const packets = normalisePacketsRates(payload); if (packets) result.packets = packets; return result; @@ -266,8 +279,9 @@ let activitySeriesFetchImpl = null; * with the meta-row toggles, exactly like the headline number. * * @param {*} payload Candidate JSON (array of bucket objects). - * @returns {Array<{meshcore: number, meshtastic: number}>|null} Oldest-first - * per-protocol packets/hour, or null when there is nothing usable. + * @returns {Array<{meshcore: number, meshtastic: number, reticulum: number}>|null} + * Oldest-first per-protocol packets/hour, or null when there is nothing + * usable. */ export function normaliseActivitySeries(payload) { if (!Array.isArray(payload)) { @@ -280,10 +294,12 @@ export function normaliseActivitySeries(payload) { } const meshcore = Number(bucket.meshcore); const meshtastic = Number(bucket.meshtastic); - if (Number.isFinite(meshcore) || Number.isFinite(meshtastic)) { + const reticulum = Number(bucket.reticulum); + if (Number.isFinite(meshcore) || Number.isFinite(meshtastic) || Number.isFinite(reticulum)) { series.push({ meshcore: Number.isFinite(meshcore) ? Math.max(0, Math.trunc(meshcore)) : 0, meshtastic: Number.isFinite(meshtastic) ? Math.max(0, Math.trunc(meshtastic)) : 0, + reticulum: Number.isFinite(reticulum) ? Math.max(0, Math.trunc(reticulum)) : 0, }); } } diff --git a/web/spec/app_spec.rb b/web/spec/app_spec.rb index 4cd45708..be4c29b6 100644 --- a/web/spec/app_spec.rb +++ b/web/spec/app_spec.rb @@ -7258,7 +7258,7 @@ RSpec.describe "Potato Mesh Sinatra app" do ) # The pre-0.7.0 flat key is gone (breaking change). expect(payload).not_to have_key("active_nodes") - # reticulum is an always-zero forward-looking stub. + # reticulum is live but nothing reticulum was seeded here, so all zero. expect(payload["reticulum"]["nodes"].values).to all(eq(0)) expect(payload["reticulum"]["messages"].values).to all(eq(0)) expect(payload["reticulum"]["telemetry"].values).to all(eq(0)) diff --git a/web/spec/data_processing_spec.rb b/web/spec/data_processing_spec.rb index 99efe533..e9fab38d 100644 --- a/web/spec/data_processing_spec.rb +++ b/web/spec/data_processing_spec.rb @@ -584,6 +584,106 @@ RSpec.describe PotatoMesh::App::DataProcessing do end end + # --------------------------------------------------------------------------- + # upsert_node — cross-protocol node-row hijack guard + # + # nodes.node_id is a global TEXT primary key, and both the MeshCore and + # Reticulum id mappings truncate a native identifier to its first 4 bytes, + # so nodes on *different* protocols can collide on one node_id. A colliding + # record must be skipped wholesale: reticulum announces stamp a wall-clock + # lastHeard, so the freshness guard alone would let them flip the stored + # row's protocol and overwrite its fields. Same-protocol collisions remain + # the accepted MeshCore-inherited merge behaviour (CONTRACTS.md, "Reticulum + # node id mapping"). + # --------------------------------------------------------------------------- + describe "#upsert_node — cross-protocol id collision guard" do + include_context "with isolated db" + + it "leaves an existing meshtastic row untouched when a reticulum record collides" do + db = open_db + dp.upsert_node(db, "!aabbccdd", { + "lastHeard" => now - 600, + "num" => 0xaabbccdd, + "user" => { "longName" => "Meshtastic Original", "shortName" => "MTOR" }, + }) + dp.upsert_node(db, "!aabbccdd", { + "lastHeard" => now, + "user" => { + "longName" => "Reticulum Impostor", + "shortName" => "aabb", + "publicKey" => "aabbccdd" + "00" * 12, + }, + }, protocol: "reticulum") + row = read_node(db) + db.close + expect(row["protocol"]).to eq("meshtastic") + expect(row["long_name"]).to eq("Meshtastic Original") + expect(row["public_key"]).to be_nil + expect(row["last_heard"]).to eq(now - 600) + end + + it "leaves an existing reticulum row untouched when a meshtastic record collides" do + db = open_db + dp.upsert_node(db, "!aabbccdd", { + "lastHeard" => now - 600, + "user" => { + "longName" => "Reticulum Original", + "shortName" => "aabb", + "publicKey" => "aabbccdd" + "00" * 12, + }, + }, protocol: "reticulum") + dp.upsert_node(db, "!aabbccdd", { + "lastHeard" => now, + "num" => 0xaabbccdd, + "user" => { "longName" => "Meshtastic Impostor", "shortName" => "MTIM" }, + }) + row = read_node(db) + db.close + expect(row["protocol"]).to eq("reticulum") + expect(row["long_name"]).to eq("Reticulum Original") + expect(row["short_name"]).to eq("aabb") + expect(row["last_heard"]).to eq(now - 600) + end + + it "still applies normal same-protocol updates" do + db = open_db + dp.upsert_node(db, "!aabbccdd", { + "lastHeard" => now - 600, + "user" => { + "longName" => "Reticulum Node", + "shortName" => "aabb", + "publicKey" => "aabbccdd" + "00" * 12, + }, + }, protocol: "reticulum") + dp.upsert_node(db, "!aabbccdd", { + "lastHeard" => now, + "user" => { + "longName" => "Reticulum Node Renamed", + "shortName" => "aabb", + "publicKey" => "aabbccdd" + "00" * 12, + }, + }, protocol: "reticulum") + row = read_node(db) + db.close + expect(row["protocol"]).to eq("reticulum") + expect(row["long_name"]).to eq("Reticulum Node Renamed") + expect(row["last_heard"]).to eq(now) + end + + it "keeps the #747 self-heal: a meshcore record reclaims a default-meshtastic row" do + db = open_db + dp.upsert_node(db, "!aabbccdd", { "lastHeard" => now - 600, "num" => 0xaabbccdd }) + dp.upsert_node(db, "!aabbccdd", { + "lastHeard" => now, + "user" => { "longName" => "MeshCore Contact", "shortName" => "MC" }, + }, protocol: "meshcore") + row = read_node(db) + db.close + expect(row["protocol"]).to eq("meshcore") + expect(row["long_name"]).to eq("MeshCore Contact") + end + end + # --------------------------------------------------------------------------- # upsert_node — issue #782: position sentinel handling # @@ -3208,12 +3308,22 @@ RSpec.describe PotatoMesh::App::DataProcessing do result = dp_with_lookup.send( :resolve_record_protocol, registered_db, - { "protocol" => "reticulum" }, + { "protocol" => "loramesh" }, "!mcingest1", ) expect(result).to eq("meshcore") end + it "honours an explicit reticulum stamp" do + result = dp_with_lookup.send( + :resolve_record_protocol, + registered_db, + { "protocol" => "reticulum" }, + "!mcingest1", + ) + expect(result).to eq("reticulum") + end + it "ignores a non-Hash record and falls back to ingestor lookup" do result = dp_with_lookup.send( :resolve_record_protocol, @@ -3248,13 +3358,13 @@ RSpec.describe PotatoMesh::App::DataProcessing do dp_with_lookup.send( :resolve_record_protocol, registered_db, - { "protocol" => "reticulum" }, + { "protocol" => "loramesh" }, "!mcingest1", ) expect(warnings).not_to be_empty log = warnings.first expect(log[:message]).to match(/malformed protocol stamp/i) - expect(log[:value]).to eq("reticulum") + expect(log[:value]).to eq("loramesh") expect(log[:ingestor]).to eq("!mcingest1") end @@ -3291,12 +3401,13 @@ RSpec.describe PotatoMesh::App::DataProcessing do expect(helper.send(:normalize_protocol_value, "meshcore")).to eq("meshcore") expect(helper.send(:normalize_protocol_value, "MESHTASTIC")).to eq("meshtastic") expect(helper.send(:normalize_protocol_value, " Meshcore ")).to eq("meshcore") + expect(helper.send(:normalize_protocol_value, "Reticulum")).to eq("reticulum") end it "returns nil for unknown or malformed values" do expect(helper.send(:normalize_protocol_value, nil)).to be_nil expect(helper.send(:normalize_protocol_value, "")).to be_nil - expect(helper.send(:normalize_protocol_value, "reticulum")).to be_nil + expect(helper.send(:normalize_protocol_value, "loramesh")).to be_nil expect(helper.send(:normalize_protocol_value, 42)).to be_nil end end diff --git a/web/spec/protocol_spec.rb b/web/spec/protocol_spec.rb index 92552484..d5046d22 100644 --- a/web/spec/protocol_spec.rb +++ b/web/spec/protocol_spec.rb @@ -607,7 +607,7 @@ RSpec.describe "Multi-protocol support" do rx_iso: Time.at(now - 10).utc.iso8601, text: "garbage protocol value", ingestor: MESHCORE_INGESTOR_ID, - protocol: "reticulum", + protocol: "loramesh", } post "/api/messages", [msg].to_json, auth_headers expect(last_response.status).to eq(201) diff --git a/web/spec/queries_spec.rb b/web/spec/queries_spec.rb index e02bf75e..8236fbc2 100644 --- a/web/spec/queries_spec.rb +++ b/web/spec/queries_spec.rb @@ -1397,14 +1397,18 @@ RSpec.describe PotatoMesh::App::Queries do expect(stats["total"]["telemetry"]["month"]).to eq(1) end - it "emits reticulum as an all-zero stub" do + it "counts reticulum nodes under their own live scope" do with_db do |db| db.execute("INSERT INTO nodes(node_id, num, last_heard, first_heard, role) VALUES (?,?,?,?,?)", ["!any00001", 1, now, now, "CLIENT"]) + db.execute( + "INSERT INTO nodes(node_id, num, last_heard, first_heard, role, protocol) VALUES (?,?,?,?,?,?)", + ["!reti0001", 2, now, now, "CLIENT", "reticulum"], + ) end stats = queries.query_active_node_stats(now: now) - stats["reticulum"].each_value do |windows| - windows.each_value { |count| expect(count).to eq(0) } - end + expect(stats["reticulum"]["nodes"]["day"]).to eq(1) + expect(stats["reticulum"]["messages"]["day"]).to eq(0) + expect(stats["total"]["nodes"]["day"]).to eq(2) end it "zeroes message counts in private mode but keeps nodes and telemetry" do @@ -1567,14 +1571,13 @@ RSpec.describe PotatoMesh::App::Queries do expect(queries.query_packets_per_hour(now: now)["meshcore"]).to eq(20) # 480 / 24 end - it "keeps reticulum a zero stub even when reticulum activity exists" do + it "reports a live reticulum rate when reticulum activity exists" do seed_activity([["!reti0001", now - 100, 720, "reticulum"]]) result = queries.query_packets_per_hour(now: now) - # The reticulum scope is always emitted as zero (forward-looking stub)… - expect(result["reticulum"]).to eq(0) - # …but a reticulum ingestor still contributes to the protocol-agnostic - # total (summed across protocols). - expect(result["total"]).to eq(30) # sole protocol: 720 / 24 + # Reticulum went live with the Reticulum ingestor (#888): the scope + # carries its real rate and contributes to the protocol-summed total. + expect(result["reticulum"]).to eq(30) # 720 / 24 + expect(result["total"]).to eq(30) end it "rounds the hourly rate to the nearest integer" do @@ -1659,7 +1662,7 @@ RSpec.describe PotatoMesh::App::Queries do expect(starts).to eq(starts.sort) end - it "folds reticulum into the total but emits no reticulum series" do + it "emits a reticulum series alongside the other protocols" do seed_activity_rows( [ ["!coreaaaa", now - 100, 3_600, "meshcore"], @@ -1668,7 +1671,7 @@ RSpec.describe PotatoMesh::App::Queries do ) bucket = queries.query_activity_buckets(window_seconds: 86_400, bucket_seconds: 3_600, now: now).first expect(bucket["meshcore"]).to eq(3600) - expect(bucket).not_to have_key("reticulum") + expect(bucket["reticulum"]).to eq(1800) expect(bucket["total"]).to eq(3600 + 1800) end diff --git a/web/spec/reticulum_spec.rb b/web/spec/reticulum_spec.rb new file mode 100644 index 00000000..5c552619 --- /dev/null +++ b/web/spec/reticulum_spec.rb @@ -0,0 +1,313 @@ +# 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. + +# frozen_string_literal: true + +require_relative "spec_helper" + +# Reticulum protocol support (#888). Mirrors the MeshCore coverage in +# protocol_spec.rb with fixtures shaped like the Python Reticulum ingestor's +# announce-derived node payloads: id = "!" + first 4 bytes of the destination +# hash, user.publicKey = the full 32-hex destination hash, no SNR/RSSI, no +# user.role, no position, no deviceMetrics, hopsAway omitted when unknown. +RSpec.describe "Reticulum protocol support" do + let(:app) { Sinatra::Application } + let(:api_token) { "test-token" } + let(:auth_headers) do + { + "CONTENT_TYPE" => "application/json", + "HTTP_AUTHORIZATION" => "Bearer #{api_token}", + } + end + let(:now) { Time.now.to_i } + + RETICULUM_INGESTOR_ID = "!feedf00d".freeze + RETICULUM_NODE_ID = "!a1b2c3d4".freeze + RETICULUM_DEST_HASH = "a1b2c3d4e5f60718293a4b5c6d7e8f90".freeze + RETICULUM_NODE_ID2 = "!0badcafe".freeze + MESHTASTIC_PEER_ID = "!12ab34cd".freeze + + # Announce-derived node fixture, exactly the shape the Reticulum ingestor + # POSTs (no snr/rssi/position/deviceMetrics/user.role). + def reticulum_node_fixture(last_heard:, hops_away: 2) + node = { + "user" => { + "longName" => "Argos Station", + "shortName" => "a1b2", + "publicKey" => RETICULUM_DEST_HASH, + }, + "lastHeard" => last_heard, + "protocol" => "reticulum", + } + node["hopsAway"] = hops_away if hops_away + node + end + + before do + @original_token = ENV.fetch("API_TOKEN", nil) + ENV["API_TOKEN"] = api_token + clear_tables + PotatoMesh::App::ApiCache.invalidate_all + end + + after do + ENV["API_TOKEN"] = @original_token + clear_tables + end + + # Open a database connection for direct inspection. + # + # @param readonly [Boolean] whether to open in read-only mode. + # @yieldparam db [SQLite3::Database] open database handle. + # @return [void] + def with_db(readonly: false) + db = PotatoMesh::Application.open_database(readonly: readonly) + db.results_as_hash = true + yield db + ensure + db&.close + end + + # Remove all rows from tables exercised by these tests. + # + # @return [void] + def clear_tables + with_db do |db| + db.execute("DELETE FROM messages") + db.execute("DELETE FROM positions") + db.execute("DELETE FROM telemetry") + db.execute("DELETE FROM nodes") + db.execute("DELETE FROM ingestors") + db.execute("DELETE FROM ingestor_activity") + end + end + + # Register the Reticulum ingestor heartbeat via the API. + # + # @return [Rack::MockResponse] the POST response. + def register_reticulum_ingestor + payload = { + node_id: RETICULUM_INGESTOR_ID, + start_time: now - 60, + last_seen_time: now, + version: "0.7.4", + protocol: "reticulum", + } + post "/api/ingestors", payload.to_json, auth_headers + last_response + end + + # POST the fixture node batch the way the ingestor does (wrapper-level + # protocol stamp plus ingestor id). + # + # @return [Rack::MockResponse] the POST response. + def post_reticulum_nodes + payload = { + RETICULUM_NODE_ID => reticulum_node_fixture(last_heard: now - 30), + "ingestor" => RETICULUM_INGESTOR_ID, + "protocol" => "reticulum", + } + post "/api/nodes", payload.to_json, auth_headers + last_response + end + + describe "POST /api/ingestors" do + it "stores the reticulum protocol" do + expect(register_reticulum_ingestor.status).to eq(201) + + with_db(readonly: true) do |db| + row = db.get_first_row("SELECT protocol FROM ingestors WHERE node_id = ?", [RETICULUM_INGESTOR_ID]) + expect(row["protocol"]).to eq("reticulum") + end + end + + it "filters /api/ingestors by protocol=reticulum" do + register_reticulum_ingestor + get "/api/ingestors?protocol=reticulum", {}, auth_headers + + expect(last_response.status).to eq(200) + rows = JSON.parse(last_response.body) + expect(rows.map { |r| r["node_id"] }).to eq([RETICULUM_INGESTOR_ID]) + expect(rows.first["protocol"]).to eq("reticulum") + end + end + + describe "POST /api/nodes" do + it "stores reticulum nodes under their own protocol via the wrapper stamp" do + register_reticulum_ingestor + expect(post_reticulum_nodes.status).to eq(201) + + with_db(readonly: true) do |db| + row = db.get_first_row("SELECT * FROM nodes WHERE node_id = ?", [RETICULUM_NODE_ID]) + expect(row["protocol"]).to eq("reticulum") + expect(row["long_name"]).to eq("Argos Station") + expect(row["short_name"]).to eq("a1b2") + expect(row["public_key"]).to eq(RETICULUM_DEST_HASH) + expect(row["hops_away"]).to eq(2) + end + end + + it "stores no fabricated radio/telemetry/position values" do + register_reticulum_ingestor + post_reticulum_nodes + + with_db(readonly: true) do |db| + row = db.get_first_row("SELECT * FROM nodes WHERE node_id = ?", [RETICULUM_NODE_ID]) + expect(row["snr"]).to be_nil + expect(row["rssi"]).to be_nil + expect(row["battery_level"]).to be_nil + expect(row["voltage"]).to be_nil + expect(row["latitude"]).to be_nil + expect(row["longitude"]).to be_nil + expect(row["position_time"]).to be_nil + expect(row["role"]).to be_nil + expect(row["lora_freq"]).to be_nil + expect(row["modem_preset"]).to be_nil + end + end + + it "classifies reticulum before the ingestor heartbeat registers (startup race)" do + # No register_reticulum_ingestor call: the wrapper stamp alone must win. + expect(post_reticulum_nodes.status).to eq(201) + + with_db(readonly: true) do |db| + row = db.get_first_row("SELECT protocol FROM nodes WHERE node_id = ?", [RETICULUM_NODE_ID]) + expect(row["protocol"]).to eq("reticulum") + end + end + + it "honours a per-node reticulum stamp inside a foreign batch" do + payload = { + RETICULUM_NODE_ID2 => { + "user" => { "shortName" => "0bad", "publicKey" => "0badcafe0badcafe0badcafe0badcafe" }, + "lastHeard" => now - 5, + "protocol" => "reticulum", + }, + MESHTASTIC_PEER_ID => { "num" => 0x12ab34cd, "lastHeard" => now - 5 }, + } + post "/api/nodes", payload.to_json, auth_headers + expect(last_response.status).to eq(201) + + with_db(readonly: true) do |db| + expect(db.get_first_value("SELECT protocol FROM nodes WHERE node_id = ?", [RETICULUM_NODE_ID2])).to eq("reticulum") + expect(db.get_first_value("SELECT protocol FROM nodes WHERE node_id = ?", [MESHTASTIC_PEER_ID])).to eq("meshtastic") + end + end + + it "accepts a hopsAway-less announce (unknown hop count stays absent)" do + payload = { + RETICULUM_NODE_ID => reticulum_node_fixture(last_heard: now - 30, hops_away: nil), + "protocol" => "reticulum", + } + post "/api/nodes", payload.to_json, auth_headers + expect(last_response.status).to eq(201) + + with_db(readonly: true) do |db| + expect(db.get_first_value("SELECT hops_away FROM nodes WHERE node_id = ?", [RETICULUM_NODE_ID])).to be_nil + end + end + end + + describe "GET /api/nodes rendering payload" do + before do + register_reticulum_ingestor + post_reticulum_nodes + end + + it "serves the reticulum node with its protocol and hop count" do + get "/api/nodes", {}, auth_headers + + expect(last_response.status).to eq(200) + node = JSON.parse(last_response.body).find { |r| r["node_id"] == RETICULUM_NODE_ID } + expect(node).not_to be_nil + expect(node["protocol"]).to eq("reticulum") + expect(node["hops_away"]).to eq(2) + expect(node["long_name"]).to eq("Argos Station") + expect(node["short_name"]).to eq("a1b2") + end + + it "omits absent metrics instead of fabricating zeros" do + get "/api/nodes", {}, auth_headers + + node = JSON.parse(last_response.body).find { |r| r["node_id"] == RETICULUM_NODE_ID } + %w[snr rssi battery_level voltage latitude longitude position_time lora_freq modem_preset].each do |key| + expect(node).not_to have_key(key), "expected #{key} to be omitted for a reticulum node" + end + end + + it "filters ?protocol=reticulum to reticulum nodes only" do + with_db do |db| + db.execute( + "INSERT INTO nodes(node_id, num, last_heard, first_heard, protocol) VALUES(?,?,?,?,?)", + [MESHTASTIC_PEER_ID, 0x12ab34cd, now - 10, now - 20, "meshtastic"], + ) + end + + get "/api/nodes?protocol=reticulum", {}, auth_headers + ids = JSON.parse(last_response.body).map { |r| r["node_id"] } + expect(ids).to eq([RETICULUM_NODE_ID]) + + get "/api/nodes?protocol=meshtastic", {}, auth_headers + ids = JSON.parse(last_response.body).map { |r| r["node_id"] } + expect(ids).to include(MESHTASTIC_PEER_ID) + expect(ids).not_to include(RETICULUM_NODE_ID) + end + end + + describe "GET /api/stats" do + it "counts reticulum nodes under the live reticulum scope" do + register_reticulum_ingestor + post_reticulum_nodes + + get "/api/stats" + + expect(last_response.status).to eq(200) + payload = JSON.parse(last_response.body) + expect(payload["reticulum"]["nodes"]["day"]).to eq(1) + expect(payload["reticulum"]["nodes"]["week"]).to eq(1) + expect(payload["total"]["nodes"]["day"]).to be >= 1 + # No packet activity was seeded, so the rate metric stays zero. + expect(payload["reticulum"]["packets"]).to eq("hour" => 0) + end + + it "reports live reticulum packets/hour from ingestor activity" do + register_reticulum_ingestor + with_db do |db| + db.execute( + "INSERT INTO ingestor_activity(ingestor_id, at, packets, protocol) VALUES (?,?,?,?)", + [RETICULUM_INGESTOR_ID, now - 100, 720, "reticulum"], + ) + end + + get "/api/stats" + + payload = JSON.parse(last_response.body) + expect(payload["reticulum"]["packets"]).to eq("hour" => 30) # 720 / 24 + expect(payload["total"]["packets"]).to eq("hour" => 30) + end + end + + describe "GET /nodes/:id detail page" do + it "renders the reticulum node detail shell" do + register_reticulum_ingestor + post_reticulum_nodes + + get "/nodes/#{RETICULUM_NODE_ID}" + + expect(last_response.status).to eq(200) + expect(last_response.body).to include("Argos Station") + expect(last_response.body).to include("a1b2") + end + end +end diff --git a/web/spec/ux_audit_spec.rb b/web/spec/ux_audit_spec.rb index 32b40811..9e05b644 100644 --- a/web/spec/ux_audit_spec.rb +++ b/web/spec/ux_audit_spec.rb @@ -170,12 +170,20 @@ RSpec.describe "UX audit remediation markup" do expect(html).not_to include("join-line__more") end - it "labels the two protocol toggles with per-protocol count elements" do + it "labels the protocol toggles with per-protocol count elements" do html = body_of("/") expect(html).to include('id="protocolToggleMeshcoreCount"') expect(html).to include('id="protocolToggleMeshtasticCount"') + expect(html).to include('id="protocolToggleReticulumCount"') expect(html).to include("protocol-toggle-count") end + + it "renders the reticulum toggle chip mirroring the other protocols (#888)" do + html = body_of("/") + expect(html).to include('id="protocolToggleReticulum"') + expect(html).to include('aria-label="Hide Reticulum nodes"') + expect(html).to include("/assets/img/reticulum.svg") + end end describe "footer dot separators (Post-Deploy 02·03)" do diff --git a/web/views/layouts/app.erb b/web/views/layouts/app.erb index 2947a828..b5e9d3ec 100644 --- a/web/views/layouts/app.erb +++ b/web/views/layouts/app.erb @@ -250,8 +250,8 @@ <% end %> <%# Protocol toggles carry the live per-protocol count (audit follow-up - 04): the two icon buttons were unlabelled, so a count both names them - and states the MeshCore/Meshtastic split. JS fills the counts. %> + 04): the icon buttons were unlabelled, so a count both names them + and states the per-protocol split. JS fills the counts. %> + <%# The join strip moved to the footer (audit follow-up 04), freeing the meta row for the per-minute vital signs. %> diff --git a/web/views/shared/_instances_table.erb b/web/views/shared/_instances_table.erb index 725836d1..b779fe5b 100644 --- a/web/views/shared/_instances_table.erb +++ b/web/views/shared/_instances_table.erb @@ -28,6 +28,7 @@ Active Nodes (24h) MeshCore (24h) Meshtastic (24h) + Reticulum (24h) Last Update Contact Version