From 433d48e84ac80ded79c84b35f248bc5e99e42fb7 Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:17:52 +0200 Subject: [PATCH] data,web: /api/stats packets rate as .packets.hour --- ACCEPTANCE.md | 39 +++++++++++-------- CHANGELOG.md | 1 + SPEC.md | 6 +-- data/mesh_ingestor/CONTRACTS.md | 39 ++++++++++--------- data/mesh_ingestor/announce.py | 16 ++++---- tests/test_announce_unit.py | 38 +++++++++++++----- .../application/queries/ingestor_queries.rb | 5 ++- web/lib/potato_mesh/application/routes/api.rb | 20 +++++----- web/spec/app_spec.rb | 15 +++---- 9 files changed, 107 insertions(+), 72 deletions(-) diff --git a/ACCEPTANCE.md b/ACCEPTANCE.md index f78b67c..f00c546 100644 --- a/ACCEPTANCE.md +++ b/ACCEPTANCE.md @@ -4040,24 +4040,29 @@ 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 +`MAX` across **all** ingestors regardless of protocol; a protocol with no active +ingestor reads `0`; rows older than 24 h do not contribute). `query_packets_per_hour` +returns these per-protocol rates, which the `GET /api/stats` route folds into each +scope as `.packets.hour` (MA-A5). -### MA-A5 — `/api/stats` exposes `packets_per_hour` additively — MA5 +### MA-A5 — `/api/stats` exposes packets as an additive `.packets.hour` metric — MA5 ```bash curl -s http://127.0.0.1:41447/api/stats \ - | python3 -c 'import sys,json; d=json.load(sys.stdin); p=d["packets_per_hour"]; \ + | python3 -c 'import sys,json; d=json.load(sys.stdin); \ SC=("total","meshcore","meshtastic","reticulum"); \ -print(all(isinstance(p[s],(int,float)) for s in SC) and p["reticulum"]==0 \ -and set(SC).issubset(d) and all(m in d["total"] for m in ("nodes","messages","telemetry")))' +print("packets_per_hour" not in d \ +and all(isinstance(d[s]["packets"]["hour"],(int,float)) for s in SC) \ +and d["reticulum"]["packets"]["hour"]==0 \ +and all(m in d["total"] for m in ("nodes","messages","telemetry","packets")))' ``` -**Expected:** prints `True` — the response carries an additive top-level -`packets_per_hour` map keyed `{total, meshcore, meshtastic, reticulum}` -(`reticulum` a `0` stub), **and** the pre-existing scope × metric × window tree -(S1: each scope still carrying `nodes`/`messages`/`telemetry`) is unchanged and -still present. No version bump — **S-A1** still passes. +**Expected:** prints `True` — each scope carries an additive `packets` metric with +a single `hour` window (`.packets.hour`, the MA4 rate; `reticulum` a `0` +stub), the old top-level `packets_per_hour` map is **gone**, **and** the +pre-existing scope × metric × window tree (S1: each scope still carrying +`nodes`/`messages`/`telemetry`) is unchanged and still present. No version bump — +**S-A1** still passes. ### MA-A6 — Announcement content is dogfed from the instance API — MA6 ```bash @@ -4066,7 +4071,7 @@ still present. No version bump — **S-A1** still passes. **Expected:** pass. The announcement string is exactly `" activity in the last 24h: active nodes, packets/hour. https://"`, where `` = the target's `GET /api/stats` `.nodes.day` and `` = -`GET /api/stats` `packets_per_hour.` — both fetched over HTTP from +`GET /api/stats` `.packets.hour` — both fetched over HTTP from ``, never computed from the ingestor's local counters — and the rendered line is truncated to the protocol's character limit. `` = the configured `INSTANCE_DOMAIN`. @@ -4113,7 +4118,7 @@ and `send_channel_announcement` is **not** a required member. git grep -nE 'packets|ingestor_activity|packets_per_hour' -- data/mesh_ingestor/CONTRACTS.md ``` **Expected:** the additive heartbeat `packets` field, the `ingestor_activity` -schema, and the `GET /api/stats` `packets_per_hour` addition are all documented in +schema, and the `GET /api/stats` `.packets.hour` addition are all documented in `CONTRACTS.md` (Layer C source of truth). The engineering bar (100 % tests/docs/ headers/lint) is enforced by Layer **B** (B1–B5); behavior is covered by MA-A1…MA-A9. @@ -4127,8 +4132,8 @@ 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); diff --git a/CHANGELOG.md b/CHANGELOG.md index 111f91b..9be75ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ table, and reported-only disclosure rows. * 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/data: `GET /api/stats` exposes the mesh-activity packets/hour rate as `.packets.hour` (folded into the S1 scope→metric→window tree) rather than a top-level `packets_per_hour` map; the ingestor announcement dogfeed reads the new path (SPEC MA5, pre-release amendment of the merged-but-unreleased #859) * Web: WCAG AA text contrast — role-badge text computed by background luminance; chat log entries, error text, links, and focus rings tokenised (`--danger`, single `--accent`) * Web: federation table's undefined CSS tokens aliased — row borders and the sticky header render again * Web: legend expanded by default on /map, honest toggle label (filter suffix only when filters are active), line-style key on the neighbor/trace toggles; fixed the malformed `data-legend-collapsed` attribute diff --git a/SPEC.md b/SPEC.md index 9c66836..3d05a90 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1086,7 +1086,7 @@ wire, not cloud egress, not third-party analytics — and is opt-out via *optional, duck-typed* provider capability, not a new formal `MeshProtocol` member, so the `A4b` conformance contract and every existing provider are untouched; both protocols announce identically. *D8 (stable contract)* — **extends**: the heartbeat -`packets` field, the `/api/stats` `packets_per_hour` map, and the activity table +`packets` field, the `/api/stats` `.packets.hour` metric, and the activity table are all additive; no version bump. *§3.3 (web is POST-only intake)* — **consistent**: the dogfeed is a read; no new ingest path. No invariant is contradicted. @@ -1097,8 +1097,8 @@ contradicted. | **MA2** | **Heartbeat carries the per-interval delta.** `POST /api/ingestors` gains an additive optional `packets` field = frames counted since the previous heartbeat (a per-interval **delta**, not a since-boot cumulative), reset to zero each time a heartbeat is queued. Per-interval deltas map one-to-one onto time-series rows and are restart-safe (a reboot simply starts a fresh interval). Absent ⇒ treated as `0`, so pre-feature ingestors and the existing `tests/test_mesh.py` fixtures are unaffected (D8). | interview | | **MA3** | **Per-ingestor activity time-series (the moving-average schema).** A new append-only table `ingestor_activity` (`ingestor_id TEXT`, `at INTEGER`, `packets INTEGER`, `protocol TEXT`, indexed on `at`) records one row per heartbeat delta. This is the schema that makes a packets/hour moving average computable while distinguishing **multiple protocols and multiple ingestors per protocol** (each ingestor's contribution is kept separate rather than pre-summed). The `ingestors` snapshot table (one row per node) is unchanged. Rows are pruned by the existing retention worker (window ≥ the 24 h the announcement needs; sized with the other activity floors). | proposed | | **MA4** | **Aggregation = MAX per protocol (dedup-free "in the air").** The mesh-wide packets/hour for a protocol is `MAX` over that protocol's ingestors of *(that ingestor's total `packets` reported in the last 24 h ÷ 24)*. A single radio can only hear ≤ what is actually transmitted, so the busiest single vantage is the best dedup-free estimate of unique air traffic and can never double-count a frame heard by two radios (true per-frame dedup is impossible anyway — ignored/errored frames carry no id). The fixed `÷ 24` denominator (not ÷ elapsed) keeps the rate stable and avoids divide-by-small spikes; an ingestor with < 24 h of data simply reads lower and ramps up (moot in practice — the announcement fires only ≥ 24 h after start, MA7). *Accepted limitation:* MAX under-estimates the true union when different radios are busiest in different hours. | interview | -| **MA5** | **Exposure via an additive `/api/stats` field.** `GET /api/stats` gains a top-level additive `packets_per_hour` map — `{ total, meshcore, meshtastic, reticulum }` — carrying the MA4 24 h MAX-aggregated moving average (`reticulum` a forward-looking `0` stub, consistent with S6; `total` is the MAX across all ingestors regardless of protocol). The existing scope × metric × window tree (S1) is untouched, so this needs **no** version bump. The announcement's active-node figure reuses the existing `.nodes.day` count (already deduped by `node_id`); only packets/hour is new. | proposed | -| **MA6** | **Announcement content, drawn from the instance's own API (dogfeeding).** Each ingestor broadcasts, for its own configured protocol, a single line formatted to that protocol's character limit: `" activity in the last 24h: active nodes, packets/hour. https://"`. `` and `` are fetched **from the target instance** (`GET /api/stats` → `.nodes.day` and `packets_per_hour.`), never computed from the ingestor's local view — because one ingestor may not see the whole mesh. `` is the configured `INSTANCE_DOMAIN`. Reporting (MA1–MA4) is independent and continues regardless of announcement state. | interview | +| **MA5** | **Exposure as an additive per-scope `packets` metric.** `GET /api/stats` exposes the MA4 24 h MAX-aggregated moving average under each scope as `.packets.hour` — a `packets` metric carrying a single `hour` window (it is a rate, not a windowed count, so no day/week/month keys), consistent with the S1 `scope → metric → window` layout. `reticulum.packets.hour` is a forward-looking `0` stub (S6); `total.packets.hour` is the MAX across all ingestors regardless of protocol. The rest of the S1 tree is untouched, so this needs **no** version bump. The announcement's active-node figure reuses the existing `.nodes.day` count (already deduped by `node_id`); only packets/hour is new. **Amended pre-release:** the field originally shipped (unreleased, on `main`) as a top-level `packets_per_hour: { total, meshcore, meshtastic, reticulum }` map; it is superseded here by the per-scope `packets.hour` form for consistency with the S1 tree. Because it was merged but **not yet in any tagged release** and is **not** on the signed federation wire, the reshape carries **no** version bump, federation-compat fallback, or signature break — only the in-repo `announce.py` dogfeed reader (MA6) and the specs/docs move with it. | proposed (amended) | +| **MA6** | **Announcement content, drawn from the instance's own API (dogfeeding).** Each ingestor broadcasts, for its own configured protocol, a single line formatted to that protocol's character limit: `" activity in the last 24h: active nodes, packets/hour. https://"`. `` and `` are fetched **from the target instance** (`GET /api/stats` → `.nodes.day` and `.packets.hour`), never computed from the ingestor's local view — because one ingestor may not see the whole mesh. `` is the configured `INSTANCE_DOMAIN`. Reporting (MA1–MA4) is independent and continues regardless of announcement state. | interview | | **MA7** | **Announcement is triple-gated.** An announcement is transmitted only when **all** hold: (a) `RX_ONLY` is unset — the **reused** receive-only flag (default `0`) is the single transmit gate; `RX_ONLY=1` forbids *every* ingestor TX (the MeshCore polls and the announcement alike), so it is the sole opt-out and **no separate `ENABLE_TX` env is added**; (b) the target instance reports **non-private** — the ingestor GETs `/version` and honors `config.private_mode`, re-checked every cycle, and **fails closed** (skips) on any fetch/parse error, so a privacy signal is never missed (Invariant II); (c) **≥ 24 h have elapsed since ingestor start** — so the first numbers are accurate over a full window and restarts cannot spam the channel. | interview | | **MA8** | **Default channel/scope + 24 h cadence.** The announcement is broadcast on the protocol's **default channel and scope** — Meshtastic channel `CHANNEL_INDEX` (default `0`) via the interface text-send; MeshCore its public/default channel — honoring existing `ALLOWED_CHANNELS`/`HIDDEN_CHANNELS` intent. After the initial ≥ 24 h wait it repeats **every 24 h**, per configured instance domain (an ingestor with several `INSTANCE_DOMAIN` targets announces each with its own numbers and link). TX volume is negligible (~1 frame/day/domain). | proposed | | **MA9** | **Optional duck-typed provider send.** Transmission is exposed as a new **optional** provider method (e.g. `send_channel_announcement(iface, text)`) accessed via `getattr(provider, …, None)` — mirroring the existing optional `self_node_item` extension — so the `@runtime_checkable MeshProtocol` interface and its `A4b` isinstance conformance are unchanged, and a provider that cannot transmit (or a receive-only transport) simply omits it. Both `meshtastic` and `meshcore` providers implement it; neither protocol is privileged. | interview | diff --git a/data/mesh_ingestor/CONTRACTS.md b/data/mesh_ingestor/CONTRACTS.md index 9b8027e..8856bb5 100644 --- a/data/mesh_ingestor/CONTRACTS.md +++ b/data/mesh_ingestor/CONTRACTS.md @@ -220,7 +220,7 @@ Heartbeat payload: - Optional: `protocol` (string; e.g. `"meshtastic"`, `"meshcore"`) — declares the mesh backend for this ingestor; defaults to `"meshtastic"` when absent - Optional: `packets` (int ≥ 0) — **mesh-activity delta (SPEC MA1/MA2).** The merged count of *every* frame this ingestor handled since its previous heartbeat: all received frames (including ignored / errored / unimplemented) **plus** its own transmissions (announcement + MeshCore telemetry polls), counted at the earliest receive/transmit seam so nothing is under-reported. It is a **per-interval delta** (reset on each send), **not** a since-boot cumulative. Additive and backward-compatible: an absent or negative value records no activity, so pre-feature ingestors are unaffected. -**Mesh-activity time-series (SPEC MA3).** Each heartbeat carrying a non-negative `packets` value appends one **append-only** row to the `ingestor_activity` table (`ingestor_id`, `at`, `packets`, `protocol`; `data/ingestor_activity.sql`); the `ingestors` snapshot row is upserted as before. Each ingestor's contribution is stored separately (never pre-summed) so a packets/hour moving average is computable across time × protocol × multiple ingestors. The row is best-effort — a failed activity insert never sinks the liveness heartbeat (still `201`). Rows are pruned by the retention worker on `at`. The read-side aggregate is served by `GET /api/stats` (`packets_per_hour`, below). +**Mesh-activity time-series (SPEC MA3).** Each heartbeat carrying a non-negative `packets` value appends one **append-only** row to the `ingestor_activity` table (`ingestor_id`, `at`, `packets`, `protocol`; `data/ingestor_activity.sql`); the `ingestors` snapshot row is upserted as before. Each ingestor's contribution is stored separately (never pre-summed) so a packets/hour moving average is computable across time × protocol × multiple ingestors. The row is best-effort — a failed activity insert never sinks the liveness heartbeat (still `201`). Rows are pruned by the retention worker on `at`. The read-side aggregate is served by `GET /api/stats` (`.packets.hour`, below). **Protocol propagation**: all event records (`messages`, `positions`, `telemetry`, `traces`, `neighbors`) that reference this ingestor via their `ingestor` field inherit its `protocol` value at write time when no explicit per-record `protocol` stamp is present. Per-record stamps take precedence — the ingestor heartbeat default only kicks in when the per-record field is absent or malformed. @@ -302,11 +302,10 @@ do **not** accept `before`. ```jsonc { - "total": { "nodes": {…}, "messages": {…}, "telemetry": {…} }, - "meshcore": { "nodes": {…}, "messages": {…}, "telemetry": {…} }, - "meshtastic": { "nodes": {…}, "messages": {…}, "telemetry": {…} }, - "reticulum": { "nodes": {…}, "messages": {…}, "telemetry": {…} }, // stub: always 0 - "packets_per_hour": { "total": 50, "meshcore": 50, "meshtastic": 30, "reticulum": 0 }, + "total": { "nodes": {…}, "messages": {…}, "telemetry": {…}, "packets": { "hour": 50 } }, + "meshcore": { "nodes": {…}, "messages": {…}, "telemetry": {…}, "packets": { "hour": 50 } }, + "meshtastic": { "nodes": {…}, "messages": {…}, "telemetry": {…}, "packets": { "hour": 30 } }, + "reticulum": { "nodes": {…}, "messages": {…}, "telemetry": {…}, "packets": { "hour": 0 } }, // stub: always 0 "sampled": false } ``` @@ -317,24 +316,28 @@ do **not** accept `before`. ingestor exists yet) and is always all-zero. - **Metrics.** `nodes` counts `nodes` by `last_heard`; `messages` counts `messages` by `rx_time`; `telemetry` is the umbrella over `positions` + `telemetry` + - `neighbors` + `traces` (every non-message packet record) by `rx_time`. -- **Windows.** Each metric maps to `{ "hour", "day", "week", "month" }` integer - counts at the fixed cutoffs (1 h / 24 h / `week_seconds` / `four_weeks_seconds`); - `month` cannot exceed the 28-day visibility floor. + `neighbors` + `traces` (every non-message packet record) by `rx_time`; `packets` + is the additive MA4/MA5 packets/hour rate (below). +- **Windows.** The `nodes`/`messages`/`telemetry` metrics map to + `{ "hour", "day", "week", "month" }` integer counts at the fixed cutoffs + (1 h / 24 h / `week_seconds` / `four_weeks_seconds`); `month` cannot exceed the + 28-day visibility floor. The `packets` metric carries only `hour` (it is a rate, + not a windowed count). - **Privacy.** Every metric honors the node opt-out marker. When `PRIVATE=1`, all `messages` counts are forced to `0` (mirroring the disabled message API); `nodes`/`telemetry` counts remain. -- **`packets_per_hour`** (additive, SPEC MA4/MA5) is a flat top-level map keyed - `{ total, meshcore, meshtastic, reticulum }` carrying the 24-hour packets/hour - moving average as a rounded integer. It is aggregated **MAX-per-protocol**: +- **`.packets.hour`** (additive, SPEC MA4/MA5) carries the 24-hour + packets/hour moving average as a rounded integer, exposed as a `packets` metric + under each scope (single `hour` window). It is aggregated **MAX-per-protocol**: `MAX` over that protocol's ingestors of *(the ingestor's `packets` total in the last 24 h ÷ 24)* — a single radio hears ≤ what is actually transmitted, so the busiest vantage is the best dedup-free estimate of air traffic and never - double-counts a frame heard by two radios. `total` is the same MAX over **every** - ingestor regardless of protocol; `reticulum` is the always-zero forward-looking - stub. Unlike `messages`, it is **not** privacy-gated (packets are a public - aggregate, no message content). Additive to the 0.7.x `/api/stats` tree — no - version bump; the ingestor dogfeeds it for the activity announcement (MA6). + double-counts a frame heard by two radios. `total.packets.hour` is the same MAX + over **every** ingestor regardless of protocol; `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/events live-update stream (SSE) diff --git a/data/mesh_ingestor/announce.py b/data/mesh_ingestor/announce.py index cdfb8c7..3c3731f 100644 --- a/data/mesh_ingestor/announce.py +++ b/data/mesh_ingestor/announce.py @@ -121,7 +121,7 @@ def fetch_activity( These are the mesh-wide numbers the announcement quotes (SPEC MA6): ``active_nodes`` = ``.nodes.day`` and ``packets_per_hour`` = - ``packets_per_hour.``. + ``.packets.hour`` (the MA4 rate exposed under each scope). Parameters: instance_url: Base URL of the PotatoMesh instance. @@ -137,17 +137,19 @@ def fetch_activity( if not isinstance(data, dict): return None scope = data.get(protocol) - packets_map = data.get("packets_per_hour") - if not isinstance(scope, dict) or not isinstance(packets_map, dict): + if not isinstance(scope, dict): return None nodes = scope.get("nodes") - if not isinstance(nodes, dict): + packets = scope.get("packets") + if not isinstance(nodes, dict) or not isinstance(packets, dict): return None active_nodes = nodes.get("day") - packets = packets_map.get(protocol) - if not isinstance(active_nodes, int) or not isinstance(packets, (int, float)): + packets_per_hour = packets.get("hour") + if not isinstance(active_nodes, int) or not isinstance( + packets_per_hour, (int, float) + ): return None - return int(active_nodes), packets + return int(active_nodes), packets_per_hour def protocol_display_name(protocol: str | None) -> str: diff --git a/tests/test_announce_unit.py b/tests/test_announce_unit.py index 519353a..1b3da46 100644 --- a/tests/test_announce_unit.py +++ b/tests/test_announce_unit.py @@ -131,21 +131,23 @@ class TestDogfeedFetchActivity: """Tests for :func:`announce.fetch_activity` (MA6).""" def test_dogfeed_reads_nodes_and_packets_per_hour(self, monkeypatch): - """Returns ``(.nodes.day, packets_per_hour.)``.""" + """Returns ``(.nodes.day, .packets.hour)``.""" _install_fake_http( monkeypatch, { "https://mesh.example/api/stats": { - "meshcore": {"nodes": {"day": 12, "hour": 3}}, - "meshtastic": {"nodes": {"day": 99}}, - "packets_per_hour": {"meshcore": 50, "meshtastic": 30}, + "meshcore": { + "nodes": {"day": 12, "hour": 3}, + "packets": {"hour": 50}, + }, + "meshtastic": {"nodes": {"day": 99}, "packets": {"hour": 30}}, }, }, ) assert announce.fetch_activity("https://mesh.example", "meshcore") == (12, 50) - def test_dogfeed_returns_none_on_malformed_shape(self, monkeypatch): - """A response missing the packets_per_hour map yields ``None``.""" + def test_dogfeed_returns_none_when_packets_section_missing(self, monkeypatch): + """A scope present but lacking a ``packets`` sub-object yields ``None``.""" _install_fake_http( monkeypatch, {"https://mesh.example/api/stats": {"meshcore": {"nodes": {"day": 1}}}}, @@ -158,8 +160,7 @@ class TestDogfeedFetchActivity: monkeypatch, { "https://mesh.example/api/stats": { - "meshcore": {}, - "packets_per_hour": {"meshcore": 50}, + "meshcore": {"packets": {"hour": 50}}, }, }, ) @@ -171,8 +172,25 @@ class TestDogfeedFetchActivity: monkeypatch, { "https://mesh.example/api/stats": { - "meshcore": {"nodes": {"day": "lots"}}, - "packets_per_hour": {"meshcore": 50}, + "meshcore": { + "nodes": {"day": "lots"}, + "packets": {"hour": 50}, + }, + }, + }, + ) + assert announce.fetch_activity("https://mesh.example", "meshcore") is None + + def test_dogfeed_returns_none_on_non_numeric_packets(self, monkeypatch): + """A valid node count but a non-numeric packets/hour yields ``None``.""" + _install_fake_http( + monkeypatch, + { + "https://mesh.example/api/stats": { + "meshcore": { + "nodes": {"day": 5}, + "packets": {"hour": "lots"}, + }, }, }, ) diff --git a/web/lib/potato_mesh/application/queries/ingestor_queries.rb b/web/lib/potato_mesh/application/queries/ingestor_queries.rb index 9672569..827ae5d 100644 --- a/web/lib/potato_mesh/application/queries/ingestor_queries.rb +++ b/web/lib/potato_mesh/application/queries/ingestor_queries.rb @@ -29,7 +29,10 @@ module PotatoMesh PACKETS_PER_HOUR_DIVISOR = PACKETS_PER_HOUR_WINDOW_SECONDS / 3600.0 # Compute the mesh-wide packets/hour moving average per protocol scope - # (SPEC MA4/MA5), aggregated **MAX-per-protocol** across ingestors. + # (SPEC MA4/MA5), aggregated **MAX-per-protocol** across ingestors. The + # +GET /api/stats+ route folds each rate into its scope as the additive + # +.packets.hour+ metric; this method returns the flat per-scope + # rate map that assembly consumes. # # A single radio can only hear ≤ what is actually transmitted, so the # busiest single vantage is the best dedup-free estimate of unique air diff --git a/web/lib/potato_mesh/application/routes/api.rb b/web/lib/potato_mesh/application/routes/api.rb index 49e06ea..908b0c1 100644 --- a/web/lib/potato_mesh/application/routes/api.rb +++ b/web/lib/potato_mesh/application/routes/api.rb @@ -115,16 +115,18 @@ module PotatoMesh content_type :json priv = private_mode? ? 1 : 0 cached = PotatoMesh::App::ApiCache.fetch("api:stats:#{priv}", ttl_seconds: 15) do - # Scope → metric → window tree (SPEC S1) plus the additive - # +packets_per_hour+ moving-average map (SPEC MA5); both are drawn - # from independent read-side queries. +sampled+ stays last and + # Scope → metric → window tree (SPEC S1). The MA4 packets/hour + # moving average is folded in as an additive +packets+ metric under + # each scope (+.packets.hour+, SPEC MA5) — a single +hour+ + # window because it is a rate, not a windowed count. Both figures + # come from independent read-side queries. +sampled+ stays last and # +false+ for backward continuity with the prior payload. - query_active_node_stats - .merge( - "packets_per_hour" => query_packets_per_hour, - "sampled" => false, - ) - .to_json + stats = query_active_node_stats + rates = query_packets_per_hour + stats.each do |scope, metrics| + metrics["packets"] = { "hour" => rates[scope] || 0 } + end + stats.merge("sampled" => false).to_json end etag cached[:etag], kind: :weak diff --git a/web/spec/app_spec.rb b/web/spec/app_spec.rb index 3bce1d1..cdae5d6 100644 --- a/web/spec/app_spec.rb +++ b/web/spec/app_spec.rb @@ -6710,7 +6710,7 @@ RSpec.describe "Potato Mesh Sinatra app" do end describe "GET /api/stats" do - it "exposes the additive packets_per_hour MAX-per-protocol map" do + it "exposes the MA4 packets/hour rate as an additive .packets.hour metric" do clear_database now = reference_time.to_i allow(Time).to receive(:now).and_return(reference_time) @@ -6737,12 +6737,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" => 50) # MAX over every ingestor = 1200/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")