mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-09 10:22:52 +02:00
web: mesh activity map card (#860)
* data,web: /api/stats packets rate as <scope>.packets.hour * data,web: /api/stats packets as <scope>.packets.hour; total sums protocols * web: mesh activity map card - packets/h total + per-protocol split * web: /api/stats/activity bucketed packets/hour time-series * web: mesh activity card draws the real 24h sparkline * web: /charts mesh-activity figure, mobile card strip, legend-toggle fix * web: mesh-activity design-review remediation * web: /api/stats total sums rounded per-protocol rates + doc fixes
This commit is contained in:
+173
-17
@@ -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
|
||||
`<scope>.packets.hour` (MA-A5).
|
||||
|
||||
### MA-A5 — `/api/stats` exposes `packets_per_hour` additively — MA5
|
||||
### MA-A5 — `/api/stats` exposes packets as an additive `<scope>.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 (`<scope>.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
|
||||
`"<Protocol> activity in the last 24h: <N> active nodes, <M> packets/hour. https://<domain>"`,
|
||||
where `<N>` = the target's `GET /api/stats` `<protocol>.nodes.day` and `<M>` =
|
||||
`GET /api/stats` `packets_per_hour.<protocol>` — both fetched over HTTP from
|
||||
`GET /api/stats` `<protocol>.packets.hour` — both fetched over HTTP from
|
||||
`<domain>`, never computed from the ingestor's local counters — and the rendered
|
||||
line is truncated to the protocol's character limit. `<domain>` = 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` `<scope>.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 `<scope>.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`.
|
||||
|
||||
@@ -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 `<scope>.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
|
||||
|
||||
@@ -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` `<scope>.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 `<protocol>.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: `"<Protocol> activity in the last 24h: <N> active nodes, <M> packets/hour. https://<domain>"`. `<N>` and `<M>` are fetched **from the target instance** (`GET /api/stats` → `<protocol>.nodes.day` and `packets_per_hour.<protocol>`), never computed from the ingestor's local view — because one ingestor may not see the whole mesh. `<domain>` 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 `<scope>.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 `<protocol>.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: `"<Protocol> activity in the last 24h: <N> active nodes, <M> packets/hour. https://<domain>"`. `<N>` and `<M>` are fetched **from the target instance** (`GET /api/stats` → `<protocol>.nodes.day` and `<protocol>.packets.hour`), never computed from the ingestor's local view — because one ingestor may not see the whole mesh. `<domain>` 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 `<domain>/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 `<scope>.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 `<scope>.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` `<scope>.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 |
|
||||
|
||||
@@ -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` (`<scope>.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**:
|
||||
- **`<scope>.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
|
||||
`<scope>.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
|
||||
|
||||
@@ -121,7 +121,7 @@ def fetch_activity(
|
||||
|
||||
These are the mesh-wide numbers the announcement quotes (SPEC MA6):
|
||||
``active_nodes`` = ``<protocol>.nodes.day`` and ``packets_per_hour`` =
|
||||
``packets_per_hour.<protocol>``.
|
||||
``<protocol>.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:
|
||||
|
||||
+28
-10
@@ -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 ``(<protocol>.nodes.day, packets_per_hour.<protocol>)``."""
|
||||
"""Returns ``(<protocol>.nodes.day, <protocol>.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"},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
# +<scope>.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 +<protocol>.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<Hash>] 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
|
||||
|
||||
@@ -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 (+<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"])
|
||||
|
||||
@@ -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 '<section class="node-detail__charts">Charts</section>';
|
||||
};
|
||||
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 = {
|
||||
|
||||
@@ -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.match(withSpark, /packets\/h/);
|
||||
assert.match(withSpark, /meshtastic\.svg/);
|
||||
assert.match(withSpark, /meshcore\.svg/);
|
||||
assert.match(withSpark, /width:100%/);
|
||||
assert.match(withSpark, /map-activity-card__spark-line/);
|
||||
assert.doesNotMatch(withSpark, /data-placeholder/);
|
||||
|
||||
const noSpark = renderMeshActivityCardHtml(
|
||||
buildMeshActivityModel({ total: 120, meshtastic: 76, meshcore: 44 }, new Set(), null)
|
||||
);
|
||||
assert.doesNotMatch(noSpark, /map-activity-card__spark/);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createMeshActivityCard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('createMeshActivityCard requires a usable document', () => {
|
||||
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</);
|
||||
|
||||
// Zero total → hidden and emptied.
|
||||
assert.equal(card.render({ packets: { total: 0 }, hiddenProtocols: new Set() }), false);
|
||||
assert.equal(card.element.classList.contains('map-activity-card--hidden'), true);
|
||||
assert.equal(card.element.innerHTML, '');
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('createMeshActivityCard setSeries before rates stays hidden until rates arrive', () => {
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -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, /<h4>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, /<h4>Mesh activity<\/h4>/);
|
||||
assert.match(html, /node-detail__chart-trend/);
|
||||
});
|
||||
@@ -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: '<figure class="injected-activity"></figure>',
|
||||
'no-such-spec': '<figure class="leftover-figure"></figure>',
|
||||
},
|
||||
});
|
||||
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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
* ``<scope>.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<number>} 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<string>} hidden Protocols the user has toggled off.
|
||||
* @returns {Array<number>|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<string>} 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 =>
|
||||
'<div class="map-activity-card__row">' +
|
||||
`<img class="map-activity-card__row-icon" src="${row.iconSrc}" alt="" width="12" height="12" aria-hidden="true" />` +
|
||||
`<span class="map-activity-card__row-name">${row.label}</span>` +
|
||||
'<span class="map-activity-card__row-bar">' +
|
||||
`<span class="map-activity-card__row-bar-fill" style="width:${row.barPct}%"></span>` +
|
||||
'</span>' +
|
||||
`<span class="map-activity-card__row-rate">${row.rate}</span>` +
|
||||
'</div>'
|
||||
)
|
||||
.join('');
|
||||
const sparkHtml = model.spark
|
||||
? '<svg class="map-activity-card__spark" viewBox="0 0 158 26" width="158" height="26" ' +
|
||||
'aria-hidden="true" focusable="false">' +
|
||||
`<path class="map-activity-card__spark-area" d="${model.spark.area}"></path>` +
|
||||
`<path class="map-activity-card__spark-line" d="${model.spark.line}" fill="none"></path>` +
|
||||
'</svg>'
|
||||
: '';
|
||||
return (
|
||||
'<div class="map-activity-card__header">' +
|
||||
'<span class="map-activity-card__pulse" aria-hidden="true"></span>' +
|
||||
'<span class="map-activity-card__title">Mesh activity</span>' +
|
||||
'</div>' +
|
||||
'<div class="map-activity-card__total">' +
|
||||
`<span class="map-activity-card__total-value">${model.total}</span>` +
|
||||
'<span class="map-activity-card__total-unit">packets/h</span>' +
|
||||
'</div>' +
|
||||
sparkHtml +
|
||||
`<div class="map-activity-card__rows">${rowsHtml}</div>`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string>}) => boolean, setSeries: (series: ?Array<number>) => 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<string>}} [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<number>} 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 };
|
||||
}
|
||||
@@ -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<Array<Object>>} 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<Object>} 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<Object>} 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 =>
|
||||
'<span class="node-detail__chart-legend-item">' +
|
||||
`<span class="node-detail__chart-legend-swatch" style="background:${line.color}"></span>` +
|
||||
`<span class="node-detail__chart-legend-text">${escapeHtml(line.label)}</span>` +
|
||||
'</span>'
|
||||
)
|
||||
.join('');
|
||||
return `
|
||||
<figure class="node-detail__chart">
|
||||
<figcaption class="node-detail__chart-header">
|
||||
<h4>Mesh activity</h4>
|
||||
<span>Last 7 days</span>
|
||||
</figcaption>
|
||||
<svg viewBox="0 0 ${dims.width} ${dims.height}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="Mesh activity packets per hour per protocol over the last seven days">
|
||||
${yAxisMarkup}
|
||||
${xAxisMarkup}
|
||||
${seriesMarkup}
|
||||
</svg>
|
||||
<div class="node-detail__chart-legend" aria-hidden="true">${legendMarkup}</div>
|
||||
</figure>
|
||||
`;
|
||||
}
|
||||
@@ -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 `
|
||||
<section class="node-detail__charts">
|
||||
<div class="node-detail__charts-grid">
|
||||
${charts.join('')}
|
||||
${parts.join('')}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
|
||||
@@ -80,14 +80,44 @@ function normaliseProtocolBucket(bucket) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the per-scope packets/hour rates from the payload's
|
||||
* ``<scope>.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
|
||||
* ``<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<Array<number>|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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+71
-7
@@ -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 <scope>.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")
|
||||
|
||||
+100
-3
@@ -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
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
-->
|
||||
<section class="charts-page">
|
||||
<header class="charts-page__intro">
|
||||
<h2><img src="/assets/img/meshtastic.svg" alt="" aria-hidden="true" width="20" height="20" class="protocol-icon protocol-icon--meshtastic" loading="lazy" decoding="async" /> Network telemetry trends</h2>
|
||||
<p>Aggregated telemetry snapshots from every Meshtastic node in the past week.</p>
|
||||
<h2>Network telemetry trends</h2>
|
||||
<p>Aggregated telemetry snapshots from all nodes in the past week.</p>
|
||||
</header>
|
||||
<div id="chartsPage" class="charts-page__content">
|
||||
<p class="charts-page__status">Loading aggregated telemetry charts…</p>
|
||||
|
||||
Reference in New Issue
Block a user