data,web: /api/stats packets rate as <scope>.packets.hour

This commit is contained in:
l5y
2026-07-26 12:17:52 +02:00
parent b52f3949e4
commit 433d48e84a
9 changed files with 107 additions and 72 deletions
+22 -17
View File
@@ -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 `<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 +4071,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 +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` `<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** (B1B5); 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);
+1
View File
@@ -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 `<scope>.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
+3 -3
View File
@@ -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.
@@ -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 `<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 (MA1MA4) 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 `<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 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 `<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 (MA1MA4) 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 |
+21 -18
View File
@@ -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,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**:
- **`<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 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)
+9 -7
View File
@@ -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
View File
@@ -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"},
},
},
},
)
@@ -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
# +<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
+11 -9
View File
@@ -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
+8 -7
View File
@@ -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 <scope>.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")