From 65a7f055de3df2bf2c73568e4f16d7d48c798b8d Mon Sep 17 00:00:00 2001 From: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com> Date: Fri, 27 Mar 2026 08:01:09 -0700 Subject: [PATCH] fix: dim stale nodes on live map instead of removing them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #130 — Nodes loaded from the database (API) are now dimmed with reduced opacity when stale, matching the static map behavior, instead of being completely removed by pruneStaleNodes(). WS-only (dynamically added) nodes are still pruned to prevent memory leaks. Changes: - loadNodes() marks API-loaded nodes with _fromAPI flag - pruneStaleNodes() dims _fromAPI nodes (fillOpacity 0.25) vs removing - Active nodes restore full opacity when refreshed - 3 new tests for dim/restore/WS-only behavior (63 total passing) - Cache busters bumped Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- public/index.html | 52 +++++++++++++-------------- public/live.js | 37 ++++++++++++++----- test-frontend-helpers.js | 77 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 129 insertions(+), 37 deletions(-) diff --git a/public/index.html b/public/index.html index c98a3141..fbe18833 100644 --- a/public/index.html +++ b/public/index.html @@ -22,9 +22,9 @@ - - - + + + @@ -81,28 +81,28 @@
- - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/live.js b/public/live.js index 025865dc..9be298eb 100644 --- a/public/live.js +++ b/public/live.js @@ -1276,6 +1276,7 @@ var now = Date.now(); list.forEach(n => { if (n.lat != null && n.lon != null && !(n.lat === 0 && n.lon === 0)) { + n._fromAPI = true; n._liveSeen = now; nodeData[n.public_key] = n; addNodeMarker(n); @@ -1467,7 +1468,9 @@ } } - // Prune nodes not seen within their role's health threshold + // 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. function pruneStaleNodes() { var now = Date.now(); var pruned = false; @@ -1477,17 +1480,33 @@ 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 marker = nodeMarkers[key]; if (status === 'stale') { - var marker = nodeMarkers[key]; - if (marker) { - if (nodesLayer) { - try { nodesLayer.removeLayer(marker); } catch (e) {} - if (marker._glowMarker) try { nodesLayer.removeLayer(marker._glowMarker); } catch (e) {} + if (n._fromAPI) { + // API-loaded nodes: dim instead of removing (consistent with static map) + if (marker && !marker._staleDimmed) { + marker._staleDimmed = true; + marker.setStyle({ fillOpacity: 0.25, opacity: 0.15 }); + if (marker._glowMarker) marker._glowMarker.setStyle({ fillOpacity: 0.04 }); } + } else { + // WS-only nodes: remove to prevent unbounded memory growth + if (marker) { + if (nodesLayer) { + try { nodesLayer.removeLayer(marker); } catch (e) {} + if (marker._glowMarker) try { nodesLayer.removeLayer(marker._glowMarker); } catch (e) {} + } + } + delete nodeMarkers[key]; + delete nodeData[key]; + pruned = true; } - delete nodeMarkers[key]; - delete nodeData[key]; - pruned = true; + } else if (marker && marker._staleDimmed) { + // Node became active again — restore full opacity + marker._staleDimmed = false; + var isRepeater = n.role === 'repeater'; + marker.setStyle({ fillOpacity: 0.85, opacity: isRepeater ? 0.6 : 0.3 }); + if (marker._glowMarker) marker._glowMarker.setStyle({ fillOpacity: 0.12 }); } } if (pruned) { diff --git a/test-frontend-helpers.js b/test-frontend-helpers.js index 2c158711..4de4131a 100644 --- a/test-frontend-helpers.js +++ b/test-frontend-helpers.js @@ -689,8 +689,81 @@ console.log('\n=== live.js: pruneStaleNodes ==='); prune(); - assert.ok(!markers['apiOld'], 'API node with stale last_heard should be pruned'); - assert.ok(markers['apiFresh'], 'API node with fresh last_heard should remain'); + assert.ok(!markers['apiOld'], 'WS node with stale last_heard should be pruned'); + assert.ok(markers['apiFresh'], 'WS node with fresh last_heard should remain'); + }); + + test('pruneStaleNodes dims API-loaded nodes instead of removing them', () => { + const { ctx } = makeLiveSandbox(); + const prune = ctx.window._livePruneStaleNodes; + const markers = ctx.window._liveNodeMarkers(); + const data = ctx.window._liveNodeData(); + + let lastStyle = {}; + let glowStyle = {}; + markers['apiStale'] = { + _glowMarker: { setStyle: function(s) { glowStyle = s; } }, + _staleDimmed: false, + setStyle: function(s) { lastStyle = s; }, + }; + data['apiStale'] = { public_key: 'apiStale', role: 'repeater', _fromAPI: true, _liveSeen: Date.now() - 96 * 3600000 }; + + prune(); + + assert.ok(markers['apiStale'], 'API node should NOT be removed'); + assert.ok(data['apiStale'], 'API node data should NOT be removed'); + assert.ok(markers['apiStale']._staleDimmed, 'API node should be marked as dimmed'); + assert.strictEqual(lastStyle.fillOpacity, 0.25, 'marker should be dimmed to 0.25 fillOpacity'); + assert.strictEqual(glowStyle.fillOpacity, 0.04, 'glow should be dimmed to 0.04 fillOpacity'); + }); + + test('pruneStaleNodes restores API nodes when they become active again', () => { + const { ctx } = makeLiveSandbox(); + const prune = ctx.window._livePruneStaleNodes; + const markers = ctx.window._liveNodeMarkers(); + const data = ctx.window._liveNodeData(); + + let lastStyle = {}; + let glowStyle = {}; + markers['apiNode'] = { + _glowMarker: { setStyle: function(s) { glowStyle = s; } }, + _staleDimmed: true, + setStyle: function(s) { lastStyle = s; }, + }; + data['apiNode'] = { public_key: 'apiNode', role: 'repeater', _fromAPI: true, _liveSeen: Date.now() }; + + prune(); + + assert.ok(markers['apiNode'], 'API node should remain'); + assert.strictEqual(markers['apiNode']._staleDimmed, false, 'staleDimmed should be cleared'); + assert.strictEqual(lastStyle.fillOpacity, 0.85, 'opacity should be restored to 0.85'); + assert.strictEqual(glowStyle.fillOpacity, 0.12, 'glow should be restored to 0.12'); + }); + + test('pruneStaleNodes still removes WS-only nodes when stale', () => { + const { ctx } = makeLiveSandbox(); + const prune = ctx.window._livePruneStaleNodes; + const markers = ctx.window._liveNodeMarkers(); + const data = ctx.window._liveNodeData(); + + // WS-only node (no _fromAPI) — should be removed + markers['wsNode'] = { _glowMarker: null }; + data['wsNode'] = { public_key: 'wsNode', role: 'companion', _liveSeen: Date.now() - 48 * 3600000 }; + + // API node — should be dimmed, not removed + markers['apiNode'] = { + _glowMarker: { setStyle: function() {} }, + _staleDimmed: false, + setStyle: function() {}, + }; + data['apiNode'] = { public_key: 'apiNode', role: 'companion', _fromAPI: true, _liveSeen: Date.now() - 48 * 3600000 }; + + prune(); + + assert.ok(!markers['wsNode'], 'WS-only stale node should be removed'); + assert.ok(!data['wsNode'], 'WS-only stale node data should be removed'); + assert.ok(markers['apiNode'], 'API stale node should NOT be removed'); + assert.ok(data['apiNode'], 'API stale node data should NOT be removed'); }); }