diff --git a/.eslintrc.json b/.eslintrc.json index 1b3101202..cedbc72b6 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -231,6 +231,7 @@ "getFavorites": "readonly", "getHashParams": "readonly", "getHealthThresholds": "readonly", + "getHealthStatus": "readonly", "getNodeStatus": "readonly", "getParsedDecoded": "readonly", "getParsedPath": "readonly", diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ab5c574d3..1d9cfec12 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -588,6 +588,7 @@ jobs: suite 2 test-issue-1639-observers-sort-e2e.js BASE_URL=$B suite 2 test-issue-748-node-analytics-coverage-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-1268-rf-params-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 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 3e0478797..466d6d035 100644 --- a/public/app.js +++ b/public/app.js @@ -1748,13 +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 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'; + // 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 f20dad502..7de673e70 100644 --- a/public/home.js +++ b/public/home.js @@ -295,8 +295,9 @@ const stats = h.stats || {}; const obs = h.observers || []; - 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'; + // 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'; @@ -317,7 +318,7 @@
${node.role || '?'}
-
${statusText}${stats.lastHeard ? ' · ' + timeAgo(stats.lastHeard) : ''}
+
${statusText}${heard ? ' · ' + timeAgo(heard) : ''}
${stats.packetsToday ?? 0}
@@ -453,14 +454,11 @@ const observers = h.observers || []; const claimed = isMyNode(pubkey); - let status = 'silent', color = 'red', statusMsg = 'Not heard in 24+ hours'; - 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}`; } - 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') : ''; @@ -475,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 0da1704be..42948996a 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 4f94cefda..19d227ed0 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-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-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