diff --git a/.eslintrc.json b/.eslintrc.json index 79b6fb412..d938e5d49 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -35,6 +35,7 @@ "Logo": "readonly", "MAX_HOP_DIST": "readonly", "MeshAudio": "readonly", + "MyMeshActivity": "readonly", "MyMeshObservers": "readonly", "ObserverAirtime": "readonly", "MeshConfigReady": "readonly", diff --git a/cmd/server/node_activity_24h.go b/cmd/server/node_activity_24h.go index 9bdfe9d95..7765fd390 100644 --- a/cmd/server/node_activity_24h.go +++ b/cmd/server/node_activity_24h.go @@ -9,19 +9,37 @@ type NodeActivityHour struct { Count int `json:"count"` } +// NodeActivityPrevious24h is the 24 hours immediately before the rolling +// window of NodeActivity24h. It exists only as a like-for-like baseline for +// that window, so it carries no hourly detail. Complete is false unless the +// in-memory index is known to cover the whole EARLIER window: an unknown +// history must never be read as a quiet period, and a Count without Complete +// is a lower bound, not a baseline (#304). +type NodeActivityPrevious24h struct { + WindowStart string `json:"windowStart"` + WindowEnd string `json:"windowEnd"` + Count int `json:"count"` + Complete bool `json:"complete"` +} + // NodeActivity24h describes the last 24 rolling hours. Complete is false // unless the in-memory index is known to cover the whole window. type NodeActivity24h struct { - WindowStart string `json:"windowStart"` - WindowEnd string `json:"windowEnd"` - Buckets [24]NodeActivityHour `json:"buckets"` - CoverageStart *string `json:"coverageStart"` - Complete bool `json:"complete"` + WindowStart string `json:"windowStart"` + WindowEnd string `json:"windowEnd"` + Buckets [24]NodeActivityHour `json:"buckets"` + CoverageStart *string `json:"coverageStart"` + Complete bool `json:"complete"` + Previous NodeActivityPrevious24h `json:"previous"` start time.Time end time.Time - // startSecond is start truncated to whole seconds in the fixed-width - // "2006-01-02T15:04:05" form; add() uses it as a string pre-filter. - startSecond string + // prevStart is the start of Previous's window (start - 24h). + prevStart time.Time + // startSecond and prevStartSecond are start / prevStart truncated to + // whole seconds in the fixed-width "2006-01-02T15:04:05" form; add() + // uses prevStartSecond as its string pre-filter. + startSecond string + prevStartSecond string } // nodeActivitySecondLayout is fixed width: no fraction, no zone suffix. @@ -30,12 +48,19 @@ const nodeActivitySecondLayout = "2006-01-02T15:04:05" func newNodeActivity24h(now, coverageStart time.Time) NodeActivity24h { end := now.UTC() start := end.Add(-24 * time.Hour) + prevStart := start.Add(-24 * time.Hour) a := NodeActivity24h{ WindowStart: start.Format(time.RFC3339Nano), WindowEnd: end.Format(time.RFC3339Nano), - start: start, - end: end, - startSecond: start.Format(nodeActivitySecondLayout), + Previous: NodeActivityPrevious24h{ + WindowStart: prevStart.Format(time.RFC3339Nano), + WindowEnd: start.Format(time.RFC3339Nano), + }, + start: start, + end: end, + prevStart: prevStart, + startSecond: start.Format(nodeActivitySecondLayout), + prevStartSecond: prevStart.Format(nodeActivitySecondLayout), } for i := range a.Buckets { bucketStart := start.Add(time.Duration(i) * time.Hour) @@ -49,6 +74,7 @@ func newNodeActivity24h(now, coverageStart time.Time) NodeActivity24h { iso := coverageStart.Format(time.RFC3339Nano) a.CoverageStart = &iso a.Complete = !coverageStart.After(start) + a.Previous.Complete = !coverageStart.After(prevStart) } return a } @@ -58,21 +84,29 @@ func (a *NodeActivity24h) add(tx *StoreTx) { return } // #351 F6/R3: cheap pre-filter before the RFC3339Nano parse, for the - // common case of a node whose in-memory history spans well past 24h. - // It compares against startSecond, the window start truncated to whole - // seconds, NOT WindowStart: RFC3339Nano trims trailing zeros, so the - // fraction widths of WindowStart and a stamp differ ("…:00Z" vs - // "…:00.100Z") and a full-string compare can sort an in-window stamp - // below the bound. The second prefix is fixed width, so a UTC stamp at - // or after the window start always sorts >= startSecond whatever its - // fraction. A stamp in the start's own second survives and the exact - // bounds check below decides it. The upper bound is deliberately NOT - // string-filtered, for the same fraction-width reason. - if tx.FirstSeen < a.startSecond { + // common case of a node whose in-memory history spans well past the + // window. It compares against prevStartSecond, the EARLIEST bound + // (prevStart) truncated to whole seconds, and never against WindowStart: + // RFC3339Nano trims trailing zeros, so the fraction widths of a bound and + // a stamp differ ("…:00Z" vs "…:00.100Z") and a full-string compare can + // sort an in-window stamp below the bound. The second prefix is fixed + // width, so a UTC stamp at or after the bound always sorts >= + // prevStartSecond whatever its fraction. A stamp inside the bound's own + // second survives and the exact bounds checks below decide it. The upper + // bound is deliberately NOT string-filtered, for the same reason. + // #304: the filter admits the previous window too, because one pass fills + // both. That costs the parse for transmissions 24-48h old, the same order + // as the per-transmission work GetNodeHealth already does for every row + // of the index. + if tx.FirstSeen < a.prevStartSecond { return } when, err := time.Parse(time.RFC3339Nano, tx.FirstSeen) - if err != nil || when.Before(a.start) || !when.Before(a.end) { + if err != nil || when.Before(a.prevStart) || !when.Before(a.end) { + return + } + if when.Before(a.start) { + a.Previous.Count++ return } idx := int(when.Sub(a.start) / time.Hour) diff --git a/cmd/server/node_activity_304_test.go b/cmd/server/node_activity_304_test.go index 47205a2b1..a966ae63f 100644 --- a/cmd/server/node_activity_304_test.go +++ b/cmd/server/node_activity_304_test.go @@ -347,3 +347,179 @@ func BenchmarkNodeActivity24h30K(b *testing.B) { } } } + +// BenchmarkNodeActivity24h30KSpread7d is the review-F6 companion: rows spread +// over seven days, so most of them fall outside both windows and the +// 24-48 h band the previous-window comparison admits is actually exercised. +func BenchmarkNodeActivity24h30KSpread7d(b *testing.B) { + now := time.Date(2026, 10, 7, 13, 17, 0, 0, time.UTC) + const week = 7 * 24 * 60 + packets := make([]*StoreTx, 30000) + for i := range packets { + packets[i] = &StoreTx{ID: i + 1, FirstSeen: now.Add(-time.Duration(i*week/len(packets)) * time.Minute).Format(time.RFC3339)} + } + b.ReportAllocs() + b.ResetTimer() + for n := 0; n < b.N; n++ { + a := newNodeActivity24h(now, now.Add(-49*time.Hour)) + for _, tx := range packets { + a.add(tx) + } + if a.Buckets[23].Count == 0 || a.Previous.Count == 0 { + b.Fatal("both windows must see rows") + } + } +} + +// TestNodeActivityPrevious24hWindowAndCoverage covers the #304 traffic +// requirement: a previous-period comparison needs a like-for-like window and +// must never read a missing history as a quiet one. The previous window is +// the 24 hours immediately before the rolling window, and it is Complete only +// when the proven coverage reaches its own start. +func TestNodeActivityPrevious24hWindowAndCoverage(t *testing.T) { + now := time.Date(2026, 10, 7, 13, 17, 0, 0, time.UTC) + start := now.Add(-24 * time.Hour) + prevStart := start.Add(-24 * time.Hour) + + full := newNodeActivity24h(now, prevStart) + if full.Previous.WindowStart != prevStart.Format(time.RFC3339Nano) || full.Previous.WindowEnd != full.WindowStart { + t.Fatalf("previous window must abut the rolling window: %+v", full.Previous) + } + if !full.Previous.Complete || !full.Complete { + t.Fatalf("coverage back to the previous window start proves both windows: %+v", full) + } + + // One nanosecond short of the previous window start: the current window + // stays complete, the previous one cannot be compared. + short := newNodeActivity24h(now, prevStart.Add(time.Nanosecond)) + if !short.Complete || short.Previous.Complete { + t.Fatalf("coverage after the previous start must not claim it: %+v", short) + } + + // 24h coverage only: the previous window is unknown, not zero. + cur := newNodeActivity24h(now, start) + if !cur.Complete || cur.Previous.Complete || cur.Previous.Count != 0 { + t.Fatalf("24h coverage must leave the previous window incomparable: %+v", cur) + } + + // No coverage evidence at all: neither window is complete. + if unknown := newNodeActivity24h(now, time.Time{}); unknown.Complete || unknown.Previous.Complete { + t.Fatalf("unknown coverage must not complete either window: %+v", unknown) + } +} + +// TestNodeActivityPrevious24hCountsOnlyItsOwnWindow pins the counting +// semantics and the boundaries of the two windows, including the cheap +// string pre-filter: a transmission in the previous window must reach +// Previous.Count and must never land in an hourly bucket. +func TestNodeActivityPrevious24hCountsOnlyItsOwnWindow(t *testing.T) { + now := time.Date(2026, 10, 7, 13, 17, 0, 500_000_000, time.UTC) // fractional now + start := now.Add(-24 * time.Hour) + prevStart := start.Add(-24 * time.Hour) + a := newNodeActivity24h(now, prevStart) + + for _, ts := range []time.Time{ + prevStart, // inclusive previous start + prevStart.Add(time.Hour), // inside previous + start.Add(-time.Nanosecond), // last instant of previous + } { + a.add(&StoreTx{FirstSeen: ts.Format(time.RFC3339Nano)}) + } + a.add(&StoreTx{FirstSeen: prevStart.Add(-time.Nanosecond).Format(time.RFC3339Nano)}) // before both + a.add(&StoreTx{FirstSeen: start.Format(time.RFC3339Nano)}) // first bucket + a.add(&StoreTx{FirstSeen: now.Add(-time.Minute).Format(time.RFC3339Nano)}) // last bucket + a.add(&StoreTx{FirstSeen: now.Format(time.RFC3339Nano)}) // exclusive end + a.add(&StoreTx{FirstSeen: "not a timestamp"}) + + if a.Previous.Count != 3 { + t.Fatalf("previous count=%d, want 3", a.Previous.Count) + } + if a.Buckets[0].Count != 1 || a.Buckets[23].Count != 1 { + t.Fatalf("buckets[0]=%d [23]=%d, want 1 each", a.Buckets[0].Count, a.Buckets[23].Count) + } + total := 0 + for _, b := range a.Buckets { + total += b.Count + } + if total != 2 { + t.Fatalf("rolling window total=%d, want 2 (previous-window rows must not be bucketed)", total) + } + + // A stamp inside the previous start's own second survives the string + // pre-filter, which compares against a bound truncated to whole seconds + // (the same fraction-width trap as #351 F6), and the exact bounds check + // then places it. 100ms after a .5s window start is in the window; + // 100ms before it is in neither. + second := newNodeActivity24h(now, prevStart) + second.add(&StoreTx{FirstSeen: prevStart.Add(100 * time.Millisecond).Format(time.RFC3339Nano)}) + second.add(&StoreTx{FirstSeen: prevStart.Add(-100 * time.Millisecond).Format(time.RFC3339Nano)}) + if second.Previous.Count != 1 { + t.Fatalf("previous boundary second mishandled: %+v", second.Previous) + } +} + +// TestNodeHealthPrevious24hThroughJSON proves the comparison baseline reaches +// the client through the real endpoint payload, with the node's own +// transmissions split across the two windows. +func TestNodeHealthPrevious24hThroughJSON(t *testing.T) { + srv, _ := setupTestServer(t) + const pubkey = "aabbccdd11223355" + now := time.Date(2026, 10, 7, 13, 17, 0, 0, time.UTC) + start := now.Add(-24 * time.Hour) + prevStart := start.Add(-24 * time.Hour) + packets := []*StoreTx{ + {ID: 1, FirstSeen: prevStart.Format(time.RFC3339), ObservationCount: 2}, + {ID: 2, FirstSeen: prevStart.Add(3 * time.Hour).Format(time.RFC3339), ObservationCount: 1}, + {ID: 3, FirstSeen: start.Add(time.Hour).Format(time.RFC3339), ObservationCount: 1}, + {ID: 4, FirstSeen: prevStart.Add(-time.Hour).Format(time.RFC3339), ObservationCount: 1}, + } + srv.store.mu.Lock() + srv.store.byNode[pubkey] = packets + srv.store.loaded = true + srv.store.retentionHours = 168 + srv.store.oldestLoaded = prevStart.Add(-2 * time.Hour).Format(time.RFC3339) + srv.store.retainedHistoryFrom = prevStart.Add(-2 * time.Hour) + srv.store.packets = []*StoreTx{{ID: 100, FirstSeen: srv.store.oldestLoaded}} + srv.store.backgroundLoadDone.Store(true) + srv.store.mu.Unlock() + + health, err := srv.store.GetNodeHealth(pubkey, now) + if err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(health) + if err != nil { + t.Fatal(err) + } + var body struct { + Activity24h struct { + Complete bool `json:"complete"` + Buckets []NodeActivityHour `json:"buckets"` + Previous struct { + WindowStart string `json:"windowStart"` + WindowEnd string `json:"windowEnd"` + Count int `json:"count"` + Complete bool `json:"complete"` + } `json:"previous"` + } `json:"activity24h"` + } + if err := json.Unmarshal(encoded, &body); err != nil { + t.Fatal(err) + } + if !body.Activity24h.Complete || !body.Activity24h.Previous.Complete { + t.Fatalf("both windows must be complete: %s", encoded) + } + if body.Activity24h.Previous.Count != 2 { + t.Fatalf("previous count=%d, want 2", body.Activity24h.Previous.Count) + } + if body.Activity24h.Previous.WindowEnd != body.Activity24h.Buckets[0].Start { + t.Fatalf("previous window must end where the rolling window starts: %s", encoded) + } + rolling := 0 + for _, b := range body.Activity24h.Buckets { + rolling += b.Count + } + if rolling != 1 { + t.Fatalf("rolling total=%d, want 1", rolling) + } +} diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index 83b25832b..0b73c2b2d 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -357,7 +357,7 @@ func heardBySchemas() map[string]*openAPISchema { "directObservers": direct, "relayObservers": relay, "recentPackets": {Type: "array", Items: &openAPISchema{Type: "object"}, Description: "Newest 20 transmissions, observations stripped."}, - "activity24h": {Type: "object", Description: "24 rolling one-hour buckets of the node's transmissions."}, + "activity24h": {Type: "object", Description: "The rolling 24-hour window of the node's transmissions: 24 one-hour buckets, windowStart/windowEnd, coverageStart and complete, plus previous {windowStart, windowEnd, count, complete} — the equally long window before it, for a like-for-like comparison only (#304). A window is complete only when the in-memory history is proven to cover all of it; an incomplete window is a lower bound, never a quiet period."}, }, }, "BulkHealthEntry": { diff --git a/public/home.css b/public/home.css index 63de8ca6d..36f1ebae4 100644 --- a/public/home.css +++ b/public/home.css @@ -509,7 +509,10 @@ border-radius: 6px; padding: 10px 12px; background: var(--surface-2); } -.mnc-metrics { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-bottom: 12px; } +/* #304: three metrics (rolling-24h traffic, direct observers, direct SNR); + the hop average moved into the evidence disclosure. Three columns at every + width keeps them on ONE row, which pays for the disclosure summary line. */ +.mnc-metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 12px; } .mnc-metric { text-align: center; } .mnc-val { font: 700 1rem var(--font); color: var(--text); } .mnc-lbl { font-size: .65rem; color: var(--text-muted); margin-top: 2px; } @@ -536,6 +539,22 @@ .mnc-advert-provisional { color: var(--status-yellow); font-size: .7rem; } .mnc-advert-note { color: var(--text-muted); font-size: .68rem; } +/* #304 — status evidence and metric scope. + The disclosure is closed by default so the card stays compact, and it is a + native
, which is reachable by keyboard and by touch (a title + tooltip is neither). A status badge that rests only on an attributed relay + hash prefix must not read as a confident green, so the accent drops to the + provisional colour; every colour here is a theme variable. */ +.mnc-evidence { font-size: .72rem; line-height: 1.35; color: var(--text-muted); margin: -12px 0 6px; padding-left: 28px; } +.mnc-evidence > summary { cursor: pointer; color: var(--accent); width: fit-content; min-height: 24px; display: flex; align-items: center; padding-right: 8px; } +.mnc-evidence > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.mnc-evidence-row { display: flex; flex-wrap: wrap; gap: .35em; line-height: 1.4; } +.mnc-evidence-row strong { color: var(--text); font-weight: 600; } +.mnc-evidence-note { margin: 2px 0 4px; line-height: 1.4; } +.mnc-metric-sub { font-size: .62rem; font-weight: 400; color: var(--text-muted); } +.health-metric .mnc-metric-sub { display: block; margin-top: 2px; } +.my-node-card.healthy.evidence-relay { border-left-color: var(--status-yellow); } + .mnc-spark { margin-bottom: 8px; } .home-spark-unavailable { font-size: .7rem; color: var(--text-muted); } .spark-label { font-size: .65rem; color: var(--text-muted); margin-bottom: 4px; } @@ -567,7 +586,6 @@ /* Responsive */ @media (max-width: 640px) { .my-nodes-grid { grid-template-columns: 1fr; padding: 0 12px 16px; } - .mnc-metrics { grid-template-columns: repeat(2, 1fr); } .mnc-remove { opacity: 1; } /* #262 — one column on a phone: the nowrap traffic label would otherwise squeeze the status rows below their own text width. */ diff --git a/public/home.js b/public/home.js index c701a016f..9df62b311 100644 --- a/public/home.js +++ b/public/home.js @@ -341,17 +341,24 @@ const obs = heard.direct; observerLists.set(mn.pubkey, heard); - const heardAt = getHealthLastHeard(node, stats); - const status = getHealthStatus(node.role, heardAt) || 'silent'; - const statusCls = status === 'healthy' ? 'status-ok' : status === 'degraded' ? 'status-warn' : 'status-err'; - const statusDot = ''; - const statusText = status === 'healthy' ? 'Active' : status === 'degraded' ? 'Degraded' : 'Silent'; + // #304 point 3: ONE evidence model drives the badge, its wording and + // the disclosure below. It reuses the shared liveness rules (roles.js, + // #539) and reports a badge that rests only on an attributed relay + // hash prefix as attributed, never as a confident green. + const ev = MyMeshActivity.statusEvidence({ node, stats }); + const status = ev.status; + const heardAt = ev.heardAt; + const statusDot = ''; const name = node.name || mn.name || truncate(mn.pubkey, 12); - // SNR quality label - const snrVal = stats.avgSnr; - const snrLabel = snrVal != null ? (snrVal > 10 ? 'Excellent' : snrVal > 0 ? 'Good' : snrVal > -5 ? 'Marginal' : 'Poor') : null; - const snrColor = snrVal != null ? (snrVal > 10 ? 'var(--status-green)' : snrVal > 0 ? 'var(--accent)' : snrVal > -5 ? 'var(--status-yellow)' : 'var(--status-red)') : '#6b7280'; + // #304 points 4 and 5: every headline number states what it covers. + // No SNR quality verdict and no verdict colour: the mean describes + // the observer links that proved a direct reception, not this node's + // signal quality (#76). + const snr = MyMeshActivity.snr(stats, obs); + const traffic = MyMeshActivity.traffic24h(h.activity24h); + const trend = MyMeshActivity.compare24h(h.activity24h); const sparkHtml = buildSparkline(h.activity24h); const isRepeater = String(node.role || '').toLowerCase() === 'repeater'; @@ -373,7 +380,7 @@ : `none proven`; const relayCount = heard.relay.length ? ` · ${heard.relay.length} via relay` : ''; - return `
+ return `
${statusDot}
${escapeHtml(name)}
@@ -381,25 +388,22 @@
${node.role || '?'}
-
${statusText}${heardAt ? ' · ' + timeAgo(heardAt) : ''}
+
${escapeHtml(ev.text)}${heardAt ? ' · ' + timeAgo(heardAt) : ''}
+ ${renderMyMeshEvidence(ev, h.activity24h, stats)} ${observerPanelHtml(observer)} ${observer ? `
${escapeHtml(MyMeshObservers.NODE_SECTION_LABEL)}
` : ''}
-
-
${stats.packetsToday ?? 0}
-
Packets today
+
+
${escapeHtml(traffic.value)}${trend.short ? ` ${escapeHtml(trend.short)}` : ''}
+
${escapeHtml(traffic.label)}
${obs.length}
Direct observers
-
-
${snrVal != null ? Number(snrVal).toFixed(1) + ' dB' : '—'}
-
Direct SNR${snrLabel ? ' · ' + snrLabel : ''}
-
-
-
${stats.avgHops != null ? stats.avgHops.toFixed(1) : '—'}
-
Avg hops
+
+
${escapeHtml(snr.value)}
+
${escapeHtml(snr.label)}
${heardTotal ? `
Heard directly by: ${directNames}${relayCount}
` : ''} @@ -482,7 +486,10 @@ // Card click → health grid.querySelectorAll('.my-node-card').forEach(card => { const handler = (e) => { - if (e.target.closest('.mnc-remove') || e.target.closest('.mnc-btn')) return; + // #304: the evidence disclosure is part of the card, so a click on + // its summary (or on the text it reveals) must toggle it instead of + // navigating to the health panel. + if (e.target.closest('.mnc-remove') || e.target.closest('.mnc-btn') || e.target.closest('.mnc-evidence')) return; loadHealth(card.dataset.key); }; card.addEventListener('click', handler); @@ -492,7 +499,7 @@ // below. Bail out before it runs, the same target check `handler` // already uses for the click case, so the button still fires its // own click via the browser's native keyboard activation. - if (e.target.closest('.mnc-remove') || e.target.closest('.mnc-btn')) return; + if (e.target.closest('.mnc-remove') || e.target.closest('.mnc-btn') || e.target.closest('.mnc-evidence')) return; if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handler(e); } }); }); @@ -576,6 +583,28 @@ dialog.showModal(); } + // #304 point 3/5: the evidence behind the badge and the two numbers that + // need their sample scope spelled out, in a native
so the card + // stays compact and the explanation is reachable by keyboard AND touch — + // a title tooltip is neither. Timestamps are rendered separately, never + // merged: "last own advert" is the node's own transmission, "last + // attributed relay activity" is other traffic whose observed path named + // this node, and it is only reported when it is newer than that advert. + function renderMyMeshEvidence(ev, activity, stats) { + const hops = MyMeshActivity.hops(stats); + const trend = MyMeshActivity.compare24h(activity); + const row = (label, value, tip) => + `
${escapeHtml(label)} ${escapeHtml(value)}
`; + return `
${escapeHtml(MyMeshActivity.EVIDENCE_SUMMARY)}` + + row(MyMeshActivity.ADVERT_ROW_LABEL, ev.advertText || timeAgo(ev.advertAt)) + + row(MyMeshActivity.RELAY_ROW_LABEL, ev.relayText || timeAgo(ev.relayAt)) + + `
${escapeHtml(ev.explanation)}
` + + row(MyMeshActivity.WINDOW_ROW_LABEL, MyMeshActivity.windowText(activity), MyMeshActivity.TRAFFIC_TIP) + + row(MyMeshActivity.COMPARE_ROW_LABEL, trend.text, trend.tip) + + row(hops.label, hops.value, hops.tip) + + '
'; + } + function renderMyMeshAdvertIntervals(intervals, backfill) { const row = (label, estimate) => { if (!estimate) return `
${label} unavailable
`; @@ -606,10 +635,11 @@ function buildSparkline(activity) { const label = '
Node-associated transmissions · last 24h
'; - const buckets = activity?.buckets; - const valid = activity?.complete === true && Array.isArray(buckets) && buckets.length === 24 && - buckets.every(b => Number.isInteger(b.count) && b.count >= 0 && Number.isFinite(Date.parse(b.start)) && Number.isFinite(Date.parse(b.end))); - if (!valid) return label + '
Activity unavailable: 24h history incomplete
'; + // #304: one shared shape check for the rolling window (MyMeshActivity), + // so the sparkline and the 24h traffic count can never disagree about + // whether the hourly counts are usable. + const buckets = MyMeshActivity.buckets(activity); + if (!buckets || activity.complete !== true) return label + '
Activity unavailable: 24h history incomplete
'; const max = Math.max(1, ...buckets.map(b => b.count)); const bars = buckets.map(b => { const height = Math.max(2, Math.round((b.count / max) * 24)); @@ -653,28 +683,36 @@ const heard = HeardBy.lists(h); const claimed = isMyNode(pubkey); - const heardAt = getHealthLastHeard(node, stats); - const status = getHealthStatus(node.role, heardAt) || 'silent'; - const color = status === 'healthy' ? 'green' : status === 'degraded' ? 'yellow' : 'red'; - const statusMsg = heardAt ? `Last heard ${timeAgo(heardAt)}` : 'No confirmed activity in the current window'; - - const snrVal = stats.avgSnr; - const snrLabel = snrVal != null ? (snrVal > 10 ? 'Excellent' : snrVal > 0 ? 'Good' : snrVal > -5 ? 'Marginal' : 'Poor') : ''; + // #304: the panel reads the same evidence model as the cards, so the + // badge, the SNR attribution and the traffic window cannot drift apart + // between the two views. + const ev = MyMeshActivity.statusEvidence({ node, stats }); + const heardAt = ev.heardAt; + const status = ev.status; + const color = ev.confident && status === 'healthy' ? 'green' : status === 'silent' ? 'red' : 'yellow'; + const statusMsg = (heardAt ? `Last heard ${timeAgo(heardAt)}` : 'No confirmed activity in the current window') + + (ev.source === 'relay' ? ' · ' + MyMeshActivity.ATTRIBUTED_SUFFIX : ''); + + const snr = MyMeshActivity.snr(stats, heard.direct); + const traffic = MyMeshActivity.traffic24h(h.activity24h); + const trend = MyMeshActivity.compare24h(h.activity24h); + const hops = MyMeshActivity.hops(stats); card.innerHTML = `
- ${status === 'healthy' ? '' : status === 'degraded' ? '' : ''} + ${ev.confident && status === 'healthy' ? '' : status === 'silent' ? '' : ''} ${escapeHtml(node.name || truncate(pubkey, 16))} — ${statusMsg} ${!claimed ? `` : ''}
-
${stats.packetsToday ?? '—'}
Packets Today
+
${escapeHtml(traffic.value)}
${escapeHtml(traffic.label)}
${escapeHtml(trend.text)}
${heard.direct.length}
Direct observers
${heardAt ? timeAgo(heardAt) : '—'}
Last seen
-
${snrVal != null ? Number(snrVal).toFixed(1) + ' dB' : '—'}
Direct SNR${snrLabel ? ' · ' + snrLabel : ''}
-
${stats.avgHops != null ? stats.avgHops.toFixed(1) : '—'}
Avg Hops
+
${escapeHtml(snr.value)}
${escapeHtml(snr.label)}
+
${escapeHtml(hops.value)}
${escapeHtml(hops.label)}
+ ${renderMyMeshEvidence(ev, h.activity24h, stats)} ${heard.direct.length || heard.relay.length ? `
Heard directly by: ${heard.direct.length ? heard.direct.map(o => escapeHtml(HeardBy.observerName(o))).join(', ') : `none proven`}
` : ''} ${heard.relay.length ? `
Heard via relay by: ${heard.relay.map(o => escapeHtml(HeardBy.observerName(o))).join(', ')}
` : ''} ${hasLocation ? '
' : ''} diff --git a/public/index.html b/public/index.html index 5e3c846de..f54443ef1 100644 --- a/public/index.html +++ b/public/index.html @@ -183,6 +183,7 @@ + diff --git a/public/my-mesh-activity.js b/public/my-mesh-activity.js new file mode 100644 index 000000000..a6bd2430d --- /dev/null +++ b/public/my-mesh-activity.js @@ -0,0 +1,305 @@ +/* === CoreScope — my-mesh-activity.js === + * + * Issue #304 (points 3, 4 and 5). A My Mesh card has to answer three + * questions honestly: has this node been active, is it being used, and has + * anything changed? The node-health payload supports less than the old card + * implied, so this helper labels each number with the evidence behind it: + * + * 3. STATUS EVIDENCE. stats.lastAdvert is strictly the node's OWN advert. + * stats.lastHeard is that advert OR identity-safe relay evidence, + * whichever is newer, where "identity-safe" still means a wire hash + * prefix that no OTHER KNOWN relay shares (cmd/server/node_activity.go + * confirmedRelayKey). Uniqueness among known nodes is not proof: an + * unknown relay can share the prefix. So a badge whose freshness rests + * only on relay attribution is reported as attributed, never as a + * confident green. The thresholds themselves are NOT defined here -- + * getHealthStatus / getHealthLastHeard (roles.js, #539) stay the single + * source of truth and are injectable for tests. + * + * 4. SNR. stats.avgSnr is the mean over the receptions that PROVE a direct + * reception of this node's own transmission (#76, heard-by.js), measured + * by the receiving observer. That describes those links at the observer + * end; it is not a device health grade for the node, so this file prints + * no Excellent/Good/Marginal/Poor verdict and hands the card no verdict + * colour. The shared API field keeps its meaning untouched. + * + * 5. TRAFFIC. activity24h is the ROLLING 24-hour window with per-hour + * counts, a coverage start and a completeness flag; activity24h.previous + * is the 24 hours before it, for a like-for-like baseline only. Every + * count states its population and window, an incomplete window renders + * as a lower bound, and a window with no proven coverage is unavailable + * rather than zero. avgHops is relabelled as what it measures: the mean + * observed path length of the node's transmissions, not a distance. + * + * Pure functions only: no DOM, no network, no clock of its own (callers pass + * nowMs). The caller already fetches node health once per card, and nothing + * here adds a request. + */ +(function (root) { + 'use strict'; + + var UNKNOWN_VALUE = '—'; + + // ─── point 3: status evidence ─── + var EVIDENCE_SUMMARY = 'Status evidence'; + var ADVERT_ROW_LABEL = 'Last own advert'; + var RELAY_ROW_LABEL = 'Last attributed relay activity'; + var WINDOW_ROW_LABEL = 'Counting window'; + var ADVERT_NONE = 'none observed'; + var RELAY_NONE_NEWER = 'none newer than the last own advert'; + var RELAY_NONE = 'none attributed'; + var ATTRIBUTED_SUFFIX = 'relay-attributed'; + var STATUS_LABELS = { healthy: 'Active', degraded: 'Degraded', silent: 'Silent' }; + var EXPLAIN_ADVERT = 'The badge follows this node’s own advert, the only direct evidence that it transmitted, ' + + 'aged with the shared role thresholds. A recent advert is not proof of continuous uptime or of a device that is online right now.'; + var EXPLAIN_RELAY = 'No own advert is recent enough to carry this badge. The newest evidence is other traffic whose observed path ' + + 'was attributed to this node by a short wire hash prefix that no other known relay shares — an unknown relay can share it, ' + + 'so the match is not proof and this is deliberately not a confident green status.'; + var EXPLAIN_NONE = 'No identity-safe activity was observed in the window, so there is nothing to classify. ' + + 'That is missing evidence, not proof that the node is off.'; + + // ─── point 4: SNR ─── + var SNR_LABEL = 'Direct SNR'; + var SNR_TIP = 'Mean SNR over the receptions that prove a direct reception of this node’s own transmission (see Heard By), ' + + 'measured by the receiving observer. The count shown is all direct receptions; the mean covers only those that carry an SNR value, which can be fewer. ' + + 'It describes those observer links, not this node’s own signal quality or device health, and the direct attribution can rest on a wire hash prefix.'; + var SNR_EMPTY_TIP = 'No observation proves a direct reception of this node’s own transmission, so there is no SNR that belongs to it. ' + + 'Any other copy carries the signal of whichever node the observer actually heard.'; + + // ─── point 5: traffic ─── + var TRAFFIC_LABEL = 'Transmissions · 24 h'; + var TRAFFIC_TIP = 'Distinct transmissions associated with this node in the rolling 24-hour window: its own, ' + + 'those addressed to it, and those whose resolved path names it. It is not a count of what this node forwarded, ' + + 'and repeated observations of one transmission count once.'; + var TRAFFIC_PARTIAL_TIP = 'The in-memory history does not cover the whole window, so this is a lower bound (≥) over an incomplete window, not a total.'; + var TRAFFIC_UNAVAILABLE_TIP = 'The rolling window is unavailable for this node, so no 24-hour count is shown. An unknown window is not a quiet one.'; + var COMPARE_ROW_LABEL = 'Change vs previous 24 h'; + var COMPARE_NO_HISTORY = 'no comparable 24 h history'; + var COMPARE_TIP = 'Compares the rolling 24-hour count with the previous 24 h, the equally long window right before it. Shown only when both windows are fully covered, ' + + 'so a missing history is never read as zero traffic, and never as a percentage of a zero baseline.'; + var HOPS_LABEL = 'Avg observed path length'; + var HOPS_TIP = 'Mean number of hops on the observed paths of the transmissions associated with this node that carried one. ' + + 'It describes the routes those copies travelled, not this node’s distance from you and not its health.'; + var WINDOW_INCOMPLETE_SUFFIX = ' (coverage incomplete)'; + + function num(v) { + if (typeof v === 'number') return isFinite(v) ? v : null; + if (typeof v === 'string' && v.trim() !== '') { + var n = Number(v); + return isFinite(n) ? n : null; + } + return null; + } + + function ms(value) { + if (typeof value === 'number') return isFinite(value) ? value : null; + if (typeof value !== 'string' || value === '') return null; + var t = Date.parse(value); + return isFinite(t) ? t : null; + } + + function arr(v) { return Array.isArray(v) ? v : []; } + + function count(n) { return Number(n).toLocaleString('en-US'); } + + /* ── point 3 ── + * statusEvidence({ node, stats, nowMs, classify?, lastHeard? }) + * + * classify defaults to window.getHealthStatus and lastHeard to + * window.getHealthLastHeard: this file adds no threshold and no evidence + * rule of its own. source is 'advert' only when the node's OWN advert + * alone already supports the badge; otherwise the badge rests on relay + * attribution and is reported as not confident. + */ + function statusEvidence(opts) { + opts = opts || {}; + var node = opts.node || {}; + var stats = opts.stats || {}; + var classify = typeof opts.classify === 'function' ? opts.classify + : (root && typeof root.getHealthStatus === 'function' ? root.getHealthStatus : null); + var lastHeardOf = typeof opts.lastHeard === 'function' ? opts.lastHeard + : (root && typeof root.getHealthLastHeard === 'function' ? root.getHealthLastHeard : null); + + var heardAt = lastHeardOf ? lastHeardOf(node, stats) : null; + if (ms(heardAt) === null) heardAt = null; + var advertAt = ms(stats.lastAdvert) === null ? null : stats.lastAdvert; + var classified = classify && heardAt ? classify(node.role, heardAt, opts.nowMs) : null; + var advertStatus = classify && advertAt ? classify(node.role, advertAt, opts.nowMs) : null; + // Relay activity is only reported where the evidence supports it: the + // payload exposes one combined lastHeard, so relay evidence is visible + // exactly when it is NEWER than the node's own advert. + var relayAt = heardAt && (!advertAt || ms(heardAt) > ms(advertAt)) ? heardAt : null; + + var source = 'none'; + if (classified) source = advertAt && advertStatus === classified ? 'advert' : (relayAt ? 'relay' : 'advert'); + var status = classified || 'silent'; + var statusCls = status === 'healthy' ? 'status-ok' : status === 'degraded' ? 'status-warn' : 'status-err'; + var attributed = source === 'relay'; + var label = STATUS_LABELS[status] || STATUS_LABELS.silent; + return { + status: status, + classified: classified || null, + source: source, + confident: source === 'advert', + // An attributed match must never read as a confident green dot, so a + // healthy status drops to the provisional colour and a hollow glyph. + cls: attributed && status === 'healthy' ? 'status-warn' : statusCls, + glyph: attributed ? 'ph-circle' : 'ph-circle-fill', + label: label, + text: attributed ? label + ' · ' + ATTRIBUTED_SUFFIX : label, + heardAt: heardAt, + advertAt: advertAt, + relayAt: relayAt, + advertText: advertAt ? null : ADVERT_NONE, + relayText: relayAt ? null : (advertAt ? RELAY_NONE_NEWER : RELAY_NONE), + explanation: source === 'advert' ? EXPLAIN_ADVERT : source === 'relay' ? EXPLAIN_RELAY : EXPLAIN_NONE + }; + } + + /* ── point 4 ── snr(stats, directObservers) */ + function snr(stats, direct) { + stats = stats || {}; + var rows = arr(direct); + var value = num(typeof stats.avgSnr === 'string' ? null : stats.avgSnr); + var samples = 0; + for (var i = 0; i < rows.length; i++) { + var n = num(rows[i] && rows[i].packetCount); + if (n !== null && n >= 0) samples += Math.floor(n); + } + // Without a proven direct reception there is no SNR that belongs to this + // node, whatever the mean says (#76). + var available = value !== null && rows.length > 0; + return { + available: available, + value: available ? value.toFixed(1) + ' dB' : UNKNOWN_VALUE, + samples: samples, + label: available && samples > 0 ? SNR_LABEL + ' · ' + count(samples) + ' receptions' : SNR_LABEL, + tip: available ? SNR_TIP : SNR_EMPTY_TIP + }; + } + + /* ── point 5 ── + * buckets(activity) validates the rolling window's shape and is the one + * place that decides whether the hourly counts are usable at all (the + * sparkline shares it). + */ + function buckets(activity) { + var b = activity && activity.buckets; + if (!Array.isArray(b) || b.length !== 24) return null; + for (var i = 0; i < b.length; i++) { + var h = b[i]; + if (!h || !Number.isInteger(h.count) || h.count < 0) return null; + if (!isFinite(Date.parse(h.start)) || !isFinite(Date.parse(h.end))) return null; + } + return b; + } + + function sum(b) { + var total = 0; + for (var i = 0; i < b.length; i++) total += b[i].count; + return total; + } + + function traffic24h(activity) { + var b = buckets(activity); + if (!b) { + return { available: false, exact: false, count: null, value: UNKNOWN_VALUE, label: TRAFFIC_LABEL, tip: TRAFFIC_UNAVAILABLE_TIP }; + } + var total = sum(b); + var exact = activity.complete === true; + return { + available: true, + exact: exact, + count: total, + value: (exact ? '' : '≥ ') + count(total), + label: TRAFFIC_LABEL, + tip: TRAFFIC_TIP + ' ' + (exact ? windowText(activity) : TRAFFIC_PARTIAL_TIP + ' ' + windowText(activity)) + }; + } + + /* compare24h(activity) -> the like-for-like change, or an explicit + * "no comparable history". A baseline of zero yields an absolute delta and + * never a percentage; an incomplete window on either side yields nothing. + */ + function compare24h(activity) { + var cur = traffic24h(activity); + var prev = activity && activity.previous; + // The baseline is stricter than the display casts elsewhere in this + // file: it is divided by, so only a genuine non-negative integer count + // from a window that is itself proven complete can serve as one. + var baseline = prev && prev.complete === true && typeof prev.count === 'number' && + Number.isInteger(prev.count) && prev.count >= 0 ? prev.count : null; + if (!cur.available || !cur.exact || baseline === null) { + return { available: false, pct: null, delta: null, short: null, text: COMPARE_NO_HISTORY, tip: COMPARE_TIP }; + } + var delta = cur.count - baseline; + if (baseline === 0) { + // A zero baseline has no percentage to show, so there is no short + // inline form either: the card would have nothing to divide by. + return { + available: true, pct: null, delta: delta, short: null, + text: (delta > 0 ? '+' : '') + count(delta) + ' vs 0 in the previous 24 h', tip: COMPARE_TIP + }; + } + var pct = Math.round((delta / baseline) * 100); + var sign = pct > 0 ? '+' : ''; + return { + available: true, pct: pct, delta: delta, short: sign + pct + '%', + text: sign + pct + '% vs previous 24 h', tip: COMPARE_TIP + }; + } + + function hops(stats) { + var v = num(stats && stats.avgHops); + if (v === null || v < 0) { + return { available: false, value: UNKNOWN_VALUE, label: HOPS_LABEL, tip: HOPS_TIP }; + } + return { + available: true, + value: count(v) + (v === 1 ? ' hop' : ' hops'), + label: HOPS_LABEL, + tip: HOPS_TIP + }; + } + + /* windowText(activity) -> the window bounds in the reader's own locale, + * with the coverage caveat when the history does not cover all of it. The + * card shows this as text, not only as a tooltip: a title attribute is not + * reachable by touch. + */ + function windowText(activity) { + var from = ms(activity && activity.windowStart); + var to = ms(activity && activity.windowEnd); + if (from === null || to === null) return UNKNOWN_VALUE; + return new Date(from).toLocaleString() + ' to ' + new Date(to).toLocaleString() + + (activity.complete === true ? '' : WINDOW_INCOMPLETE_SUFFIX); + } + + root.MyMeshActivity = { + UNKNOWN_VALUE: UNKNOWN_VALUE, + EVIDENCE_SUMMARY: EVIDENCE_SUMMARY, + ADVERT_ROW_LABEL: ADVERT_ROW_LABEL, + RELAY_ROW_LABEL: RELAY_ROW_LABEL, + WINDOW_ROW_LABEL: WINDOW_ROW_LABEL, + ADVERT_NONE: ADVERT_NONE, + RELAY_NONE_NEWER: RELAY_NONE_NEWER, + RELAY_NONE: RELAY_NONE, + ATTRIBUTED_SUFFIX: ATTRIBUTED_SUFFIX, + SNR_LABEL: SNR_LABEL, + SNR_TIP: SNR_TIP, + TRAFFIC_LABEL: TRAFFIC_LABEL, + TRAFFIC_TIP: TRAFFIC_TIP, + COMPARE_ROW_LABEL: COMPARE_ROW_LABEL, + COMPARE_NO_HISTORY: COMPARE_NO_HISTORY, + COMPARE_TIP: COMPARE_TIP, + HOPS_LABEL: HOPS_LABEL, + HOPS_TIP: HOPS_TIP, + statusEvidence: statusEvidence, + snr: snr, + buckets: buckets, + traffic24h: traffic24h, + compare24h: compare24h, + hops: hops, + windowText: windowText + }; +})(typeof window !== 'undefined' ? window : this); diff --git a/test-all.sh b/test-all.sh index e9dc28c11..77ce66086 100755 --- a/test-all.sh +++ b/test-all.sh @@ -67,6 +67,7 @@ run test-page-title-563.js run test-node-adverts.js run test-heard-by.js run test-issue-262-mymesh-observer.js +run test-issue-304-my-mesh-metrics.js run test-issue-254-affinity-debug-toggle.js run test-issue-258-column-widths.js run test-issue-259-nodes-esc-listener.js diff --git a/test-health-status-e2e.js b/test-health-status-e2e.js index 3cc50a505..1b23a0e16 100644 --- a/test-health-status-e2e.js +++ b/test-health-status-e2e.js @@ -96,6 +96,14 @@ const server = http.createServer((req, res) => { healthThresholds: { infraDegradedMs: 2 * HOUR, infraSilentMs: 6 * HOUR, nodeDegradedMs: HOUR, nodeSilentMs: 24 * HOUR }, } })); const expected = f => mode.custom ? f.custom : f.want; + // #304: the badge now names the evidence behind it. A fixture with no + // own advert — the confirmed-relay case and the legacy directory + // fallback — can only be attributed, so its label carries the suffix; + // a fixture with no identity-safe activity at all has nothing to + // attribute and keeps the plain label. + const STATUS_LABEL = { healthy: 'Active', degraded: 'Degraded', silent: 'Silent' }; + const expectedLabel = f => STATUS_LABEL[expected(f)] + + (!f.unknown && !f.invalid && (f.relay || f.legacy) ? ' · relay-attributed' : ''); await page.goto(origin + '/#/home', { waitUntil: 'domcontentloaded' }); await page.waitForFunction(n => document.querySelectorAll('#myNodesGrid .my-node-card').length === n, fixtures.length); assert.equal(await page.locator('html').getAttribute('data-theme'), mode.theme, 'requested theme is applied'); checks++; @@ -107,9 +115,25 @@ const server = http.createServer((req, res) => { await card.locator('.mnc-name').click(); const banner = page.locator('#homeHealth .health-banner'); await page.waitForFunction(name => document.querySelector('#homeHealth .health-banner')?.textContent.includes(name), f.name); - assert.equal(await banner.locator('[aria-label]').first().getAttribute('aria-label'), expected(f), f.name + ': health detail'); checks++; + assert.equal(await banner.locator('[aria-label]').first().getAttribute('aria-label'), expectedLabel(f), f.name + ': health detail'); checks++; + assert.ok((await card.locator('.mnc-status .ph-icon use').getAttribute('href')).endsWith( + !f.unknown && !f.invalid && (f.relay || f.legacy) ? '#ph-circle' : '#ph-circle-fill'), f.name + ': evidence glyph'); checks++; if (f.unknown || f.invalid) { assert.match(await banner.innerText(), /No confirmed activity in the current window/); checks++; + // #304 F1: no evidence means nothing was attributed to the node. + assert.ok(!/relay-attributed/.test(await banner.innerText()), f.name + ': no attribution claim without evidence'); checks++; + } + // #304 F2: relay-attributed evidence must never paint the panel as a + // confident green, whatever the badge says; own-advert Active stays green. + const attributed = !f.unknown && !f.invalid && (f.relay || f.legacy); + const bannerCls = (await banner.getAttribute('class')).split(/\s+/); + const bannerIcon = await banner.locator('.ph-icon use').first().getAttribute('href'); + if (attributed) { + assert.ok(!bannerCls.includes('green'), f.name + ': attributed banner is not green'); checks++; + assert.ok(!bannerIcon.endsWith('#ph-check-circle'), f.name + ': attributed banner has no check icon'); checks++; + assert.match(await banner.innerText(), /relay-attributed/, f.name + ': attributed banner says so'); checks++; + } else if (expected(f) === 'healthy') { + assert.ok(bannerCls.includes('green') && bannerIcon.endsWith('#ph-check-circle'), f.name + ': own-advert Active is green'); checks++; } } if (mode.width > 768) { diff --git a/test-health-status.js b/test-health-status.js index 0b4a503fd..9e3509132 100644 --- a/test-health-status.js +++ b/test-health-status.js @@ -6,6 +6,10 @@ const fs = require('node:fs'); const vm = require('node:vm'); const path = require('node:path'); const source = fs.readFileSync(path.join(__dirname, 'public/roles.js'), 'utf8'); +// #304: the My Mesh and Home-health decision blocks now go through the +// shared evidence helper, which reads these very thresholds. It is loaded +// into the SAME context, so the blocks below still execute the real rules. +const activitySource = fs.readFileSync(path.join(__dirname, 'public/my-mesh-activity.js'), 'utf8'); const HOUR = 3600000; const NOW = Date.UTC(2026, 9, 24, 12); let passed = 0; @@ -17,6 +21,7 @@ async function load(config = {}) { }; ctx.window = ctx; vm.runInNewContext(source, ctx); + vm.runInNewContext(activitySource, ctx); await ctx.MeshConfigReady; return ctx; } @@ -78,17 +83,17 @@ function check(label, fn) { // Execute the actual decision blocks, not hand-copied equivalents. Browser // tests cover their surrounding asynchronous/DOM wiring and visible labels. - const callArgs = ['node', 'stats', 'h', 'n', 'getHealthLastHeard', 'getHealthStatus']; + const callArgs = ['node', 'stats', 'h', 'n', 'getHealthLastHeard', 'getHealthStatus', 'MyMeshActivity']; function decision(file, start, end, returned) { const src = fs.readFileSync(path.join(__dirname, 'public', file), 'utf8'); const from = src.indexOf(start), to = src.indexOf(end, from); assert.ok(from >= 0 && to > from, 'production decision block exists in ' + file); const fn = new Function(...callArgs, 'timeAgo', src.slice(from, to) + '\nreturn ' + returned + ';'); - return (...values) => fn(...values, () => 'synthetic age'); + return (...values) => fn(...values, r.MyMeshActivity, () => 'synthetic age'); } const blocks = [ - ['My Mesh', decision('home.js', 'const heardAt = getHealthLastHeard(node, stats);', 'const statusCls', 'status')], - ['Home health', decision('home.js', 'const heardAt = getHealthLastHeard(node, stats);\n const status', 'const snrVal', 'status')], + ['My Mesh', decision('home.js', 'const ev = MyMeshActivity.statusEvidence({ node, stats });', 'const statusDot', 'status')], + ['Home health', decision('home.js', 'const ev = MyMeshActivity.statusEvidence({ node, stats });\n const heardAt', 'const snr = MyMeshActivity.snr', 'status')], ['Favorites', decision('app.js', 'const heardAt = getHealthLastHeard(h.node, h.stats);', 'return \' { // No nav shell / other pages exist in this fixture, so the real router // (wired to hashchange at app.js load time) would null-deref walking @@ -487,7 +487,7 @@ function cardFor(page, pubkey) { return route.fulfill({ contentType: 'text/html', body: '
' }); }); await page.goto(ORIGIN + '/'); - for (const file of ['app.js', 'roles.js', 'heard-by.js', 'my-mesh-observers.js']) await page.addScriptTag({ path: path.join(PUB, file) }); + for (const file of ['app.js', 'roles.js', 'heard-by.js', 'my-mesh-observers.js', 'my-mesh-activity.js']) await page.addScriptTag({ path: path.join(PUB, file) }); await page.evaluate(() => { window.removeEventListener('hashchange', navigate); window.fixturePages = {}; diff --git a/test-issue-262-mymesh-observer-e2e.js b/test-issue-262-mymesh-observer-e2e.js index e276e2632..8e89527c3 100644 --- a/test-issue-262-mymesh-observer-e2e.js +++ b/test-issue-262-mymesh-observer-e2e.js @@ -30,18 +30,22 @@ const HOSTILE = '">'; const now = Date.now(); const iso = msAgo => new Date(now - msAgo).toISOString(); +// #304: the rolling window carries a known total (276 = 0+1+...+23) and a +// complete previous window, so the node metric the assertions read is the +// node's own 24h transmission count, never the observer's reported traffic. const activity = () => ({ - windowStart: iso(24 * 3600000), windowEnd: iso(0), coverageStart: iso(24 * 3600000), complete: true, + windowStart: iso(24 * 3600000), windowEnd: iso(0), coverageStart: iso(48 * 3600000), complete: true, buckets: Array.from({ length: 24 }, (_, i) => ({ start: iso((24 - i) * 3600000), end: iso((23 - i) * 3600000), count: i })), + previous: { windowStart: iso(48 * 3600000), windowEnd: iso(24 * 3600000), count: 230, complete: true }, }); // Node health, one entry per card. Card 3 is "Dokk1 Lab": NOT an observer, // even though the observer list holds a device called "Dokk1 Lab Meshview". const health = { - [KEY_OBS]: { node: { name: 'Hub North', role: 'repeater' }, stats: { lastHeard: iso(60000), packetsToday: 77, avgSnr: 6, avgHops: 1.5 } }, - [KEY_PLAIN]: { node: { name: 'Plain Relay', role: 'repeater' }, stats: { lastHeard: iso(60000), packetsToday: 12, avgSnr: 2, avgHops: 2 } }, - [KEY_ROOM]: { node: { name: 'Chat Room', role: 'room' }, stats: { lastHeard: iso(60000), packetsToday: 4, avgSnr: 1, avgHops: 3 } }, - [KEY_DOKK]: { node: { name: 'Dokk1 Lab', role: 'repeater' }, stats: { lastHeard: iso(60000), packetsToday: 9, avgSnr: 0, avgHops: 2 } }, + [KEY_OBS]: { node: { name: 'Hub North', role: 'repeater' }, stats: { lastHeard: iso(60000), lastAdvert: iso(60000), packetsToday: 77, avgSnr: 6, avgHops: 1.5 } }, + [KEY_PLAIN]: { node: { name: 'Plain Relay', role: 'repeater' }, stats: { lastHeard: iso(60000), lastAdvert: iso(60000), packetsToday: 12, avgSnr: 2, avgHops: 2 } }, + [KEY_ROOM]: { node: { name: 'Chat Room', role: 'room' }, stats: { lastHeard: iso(60000), lastAdvert: iso(60000), packetsToday: 4, avgSnr: 1, avgHops: 3 } }, + [KEY_DOKK]: { node: { name: 'Dokk1 Lab', role: 'repeater' }, stats: { lastHeard: iso(60000), lastAdvert: iso(60000), packetsToday: 9, avgSnr: 0, avgHops: 2 } }, }; const observersPayload = { @@ -75,7 +79,7 @@ async function render(page, { observersFail = false, holdObservers = false } = { // observers.js is loaded for real so the card's status uses the observers // page's own classifier (window.observerHealthStatus, #1552/#1563) rather // than a threshold invented for the home page. - for (const file of ['app.js', 'roles.js', 'heard-by.js', 'my-mesh-observers.js', 'observers.js']) { + for (const file of ['app.js', 'roles.js', 'heard-by.js', 'my-mesh-observers.js', 'my-mesh-activity.js', 'observers.js']) { await page.addScriptTag({ path: path.join(ROOT, file) }); } await page.evaluate((data) => { @@ -192,8 +196,8 @@ async function render(page, { observersFail = false, holdObservers = false } = { assert.deepEqual(await obsCard.locator('.mnc-section-lbl').evaluateAll(els => els.map(e => e.textContent)), ['Observer reporting', 'Node activity']); assert.deepEqual(await obsCard.locator('.mnc-section-lbl').allInnerTexts(), ['OBSERVER REPORTING', 'NODE ACTIVITY']); - assert.equal(await obsCard.locator('.mnc-metrics .mnc-lbl').first().innerText(), 'Packets today'); - assert.equal(await obsCard.locator('.mnc-metrics .mnc-val').first().innerText(), '77'); + assert.equal(await obsCard.locator('.mnc-metrics .mnc-lbl').first().innerText(), 'Transmissions · 24 h'); + assert.equal(await obsCard.locator('.mnc-metrics .mnc-val').first().innerText(), '276 +20%'); assert.match(await obsCard.locator('.mnc-spark').innerText(), /Node-associated transmissions.*last 24h/); assert.equal(await plainCard.locator('.mnc-section-lbl').count(), 0, 'a non-observer card gains no section labels'); // The observer metric must not sit inside the node metric grid. @@ -264,7 +268,7 @@ async function render(page, { observersFail = false, holdObservers = false } = { assert.equal(await page.locator('[data-action="observer"]').count(), 0); // Every card still renders, with its node data intact. assert.equal(await page.locator('.my-node-card').count(), KEYS.length); - assert.equal(await cards.nth(0).locator('.mnc-metrics .mnc-val').first().innerText(), '77'); + assert.equal(await cards.nth(0).locator('.mnc-metrics .mnc-val').first().innerText(), '276 +20%'); assert.equal(await page.locator('.my-node-card.is-observer').count(), 0); assert.deepEqual(errors, []); diff --git a/test-issue-304-my-mesh-e2e.js b/test-issue-304-my-mesh-e2e.js index e4145dd3e..f518ea493 100644 --- a/test-issue-304-my-mesh-e2e.js +++ b/test-issue-304-my-mesh-e2e.js @@ -23,14 +23,23 @@ const end = new Date(now).toISOString(); const start = new Date(now - 24 * 3600000).toISOString(); // #384: a fixed advert age so the card's "· last …" text is pinned. const advert33m = new Date(now - 33 * 60000).toISOString(); -const activity = (complete = true) => ({ - windowStart: start, windowEnd: end, coverageStart: complete ? start : new Date(now - 8 * 3600000).toISOString(), complete, +// #304 remainder: `previous` is the 24h window before the rolling one, the +// only baseline the card may compare against, and only when both windows are +// fully covered. +const prevStart = new Date(now - 48 * 3600000).toISOString(); +const activity = (complete = true, previous = { windowStart: prevStart, windowEnd: start, count: 28, complete: true }) => ({ + windowStart: start, windowEnd: end, coverageStart: complete ? prevStart : new Date(now - 8 * 3600000).toISOString(), complete, buckets: Array.from({ length: 24 }, (_, i) => ({ start: new Date(now - (24 - i) * 3600000).toISOString(), end: new Date(now - (23 - i) * 3600000).toISOString(), count: i === 3 ? 35 : 0, })), + previous, }); +// An own advert older than the infra degraded threshold (24h): on its own it +// cannot carry an Active badge, so a 24h-fresh lastHeard can only come from +// attributed relay evidence. +const advertStale = new Date(now - 30 * 3600000).toISOString(); async function render(page, responses, width, height) { await page.setViewportSize({ width, height }); @@ -43,7 +52,7 @@ async function render(page, responses, width, height) { await page.goto(ORIGIN + '/'); await page.addStyleTag({ path: path.join(ROOT, 'style.css') }); await page.addStyleTag({ path: path.join(ROOT, 'home.css') }); - for (const file of ['app.js', 'roles.js', 'heard-by.js', 'my-mesh-observers.js']) await page.addScriptTag({ path: path.join(ROOT, file) }); + for (const file of ['app.js', 'roles.js', 'heard-by.js', 'my-mesh-observers.js', 'my-mesh-activity.js']) await page.addScriptTag({ path: path.join(ROOT, file) }); await page.evaluate((data) => { window.removeEventListener('hashchange', navigate); window.fixturePages = {}; @@ -82,16 +91,16 @@ async function render(page, responses, width, height) { // carry advert-interval estimates with pending, complete and missing // route-mask backfill; repeater 3 has no estimate at all (#352). const specs = [ - { role: 'repeater', observers: names.map(observer_name => ({ observer_name })), activity: activity(), + { role: 'repeater', observers: names.map((observer_name, i) => ({ observer_name, packetCount: i === 0 ? 30 : 0 })), activity: activity(), backfill: { status: 'pending', remaining: 15 }, intervals: { zero_hop: { status: 'estimated', interval_s: 7200, samples: 5, last_advert: advert33m, confidence: 'medium' }, flood: { status: 'too_few', interval_s: null, samples: 2, last_advert: end }, } }, - { role: 'repeater', observers: [{ observer_name: 'One' }], activity: activity(false), + { role: 'repeater', observers: [{ observer_name: 'One' }], activity: activity(false), stats: { lastAdvert: advertStale }, backfill: { status: 'complete', remaining: 0 }, intervals: { zero_hop: { status: 'none_observed', samples: 0 }, flood: { status: 'irregular', samples: 5 } } }, - { role: 'repeater', observers: [], activity: undefined, + { role: 'repeater', observers: [], activity: undefined, stats: { lastAdvert: null, avgSnr: 7, avgHops: null }, intervals: { zero_hop: { status: 'estimated', interval_s: 3600, samples: 4 }, flood: { status: 'too_few', samples: 1 } } }, { role: 'repeater', observers: [{ observer_name: CLIP }], activity: activity(false) }, { role: 'client', observers: [{ observer_name: CLIP }, { observer_name: 'Shorty' }], activity: activity(false) }, @@ -100,7 +109,8 @@ async function render(page, responses, width, height) { { role: 'client', observers: [{ observer_name: 'Shorty' }, ...SHORT_WIDE.map(observer_name => ({ observer_name }))], activity: activity(false) }, ]; const data = Object.fromEntries(specs.map((spec, i) => [`/nodes/${KEYS[i]}/health?include=advertIntervals`, { - node: { name: `Node ${i}`, role: spec.role }, stats: { lastHeard: end, packetsToday: 2 }, + node: { name: `Node ${i}`, role: spec.role }, + stats: Object.assign({ lastHeard: end, lastAdvert: end, avgSnr: -3.25, avgHops: 10 }, spec.stats), directObservers: spec.observers, recentPackets: [], activity24h: spec.activity, advertRouteBackfill: spec.backfill, advertIntervals: spec.intervals, }])); @@ -143,6 +153,101 @@ async function render(page, responses, width, height) { assert.match(await cards.nth(3).locator('.mnc-advert-cadence').innerText(), /unavailable/i); assert.equal(await cards.nth(3).locator('.mnc-advert-provisional').count(), 0); assert.equal(await cards.nth(4).locator('.mnc-advert-cadence').count(), 0, 'non-repeater card must not show advert intervals'); + // ── #304 remainder: traffic, SNR attribution and status evidence ── + // Card 0: fully covered window, own advert as fresh as the newest + // evidence. Card 1: 8h coverage only and an own advert 30h old, so + // the Active badge can only rest on attributed relay evidence. + // Card 2: no rolling window, no own advert, no direct observer. + await page.mouse.move(0, 0); // keep :hover off the border-colour probe + const labels = async i => (await cards.nth(i).locator('.mnc-metrics .mnc-lbl').allInnerTexts()).map(t => t.replace(/\s+/g, ' ').trim()); + const vals = async i => (await cards.nth(i).locator('.mnc-metrics .mnc-val').allInnerTexts()).map(t => t.trim()); + assert.deepEqual(await labels(0), ['Transmissions · 24 h', 'Direct observers', 'Direct SNR · 30 receptions']); + assert.deepEqual(await vals(0), ['35 +25%', '51', '-3.3 dB']); + assert.deepEqual(await labels(1), ['Transmissions · 24 h', 'Direct observers', 'Direct SNR']); + assert.deepEqual(await vals(1), ['≥ 35', '1', '-3.3 dB'], 'an incomplete window is a lower bound, and an unknown sample size prints no n='); + assert.deepEqual(await vals(2), ['—', '0', '—'], 'no window and no direct attribution render em dashes, not zeroes'); + // The calendar-day counter and the bare hop average are gone from the + // headline metrics, and no metric carries a quality verdict or colour. + const metricText = (await page.locator('.mnc-metrics').allInnerTexts()).join(' '); + assert.ok(!/today/i.test(metricText), 'no "Packets today" left: ' + metricText); + assert.ok(!/avg hops/i.test(metricText), 'Avg hops must move into the details'); + assert.ok(!/excellent|\bgood\b|marginal|\bpoor\b/i.test(metricText), 'no SNR verdict: ' + metricText); + assert.equal(await page.locator('.mnc-metrics .mnc-val[style]').count(), 0, 'no verdict colour on a metric value'); + // The previous-period comparison is like-for-like or absent: the inline + // chip only appears for two complete windows with a non-zero baseline, + // and its full wording is in the evidence disclosure either way. + assert.equal(await cards.nth(0).locator('.mnc-metric-sub').innerText(), '+25%'); + assert.match(await cards.nth(0).locator('.mnc-metric-sub').getAttribute('title'), /^\+25% vs previous 24 h — /); + for (const i of [1, 2]) { + assert.equal(await cards.nth(i).locator('.mnc-metric-sub').count(), 0, `card ${i} must not show a comparison chip`); + } + + // The badge: a confident own-advert green, and an attributed match that + // is deliberately NOT green — dot, wording and card accent. + assert.equal(await cards.nth(0).locator('.mnc-status .status-ok').count(), 1); + assert.equal(await cards.nth(0).locator('.mnc-status use').getAttribute('href'), '/icons/phosphor-sprite.svg#ph-circle-fill'); + assert.equal((await cards.nth(0).locator('.mnc-status-text').innerText()).replace(/\s+/g, ' ').trim().split(' · ')[0], 'Active'); + for (const i of [1, 2]) { + assert.equal(await cards.nth(i).locator('.mnc-status .status-ok').count(), 0, `card ${i} must not show a confident green`); + assert.equal(await cards.nth(i).locator('.mnc-status .status-warn').count(), 1, `card ${i} must show the provisional dot`); + assert.equal(await cards.nth(i).locator('.mnc-status use').getAttribute('href'), '/icons/phosphor-sprite.svg#ph-circle'); + assert.match(await cards.nth(i).locator('.mnc-status-text').innerText(), /Active · relay-attributed/); + assert.equal(await cards.nth(i).evaluate(el => el.classList.contains('evidence-relay')), true); + } + const probe = await page.evaluate(() => { + const root = getComputedStyle(document.documentElement); + const resolve = name => { + const el = document.createElement('div'); + el.style.color = root.getPropertyValue(name); + document.body.appendChild(el); + const value = getComputedStyle(el).color; + el.remove(); + return value; + }; + return { green: resolve('--status-green'), yellow: resolve('--status-yellow') }; + }); + const accents = await cards.evaluateAll(els => els.map(el => getComputedStyle(el).borderLeftColor)); + assert.equal(accents[0], probe.green, 'an own-advert Active card keeps the green accent'); + assert.equal(accents[1], probe.yellow, 'an attributed Active card must not keep the green accent'); + assert.notEqual(probe.green, probe.yellow); + + // The evidence disclosure: compact by default, reachable by keyboard + // and touch, and it must not navigate to the health panel. + const ev = cards.nth(1).locator('.mnc-evidence'); + assert.equal(await ev.evaluate(el => el.open), false, 'the disclosure starts closed so the card stays compact'); + assert.equal(await ev.locator('.mnc-evidence-row').first().isVisible(), false); + await ev.locator('summary').focus(); + await page.keyboard.press('Enter'); + assert.equal(await ev.evaluate(el => el.open), true, 'Enter on the summary must open it'); + assert.equal(await page.locator('#homeHealth.visible').count(), 0, 'opening the evidence must not open the health panel'); + const rows = async i => (await cards.nth(i).locator('.mnc-evidence-row').allInnerTexts()).map(t => t.replace(/\s+/g, ' ').trim()); + const [advert1, relay1, window1, trend1, hops1] = await rows(1); + assert.match(advert1, /^Last own advert 1d ago$/); + assert.match(relay1, /^Last attributed relay activity \d+s ago$/); + assert.match(window1, /^Counting window .+ to .+ \(coverage incomplete\)$/); + assert.equal(trend1, 'Change vs previous 24 h no comparable 24 h history'); + assert.match(hops1, /^Avg observed path length 10 hops$/); + assert.match(await cards.nth(1).locator('.mnc-evidence-note').first().innerText(), /prefix/i); + await cards.nth(0).locator('.mnc-evidence summary').click(); + const [advert0, relay0, window0, trend0] = await rows(0); + assert.match(advert0, /^Last own advert \d+s ago$/); + assert.equal(relay0, 'Last attributed relay activity none newer than the last own advert'); + assert.ok(!/coverage incomplete/.test(window0), window0); + assert.equal(trend0, 'Change vs previous 24 h +25% vs previous 24 h'); + assert.match(await cards.nth(0).locator('.mnc-evidence-note').first().innerText(), /own advert/i); + await cards.nth(2).locator('.mnc-evidence summary').click(); + const [advert2, , , , hops2] = await rows(2); + assert.equal(advert2, 'Last own advert none observed'); + assert.equal(hops2, 'Avg observed path length —'); + assert.equal(await page.locator('#homeHealth.visible').count(), 0); + if (width < 500) { + await cards.nth(3).locator('.mnc-evidence summary').tap(); + assert.equal(await cards.nth(3).locator('.mnc-evidence').evaluate(el => el.open), true, 'touch must open the disclosure'); + assert.equal(await page.locator('#homeHealth.visible').count(), 0, 'a tap on the summary must not navigate'); + await cards.nth(3).locator('.mnc-evidence summary').tap(); + } + for (const i of [0, 1, 2]) await cards.nth(i).locator('.mnc-evidence summary').click(); + const heights = await cards.evaluateAll(els => els.map(el => el.getBoundingClientRect().height)); assert(heights[0] < 350, `repeater card too tall: ${heights[0]} at ${width}px`); const open = large.locator('.mnc-view-all'); diff --git a/test-issue-304-my-mesh-metrics.js b/test-issue-304-my-mesh-metrics.js new file mode 100644 index 000000000..e2407b5e8 --- /dev/null +++ b/test-issue-304-my-mesh-metrics.js @@ -0,0 +1,384 @@ +/* Unit tests for issue #304 (remainder) — My Mesh status evidence, SNR + * attribution and traffic metrics. + * + * public/my-mesh-activity.js is the shared helper the My Mesh cards and the + * home health panel use to label what the node-health payload actually + * proves: which evidence drives the status badge, what the direct SNR mean + * covers, and what the rolling 24-hour traffic count counts. It owns no + * thresholds of its own — liveness stays with roles.js (#539) — and it never + * turns missing evidence into a confident answer. + * + * Run: node test-issue-304-my-mesh-metrics.js + */ +'use strict'; +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const rolesSrc = fs.readFileSync(path.join(__dirname, 'public', 'roles.js'), 'utf8'); +const src = fs.readFileSync(path.join(__dirname, 'public', 'my-mesh-activity.js'), 'utf8'); +const ctx = { + console, Date, fetch: async () => ({ json: async () => ({}) }), + document: { readyState: 'complete', createElement: () => ({}), head: { appendChild() {} }, getElementById: () => null, addEventListener() {} }, +}; +ctx.window = ctx; +vm.createContext(ctx); +// roles.js is loaded for real: the helper must reuse the shipped liveness +// rules (getHealthStatus / getHealthLastHeard), not a copy of them. +vm.runInContext(rolesSrc, ctx, { filename: 'roles.js' }); +vm.runInContext(src, ctx, { filename: 'my-mesh-activity.js' }); +const MMA = ctx.MyMeshActivity; + +const homeJs = fs.readFileSync(path.join(__dirname, 'public', 'home.js'), 'utf8'); +const homeCss = fs.readFileSync(path.join(__dirname, 'public', 'home.css'), 'utf8'); +const indexHtml = fs.readFileSync(path.join(__dirname, 'public', 'index.html'), 'utf8'); + +let passed = 0, failed = 0; +function test(name, fn) { + try { fn(); passed++; console.log(' ✅ ' + name); } + catch (e) { failed++; console.log(' ❌ ' + name + ': ' + e.message); } +} + +const NOW = Date.parse('2026-10-25T12:00:00Z'); +const ago = min => new Date(NOW - min * 60000).toISOString(); +const VERDICTS = /excellent|\bgood\b|marginal|\bpoor\b/i; + +// A rolling window payload as the server sends it (cmd/server/node_activity_24h.go). +function activity(opts) { + const o = opts || {}; + const end = new Date(NOW).toISOString(); + const start = new Date(NOW - 24 * 3600000).toISOString(); + const prevStart = new Date(NOW - 48 * 3600000).toISOString(); + const counts = o.counts || []; + return { + windowStart: start, windowEnd: end, + coverageStart: o.coverageStart === undefined ? prevStart : o.coverageStart, + complete: o.complete !== false, + buckets: Array.from({ length: 24 }, (_, i) => ({ + start: new Date(NOW - (24 - i) * 3600000).toISOString(), + end: new Date(NOW - (23 - i) * 3600000).toISOString(), + count: counts[i] || 0, + })), + previous: o.previous === null ? undefined + : Object.assign({ windowStart: prevStart, windowEnd: start, count: 0, complete: true }, o.previous), + }; +} + +console.log('\n=== #304 point 3: status evidence ==='); + +test('an own advert inside the threshold is confident, and says so', () => { + const ev = MMA.statusEvidence({ node: { role: 'repeater' }, stats: { lastHeard: ago(10), lastAdvert: ago(10) }, nowMs: NOW }); + assert.strictEqual(ev.status, 'healthy'); + assert.strictEqual(ev.source, 'advert'); + assert.strictEqual(ev.confident, true); + assert.strictEqual(ev.cls, 'status-ok'); + assert.strictEqual(ev.label, 'Active'); + assert.strictEqual(ev.text, 'Active'); + assert.strictEqual(ev.advertAt, ago(10)); + assert.strictEqual(ev.relayAt, null, 'relay activity is only reported when it is newer than the advert'); + assert.match(ev.explanation, /own advert/i); + assert.match(ev.explanation, /not proof of continuous uptime|not proof .*online/i); +}); + +test('relay-only evidence never produces a confident green status', () => { + // Own advert 30h old (degraded for infra), relay evidence 10 min old. + const ev = MMA.statusEvidence({ node: { role: 'repeater' }, stats: { lastHeard: ago(10), lastAdvert: ago(30 * 60) }, nowMs: NOW }); + assert.strictEqual(ev.status, 'healthy', 'the shared thresholds still classify the newest evidence'); + assert.strictEqual(ev.source, 'relay'); + assert.strictEqual(ev.confident, false); + assert.notStrictEqual(ev.cls, 'status-ok', 'an attributed match must not be shown as a confident green'); + assert.strictEqual(ev.cls, 'status-warn'); + assert.strictEqual(ev.glyph, 'ph-circle', 'an attributed status uses the hollow dot'); + assert.strictEqual(ev.text, 'Active · relay-attributed'); + assert.strictEqual(ev.relayAt, ago(10)); + assert.strictEqual(ev.advertAt, ago(30 * 60)); + assert.match(ev.explanation, /prefix/i, 'the explanation must name the short-prefix uncertainty'); +}); + +test('a node with no own advert at all is relay-attributed, never confident', () => { + const ev = MMA.statusEvidence({ node: { role: 'repeater' }, stats: { lastHeard: ago(5), lastAdvert: null }, nowMs: NOW }); + assert.strictEqual(ev.source, 'relay'); + assert.strictEqual(ev.confident, false); + assert.notStrictEqual(ev.cls, 'status-ok'); + assert.strictEqual(ev.advertAt, null); + assert.strictEqual(ev.relayAt, ago(5)); +}); + +test('an advert that alone supports the badge stays confident even with newer relay traffic', () => { + const ev = MMA.statusEvidence({ node: { role: 'repeater' }, stats: { lastHeard: ago(5), lastAdvert: ago(120) }, nowMs: NOW }); + assert.strictEqual(ev.status, 'healthy'); + assert.strictEqual(ev.source, 'advert'); + assert.strictEqual(ev.confident, true); + assert.strictEqual(ev.cls, 'status-ok'); + assert.strictEqual(ev.relayAt, ago(5), 'the newer relay activity is still reported separately'); +}); + +test('no identity-safe activity is reported as missing evidence', () => { + for (const stats of [{}, { lastHeard: null }, { lastHeard: '', lastAdvert: '' }, { lastHeard: 'nonsense' }]) { + const ev = MMA.statusEvidence({ node: { role: 'repeater' }, stats, nowMs: NOW }); + assert.strictEqual(ev.source, 'none', JSON.stringify(stats)); + assert.strictEqual(ev.confident, false); + assert.strictEqual(ev.heardAt, null); + assert.strictEqual(ev.relayAt, null); + assert.match(ev.explanation, /missing evidence|not proof/i); + } +}); + +test('the role thresholds come from roles.js, with no home-page copy', () => { + // A 90-minute silence is healthy for infra (24h) and degraded for a + // client (1h). Both must fall out of the shared classifier. + const infra = MMA.statusEvidence({ node: { role: 'repeater' }, stats: { lastHeard: ago(90), lastAdvert: ago(90) }, nowMs: NOW }); + const client = MMA.statusEvidence({ node: { role: 'client' }, stats: { lastHeard: ago(90), lastAdvert: ago(90) }, nowMs: NOW }); + assert.strictEqual(infra.status, 'healthy'); + assert.strictEqual(client.status, 'degraded'); + assert.strictEqual(client.cls, 'status-warn'); + assert.ok(!/HEALTH_THRESHOLDS\s*=|degradedMs\s*[:=]\s*\d/.test(src), 'the helper must not define its own thresholds'); + // An injected classifier is used instead of the global: dependency + // injection, so the helper is testable without roles.js at all. + const injected = MMA.statusEvidence({ + node: { role: 'repeater' }, stats: { lastHeard: ago(1), lastAdvert: ago(1) }, nowMs: NOW, + classify: () => 'silent', + }); + assert.strictEqual(injected.status, 'silent'); + assert.strictEqual(injected.cls, 'status-err'); +}); + +test('the two evidence rows are labelled separately and never merged', () => { + assert.strictEqual(MMA.ADVERT_ROW_LABEL, 'Last own advert'); + assert.strictEqual(MMA.RELAY_ROW_LABEL, 'Last attributed relay activity'); + const ev = MMA.statusEvidence({ node: { role: 'repeater' }, stats: { lastHeard: ago(10), lastAdvert: ago(10) }, nowMs: NOW }); + assert.strictEqual(ev.relayText, MMA.RELAY_NONE_NEWER, 'no newer relay evidence must say so, not show the advert time'); + assert.notStrictEqual(MMA.RELAY_NONE_NEWER, MMA.UNKNOWN_VALUE); + const noAdvert = MMA.statusEvidence({ node: { role: 'repeater' }, stats: { lastHeard: ago(5) }, nowMs: NOW }); + assert.strictEqual(noAdvert.advertText, MMA.ADVERT_NONE); +}); + +console.log('\n=== #304 point 4: SNR attribution ==='); + +test('the direct SNR mean carries no quality verdict and no verdict colour', () => { + const direct = [{ packetCount: 30, avgSnr: 11 }, { packetCount: 12, avgSnr: 9 }]; + for (const avgSnr of [11.2, 4.5, -2.5, -12]) { + const snr = MMA.snr({ avgSnr }, direct); + assert.strictEqual(snr.available, true, String(avgSnr)); + assert.strictEqual(snr.value, avgSnr.toFixed(1) + ' dB'); + assert.ok(!VERDICTS.test(snr.label + snr.value), 'no verdict in ' + snr.label); + assert.strictEqual(snr.color, undefined, 'the helper must not hand the card a verdict colour'); + } + const code = src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); + assert.ok(!VERDICTS.test(code), 'no quality verdict words in the helper code'); +}); + +test('the SNR label states the sample scope it was averaged over', () => { + const snr = MMA.snr({ avgSnr: -3.25 }, [{ packetCount: 30 }, { packetCount: 12 }]); + assert.strictEqual(snr.samples, 42); + assert.strictEqual(snr.label, 'Direct SNR · 42 receptions'); + assert.strictEqual(snr.value, '-3.3 dB'); + assert.match(snr.tip, /direct reception/i); + assert.match(snr.tip, /observer/i, 'the tip must say the value is measured at the observer'); + assert.match(snr.tip, /not .*(radio quality|signal quality|health)/i); +}); + +test('the SNR tip does not claim the count is the SNR sample (F3)', () => { + const snr = MMA.snr({ avgSnr: -3.25 }, [{ packetCount: 30 }]); + assert.ok(!/\bn is\b|\bn=/.test(snr.tip + snr.label), 'no "n" that implies the SNR sample'); + assert.match(snr.tip, /direct receptions?/i); + assert.match(snr.tip, /(only|fewer|not every|some).{0,80}SNR|SNR.{0,80}(only|fewer|not every|some)/i, + 'the tip must say the mean covers only receptions that carry an SNR'); +}); + +test('the traffic tip names only what the payload counts (F4)', () => { + assert.ok(!/confident/i.test(MMA.TRAFFIC_TIP), 'path attribution is resolved, not always confident'); + assert.match(MMA.TRAFFIC_TIP, /resolved path/i); + assert.ok(!/\bbelow\b/i.test(MMA.TRAFFIC_TIP), 'the window row is not below the metric on the card'); +}); + +test('a payload without a boolean complete flag is a lower bound, not an exact total (F5)', () => { + for (const c of [undefined, null, 'true', 1]) { + const a = Object.assign(activity({ counts: { 3: 4 } }), { complete: c }); + const t = MMA.traffic24h(a); + assert.strictEqual(t.exact, false, String(c)); + assert.strictEqual(t.value, '≥ 4', String(c)); + } +}); + +test('an SNR with no direct attribution behind it is not presented at all', () => { + // #76: a mean without a proven direct reception would describe whichever + // node the observer actually heard. + const none = MMA.snr({ avgSnr: 7 }, []); + assert.strictEqual(none.available, false); + assert.strictEqual(none.value, MMA.UNKNOWN_VALUE); + assert.strictEqual(none.label, 'Direct SNR'); + assert.strictEqual(MMA.snr({}, [{ packetCount: 3 }]).available, false); + for (const v of [null, undefined, 'x', NaN, Infinity, {}, true]) { + assert.strictEqual(MMA.snr({ avgSnr: v }, [{ packetCount: 3 }]).available, false, JSON.stringify(v)); + } +}); + +test('a non-numeric packet count never fabricates a sample size', () => { + const snr = MMA.snr({ avgSnr: 1 }, [{ packetCount: '7' }, { packetCount: null }, {}]); + assert.strictEqual(snr.available, true); + assert.strictEqual(snr.samples, 7, 'a numeric string is cast at the boundary'); + const unknown = MMA.snr({ avgSnr: 1 }, [{ packetCount: 'many' }]); + assert.strictEqual(unknown.samples, 0); + assert.strictEqual(unknown.label, 'Direct SNR', 'an unknown sample size must not print n=0'); +}); + +console.log('\n=== #304 point 5: traffic metrics ==='); + +test('the traffic metric is a labelled rolling 24h count, not "packets today"', () => { + const t = MMA.traffic24h(activity({ counts: { 3: 35, 20: 5 } })); + assert.strictEqual(t.available, true); + assert.strictEqual(t.exact, true); + assert.strictEqual(t.count, 40); + assert.strictEqual(t.value, '40'); + assert.strictEqual(t.label, MMA.TRAFFIC_LABEL); + assert.match(MMA.TRAFFIC_LABEL, /24\s?h/); + assert.ok(!/today/i.test(MMA.TRAFFIC_LABEL + MMA.TRAFFIC_TIP), 'the window must not be described as a calendar day'); + assert.match(t.tip, /transmission/i); + assert.ok(!/forward/i.test(t.tip) || /not .*forward/i.test(t.tip), 'node-associated traffic must not be sold as forwarded traffic'); +}); + +test('an incomplete window is a lower bound, never a measured total', () => { + const t = MMA.traffic24h(activity({ complete: false, coverageStart: new Date(NOW - 8 * 3600000).toISOString(), counts: { 20: 5 } })); + assert.strictEqual(t.available, true); + assert.strictEqual(t.exact, false); + assert.strictEqual(t.count, 5); + assert.strictEqual(t.value, '≥ 5'); + assert.match(t.tip, /incomplete|partial/i); +}); + +test('a missing or malformed window is unavailable, not zero', () => { + for (const bad of [undefined, null, {}, { buckets: [] }, { complete: true, buckets: 'x' }, + { complete: true, buckets: Array.from({ length: 23 }, () => ({ start: ago(0), end: ago(0), count: 0 })) }, + { complete: true, buckets: Array.from({ length: 24 }, () => ({ start: ago(0), end: ago(0), count: -1 })) }, + { complete: true, buckets: Array.from({ length: 24 }, () => ({ start: 'x', end: 'y', count: 1 })) }]) { + const t = MMA.traffic24h(bad); + assert.strictEqual(t.available, false, JSON.stringify(bad)); + assert.strictEqual(t.value, MMA.UNKNOWN_VALUE, JSON.stringify(bad)); + } + assert.strictEqual(MMA.buckets(activity()).length, 24); + assert.strictEqual(MMA.buckets({ buckets: [] }), null); +}); + +test('a genuine quiet window with proven coverage is an exact zero', () => { + const t = MMA.traffic24h(activity({})); + assert.strictEqual(t.available, true); + assert.strictEqual(t.exact, true); + assert.strictEqual(t.value, '0'); +}); + +test('the comparison needs two complete, like-for-like windows', () => { + const up = MMA.compare24h(activity({ counts: { 1: 60 }, previous: { count: 50 } })); + assert.strictEqual(up.available, true); + assert.strictEqual(up.pct, 20); + assert.strictEqual(up.text, '+20% vs previous 24 h'); + const down = MMA.compare24h(activity({ counts: { 1: 40 }, previous: { count: 50 } })); + assert.strictEqual(down.pct, -20); + assert.strictEqual(down.text, '-20% vs previous 24 h'); + assert.match(up.tip, /previous 24/i); +}); + +test('an unknown previous window is never treated as zero traffic', () => { + for (const prev of [null, { complete: false, count: 0 }, { complete: false, count: 99 }, { count: 7, complete: undefined }, { count: '7' }, { count: 1.5 }]) { + const c = MMA.compare24h(activity({ counts: { 1: 10 }, previous: prev })); + assert.strictEqual(c.available, false, JSON.stringify(prev)); + assert.strictEqual(c.pct, null, JSON.stringify(prev)); + assert.strictEqual(c.text, MMA.COMPARE_NO_HISTORY, JSON.stringify(prev)); + } + // An incomplete CURRENT window is not comparable either. + const partial = MMA.compare24h(activity({ complete: false, counts: { 1: 10 }, previous: { count: 5 } })); + assert.strictEqual(partial.available, false); + assert.strictEqual(partial.text, MMA.COMPARE_NO_HISTORY); +}); + +test('a zero baseline produces no percentage and never divides by zero', () => { + const c = MMA.compare24h(activity({ counts: { 1: 9 }, previous: { count: 0 } })); + assert.strictEqual(c.available, true); + assert.strictEqual(c.pct, null); + assert.strictEqual(c.delta, 9); + assert.strictEqual(c.text, '+9 vs 0 in the previous 24 h'); + assert.ok(Number.isFinite(c.delta)); + const quiet = MMA.compare24h(activity({ previous: { count: 0 } })); + assert.strictEqual(quiet.pct, null); + assert.strictEqual(quiet.text, '0 vs 0 in the previous 24 h'); +}); + +test('avg hops is relabelled as an observed path length, with its sample scope', () => { + const h = MMA.hops({ avgHops: 10, totalTransmissions: 240 }); + assert.strictEqual(h.available, true); + assert.strictEqual(h.value, '10 hops'); + assert.strictEqual(h.label, MMA.HOPS_LABEL); + assert.ok(!/avg hops/i.test(MMA.HOPS_LABEL)); + assert.match(MMA.HOPS_LABEL, /path/i); + assert.match(h.tip, /distance/i, 'the tip must deny the distance reading'); + assert.match(h.tip, /path/i); + assert.strictEqual(MMA.hops({ avgHops: 1 }).value, '1 hop'); + for (const v of [null, undefined, 'x', NaN, -1, {}]) { + assert.strictEqual(MMA.hops({ avgHops: v }).available, false, JSON.stringify(v)); + assert.strictEqual(MMA.hops({ avgHops: v }).value, MMA.UNKNOWN_VALUE); + } + assert.strictEqual(MMA.hops({ avgHops: '3' }).value, '3 hops', 'cast at the boundary'); +}); + +test('the window text states both bounds and the coverage caveat', () => { + const w = MMA.windowText(activity()); + assert.ok(w.includes(new Date(NOW - 24 * 3600000).toLocaleString()), w); + assert.ok(w.includes(new Date(NOW).toLocaleString()), w); + const partial = MMA.windowText(activity({ complete: false, coverageStart: new Date(NOW - 8 * 3600000).toISOString() })); + assert.match(partial, /incomplete|partial/i); + assert.strictEqual(MMA.windowText(null), MMA.UNKNOWN_VALUE); +}); + +console.log('\n=== #304 home wiring (static) ==='); + +test('index.html loads my-mesh-activity.js before home.js', () => { + const helper = indexHtml.indexOf('my-mesh-activity.js'); + const home = indexHtml.indexOf('home.js?v='); + assert.ok(helper > 0, 'my-mesh-activity.js must be in index.html'); + assert.ok(home > 0 && helper < home, 'the helper must load before home.js'); +}); + +test('home.js has no SNR verdict or verdict colour left', () => { + assert.ok(!/snrLabel|snrColor/.test(homeJs), 'the SNR verdict and its colour must be gone'); + assert.ok(!VERDICTS.test(homeJs.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')), + 'no quality verdict words left in home.js code'); +}); + +test('home.js no longer shows an unexplained "Packets today" or "Avg hops"', () => { + assert.ok(!/Packets\s+[Tt]oday/.test(homeJs), 'the calendar-day counter must be gone'); + assert.ok(!/>Avg [Hh]ops { + const calls = homeJs.match(/\bapi\(/g) || []; + assert.strictEqual(calls.length, 5, 'exactly the pre-#304 call sites: node search, observers, card health, stats, panel health'); + assert.ok(!/api\([^)]*activity|api\([^)]*previous/.test(homeJs), 'no separate activity request'); +}); + +test('the evidence disclosure is keyboard and touch reachable, and does not navigate', () => { + assert.ok(/
{ + for (const sel of ['.mnc-evidence', '.mnc-evidence-row', '.mnc-metric-sub', '.my-node-card.healthy.evidence-relay']) { + assert.ok(homeCss.includes(sel), 'home.css must style ' + sel); + } + const from = homeCss.indexOf('.mnc-evidence'); + const block = homeCss.slice(from, homeCss.indexOf('.mnc-spark {', from)); + const hex = block.replace(/\/\*[\s\S]*?\*\//g, '').match(/#[0-9a-fA-F]{3,8}\b/g); + assert.strictEqual(hex, null, 'no hardcoded colors in the #304 block, got ' + JSON.stringify(hex)); + assert.match(block, /evidence-relay[\s\S]{0,160}var\(--status-yellow\)/, 'an attributed status must not keep the green accent'); +}); + +console.log('\n' + passed + ' passed, ' + failed + ' failed'); +process.exit(failed ? 1 : 0);