From 40f7c0c96384fcf81bd28b9205991078ecd57c95 Mon Sep 17 00:00:00 2001 From: nullrouten <201262769+nullrouten0@users.noreply.github.com> Date: Thu, 15 Oct 2026 05:04:14 +0000 Subject: [PATCH 1/3] fix(health): judge favorites and home status cards by the node's role renderFavDropdown (app.js) and the two health cards in home.js ("My Nodes" and the hero search result) compared every node against the node thresholds (1 h degraded, 24 h silent) and never looked at its role. A starred repeater or room was therefore held to limits meant for companions: last heard 5 h ago it showed "degraded" and at 30 h "silent", while the My Repeaters tab (#1761) and getNodeStatus in roles.js, which both use getHealthThresholds(role), call those healthy and degraded (infra limits: 24 h / 72 h). These three call sites now use getHealthThresholds(role) as well. The search card's "Not heard in 24+ hours" fallback text was only true for nodes, so it now reads "Not heard recently". The new test fails on the old code (5 of 7 assertions) and passes now. Co-Authored-By: Claude Sonnet 5.5 --- public/app.js | 5 +- public/home.js | 10 ++- test-all.sh | 1 + .../unit/test-health-card-role-thresholds.js | 75 +++++++++++++++++++ 4 files changed, 85 insertions(+), 6 deletions(-) create mode 100644 tests/unit/test-health-card-role-thresholds.js diff --git a/public/app.js b/public/app.js index 3e0478797..5bd00cdbc 100644 --- a/public/app.js +++ b/public/app.js @@ -1748,9 +1748,10 @@ window.addEventListener('DOMContentLoaded', () => { const items = await Promise.all(favs.map(async (pk) => { try { const h = await api('/nodes/' + pk + '/health', { ttl: CLIENT_TTL.nodeHealth }); + const th = getHealthThresholds(h.node && h.node.role); const age = h.stats.lastHeard ? Date.now() - new Date(h.stats.lastHeard).getTime() : null; - const statusCls = age === null ? 'status-err' : age < HEALTH_THRESHOLDS.nodeDegradedMs ? 'status-ok' : age < HEALTH_THRESHOLDS.nodeSilentMs ? 'status-warn' : 'status-err'; - const statusLabel = age === null ? 'unknown' : age < HEALTH_THRESHOLDS.nodeDegradedMs ? 'healthy' : age < HEALTH_THRESHOLDS.nodeSilentMs ? 'degraded' : 'silent'; + const statusCls = age === null ? 'status-err' : age < th.degradedMs ? 'status-ok' : age < th.silentMs ? 'status-warn' : 'status-err'; + const statusLabel = age === null ? 'unknown' : age < th.degradedMs ? 'healthy' : age < th.silentMs ? 'degraded' : 'silent'; return '' + '' + '' + escapeHtml(h.node.name || truncate(pk, 12)) + '' diff --git a/public/home.js b/public/home.js index f20dad502..07aa94f2e 100644 --- a/public/home.js +++ b/public/home.js @@ -295,8 +295,9 @@ const stats = h.stats || {}; const obs = h.observers || []; + const th = getHealthThresholds(node.role); const age = stats.lastHeard ? Date.now() - new Date(stats.lastHeard).getTime() : null; - const status = age === null ? 'silent' : age < HEALTH_THRESHOLDS.nodeDegradedMs ? 'healthy' : age < HEALTH_THRESHOLDS.nodeSilentMs ? 'degraded' : 'silent'; + const status = age === null ? 'silent' : age < th.degradedMs ? 'healthy' : age < th.silentMs ? 'degraded' : 'silent'; const statusCls = status === 'healthy' ? 'status-ok' : status === 'degraded' ? 'status-warn' : 'status-err'; const statusDot = ''; const statusText = status === 'healthy' ? 'Active' : status === 'degraded' ? 'Degraded' : 'Silent'; @@ -453,12 +454,13 @@ const observers = h.observers || []; const claimed = isMyNode(pubkey); - let status = 'silent', color = 'red', statusMsg = 'Not heard in 24+ hours'; + const th = getHealthThresholds(node.role); + let status = 'silent', color = 'red', statusMsg = 'Not heard recently'; if (stats.lastHeard) { const ageMs = Date.now() - new Date(stats.lastHeard).getTime(); const ago = timeAgo(stats.lastHeard); - if (ageMs < HEALTH_THRESHOLDS.nodeDegradedMs) { status = 'healthy'; color = 'green'; statusMsg = `Last heard ${ago}`; } - else if (ageMs < HEALTH_THRESHOLDS.nodeSilentMs) { status = 'degraded'; color = 'yellow'; statusMsg = `Last heard ${ago}`; } + if (ageMs < th.degradedMs) { status = 'healthy'; color = 'green'; statusMsg = `Last heard ${ago}`; } + else if (ageMs < th.silentMs) { status = 'degraded'; color = 'yellow'; statusMsg = `Last heard ${ago}`; } else { statusMsg = `Last heard ${ago}`; } } diff --git a/test-all.sh b/test-all.sh index 5efc9a9af..9ce10aa01 100755 --- a/test-all.sh +++ b/test-all.sh @@ -56,6 +56,7 @@ node tests/unit/test-fluid-scaffolding.js node tests/unit/test-frontend-helpers.js node tests/unit/test-geofilter-draft.js node tests/unit/test-hash-color.js +node tests/unit/test-health-card-role-thresholds.js node tests/unit/test-hop-resolver-affinity.js node tests/unit/test-issue-1065-gesture-hints-gates.js node tests/unit/test-issue-1107-live-layout.js diff --git a/tests/unit/test-health-card-role-thresholds.js b/tests/unit/test-health-card-role-thresholds.js new file mode 100644 index 000000000..8e87a3023 --- /dev/null +++ b/tests/unit/test-health-card-role-thresholds.js @@ -0,0 +1,75 @@ +/* Favorites dropdown and home health cards must judge a node by its role's + * thresholds (repeater/room: infra, others: node), like the My Repeaters tab + * and getNodeStatus already do. + * + * Before the fix these three sites compared every node against + * HEALTH_THRESHOLDS.nodeDegradedMs / nodeSilentMs (1 h / 24 h), so a starred + * repeater last heard 5 h ago showed "degraded" and one heard 30 h ago showed + * "silent", although the infra thresholds (24 h / 72 h) call them healthy and + * degraded. + */ +'use strict'; +const vm = require('vm'); +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); + +const PUBLIC = path.resolve(__dirname, '..', '..', 'public'); +const read = (f) => fs.readFileSync(path.join(PUBLIC, f), 'utf8'); + +let passed = 0, failed = 0; +function test(name, fn) { + try { fn(); passed++; console.log(` ✅ ${name}`); } + catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}`); } +} + +// The thresholds themselves: infra roles are held to looser limits than nodes. +const ctx = { window: {}, console, Date, Infinity, document: { readyState: 'complete', createElement: () => ({ id: '' }), head: { appendChild: () => {} }, getElementById: () => null, addEventListener: () => {} }, fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }) }; +vm.createContext(ctx); +vm.runInContext(read('roles.js'), ctx); +// roles.js reads HEALTH_THRESHOLDS as a bare global; window.X does not create one here. +for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; +const { getHealthThresholds } = ctx.window; +const H = 3600 * 1000; + +test('a repeater 30 h quiet is inside the infra silent window, a companion is not', () => { + assert.ok(30 * H < getHealthThresholds('repeater').silentMs); + assert.ok(30 * H >= getHealthThresholds('companion').silentMs); +}); +test('room is held to the infra thresholds too', () => { + assert.strictEqual(getHealthThresholds('room').silentMs, getHealthThresholds('repeater').silentMs); +}); + +// The three call sites must go through getHealthThresholds(role). +function region(src, startMarker, endMarker) { + const a = src.indexOf(startMarker); + assert.ok(a >= 0, 'marker not found: ' + startMarker); + const b = src.indexOf(endMarker, a + startMarker.length); + return src.slice(a, b < 0 ? undefined : b); +} +const NODE_ONLY = /HEALTH_THRESHOLDS\.node(Degraded|Silent)Ms/; + +test('app.js favorites dropdown uses role thresholds', () => { + const body = region(read('app.js'), 'async function renderFavDropdown', 'fav-dd-item'); + assert.ok(/getHealthThresholds\(/.test(body), 'renderFavDropdown does not call getHealthThresholds'); + assert.ok(!NODE_ONLY.test(body), 'renderFavDropdown still uses node-only thresholds'); +}); +test('home.js "My Nodes" card uses role thresholds', () => { + const body = region(read('home.js'), 'const stats = h.stats || {};', 'statusText'); + assert.ok(/getHealthThresholds\(node\.role\)/.test(body), 'My Nodes card does not call getHealthThresholds(node.role)'); + assert.ok(!NODE_ONLY.test(body), 'My Nodes card still uses node-only thresholds'); +}); +test('home.js search-result card uses role thresholds', () => { + const body = region(read('home.js'), 'const claimed = isMyNode(pubkey);', 'statusMsg = `Last heard'); + assert.ok(/getHealthThresholds\(node\.role\)/.test(body), 'search card does not call getHealthThresholds(node.role)'); + assert.ok(!NODE_ONLY.test(body), 'search card still uses node-only thresholds'); +}); +test('no node-only threshold comparison is left in home.js', () => { + assert.ok(!NODE_ONLY.test(read('home.js'))); +}); +test('search card no longer hardcodes "24+ hours" for every role', () => { + assert.ok(!/Not heard in 24\+ hours/.test(read('home.js'))); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +process.exit(failed ? 1 : 0); From 0f2376dfc9b5146caf69d7bd1628f88b6982f7cf Mon Sep 17 00:00:00 2001 From: nullrouten <201262769+nullrouten0@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:05:33 +0000 Subject: [PATCH 2/3] fix(health): one getHealthStatus helper, tested by behavior Review feedback on #2215: the tests checked source text, so a swapped threshold, a reversed comparison or the wrong field at a call site still passed. getHealthStatus(role, heard, now) in roles.js returns healthy / degraded / silent by the role's thresholds, or null when there is no usable timestamp. The six places that compared an age against degradedMs / silentMs now call it: the favorites dropdown, both home cards, the analytics network status, My Repeaters and the live node panel. Each keeps its own timestamp and labels. The favorites dropdown and both home cards now fall back to the node's last_seen when the packet store has no packet for it, like My Repeaters (last_heard || last_seen) and the node page (nodes.js). Before, with a store window under 72 h a repeater could be silent on the cards and degraded on My Repeaters. The "ago" text uses the same timestamp, and the search card says "No packets in the current window" when there is none. Tests: - test-health-status.js runs roles.js in vm: every role at ages either side of 1 h, 24 h and 72 h, ISO and epoch input, missing and invalid timestamps, and a healthThresholds override through /api/config/client. One source guard: no threshold comparison outside roles.js. - test-my-repeaters-dashboard.js uses the real helper instead of a stub, and checks that a repeater heard 30 h ago is Degraded. - New test-health-status-e2e.js mocks the health API for six nodes and checks all five views in Chromium. It fails on master and on the previous commit of this PR. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/deploy.yml | 1 + public/analytics.js | 22 +- public/app.js | 11 +- public/home.js | 24 +-- public/live.js | 7 +- public/roles.js | 14 ++ scripts/non-unit-tests.json | 1 + test-all.sh | 2 +- tests/e2e/test-health-status-e2e.js | 201 ++++++++++++++++++ .../unit/test-health-card-role-thresholds.js | 75 ------- tests/unit/test-health-status.js | 144 +++++++++++++ tests/unit/test-my-repeaters-dashboard.js | 28 ++- 12 files changed, 412 insertions(+), 118 deletions(-) create mode 100644 tests/e2e/test-health-status-e2e.js delete mode 100644 tests/unit/test-health-card-role-thresholds.js create mode 100644 tests/unit/test-health-status.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5796328ad..e67377e27 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -584,6 +584,7 @@ jobs: suite 3 test-observer-iata-1188-e2e.js BASE_URL=$B suite 2 test-issue-1639-observers-sort-e2e.js BASE_URL=$B suite 2 test-issue-661-airtime-e2e.js BASE_URL=$B + suite 2 test-health-status-e2e.js BASE_URL=$B suite 2 test-issue-1758-ng-filter-rerenders-e2e.js CHROMIUM_REQUIRE=1 BASE_URL=$B suite 2 test-nav-fluid-1055-e2e.js CHROMIUM_REQUIRE=1 BASE_URL=$B suite 3 test-nav-priority-1102-e2e.js CHROMIUM_REQUIRE=1 BASE_URL=$B diff --git a/public/analytics.js b/public/analytics.js index 1bfb12b89..7dd24760f 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -2453,18 +2453,13 @@ const byObservers = [...enriched].sort((a, b) => (b.health.observers?.length || 0) - (a.health.observers?.length || 0)); const byRecent = [...enriched].filter(n => n.health.stats.lastHeard).sort((a, b) => new Date(b.health.stats.lastHeard) - new Date(a.health.stats.lastHeard)); - // Compute network status client-side from loaded nodes using shared getHealthThresholds() + // Compute network status client-side from loaded nodes using shared getHealthStatus() const now = Date.now(); let active = 0, degraded = 0, silent = 0; nodes.forEach(function(n) { - const role = n.role || 'unknown'; - const th = getHealthThresholds(role); - const lastMs = n.last_heard ? new Date(n.last_heard).getTime() - : n.last_seen ? new Date(n.last_seen).getTime() - : 0; - const age = lastMs ? (now - lastMs) : Infinity; - if (age < th.degradedMs) active++; - else if (age < th.silentMs) degraded++; + const s = getHealthStatus(n.role, n.last_heard || n.last_seen, now); + if (s === 'healthy') active++; + else if (s === 'degraded') degraded++; else silent++; }); const totalNodes = nodesResp.total || nodes.length; @@ -2655,14 +2650,9 @@ (Array.isArray(clockData) ? clockData : []).forEach(c => { if (c && c.pubkey) clockByPk[c.pubkey] = c; }); const now = Date.now(); - function ageOf(n) { - const ms = n.last_heard ? new Date(n.last_heard).getTime() : n.last_seen ? new Date(n.last_seen).getTime() : 0; - return ms ? now - ms : Infinity; - } function statusOf(n) { - const th = (typeof getHealthThresholds === 'function') ? getHealthThresholds(n.role) : { degradedMs: 3600000, silentMs: 86400000 }; - const age = ageOf(n); - return age < th.degradedMs ? 'active' : age < th.silentMs ? 'degraded' : 'silent'; + const s = getHealthStatus(n.role, n.last_heard || n.last_seen, now); + return s === 'healthy' ? 'active' : (s || 'silent'); } const pct = v => (v != null ? (v * 100).toFixed(1) + '%' : '—'); // #1927: no fallback to usefulness_score. They are different metrics: diff --git a/public/app.js b/public/app.js index 5bd00cdbc..466d6d035 100644 --- a/public/app.js +++ b/public/app.js @@ -1748,14 +1748,15 @@ window.addEventListener('DOMContentLoaded', () => { const items = await Promise.all(favs.map(async (pk) => { try { const h = await api('/nodes/' + pk + '/health', { ttl: CLIENT_TTL.nodeHealth }); - const th = getHealthThresholds(h.node && h.node.role); - const age = h.stats.lastHeard ? Date.now() - new Date(h.stats.lastHeard).getTime() : null; - const statusCls = age === null ? 'status-err' : age < th.degradedMs ? 'status-ok' : age < th.silentMs ? 'status-warn' : 'status-err'; - const statusLabel = age === null ? 'unknown' : age < th.degradedMs ? 'healthy' : age < th.silentMs ? 'degraded' : 'silent'; + // Packet-store time first, then the DB's last_seen, as My Repeaters does. + const heard = h.stats.lastHeard || (h.node && h.node.last_seen); + const status = getHealthStatus(h.node && h.node.role, heard); + const statusCls = status === 'healthy' ? 'status-ok' : status === 'degraded' ? 'status-warn' : 'status-err'; + const statusLabel = status || 'unknown'; return '' + '' + '' + escapeHtml(h.node.name || truncate(pk, 12)) + '' - + '' + (h.stats.lastHeard ? timeAgo(h.stats.lastHeard) : 'never') + '' + + '' + (heard ? timeAgo(heard) : 'never') + '' + favStar(pk, 'fav-dd-star') + ''; } catch { diff --git a/public/home.js b/public/home.js index 07aa94f2e..7de673e70 100644 --- a/public/home.js +++ b/public/home.js @@ -295,9 +295,9 @@ const stats = h.stats || {}; const obs = h.observers || []; - const th = getHealthThresholds(node.role); - const age = stats.lastHeard ? Date.now() - new Date(stats.lastHeard).getTime() : null; - const status = age === null ? 'silent' : age < th.degradedMs ? 'healthy' : age < th.silentMs ? 'degraded' : 'silent'; + // Packet-store time first, then the DB's last_seen, as My Repeaters does. + const heard = stats.lastHeard || node.last_seen; + const status = getHealthStatus(node.role, heard) || 'silent'; const statusCls = status === 'healthy' ? 'status-ok' : status === 'degraded' ? 'status-warn' : 'status-err'; const statusDot = ''; const statusText = status === 'healthy' ? 'Active' : status === 'degraded' ? 'Degraded' : 'Silent'; @@ -318,7 +318,7 @@
${node.role || '?'}
-
${statusText}${stats.lastHeard ? ' · ' + timeAgo(stats.lastHeard) : ''}
+
${statusText}${heard ? ' · ' + timeAgo(heard) : ''}
${stats.packetsToday ?? 0}
@@ -454,15 +454,11 @@ const observers = h.observers || []; const claimed = isMyNode(pubkey); - const th = getHealthThresholds(node.role); - let status = 'silent', color = 'red', statusMsg = 'Not heard recently'; - if (stats.lastHeard) { - const ageMs = Date.now() - new Date(stats.lastHeard).getTime(); - const ago = timeAgo(stats.lastHeard); - if (ageMs < th.degradedMs) { status = 'healthy'; color = 'green'; statusMsg = `Last heard ${ago}`; } - else if (ageMs < th.silentMs) { status = 'degraded'; color = 'yellow'; statusMsg = `Last heard ${ago}`; } - else { statusMsg = `Last heard ${ago}`; } - } + // Packet-store time first, then the DB's last_seen, as My Repeaters does. + const heard = stats.lastHeard || node.last_seen; + const status = getHealthStatus(node.role, heard) || 'silent'; + const color = status === 'healthy' ? 'green' : status === 'degraded' ? 'yellow' : 'red'; + const statusMsg = heard ? `Last heard ${timeAgo(heard)}` : 'No packets in the current window'; const snrVal = stats.avgSnr; const snrLabel = snrVal != null ? (snrVal > 10 ? 'Excellent' : snrVal > 0 ? 'Good' : snrVal > -5 ? 'Marginal' : 'Poor') : ''; @@ -477,7 +473,7 @@
${stats.packetsToday ?? '—'}
Packets Today
${observers.length}
Observers
-
${stats.lastHeard ? timeAgo(stats.lastHeard) : '—'}
Last seen
+
${heard ? timeAgo(heard) : '—'}
Last seen
${snrVal != null ? Number(snrVal).toFixed(1) + ' dB' : '—'}
Avg SNR${snrLabel ? ' · ' + snrLabel : ''}
${stats.avgHops != null ? stats.avgHops.toFixed(1) : '—'}
Avg Hops
diff --git a/public/live.js b/public/live.js index abee9316c..8a35e393a 100644 --- a/public/live.js +++ b/public/live.js @@ -2610,10 +2610,9 @@ const roleLabel = (ROLE_LABELS[n.role] || n.role || 'unknown').replace(/s$/, ''); const hasLoc = n.lat != null && n.lon != null; const lastSeen = formatLiveTimestampHtml(n.last_seen); - const thresholds = window.getHealthThresholds ? getHealthThresholds(n.role) : { degradedMs: 3600000, silentMs: 86400000 }; - const ageMs = n.last_seen ? Date.now() - new Date(n.last_seen).getTime() : Infinity; - const statusDot = ageMs < thresholds.degradedMs ? 'health-green' : ageMs < thresholds.silentMs ? 'health-yellow' : 'health-red'; - const statusLabel = ageMs < thresholds.degradedMs ? 'Online' : ageMs < thresholds.silentMs ? 'Degraded' : 'Offline'; + const health = getHealthStatus(n.role, n.last_seen); + const statusDot = health === 'healthy' ? 'health-green' : health === 'degraded' ? 'health-yellow' : 'health-red'; + const statusLabel = health === 'healthy' ? 'Online' : health === 'degraded' ? 'Degraded' : 'Offline'; let html = `
diff --git a/public/roles.js b/public/roles.js index 53373e87b..2c0476acb 100644 --- a/public/roles.js +++ b/public/roles.js @@ -444,6 +444,20 @@ }; }; + // Three-state health from when a node was last heard: 'healthy', 'degraded' + // or 'silent' by its role's thresholds, or null when `heard` is missing or + // not a date. The caller picks the timestamp (ISO string or epoch ms); this + // only applies the cut-offs, so every view that shows the three states uses + // the same ones. `now` defaults to Date.now(). + window.getHealthStatus = function (role, heard, now) { + if (heard == null || heard === '') return null; + var heardMs = typeof heard === 'number' ? heard : new Date(heard).getTime(); + if (isNaN(heardMs)) return null; + var age = (now == null ? Date.now() : now) - heardMs; + var th = window.getHealthThresholds(role); + return age < th.degradedMs ? 'healthy' : age < th.silentMs ? 'degraded' : 'silent'; + }; + // Simplified two-state helper: returns 'active' or 'stale'. // Accepts either a full node object (preferred) or legacy (role, lastSeenMs). // diff --git a/scripts/non-unit-tests.json b/scripts/non-unit-tests.json index 2388c1a55..5cbb3cc1a 100644 --- a/scripts/non-unit-tests.json +++ b/scripts/non-unit-tests.json @@ -33,6 +33,7 @@ "tests/e2e/test-gesture-hints-1065-e2e.js", "tests/e2e/test-gestures-1062-e2e.js", "tests/e2e/test-gestures-1185-scroll-discriminator-e2e.js", + "tests/e2e/test-health-status-e2e.js", "tests/e2e/test-home-coverage-e2e.js", "tests/e2e/test-issue-1109-hamburger-dropdown-visible-e2e.js", "tests/e2e/test-issue-1122-packets-filter-ux-e2e.js", diff --git a/test-all.sh b/test-all.sh index 9ce10aa01..38959901a 100755 --- a/test-all.sh +++ b/test-all.sh @@ -56,7 +56,7 @@ node tests/unit/test-fluid-scaffolding.js node tests/unit/test-frontend-helpers.js node tests/unit/test-geofilter-draft.js node tests/unit/test-hash-color.js -node tests/unit/test-health-card-role-thresholds.js +node tests/unit/test-health-status.js node tests/unit/test-hop-resolver-affinity.js node tests/unit/test-issue-1065-gesture-hints-gates.js node tests/unit/test-issue-1107-live-layout.js diff --git a/tests/e2e/test-health-status-e2e.js b/tests/e2e/test-health-status-e2e.js new file mode 100644 index 000000000..8b060e087 --- /dev/null +++ b/tests/e2e/test-health-status-e2e.js @@ -0,0 +1,201 @@ +/** + * E2E test: the favorites dropdown, the "My Nodes" cards and the home health + * card judge a node by its role (repeater and room: degraded after 24 h, + * silent after 72 h; other roles: 1 h and 24 h), the same as My Repeaters. + * + * /api/nodes//health is intercepted with page.route for five synthetic + * nodes, so the ages are exact regardless of the fixture's dates. One repeater + * has no packet in the store window, only the DB's last_seen, which these + * views fall back to like My Repeaters does. + * + * Usage: BASE_URL=http://localhost:13581 node test-health-status-e2e.js + */ +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:3000'; +const H = 3600 * 1000; + +async function test(name, fn) { + try { + await fn(); + console.log(` ✅ ${name}`); + } catch (err) { + console.log(` ❌ ${name}: ${err.message}`); + process.exit(1); + } +} + +function assert(cond, msg) { + if (!cond) throw new Error(msg || 'Assertion failed'); +} + +const pk = (c) => c.repeat(64); +const ago = (ms) => new Date(Date.now() - ms).toISOString(); +// want: the status each view must show. On the role-blind code the two +// repeaters at 5 h and 30 h showed degraded and silent. +const NODES = [ + { pk: pk('a'), name: 'E2E Rptr 5h', role: 'repeater', lastHeard: ago(5 * H), want: 'healthy' }, + { pk: pk('b'), name: 'E2E Rptr 30h', role: 'repeater', lastHeard: ago(30 * H), want: 'degraded' }, + { pk: pk('c'), name: 'E2E Room 80h', role: 'room', lastHeard: ago(80 * H), want: 'silent' }, + { pk: pk('d'), name: 'E2E Comp 30h', role: 'companion', lastHeard: ago(30 * H), want: 'silent' }, + { pk: pk('e'), name: 'E2E Rptr DB 30h', role: 'repeater', lastHeard: null, lastSeen: ago(30 * H), want: 'degraded' }, + // Never heard at all: silent on the cards, "unknown" in the dropdown. + { pk: pk('f'), name: 'E2E Sensor never', role: 'sensor', lastHeard: null, want: 'silent', favTitle: 'unknown' }, +]; +const byPk = Object.fromEntries(NODES.map((n) => [n.pk, n])); + +function healthFor(n) { + return { + node: { public_key: n.pk, name: n.name, role: n.role, last_seen: n.lastSeen || n.lastHeard }, + stats: { lastHeard: n.lastHeard, packetsToday: 0, avgSnr: null, avgHops: null }, + observers: [], + recentPackets: [], + }; +} + +async function run() { + const browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH || undefined, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'] + }); + const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } }); + await ctx.addInitScript((nodes) => { + localStorage.setItem('meshcore-user-level', 'experienced'); + localStorage.setItem('meshcore-my-nodes', JSON.stringify(nodes.map((n) => ({ pubkey: n.pk, name: n.name, addedAt: 0 })))); + localStorage.setItem('meshcore-favorites', JSON.stringify(nodes.map((n) => n.pk))); + }, NODES); + const page = await ctx.newPage(); + page.setDefaultTimeout(15000); + const pageErrors = []; + page.on('pageerror', (e) => pageErrors.push(e.message)); + + const served = new Set(); + await page.route(/\/api\/nodes\/([0-9a-f]{64})\/health/, (route) => { + const n = byPk[route.request().url().match(/\/api\/nodes\/([0-9a-f]{64})\/health/)[1]]; + if (!n) return route.continue(); + served.add(n.pk); + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(healthFor(n)) }); + }); + + console.log(`\nRunning role-aware health status E2E tests against ${BASE}\n`); + + await page.goto(`${BASE}/#/home`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#myNodesGrid .my-node-card'); + + await test('every mocked node was fetched from /api/nodes//health', async () => { + await page.waitForFunction((n) => document.querySelectorAll('#myNodesGrid .my-node-card').length === n, NODES.length); + for (const n of NODES) assert(served.has(n.pk), `health for ${n.name} was not requested`); + }); + + const TEXT = { healthy: 'Active', degraded: 'Degraded', silent: 'Silent' }; + await test('"My Nodes" cards show each node\'s status by its role', async () => { + for (const n of NODES) { + const card = page.locator(`#myNodesGrid .my-node-card[data-key="${n.pk}"]`); + const cls = await card.getAttribute('class'); + const text = (await card.locator('.mnc-status-text').innerText()).trim(); + assert(cls.split(/\s+/).includes(n.want), `${n.name}: card class "${cls}", want ${n.want}`); + assert(text.startsWith(TEXT[n.want]), `${n.name}: status text "${text}", want ${TEXT[n.want]}`); + } + }); + + await test('the DB-only repeater shows how long ago it was heard, not nothing', async () => { + const text = await page.locator(`#myNodesGrid .my-node-card[data-key="${pk('e')}"] .mnc-status-text`).innerText(); + assert(/·/.test(text), `status text "${text}" should include the time since last_seen`); + }); + + const BANNER = { healthy: 'green', degraded: 'yellow', silent: 'red' }; + await test('the home health card agrees with the "My Nodes" card', async () => { + for (const n of NODES) { + await page.locator(`#myNodesGrid .my-node-card[data-key="${n.pk}"] .mnc-name`).click(); + const banner = page.locator('#homeHealth .health-banner'); + await page.waitForFunction((name) => { + const b = document.querySelector('#homeHealth .health-banner'); + return b && b.textContent.includes(name); + }, n.name); + const cls = await banner.getAttribute('class'); + const label = await banner.locator('[aria-label]').first().getAttribute('aria-label'); + assert(cls.split(/\s+/).includes(BANNER[n.want]), `${n.name}: banner class "${cls}", want ${BANNER[n.want]}`); + assert(label === n.want, `${n.name}: banner aria-label "${label}", want ${n.want}`); + const msg = (n.lastHeard || n.lastSeen) ? /Last heard/ : /No packets in the current window/; + assert(msg.test(await banner.innerText()), `${n.name}: banner text should match ${msg}`); + } + }); + + const DOT = { healthy: 'status-ok', degraded: 'status-warn', silent: 'status-err' }; + await test('the favorites dropdown agrees as well', async () => { + await page.click('#favToggle'); + await page.waitForSelector(`#favDropdown .fav-dd-item[data-key="${pk('e')}"] .fav-dd-status`); + for (const n of NODES) { + const dot = page.locator(`#favDropdown .fav-dd-item[data-key="${n.pk}"] .fav-dd-status`); + const cls = await dot.getAttribute('class'); + const title = await dot.getAttribute('title'); + assert(cls.split(/\s+/).includes(DOT[n.want]), `${n.name}: dot class "${cls}", want ${DOT[n.want]}`); + assert(title === (n.favTitle || n.want), `${n.name}: dot title "${title}", want ${n.favTitle || n.want}`); + } + const meta = await page.locator(`#favDropdown .fav-dd-item[data-key="${pk('e')}"] .fav-dd-meta`).innerText(); + assert(meta.trim() !== 'never', 'DB-only repeater should show the time since last_seen, not "never"'); + }); + + await test('no page errors', async () => { + assert(pageErrors.length === 0, 'page errors: ' + pageErrors.join(' | ')); + }); + await ctx.close(); + + // The analytics network status and the live node panel use the same helper + // with their own timestamps (last_heard || last_seen, and last_seen). + const listNode = (n) => ({ public_key: n.pk, name: n.name, role: n.role, last_heard: n.lastHeard, last_seen: n.lastSeen || n.lastHeard }); + const nodeListUrl = /\/api\/nodes(?:\?.*)?$/; + + await test('analytics network status counts each node by its role', async () => { + const actx = await browser.newContext({ viewport: { width: 1400, height: 900 } }); + const p = await actx.newPage(); + const errs = []; + p.on('pageerror', (e) => errs.push(e.message)); + await p.route(nodeListUrl, (route) => route.fulfill({ json: { nodes: NODES.map(listNode), total: NODES.length, counts: {} } })); + await p.route(/\/api\/nodes\/bulk-health/, (route) => route.fulfill({ json: [] })); + await p.goto(`${BASE}/#/analytics?tab=nodes`, { waitUntil: 'domcontentloaded' }); + const cards = p.locator('.analytics-section', { hasText: 'Network Status' }).first().locator('.analytics-stat-card'); + await cards.first().waitFor(); + const count = async (i) => Number((await cards.nth(i).locator('div').first().innerText()).trim()); + const got = [await count(0), await count(1), await count(2)]; + // active: rptr 5h; degraded: rptr 30h, rptr DB 30h; silent: room 80h, comp 30h, sensor never + assert(JSON.stringify(got) === '[1,2,3]', `active/degraded/silent = ${JSON.stringify(got)}, want [1,2,3]`); + assert(errs.length === 0, 'page errors: ' + errs.join(' | ')); + await actx.close(); + }); + + const LIVE = { healthy: ['health-green', 'Online'], degraded: ['health-yellow', 'Degraded'], silent: ['health-red', 'Offline'] }; + await test('live node panel shows each node\'s status by its role', async () => { + for (const n of [byPk[pk('a')], byPk[pk('e')], byPk[pk('d')]]) { + const lctx = await browser.newContext({ viewport: { width: 1400, height: 900 } }); + const p = await lctx.newPage(); + const node = listNode(n); + await p.route(nodeListUrl, (route) => route.fulfill({ json: { nodes: [{ ...node, lat: 1, lon: 1 }], total: 1 } })); + await p.route('**/api/nodes/' + n.pk, (route) => route.fulfill({ json: { node, recentAdverts: [] } })); + await p.route('**/api/nodes/' + n.pk + '/health', (route) => route.fulfill({ json: healthFor(n) })); + // live.js re-renders the panel on the first 'theme-refresh'; assert after it. + await p.addInitScript(() => { + window.addEventListener('theme-refresh', () => { window.__themeReady = true; }, { once: true }); + }); + await p.goto(`${BASE}/#/live?lat=1&lon=1&zoom=10`, { waitUntil: 'domcontentloaded' }); + await p.locator('#liveMap .live-node-marker').first().click(); + await p.waitForFunction(() => window.__themeReady === true, null, { timeout: 8000 }); + await p.waitForFunction((name) => { + const c = document.getElementById('nodeDetailContent'); + return c && c.textContent.includes(name) && /health-(green|yellow|red)/.test(c.innerHTML); + }, n.name); + const [dotCls, label] = LIVE[n.want]; + const dot = await p.locator('#nodeDetailContent [class^="health-"]').first().getAttribute('class'); + assert(dot === dotCls, `${n.name}: dot "${dot}", want ${dotCls}`); + const labels = await p.locator('#nodeDetailContent span').filter({ hasText: new RegExp(`^${label}$`) }).count(); + assert(labels >= 1, `${n.name}: no "${label}" label in the live panel`); + await lctx.close(); + } + }); + + await browser.close(); + console.log('\nAll role-aware health status E2E tests passed.'); +} + +run().catch((e) => { console.error(e); process.exit(1); }); diff --git a/tests/unit/test-health-card-role-thresholds.js b/tests/unit/test-health-card-role-thresholds.js deleted file mode 100644 index 8e87a3023..000000000 --- a/tests/unit/test-health-card-role-thresholds.js +++ /dev/null @@ -1,75 +0,0 @@ -/* Favorites dropdown and home health cards must judge a node by its role's - * thresholds (repeater/room: infra, others: node), like the My Repeaters tab - * and getNodeStatus already do. - * - * Before the fix these three sites compared every node against - * HEALTH_THRESHOLDS.nodeDegradedMs / nodeSilentMs (1 h / 24 h), so a starred - * repeater last heard 5 h ago showed "degraded" and one heard 30 h ago showed - * "silent", although the infra thresholds (24 h / 72 h) call them healthy and - * degraded. - */ -'use strict'; -const vm = require('vm'); -const fs = require('fs'); -const path = require('path'); -const assert = require('assert'); - -const PUBLIC = path.resolve(__dirname, '..', '..', 'public'); -const read = (f) => fs.readFileSync(path.join(PUBLIC, f), 'utf8'); - -let passed = 0, failed = 0; -function test(name, fn) { - try { fn(); passed++; console.log(` ✅ ${name}`); } - catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}`); } -} - -// The thresholds themselves: infra roles are held to looser limits than nodes. -const ctx = { window: {}, console, Date, Infinity, document: { readyState: 'complete', createElement: () => ({ id: '' }), head: { appendChild: () => {} }, getElementById: () => null, addEventListener: () => {} }, fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }) }; -vm.createContext(ctx); -vm.runInContext(read('roles.js'), ctx); -// roles.js reads HEALTH_THRESHOLDS as a bare global; window.X does not create one here. -for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; -const { getHealthThresholds } = ctx.window; -const H = 3600 * 1000; - -test('a repeater 30 h quiet is inside the infra silent window, a companion is not', () => { - assert.ok(30 * H < getHealthThresholds('repeater').silentMs); - assert.ok(30 * H >= getHealthThresholds('companion').silentMs); -}); -test('room is held to the infra thresholds too', () => { - assert.strictEqual(getHealthThresholds('room').silentMs, getHealthThresholds('repeater').silentMs); -}); - -// The three call sites must go through getHealthThresholds(role). -function region(src, startMarker, endMarker) { - const a = src.indexOf(startMarker); - assert.ok(a >= 0, 'marker not found: ' + startMarker); - const b = src.indexOf(endMarker, a + startMarker.length); - return src.slice(a, b < 0 ? undefined : b); -} -const NODE_ONLY = /HEALTH_THRESHOLDS\.node(Degraded|Silent)Ms/; - -test('app.js favorites dropdown uses role thresholds', () => { - const body = region(read('app.js'), 'async function renderFavDropdown', 'fav-dd-item'); - assert.ok(/getHealthThresholds\(/.test(body), 'renderFavDropdown does not call getHealthThresholds'); - assert.ok(!NODE_ONLY.test(body), 'renderFavDropdown still uses node-only thresholds'); -}); -test('home.js "My Nodes" card uses role thresholds', () => { - const body = region(read('home.js'), 'const stats = h.stats || {};', 'statusText'); - assert.ok(/getHealthThresholds\(node\.role\)/.test(body), 'My Nodes card does not call getHealthThresholds(node.role)'); - assert.ok(!NODE_ONLY.test(body), 'My Nodes card still uses node-only thresholds'); -}); -test('home.js search-result card uses role thresholds', () => { - const body = region(read('home.js'), 'const claimed = isMyNode(pubkey);', 'statusMsg = `Last heard'); - assert.ok(/getHealthThresholds\(node\.role\)/.test(body), 'search card does not call getHealthThresholds(node.role)'); - assert.ok(!NODE_ONLY.test(body), 'search card still uses node-only thresholds'); -}); -test('no node-only threshold comparison is left in home.js', () => { - assert.ok(!NODE_ONLY.test(read('home.js'))); -}); -test('search card no longer hardcodes "24+ hours" for every role', () => { - assert.ok(!/Not heard in 24\+ hours/.test(read('home.js'))); -}); - -console.log(`\n${passed} passed, ${failed} failed`); -process.exit(failed ? 1 : 0); diff --git a/tests/unit/test-health-status.js b/tests/unit/test-health-status.js new file mode 100644 index 000000000..4595ed1fc --- /dev/null +++ b/tests/unit/test-health-status.js @@ -0,0 +1,144 @@ +/* getHealthStatus (roles.js) turns "last heard" into healthy / degraded / + * silent by the node's role. The favorites dropdown, both home cards, the + * analytics network status, My Repeaters and the live node panel all call it. + * + * Expected values are written out from the documented defaults (repeater and + * room: degraded after 24 h, silent after 72 h; every other role: 1 h and + * 24 h), not computed from HEALTH_THRESHOLDS, so a wrong threshold, a swapped + * pair or a flipped comparison in roles.js fails here. Which timestamp and + * role each view passes in is covered by tests/e2e/test-health-status-e2e.js. + */ +'use strict'; +const vm = require('vm'); +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); + +const PUBLIC = path.resolve(__dirname, '..', '..', 'public'); +const ROLES_JS = fs.readFileSync(path.join(PUBLIC, 'roles.js'), 'utf8'); + +let passed = 0, failed = 0; +async function test(name, fn) { + try { await fn(); passed++; console.log(` ✅ ${name}`); } + catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}`); } +} + +// Load roles.js the way the browser does. `config` is what /api/config/client +// returns, so the healthThresholds override goes through the real code path. +function loadRoles(config) { + const ctx = { + window: {}, console, Date, Infinity, + document: { readyState: 'complete', createElement: () => ({ id: '' }), head: { appendChild: () => {} }, getElementById: () => null, addEventListener: () => {} }, + fetch: () => Promise.resolve({ json: () => Promise.resolve(config || {}) }), + }; + vm.createContext(ctx); + vm.runInContext(ROLES_JS, ctx); + // roles.js reads HEALTH_THRESHOLDS as a bare global; window.X does not create one here. + for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; + return ctx.window; +} + +const M = 60 * 1000, H = 60 * M; +const NOW = Date.UTC(2026, 9, 15, 12, 0, 0); +const iso = (ageMs) => new Date(NOW - ageMs).toISOString(); + +const INFRA = ['repeater', 'room']; +const OTHER = ['companion', 'sensor', 'unknown', undefined, null, '']; +const INFRA_CASES = [ + [0, 'healthy'], [30 * M, 'healthy'], [5 * H, 'healthy'], [24 * H - 1, 'healthy'], + [24 * H, 'degraded'], [30 * H, 'degraded'], [72 * H - 1, 'degraded'], + [72 * H, 'silent'], [80 * H, 'silent'], [200 * H, 'silent'], +]; +const OTHER_CASES = [ + [0, 'healthy'], [30 * M, 'healthy'], [1 * H - 1, 'healthy'], + [1 * H, 'degraded'], [5 * H, 'degraded'], [24 * H - 1, 'degraded'], + [24 * H, 'silent'], [30 * H, 'silent'], [80 * H, 'silent'], +]; + +// Source guard: the age-vs-threshold comparison lives only in roles.js. A view +// that compares on its own can drift from the others, which is how the +// favorites dropdown and home cards ended up role-blind. +const COMPARISONS = [ + /[<>]=?\s*[\w$]+(?:\.[\w$]+)*\.\w*(?:degradedMs|silentMs|DegradedMs|SilentMs)\b/, + /\.\w*(?:degradedMs|silentMs|DegradedMs|SilentMs)\s*[<>]/, +]; +const comparesThresholds = (line) => COMPARISONS.some((re) => re.test(line)); + +(async () => { + const roles = loadRoles(); + const { getHealthStatus } = roles; + + console.log('\n=== getHealthStatus: role x age ==='); + for (const role of INFRA) { + await test(`${role}: 24 h / 72 h, ISO and epoch ms agree`, () => { + for (const [age, want] of INFRA_CASES) { + assert.strictEqual(getHealthStatus(role, iso(age), NOW), want, `${role} at ${age} ms (ISO)`); + assert.strictEqual(getHealthStatus(role, NOW - age, NOW), want, `${role} at ${age} ms (epoch)`); + } + }); + } + for (const role of OTHER) { + await test(`${JSON.stringify(role)}: 1 h / 24 h, ISO and epoch ms agree`, () => { + for (const [age, want] of OTHER_CASES) { + assert.strictEqual(getHealthStatus(role, iso(age), NOW), want, `${role} at ${age} ms (ISO)`); + assert.strictEqual(getHealthStatus(role, NOW - age, NOW), want, `${role} at ${age} ms (epoch)`); + } + }); + } + + console.log('\n=== getHealthStatus: inputs ==='); + await test('no usable timestamp is null, not silent, for every role', () => { + for (const role of [...INFRA, ...OTHER]) { + for (const heard of [null, undefined, '', 'not a date']) { + assert.strictEqual(getHealthStatus(role, heard, NOW), null, `${role} / ${JSON.stringify(heard)}`); + } + } + }); + await test('a timestamp ahead of now (clock skew) counts as healthy', () => { + assert.strictEqual(getHealthStatus('repeater', iso(-5 * M), NOW), 'healthy'); + assert.strictEqual(getHealthStatus('companion', iso(-5 * M), NOW), 'healthy'); + }); + await test('now defaults to the current time', () => { + assert.strictEqual(getHealthStatus('companion', Date.now() - 30 * M), 'healthy'); + assert.strictEqual(getHealthStatus('companion', Date.now() - 2 * H), 'degraded'); + assert.strictEqual(getHealthStatus('repeater', new Date(Date.now() - 30 * H).toISOString()), 'degraded'); + }); + + console.log('\n=== healthThresholds from config.json ==='); + await test('an override from /api/config/client changes the cut-offs', async () => { + const r = loadRoles({ healthThresholds: { infraDegradedMs: 2 * H, infraSilentMs: 6 * H, nodeSilentMs: 3 * H } }); + await r.MeshConfigReady; + assert.strictEqual(r.getHealthStatus('repeater', iso(1 * H), NOW), 'healthy'); + assert.strictEqual(r.getHealthStatus('repeater', iso(3 * H), NOW), 'degraded'); + assert.strictEqual(r.getHealthStatus('repeater', iso(7 * H), NOW), 'silent'); + assert.strictEqual(r.getHealthStatus('companion', iso(2 * H), NOW), 'degraded'); + assert.strictEqual(r.getHealthStatus('companion', iso(4 * H), NOW), 'silent'); + // The default instance is unaffected. + assert.strictEqual(getHealthStatus('repeater', iso(7 * H), NOW), 'healthy'); + }); + + console.log('\n=== guard: no threshold comparison outside roles.js ==='); + await test('the guard matches comparisons and nothing else', () => { + for (const s of ['age < th.degradedMs', 'ageMs age', 'th.degradedMs>=age']) { + assert.ok(comparesThresholds(s), 'should match: ' + s); + } + for (const s of ['const silentMs = Date.now() - wsLastMessageAt;', 'if (silentMs >= 0 && silentMs < WS_STALE_MS) {', + 'const threshMs = isInfra ? HEALTH_THRESHOLDS.infraSilentMs : HEALTH_THRESHOLDS.nodeSilentMs;', + 'const s = getHealthStatus(n.role, n.last_heard || n.last_seen, now);']) { + assert.ok(!comparesThresholds(s), 'should not match: ' + s); + } + }); + await test('public/*.js has no threshold comparison outside roles.js', () => { + const hits = []; + for (const f of fs.readdirSync(PUBLIC).filter((f) => f.endsWith('.js') && f !== 'roles.js')) { + fs.readFileSync(path.join(PUBLIC, f), 'utf8').split('\n').forEach((line, i) => { + if (comparesThresholds(line)) hits.push(`${f}:${i + 1}: ${line.trim().slice(0, 100)}`); + }); + } + assert.deepStrictEqual(hits, [], 'use getHealthStatus(role, heard) instead:\n ' + hits.join('\n ')); + }); + + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed ? 1 : 0); +})(); diff --git a/tests/unit/test-my-repeaters-dashboard.js b/tests/unit/test-my-repeaters-dashboard.js index 30664a579..18d674e16 100644 --- a/tests/unit/test-my-repeaters-dashboard.js +++ b/tests/unit/test-my-repeaters-dashboard.js @@ -56,7 +56,13 @@ const fetchAllNodes = async () => { fetchAllNodesCalls++; return { nodes: nodesD let clockData = [], graphData = { nodes: [], edges: [] }; const api = async (p) => (p.indexOf('/nodes/clock-skew') === 0 ? clockData : graphData); const CLIENT_TTL = { nodeList: 0, analyticsRF: 0 }; -const getHealthThresholds = () => ({ degradedMs: 3600000, silentMs: 86400000 }); +// The real status helper from roles.js, so the dashboard is judged by the +// same thresholds as the browser uses. +const rolesCtx = { window: {}, console, Date, Infinity, document: { readyState: 'complete', createElement: () => ({ id: '' }), head: { appendChild: () => {} }, getElementById: () => null, addEventListener: () => {} }, fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }) }; +require('vm').createContext(rolesCtx); +require('vm').runInContext(fs.readFileSync(path.join(REPO_ROOT, 'public', 'roles.js'), 'utf8'), rolesCtx); +for (const k of Object.keys(rolesCtx.window)) rolesCtx[k] = rolesCtx.window[k]; +const getHealthStatus = rolesCtx.window.getHealthStatus; const renderSkewBadge = (sev) => 'CLOCKBADGE:' + sev; const currentSkewValue = (cs) => (cs.recentMedianSkewSec != null ? cs.recentMedianSkewSec : cs.medianSkewSec); const favStar = (pk) => ''; @@ -67,10 +73,10 @@ const window = { ROLE_COLORS: { repeater: '#3b82f6', room: '#a855f7' }, aaBadgeS const M = new Function( 'esc', 'getFavorites', 'RegionFilter', 'AreaFilter', 'fetchAllNodes', 'api', 'CLIENT_TTL', - 'getHealthThresholds', 'renderSkewBadge', 'currentSkewValue', 'favStar', 'timeAgo', 'bindFavStars', 'window', + 'getHealthStatus', 'renderSkewBadge', 'currentSkewValue', 'favStar', 'timeAgo', 'bindFavStars', 'window', block + '\nreturn { renderMyRepeatersTab, _mrStatusCell, _mrSummaryCard };')( esc, getFavorites, RegionFilter, AreaFilter, fetchAllNodes, api, CLIENT_TTL, - getHealthThresholds, renderSkewBadge, currentSkewValue, favStar, timeAgo, bindFavStars, window); + getHealthStatus, renderSkewBadge, currentSkewValue, favStar, timeAgo, bindFavStars, window); function makeEl() { return { _h: '', set innerHTML(v) { this._h = v; }, get innerHTML() { return this._h; } }; } const tick = () => new Promise(r => setTimeout(r, 0)); @@ -183,6 +189,22 @@ const tick = () => new Promise(r => setTimeout(r, 0)); assert(!/not shown with the active region/.test(el.innerHTML), 'no filter active → no hidden-favorite notice'); + console.log('\n=== status follows the repeater thresholds ==='); + // A repeater last heard 30 h ago is Degraded (repeater limits 24 h / 72 h), + // not Silent as a companion would be (1 h / 24 h). DD has only last_seen, + // so this also covers the last_heard || last_seen fallback. + const ago30h = new Date(Date.now() - 30 * 3600000).toISOString(); + favorites = ['AA', 'DD']; + nodesData = [ + { public_key: 'AA', name: 'Active Rptr', role: 'repeater', last_seen: nowIso, last_heard: nowIso }, + { public_key: 'DD', name: 'Quiet Rptr', role: 'repeater', last_seen: ago30h }, + ]; + el = makeEl(); + await M.renderMyRepeatersTab(el); + const rowOf = (h, name) => { const i = h.indexOf(name); return h.slice(h.lastIndexOf('', i)); }; + assert(/>DegradedActive Date: Fri, 16 Oct 2026 02:17:17 +0000 Subject: [PATCH 3/3] chore(lint): declare getHealthStatus as a shared global The helper added in 0f2376df is defined in roles.js and used from app.js, home.js, analytics.js and nodes.js; .eslintrc.json did not list it, so CI's eslint no-undef step failed on six call sites. Co-Authored-By: Claude Fable 5.1 --- .eslintrc.json | 1 + 1 file changed, 1 insertion(+) diff --git a/.eslintrc.json b/.eslintrc.json index deb317e8d..d29aa89bd 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -230,6 +230,7 @@ "getFavorites": "readonly", "getHashParams": "readonly", "getHealthThresholds": "readonly", + "getHealthStatus": "readonly", "getNodeStatus": "readonly", "getParsedDecoded": "readonly", "getParsedPath": "readonly",