Compare commits

...

3 Commits

Author SHA1 Message Date
you
6efbe2c9be perf: use Map for O(1) pubKey lookup in ADVERT upsert handler
Replace _allNodes.find() (O(n) per advert) with a Map built once per
WS batch. With 2K+ nodes and multiple adverts per batch, this avoids
repeated linear scans in a hot path.
2026-04-02 01:23:02 +00:00
efiten
4628acf946 test: add coverage for nodes ADVERT upsert vs full reload 2026-04-02 01:22:16 +00:00
efiten
5020e48d22 perf: upsert known nodes in-place on ADVERT, skip full reload (closes #399) 2026-04-02 01:22:16 +00:00
3 changed files with 121 additions and 30 deletions

View File

@@ -22,9 +22,9 @@
<meta name="twitter:title" content="CoreScope">
<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/corescope/master/public/og-image.png">
<link rel="stylesheet" href="style.css?v=1775062162">
<link rel="stylesheet" href="home.css?v=1775062162">
<link rel="stylesheet" href="live.css?v=1775062162">
<link rel="stylesheet" href="style.css?v=1775092976">
<link rel="stylesheet" href="home.css?v=1775092976">
<link rel="stylesheet" href="live.css?v=1775092976">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="anonymous">
@@ -85,30 +85,30 @@
<main id="app" role="main"></main>
<script src="vendor/qrcode.js"></script>
<script src="roles.js?v=1775062162"></script>
<script src="customize.js?v=1775062162" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1775062162"></script>
<script src="hop-resolver.js?v=1775062162"></script>
<script src="hop-display.js?v=1775062162"></script>
<script src="app.js?v=1775062162"></script>
<script src="home.js?v=1775062162"></script>
<script src="packet-filter.js?v=1775062162"></script>
<script src="packets.js?v=1775062162"></script>
<script src="geo-filter-overlay.js?v=1775062162"></script>
<script src="map.js?v=1775062162" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1775062162" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1775062162" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1775062162" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1775062162" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1775062162" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1775062162" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1775062162" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1775062162" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1775062162" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1775062162" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1775062162" onerror="console.error('Failed to load:', this.src)"></script>
<script src="compare.js?v=1775062162" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1775062162" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1775062162" onerror="console.error('Failed to load:', this.src)"></script>
<script src="roles.js?v=1775092976"></script>
<script src="customize.js?v=1775092976" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1775092976"></script>
<script src="hop-resolver.js?v=1775092976"></script>
<script src="hop-display.js?v=1775092976"></script>
<script src="app.js?v=1775092976"></script>
<script src="home.js?v=1775092976"></script>
<script src="packet-filter.js?v=1775092976"></script>
<script src="packets.js?v=1775092976"></script>
<script src="geo-filter-overlay.js?v=1775092976"></script>
<script src="map.js?v=1775092976" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1775092976" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1775092976" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1775092976" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1775092976" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1775092976" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1775092976" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1775092976" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1775092976" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1775092976" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1775092976" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1775092976" onerror="console.error('Failed to load:', this.src)"></script>
<script src="compare.js?v=1775092976" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1775092976" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1775092976" onerror="console.error('Failed to load:', this.src)"></script>
</body>
</html>

View File

@@ -228,11 +228,45 @@
loadNodes();
// Auto-refresh when ADVERT packets arrive via WebSocket (fixes #131)
wsHandler = debouncedOnWS(function (msgs) {
if (msgs.some(isAdvertMessage)) {
_allNodes = null;
const advertMsgs = msgs.filter(isAdvertMessage);
if (!advertMsgs.length) return;
if (!_allNodes) {
invalidateApiCache('/nodes');
loadNodes(true);
return;
}
// Build a Map for O(1) lookup instead of O(n) .find() per advert
var nodesByKey = new Map();
for (var i = 0; i < _allNodes.length; i++) {
if (_allNodes[i].public_key) nodesByKey.set(_allNodes[i].public_key, _allNodes[i]);
}
let needReload = false;
for (const m of advertMsgs) {
const payload = m.data && m.data.decoded && m.data.decoded.payload;
const pubKey = payload && (payload.pubKey || payload.public_key);
if (!pubKey) { needReload = true; break; }
const existing = nodesByKey.get(pubKey);
if (existing) {
if (payload.name) existing.name = payload.name;
if (payload.lat != null) existing.lat = payload.lat;
if (payload.lon != null) existing.lon = payload.lon;
const ts = m.data.packet && (m.data.packet.timestamp || m.data.packet.first_seen);
if (ts) existing.last_seen = ts;
} else {
needReload = true;
break;
}
}
if (needReload) {
_allNodes = null;
invalidateApiCache('/nodes');
}
loadNodes(true);
}, 5000);
}
@@ -929,4 +963,6 @@
// Test hooks
window._nodesIsAdvertMessage = isAdvertMessage;
window._nodesGetAllNodes = function() { return _allNodes; };
window._nodesSetAllNodes = function(n) { _allNodes = n; };
})();

View File

@@ -1181,6 +1181,61 @@ console.log('\n=== nodes.js: WS handler runtime behavior ===');
assert.ok(env.getApiCalls() > 0, 'api called because _allNodes was reset to null');
});
test('ADVERT for known node upserts in-place without API fetch', () => {
const env = makeNodesWsSandbox();
// Pre-populate _allNodes with a known node
assert.ok(typeof env.ctx.window._nodesSetAllNodes === 'function', '_nodesSetAllNodes must be exposed');
env.ctx.window._nodesSetAllNodes([
{ public_key: 'aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899', name: 'OldName', role: 'repeater', lat: null, lon: null, last_seen: '2024-01-01T00:00:00Z' }
]);
env.resetCounters();
env.sendWS({
type: 'packet',
data: {
packet: { payload_type: 4, timestamp: '2024-06-01T12:00:00Z' },
decoded: {
header: { payloadTypeName: 'ADVERT' },
payload: { type: 'ADVERT', pubKey: 'aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899', name: 'NewName', lat: 50.85, lon: 4.35 }
}
}
});
env.fireTimers();
assert.strictEqual(env.getApiCalls(), 0, 'known node upsert must NOT trigger API fetch');
assert.strictEqual(env.getInvalidated().length, 0, 'no cache invalidation for known node upsert');
const nodes = env.ctx.window._nodesGetAllNodes();
assert.ok(nodes, '_nodesGetAllNodes must be exposed');
assert.strictEqual(nodes[0].name, 'NewName', 'name must be updated in place');
assert.strictEqual(nodes[0].lat, 50.85, 'lat must be updated in place');
assert.strictEqual(nodes[0].lon, 4.35, 'lon must be updated in place');
assert.strictEqual(nodes[0].last_seen, '2024-06-01T12:00:00Z', 'last_seen must be updated from packet timestamp');
});
test('ADVERT for unknown node falls back to full reload', () => {
const env = makeNodesWsSandbox();
env.ctx.window._nodesSetAllNodes([
{ public_key: 'aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899', name: 'ExistingNode', role: 'repeater' }
]);
env.resetCounters();
// Send ADVERT from a pubKey NOT in _allNodes
env.sendWS({
type: 'packet',
data: {
packet: { payload_type: 4 },
decoded: {
header: { payloadTypeName: 'ADVERT' },
payload: { type: 'ADVERT', pubKey: 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', name: 'BrandNewNode' }
}
}
});
env.fireTimers();
assert.ok(env.getApiCalls() > 0, 'unknown node must trigger full reload');
assert.ok(env.getInvalidated().includes('/nodes'), 'cache must be invalidated for unknown node');
});
test('scroll position and selection preserved during WS-triggered refresh', () => {
const env = makeNodesWsSandbox();
// Simulate scrolled panel state — WS handler should not touch scroll or rebuild panel