Skip to content

My Mesh: status evidence, SNR attribution and traffic metrics (#304 remainder) - #586

Merged
dborup merged 4 commits into
masterfrom
feat/304-mymesh-status-snr-traffic
Oct 25, 2026
Merged

dborup merged 4 commits into
masterfrom
feat/304-mymesh-status-snr-traffic

Conversation

@adminopenclaw8-sketch

Copy link
Copy Markdown
Collaborator

Relates to #304

Points 1, 2 and 6 of the issue landed in #351 and #352. This PR implements the
remaining points 3 (status evidence), 4 (SNR attribution) and
5 (traffic metrics) on the My Mesh cards and the home health panel, and
builds on the observer identity #572 (#262) just added.

Plan

  1. Check what the node-health payload actually proves before touching the UI
    (AGENTS.md rule 4): stats.lastAdvert is strictly the node's own ADVERT,
    stats.lastHeard is that advert or identity-safe relay evidence
    (whichever is newer), stats.avgSnr is already the mean over direct
    receptions since Node "Heard By" card: decide whether to show direct-RF observers (defer) #76, and activity24h is the rolling window from My Mesh: accurate 24h node activity and compact repeater observer cards #351.
  2. Put the labelling rules in one shared, pure helper
    (public/my-mesh-activity.js, the shape My Mesh: show observer identity, status and traffic separately from mesh role #262 established) so the cards and
    the health panel cannot drift apart, and so every rule is unit-testable.
  3. Add the only missing datum — the previous 24-hour baseline — to the window
    the card already fetches, never as a new request.
  4. Keep the My Mesh: accurate 24h node activity and compact repeater observer cards #351 compactness guarantee: new explanation goes into a native
    <details>, and the metric grid loses a tile.

What changed

3. Status evidence

  • Last own advert and Last attributed relay activity are separate
    rows in a new Status evidence disclosure on each card. The relay row is
    only filled where the evidence supports it: the payload exposes one combined
    lastHeard, so relay activity is reported exactly when it is newer than
    the node's own advert, and otherwise says none newer than the last own advert rather than repeating the advert's time.
  • The badge names its evidence. When the node's own advert alone supports
    the badge the dot stays filled and green. When the only evidence recent
    enough is traffic attributed to the node by a short wire-hash prefix, the
    badge becomes Active · relay-attributed, the dot is hollow and the card's
    accent drops to the provisional colour — an ambiguous match never produces
    a confident green
    . The disclosure explains that a prefix unique among
    known relays can still belong to an unknown one, and that a recent advert
    is not proof of continuous uptime.
  • No new thresholds. getHealthStatus / getHealthLastHeard (roles.js,
    fix(health): apply role thresholds without weakening activity evidence #539) remain the single source of truth and are injectable; the helper
    defines none and the test asserts it.

4. SNR

  • The Excellent / Good / Marginal / Poor verdict and its colour are removed
    from both the card and the health panel. Until Node "Heard By" card: decide whether to show direct-RF observers (defer) #76's attribution decision
    is made, the card explains the metric instead of grading the repeater.
  • The label states the sample scope the mean covers (Direct SNR · n=42), and
    the value is only shown when at least one direct reception proves it belongs
    to this node — a mean with no direct attribution behind it would describe
    whichever node the observer actually heard.
  • stats.avgSnr itself is untouched: no shared health API metric is
    redefined.

5. Traffic

  • Packets today (a UTC calendar-day counter) becomes
    Transmissions · 24 h, the rolling window's own count, with the
    population and window in the tooltip and as a Counting window row in the
    disclosure (a title is not reachable by touch).
  • Incomplete coverage renders as a lower bound (≥ 35) and an unknown window
    as an em dash — never as a measured zero.
  • activity24h gains previous (windowStart, windowEnd, count,
    complete): the equally long window right before the rolling one. The
    comparison is shown only when both windows are proven complete, a
    missing history is reported as no comparable 24 h history, and a zero
    baseline yields an absolute delta, never a division.
  • Avg hops leaves the headline grid for the disclosure as
    Avg observed path length, explained as the mean hop count of the
    observed paths of the node's transmissions — not a distance and not health.
    Nothing labels node-associated traffic as forwarded traffic.

Cost and compactness

  • No new request. Everything comes from the node-health call the card
    already makes (/api/nodes/{key}/health?include=advertIntervals); the five
    api( call sites in home.js are unchanged and pinned by a test.
  • Server side, previous is filled by the same single pass over the
    node's index that already fills the hourly buckets. The only added cost is
    the RFC3339 parse for rows 24–48 h old that the string pre-filter used to
    reject — the same order as the per-transmission work GetNodeHealth already
    does for every row (it calls heard.add on each). No new scan, no new lock,
    no new allocation per row.
  • The card stays compact: the metric grid is now three tiles on one row at
    every width
    (it was four over two rows below 600 px) and the explanation
    is a closed <details>. Measured on the 51-observer repeater card:
    1280 px 337.8 → 343.8, 390/375 px 349.1 → 329.1 — still inside the < 350
    bound My Mesh: accurate 24h node activity and compact repeater observer cards #351 pinned, and lower than before on mobile.

Testing

Red before, green after for every point, each with at least one mutant; see
the report comment for the full table. Locally: go test ./... in
cmd/server and cmd/ingestor, sh test-all.sh (271 files),
node test-frontend-helpers.js, and the affected Playwright suites against a
local Go server on the committed e2e-fixture.db prepared as in CI.

dborup and others added 2 commits October 25, 2026 08:14
Relates to #304 (points 3, 4 and 5; 1, 2 and 6 landed in #351/#352).

3. Status evidence. A new shared helper (public/my-mesh-activity.js) reads
   the node-health payload and reports which evidence carries the badge:
   stats.lastAdvert (the node's own advert) and the relay activity behind
   stats.lastHeard are shown as separate rows, the relay row only where the
   evidence supports it. A badge that rests only on an attributed wire-hash
   prefix is rendered as attributed — hollow dot, provisional accent, "·
   relay-attributed" — never as a confident green. Thresholds stay in
   roles.js (#539); the helper defines none.

4. SNR. The Excellent/Good/Marginal/Poor verdict and its colour are gone.
   The mean is labelled with the sample scope it covers (n=…) and is only
   shown when a direct reception proves it belongs to this node (#76). The
   shared API field keeps its meaning.

5. Traffic. "Packets today" (a UTC calendar-day counter) is replaced by the
   rolling 24-hour count with its population and window stated, a lower
   bound (≥) when the history does not cover the window, and an em dash when
   the window is unknown. activity24h gains `previous`, the equally long
   window before it, so a comparison is like-for-like; it is shown only when
   both windows are complete and never as a percentage of a zero baseline.
   Avg hops moves into the evidence disclosure as "Avg observed path
   length", with its route/sample meaning.

The card keeps its #351 compactness: the metrics are one row of three at
every width and the explanation sits in a native <details> that is reachable
by keyboard and touch. No new API request — the existing per-card health
call carries everything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The frontend no-undef lint (Kpa-clawbot#1342) reads .eslintrc.json's globals, so a new
window-scoped helper has to be declared there like MyMeshObservers (#262).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@adminopenclaw8-sketch

adminopenclaw8-sketch commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator Author

Rapport — CS-Macmini PR#586 #304 — head a6b8c45

Status: All three remaining #304 points implemented; every point has a test that is red before and green after plus at least one killed mutant; all 17 CI jobs pass.

Requirement coverage

# Requirement Implementation Test (red before → green after) Mutant (killed)
3a Last own advert and Last attributed relay activity shown separately, only where the evidence supports it statusEvidence() in public/my-mesh-activity.js; two rows in the card's Status evidence disclosure. Relay activity is reported exactly when lastHeard is newer than lastAdvert, otherwise none newer than the last own advert [T] test-issue-304-my-mesh-metrics.js — "the two evidence rows are labelled separately and never merged", "an advert that alone supports the badge stays confident even with newer relay traffic"; [T] test-issue-304-my-mesh-e2e.js rows 0/1/2 M3 relayAt = heardAt (relay row echoes the advert time) → killed
3b Explain what drives the status badge ev.explanation per source (advert / relay / none), rendered in the disclosure; badge text carries the source [T] metrics test — explanation asserted per source, incl. "not proof of continuous uptime"; [T] E2E .mnc-evidence-note per card M1 confident: true (badge never names attributed evidence) → killed
3c Ambiguous short-prefix relay matches must not give a confident green status Attributed status → hollow dot (ph-circle), status-warn class, Active · relay-attributed, and .my-node-card.healthy.evidence-relay drops the green accent [T] metrics test — "relay-only evidence never produces a confident green status", "a node with no own advert at all is relay-attributed, never confident"; [T] E2E — dot class, glyph href, card class and computed border-left-color vs the resolved --status-green M2 cls: statusCls (green dot kept) → killed; [T] E2E mutant E2 (remove the accent rule) → killed
3d Reuse the existing liveness rules, no new thresholds getHealthStatus / getHealthLastHeard (roles.js, #539) injected, defaulted from the window globals; the helper defines none [T] metrics test — "the role thresholds come from roles.js, with no home-page copy" (infra 90 min healthy vs client degraded, plus a source scan for a threshold definition and an injected classifier); [T] test-health-status.js executes the real production decision blocks M (helper declaring its own HEALTH_THRESHOLDS) → asserted by source scan
4a Do not present the representative observation's SNR as the repeater's own signal quality Verdict words and verdict colour removed from the card and the health panel; tooltip states the value is measured at the observer and is not a quality or health grade [T] metrics test — "the direct SNR mean carries no quality verdict and no verdict colour"; [T] static pins on home.js (snrLabel/snrColor gone, no verdict words); [T] E2E — metric text has no verdict, .mnc-metrics .mnc-val[style] count 0 [T] E2E mutant E4 (re-add an inline verdict colour) → killed
4b Explain the metric / sample scope; attribution must support the claim Label Direct SNR · n=<receptions>; value only when at least one direct observer proves it (#76) [T] metrics test — "the SNR label states the sample scope", "an SNR with no direct attribution behind it is not presented at all", "a non-numeric packet count never fabricates a sample size"; [T] E2E — card 0 n=30, card 1 no n=, card 2 em dash M4 available = value !== null (SNR shown with no direct attribution) → killed
4c Do not redefine shared health API metrics stats.avgSnr untouched server-side; only the client label changed [A] cmd/server diff touches no SNR code; GetNodeHealth SNR path unchanged —
5a Clearly labelled rolling 24 h count with population and window, replacing "Packets today" traffic24h() sums the rolling window's own buckets; label Transmissions · 24 h; population + window in the tooltip and as a Counting window row (a title is not touch-reachable) [T] metrics test — "the traffic metric is a labelled rolling 24h count, not 'packets today'", "the window text states both bounds and the coverage caveat"; [T] E2E labels/values; [T] static pin: no Packets today left in home.js —
5b Missing/incomplete coverage ≠ zero Incomplete window renders ≥ N; an unknown/malformed window renders an em dash [T] metrics test — "an incomplete window is a lower bound, never a measured total", "a missing or malformed window is unavailable, not zero", "a genuine quiet window with proven coverage is an exact zero"; [T] E2E cards 1 and 2 M5 exact = true (lower bound shown as a total) → killed
5c Previous-24 h comparison only with comparable history; no division by 0; missing history ≠ 0 activity24h.previous added server-side (same single pass); compare24h() requires both windows complete and a genuine integer baseline; a zero baseline yields an absolute delta, never a percentage [T] metrics test — "the comparison needs two complete, like-for-like windows", "an unknown previous window is never treated as zero traffic", "a zero baseline produces no percentage and never divides by zero"; [T] cmd/server/node_activity_304_test.go × 3; [T] E2E — chip on card 0, absent on 1 and 2 M6 baseline = prev.count ?? 0 → killed; M7 (skip the zero-baseline branch) → killed; G1 Previous.Complete = a.Complete → killed; G4 previous window 12 h instead of 24 h → killed
5d Move/explain Avg hops Out of the headline grid, into the disclosure as Avg observed path length with its route/sample meaning and an explicit denial of the distance reading [T] metrics test — "avg hops is relabelled as an observed path length, with its sample scope"; [T] E2E — avg hops absent from .mnc-metrics, row present in the disclosure (10 hops / —) M8 label reverted to Avg hops → killed
5e Do not label node-associated traffic as forwarded traffic Tooltip states the population (own / addressed to it / observed path names it) and that it is not a forwarding count [T] metrics test asserts the tooltip never sells the count as forwarded traffic —
— API shapes checked before UI (AGENTS.md rule 4) [A] NodeHealthStats (cmd/server/node_activity.go), heardByAccumulator.avgSnr (heard_by.go), NodeActivity24h (node_activity_24h.go), openapi.go read before writing client code — —
— No per-card API call Everything comes from the node-health request the card already makes [T] metrics test — "the card adds no API request of its own" (the five api( call sites are pinned) —
— Unit tests for labels and status logic, E2E incl. mobile 390 px, CSS variables New test-issue-304-my-mesh-metrics.js (26 cases); test-issue-304-my-mesh-e2e.js runs at 1280 px and 375 px with touch [T] CSS block scanned for hardcoded colors; [K] node scripts/check-css-vars.js clean [T] E2E mutant E3 (disclosure open by default) → killed; E1 (summary click navigates) → killed

Server-side change

activity24h gains previous {windowStart, windowEnd, count, complete} — the equally long window before the rolling one, filled by the same single pass over the node's index. The only added cost is the RFC3339 parse for rows 24–48 h old that the string pre-filter used to reject, which is the same order as the per-transmission work GetNodeHealth already performs on every row. [A] No new scan, no new lock, no new allocation per row; cmd/server stays read-only and no map[string]interface{} was added outside tests.

Compactness (#351's bound preserved)

[K] Measured on the 51-observer repeater card: 1280 px 337.8 → 343.8 px, 390/375 px 349.1 → 329.1 px. Both stay inside the < 350 bound #351 pinned, and mobile is 20 px shorter than before: the metric grid is now three tiles on one row at every width (it was four over two rows below 600 px) and the explanation lives in a closed <details>.

CI (run 40172746937, head a6b8c45)

Job Result
✅ Go Build & Test pass
✅ Go Server Tests (1/3) pass
✅ Go Server Tests (2/3) pass
✅ Go Server Tests (3/3) pass
✅ Go Ingestor Tests pass
✅ Go Libraries & CLI pass
✅ JavaScript Unit Tests & Lint pass
✅ Script & Container Unit Checks pass
🎭 Playwright E2E Tests (1/3) pass
🎭 Playwright E2E Tests (2/3) pass
🎭 Playwright E2E Tests (3/3) pass
📊 Verify E2E Shards & Coverage pass
📋 Verify Go Test Assignments pass
🧱 Validate Docker Image pass
🏗️ Build & Publish Docker Image pass
📦 Release Artifacts / 🚀 Deploy Staging / 📝 Publish Badges skipped (fork guards)

The first push failed 🧹 Frontend lint (eslint no-undef) only: a new window-scoped helper needs a .eslintrc.json globals entry, exactly as MyMeshObservers did in #262. Fixed in a6b8c456; no other job failed and the known-flaky #271 suite did not fire.

Local before pushing: go test ./... in cmd/server and cmd/ingestor, sh test-all.sh (271 files), node test-frontend-helpers.js (725 cases), and the affected Playwright suites (#304, #262, #2027, #76, home coverage, health status, the 157-test core suite) against a local Go server on the committed e2e-fixture.db, prepared exactly as CI does.

Checks

[K] scripts/check-xss-sinks.sh --diff origin/master → exit 0, no finding. [K] node scripts/check-css-vars.js → 0 undefined. [K] Fork guards unchanged: 9 in deploy.yml, 1 in release-fast-path.yml. [A] cmd/server remains read-only; no new map[string]interface{} outside tests; no hardcoded colors (the new CSS block is scanned by the unit test). [A] No staging or production access, no server API key used, no upstream write.

Adjacent tests updated (behaviour change, not weakened assertions)

  • test-health-status.js — the two home decision blocks it executes now go through the shared helper, so the extraction markers moved and the helper is loaded into the same VM context. The blocks executed are still the real production code.
  • test-health-status-e2e.js — the health banner's aria-label is now the user-facing badge text instead of the raw status token. The expectation maps status → label and adds an assertion: the confirmed-relay and legacy-directory fixtures (neither has an own advert) must render the attributed glyph, the others the confirmed one.
  • test-issue-262-mymesh-observer-e2e.js — its fixtures gained lastAdvert and a complete rolling window, so its "node activity stays separate from observer reporting" assertion now reads the rolling-24 h metric instead of the removed Packets today.
  • test-issue-2027-my-mesh-node-page-e2e.js, test-issue-262-mymesh-observer-e2e.js — load the new helper alongside my-mesh-observers.js, as index.html does.

Remainder

  • Point 4 stays deliberately descriptive: the card explains the direct-SNR metric rather than grading the repeater, pending the attribution decision in Node "Heard By" card: decide whether to show direct-RF observers (defer) #76. Nothing here pre-empts that decision.
  • The direct-SNR sample size counts receptions across all direct observers; a per-observer breakdown stays on the Heard By tables.
  • getHealthLastHeard's legacy directory fallback (a response without lastHeard) now reads as relay-attributed, since no own advert backs it. That is honest for the fallback, but it means an old cached payload shows the provisional badge; the live API always sends lastHeard.
  • Not filed: a follow-up could expose the relay evidence timestamp separately from lastHeard, which would let the card show relay activity that is older than the last own advert. That needs a second pass over the relay candidates and was left out on cost grounds.

dborup and others added 2 commits October 25, 2026 08:29
…tests

- Panel banner adds "relay-attributed" only for relay-sourced evidence,
  never for "no confirmed activity".
- Pin the panel's no-confident-green rule (banner class and icon).
- Direct SNR label counts "receptions" (all direct receptions); the tip says
  the mean covers only those that carry an SNR.
- Traffic tip: "resolved path", no "confidently"/"below".
- Pin a payload without a boolean complete flag as a lower bound.
- Benchmark with 30K rows spread over 7 days (exercises the 24-48 h band).

Relates to #304

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@dborup
dborup marked this pull request as ready for review October 25, 2026 09:12
@dborup
dborup merged commit 88f3be2 into master Oct 25, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants