mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-17 10:44:20 +00:00
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:
+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=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
@@ -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;
|
||||
})();
|
||||
|
||||
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user