mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-08-01 11:39:43 +00:00
fix: dim stale nodes on live map instead of removing them
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>
This commit is contained in:
+26
-26
@@ -22,9 +22,9 @@
|
||||
<meta name="twitter:title" content="MeshCore Analyzer">
|
||||
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
|
||||
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/meshcore-analyzer/master/public/og-image.png">
|
||||
<link rel="stylesheet" href="style.css?v=1774623240">
|
||||
<link rel="stylesheet" href="home.css?v=1774623240">
|
||||
<link rel="stylesheet" href="live.css?v=1774623240">
|
||||
<link rel="stylesheet" href="style.css?v=1774623647">
|
||||
<link rel="stylesheet" href="home.css?v=1774623647">
|
||||
<link rel="stylesheet" href="live.css?v=1774623647">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin="anonymous">
|
||||
@@ -81,28 +81,28 @@
|
||||
<main id="app" role="main"></main>
|
||||
|
||||
<script src="vendor/qrcode.js"></script>
|
||||
<script src="roles.js?v=1774623240"></script>
|
||||
<script src="customize.js?v=1774623240" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="region-filter.js?v=1774623240"></script>
|
||||
<script src="hop-resolver.js?v=1774623240"></script>
|
||||
<script src="hop-display.js?v=1774623240"></script>
|
||||
<script src="app.js?v=1774623240"></script>
|
||||
<script src="home.js?v=1774623240"></script>
|
||||
<script src="packet-filter.js?v=1774623240"></script>
|
||||
<script src="packets.js?v=1774623240"></script>
|
||||
<script src="map.js?v=1774623240" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=1774623240" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="nodes.js?v=1774623240" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=1774623240" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="analytics.js?v=1774623240" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio.js?v=1774623240" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v1-constellation.js?v=1774623240" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v2-constellation.js?v=1774623240" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-lab.js?v=1774623240" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="live.js?v=1774623240" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observers.js?v=1774623240" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observer-detail.js?v=1774623240" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="node-analytics.js?v=1774623240" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="perf.js?v=1774623240" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="roles.js?v=1774623647"></script>
|
||||
<script src="customize.js?v=1774623647" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="region-filter.js?v=1774623647"></script>
|
||||
<script src="hop-resolver.js?v=1774623647"></script>
|
||||
<script src="hop-display.js?v=1774623647"></script>
|
||||
<script src="app.js?v=1774623647"></script>
|
||||
<script src="home.js?v=1774623647"></script>
|
||||
<script src="packet-filter.js?v=1774623647"></script>
|
||||
<script src="packets.js?v=1774623647"></script>
|
||||
<script src="map.js?v=1774623647" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=1774623647" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="nodes.js?v=1774623647" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=1774623647" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="analytics.js?v=1774623647" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio.js?v=1774623647" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v1-constellation.js?v=1774623647" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v2-constellation.js?v=1774623647" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-lab.js?v=1774623647" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="live.js?v=1774623647" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observers.js?v=1774623647" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observer-detail.js?v=1774623647" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="node-analytics.js?v=1774623647" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="perf.js?v=1774623647" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+28
-9
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user