diff --git a/ACCEPTANCE.md b/ACCEPTANCE.md index f78b67c..00fd8aa 100644 --- a/ACCEPTANCE.md +++ b/ACCEPTANCE.md @@ -4040,24 +4040,30 @@ older than the configured window (≥ 24 h), so the table cannot grow unbounded. ( cd web && bundle exec rspec spec/queries_spec.rb -e "packets_per_hour" ) ``` **Expected:** pass. With two `meshcore` ingestors reporting different 24 h packet -totals, `packets_per_hour.meshcore` = `MAX(total_A, total_B) ÷ 24` — the busiest -single vantage, so the quieter ingestor and any overlap never inflate it. `total` -is the `MAX` across **all** ingestors regardless of protocol; a protocol with no -active ingestor reads `0`. Rows older than 24 h do not contribute. +totals, the meshcore rate = `MAX(total_A, total_B) ÷ 24` — the busiest single +vantage, so the quieter ingestor and any overlap never inflate it. `total` is the +**SUM** of the per-protocol rates (distinct protocols never share the air, so they +add — e.g. meshcore + meshtastic), a protocol with no active ingestor reads `0`, +and rows older than 24 h do not contribute. `query_packets_per_hour` returns these +per-protocol rates, which the `GET /api/stats` route folds into each scope as +`.packets.hour` (MA-A5). -### MA-A5 — `/api/stats` exposes `packets_per_hour` additively — MA5 +### MA-A5 — `/api/stats` exposes packets as an additive `.packets.hour` metric — MA5 ```bash curl -s http://127.0.0.1:41447/api/stats \ - | python3 -c 'import sys,json; d=json.load(sys.stdin); p=d["packets_per_hour"]; \ + | python3 -c 'import sys,json; d=json.load(sys.stdin); \ SC=("total","meshcore","meshtastic","reticulum"); \ -print(all(isinstance(p[s],(int,float)) for s in SC) and p["reticulum"]==0 \ -and set(SC).issubset(d) and all(m in d["total"] for m in ("nodes","messages","telemetry")))' +print("packets_per_hour" not in d \ +and all(isinstance(d[s]["packets"]["hour"],(int,float)) for s in SC) \ +and d["reticulum"]["packets"]["hour"]==0 \ +and all(m in d["total"] for m in ("nodes","messages","telemetry","packets")))' ``` -**Expected:** prints `True` — the response carries an additive top-level -`packets_per_hour` map keyed `{total, meshcore, meshtastic, reticulum}` -(`reticulum` a `0` stub), **and** the pre-existing scope × metric × window tree -(S1: each scope still carrying `nodes`/`messages`/`telemetry`) is unchanged and -still present. No version bump — **S-A1** still passes. +**Expected:** prints `True` — each scope carries an additive `packets` metric with +a single `hour` window (`.packets.hour`, the MA4 rate; `reticulum` a `0` +stub), the old top-level `packets_per_hour` map is **gone**, **and** the +pre-existing scope × metric × window tree (S1: each scope still carrying +`nodes`/`messages`/`telemetry`) is unchanged and still present. No version bump — +**S-A1** still passes. ### MA-A6 — Announcement content is dogfed from the instance API — MA6 ```bash @@ -4066,7 +4072,7 @@ still present. No version bump — **S-A1** still passes. **Expected:** pass. The announcement string is exactly `" activity in the last 24h: active nodes, packets/hour. https://"`, where `` = the target's `GET /api/stats` `.nodes.day` and `` = -`GET /api/stats` `packets_per_hour.` — both fetched over HTTP from +`GET /api/stats` `.packets.hour` — both fetched over HTTP from ``, never computed from the ingestor's local counters — and the rendered line is truncated to the protocol's character limit. `` = the configured `INSTANCE_DOMAIN`. @@ -4113,7 +4119,7 @@ and `send_channel_announcement` is **not** a required member. git grep -nE 'packets|ingestor_activity|packets_per_hour' -- data/mesh_ingestor/CONTRACTS.md ``` **Expected:** the additive heartbeat `packets` field, the `ingestor_activity` -schema, and the `GET /api/stats` `packets_per_hour` addition are all documented in +schema, and the `GET /api/stats` `.packets.hour` addition are all documented in `CONTRACTS.md` (Layer C source of truth). The engineering bar (100 % tests/docs/ headers/lint) is enforced by Layer **B** (B1–B5); behavior is covered by MA-A1…MA-A9. @@ -4127,9 +4133,159 @@ MA-A1…MA-A9. remain green: **A1a/A1b** (apex — the new ingestor→instance GET and the LoRa announcement add no broker term or dependency); **A4b** (MeshProtocol isinstance conformance — send stays optional/duck-typed, MA9); **S-A1** (the `/api/stats` -scope × metric × window tree is unchanged; `packets_per_hour` is an additive -sibling — no version bump, so `test_version_sync.py` is unaffected); **A2a/A2b** +scope × metric × window tree is unchanged; `packets.hour` is an additive metric +under each scope — no version bump, so `test_version_sync.py` is unaffected); **A2a/A2b** (privacy — the message API still 404s under `PRIVATE`, and the announcement fail-closes on the same flag, MA7); **C2** (`tests/test_mesh.py` — the `POST /api/ingestors` `packets` field is additive and old payloads still validate); and **B1** (all suites). No `/api/*` response shape is broken by construction. + +--- + +## Feature: Mesh activity map card (frontend) + +Maps to SPEC decisions **MA-F1…MA-F6**. The card logic lives in +`web/public/assets/js/app/map-activity-card.js` (DOM-building, 100 % unit-tested) +and the packets parsing in `web/public/assets/js/app/stats.js`; `main.js` wires a +Leaflet `bottomleft` control and renders it from the `/api/stats` stats callback. +Behaviour is verified by the JS unit suite. + +### MA-FA1 — Card renders the total + per-protocol rows from `/api/stats` — MA-F1/MA-F2 +```bash +( cd web && node --test public/assets/js/app/__tests__/map-activity-card.test.js \ + public/assets/js/app/__tests__/stats.test.js ) +``` +**Expected:** pass. `stats.js` attaches `stats.packets = { total, meshcore, meshtastic }` +from the payload's `.packets.hour`; `buildMeshActivityModel({total,meshtastic,meshcore})` +returns the total plus one row per protocol (Meshtastic then MeshCore), each with a +`barPct` sized to the busiest visible protocol; `renderMeshActivityCardHtml` emits the +total, a `packets/h` unit, both protocol icons, and the row rates. + +### MA-FA2 — Reticulum is never rendered — MA-F2 +**Expected (covered by the MA-FA1 suite):** `buildMeshActivityModel` with a `reticulum` +rate present still returns only `meshtastic`/`meshcore` rows — reticulum is absent from +the card (forward-looking zero stub, S6). + +### MA-FA3 — Zero / absent activity unmounts the card — MA-F3 +**Expected (covered by the MA-FA1 suite):** a `0` total, all-zero protocol rates, or an +absent `packets` payload yield `model.visible === false`; `createMeshActivityCard().render(...)` +then adds `.map-activity-card--hidden`, sets `hidden`, and empties the element. + +### MA-FA4 — A hidden protocol drops its row and rebases the total — MA-F4 +**Expected (covered by the MA-FA1 suite):** `buildMeshActivityModel({total:120, +meshtastic:76, meshcore:44}, new Set(['meshcore']))` returns only the Meshtastic row with +`total === 76` (the sum of the *visible* protocols), and `card.render(...)` sets the +aria-label to `"Mesh activity: 76 packets per hour"` — the same rebasing the node counts +already do (Invariant IV parity). Hiding both protocols unmounts the card. + +### MA-FA5 — Sparkline (superseded by F2-A2) — MA-F5 +**Superseded by F2-A2 (and SPEC F2-4).** F1 shipped the sparkline as a deterministic +placeholder (`data-placeholder="true"`); F2 replaced it with the real 24 h series from +`/api/stats/activity`. The card now emits **no** `data-placeholder` and draws the +sparkline only when real data is present — verified by **F2-A2** (and the MA-FA1 suite, +which now asserts `data-placeholder` is *absent*). Retained for provenance; the live +requirement is F2-A2. + +### MA-FR1 — Regression: prior acceptance still holds +```bash +( cd web && npm test ) +``` +**Expected:** every prior check still passes. Frontend/read-side only: no `/api/*` +response shape changes (so **S-A1**, **MA-A5**, **C2** are untouched), and the packets +figures are the same public aggregate MA5 already exposes (privacy **A2**/**S-A4** +unchanged). The stats consumer (`stats.js`) gains `packets` parsing additively — the +existing `normaliseActiveNodeStatsPayload` node-count assertions are unchanged, not removed. + +--- + +## Feature: Mesh activity time-series (F2) + +Maps to SPEC decisions **F2-1…F2-6**. The bucket query lives in +`ingestor_queries.rb` (`query_activity_buckets`) and the route in +`application/routes/api.rb`; the sparkline + charts wiring is JS. Behaviour is +verified by the Ruby and JS unit suites. + +### F2-A1 — `/api/stats/activity` serves a snake_case packets/hour series — F2-1/F2-2 +```bash +( cd web && bundle exec rspec spec/queries_spec.rb -e "query_activity_buckets" \ + spec/app_spec.rb -e "/api/stats/activity" ) +``` +**Expected:** pass. `GET /api/stats/activity?window_seconds=&bucket_seconds=` returns an +ascending array of `{ bucket_start, bucket_end, total, meshcore, meshtastic }`; each +protocol's value is the MAX over that protocol's ingestors of their summed `packets` in +the bucket ÷ the bucket's hour-span, and `total` is the SUM across protocols (MA4). +`reticulum` folds into `total` with no series key. A non-positive +`window_seconds`/`bucket_seconds`, or a bucket count over `MAX_QUERY_LIMIT`, is a `400`; +the window is clamped to the 28-day floor. Params are **snake_case** (no camelCase). + +### F2-A2 — The map card draws its 24h sparkline from `/api/stats/activity` — F2-4 +```bash +( cd web && node --test public/assets/js/app/__tests__/map-activity-card.test.js \ + public/assets/js/app/__tests__/stats.test.js ) +``` +**Expected:** pass. `fetchActivitySeries` GETs +`/api/stats/activity?window_seconds=86400&bucket_seconds=3600`, caches it, and fails +soft to `null` (non-OK / network error / empty). `sparklinePathsFromSeries` maps a +≥2-point total series to an SVG path (null otherwise). The card is stateful: +`render(rates)` and `setSeries(series)` each repaint from the last-known other; the +sparkline appears only once a real series arrives (no `data-placeholder`, no fake +curve) and is omitted on failure while the live total/rows still render. + +### F2-A3 — `/charts` shows a protocol-aware Mesh activity figure — F2-5 +```bash +( cd web && node --test public/assets/js/app/__tests__/mesh-activity-chart.test.js \ + public/assets/js/app/__tests__/node-page.test.js \ + public/assets/js/app/__tests__/charts-page.test.js ) +``` +**Expected:** pass. `renderMeshActivityChart` draws a two-line (Meshtastic `#8856a7`, +MeshCore `#3182bd`) packets/hour figure with an **"Activity (pkt/h)"** y-axis, fed by +`fetchActivityChartBuckets` (`/api/stats/activity`, 7 d / 2 h; fails soft to `[]`). +`renderTelemetryCharts` accepts an `insertBefore` map that places the figure +immediately before the `environment` spec — i.e. **between** the channel-utilization +and environmental figures — and `initializeChartsPage` wires it there. The `/charts` +intro no longer names a single protocol (the aggregate is all-protocol). + +--- + +## Bugfix: Neighbor/trace legend toggles highlight when visible + +Maps to SPEC decision **NT1**. + +### NT-A1 — Neighbor/trace toggles are pressed when their lines are visible — NT1 +```bash +git grep -nE "aria-pressed', neighborLinesVisible \? 'true'|aria-pressed', traceLinesVisible \? 'true'" \ + -- web/public/assets/js/app/main.js +``` +**Expected:** both matches present — the neighbor- and trace-line legend toggles set +`aria-pressed` to `'true'` when their lines are **visible** (`…Visible ? 'true' : 'false'`), +so the highlighted state (`button.legend-item[aria-pressed="true"]`, LC2) marks *shown* +lines, consistent with the role chips and the meta-row protocol toggles. Previously +reversed (pressed when hidden). + +--- + +## Bugfix: Mesh activity design-review remediation + +Maps to SPEC decisions **MR1…MR8**. + +### MR-A1 — Sparkline rebasing, headroom, and card role — MR4/MR5/MR6 +```bash +( cd web && node --test public/assets/js/app/__tests__/map-activity-card.test.js \ + public/assets/js/app/__tests__/stats.test.js ) +``` +**Expected:** pass. `normaliseActivitySeries` returns per-bucket `{meshcore, meshtastic}` +(not a pre-summed total); `buildMeshActivityModel` sums only the **visible** protocols +for the sparkline, so toggling a protocol changes the curve (a rebasing test asserts the +paths differ); `sparklinePathsFromSeries` scales to `max × 1.15` (headroom); and +`createMeshActivityCard` sets `role="group"` on the card root. + +### MR-A2 — Idle card stays hidden on mobile + protocol-neutral intro — MR1/MR3/MR7 +```bash +grep -n 'max-width: 659px' web/public/assets/styles/base.css +awk '/max-width: 659px/{f=1} f&&/map-activity-card--hidden \{/{print " re-declared --hidden in media block"; exit}' web/public/assets/styles/base.css +git grep -n 'meshtastic.svg' -- web/views/charts.erb +``` +**Expected:** the `≤659px` media query exists; `.map-activity-card--hidden { display: none }` +is re-declared inside it (so an idle card cannot paint an empty pill over the map, +restoring MA-F3 on phones — the `awk` prints its confirmation line); and the last command +prints **nothing** — the `/charts` intro heading no longer references `meshtastic.svg`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 111f91b..fc2a1dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,12 +16,19 @@ on the map toggles, an equal-area diamond MeshCore marker, a condensed nodes table, and reported-only disclosure rows. ### Features +* Web: `GET /api/stats/activity` — bucketed packets/hour time-series over `ingestor_activity` (snake_case `window_seconds`/`bucket_seconds`), feeding the map-card sparkline and a protocol-aware `/charts` figure (SPEC F2) +* Web: `/charts` gains a protocol-aware "Mesh activity" figure (packets/h per protocol, 7 d) between the channel-utilization and environmental charts; the aggregated-telemetry intro is de-scoped from "Meshtastic" to all protocols (SPEC F2-5) +* Web: mesh-activity map card collapses to a compact caption strip on phones (≤ 640 px) so it no longer covers a small map (design 1d) +* Web: "Mesh activity" map card — bottom-left overlay showing packets/h (total + per-protocol split) from `/api/stats`, toggle-reactive and hidden when idle, with a 24 h sparkline drawn from `/api/stats/activity` (SPEC MA-F1…MA-F6, F2-4) * Web: join strip renders the radio settings newcomers need; new `MESHTASTIC_PRESET`/`MESHTASTIC_FREQ` + `MESHCORE_PRESET`/`MESHCORE_FREQ` env vars (`CHANNEL`/`FREQUENCY` deprecated but honoured) * Web: node freshness buckets (live/today/stale) on table rows and map markers, riding the shared relative-time tick * Web: MeshCore nodes render as equal-area diamond map chips — shape encodes protocol, colour keeps encoding role * Web: nodes table gains grouped headers, curated mobile columns (Battery survives), a per-row disclosure of hidden fields, row hover/click, numeric alignment, captions/scopes ### Fixes +* Web: mesh-activity card — idle card no longer paints an empty pill over the map on phones (≤659px), the mobile strip spans the map as a caption, the sparkline gains headroom and rebases with the protocol toggles, the card is a labelled `role="group"`, and the /charts intro heading drops the single-protocol badge (design-review remediation, SPEC MR1–MR8) +* Web: neighbor/trace line legend toggles highlight when their lines are visible (was reversed — highlighted when hidden), matching the role chips (SPEC NT1) +* Web/data: `GET /api/stats` exposes the mesh-activity packets/hour rate as `.packets.hour` (folded into the S1 scope→metric→window tree) rather than a top-level `packets_per_hour` map, and `total.packets.hour` now **sums** the per-protocol rates instead of taking a single MAX vantage (distinct protocols never share the air, so they add); the ingestor announcement dogfeed reads the new path (SPEC MA4/MA5, pre-release amendment of the merged-but-unreleased #859) * Web: WCAG AA text contrast — role-badge text computed by background luminance; chat log entries, error text, links, and focus rings tokenised (`--danger`, single `--accent`) * Web: federation table's undefined CSS tokens aliased — row borders and the sticky header render again * Web: legend expanded by default on /map, honest toggle label (filter suffix only when filters are active), line-style key on the neighbor/trace toggles; fixed the malformed `data-legend-collapsed` attribute diff --git a/SPEC.md b/SPEC.md index 9c66836..fdb7bb0 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1086,7 +1086,7 @@ wire, not cloud egress, not third-party analytics — and is opt-out via *optional, duck-typed* provider capability, not a new formal `MeshProtocol` member, so the `A4b` conformance contract and every existing provider are untouched; both protocols announce identically. *D8 (stable contract)* — **extends**: the heartbeat -`packets` field, the `/api/stats` `packets_per_hour` map, and the activity table +`packets` field, the `/api/stats` `.packets.hour` metric, and the activity table are all additive; no version bump. *§3.3 (web is POST-only intake)* — **consistent**: the dogfeed is a read; no new ingest path. No invariant is contradicted. @@ -1096,10 +1096,110 @@ contradicted. | **MA1** | **Merged packet counter — count everything, at the earliest seam.** Each ingestor maintains one merged `packets` counter incremented on **every received frame** — *all of them, including ignored / errored / unimplemented / unsupported-port packets* — counted at the earliest common receive seam (`handlers/_state._mark_packet_seen`, already invoked by both the Meshtastic `on_receive` path and every MeshCore handler/telemetry path *before* any dispatch or filtering), **plus every ingestor-initiated transmission** (the announcement itself and the existing MeshCore telemetry/status polls). RX and TX are deliberately **merged** into a single figure (no separate breakdown). "We don't want to under-report" is satisfied at source: counting precedes every drop/ignore decision. A build-phase check confirms each protocol's RX entry funnels through the seam so no family is missed. | interview | | **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 (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 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:* MAX under-estimates the true union when different radios are busiest in different hours. | interview | -| **MA5** | **Exposure via an additive `/api/stats` field.** `GET /api/stats` gains a top-level additive `packets_per_hour` map — `{ total, meshcore, meshtastic, reticulum }` — carrying the MA4 24 h MAX-aggregated moving average (`reticulum` a forward-looking `0` stub, consistent with S6; `total` is the MAX across all ingestors regardless of protocol). The existing scope × metric × window tree (S1) 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. | proposed | -| **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_per_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 | +| **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) | +| **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 triple-gated.** An announcement is transmitted only when **all** hold: (a) `RX_ONLY` is unset — the **reused** receive-only flag (default `0`) is the single transmit gate; `RX_ONLY=1` forbids *every* ingestor TX (the MeshCore polls and the announcement alike), so it is the sole opt-out and **no separate `ENABLE_TX` env is added**; (b) 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); (c) **≥ 24 h have elapsed since ingestor start** — so the first numbers are accurate over a full window and restarts cannot spam the channel. | interview | | **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 | | **MA9** | **Optional duck-typed provider send.** Transmission is exposed as a new **optional** provider method (e.g. `send_channel_announcement(iface, text)`) accessed via `getattr(provider, …, None)` — mirroring the existing optional `self_node_item` extension — so the `@runtime_checkable MeshProtocol` interface and its `A4b` isinstance conformance are unchanged, and a provider that cannot transmit (or a receive-only transport) simply omits it. Both `meshtastic` and `meshcore` providers implement it; neither protocol is privileged. | interview | | **MA10** | **Engineering bar (D9).** Every new/changed unit ships with 100 % unit tests (counter increment across RX families + TX; heartbeat delta + reset; activity-table write + retention; MAX-aggregation math incl. multi-ingestor; the `/api/stats` field; the four announcement gates incl. fail-closed privacy and the 24 h wait; per-protocol send), full PDoc/RDoc, Apache headers, `black`/`rufo` clean; `pytest`/`rspec`/`npm test` stay green; `CONTRACTS.md` documents the additive heartbeat field, the activity schema, and the `/api/stats` addition. | CLAUDE.md | + +--- + +## Feature: Mesh activity map card (frontend) + +Surfaces the MA5 `.packets.hour` rate on the dashboard as a "Mesh +activity" card in the map's bottom-left corner (the recommended treatment from +the imported Claude-Design review, option 1a). Frontend/read-side only: +integrates with `web/public/assets/js/app/stats.js` (parses the new `packets` +rates), a new `web/public/assets/js/app/map-activity-card.js` module, the map +setup + stats callback in `web/public/assets/js/app/main.js`, and +`web/public/assets/styles/base.css`. Consumes the existing `GET /api/stats` with +**no** API/DB/ingestor change. + +**Conflict check against existing decisions.** *Invariant I (apex)* — +**consistent**: a read-side consumer of the existing local API; no broker, +dependency, or egress. *Invariant II (privacy)* — **consistent**: packets are a +public aggregate (no message content), already un-gated by `PRIVATE` (MA5); the +card shows only rates the API already returns. *Invariant IV (parity)* — +**extends**: both protocols render identically and either can be toggled; neither +is privileged. *D8 (contract) / §3.3 (POST-only intake)* — **consistent**: no new +endpoint or shape; the card reads `.packets.hour` as it ships. *AV3 +(cache-busting)* — **consistent**: the new module self-registers in the automatic +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-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 | +| **MA-F6** | **Engineering bar (D9) & scope.** Frontend/read-side only — no API/DB/ingestor change (D8, §3.3), apex (I) untouched. The DOM-building logic lives in `map-activity-card.js` at **100 % unit coverage** (JSDoc, Apache header); `main.js` holds only the Leaflet-control wiring + the one render call in the stats callback (integration-only, matching the sibling legend control). The module self-registers in the cache-busting import map (AV3). `npm test` stays green; no Ruby/Python change. | CLAUDE.md | + +--- + +## Feature: Mesh activity time-series (F2) + +Adds a bucketed packets/hour time-series over `ingestor_activity` so the map +card's 24 h sparkline (MA-F5) and a protocol-aware `/charts` figure render on +real history instead of a placeholder. Backend: `GET /api/stats/activity` + +`query_activity_buckets` (`web/lib/potato_mesh/application/queries/ingestor_queries.rb`) ++ the route in `application/routes/api.rb`. Frontend: `map-activity-card.js` +(real sparkline) and `charts-page.js` + `views/charts.erb` (the new figure). +Read-side, additive; no ingest or DB-schema change. + +**Conflict check.** *Apex I / §3.3* — **consistent**: a read of the existing +local DB; no broker, egress, or ingest path. *Privacy II* — **consistent**: +packets are a public aggregate (no message content), un-gated like MA5. *Parity +IV* — **extends**: the series and the chart are per-protocol and equal, and the +`/charts` intro stops naming a single protocol. *D8* — **extends**: a new +additive endpoint (snake_case params, no version bump); the camelCase +`/aggregated` params remain BP9's separate migration. No invariant is +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-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 | +| **F2-6** | **Engineering bar (D9).** 100 % unit tests (rspec for `query_activity_buckets` + the route; node --test for the card sparkline + charts figure), full RDoc/JSDoc, Apache headers, `rufo`/`black` clean; `CONTRACTS.md` documents the new endpoint; all suites stay green. | CLAUDE.md | + +--- + +## Bugfix: Neighbor/trace legend toggles highlight when visible + +The neighbor-line and trace-line legend toggles set `aria-pressed="true"` when their +lines were **hidden**, so `button.legend-item[aria-pressed="true"]` (the selected/ +highlighted style, LC2) painted them highlighted while the lines were off — reversed +relative to the role chips, which highlight when a role is **visible**. Fixed in +`web/public/assets/js/app/main.js` (`updateNeighborLinesToggleState` / +`updateTraceLinesToggleState`). Presentation-only; no data/API change. + +| # | Decision | Source | +| --- | --- | --- | +| **NT1** | The neighbor/trace line toggles set `aria-pressed="true"` when their lines are **visible** (highlighted = shown), consistent with the role chips (LC2) and the meta-row protocol toggles; previously reversed (pressed when hidden). Presentation-only, no data/API change. | review | + +--- + +## Bugfix: Mesh activity design-review remediation + +A Claude-Design review of the shipped F1/F2 work against the 1a/1c/1d design. The +card, the real-data sparkline, and the toggle rebasing all matched or bettered the +design; this section remediates the one regression and the mobile/charts +divergences it found. Presentation + read-side only; no API/DB change. +**Conflict check:** **fixes** an MA-F3 regression and **extends** MA-F4/MA-F5/F2-4 +(the sparkline now rebases with the toggles); consistent with all invariants. + +| # | Decision | Source | +| --- | --- | --- | +| **MR1** | **Idle card stays hidden on mobile (MA-F3 regression fix).** The mobile media query set `.map-activity-card { display: flex }`, which outranked `.map-activity-card--hidden { display: none }` (equal specificity, later rule) and the `[hidden]` attribute, so an idle card painted an empty pill over the map. `.map-activity-card--hidden { display: none }` is re-declared inside the media query. | review | +| **MR2** | **Mobile strip is a full-width caption (design 1d).** Below the mobile breakpoint the bottom-left Leaflet control spans the map (`left: 0; right: 0`, 8px inset) at ≥ 32px tall with the protocol split pushed to the far edge (`margin-left: auto`), so it reads as a map caption rather than a content-width corner pill. | review | +| **MR3** | **Mobile breakpoint aligned to the app band.** The strip triggers at ≤ 659px (the app's mobile band, UX9), not a one-off 640px. | review | +| **MR4** | **Sparkline headroom.** `sparklinePathsFromSeries` scales to `max × 1.15`, so the busiest hour's vertex sits below the box edge and the 1.5px stroke is never clipped. | review | +| **MR5** | **Sparkline rebases with the toggles.** The curve is the sum of the **visible** protocols per bucket (from `/api/stats/activity`'s per-protocol fields), so it agrees with the headline total once a protocol is toggled off — previously the number rebased but the curve stayed all-protocol. Extends MA-F4 / F2-4. | review | +| **MR6** | **Card is a labelled `group`.** The card root carries `role="group"` so its `aria-label` is announced (a bare div's is not); children stay readable, unlike `role="img"`. | review | +| **MR7** | **`/charts` intro is protocol-neutral.** The all-protocol "Network telemetry trends" heading no longer carries the single Meshtastic badge (`charts.erb`), matching the de-scoped copy (F2-5). | review | +| **MR8** | **Utilization second-axis (design 1c) explicitly out of scope.** The packets/h right-hand axis overlay on the channel-utilization chart was **not** built — the standalone "Mesh activity" figure (F2-5) is the shipped scope; recorded so the design is not re-litigated. | review | diff --git a/data/mesh_ingestor/CONTRACTS.md b/data/mesh_ingestor/CONTRACTS.md index 9b8027e..d305a23 100644 --- a/data/mesh_ingestor/CONTRACTS.md +++ b/data/mesh_ingestor/CONTRACTS.md @@ -220,7 +220,7 @@ Heartbeat payload: - Optional: `protocol` (string; e.g. `"meshtastic"`, `"meshcore"`) — declares the mesh backend for this ingestor; defaults to `"meshtastic"` when absent - Optional: `packets` (int ≥ 0) — **mesh-activity delta (SPEC MA1/MA2).** The merged count of *every* frame this ingestor handled since its previous heartbeat: all received frames (including ignored / errored / unimplemented) **plus** its own transmissions (announcement + MeshCore telemetry polls), counted at the earliest receive/transmit seam so nothing is under-reported. It is a **per-interval delta** (reset on each send), **not** a since-boot cumulative. Additive and backward-compatible: an absent or negative value records no activity, so pre-feature ingestors are unaffected. -**Mesh-activity time-series (SPEC MA3).** Each heartbeat carrying a non-negative `packets` value appends one **append-only** row to the `ingestor_activity` table (`ingestor_id`, `at`, `packets`, `protocol`; `data/ingestor_activity.sql`); the `ingestors` snapshot row is upserted as before. Each ingestor's contribution is stored separately (never pre-summed) so a packets/hour moving average is computable across time × protocol × multiple ingestors. The row is best-effort — a failed activity insert never sinks the liveness heartbeat (still `201`). Rows are pruned by the retention worker on `at`. The read-side aggregate is served by `GET /api/stats` (`packets_per_hour`, below). +**Mesh-activity time-series (SPEC MA3).** Each heartbeat carrying a non-negative `packets` value appends one **append-only** row to the `ingestor_activity` table (`ingestor_id`, `at`, `packets`, `protocol`; `data/ingestor_activity.sql`); the `ingestors` snapshot row is upserted as before. Each ingestor's contribution is stored separately (never pre-summed) so a packets/hour moving average is computable across time × protocol × multiple ingestors. The row is best-effort — a failed activity insert never sinks the liveness heartbeat (still `201`). Rows are pruned by the retention worker on `at`. The read-side aggregate is served by `GET /api/stats` (`.packets.hour`, below). **Protocol propagation**: all event records (`messages`, `positions`, `telemetry`, `traces`, `neighbors`) that reference this ingestor via their `ingestor` field inherit its `protocol` value at write time when no explicit per-record `protocol` stamp is present. Per-record stamps take precedence — the ingestor heartbeat default only kicks in when the per-record field is absent or malformed. @@ -302,11 +302,10 @@ do **not** accept `before`. ```jsonc { - "total": { "nodes": {…}, "messages": {…}, "telemetry": {…} }, - "meshcore": { "nodes": {…}, "messages": {…}, "telemetry": {…} }, - "meshtastic": { "nodes": {…}, "messages": {…}, "telemetry": {…} }, - "reticulum": { "nodes": {…}, "messages": {…}, "telemetry": {…} }, // stub: always 0 - "packets_per_hour": { "total": 50, "meshcore": 50, "meshtastic": 30, "reticulum": 0 }, + "total": { "nodes": {…}, "messages": {…}, "telemetry": {…}, "packets": { "hour": 50 } }, + "meshcore": { "nodes": {…}, "messages": {…}, "telemetry": {…}, "packets": { "hour": 50 } }, + "meshtastic": { "nodes": {…}, "messages": {…}, "telemetry": {…}, "packets": { "hour": 30 } }, + "reticulum": { "nodes": {…}, "messages": {…}, "telemetry": {…}, "packets": { "hour": 0 } }, // stub: always 0 "sampled": false } ``` @@ -317,26 +316,52 @@ do **not** accept `before`. ingestor exists yet) and is always all-zero. - **Metrics.** `nodes` counts `nodes` by `last_heard`; `messages` counts `messages` by `rx_time`; `telemetry` is the umbrella over `positions` + `telemetry` + - `neighbors` + `traces` (every non-message packet record) by `rx_time`. -- **Windows.** Each metric maps to `{ "hour", "day", "week", "month" }` integer - counts at the fixed cutoffs (1 h / 24 h / `week_seconds` / `four_weeks_seconds`); - `month` cannot exceed the 28-day visibility floor. + `neighbors` + `traces` (every non-message packet record) by `rx_time`; `packets` + is the additive MA4/MA5 packets/hour rate (below). +- **Windows.** The `nodes`/`messages`/`telemetry` metrics map to + `{ "hour", "day", "week", "month" }` integer counts at the fixed cutoffs + (1 h / 24 h / `week_seconds` / `four_weeks_seconds`); `month` cannot exceed the + 28-day visibility floor. The `packets` metric carries only `hour` (it is a rate, + not a windowed count). - **Privacy.** Every metric honors the node opt-out marker. When `PRIVATE=1`, all `messages` counts are forced to `0` (mirroring the disabled message API); `nodes`/`telemetry` counts remain. -- **`packets_per_hour`** (additive, SPEC MA4/MA5) is a flat top-level map keyed - `{ total, meshcore, meshtastic, reticulum }` carrying the 24-hour packets/hour - moving average as a rounded integer. It is aggregated **MAX-per-protocol**: +- **`.packets.hour`** (additive, SPEC MA4/MA5) carries the 24-hour + packets/hour moving average as a rounded integer, exposed as a `packets` metric + under each scope (single `hour` window). It is aggregated **MAX-per-protocol**: `MAX` over that protocol's ingestors of *(the ingestor's `packets` total in the last 24 h ÷ 24)* — a single radio hears ≤ what is actually transmitted, so the busiest vantage is the best dedup-free estimate of air traffic and never - double-counts a frame heard by two radios. `total` is the same MAX over **every** - ingestor regardless of protocol; `reticulum` is the always-zero forward-looking - stub. Unlike `messages`, it is **not** privacy-gated (packets are a public - aggregate, no message content). Additive to the 0.7.x `/api/stats` tree — no - version bump; the ingestor dogfeeds it for the activity announcement (MA6). + double-counts a frame heard by two radios. `total.packets.hour` is the **SUM** + of the per-protocol rates (distinct protocols ride distinct frequencies, so they + add rather than dedup); `reticulum.packets.hour` is the always-zero + forward-looking stub. Unlike `messages`, it is **not** privacy-gated + (packets are a public aggregate, no message content). Additive to the 0.7.x + `/api/stats` tree — no version bump; the ingestor dogfeeds it for the activity + announcement (MA6). - **`sampled`** is unchanged: always `false` (the counts are exact, not sampled). +### GET /api/stats/activity packets/hour time-series (SPEC F2) + +A bucketed packets/hour series over `ingestor_activity`, feeding the mesh-activity +map-card sparkline and the `/charts` activity figure. **snake_case** params (the +API norm): `window_seconds` (default 86 400, clamped to the 28-day floor) and +`bucket_seconds` (default 3 600); a bucket count over `MAX_QUERY_LIMIT` is a `400`. +An optional `since` bypasses the response cache. + +```jsonc +[ + { "bucket_start": 1785000000, "bucket_end": 1785003600, "total": 120, "meshcore": 44, "meshtastic": 76 }, + … +] +``` + +Each bucket's per-protocol value is the **MAX** over that protocol's ingestors of +their summed `packets` in the bucket, ÷ the bucket's hour-span → a packets/hour +rate; `total` is the **SUM** across protocols (matching the live +`.packets.hour`, SPEC MA4). `reticulum` folds into `total` but has no series +key. Buckets are ascending by `bucket_start`. Additive, read-side — no version bump. + ### GET /api/events live-update stream (SSE) A read-only **Server-Sent Events** stream (`text/event-stream`) that pushes thin diff --git a/data/mesh_ingestor/announce.py b/data/mesh_ingestor/announce.py index cdfb8c7..3c3731f 100644 --- a/data/mesh_ingestor/announce.py +++ b/data/mesh_ingestor/announce.py @@ -121,7 +121,7 @@ def fetch_activity( These are the mesh-wide numbers the announcement quotes (SPEC MA6): ``active_nodes`` = ``.nodes.day`` and ``packets_per_hour`` = - ``packets_per_hour.``. + ``.packets.hour`` (the MA4 rate exposed under each scope). Parameters: instance_url: Base URL of the PotatoMesh instance. @@ -137,17 +137,19 @@ def fetch_activity( if not isinstance(data, dict): return None scope = data.get(protocol) - packets_map = data.get("packets_per_hour") - if not isinstance(scope, dict) or not isinstance(packets_map, dict): + if not isinstance(scope, dict): return None nodes = scope.get("nodes") - if not isinstance(nodes, dict): + packets = scope.get("packets") + if not isinstance(nodes, dict) or not isinstance(packets, dict): return None active_nodes = nodes.get("day") - packets = packets_map.get(protocol) - if not isinstance(active_nodes, int) or not isinstance(packets, (int, float)): + packets_per_hour = packets.get("hour") + if not isinstance(active_nodes, int) or not isinstance( + packets_per_hour, (int, float) + ): return None - return int(active_nodes), packets + return int(active_nodes), packets_per_hour def protocol_display_name(protocol: str | None) -> str: diff --git a/tests/test_announce_unit.py b/tests/test_announce_unit.py index 519353a..1b3da46 100644 --- a/tests/test_announce_unit.py +++ b/tests/test_announce_unit.py @@ -131,21 +131,23 @@ class TestDogfeedFetchActivity: """Tests for :func:`announce.fetch_activity` (MA6).""" def test_dogfeed_reads_nodes_and_packets_per_hour(self, monkeypatch): - """Returns ``(.nodes.day, packets_per_hour.)``.""" + """Returns ``(.nodes.day, .packets.hour)``.""" _install_fake_http( monkeypatch, { "https://mesh.example/api/stats": { - "meshcore": {"nodes": {"day": 12, "hour": 3}}, - "meshtastic": {"nodes": {"day": 99}}, - "packets_per_hour": {"meshcore": 50, "meshtastic": 30}, + "meshcore": { + "nodes": {"day": 12, "hour": 3}, + "packets": {"hour": 50}, + }, + "meshtastic": {"nodes": {"day": 99}, "packets": {"hour": 30}}, }, }, ) assert announce.fetch_activity("https://mesh.example", "meshcore") == (12, 50) - def test_dogfeed_returns_none_on_malformed_shape(self, monkeypatch): - """A response missing the packets_per_hour map yields ``None``.""" + def test_dogfeed_returns_none_when_packets_section_missing(self, monkeypatch): + """A scope present but lacking a ``packets`` sub-object yields ``None``.""" _install_fake_http( monkeypatch, {"https://mesh.example/api/stats": {"meshcore": {"nodes": {"day": 1}}}}, @@ -158,8 +160,7 @@ class TestDogfeedFetchActivity: monkeypatch, { "https://mesh.example/api/stats": { - "meshcore": {}, - "packets_per_hour": {"meshcore": 50}, + "meshcore": {"packets": {"hour": 50}}, }, }, ) @@ -171,8 +172,25 @@ class TestDogfeedFetchActivity: monkeypatch, { "https://mesh.example/api/stats": { - "meshcore": {"nodes": {"day": "lots"}}, - "packets_per_hour": {"meshcore": 50}, + "meshcore": { + "nodes": {"day": "lots"}, + "packets": {"hour": 50}, + }, + }, + }, + ) + assert announce.fetch_activity("https://mesh.example", "meshcore") is None + + def test_dogfeed_returns_none_on_non_numeric_packets(self, monkeypatch): + """A valid node count but a non-numeric packets/hour yields ``None``.""" + _install_fake_http( + monkeypatch, + { + "https://mesh.example/api/stats": { + "meshcore": { + "nodes": {"day": 5}, + "packets": {"hour": "lots"}, + }, }, }, ) diff --git a/web/lib/potato_mesh/application/queries/common.rb b/web/lib/potato_mesh/application/queries/common.rb index 6b94e0d..1eaac85 100644 --- a/web/lib/potato_mesh/application/queries/common.rb +++ b/web/lib/potato_mesh/application/queries/common.rb @@ -20,6 +20,11 @@ module PotatoMesh MAX_QUERY_LIMIT = 1000 DEFAULT_TELEMETRY_WINDOW_SECONDS = 86_400 DEFAULT_TELEMETRY_BUCKET_SECONDS = 300 + # Defaults for the mesh-activity packets/hour time-series (SPEC F2): a + # 24 h window in 1 h buckets (the map card's sparkline default; the + # /charts figure overrides to 7 d / 2 h). + DEFAULT_ACTIVITY_WINDOW_SECONDS = 86_400 + DEFAULT_ACTIVITY_BUCKET_SECONDS = 3_600 PROTOCOL_CLAUSE = "protocol = ?".freeze TELEMETRY_ZERO_INVALID_COLUMNS = %w[battery_level voltage].freeze TELEMETRY_AGGREGATE_COLUMNS = diff --git a/web/lib/potato_mesh/application/queries/ingestor_queries.rb b/web/lib/potato_mesh/application/queries/ingestor_queries.rb index 9672569..8059f8d 100644 --- a/web/lib/potato_mesh/application/queries/ingestor_queries.rb +++ b/web/lib/potato_mesh/application/queries/ingestor_queries.rb @@ -29,17 +29,22 @@ module PotatoMesh PACKETS_PER_HOUR_DIVISOR = PACKETS_PER_HOUR_WINDOW_SECONDS / 3600.0 # Compute the mesh-wide packets/hour moving average per protocol scope - # (SPEC MA4/MA5), aggregated **MAX-per-protocol** across ingestors. + # (SPEC MA4/MA5), aggregated **MAX-per-protocol** across ingestors. The + # +GET /api/stats+ route folds each rate into its scope as the additive + # +.packets.hour+ metric; this method returns the flat per-scope + # rate map that assembly consumes. # # 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 — ignored/errored frames carry no id). # For each protocol the rate is - # +MAX(ingestor's 24 h packet total) ÷ 24+, rounded; +total+ is the same - # MAX taken over **every** ingestor regardless of protocol (so an ingestor - # reporting several protocols contributes its combined total). +reticulum+ - # is a forward-looking always-zero stub (SPEC S6/MA5). + # +MAX(ingestor's 24 h packet total) ÷ 24+, rounded. +total+ 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 (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). # # @param now [Integer] reference unix timestamp in seconds. # @param db [SQLite3::Database, nil] optional open database handle to reuse. @@ -60,18 +65,24 @@ module PotatoMesh end per_protocol_max = Hash.new(0) - per_ingestor_total = Hash.new(0) rows.each do |row| protocol = row["p"] - total = row["total"].to_i - per_protocol_max[protocol] = [per_protocol_max[protocol], total].max if protocol - per_ingestor_total[row["ingestor_id"]] += total + next unless protocol + per_protocol_max[protocol] = [per_protocol_max[protocol], row["total"].to_i].max end + # +total+ sums the per-protocol MAX vantages: distinct protocols ride + # distinct 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 above). The sum is over the + # *rounded* per-protocol rates, so +total+ always equals the sum of the + # exposed +.packets.hour+ values (SPEC MA4, matching + # +query_activity_buckets+); rounding after the raw sum could drift ±1. + rates = per_protocol_max.transform_values { |packets| packets_per_hour_rate(packets) } { - "total" => packets_per_hour_rate(per_ingestor_total.values.max || 0), - "meshcore" => packets_per_hour_rate(per_protocol_max["meshcore"]), - "meshtastic" => packets_per_hour_rate(per_protocol_max["meshtastic"]), + "total" => rates.values.sum, + "meshcore" => rates["meshcore"] || 0, + "meshtastic" => rates["meshtastic"] || 0, "reticulum" => 0, } ensure @@ -85,6 +96,82 @@ module PotatoMesh def packets_per_hour_rate(total_packets) (total_packets / PACKETS_PER_HOUR_DIVISOR).round end + + # Aggregate mesh-wide packets/hour into ascending time buckets (SPEC F2). + # + # Each bucket carries the packets/hour rate per protocol, computed the + # 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 + # 28-day visibility floor (C4); the route pre-validates the bucket count + # against +MAX_QUERY_LIMIT+ and this query caps the row count too. + # + # @param window_seconds [Integer] span to include, in seconds. + # @param bucket_seconds [Integer] bucket width, in seconds. + # @param since [Integer] extra lower-bound timestamp (unix seconds). + # @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. + 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 + window = clamp_window_seconds(window) || DEFAULT_ACTIVITY_WINDOW_SECONDS + bucket = coerce_integer(bucket_seconds) || DEFAULT_ACTIVITY_BUCKET_SECONDS + bucket = DEFAULT_ACTIVITY_BUCKET_SECONDS if bucket <= 0 + + handle = db || open_database(readonly: true) + handle.results_as_hash = true + reference_now = coerce_integer(now) || Time.now.to_i + since_threshold = normalize_since_threshold(since, floor: reference_now - window) + + rows = with_busy_retry do + handle.execute(<<~SQL, [bucket, bucket, since_threshold, MAX_QUERY_LIMIT]) + SELECT bucket_start, p, MAX(ingestor_total) AS protocol_max + FROM ( + SELECT ((at / ?) * ?) AS bucket_start, protocol AS p, ingestor_id, + SUM(packets) AS ingestor_total + FROM ingestor_activity + WHERE at >= ? + GROUP BY bucket_start, p, ingestor_id + ) + GROUP BY bucket_start, p + ORDER BY bucket_start ASC + LIMIT ? + SQL + end + + hours = bucket / 3600.0 + buckets = {} + order = [] + rows.each do |row| + bucket_start = coerce_integer(row["bucket_start"]) + next unless bucket_start + entry = buckets[bucket_start] + unless entry + entry = { + "bucket_start" => bucket_start, + "bucket_end" => bucket_start + bucket, + "total" => 0, + "meshcore" => 0, + "meshtastic" => 0, + } + buckets[bucket_start] = entry + order << bucket_start + end + rate = ((coerce_integer(row["protocol_max"]) || 0) / hours).round + entry["total"] += rate + protocol = row["p"] + entry[protocol] = rate if entry.key?(protocol) + end + order.map { |key| buckets[key] } + ensure + handle&.close unless db + end end end end diff --git a/web/lib/potato_mesh/application/routes/api.rb b/web/lib/potato_mesh/application/routes/api.rb index 49e06ea..31fa821 100644 --- a/web/lib/potato_mesh/application/routes/api.rb +++ b/web/lib/potato_mesh/application/routes/api.rb @@ -115,16 +115,18 @@ module PotatoMesh content_type :json priv = private_mode? ? 1 : 0 cached = PotatoMesh::App::ApiCache.fetch("api:stats:#{priv}", ttl_seconds: 15) do - # Scope → metric → window tree (SPEC S1) plus the additive - # +packets_per_hour+ moving-average map (SPEC MA5); both are drawn - # from independent read-side queries. +sampled+ stays last and + # Scope → metric → window tree (SPEC S1). The MA4 packets/hour + # moving average is folded in as an additive +packets+ metric under + # each scope (+.packets.hour+, SPEC MA5) — a single +hour+ + # window because it is a rate, not a windowed count. Both figures + # come from independent read-side queries. +sampled+ stays last and # +false+ for backward continuity with the prior payload. - query_active_node_stats - .merge( - "packets_per_hour" => query_packets_per_hour, - "sampled" => false, - ) - .to_json + stats = query_active_node_stats + rates = query_packets_per_hour + stats.each do |scope, metrics| + metrics["packets"] = { "hour" => rates[scope] || 0 } + end + stats.merge("sampled" => false).to_json end etag cached[:etag], kind: :weak @@ -132,6 +134,61 @@ module PotatoMesh cached[:value] end + # Mesh-activity packets/hour time-series (SPEC F2). Mirrors + # +/api/telemetry/aggregated+ but over the +ingestor_activity+ table, + # with **snake_case** window/bucket params (the API norm; the older + # +/aggregated+ camelCase params are the BP9 wart, migrated separately). + app.get "/api/stats/activity" do + content_type :json + default_window = PotatoMesh::App::Queries::DEFAULT_ACTIVITY_WINDOW_SECONDS + default_bucket = PotatoMesh::App::Queries::DEFAULT_ACTIVITY_BUCKET_SECONDS + + window_seconds = if params.key?("window_seconds") + coerce_integer(params["window_seconds"]) + else + default_window + end + bucket_seconds = if params.key?("bucket_seconds") + coerce_integer(params["bucket_seconds"]) + else + default_bucket + end + + if window_seconds.nil? || window_seconds <= 0 + halt 400, { error: "window_seconds must be positive" }.to_json + end + if bucket_seconds.nil? || bucket_seconds <= 0 + halt 400, { error: "bucket_seconds must be positive" }.to_json + end + + # Clamp the window to the 28-day visibility floor so a caller cannot + # reach past the retention cap (C4); the query repeats the clamp. + window_seconds = clamp_window_seconds(window_seconds) + + bucket_count = (window_seconds.to_f / bucket_seconds).ceil + if bucket_count > PotatoMesh::App::Queries::MAX_QUERY_LIMIT + halt 400, { error: "bucket_seconds too small for requested window" }.to_json + end + + since = params["since"] + since_val = coerce_integer(since) || 0 + + if since_val > 0 + json_body = query_activity_buckets(window_seconds: window_seconds, bucket_seconds: bucket_seconds, since: since).to_json + etag Digest::MD5.hexdigest(json_body), kind: :weak + api_cache_control(max_age: 30) + json_body + else + cache_key = "api:stats_activity:#{window_seconds}:#{bucket_seconds}" + cached = PotatoMesh::App::ApiCache.fetch(cache_key, ttl_seconds: 60) do + query_activity_buckets(window_seconds: window_seconds, bucket_seconds: bucket_seconds, since: since).to_json + end + etag cached[:etag], kind: :weak + api_cache_control(max_age: 30) + cached[:value] + end + end + app.get "/api/nodes/:id" do content_type :json node_ref = string_or_nil(params["id"]) diff --git a/web/public/assets/js/app/__tests__/charts-page.test.js b/web/public/assets/js/app/__tests__/charts-page.test.js index d5e676c..ca1931f 100644 --- a/web/public/assets/js/app/__tests__/charts-page.test.js +++ b/web/public/assets/js/app/__tests__/charts-page.test.js @@ -108,6 +108,36 @@ test('initializeChartsPage renders the telemetry charts when snapshots are avail assert.equal(/^\d{2}$/.test(label), true); }); +test('initializeChartsPage injects the mesh-activity figure before the environmental charts', async () => { + const container = { innerHTML: '' }; + const documentStub = { + getElementById(id) { + return id === 'chartsPage' ? container : null; + }, + }; + const nowSec = 1_700_000_000; + const fetchImpl = async url => { + if (url.includes('/api/stats/activity')) { + return createResponse(200, [ + { bucket_start: nowSec - 7200, meshtastic: 20, meshcore: 10 }, + { bucket_start: nowSec, meshtastic: 30, meshcore: 20 }, + ]); + } + return createResponse(200, [ + { bucket_start: nowSec, bucket_seconds: 300, aggregates: { temperature: { avg: 22 } } }, + ]); + }; + let receivedOptions = null; + const renderCharts = (node, options) => { + receivedOptions = options; + return '
Charts
'; + }; + await initializeChartsPage({ document: documentStub, fetchImpl, renderCharts }); + assert.ok(receivedOptions.insertBefore, 'insertBefore option is passed'); + assert.ok(receivedOptions.insertBefore.environment, 'activity figure keyed to environment'); + assert.match(receivedOptions.insertBefore.environment, /Mesh activity/); +}); + test('initializeChartsPage shows an error message when fetching fails', async () => { const container = { innerHTML: '' }; const documentStub = { 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 new file mode 100644 index 0000000..eb67ebd --- /dev/null +++ b/web/public/assets/js/app/__tests__/map-activity-card.test.js @@ -0,0 +1,222 @@ +/* + * Copyright © 2025-26 l5yth & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { createDomEnvironment } from './dom-environment.js'; +import { + sparklinePathsFromSeries, + buildMeshActivityModel, + renderMeshActivityCardHtml, + createMeshActivityCard, +} from '../map-activity-card.js'; + +// --------------------------------------------------------------------------- +// sparklinePathsFromSeries +// --------------------------------------------------------------------------- + +test('sparklinePathsFromSeries maps totals to a closed area path', () => { + const paths = sparklinePathsFromSeries([10, 20, 15, 40]); + assert.match(paths.line, /^M1 /); + assert.ok(paths.area.startsWith(paths.line)); + assert.ok(paths.area.endsWith('L157 26 L1 26 Z')); +}); + +test('sparklinePathsFromSeries returns null for fewer than two points or non-arrays', () => { + assert.equal(sparklinePathsFromSeries([]), null); + assert.equal(sparklinePathsFromSeries([5]), null); + assert.equal(sparklinePathsFromSeries(null), null); + assert.equal(sparklinePathsFromSeries('nope'), null); +}); + +test('sparklinePathsFromSeries draws a flat baseline for an all-zero series', () => { + const paths = sparklinePathsFromSeries([0, 0, 0]); + assert.ok(paths); + assert.match(paths.line, /24/); // every point sits on the 24px baseline +}); + +// --------------------------------------------------------------------------- +// buildMeshActivityModel +// --------------------------------------------------------------------------- + +test('buildMeshActivityModel sums visible protocols and sizes bars to the busiest', () => { + const model = buildMeshActivityModel({ total: 120, meshtastic: 76, meshcore: 44 }, new Set(), null); + assert.equal(model.visible, true); + assert.equal(model.total, 120); + assert.deepEqual(model.rows.map(row => row.label), ['Meshtastic', 'MeshCore']); + assert.equal(model.rows[0].barPct, 100); + assert.equal(model.rows[1].barPct, Math.round((44 / 76) * 100)); + assert.equal(model.spark, null); // no series supplied +}); + +test('buildMeshActivityModel attaches a sparkline from the visible per-protocol series', () => { + const series = [ + { meshcore: 5, meshtastic: 10 }, + { meshcore: 8, meshtastic: 12 }, + ]; + const model = buildMeshActivityModel({ total: 120, meshtastic: 76, meshcore: 44 }, new Set(), series); + assert.ok(model.spark); + assert.match(model.spark.line, /^M1 /); +}); + +test('buildMeshActivityModel rebases the sparkline with the protocol toggles', () => { + const series = [ + { meshcore: 90, meshtastic: 10 }, + { meshcore: 10, meshtastic: 90 }, + ]; + const all = buildMeshActivityModel({ meshcore: 10, meshtastic: 90 }, new Set(), series); + const mtOnly = buildMeshActivityModel({ meshtastic: 90 }, new Set(['meshcore']), series); + assert.ok(all.spark && mtOnly.spark); + // All-visible sums to a flat [100,100]; meshcore-hidden rises [10,90] — the + // curve rebases with the toggle just like the headline total. + 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); + assert.deepEqual(model.rows.map(row => row.label), ['Meshtastic']); +}); + +test('buildMeshActivityModel drops a hidden protocol and rebases the total', () => { + const model = buildMeshActivityModel({ total: 120, meshtastic: 76, meshcore: 44 }, new Set(['meshcore']), null); + assert.deepEqual(model.rows.map(row => row.label), ['Meshtastic']); + assert.equal(model.total, 76); +}); + +test('buildMeshActivityModel hides when all protocols are off or the total is zero', () => { + const allHidden = buildMeshActivityModel( + { total: 120, meshtastic: 76, meshcore: 44 }, + new Set(['meshcore', 'meshtastic']), + null + ); + assert.equal(allHidden.visible, false); + const zero = buildMeshActivityModel({ total: 0, meshtastic: 0, meshcore: 0 }, new Set(), null); + assert.equal(zero.visible, false); + assert.deepEqual(zero.rows.map(row => row.barPct), [0, 0]); +}); + +test('buildMeshActivityModel hides when packets are absent or malformed', () => { + assert.equal(buildMeshActivityModel(null, new Set(), null).visible, false); + assert.equal(buildMeshActivityModel(undefined, undefined, undefined).visible, false); + const skipped = buildMeshActivityModel({ meshtastic: 'n/a', meshcore: -5 }, new Set(), null); + assert.equal(skipped.visible, false); +}); + +// --------------------------------------------------------------------------- +// renderMeshActivityCardHtml +// --------------------------------------------------------------------------- + +test('renderMeshActivityCardHtml emits total/rows/icons; sparkline only with a series', () => { + const withSpark = renderMeshActivityCardHtml( + buildMeshActivityModel({ total: 120, meshtastic: 76, meshcore: 44 }, new Set(), [ + { meshcore: 5, meshtastic: 10 }, + { meshcore: 8, meshtastic: 12 }, + ]) + ); + assert.match(withSpark, /map-activity-card__total-value">120 { + assert.throws(() => createMeshActivityCard(null), /requires a document/); + assert.throws(() => createMeshActivityCard({}), /requires a document/); +}); + +test('createMeshActivityCard renders, adds the sparkline via setSeries, hides on zero', () => { + const env = createDomEnvironment(); + try { + const card = createMeshActivityCard(env.document); + assert.equal(card.element.getAttribute('role'), 'group'); + assert.equal(card.element.classList.contains('map-activity-card--hidden'), true); + + // Live rates → visible, no sparkline yet. + const visible = card.render({ + packets: { total: 120, meshtastic: 76, meshcore: 44 }, + hiddenProtocols: new Set(), + }); + assert.equal(visible, true); + assert.equal(card.element.getAttribute('aria-label'), 'Mesh activity: 120 packets per hour'); + assert.doesNotMatch(card.element.innerHTML, /map-activity-card__spark/); + + // Series arrives → repaint with the sparkline, rates preserved. + assert.equal(card.setSeries([{ meshcore: 5, meshtastic: 10 }, { meshcore: 8, meshtastic: 12 }]), true); + assert.match(card.element.innerHTML, /map-activity-card__spark-line/); + assert.match(card.element.innerHTML, /map-activity-card__total-value">120 { + const env = createDomEnvironment(); + try { + const card = createMeshActivityCard(env.document); + assert.equal(card.setSeries([{ meshcore: 5, meshtastic: 10 }, { meshcore: 8, meshtastic: 12 }]), false); // nothing to show without rates + assert.equal(card.element.classList.contains('map-activity-card--hidden'), true); + assert.equal( + card.render({ packets: { total: 50, meshtastic: 50 }, hiddenProtocols: new Set() }), + true + ); + assert.match(card.element.innerHTML, /map-activity-card__spark-line/); // earlier series used + } finally { + env.cleanup(); + } +}); + +test('createMeshActivityCard rebases when a protocol is toggled off', () => { + const env = createDomEnvironment(); + try { + const card = createMeshActivityCard(env.document); + card.render({ + packets: { total: 120, meshtastic: 76, meshcore: 44 }, + hiddenProtocols: new Set(['meshcore']), + }); + assert.equal(card.element.getAttribute('aria-label'), 'Mesh activity: 76 packets per hour'); + assert.doesNotMatch(card.element.innerHTML, /MeshCore/); + } finally { + env.cleanup(); + } +}); + +test('createMeshActivityCard defaults to the global document and no-arg render hides', () => { + const env = createDomEnvironment(); + try { + const card = createMeshActivityCard(); + assert.equal(card.render(), false); + } finally { + env.cleanup(); + } +}); diff --git a/web/public/assets/js/app/__tests__/mesh-activity-chart.test.js b/web/public/assets/js/app/__tests__/mesh-activity-chart.test.js new file mode 100644 index 0000000..16443fb --- /dev/null +++ b/web/public/assets/js/app/__tests__/mesh-activity-chart.test.js @@ -0,0 +1,95 @@ +/* + * Copyright © 2025-26 l5yth & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + fetchActivityChartBuckets, + renderMeshActivityChart, +} from '../mesh-activity-chart.js'; + +const NOW = 1_700_000_000_000; // milliseconds +const TWO_HOURS = 7200; + +function bucketsFixture() { + const startSec = Math.floor(NOW / 1000) - 4 * TWO_HOURS; + return [ + { bucket_start: startSec, bucket_end: startSec + TWO_HOURS, total: 30, meshtastic: 20, meshcore: 10 }, + { bucket_start: startSec + TWO_HOURS, bucket_end: startSec + 2 * TWO_HOURS, total: 50, meshtastic: 30, meshcore: 20 }, + ]; +} + +test('fetchActivityChartBuckets requests the 7d/2h series and returns the array', async () => { + const calls = []; + const fetchImpl = async url => { + calls.push(url); + return { ok: true, async json() { return bucketsFixture(); } }; + }; + const buckets = await fetchActivityChartBuckets({ fetchImpl }); + assert.equal(buckets.length, 2); + assert.match(calls[0], /\/api\/stats\/activity\?window_seconds=604800&bucket_seconds=7200/); +}); + +test('fetchActivityChartBuckets fails soft to [] on non-OK, error, and non-array', async () => { + assert.deepEqual( + await fetchActivityChartBuckets({ fetchImpl: async () => ({ ok: false, status: 500 }) }), + [] + ); + assert.deepEqual( + await fetchActivityChartBuckets({ fetchImpl: async () => { throw new Error('down'); } }), + [] + ); + assert.deepEqual( + await fetchActivityChartBuckets({ + fetchImpl: async () => ({ ok: true, async json() { return { not: 'array' }; } }), + }), + [] + ); +}); + +test('renderMeshActivityChart draws a per-protocol figure with the Activity axis label', () => { + const html = renderMeshActivityChart(bucketsFixture(), NOW); + assert.match(html, /

Mesh activity<\/h4>/); + assert.match(html, /Activity \(pkt\/h\)/); // y-axis label (per the note) + assert.match(html, /Meshtastic/); + assert.match(html, /MeshCore/); + assert.match(html, /#8856a7/); // meshtastic colour + assert.match(html, /#3182bd/); // meshcore colour + assert.match(html, /node-detail__chart-trend/); // a trend line path + assert.match(html, /node-detail__chart-legend/); +}); + +test('renderMeshActivityChart returns empty string when there is no usable data', () => { + assert.equal(renderMeshActivityChart([], NOW), ''); + assert.equal(renderMeshActivityChart(null, NOW), ''); + // Buckets present but no finite per-protocol values → no lines. + assert.equal(renderMeshActivityChart([{ bucket_start: 'x', meshtastic: 'y' }], NOW), ''); +}); + +test('renderMeshActivityChart still renders when the whole series is zero', () => { + const startSec = Math.floor(NOW / 1000) - TWO_HOURS; + const html = renderMeshActivityChart( + [ + { bucket_start: startSec, meshtastic: 0, meshcore: 0 }, + { bucket_start: startSec + TWO_HOURS, meshtastic: 0, meshcore: 0 }, + ], + NOW + ); + // maxValue 0 → axis.max falls back to 1; the figure and its lines still draw. + assert.match(html, /

Mesh activity<\/h4>/); + assert.match(html, /node-detail__chart-trend/); +}); diff --git a/web/public/assets/js/app/__tests__/node-page.test.js b/web/public/assets/js/app/__tests__/node-page.test.js index bbcf94a..1f476fc 100644 --- a/web/public/assets/js/app/__tests__/node-page.test.js +++ b/web/public/assets/js/app/__tests__/node-page.test.js @@ -472,6 +472,28 @@ test('renderTelemetryCharts renders condensed scatter charts when telemetry exis assert.equal(html.includes('node-detail__chart-point'), true); }); +test('renderTelemetryCharts injects insertBefore figures before their target spec', () => { + const nowMs = CHART_NOW_MS; + const nowSeconds = CHART_NOW_SECONDS; + const node = makeAggregatedNode([ + { rx_time: nowSeconds - 60, telemetry_type: 'device', battery_level: 80, voltage: 4.1 }, + { rx_time: nowSeconds - 3_600, telemetry_type: 'environment', temperature: 18.4, relative_humidity: 52 }, + ]); + const html = renderTelemetryCharts(node, { + nowMs, + insertBefore: { + environment: '
', + 'no-such-spec': '
', + }, + }); + const injectedIdx = html.indexOf('injected-activity'); + const envIdx = html.indexOf('Environmental telemetry'); + assert.ok(injectedIdx > -1 && envIdx > -1); + assert.ok(injectedIdx < envIdx, 'injected figure precedes the environmental figure'); + // A figure whose target spec did not render is appended, never dropped. + assert.ok(html.includes('leftover-figure')); +}); + test('renderTelemetryCharts expands upper bounds when overflow metrics exceed defaults', () => { const nowMs = CHART_NOW_MS; const nowSeconds = CHART_NOW_SECONDS; diff --git a/web/public/assets/js/app/__tests__/stats.test.js b/web/public/assets/js/app/__tests__/stats.test.js index 8fbc515..50cc470 100644 --- a/web/public/assets/js/app/__tests__/stats.test.js +++ b/web/public/assets/js/app/__tests__/stats.test.js @@ -21,6 +21,8 @@ import { computeLocalActiveNodeStats, normaliseActiveNodeStatsPayload, fetchActiveNodeStats, + fetchActivitySeries, + normaliseActivitySeries, formatActiveNodeStatsText, } from '../stats.js'; @@ -186,6 +188,56 @@ test('normaliseActiveNodeStatsPayload returns null for null/non-object input', ( assert.equal(normaliseActiveNodeStatsPayload('string'), null); }); +// --------------------------------------------------------------------------- +// per-scope packets rates (SPEC MA5) — feeds the mesh-activity map card +// --------------------------------------------------------------------------- + +test('normaliseActiveNodeStatsPayload attaches per-scope packets rates', () => { + const result = normaliseActiveNodeStatsPayload({ + total: { nodes: { hour: 1, day: 2, week: 3, month: 4 }, packets: { hour: 120 } }, + meshcore: { nodes: { hour: 1, day: 1, week: 1, month: 1 }, packets: { hour: 44 } }, + meshtastic: { nodes: { hour: 1, day: 1, week: 1, month: 1 }, packets: { hour: 76 } }, + sampled: false, + }); + assert.deepEqual(result.packets, { total: 120, meshcore: 44, meshtastic: 76 }); +}); + +test('normaliseActiveNodeStatsPayload omits packets when total.packets is absent', () => { + const result = normaliseActiveNodeStatsPayload({ + total: { nodes: { hour: 1, day: 2, week: 3, month: 4 } }, + sampled: false, + }); + assert.equal(result.packets, undefined); +}); + +test('normaliseActiveNodeStatsPayload omits packets when the total rate is non-finite', () => { + const result = normaliseActiveNodeStatsPayload({ + total: { nodes: { hour: 1, day: 2, week: 3, month: 4 }, packets: { hour: 'lots' } }, + meshcore: { nodes: { hour: 1, day: 1, week: 1, month: 1 }, packets: { hour: 44 } }, + sampled: false, + }); + assert.equal(result.packets, undefined); +}); + +test('normaliseActiveNodeStatsPayload keeps only the protocol rates that are present', () => { + const result = normaliseActiveNodeStatsPayload({ + total: { nodes: { hour: 1, day: 2, week: 3, month: 4 }, packets: { hour: 90 } }, + meshcore: { nodes: { hour: 1, day: 1, week: 1, month: 1 } }, + meshtastic: { nodes: { hour: 1, day: 1, week: 1, month: 1 } }, + sampled: false, + }); + assert.deepEqual(result.packets, { total: 90 }); +}); + +test('normaliseActiveNodeStatsPayload clamps negative and truncates float packet rates', () => { + const result = normaliseActiveNodeStatsPayload({ + total: { nodes: { hour: 1, day: 2, week: 3, month: 4 }, packets: { hour: 12.9 } }, + meshcore: { nodes: { hour: 1, day: 1, week: 1, month: 1 }, packets: { hour: -3 } }, + sampled: false, + }); + assert.deepEqual(result.packets, { total: 12, meshcore: 0 }); +}); + // --------------------------------------------------------------------------- // fetchActiveNodeStats // --------------------------------------------------------------------------- @@ -313,3 +365,78 @@ test('formatActiveNodeStatsText handles missing or null stats gracefully', () => const text = formatActiveNodeStatsText({ stats: null }); assert.equal(text, '0 nodes today · 0 this week', 'defaults to zero counts for null stats'); }); + +// --------------------------------------------------------------------------- +// activity time-series (SPEC F2-4) — feeds the map-card sparkline +// --------------------------------------------------------------------------- + +test('normaliseActivitySeries keeps oldest-first per-protocol rates, clamped and truncated', () => { + const series = normaliseActivitySeries([ + { bucket_start: 1, meshcore: 10, meshtastic: 20.9 }, + { bucket_start: 2, meshcore: -4, meshtastic: 5 }, // meshcore clamped to 0 + { bucket_start: 3, meshcore: 7 }, // meshtastic absent → 0 + { bucket_start: 4, meshtastic: 3 }, // meshcore absent → 0 + { bucket_start: 5, total: 99 }, // no per-protocol values → skipped + null, // falsy bucket → skipped + 'nope', // non-object → skipped + ]); + assert.deepEqual(series, [ + { meshcore: 10, meshtastic: 20 }, + { meshcore: 0, meshtastic: 5 }, + { meshcore: 7, meshtastic: 0 }, + { meshcore: 0, meshtastic: 3 }, + ]); +}); + +test('normaliseActivitySeries returns null for non-arrays or all-unusable input', () => { + assert.equal(normaliseActivitySeries(null), null); + assert.equal(normaliseActivitySeries({}), null); + assert.equal(normaliseActivitySeries([]), null); + assert.equal(normaliseActivitySeries([{ total: 5 }]), null); // total ignored; no protocol fields +}); + +test('fetchActivitySeries returns the normalised per-protocol series on success', async () => { + const calls = []; + const fetchImpl = async url => { + calls.push(url); + 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.match(calls[0], /\/api\/stats\/activity\?window_seconds=86400&bucket_seconds=3600/); +}); + +test('fetchActivitySeries fails soft to null on non-OK, error, and empty payloads', async () => { + assert.equal(await fetchActivitySeries({ fetchImpl: async () => ({ ok: false, status: 500 }) }), null); + assert.equal(await fetchActivitySeries({ fetchImpl: async () => { throw new Error('down'); } }), null); + assert.equal( + await fetchActivitySeries({ fetchImpl: async () => ({ ok: true, async json() { return []; } }) }), + null + ); +}); + +test('fetchActivitySeries caches the result for repeated calls with the same fetchImpl', async () => { + let hits = 0; + const fetchImpl = async () => { + 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.equal(hits, 1, 'the second call is served from cache'); +}); + +test('fetchActivitySeries coalesces concurrent calls into one request', async () => { + let hits = 0; + const fetchImpl = async () => { + hits += 1; + return { ok: true, async json() { return [{ meshcore: 3, meshtastic: 4 }]; } }; + }; + const [a, b] = await Promise.all([ + fetchActivitySeries({ fetchImpl }), + fetchActivitySeries({ fetchImpl }), + ]); + assert.deepEqual(a, [{ meshcore: 3, meshtastic: 4 }]); + assert.deepEqual(b, [{ meshcore: 3, meshtastic: 4 }]); + assert.equal(hits, 1, 'concurrent callers share one in-flight request'); +}); diff --git a/web/public/assets/js/app/charts-page.js b/web/public/assets/js/app/charts-page.js index ba8bcd2..f80f296 100644 --- a/web/public/assets/js/app/charts-page.js +++ b/web/public/assets/js/app/charts-page.js @@ -15,6 +15,7 @@ */ import { renderTelemetryCharts } from './node-page.js'; +import { fetchActivityChartBuckets, renderMeshActivityChart } from './mesh-activity-chart.js'; const TELEMETRY_BUCKET_SECONDS = 60 * 60; const HOUR_MS = 60 * 60 * 1000; @@ -217,9 +218,16 @@ export async function initializeChartsPage(options = {}) { container.innerHTML = renderStatus('Telemetry snapshots are unavailable.'); return true; } + const nowMs = Date.now(); + // Mesh activity figure (SPEC F2-5), injected between the channel-utilization + // and environmental telemetry figures; drawn from /api/stats/activity. + const activityFigure = renderMeshActivityChart( + await fetchActivityChartBuckets({ fetchImpl }), + nowMs + ); const node = { rawSources: { telemetry: { snapshots } } }; const chartsHtml = renderCharts(node, { - nowMs: Date.now(), + nowMs, chartOptions: { windowMs, timeRangeLabel: 'Last 7 days', @@ -227,6 +235,7 @@ export async function initializeChartsPage(options = {}) { xAxisTickFormatter: formatDayOfMonthLabel, lineReducer: points => buildMovingAverageSeries(points, HOUR_MS), }, + insertBefore: activityFigure ? { environment: activityFigure } : {}, }); if (!chartsHtml) { container.innerHTML = renderStatus('Telemetry snapshots are unavailable.'); diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js index 3b13103..39c93e4 100644 --- a/web/public/assets/js/app/main.js +++ b/web/public/assets/js/app/main.js @@ -20,6 +20,7 @@ import { computeLocalActiveNodeStats, normaliseActiveNodeStatsPayload, fetchActiveNodeStats, + fetchActivitySeries, formatActiveNodeStatsText, formatActiveNodeStatsHtml, } from './stats.js'; @@ -31,6 +32,8 @@ export { formatActiveNodeStatsHtml, }; +import { createMeshActivityCard } from './map-activity-card.js'; + import { normalizeNodeNameValue, buildNodeDetailHref, @@ -1673,6 +1676,7 @@ export function initializeApp(config) { let meshtasticCountEl = null; let meshcoreColEl = null; let meshtasticColEl = null; + let meshActivityCard = null; let legendToggleButton = null; let legendVisible = true; @@ -1735,9 +1739,10 @@ export function initializeApp(config) { // The toggle doubles as the legend key for the solid neighbor-line style // (SPEC UX7, audit D-014). neighborLinesToggleButton.innerHTML = `${legendLineSampleSvg('neighbor')} ${label}`; - // aria-pressed reflects whether the user has *activated* the toggle (i.e. lines are - // currently hidden). When lines are visible (default), the button is unpressed. - neighborLinesToggleButton.setAttribute('aria-pressed', neighborLinesVisible ? 'false' : 'true'); + // aria-pressed marks the *selected* (highlighted) state, consistent with the + // role chips (button.legend-item[aria-pressed="true"]): the toggle is pressed + // when its lines are visible, unpressed when hidden. (Previously reversed.) + neighborLinesToggleButton.setAttribute('aria-pressed', neighborLinesVisible ? 'true' : 'false'); neighborLinesToggleButton.setAttribute('aria-label', label); } @@ -1771,8 +1776,10 @@ export function initializeApp(config) { // The toggle doubles as the legend key for the dashed traceroute style // (SPEC UX7, audit D-014). traceLinesToggleButton.innerHTML = `${legendLineSampleSvg('trace')} ${label}`; - // aria-pressed reflects whether the user has *activated* the toggle (lines hidden). - traceLinesToggleButton.setAttribute('aria-pressed', traceLinesVisible ? 'false' : 'true'); + // aria-pressed marks the *selected* (highlighted) state, consistent with the + // role chips: pressed when its lines are visible, unpressed when hidden. + // (Previously reversed.) + traceLinesToggleButton.setAttribute('aria-pressed', traceLinesVisible ? 'true' : 'false'); traceLinesToggleButton.setAttribute('aria-label', label); } @@ -2022,6 +2029,20 @@ export function initializeApp(config) { }; legendControl.addTo(map); + // Mesh activity card (SPEC MA-F1): a bottom-left overlay mirroring the roles + // legend at bottom-right; populated from /api/stats packets rates in + // applyFilter's stats callback and rebased by the protocol toggles. + const meshActivityControl = L.control({ position: 'bottomleft' }); + meshActivityControl.onAdd = function () { + const wrapper = L.DomUtil.create('div', 'map-activity-outer'); + meshActivityCard = createMeshActivityCard(); + wrapper.appendChild(meshActivityCard.element); + L.DomEvent.disableClickPropagation(wrapper); + L.DomEvent.disableScrollPropagation(wrapper); + return wrapper; + }; + meshActivityControl.addTo(map); + const legendMediaQuery = window.matchMedia('(max-width: 1024px)'); const initialLegendVisible = resolveLegendVisibility({ defaultCollapsed: legendDefaultCollapsed, @@ -4832,6 +4853,18 @@ export function initializeApp(config) { updateProtocolToggleCounts(stats); updateFooterStats(visibleStats); applyProtocolVisibility(stats); + // Mesh activity card reads the raw per-protocol rates and rebases itself + // against the hidden-protocol set (SPEC MA-F4); a toggle re-runs + // applyFilter, so this refreshes the card on both data and toggle changes. + if (meshActivityCard) { + meshActivityCard.render({ packets: stats && stats.packets, hiddenProtocols }); + // The 24h sparkline series is fetched separately (cached ~5 min, F2-4); + // setSeries repaints the card when it resolves, and null on failure just + // omits the sparkline. + void fetchActivitySeries({}).then(series => { + if (meshActivityCard) meshActivityCard.setSeries(series); + }); + } }); } diff --git a/web/public/assets/js/app/map-activity-card.js b/web/public/assets/js/app/map-activity-card.js new file mode 100644 index 0000000..0535270 --- /dev/null +++ b/web/public/assets/js/app/map-activity-card.js @@ -0,0 +1,322 @@ +/* + * 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. + */ + +/** + * "Mesh activity" map-overlay card (SPEC MA-F1…MA-F6, F2-4). + * + * Renders the mesh-wide packets/hour rate — the total and its per-protocol + * split — as a small card pinned to the map's bottom-left corner (mirroring the + * roles legend bottom-right). The live figures come from ``/api/stats``'s + * ``.packets.hour`` metric; the 24-hour sparkline comes from the + * ``/api/stats/activity`` time-series (SPEC F2). This module turns those two + * inputs into DOM. + * + * Behaviour: + * - ``reticulum`` is never rendered — only ``meshtastic`` and ``meshcore`` have + * rows (MA-F2). + * - 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 + * row and rebases the displayed total to the sum of the *visible* protocols + * (MA-F4, Invariant IV parity). + * - The sparkline is drawn from the real 24-hour ``total`` series when present + * (SPEC F2-4); when the series is absent or too short it is simply omitted — + * the card never falls back to a fake curve. The sparkline shows the overall + * total-activity trend and is not rebased by the protocol toggles. + * + * The controller is stateful: {@link MeshActivityCard#render} updates the live + * rates and {@link MeshActivityCard#setSeries} updates the sparkline series + * independently, each repainting from the last-known other. + * + * @module map-activity-card + */ + +import { MESHTASTIC_ICON_SRC, MESHCORE_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). + * + * @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 }), +]); + +/** + * Round a number to two decimals for compact, stable SVG path output. + * + * @param {number} value Raw coordinate. + * @returns {number} Value rounded to 2 decimal places. + */ +function round2(value) { + return Math.round(value * 100) / 100; +} + +/** + * Build the sparkline path strings from a series of per-bucket totals. + * + * Maps the totals across a ``0 0 158 26`` viewBox (1 px left inset, 2 px top / + * 24 px bottom band), scaled to the series maximum. Returns null when there are + * fewer than two points (nothing to draw) so the caller omits the sparkline + * rather than inventing one (SPEC F2-4). + * + * @param {?Array} totals Per-bucket total packets/hour, oldest first. + * @returns {{line: string, area: string}|null} The open line path and closed + * area path, or null when the series is unusable. + */ +export function sparklinePathsFromSeries(totals) { + if (!Array.isArray(totals) || totals.length < 2) return null; + const width = 156; + const left = 1; + const top = 2; + const bottom = 24; + // 15% headroom above the series max so the busiest hour's vertex sits below + // the top edge and the 1.5px stroke is never clipped (F2 review). + const max = totals.reduce((peak, value) => Math.max(peak, value), 0) * 1.15; + const last = totals.length - 1; + const points = totals.map((value, index) => { + const x = round2(left + (index * width) / last); + const norm = max > 0 ? value / max : 0; + const y = round2(bottom - norm * (bottom - top)); + return { x, y }; + }); + const line = points + .map((point, index) => `${index === 0 ? 'M' : 'L'}${point.x} ${point.y}`) + .join(' '); + const area = `${line} L157 26 L1 26 Z`; + return { line, area }; +} + +/** + * Coerce a candidate packets/hour rate to a non-negative integer, or null. + * + * @param {*} value Candidate rate. + * @returns {number|null} Truncated non-negative rate, or null when unusable. + */ +function coerceRate(value) { + const rate = Number(value); + if (!Number.isFinite(rate) || rate < 0) return null; + return Math.trunc(rate); +} + +/** + * Sum the **visible** protocols' packets/hour in each sparkline bucket, so the + * curve rebases with the meta-row toggles exactly like the headline total does + * (F2 review — otherwise the number and the curve measure different things once + * a protocol is toggled off). + * + * @param {?Array<{meshcore?: number, meshtastic?: number}>} series Per-bucket + * per-protocol rates from ``/api/stats/activity``. + * @param {Set} hidden Protocols the user has toggled off. + * @returns {Array|null} Per-bucket visible totals, or null. + */ +function visibleSeriesTotals(series, hidden) { + if (!Array.isArray(series)) return null; + return series.map(bucket => { + let sum = 0; + for (const entry of PROTOCOL_ROWS) { + if (hidden.has(entry.protocol)) continue; + const value = Number(bucket?.[entry.protocol]); + if (Number.isFinite(value) && value >= 0) sum += value; + } + return sum; + }); +} + +/** + * 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 + * 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 + * visible protocol. The card is visible only when at least one protocol row + * survives and the total is greater than 0 (MA-F3). ``spark`` is the sparkline + * paths over the **visible** protocols' per-bucket sum, or null when absent + * (F2-4) — so the curve rebases with the toggles just like the total. + * + * @param {?{total?: number, meshcore?: number, meshtastic?: number}} packets + * Per-scope packets/hour rates from ``/api/stats`` (``stats.packets``). + * @param {?Set} hiddenProtocols Protocols the user has toggled off. + * @param {?Array<{meshcore?: number, meshtastic?: number}>} series Per-bucket + * per-protocol packets/hour for the sparkline. + * @returns {{visible: boolean, total: number, rows: Array<{label: string, iconSrc: string, rate: number, barPct: number}>, spark: ({line: string, area: string}|null)}} + * The render model. + */ +export function buildMeshActivityModel(packets, hiddenProtocols, series) { + const hidden = hiddenProtocols instanceof Set ? hiddenProtocols : new Set(); + const rates = packets && typeof packets === 'object' ? packets : {}; + const visible = []; + for (const entry of PROTOCOL_ROWS) { + if (hidden.has(entry.protocol)) continue; + const rate = coerceRate(rates[entry.protocol]); + if (rate === null) continue; + visible.push({ label: entry.label, iconSrc: entry.iconSrc, rate }); + } + const total = visible.reduce((sum, row) => sum + row.rate, 0); + const maxRate = visible.reduce((max, row) => Math.max(max, row.rate), 0); + const rows = visible.map(row => ({ + ...row, + barPct: maxRate > 0 ? Math.round((row.rate / maxRate) * 100) : 0, + })); + return { + visible: rows.length > 0 && total > 0, + total, + rows, + spark: sparklinePathsFromSeries(visibleSeriesTotals(series, hidden)), + }; +} + +/** + * Render the card interior to an HTML string from a model. + * + * Every interpolated value is a static label, a constant icon URL, or a + * number-coerced rate/percentage/coordinate, so the markup needs no escaping + * (mirrors the ``formatActiveNodeStatsHtml`` convention in {@link module:stats}). + * The sparkline SVG is emitted only when ``model.spark`` is present (F2-4). + * + * @param {{total: number, rows: Array<{label: string, iconSrc: string, rate: number, barPct: number}>, spark: ({line: string, area: string}|null)}} model + * Render model from {@link buildMeshActivityModel}. + * @returns {string} Inner HTML for the card element. + */ +export function renderMeshActivityCardHtml(model) { + const rowsHtml = model.rows + .map(row => + '
' + + `` + + `${row.label}` + + '' + + `` + + '' + + `${row.rate}` + + '
' + ) + .join(''); + const sparkHtml = model.spark + ? '' + + `` + + `` + + '' + : ''; + return ( + '
' + + '' + + 'Mesh activity' + + '
' + + '
' + + `${model.total}` + + 'packets/h' + + '
' + + sparkHtml + + `
${rowsHtml}
` + ); +} + +/** + * Create the Mesh-activity card controller. + * + * Builds the root ``.map-activity-card`` element once and returns a stateful + * controller. {@link MeshActivityCard#render} updates the live rates (from + * ``/api/stats``) and {@link MeshActivityCard#setSeries} updates the sparkline + * series (from ``/api/stats/activity``); each repaints from the last-known + * other, so the two data sources can arrive independently. + * + * @param {Document} [doc] DOM document (defaults to the global ``document``). + * @returns {{element: HTMLElement, render: (data: {packets: ?Object, hiddenProtocols: ?Set}) => boolean, setSeries: (series: ?Array) => boolean}} + * The card controller. ``render``/``setSeries`` return whether the card is visible. + */ +export function createMeshActivityCard(doc = globalThis.document) { + if (!doc || typeof doc.createElement !== 'function') { + throw new Error('createMeshActivityCard requires a document'); + } + const element = doc.createElement('div'); + element.classList.add('map-activity-card'); + // A labelled `group` so the aria-label is announced (a bare div's label is + // not); children stay readable, unlike role="img" (F2 review). + element.setAttribute('role', 'group'); + + let lastPackets = null; + let lastHidden = null; + let lastSeries = null; + + /** + * Toggle the card's hidden state (class + ``hidden``/``aria-hidden``). + * + * @param {boolean} hidden Whether the card should be hidden. + * @returns {void} + */ + function setHidden(hidden) { + if (hidden) { + element.classList.add('map-activity-card--hidden'); + element.setAttribute('hidden', 'hidden'); + element.setAttribute('aria-hidden', 'true'); + } else { + element.classList.remove('map-activity-card--hidden'); + element.removeAttribute('hidden'); + element.removeAttribute('aria-hidden'); + } + } + + setHidden(true); + + /** + * Repaint the card from the last-known rates and series. + * + * @returns {boolean} Whether the card is visible after the repaint. + */ + function paint() { + const model = buildMeshActivityModel(lastPackets, lastHidden, lastSeries); + if (!model.visible) { + element.innerHTML = ''; + setHidden(true); + return false; + } + element.setAttribute('aria-label', `Mesh activity: ${model.total} packets per hour`); + element.innerHTML = renderMeshActivityCardHtml(model); + setHidden(false); + return true; + } + + /** + * Update the live packet rates and hidden-protocol set, then repaint. + * + * @param {{packets: ?Object, hiddenProtocols: ?Set}} [data] Render input. + * @returns {boolean} Whether the card is visible after the update. + */ + function render(data = {}) { + lastPackets = data.packets; + lastHidden = data.hiddenProtocols; + return paint(); + } + + /** + * Update the sparkline total series, then repaint. + * + * @param {?Array} series Per-bucket total packets/hour (oldest first). + * @returns {boolean} Whether the card is visible after the update. + */ + function setSeries(series) { + lastSeries = series; + return paint(); + } + + return { element, render, setSeries }; +} diff --git a/web/public/assets/js/app/mesh-activity-chart.js b/web/public/assets/js/app/mesh-activity-chart.js new file mode 100644 index 0000000..f1b09f0 --- /dev/null +++ b/web/public/assets/js/app/mesh-activity-chart.js @@ -0,0 +1,171 @@ +/* + * 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. + */ + +/** + * Mesh-activity `/charts` figure (SPEC F2-5): packets/hour **per protocol** over + * the last 7 days. Built with the shared telemetry-chart helpers so it matches + * the surrounding chart chrome; `/charts` injects it between the + * channel-utilization and environmental figures. Data comes from the + * `/api/stats/activity` time-series (SPEC F2-1). + * + * @module mesh-activity-chart + */ + +import { escapeHtml } from './utils.js'; +import { createChartDimensions } from './node-page-charts/layout.js'; +import { + renderYAxis, + renderXAxis, + renderTelemetrySeries, +} from './node-page-charts/svg-renderers.js'; +import { buildMidnightTicks } from './node-page-charts/tick-builders.js'; + +const ACTIVITY_CHART_WINDOW_SECONDS = 7 * 24 * 3600; // 7 days +const ACTIVITY_CHART_BUCKET_SECONDS = 2 * 3600; // 2-hour buckets +const ACTIVITY_CHART_WINDOW_MS = ACTIVITY_CHART_WINDOW_SECONDS * 1000; + +/** + * Per-protocol lines, in draw/legend order. Colours match the design (option + * 1c): Meshtastic purple, MeshCore blue. + * + * @type {ReadonlyArray<{protocol: string, label: string, color: string}>} + */ +const ACTIVITY_CHART_LINES = Object.freeze([ + Object.freeze({ protocol: 'meshtastic', label: 'Meshtastic', color: '#8856a7' }), + Object.freeze({ protocol: 'meshcore', label: 'MeshCore', color: '#3182bd' }), +]); + +/** + * Fetch the per-protocol activity buckets for the `/charts` figure (7 d / 2 h). + * Fails soft to an empty array so the figure is simply omitted on any error. + * + * @param {{fetchImpl?: Function}} [params] Fetch parameters. + * @returns {Promise>} Ascending bucket objects, or `[]`. + */ +export async function fetchActivityChartBuckets({ fetchImpl = fetch } = {}) { + try { + const url = + `/api/stats/activity?window_seconds=${ACTIVITY_CHART_WINDOW_SECONDS}` + + `&bucket_seconds=${ACTIVITY_CHART_BUCKET_SECONDS}`; + const response = await fetchImpl(url, { cache: 'default' }); + if (!response?.ok) { + return []; + } + const payload = await response.json(); + return Array.isArray(payload) ? payload : []; + } catch (error) { + console.debug('Failed to fetch /api/stats/activity for /charts', error); + return []; + } +} + +/** + * Build a `{timestamp, value}` point list for one protocol from the buckets. + * + * @param {Array} buckets Bucket objects from the activity endpoint. + * @param {string} protocol Protocol key (`meshtastic` / `meshcore`). + * @returns {Array<{timestamp: number, value: number}>} Chart points (ms). + */ +function pointsForProtocol(buckets, protocol) { + const points = []; + for (const bucket of buckets) { + const start = Number(bucket?.bucket_start); + const value = Number(bucket?.[protocol]); + if (Number.isFinite(start) && Number.isFinite(value)) { + points.push({ timestamp: start * 1000, value: Math.max(0, value) }); + } + } + return points; +} + +/** + * Render the mesh-activity `/charts` figure (SPEC F2-5). + * + * @param {Array} buckets Per-protocol activity buckets (7 d / 2 h). + * @param {number} [nowMs] Reference timestamp in milliseconds. + * @returns {string} Figure markup, or an empty string when there is no data. + */ +export function renderMeshActivityChart(buckets, nowMs = Date.now()) { + if (!Array.isArray(buckets) || buckets.length === 0) { + return ''; + } + const lines = ACTIVITY_CHART_LINES.map(line => ({ + ...line, + points: pointsForProtocol(buckets, line.protocol), + })).filter(line => line.points.length > 0); + if (lines.length === 0) { + return ''; + } + + const domainEnd = nowMs; + const domainStart = nowMs - ACTIVITY_CHART_WINDOW_MS; + const maxValue = lines.reduce( + (peak, line) => line.points.reduce((max, point) => Math.max(max, point.value), peak), + 0 + ); + const dims = createChartDimensions({ axes: [{ position: 'left' }] }); + const axis = { + position: 'left', + scale: 'linear', + min: 0, + max: maxValue > 0 ? maxValue : 1, + ticks: 5, + label: 'Activity (pkt/h)', + visible: true, + }; + + const yAxisMarkup = renderYAxis(axis, dims); + const xAxisMarkup = renderXAxis( + dims, + domainStart, + domainEnd, + buildMidnightTicks(nowMs, ACTIVITY_CHART_WINDOW_MS) + ); + const seriesMarkup = lines + .map(line => + renderTelemetrySeries( + { color: line.color, valueFormatter: value => `${value} pkt/h` }, + line.points, + axis, + dims, + domainStart, + domainEnd + ) + ) + .join(''); + const legendMarkup = lines + .map(line => + '' + + `` + + `${escapeHtml(line.label)}` + + '' + ) + .join(''); + return ` +
+
+

Mesh activity

+ Last 7 days +
+ + ${yAxisMarkup} + ${xAxisMarkup} + ${seriesMarkup} + + +
+ `; +} diff --git a/web/public/assets/js/app/node-page/telemetry-charts.js b/web/public/assets/js/app/node-page/telemetry-charts.js index 6fb1e3e..6b029cc 100644 --- a/web/public/assets/js/app/node-page/telemetry-charts.js +++ b/web/public/assets/js/app/node-page/telemetry-charts.js @@ -33,10 +33,12 @@ import { stringOrNull } from '../value-helpers.js'; * exist. * * @param {Object} node Normalised node payload. - * @param {{ nowMs?: number, chartOptions?: Object }} [options] Rendering options. + * @param {{ nowMs?: number, chartOptions?: Object, insertBefore?: Object }} [options] + * Rendering options. ``insertBefore`` maps a chart spec id to HTML injected + * immediately before that spec's figure (SPEC F2-5); default none. * @returns {string} Chart grid markup or an empty string. */ -export function renderTelemetryCharts(node, { nowMs = Date.now(), chartOptions = {} } = {}) { +export function renderTelemetryCharts(node, { nowMs = Date.now(), chartOptions = {}, insertBefore = {} } = {}) { const telemetrySource = node?.rawSources?.telemetry; const snapshotHistory = Array.isArray(node?.rawSources?.telemetrySnapshots) && node.rawSources.telemetrySnapshots.length > 0 ? node.rawSources.telemetrySnapshots @@ -60,16 +62,39 @@ export function renderTelemetryCharts(node, { nowMs = Date.now(), chartOptions = return ''; } const isAggregated = snapshotHistory == null && aggregatedSnapshots != null; - const charts = TELEMETRY_CHART_SPECS - .map(spec => renderTelemetryChart(spec, entries, nowMs, { ...chartOptions, isAggregated })) - .filter(chart => stringOrNull(chart)); - if (charts.length === 0) { + const rendered = TELEMETRY_CHART_SPECS + .map(spec => ({ + specId: spec.id, + html: renderTelemetryChart(spec, entries, nowMs, { ...chartOptions, isAggregated }), + })) + .filter(chart => stringOrNull(chart.html)); + if (rendered.length === 0) { return ''; } + // Optional caller-supplied figures keyed by the spec id they precede (SPEC + // F2-5: /charts injects the Mesh-activity figure before ``environment``). + const injections = insertBefore && typeof insertBefore === 'object' ? insertBefore : {}; + const used = new Set(); + const parts = []; + for (const chart of rendered) { + const injected = stringOrNull(injections[chart.specId]); + if (injected && !used.has(chart.specId)) { + parts.push(injected); + used.add(chart.specId); + } + parts.push(chart.html); + } + // Any injection whose target spec did not render is appended rather than + // silently dropped. + for (const [specId, html] of Object.entries(injections)) { + if (!used.has(specId) && stringOrNull(html)) { + parts.push(html); + } + } return `
- ${charts.join('')} + ${parts.join('')}
`; diff --git a/web/public/assets/js/app/stats.js b/web/public/assets/js/app/stats.js index 07f24d8..83bddff 100644 --- a/web/public/assets/js/app/stats.js +++ b/web/public/assets/js/app/stats.js @@ -80,14 +80,44 @@ function normaliseProtocolBucket(bucket) { }; } +/** + * Extract the per-scope packets/hour rates from the payload's + * ``.packets.hour`` metric (SPEC MA5) into a flat rate bag. + * + * 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. + * + * @param {*} payload Candidate JSON object from the stats endpoint. + * @returns {{total: number, meshcore?: number, meshtastic?: number}|null} Rates or null. + */ +function normalisePacketsRates(payload) { + const readHourRate = scope => { + const hour = Number(payload?.[scope]?.packets?.hour); + return Number.isFinite(hour) ? Math.max(0, Math.trunc(hour)) : null; + }; + const total = readHourRate('total'); + if (total === null) { + return null; + } + const rates = { total }; + const meshcore = readHourRate('meshcore'); + const meshtastic = readHourRate('meshtastic'); + if (meshcore !== null) rates.meshcore = meshcore; + if (meshtastic !== null) rates.meshtastic = meshtastic; + return rates; +} + /** * Parse and validate the ``/api/stats`` payload (0.7.0 scope → metric → window * shape) into the flat node-count snapshot the dashboard renders. * * Node counts are read from ``total.nodes`` and the per-protocol * ``.nodes`` sub-buckets; the other metrics (messages/telemetry) are - * not surfaced in the header. The browser only ever calls its own same-version - * instance, so only the current shape is parsed. + * not surfaced in the header. The per-scope ``packets.hour`` rate (SPEC MA5) is + * attached as ``result.packets`` for the mesh-activity map card when present. + * The browser only ever calls its own same-version instance, so only the + * 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. @@ -108,6 +138,8 @@ export function normaliseActiveNodeStatsPayload(payload) { const meshtastic = normaliseProtocolBucket(payload.meshtastic?.nodes); if (meshcore) result.meshcore = meshcore; if (meshtastic) result.meshtastic = meshtastic; + const packets = normalisePacketsRates(payload); + if (packets) result.packets = packets; return result; } @@ -217,3 +249,91 @@ export function formatActiveNodeStatsHtml({ stats }) { ` · ${week} this week` ); } + +// Module-level cache for the activity time-series. The 24 h trend moves slowly, +// so a longer TTL keeps the map card from re-fetching it on every filter/refresh. +const ACTIVITY_SERIES_CACHE_TTL_MS = 300_000; +const ACTIVITY_SERIES_WINDOW_SECONDS = 86_400; +const ACTIVITY_SERIES_BUCKET_SECONDS = 3_600; +let activitySeriesCache = null; +let activitySeriesFetchPromise = null; +let activitySeriesFetchImpl = null; + +/** + * Reduce a ``/api/stats/activity`` payload to the per-bucket **per-protocol** + * rates the mesh-activity sparkline draws (SPEC F2-4). Keeping the protocols + * split (rather than pre-summing to a total) lets the card rebase the curve + * 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. + */ +export function normaliseActivitySeries(payload) { + if (!Array.isArray(payload)) { + return null; + } + const series = []; + for (const bucket of payload) { + if (!bucket || typeof bucket !== 'object') { + continue; + } + const meshcore = Number(bucket.meshcore); + const meshtastic = Number(bucket.meshtastic); + if (Number.isFinite(meshcore) || Number.isFinite(meshtastic)) { + series.push({ + meshcore: Number.isFinite(meshcore) ? Math.max(0, Math.trunc(meshcore)) : 0, + meshtastic: Number.isFinite(meshtastic) ? Math.max(0, Math.trunc(meshtastic)) : 0, + }); + } + } + return series.length > 0 ? series : null; +} + +/** + * Fetch the 24-hour packets/hour total series for the map-card sparkline + * (SPEC F2) with a long-lived cache. Fails soft to null on any error so the + * card simply omits the sparkline (F2-4) rather than throwing. + * + * @param {{fetchImpl?: Function}} [params] Fetch parameters. + * @returns {Promise|null>} Total series, or null. + */ +export async function fetchActivitySeries({ fetchImpl = fetch } = {}) { + const nowMs = Date.now(); + if (activitySeriesCache?.fetchImpl === fetchImpl && activitySeriesCache.expiresAt > nowMs) { + return activitySeriesCache.series; + } + if (activitySeriesFetchPromise && activitySeriesFetchImpl === fetchImpl) { + return activitySeriesFetchPromise; + } + + activitySeriesFetchImpl = fetchImpl; + activitySeriesFetchPromise = (async () => { + try { + const url = + `/api/stats/activity?window_seconds=${ACTIVITY_SERIES_WINDOW_SECONDS}` + + `&bucket_seconds=${ACTIVITY_SERIES_BUCKET_SECONDS}`; + const response = await fetchImpl(url, { cache: 'default' }); + if (!response?.ok) { + return null; + } + const series = normaliseActivitySeries(await response.json()); + activitySeriesCache = { + fetchImpl, + expiresAt: Date.now() + ACTIVITY_SERIES_CACHE_TTL_MS, + series, + }; + return series; + } catch (error) { + console.debug('Failed to fetch /api/stats/activity; sparkline omitted.', error); + return null; + } + })(); + + try { + return await activitySeriesFetchPromise; + } finally { + activitySeriesFetchPromise = null; + activitySeriesFetchImpl = null; + } +} diff --git a/web/public/assets/styles/base.css b/web/public/assets/styles/base.css index c6c4d58..c6091ea 100644 --- a/web/public/assets/styles/base.css +++ b/web/public/assets/styles/base.css @@ -1830,6 +1830,205 @@ input[type="radio"] { border-color: #444; } +/* Mesh activity card (SPEC MA-F1…MA-F6): a bottom-left map overlay, sibling to + the roles legend at bottom-right. Chrome matches .legend so the two map + corners read as a pair; the sparkline is an explicit placeholder (MA-F5). */ +.map-activity-outer { + /* Reset Leaflet control chrome so only the inner card carries styling. */ + background: transparent !important; + border: none !important; + box-shadow: none !important; + padding: 0 !important; +} + +.map-activity-card { + background: #333; + color: #eee; + padding: 8px 10px 10px; + border: 1px solid #444; + border-radius: 8px; + font-size: 12px; + line-height: 18px; + min-width: 178px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45); +} + +.map-activity-card--hidden { + display: none; +} + +.map-activity-card__header { + display: flex; + align-items: center; + gap: 4px; + margin-bottom: 6px; + font-weight: 600; +} + +.map-activity-card__pulse { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--accent); + animation: mesh-activity-pulse 2.4s ease-in-out infinite; +} + +.map-activity-card__title { + font-size: 13px; +} + +.map-activity-card__total { + display: flex; + align-items: baseline; + gap: 6px; + margin-bottom: 6px; +} + +.map-activity-card__total-value { + font-family: ui-monospace, Menlo, Consolas, monospace; + font-size: 26px; + line-height: 26px; + font-weight: 600; + color: #fff; + font-variant-numeric: tabular-nums; +} + +.map-activity-card__total-unit { + color: var(--muted); + font-size: 12px; +} + +.map-activity-card__spark { + display: block; + margin-bottom: 8px; +} + +.map-activity-card__spark-area { + fill: rgba(95, 168, 255, 0.18); +} + +.map-activity-card__spark-line { + fill: none; + stroke: var(--accent); + stroke-width: 1.5; + stroke-linejoin: round; +} + +.map-activity-card__rows { + display: flex; + flex-direction: column; + gap: 3px; +} + +.map-activity-card__row { + display: flex; + align-items: center; + gap: 6px; +} + +.map-activity-card__row-name { + flex: 1 1 auto; + color: #ccc; +} + +.map-activity-card__row-bar { + width: 44px; + height: 4px; + border-radius: 2px; + background: rgba(255, 255, 255, 0.12); + overflow: hidden; +} + +.map-activity-card__row-bar-fill { + display: block; + height: 100%; + background: var(--accent); +} + +.map-activity-card__row-rate { + min-width: 30px; + text-align: right; + font-family: ui-monospace, Menlo, Consolas, monospace; + font-variant-numeric: tabular-nums; + color: #fff; +} + +@keyframes mesh-activity-pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.35; + } +} + +@media (prefers-reduced-motion: reduce) { + .map-activity-card__pulse { + animation: none; + } +} + +/* Mesh activity card → full-width caption strip on phones (design 1d): the full + card covers ~a third of a small map, so span the map width (8px inset) with + the protocol split pushed to the far edge, so it reads as a map caption + rather than covering it. 659px matches the app's mobile band (UX9). */ +@media (max-width: 659px) { + /* Span the full map width instead of the content-width Leaflet corner. Only + the activity card lives bottom-left, so this scopes cleanly to it. */ + .leaflet-bottom.leaflet-left { + left: 0; + right: 0; + } + .map-activity-outer { + margin: 0 8px 8px !important; + } + .map-activity-card { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 8px; + padding: 4px 9px; + min-width: 0; + min-height: 32px; + line-height: 1.4; + } + /* Keep the hidden state hidden: re-declared *after* the flex rule so it wins + on source order (equal specificity), since an author `display` also beats + the UA sheet's `[hidden]`. Without this the idle card paints an empty pill + over the map, breaking MA-F3. */ + .map-activity-card--hidden { + display: none; + } + .map-activity-card__header, + .map-activity-card__total { + margin-bottom: 0; + gap: 4px; + } + .map-activity-card__title { + display: none; /* the numbers make the label redundant in a strip */ + } + .map-activity-card__total-value { + font-size: 15px; + line-height: 1.4; + } + .map-activity-card__spark { + display: none; /* the trend line needs vertical room the strip lacks */ + } + .map-activity-card__rows { + flex-direction: row; + gap: 8px; + margin-left: auto; /* push the protocol split to the far edge (caption) */ + } + .map-activity-card__row { + gap: 3px; + } + .map-activity-card__row-name, + .map-activity-card__row-bar { + display: none; /* icon + rate is enough in the strip */ + } +} + .legend-header { display: flex; align-items: center; diff --git a/web/spec/app_spec.rb b/web/spec/app_spec.rb index 3bce1d1..1ad50bd 100644 --- a/web/spec/app_spec.rb +++ b/web/spec/app_spec.rb @@ -6709,8 +6709,71 @@ RSpec.describe "Potato Mesh Sinatra app" do end end + describe "GET /api/stats/activity" do + it "serves a snake_case-param packets/hour time-series" do + clear_database + now = reference_time.to_i + allow(Time).to receive(:now).and_return(reference_time) + with_db do |db| + db.execute( + "INSERT INTO ingestor_activity(ingestor_id, at, packets, protocol) VALUES (?,?,?,?)", + ["!core0001", now - 100, 3600, "meshcore"], + ) + db.execute( + "INSERT INTO ingestor_activity(ingestor_id, at, packets, protocol) VALUES (?,?,?,?)", + ["!tast0001", now - 100, 1800, "meshtastic"], + ) + end + + get "/api/stats/activity?window_seconds=86400&bucket_seconds=3600" + + expect(last_response).to be_ok + series = JSON.parse(last_response.body) + expect(series).to be_an(Array) + expect(series.length).to eq(1) + bucket = series.first + expect(bucket).to have_key("bucket_start") + expect(bucket).to have_key("bucket_end") + expect(bucket["meshcore"]).to eq(3600) + expect(bucket["meshtastic"]).to eq(1800) + expect(bucket["total"]).to eq(5400) # SUM across protocols + end + + it "bypasses the cache when since is provided" do + clear_database + get "/api/stats/activity?window_seconds=86400&bucket_seconds=3600&since=#{Time.now.to_i - 3600}" + expect(last_response).to be_ok + expect(JSON.parse(last_response.body)).to be_an(Array) + end + + it "defaults to a 24h/1h window when no params are given" do + clear_database + get "/api/stats/activity" + expect(last_response).to be_ok + expect(JSON.parse(last_response.body)).to be_an(Array) + end + + it "rejects a non-positive window_seconds" do + get "/api/stats/activity?window_seconds=0&bucket_seconds=3600" + expect(last_response.status).to eq(400) + expect(JSON.parse(last_response.body)).to eq("error" => "window_seconds must be positive") + end + + it "rejects a non-positive bucket_seconds" do + get "/api/stats/activity?window_seconds=86400&bucket_seconds=0" + expect(last_response.status).to eq(400) + expect(JSON.parse(last_response.body)).to eq("error" => "bucket_seconds must be positive") + end + + it "rejects a bucket too small for the requested window" do + get "/api/stats/activity?window_seconds=86400&bucket_seconds=1" + expect(last_response.status).to eq(400) + expect(JSON.parse(last_response.body)).to eq("error" => "bucket_seconds too small for requested window") + end + end + describe "GET /api/stats" do - it "exposes the additive packets_per_hour MAX-per-protocol map" do + it "exposes the MA4 packets/hour rate as an additive .packets.hour metric" do clear_database now = reference_time.to_i allow(Time).to receive(:now).and_return(reference_time) @@ -6737,12 +6800,13 @@ RSpec.describe "Potato Mesh Sinatra app" do expect(last_response).to be_ok payload = JSON.parse(last_response.body) - pph = payload["packets_per_hour"] - expect(pph.keys).to contain_exactly("total", "meshcore", "meshtastic", "reticulum") - expect(pph["meshcore"]).to eq(50) - expect(pph["meshtastic"]).to eq(30) - expect(pph["total"]).to eq(50) # MAX over every ingestor = 1200 / 24 - expect(pph["reticulum"]).to eq(0) + # Opt 3 shape: the MA4 rate is folded into each scope as packets.hour; + # no top-level packets_per_hour map remains. + expect(payload).not_to have_key("packets_per_hour") + expect(payload["meshcore"]["packets"]).to eq("hour" => 50) + expect(payload["meshtastic"]["packets"]).to eq("hour" => 30) + expect(payload["total"]["packets"]).to eq("hour" => 80) # SUM of per-protocol MAX = (1200+720)/24 + expect(payload["reticulum"]["packets"]).to eq("hour" => 0) # The additive field leaves the S1 scope × metric × window tree intact. expect(payload["sampled"]).to eq(false) expect(payload["total"]).to have_key("nodes") diff --git a/web/spec/queries_spec.rb b/web/spec/queries_spec.rb index 3a5a4f2..8079654 100644 --- a/web/spec/queries_spec.rb +++ b/web/spec/queries_spec.rb @@ -1304,7 +1304,7 @@ RSpec.describe PotatoMesh::App::Queries do result = queries.query_packets_per_hour(now: now) expect(result["meshcore"]).to eq(50) # MAX(1200, 900) / 24 expect(result["meshtastic"]).to eq(30) # 720 / 24 - expect(result["total"]).to eq(50) # MAX over every ingestor = 1200 / 24 + expect(result["total"]).to eq(80) # SUM of per-protocol MAX = (1200 + 720) / 24 expect(result["reticulum"]).to eq(0) end @@ -1332,8 +1332,8 @@ RSpec.describe PotatoMesh::App::Queries do # 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 (MAX across every ingestor). - expect(result["total"]).to eq(30) # 720 / 24 + # total (summed across protocols). + expect(result["total"]).to eq(30) # sole protocol: 720 / 24 end it "rounds the hourly rate to the nearest integer" do @@ -1341,6 +1341,103 @@ RSpec.describe PotatoMesh::App::Queries do # 100 / 24 = 4.166… → 4 expect(queries.query_packets_per_hour(now: now)["meshcore"]).to eq(4) end + + it "sums the rounded per-protocol rates so total == meshcore + meshtastic" do + # 1210 / 24 = 50.4 → 50; 730 / 24 = 30.4 → 30. Rounding after the raw sum + # would give round(1940 / 24) = 81, which would not equal the exposed 50 + 30. + seed_activity( + [ + ["!coreaaaa", now - 100, 1210, "meshcore"], + ["!tastbbbb", now - 100, 730, "meshtastic"], + ], + ) + result = queries.query_packets_per_hour(now: now) + expect(result["meshcore"]).to eq(50) + expect(result["meshtastic"]).to eq(30) + expect(result["total"]).to eq(80) # 50 + 30, not round(1940 / 24) = 81 + end + end + + describe "#query_activity_buckets" do + before { with_db { |db| db.execute("DELETE FROM ingestor_activity") } } + after { with_db { |db| db.execute("DELETE FROM ingestor_activity") } } + + def seed_activity_rows(rows) + with_db do |db| + rows.each do |ingestor_id, at, packets, protocol| + db.execute( + "INSERT INTO ingestor_activity(ingestor_id, at, packets, protocol) VALUES (?,?,?,?)", + [ingestor_id, at, packets, protocol], + ) + end + end + end + + it "returns an empty series when there is no activity" do + expect( + queries.query_activity_buckets(window_seconds: 86_400, bucket_seconds: 3_600, now: now) + ).to eq([]) + end + + it "computes MAX-per-protocol rates and a SUM total per 1h bucket" do + at = now - 100 + seed_activity_rows( + [ + ["!coreaaaa", at, 1200, "meshcore"], # busiest meshcore vantage + ["!corebbbb", at, 900, "meshcore"], # quieter — MAX must ignore + ["!tastcccc", at, 720, "meshtastic"], + ], + ) + series = queries.query_activity_buckets(window_seconds: 86_400, bucket_seconds: 3_600, now: now) + expect(series.length).to eq(1) + bucket = series.first + expect(bucket["bucket_start"]).to eq((at / 3_600) * 3_600) + expect(bucket["bucket_end"]).to eq((at / 3_600) * 3_600 + 3_600) + expect(bucket["meshcore"]).to eq(1200) + expect(bucket["meshtastic"]).to eq(720) + expect(bucket["total"]).to eq(1920) # SUM across protocols + end + + it "divides each bucket's packet total by its hour span" do + seed_activity_rows([["!coreaaaa", now - 100, 88, "meshcore"]]) + series = queries.query_activity_buckets(window_seconds: 86_400, bucket_seconds: 7_200, now: now) + expect(series.first["meshcore"]).to eq(44) # 88 / 2h + end + + it "orders buckets ascending and excludes activity outside the window" do + seed_activity_rows( + [ + ["!coreaaaa", now - 7_400, 60, "meshcore"], # older bucket, in window + ["!coreaaaa", now - 100, 120, "meshcore"], # newer bucket + ["!coreaaaa", now - 200_000, 999_999, "meshcore"], # > 24h → excluded + ], + ) + series = queries.query_activity_buckets(window_seconds: 86_400, bucket_seconds: 3_600, now: now) + starts = series.map { |bucket| bucket["bucket_start"] } + expect(series.length).to eq(2) + expect(starts).to eq(starts.sort) + end + + it "folds reticulum into the total but emits no reticulum series" do + seed_activity_rows( + [ + ["!coreaaaa", now - 100, 3_600, "meshcore"], + ["!retiaaaa", now - 100, 1_800, "reticulum"], + ], + ) + 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["total"]).to eq(3600 + 1800) + end + + it "falls back to defaults for non-positive or missing window/bucket" do + seed_activity_rows([["!coreaaaa", now - 100, 3_600, "meshcore"]]) + from_zero = queries.query_activity_buckets(window_seconds: 0, bucket_seconds: 0, now: now) + from_nil = queries.query_activity_buckets(window_seconds: nil, bucket_seconds: nil, now: now) + expect(from_zero.first["meshcore"]).to eq(3600) # 24h/1h defaults + expect(from_nil.first["meshcore"]).to eq(3600) + end end describe "#query_telemetry_buckets" do diff --git a/web/views/charts.erb b/web/views/charts.erb index c17c796..3cab9b4 100644 --- a/web/views/charts.erb +++ b/web/views/charts.erb @@ -15,8 +15,8 @@ -->
-

Network telemetry trends

-

Aggregated telemetry snapshots from every Meshtastic node in the past week.

+

Network telemetry trends

+

Aggregated telemetry snapshots from all nodes in the past week.

Loading aggregated telemetry charts…