mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-11-11 11:57:57 +00:00
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:
5 files changed
+147
-27
No files matched your search
+10
-4
@@ -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
@@ -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
@@ -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
@@ -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';
|
||||
};
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user