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