mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-09 10:22:52 +02:00
data,web: /api/stats packets rate as <scope>.packets.hour
This commit is contained in:
+22
-17
@@ -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** (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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (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 `<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 (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 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user