fix(frontend): relay-aware staleness for infra nodes + dim-not-delete (#1598, PR A) (#1815)

Implements **PR A** from the #1598 triage fix path (r6 — all three
operator decisions locked: confidence ≥0.75 [PR B], **dim-not-delete**,
**in-place `getNodeStatus` signature extension**).

## Changes

**`public/roles.js` — `getNodeStatus()`**
- Now accepts a full node object (preferred); the legacy `(role,
lastSeenMs)` signature keeps working unchanged.
- For infra roles (repeater/room), freshness = `max(advert-based
timestamp, last_relayed)`. Freshness precedence mirrors existing call
sites: `_liveSeen` > `_lastHeard` > `last_heard` > `last_seen`.
- `last_relayed` is only consulted for infra — companions keep pure
advert/heard-based staleness (per @liquidraver's collision caveat; the
≥0.75-confidence `_liveSeen` refresh is PR B).

**`public/live.js` — `pruneStaleNodes()`**
- Repeater/room markers are **dimmed, never deleted**, regardless of
`_fromAPI` origin. WS-only non-infra nodes are still removed to prevent
unbounded memory growth.

**Call sites** — all seven (`nodes.js` ×3, `map.js` ×3, `live.js` ×1)
now pass the node object. The Nodes-page status explanation shows "Last
relayed …" when relay participation is the fresher signal, so an Active
badge next to an old "last heard" isn't confusing.

**Tests** — 16 new `getNodeStatus` unit tests incl. the triage's
backbone-repeater fixture (`last_seen`=25h, `last_relayed`=5min →
`active`); `pruneStaleNodes` tests updated for dim-not-delete plus a new
relay-aware prune test.

## Test results
`node test-frontend-helpers.js`: **641 passed, 2 failed** — the 2
failures (favStar ★ assertions) are pre-existing on current master
(verified on a clean upstream clone).

## Validation offer
live.saarmesh.de currently has **160 infra nodes past
`infraSilentMs`=72h while actively relaying** (incl. KatS
disaster-relief repeaters) — a ready-made test population. Happy to run
this branch there and report before/after node visibility.

Refs #1598 (PR A of two; PR B = `_liveSeen` refresh on `resolved_path`
≥0.75 confidence).

---------

Co-authored-by: Mathias Kasper <fallisaar@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: SaarMesh-Bot <bot@saarmesh.de>
This commit is contained in:
authored and GitHub committed 2026-09-02 10:27:44 +02:00
1 parent 6528c7ba3e
commit 4fc42d30a7
5 files changed
+147 -27

No files matched your search

+10 -4
View File
@@ -3085,7 +3085,12 @@
// Prune nodes not seen within their role's health threshold.
// API-loaded nodes (_fromAPI) are dimmed instead of removed — matches static map behavior.
// WS-only nodes (dynamically added from ADVERTs) are removed to prevent memory leaks.
// #1598: infra nodes (repeater/room) are ALWAYS dimmed, never deleted —
// a dimmed marker still tells operators "infrastructure exists here,
// advert stale", whereas deletion silently rewrites the map. Staleness
// itself is relay-aware via getNodeStatus(node) (max of advert-based
// freshness and last_relayed for infra).
// Remaining WS-only non-infra nodes are removed to prevent memory leaks.
function pruneStaleNodes() {
var now = Date.now();
var pruned = false;
@@ -3094,17 +3099,18 @@
if (!n) continue;
var lastSeen = n._liveSeen || (n.last_heard ? new Date(n.last_heard).getTime() : null) || (n.last_seen ? new Date(n.last_seen).getTime() : null);
if (lastSeen == null) continue;
var status = window.getNodeStatus ? getNodeStatus(n.role || 'unknown', lastSeen) : 'active';
var status = window.getNodeStatus ? getNodeStatus(n) : 'active';
var isInfra = n.role === 'repeater' || n.role === 'room';
var marker = nodeMarkers[key];
if (status === 'stale') {
if (n._fromAPI) {
if (n._fromAPI || isInfra) {
// API-loaded nodes: dim instead of removing (consistent with static map)
if (marker && !marker._staleDimmed) {
marker._staleDimmed = true;
_liveSetMarkerOpacity(marker, 0.35);
}
} else {
// WS-only nodes: remove to prevent unbounded memory growth
// WS-only non-infra nodes: remove to prevent unbounded memory growth
if (marker) {
if (nodesLayer) {
try { nodesLayer.removeLayer(marker); } catch (e) { }
+3 -6
View File
@@ -1404,8 +1404,7 @@
for (const n of nodes) {
const role = (n.role || 'companion').toLowerCase();
if (!roleCounts[role]) roleCounts[role] = { active: 0, stale: 0 };
const lastMs = (n.last_heard || n.last_seen) ? new Date(n.last_heard || n.last_seen).getTime() : 0;
const status = getNodeStatus(role, lastMs);
const status = getNodeStatus(n); // #1598: relay-aware for infra
roleCounts[role][status]++;
}
@@ -1618,9 +1617,7 @@
}
// Status filter
if (filters.statusFilter !== 'all') {
const role = (n.role || 'companion').toLowerCase();
const lastMs = (n.last_heard || n.last_seen) ? new Date(n.last_heard || n.last_seen).getTime() : 0;
const status = getNodeStatus(role, lastMs);
const status = getNodeStatus(n); // #1598: relay-aware for infra
if (status !== filters.statusFilter) return false;
}
// Neighbor filter: show only the reference node and its direct neighbors
@@ -1641,7 +1638,7 @@
for (const node of filtered) {
const lastSeenTime = node.last_heard || node.last_seen;
const isStale = getNodeStatus(node.role || 'companion', lastSeenTime ? new Date(lastSeenTime).getTime() : 0) === 'stale';
const isStale = getNodeStatus(node) === 'stale'; // #1598: relay-aware for infra
const pk = (node.public_key || '').toLowerCase();
const isAlsoObserver = _observerByPubkey.has(pk);
const useLabel = node.role === 'repeater' && filters.hashLabels;
+12 -9
View File
@@ -189,14 +189,22 @@
// Prefer last_heard (from in-memory packets) > _lastHeard (health API) > last_seen (DB)
const lastHeardTime = n._lastHeard || n.last_heard || n.last_seen;
const lastHeardMs = lastHeardTime ? new Date(lastHeardTime).getTime() : 0;
const status = getNodeStatus(role, lastHeardMs);
// #1598: pass the full node so infra staleness also considers last_relayed
const status = getNodeStatus(n);
const statusTooltip = getStatusTooltip(role, status);
const statusLabel = status === 'active' ? '<span style="color:var(--status-green-text)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg></span> Active' : '<span style="color:var(--text-muted)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg></span> Stale';
const statusAge = lastHeardMs ? (Date.now() - lastHeardMs) : Infinity;
let explanation = '';
if (status === 'active') {
explanation = 'Last heard ' + (lastHeardTime ? renderNodeTimestampText(lastHeardTime) : 'unknown');
// #1598: an infra node can be active purely via relay participation —
// surface that instead of a misleading old "last heard".
const relayedMs = n.last_relayed ? new Date(n.last_relayed).getTime() : 0;
if (relayedMs > lastHeardMs) {
explanation = 'Last relayed ' + renderNodeTimestampText(n.last_relayed);
} else {
explanation = 'Last heard ' + (lastHeardTime ? renderNodeTimestampText(lastHeardTime) : 'unknown');
}
} else {
const ageDays = Math.floor(statusAge / 86400000);
const ageHours = Math.floor(statusAge / 3600000);
@@ -1238,12 +1246,7 @@
}
// Status filter (active/stale)
if (statusFilter === 'active' || statusFilter === 'stale') {
filtered = filtered.filter(n => {
const role = (n.role || 'companion').toLowerCase();
const t = n.last_heard || n.last_seen;
const lastMs = t ? new Date(t).getTime() : 0;
return getNodeStatus(role, lastMs) === statusFilter;
});
filtered = filtered.filter(n => getNodeStatus(n) === statusFilter); // #1598
}
nodes = filtered;
@@ -1469,7 +1472,7 @@
const roleColor = ROLE_COLORS[n.role] || '#6b7280';
const isClaimed = myKeys.has(n.public_key);
const lastSeenTime = n.last_heard || n.last_seen;
const status = getNodeStatus(n.role || 'companion', lastSeenTime ? new Date(lastSeenTime).getTime() : 0);
const status = getNodeStatus(n); // #1598: relay-aware for infra
const lastSeenClass = status === 'active' ? 'last-seen-active' : 'last-seen-stale';
const cs = _fleetSkew && _fleetSkew[n.public_key];
const skewBadgeHtml = cs && cs.severity && cs.severity !== 'ok' ? renderSkewBadge(cs.severity, window.currentSkewValue(cs), cs) : '';
+34 -4
View File
@@ -444,11 +444,41 @@
};
};
// Simplified two-state helper: returns 'active' or 'stale'
window.getNodeStatus = function (role, lastSeenMs) {
var isInfra = role === 'repeater' || role === 'room';
// Simplified two-state helper: returns 'active' or 'stale'.
// Accepts either a full node object (preferred) or legacy (role, lastSeenMs).
//
// #1598: for infra roles (repeater/room), freshness is the max of the
// ADVERT-based timestamp and last_relayed (resolved path participation).
// Operators increasingly run long or disabled advert intervals (firmware
// default trajectory is 47h flood adverts), so an actively-relaying
// backbone repeater must not be marked stale just because its last
// ADVERT is old.
window.getNodeStatus = function (roleOrNode, lastSeenMs) {
var role, effectiveMs;
if (roleOrNode && typeof roleOrNode === 'object') {
var n = roleOrNode;
role = n.role || 'companion';
// Freshness precedence mirrors the existing call sites:
// _liveSeen (live view, ms) > _lastHeard (health API) >
// last_heard (in-memory packets) > last_seen (DB, ADVERT).
var seenTime = n._lastHeard || n.last_heard || n.last_seen;
effectiveMs = (typeof n._liveSeen === 'number' && n._liveSeen) ||
(seenTime ? new Date(seenTime).getTime() : NaN);
var infra = String(role).toLowerCase() === 'repeater' ||
String(role).toLowerCase() === 'room';
if (infra && n.last_relayed) {
var relayedMs = new Date(n.last_relayed).getTime();
if (!(effectiveMs >= relayedMs)) effectiveMs = relayedMs;
}
if (isNaN(effectiveMs)) effectiveMs = undefined;
} else {
role = roleOrNode;
effectiveMs = lastSeenMs;
}
var isInfra = String(role || '').toLowerCase() === 'repeater' ||
String(role || '').toLowerCase() === 'room';
var staleMs = isInfra ? HEALTH_THRESHOLDS.infraSilentMs : HEALTH_THRESHOLDS.nodeSilentMs;
var age = typeof lastSeenMs === 'number' ? (Date.now() - lastSeenMs) : Infinity;
var age = typeof effectiveMs === 'number' ? (Date.now() - effectiveMs) : Infinity;
return age < staleMs ? 'active' : 'stale';
};
+88 -4
View File
@@ -840,6 +840,68 @@ console.log('\n=== pickByAffinity neighbor-graph scoring (#874) ===');
});
}
// ===== ROLES.JS: getNodeStatus (#1598 relay-aware staleness) =====
console.log('\n=== roles.js: getNodeStatus (#1598) ===');
{
const ctx = makeSandbox();
loadInCtx(ctx, 'public/roles.js');
const getNodeStatus = ctx.getNodeStatus;
const iso = (msAgo) => new Date(Date.now() - msAgo).toISOString();
const H = 3600000;
// Legacy (role, lastSeenMs) signature — unchanged behavior
test('legacy: repeater seen 1h ago is active', () =>
assert.strictEqual(getNodeStatus('repeater', Date.now() - 1 * H), 'active'));
test('legacy: repeater seen 80h ago is stale (infraSilentMs=72h)', () =>
assert.strictEqual(getNodeStatus('repeater', Date.now() - 80 * H), 'stale'));
test('legacy: companion seen 25h ago is stale (nodeSilentMs=24h)', () =>
assert.strictEqual(getNodeStatus('companion', Date.now() - 25 * H), 'stale'));
test('legacy: non-number lastSeenMs is stale', () =>
assert.strictEqual(getNodeStatus('repeater', undefined), 'stale'));
// Node-object signature
test('node: backbone-repeater fixture — last_seen 25h, last_relayed 5min → active', () =>
assert.strictEqual(getNodeStatus({
role: 'repeater', last_seen: iso(25 * H), last_relayed: iso(5 * 60000)
}), 'active'));
test('node: repeater advert-silent 151h but relayed 2min ago → active', () =>
assert.strictEqual(getNodeStatus({
role: 'repeater', last_seen: iso(151 * H), last_relayed: iso(2 * 60000)
}), 'active'));
test('node: repeater with BOTH last_seen and last_relayed past 72h → stale', () =>
assert.strictEqual(getNodeStatus({
role: 'repeater', last_seen: iso(100 * H), last_relayed: iso(80 * H)
}), 'stale'));
test('node: room honors last_relayed like repeater', () =>
assert.strictEqual(getNodeStatus({
role: 'room', last_seen: iso(90 * H), last_relayed: iso(10 * 60000)
}), 'active'));
test('node: companion does NOT get relay-based freshness', () =>
assert.strictEqual(getNodeStatus({
role: 'companion', last_seen: iso(30 * H), last_relayed: iso(5 * 60000)
}), 'stale'));
test('node: repeater with no last_relayed falls back to advert freshness (stale at 80h)', () =>
assert.strictEqual(getNodeStatus({
role: 'repeater', last_seen: iso(80 * H)
}), 'stale'));
test('node: repeater with fresh advert and no last_relayed → active', () =>
assert.strictEqual(getNodeStatus({
role: 'repeater', last_seen: iso(1 * H)
}), 'active'));
test('node: _liveSeen (ms) takes precedence over stale last_seen', () =>
assert.strictEqual(getNodeStatus({
role: 'companion', _liveSeen: Date.now() - 5 * 60000, last_seen: iso(48 * H)
}), 'active'));
test('node: _lastHeard preferred over last_seen', () =>
assert.strictEqual(getNodeStatus({
role: 'companion', _lastHeard: iso(10 * 60000), last_seen: iso(48 * H)
}), 'active'));
test('node: missing role defaults to companion thresholds', () =>
assert.strictEqual(getNodeStatus({ last_seen: iso(25 * H) }), 'stale'));
test('node: no timestamps at all → stale', () =>
assert.strictEqual(getNodeStatus({ role: 'repeater' }), 'stale'));
}
// ===== ROLES.JS: copyToClipboard =====
console.log('\n=== roles.js: copyToClipboard ===');
{
@@ -1062,11 +1124,11 @@ console.log('\n=== live.js: pruneStaleNodes ===');
const markers = ctx.window._liveNodeMarkers();
const data = ctx.window._liveNodeData();
// A repeater seen 48h ago should NOT be pruned (infraSilentMs = 72h)
// A repeater seen 48h ago should NOT be pruned or dimmed (infraSilentMs = 72h)
markers['rpt1'] = { _glowMarker: null };
data['rpt1'] = { public_key: 'rpt1', role: 'repeater', _liveSeen: Date.now() - 48 * 3600000 };
// A repeater seen 96h ago SHOULD be pruned
// #1598: a repeater seen 96h ago is stale, but infra is DIMMED, never deleted
markers['rpt2'] = { _glowMarker: null };
data['rpt2'] = { public_key: 'rpt2', role: 'repeater', _liveSeen: Date.now() - 96 * 3600000 };
@@ -1074,8 +1136,30 @@ console.log('\n=== live.js: pruneStaleNodes ===');
assert.ok(markers['rpt1'], 'repeater at 48h should remain (under 72h threshold)');
assert.ok(data['rpt1'], 'repeater data at 48h should remain');
assert.ok(!markers['rpt2'], 'repeater at 96h should be pruned (over 72h threshold)');
assert.ok(!data['rpt2'], 'repeater data at 96h should be pruned');
assert.ok(!markers['rpt1']._staleDimmed, 'repeater at 48h should not be dimmed');
assert.ok(markers['rpt2'], 'repeater at 96h should remain (#1598: dim, not delete)');
assert.ok(data['rpt2'], 'repeater data at 96h should remain (#1598)');
assert.ok(markers['rpt2']._staleDimmed === true, 'repeater at 96h should be dimmed');
});
test('pruneStaleNodes: relay-aware — advert-silent repeater with fresh last_relayed stays active (#1598)', () => {
const { ctx } = makeLiveSandbox();
const prune = ctx.window._livePruneStaleNodes;
const markers = ctx.window._liveNodeMarkers();
const data = ctx.window._liveNodeData();
// Backbone fixture: advert 96h stale, relayed 5 minutes ago → active, undimmed
markers['bb1'] = { _glowMarker: null };
data['bb1'] = {
public_key: 'bb1', role: 'repeater',
_liveSeen: Date.now() - 96 * 3600000,
last_relayed: new Date(Date.now() - 5 * 60000).toISOString()
};
prune();
assert.ok(markers['bb1'], 'relaying repeater should remain');
assert.ok(!markers['bb1']._staleDimmed, 'relaying repeater should not be dimmed');
});
test('node count does not grow unbounded with repeated ADVERTs', () => {