Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@
"getFavorites": "readonly",
"getHashParams": "readonly",
"getHealthThresholds": "readonly",
"getHealthStatus": "readonly",
"getNodeStatus": "readonly",
"getParsedDecoded": "readonly",
"getParsedPath": "readonly",
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 6 additions & 16 deletions public/analytics.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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:
Expand Down
10 changes: 6 additions & 4 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<a href="#/nodes/' + pk + '" class="fav-dd-item" data-key="' + pk + '">'
+ '<span class="fav-dd-status ' + statusCls + '" title="' + statusLabel + '" aria-label="' + statusLabel + '"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg></span>'
+ '<span class="fav-dd-name">' + escapeHtml(h.node.name || truncate(pk, 12)) + '</span>'
+ '<span class="fav-dd-meta">' + (h.stats.lastHeard ? timeAgo(h.stats.lastHeard) : 'never') + '</span>'
+ '<span class="fav-dd-meta">' + (heard ? timeAgo(heard) : 'never') + '</span>'
+ favStar(pk, 'fav-dd-star')
+ '</a>';
} catch {
Expand Down
22 changes: 10 additions & 12 deletions public/home.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<span class="' + statusCls + '" aria-label="' + status + '"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg></span>';
const statusText = status === 'healthy' ? 'Active' : status === 'degraded' ? 'Degraded' : 'Silent';
Expand All @@ -317,7 +318,7 @@
<div class="mnc-role">${node.role || '?'}</div>
<button class="mnc-remove" data-key="${mn.pubkey}" title="Remove from My Mesh" aria-label="Remove ${escapeAttr(name)} from My Mesh"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg></button>
</div>
<div class="mnc-status-text">${statusText}${stats.lastHeard ? ' · ' + timeAgo(stats.lastHeard) : ''}</div>
<div class="mnc-status-text">${statusText}${heard ? ' · ' + timeAgo(heard) : ''}</div>
<div class="mnc-metrics">
<div class="mnc-metric">
<div class="mnc-val">${stats.packetsToday ?? 0}</div>
Expand Down Expand Up @@ -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') : '';
Expand All @@ -475,7 +473,7 @@
<div class="health-metrics">
<div class="health-metric"><div class="val">${stats.packetsToday ?? '—'}</div><div class="lbl">Packets Today</div></div>
<div class="health-metric"><div class="val">${observers.length}</div><div class="lbl">Observers</div></div>
<div class="health-metric"><div class="val">${stats.lastHeard ? timeAgo(stats.lastHeard) : '—'}</div><div class="lbl">Last seen</div></div>
<div class="health-metric"><div class="val">${heard ? timeAgo(heard) : '—'}</div><div class="lbl">Last seen</div></div>
<div class="health-metric"><div class="val">${snrVal != null ? Number(snrVal).toFixed(1) + ' dB' : '—'}</div><div class="lbl">Avg SNR${snrLabel ? ' · ' + snrLabel : ''}</div></div>
<div class="health-metric"><div class="val">${stats.avgHops != null ? stats.avgHops.toFixed(1) : '—'}</div><div class="lbl">Avg Hops</div></div>
</div>
Expand Down
7 changes: 3 additions & 4 deletions public/live.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
<div style="padding:16px;">
Expand Down
14 changes: 14 additions & 0 deletions public/roles.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
//
Expand Down
1 change: 1 addition & 0 deletions scripts/non-unit-tests.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions test-all.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading