fix: auto-update Nodes tab when ADVERT packets arrive via WebSocket

Fixes #131

The Nodes tab required a full page reload to see newly advertised nodes
because loadNodes() cached the node list in _allNodes and never
re-fetched it on WebSocket updates.

Changes:
- WS handler now filters for ADVERT packets only (payload_type 4 or
  payloadTypeName ADVERT), instead of triggering on every packet type
- Uses 5-second debounce to avoid excessive API calls during bursts
- Resets _allNodes cache and invalidates API cache before re-fetching
- loadNodes(refreshOnly) parameter: when true, updates table rows and
  counts without rebuilding the entire panel (preserves scroll position,
  selected node, tabs, filters, and event listeners)
- Extracted isAdvertMessage() as testable helper with window._nodesIsAdvertMessage hook
- 13 new tests (76 total frontend helpers)
- Cache busters bumped

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Kpa-clawbot
2026-03-27 08:08:43 -07:00
co-authored by Copilot
parent d793a561f3
commit 55db2bef27
3 changed files with 184 additions and 29 deletions
+26 -26
View File
@@ -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=1774623647">
<link rel="stylesheet" href="home.css?v=1774623647">
<link rel="stylesheet" href="live.css?v=1774623647">
<link rel="stylesheet" href="style.css?v=1774624024">
<link rel="stylesheet" href="home.css?v=1774624024">
<link rel="stylesheet" href="live.css?v=1774624024">
<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=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>
<script src="roles.js?v=1774624024"></script>
<script src="customize.js?v=1774624024" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1774624024"></script>
<script src="hop-resolver.js?v=1774624024"></script>
<script src="hop-display.js?v=1774624024"></script>
<script src="app.js?v=1774624024"></script>
<script src="home.js?v=1774624024"></script>
<script src="packet-filter.js?v=1774624024"></script>
<script src="packets.js?v=1774624024"></script>
<script src="map.js?v=1774624024" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1774624024" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774624024" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1774624024" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1774624024" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1774624024" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1774624024" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1774624024" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1774624024" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1774624024" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1774624024" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1774624024" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1774624024" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1774624024" onerror="console.error('Failed to load:', this.src)"></script>
</body>
</html>
+24 -3
View File
@@ -206,7 +206,14 @@
}, 250));
loadNodes();
wsHandler = debouncedOnWS(function (msgs) { if (msgs.some(function (m) { return m.type === 'packet'; })) loadNodes(); });
// Auto-refresh when ADVERT packets arrive via WebSocket (fixes #131)
wsHandler = debouncedOnWS(function (msgs) {
if (msgs.some(isAdvertMessage)) {
_allNodes = null;
invalidateApiCache('/nodes');
loadNodes(true);
}
}, 5000);
}
async function loadFullNode(pubkey) {
@@ -426,7 +433,7 @@
let _allNodes = null; // cached full node list
async function loadNodes() {
async function loadNodes(refreshOnly) {
try {
// Fetch all nodes once, filter client-side
if (!_allNodes) {
@@ -487,7 +494,11 @@
syncClaimedToFavorites();
renderCounts();
renderLeft();
if (refreshOnly) {
renderRows();
} else {
renderLeft();
}
} catch (e) {
console.error('Failed to load nodes:', e);
const tbody = document.getElementById('nodesBody');
@@ -838,5 +849,15 @@
});
}
function isAdvertMessage(m) {
if (m.type !== 'packet') return false;
if (m.data && m.data.packet && m.data.packet.payload_type === 4) return true;
if (m.data && m.data.decoded && m.data.decoded.header && m.data.decoded.header.payloadTypeName === 'ADVERT') return true;
return false;
}
registerPage('nodes', { init, destroy });
// Test hooks
window._nodesIsAdvertMessage = isAdvertMessage;
})();
+134
View File
@@ -767,6 +767,140 @@ console.log('\n=== live.js: pruneStaleNodes ===');
});
}
// ===== NODES.JS: isAdvertMessage + auto-update logic =====
console.log('\n=== nodes.js: isAdvertMessage ===');
{
const ctx = makeSandbox();
// Provide the globals nodes.js depends on
ctx.ROLE_COLORS = { repeater: '#22c55e', room: '#6366f1', companion: '#3b82f6', sensor: '#f59e0b' };
ctx.ROLE_STYLE = {};
ctx.TYPE_COLORS = {};
ctx.getNodeStatus = () => 'active';
ctx.getHealthThresholds = () => ({ staleMs: 600000, degradedMs: 1800000, silentMs: 86400000 });
ctx.timeAgo = () => '1m ago';
ctx.truncate = (s) => s;
ctx.escapeHtml = (s) => String(s || '');
ctx.payloadTypeName = () => 'Advert';
ctx.payloadTypeColor = () => 'advert';
ctx.registerPage = () => {};
ctx.RegionFilter = { init: () => {}, onChange: () => () => {}, getRegionParam: () => '' };
ctx.debouncedOnWS = () => null;
ctx.onWS = () => {};
ctx.offWS = () => {};
ctx.debounce = (fn) => fn;
ctx.api = () => Promise.resolve({ nodes: [], counts: {} });
ctx.invalidateApiCache = () => {};
ctx.CLIENT_TTL = { nodeList: 90000, nodeDetail: 240000, nodeHealth: 240000 };
ctx.initTabBar = () => {};
ctx.getFavorites = () => [];
ctx.favStar = () => '';
ctx.bindFavStars = () => {};
ctx.makeColumnsResizable = () => {};
ctx.Set = Set;
loadInCtx(ctx, 'public/nodes.js');
const isAdvert = ctx._nodesIsAdvertMessage;
test('rejects non-packet message', () => {
assert.strictEqual(isAdvert({ type: 'message', data: {} }), false);
});
test('rejects packet without advert payload_type', () => {
assert.strictEqual(isAdvert({ type: 'packet', data: { packet: { payload_type: 2 } } }), false);
});
test('detects format 1 advert (payload_type 4)', () => {
assert.strictEqual(isAdvert({ type: 'packet', data: { packet: { payload_type: 4 } } }), true);
});
test('detects format 2 advert (payloadTypeName ADVERT)', () => {
assert.strictEqual(isAdvert({ type: 'packet', data: { decoded: { header: { payloadTypeName: 'ADVERT' } } } }), true);
});
test('rejects packet with non-ADVERT payloadTypeName', () => {
assert.strictEqual(isAdvert({ type: 'packet', data: { decoded: { header: { payloadTypeName: 'GRP_TXT' } } } }), false);
});
test('rejects empty data', () => {
assert.strictEqual(isAdvert({ type: 'packet', data: {} }), false);
});
test('rejects null data', () => {
assert.strictEqual(isAdvert({ type: 'packet', data: null }), false);
});
test('rejects missing data', () => {
assert.strictEqual(isAdvert({ type: 'packet' }), false);
});
}
console.log('\n=== nodes.js: loadNodes refreshOnly =====');
{
// Verify that loadNodes(true) calls renderRows instead of renderLeft
const ctx = makeSandbox();
ctx.ROLE_COLORS = { repeater: '#22c55e', room: '#6366f1', companion: '#3b82f6', sensor: '#f59e0b' };
ctx.ROLE_STYLE = {};
ctx.TYPE_COLORS = {};
ctx.getNodeStatus = () => 'active';
ctx.getHealthThresholds = () => ({ staleMs: 600000, degradedMs: 1800000, silentMs: 86400000 });
ctx.timeAgo = () => '1m ago';
ctx.truncate = (s) => s;
ctx.escapeHtml = (s) => String(s || '');
ctx.payloadTypeName = () => 'Advert';
ctx.payloadTypeColor = () => 'advert';
ctx.debouncedOnWS = () => null;
ctx.onWS = () => {};
ctx.offWS = () => {};
ctx.debounce = (fn) => fn;
ctx.invalidateApiCache = () => {};
ctx.CLIENT_TTL = { nodeList: 90000, nodeDetail: 240000, nodeHealth: 240000 };
ctx.initTabBar = () => {};
ctx.getFavorites = () => [];
ctx.favStar = () => '';
ctx.bindFavStars = () => {};
ctx.makeColumnsResizable = () => {};
ctx.Set = Set;
ctx.RegionFilter = { init: () => {}, onChange: () => () => {}, getRegionParam: () => '' };
let renderLeftCalls = 0;
let renderRowsCalls = 0;
let initCaptured = null;
ctx.api = () => Promise.resolve({ nodes: [{ public_key: 'abc123def456ghij', name: 'TestNode', role: 'repeater' }], counts: { repeaters: 1 } });
ctx.registerPage = (name, handlers) => { initCaptured = handlers; };
// Load nodes.js — captures the init/destroy via registerPage
loadInCtx(ctx, 'public/nodes.js');
// Now we need to call init, but it requires DOM.
// Instead, test the loadNodes function behavior via the WS handler pattern.
// The key test: debouncedOnWS callback should call loadNodes(true) for adverts.
// We already tested isAdvertMessage above. Here we verify the refreshOnly param
// is passed correctly by checking it was wired in the source.
const src = fs.readFileSync('public/nodes.js', 'utf8');
test('WS handler calls loadNodes with refreshOnly=true', () => {
assert.ok(src.includes('loadNodes(true)'), 'WS handler should call loadNodes(true) for refresh-only updates');
});
test('WS handler uses 5-second debounce', () => {
assert.ok(src.includes('}, 5000)'), 'debouncedOnWS should use 5000ms debounce');
});
test('WS handler invalidates API cache before refresh', () => {
assert.ok(src.includes("invalidateApiCache('/nodes')"), 'Should invalidate /nodes cache');
});
test('WS handler resets _allNodes before refresh', () => {
assert.ok(src.includes('_allNodes = null'), 'Should reset _allNodes to force re-fetch');
});
test('loadNodes uses refreshOnly to render rows only', () => {
assert.ok(src.includes('if (refreshOnly)'), 'loadNodes should check refreshOnly parameter');
assert.ok(src.includes('renderRows()'), 'Should call renderRows when refreshOnly is true');
});
}
// ===== SUMMARY =====
console.log(`\n${'═'.repeat(40)}`);
console.log(` Frontend helpers: ${passed} passed, ${failed} failed`);