mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-08-29 09:38:28 +00:00
fix: packets page crash on mobile — time filter default and limit cap (#340)
## Summary Fixes #326 — the packets page crashes mobile browsers (iOS Safari, Edge) by loading 50K+ packets when no time filter is persisted in localStorage. ## Root Cause Two problems in public/packets.js: ### Bug 1: savedTimeWindowMin defaults to 0 instead of 15 localStorage.getItem('meshcore-time-window') returns ull when never set. Number(null) = 0. The guard checked < 0 but not <= 0, so savedTimeWindowMin = 0 meant "All time" — fetching all 50K+ packets. **Fix:** Changed < 0 to <= 0 in both the initialization guard (line 30) and the change handler (line 758). ### Bug 2: No mobile protection against large packet loads Even with valid large time windows, mobile browsers crash under the weight of thousands of DOM rows and packet data (~1.4 GB WebKit memory limit). **Fix:** - Detect mobile viewport: window.innerWidth <= 768 - Cap limit at 1000 on mobile (vs 50000 on desktop) - Disable 6h/12h/24h options and hide "All time" on mobile - Reset persisted windows >3h to 15 min on mobile ## Testing Added 9 unit tests in est-frontend-helpers.js covering: - savedTimeWindowMin defaults to 15 when localStorage returns null - savedTimeWindowMin defaults to 15 when localStorage returns "0" - Valid values (60) are preserved - Negative and NaN values default to 15 - PACKET_LIMIT is 1000 on mobile, 50000 on desktop - Mobile caps large time windows (1440 → 15) but allows 180 All 218 frontend helper tests pass. Packet filter (62) and aging (29) tests also pass. ## Changes | File | Change | |------|--------| | public/packets.js | Fix <= 0 guard, add mobile detection, cap limit, restrict time options | | public/index.html | Cache buster bump | | est-frontend-helpers.js | 9 new regression tests for time window defaults and mobile caps | --------- Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
co-authored by
Kpa-clawbot
Copilot
parent
2d8203ae17
commit
f7c182c5f7
+28
-31
@@ -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=1775020710">
|
||||
<link rel="stylesheet" href="home.css?v=1775020710">
|
||||
<link rel="stylesheet" href="live.css?v=1775020710">
|
||||
<link rel="stylesheet" href="style.css?v=1775021600">
|
||||
<link rel="stylesheet" href="home.css?v=1775021600">
|
||||
<link rel="stylesheet" href="live.css?v=1775021600">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin="anonymous">
|
||||
@@ -81,33 +81,30 @@
|
||||
<main id="app" role="main"></main>
|
||||
|
||||
<script src="vendor/qrcode.js"></script>
|
||||
<script src="roles.js?v=1775020710"></script>
|
||||
<script src="customize.js?v=1775020710" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="region-filter.js?v=1775020710"></script>
|
||||
<script src="hop-resolver.js?v=1775020710"></script>
|
||||
<script src="hop-display.js?v=1775020710"></script>
|
||||
<script src="app.js?v=1775020710"></script>
|
||||
<script src="home.js?v=1775020710"></script>
|
||||
<script src="packet-filter.js?v=1775020710"></script>
|
||||
<script src="packets.js?v=1775020710"></script>
|
||||
<script src="geo-filter-overlay.js?v=1775020710"></script>
|
||||
<script src="map.js?v=1775020710" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=1775020710" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="nodes.js?v=1775020710" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=1775020710" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="analytics.js?v=1775020710" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio.js?v=1775020710" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v1-constellation.js?v=1775020710" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v2-constellation.js?v=1775020710" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-lab.js?v=1775020710" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="live.js?v=1775020710" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observers.js?v=1775020710" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observer-detail.js?v=1775020710" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="compare.js?v=1775020710" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="node-analytics.js?v=1775020710" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="perf.js?v=1775020710" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="roles.js?v=1775021600"></script>
|
||||
<script src="customize.js?v=1775021600" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="region-filter.js?v=1775021600"></script>
|
||||
<script src="hop-resolver.js?v=1775021600"></script>
|
||||
<script src="hop-display.js?v=1775021600"></script>
|
||||
<script src="app.js?v=1775021600"></script>
|
||||
<script src="home.js?v=1775021600"></script>
|
||||
<script src="packet-filter.js?v=1775021600"></script>
|
||||
<script src="packets.js?v=1775021600"></script>
|
||||
<script src="geo-filter-overlay.js?v=1775021600"></script>
|
||||
<script src="map.js?v=1775021600" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=1775021600" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="nodes.js?v=1775021600" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=1775021600" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="analytics.js?v=1775021600" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio.js?v=1775021600" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v1-constellation.js?v=1775021600" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v2-constellation.js?v=1775021600" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-lab.js?v=1775021600" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="live.js?v=1775021600" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observers.js?v=1775021600" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observer-detail.js?v=1775021600" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="compare.js?v=1775021600" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="node-analytics.js?v=1775021600" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="perf.js?v=1775021600" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
|
||||
+10
-7
@@ -24,8 +24,11 @@
|
||||
let regionMap = {};
|
||||
const TYPE_NAMES = { 0:'Request', 1:'Response', 2:'Direct Msg', 3:'ACK', 4:'Advert', 5:'Channel Msg', 7:'Anon Req', 8:'Path', 9:'Trace', 11:'Control' };
|
||||
function typeName(t) { return TYPE_NAMES[t] ?? `Type ${t}`; }
|
||||
const isMobile = window.innerWidth <= 1024;
|
||||
const PACKET_LIMIT = isMobile ? 1000 : 50000;
|
||||
let savedTimeWindowMin = Number(localStorage.getItem('meshcore-time-window'));
|
||||
if (!Number.isFinite(savedTimeWindowMin) || savedTimeWindowMin < 0) savedTimeWindowMin = 15;
|
||||
if (!Number.isFinite(savedTimeWindowMin) || savedTimeWindowMin <= 0) savedTimeWindowMin = 15;
|
||||
if (isMobile && savedTimeWindowMin > 180) savedTimeWindowMin = 15;
|
||||
let totalCount = 0;
|
||||
let expandedHashes = new Set();
|
||||
let hopNameCache = {};
|
||||
@@ -429,7 +432,7 @@
|
||||
const since = new Date(Date.now() - windowMin * 60000).toISOString();
|
||||
params.set('since', since);
|
||||
}
|
||||
params.set('limit', '50000');
|
||||
params.set('limit', String(PACKET_LIMIT));
|
||||
const regionParam = RegionFilter.getRegionParam();
|
||||
if (regionParam) params.set('region', regionParam);
|
||||
if (filters.hash) params.set('hash', filters.hash);
|
||||
@@ -568,10 +571,10 @@
|
||||
<option value="30">Last 30 min</option>
|
||||
<option value="60">Last 1 hour</option>
|
||||
<option value="180">Last 3 hours</option>
|
||||
<option value="360">Last 6 hours</option>
|
||||
<option value="720">Last 12 hours</option>
|
||||
<option value="1440">Last 24 hours</option>
|
||||
<option value="0">All time</option>
|
||||
<option value="360"${isMobile ? ' disabled title="Disabled on mobile to prevent browser crashes"' : ''}>Last 6 hours</option>
|
||||
<option value="720"${isMobile ? ' disabled title="Disabled on mobile to prevent browser crashes"' : ''}>Last 12 hours</option>
|
||||
<option value="1440"${isMobile ? ' disabled title="Disabled on mobile to prevent browser crashes"' : ''}>Last 24 hours</option>
|
||||
${isMobile ? '' : '<option value="0">All time</option>'}
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
@@ -752,7 +755,7 @@
|
||||
fTimeWindow.value = String(savedTimeWindowMin);
|
||||
fTimeWindow.addEventListener('change', () => {
|
||||
savedTimeWindowMin = Number(fTimeWindow.value);
|
||||
if (!Number.isFinite(savedTimeWindowMin) || savedTimeWindowMin < 0) savedTimeWindowMin = 15;
|
||||
if (!Number.isFinite(savedTimeWindowMin) || savedTimeWindowMin <= 0) savedTimeWindowMin = 15;
|
||||
localStorage.setItem('meshcore-time-window', fTimeWindow.value);
|
||||
loadPackets();
|
||||
});
|
||||
|
||||
+20
-5
@@ -363,8 +363,16 @@ async function run() {
|
||||
|
||||
// Test 4: Packets page loads with filter
|
||||
await test('Packets page loads with filter', async () => {
|
||||
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('table tbody tr');
|
||||
// Ensure desktop viewport and broad time window so fixture timestamps are included.
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
// Set time window BEFORE packets.js IIFE re-executes (525600 min ≈ 1 year)
|
||||
await page.evaluate(() => localStorage.setItem('meshcore-time-window', '525600'));
|
||||
// Navigate away so next goto is a full page load (not a same-document hash change).
|
||||
// This guarantees scripts re-execute and packets.js IIFE reads the new localStorage.
|
||||
await page.goto('about:blank');
|
||||
await page.goto(`${BASE}/#/packets`, { waitUntil: 'load' });
|
||||
await page.waitForSelector('table tbody tr', { timeout: 15000 });
|
||||
const rowsBefore = await page.$$('table tbody tr');
|
||||
assert(rowsBefore.length > 0, 'No packets visible');
|
||||
// Use the specific filter input
|
||||
@@ -382,6 +390,8 @@ async function run() {
|
||||
// Navigate to base first to get same-origin context for localStorage
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.evaluate(() => localStorage.setItem('meshcore-time-window', '60'));
|
||||
// Navigate away so next goto is a full page load
|
||||
await page.goto('about:blank');
|
||||
|
||||
const packetsRequestPromise = page.waitForRequest((req) => {
|
||||
try {
|
||||
@@ -392,8 +402,8 @@ async function run() {
|
||||
}
|
||||
}, { timeout: 10000 });
|
||||
|
||||
// Full reload to packets page — forces app to re-read localStorage
|
||||
await page.evaluate(() => { window.location.href = window.location.origin + '/#/packets'; window.location.reload(); });
|
||||
// Full navigation from about:blank — scripts re-execute, IIFE reads localStorage
|
||||
await page.goto(`${BASE}/#/packets`, { waitUntil: 'load' });
|
||||
await page.waitForSelector('#fTimeWindow', { timeout: 10000 });
|
||||
const timeWindowValue = await page.$eval('#fTimeWindow', (el) => el.value);
|
||||
assert(timeWindowValue === '60', `Expected time window dropdown to restore 60, got ${timeWindowValue}`);
|
||||
@@ -417,7 +427,12 @@ async function run() {
|
||||
|
||||
// Test: Packets groupByHash toggle changes view
|
||||
await test('Packets groupByHash toggle works', async () => {
|
||||
await page.waitForSelector('table tbody tr');
|
||||
// Restore wide time window — previous test set it to 60 min which excludes fixture data
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
await page.evaluate(() => localStorage.setItem('meshcore-time-window', '525600'));
|
||||
await page.goto('about:blank');
|
||||
await page.goto(`${BASE}/#/packets`, { waitUntil: 'load' });
|
||||
await page.waitForSelector('table tbody tr', { timeout: 15000 });
|
||||
const groupBtn = await page.$('#fGroup');
|
||||
assert(groupBtn, 'Group by hash button (#fGroup) not found');
|
||||
// Check initial state (default is grouped/active)
|
||||
|
||||
@@ -2522,6 +2522,126 @@ console.log('\n=== channels.js: WS batch + region snapshot integration ===');
|
||||
assert.ok(historyCalls.includes('#/channels'), 'should route back to channels root');
|
||||
});
|
||||
}
|
||||
// ===== PACKETS.JS: savedTimeWindowMin default guard =====
|
||||
console.log('\n=== packets.js: savedTimeWindowMin defaults ===');
|
||||
{
|
||||
async function captureInitialPacketsRequest(storageValue, innerWidth) {
|
||||
const ctx = makeSandbox();
|
||||
const apiCalls = [];
|
||||
if (storageValue !== undefined) ctx.localStorage.setItem('meshcore-time-window', storageValue);
|
||||
ctx.window.localStorage = ctx.localStorage;
|
||||
ctx.window.innerWidth = innerWidth;
|
||||
const dom = {
|
||||
pktRight: { addEventListener() {}, classList: { add() {}, remove() {}, contains() { return false; } }, innerHTML: '' },
|
||||
};
|
||||
ctx.document.getElementById = (id) => {
|
||||
if (id === 'fTimeWindow') return null;
|
||||
return dom[id] || null;
|
||||
};
|
||||
ctx.document.addEventListener = () => {};
|
||||
ctx.document.removeEventListener = () => {};
|
||||
ctx.document.body = { appendChild() {}, removeChild() {}, contains() { return false; } };
|
||||
ctx.window.addEventListener = () => {};
|
||||
ctx.window.removeEventListener = () => {};
|
||||
ctx.RegionFilter = { init() {}, onChange() { return () => {}; }, offChange() {}, getRegionParam() { return ''; } };
|
||||
ctx.CLIENT_TTL = { observers: 120000 };
|
||||
ctx.debouncedOnWS = (fn) => fn;
|
||||
ctx.onWS = () => {};
|
||||
ctx.offWS = () => {};
|
||||
ctx.registerPage = (name, handlers) => { if (name === 'packets') ctx._packetsHandlers = handlers; };
|
||||
ctx.api = (path) => {
|
||||
apiCalls.push(path);
|
||||
if (path.indexOf('/observers') === 0) return Promise.resolve({ observers: [] });
|
||||
if (path.indexOf('/packets?') === 0) return Promise.reject(new Error('stop after request capture'));
|
||||
if (path.indexOf('/config/regions') === 0) return Promise.resolve({});
|
||||
return Promise.resolve({});
|
||||
};
|
||||
|
||||
loadInCtx(ctx, 'public/packets.js');
|
||||
assert.ok(ctx._packetsHandlers && typeof ctx._packetsHandlers.init === 'function',
|
||||
'packets page should register init handler');
|
||||
await ctx._packetsHandlers.init({ innerHTML: '' });
|
||||
|
||||
const firstPacketsCall = apiCalls.find(p => p.indexOf('/packets?') === 0);
|
||||
assert.ok(firstPacketsCall, 'packets API should be called during initial packets page load');
|
||||
const params = new URLSearchParams((firstPacketsCall.split('?')[1] || ''));
|
||||
return { firstPacketsCall, params };
|
||||
}
|
||||
|
||||
test('savedTimeWindowMin defaults to 15 when localStorage returns null', async () => {
|
||||
const r = await captureInitialPacketsRequest(undefined, 1366);
|
||||
const since = r.params.get('since');
|
||||
assert.ok(since, 'initial packets request should include since parameter');
|
||||
const deltaMin = (Date.now() - Date.parse(since)) / 60000;
|
||||
assert.ok(deltaMin > 10 && deltaMin < 25, `expected default ~15m window, got ${deltaMin.toFixed(2)}m`);
|
||||
});
|
||||
|
||||
test('savedTimeWindowMin defaults to 15 when localStorage returns "0"', async () => {
|
||||
const r = await captureInitialPacketsRequest('0', 1366);
|
||||
const since = r.params.get('since');
|
||||
assert.ok(since, 'initial packets request should include since parameter');
|
||||
const deltaMin = (Date.now() - Date.parse(since)) / 60000;
|
||||
assert.ok(deltaMin > 10 && deltaMin < 25, `expected default ~15m window, got ${deltaMin.toFixed(2)}m`);
|
||||
});
|
||||
|
||||
test('savedTimeWindowMin preserves valid value (60)', async () => {
|
||||
const r = await captureInitialPacketsRequest('60', 1366);
|
||||
const since = r.params.get('since');
|
||||
assert.ok(since, 'initial packets request should include since parameter');
|
||||
const deltaMin = (Date.now() - Date.parse(since)) / 60000;
|
||||
assert.ok(deltaMin > 45 && deltaMin < 75, `expected persisted ~60m window, got ${deltaMin.toFixed(2)}m`);
|
||||
});
|
||||
|
||||
test('savedTimeWindowMin defaults to 15 for negative value', async () => {
|
||||
const r = await captureInitialPacketsRequest('-5', 1366);
|
||||
const since = r.params.get('since');
|
||||
assert.ok(since, 'initial packets request should include since parameter');
|
||||
const deltaMin = (Date.now() - Date.parse(since)) / 60000;
|
||||
assert.ok(deltaMin > 10 && deltaMin < 25, `expected default ~15m window, got ${deltaMin.toFixed(2)}m`);
|
||||
});
|
||||
|
||||
test('savedTimeWindowMin defaults to 15 for NaN string', async () => {
|
||||
const r = await captureInitialPacketsRequest('abc', 1366);
|
||||
const since = r.params.get('since');
|
||||
assert.ok(since, 'initial packets request should include since parameter');
|
||||
const deltaMin = (Date.now() - Date.parse(since)) / 60000;
|
||||
assert.ok(deltaMin > 10 && deltaMin < 25, `expected default ~15m window, got ${deltaMin.toFixed(2)}m`);
|
||||
});
|
||||
|
||||
test('PACKET_LIMIT is 1000 on mobile', async () => {
|
||||
const r = await captureInitialPacketsRequest('15', 375);
|
||||
assert.strictEqual(r.params.get('limit'), '1000');
|
||||
});
|
||||
|
||||
test('PACKET_LIMIT is 50000 on desktop', async () => {
|
||||
const r = await captureInitialPacketsRequest('15', 1366);
|
||||
assert.strictEqual(r.params.get('limit'), '50000');
|
||||
});
|
||||
|
||||
test('mobile caps large time window to 15', async () => {
|
||||
const r = await captureInitialPacketsRequest('1440', 375);
|
||||
const since = r.params.get('since');
|
||||
assert.ok(since, 'initial packets request should include since parameter');
|
||||
const deltaMin = (Date.now() - Date.parse(since)) / 60000;
|
||||
assert.ok(deltaMin > 10 && deltaMin < 25, `expected capped ~15m window, got ${deltaMin.toFixed(2)}m`);
|
||||
});
|
||||
|
||||
test('mobile allows 180 min window', async () => {
|
||||
const r = await captureInitialPacketsRequest('180', 375);
|
||||
const since = r.params.get('since');
|
||||
assert.ok(since, 'initial packets request should include since parameter');
|
||||
const deltaMin = (Date.now() - Date.parse(since)) / 60000;
|
||||
assert.ok(deltaMin > 160 && deltaMin < 210, `expected ~180m window, got ${deltaMin.toFixed(2)}m`);
|
||||
});
|
||||
|
||||
test('mobile corrects desktop-persisted all-time value to 15 minutes', async () => {
|
||||
const r = await captureInitialPacketsRequest('0', 375);
|
||||
const since = r.params.get('since');
|
||||
assert.ok(since, 'mobile should not keep all-time persisted value');
|
||||
const deltaMin = (Date.now() - Date.parse(since)) / 60000;
|
||||
assert.ok(deltaMin > 10 && deltaMin < 25, `expected capped ~15m window, got ${deltaMin.toFixed(2)}m`);
|
||||
});
|
||||
}
|
||||
// ===== SUMMARY =====
|
||||
Promise.allSettled(pendingTests).then(() => {
|
||||
console.log(`\n${'═'.repeat(40)}`);
|
||||
|
||||
Reference in New Issue
Block a user