perf: replace server round-trip with client-side filter for My Nodes toggle (#401)

## Summary

Fixes #381 — The "My Nodes" filter in `packets.js` was making a **server
API call inside `renderTableRows()`** on every render cycle. With
WebSocket updates arriving every few seconds while the toggle was
active, this created continuous unnecessary server load.

## What Changed

**`public/packets.js`** — Replaced the `api('/packets?nodes=...')`
server call with a pure client-side filter:

```js
// Before: server round-trip on every render
const myData = await api('/packets?nodes=' + allKeys.join(',') + '&limit=500');
displayPackets = myData.packets || [];

// After: filter already-loaded packets client-side
displayPackets = displayPackets.filter(p => {
  const dj = p.decoded_json || '';
  return allKeys.some(k => dj.includes(k));
});
```

This uses the exact same matching logic as the server's
`QueryMultiNodePackets()` — a string contains check on `decoded_json`
for each pubkey — but without the network round-trip.

**`test-frontend-helpers.js`** — Added 5 unit tests for the filter
logic:
- Single and multiple pubkey matching
- No matches / empty keys edge case
- Null/empty `decoded_json` handled gracefully

**`public/index.html`** — Cache busters bumped.

## Test Results

- Frontend helpers: **232 passed, 0 failed** (including 5 new tests)
- Packet filter: **62 passed, 0 failed**
- Aging: **29 passed, 0 failed**

Co-authored-by: you <you@example.com>
This commit is contained in:
Kpa-clawbot
2026-04-01 08:27:06 -07:00
committed by GitHub
co-authored by you
parent 8f3d12eca5
commit 5f50e80931
3 changed files with 83 additions and 33 deletions
+28 -28
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=1775022775">
<link rel="stylesheet" href="home.css?v=1775022775">
<link rel="stylesheet" href="live.css?v=1775022775">
<link rel="stylesheet" href="style.css?v=1775055000">
<link rel="stylesheet" href="home.css?v=1775055000">
<link rel="stylesheet" href="live.css?v=1775055000">
<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=1775022775"></script>
<script src="customize.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1775022775"></script>
<script src="hop-resolver.js?v=1775022775"></script>
<script src="hop-display.js?v=1775022775"></script>
<script src="app.js?v=1775022775"></script>
<script src="home.js?v=1775022775"></script>
<script src="packet-filter.js?v=1775022775"></script>
<script src="packets.js?v=1775022775"></script>
<script src="geo-filter-overlay.js?v=1775022775"></script>
<script src="map.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="compare.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="roles.js?v=1775055000"></script>
<script src="customize.js?v=1775055000" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1775055000"></script>
<script src="hop-resolver.js?v=1775055000"></script>
<script src="hop-display.js?v=1775055000"></script>
<script src="app.js?v=1775055000"></script>
<script src="home.js?v=1775055000"></script>
<script src="packet-filter.js?v=1775055000"></script>
<script src="packets.js?v=1775055000"></script>
<script src="geo-filter-overlay.js?v=1775055000"></script>
<script src="map.js?v=1775055000" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1775055000" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1775055000" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1775055000" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1775055000" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1775055000" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1775055000" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1775055000" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1775055000" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1775055000" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1775055000" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1775055000" onerror="console.error('Failed to load:', this.src)"></script>
<script src="compare.js?v=1775055000" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1775055000" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1775055000" onerror="console.error('Failed to load:', this.src)"></script>
</body>
</html>
+5 -5
View File
@@ -997,7 +997,7 @@
const groupBtn = document.getElementById('fGroup');
if (groupBtn) groupBtn.classList.toggle('active', groupByHash);
// Filter to claimed/favorited nodes if toggle is on — use server-side multi-node lookup
// Filter to claimed/favorited nodes — pure client-side filter (no server round-trip)
let displayPackets = packets;
if (filters.myNodes) {
const myNodes = JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]');
@@ -1005,10 +1005,10 @@
const favs = getFavorites();
const allKeys = [...new Set([...myKeys, ...favs])];
if (allKeys.length > 0) {
try {
const myData = await api('/packets?nodes=' + allKeys.join(',') + '&limit=500');
displayPackets = myData.packets || [];
} catch { displayPackets = []; }
displayPackets = displayPackets.filter(p => {
const dj = p.decoded_json || '';
return allKeys.some(k => dj.includes(k));
});
} else {
displayPackets = [];
}
+50
View File
@@ -2642,6 +2642,56 @@ console.log('\n=== packets.js: savedTimeWindowMin defaults ===');
assert.ok(deltaMin > 10 && deltaMin < 25, `expected capped ~15m window, got ${deltaMin.toFixed(2)}m`);
});
}
// ===== My Nodes client-side filter (issue #381) =====
{
console.log('\n--- My Nodes client-side filter ---');
// Simulate the client-side filter logic from packets.js renderTableRows()
function filterMyNodes(packets, allKeys) {
if (!allKeys.length) return [];
return packets.filter(p => {
const dj = p.decoded_json || '';
return allKeys.some(k => dj.includes(k));
});
}
const testPackets = [
{ decoded_json: '{"pubKey":"abc123","name":"Node1"}' },
{ decoded_json: '{"pubKey":"def456","name":"Node2"}' },
{ decoded_json: '{"pubKey":"ghi789","name":"Node3","hops":["abc123"]}' },
{ decoded_json: '' },
{ decoded_json: null },
];
test('filters packets matching a single pubkey', () => {
const result = filterMyNodes(testPackets, ['abc123']);
assert.strictEqual(result.length, 2, 'should match sender + hop');
assert.ok(result[0].decoded_json.includes('abc123'));
assert.ok(result[1].decoded_json.includes('abc123'));
});
test('filters packets matching multiple pubkeys', () => {
const result = filterMyNodes(testPackets, ['abc123', 'def456']);
assert.strictEqual(result.length, 3);
});
test('returns empty array for no matching keys', () => {
const result = filterMyNodes(testPackets, ['zzz999']);
assert.strictEqual(result.length, 0);
});
test('returns empty array when allKeys is empty', () => {
const result = filterMyNodes(testPackets, []);
assert.strictEqual(result.length, 0);
});
test('handles null/empty decoded_json gracefully', () => {
const result = filterMyNodes(testPackets, ['abc123']);
// Should not throw, null decoded_json packets are skipped
assert.strictEqual(result.length, 2);
});
}
// ===== SUMMARY =====
Promise.allSettled(pendingTests).then(() => {
console.log(`\n${'═'.repeat(40)}`);