From ae32e3b43a5c5cfb429cb04eab759087d54ba4a9 Mon Sep 17 00:00:00 2001 From: efiten Date: Tue, 28 Apr 2026 13:43:03 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(live):=20clickable=20path=20overlay=20?= =?UTF-8?q?=E2=80=94=20packet=20info=20popup=20on=20path=20click=20(closes?= =?UTF-8?q?=20#771=20M2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a path animation completes, registers an invisible clickable polyline (weight 12, opacity 0) on a dedicated clickablePathsLayer for 30s. Clicking shows a Leaflet popup with type badge, hop chain, relative time, and a link to the packets page. Popup auto-dismisses after 20s. Max 50 paths retained (FIFO eviction); TTL pruning runs on the existing _pruneInterval. Co-Authored-By: Claude Sonnet 4.6 --- public/live.js | 69 ++++++++++++++++++++++++++++++++++++++++++++++---- test-live.js | 63 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 5 deletions(-) diff --git a/public/live.js b/public/live.js index 52b5a04e..bd31f22c 100644 --- a/public/live.js +++ b/public/live.js @@ -9,7 +9,11 @@ function cssVar(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); } function statusGreen() { return cssVar('--status-green') || '#22c55e'; } - let map, ws, nodesLayer, pathsLayer, animLayer, heatLayer, geoFilterLayer; + let map, ws, nodesLayer, pathsLayer, animLayer, heatLayer, geoFilterLayer, clickablePathsLayer; + let clickablePaths = []; + const CLICKABLE_PATH_TTL_MS = 30000; + const CLICKABLE_PATH_MAX = 50; + const CLICKABLE_POPUP_DISMISS_MS = 20000; let nodeMarkers = {}; let nodeData = {}; let packetCount = 0; @@ -423,6 +427,50 @@ if (VCR.replayTimer) { clearTimeout(VCR.replayTimer); VCR.replayTimer = null; } } + function buildClickablePathPopupHtml(typeName, color, hopNames, tsMs, hash) { + const secsAgo = Math.round((Date.now() - tsMs) / 1000); + const timeStr = secsAgo < 60 ? secsAgo + 's ago' : Math.round(secsAgo / 60) + 'm ago'; + const chain = hopNames.join(' → '); + const link = hash ? `full detail →` : ''; + return `
+ ${typeName} +
${timeStr}
+
${chain}
+ ${link ? '
' + link + '
' : ''} +
`; + } + + function pruneClickablePaths(paths, now) { + const cutoff = now - CLICKABLE_PATH_TTL_MS; + const alive = paths.filter(p => { + if (p.addedAt < cutoff) { try { p.poly.remove(); } catch (_) {} return false; } + return true; + }); + while (alive.length > CLICKABLE_PATH_MAX) { + const evicted = alive.shift(); + try { evicted.poly.remove(); } catch (_) {} + } + return alive; + } + + function registerClickablePath(latLngs, typeName, color, hopNames, tsMs, hash) { + if (!clickablePathsLayer) return; + const poly = L.polyline(latLngs, { weight: 12, opacity: 0, interactive: true }).addTo(clickablePathsLayer); + const entry = { addedAt: Date.now(), poly }; + clickablePaths.push(entry); + clickablePaths = pruneClickablePaths(clickablePaths, Date.now()); + let dismissTimer = null; + poly.on('click', function(e) { + if (dismissTimer) clearTimeout(dismissTimer); + const html = buildClickablePathPopupHtml(typeName, color, hopNames, tsMs, hash); + L.popup({ maxWidth: 280, className: 'path-info-popup' }) + .setLatLng(e.latlng) + .setContent(html) + .openOn(map); + dismissTimer = setTimeout(() => { if (map) map.closePopup(); }, CLICKABLE_POPUP_DISMISS_MS); + }); + } + function vcrSpeedCycle() { const speeds = [1, 2, 4, 8]; const idx = speeds.indexOf(VCR.speed); @@ -975,6 +1023,7 @@ nodesLayer = L.layerGroup().addTo(map); pathsLayer = L.layerGroup().addTo(map); animLayer = L.layerGroup().addTo(map); + clickablePathsLayer = L.layerGroup().addTo(map); injectSVGFilters(); await loadNodes(); @@ -2106,10 +2155,13 @@ for (var aKey in nodeActivity) { if (!(aKey in nodeData)) delete nodeActivity[aKey]; } + clickablePaths = pruneClickablePaths(clickablePaths, Date.now()); } // Expose for testing window._livePruneStaleNodes = pruneStaleNodes; + window._liveBuildClickablePathPopupHtml = buildClickablePathPopupHtml; + window._livePruneClickablePaths = pruneClickablePaths; window._liveNodeMarkers = function() { return nodeMarkers; }; window._liveNodeData = function() { return nodeData; }; window._liveNodeActivity = function() { return nodeActivity; }; @@ -2330,6 +2382,7 @@ // --- Animate all unique paths simultaneously --- // First path gets audio sync hook, rest are visual-only + var pktMeta = { hash: first.hash, ts: first._ts || Date.now() }; var firstPathDone = false; for (var ai = 0; ai < allPaths.length; ai++) { var onHop = null; @@ -2348,7 +2401,7 @@ var completedPositions = allPaths[ai].hopPositions.slice(0, hopsCompleted + 1); var remainingPositions = allPaths[ai].hopPositions.slice(hopsCompleted); if (completedPositions.length >= 2) { - animatePath(completedPositions, typeName, color, allPaths[ai].raw, onHop, first.hash); + animatePath(completedPositions, typeName, color, allPaths[ai].raw, onHop, pktMeta); } else if (completedPositions.length === 1) { pulseNode(completedPositions[0].key, completedPositions[0].pos, typeName); } @@ -2356,7 +2409,7 @@ drawDashedPath(remainingPositions, color); } } else { - animatePath(allPaths[ai].hopPositions, typeName, color, allPaths[ai].raw, onHop, first.hash); + animatePath(allPaths[ai].hopPositions, typeName, color, allPaths[ai].raw, onHop, pktMeta); } } } @@ -2465,7 +2518,7 @@ return raw.filter(h => h.pos != null); } - function animatePath(hopPositions, typeName, color, rawHex, onHop, hash) { + function animatePath(hopPositions, typeName, color, rawHex, onHop, pktMeta) { if (!animLayer || !pathsLayer) return; if (activeAnims >= MAX_CONCURRENT_ANIMS) return; activeAnims++; @@ -2477,6 +2530,11 @@ activeAnims = Math.max(0, activeAnims - 1); const countEl = document.getElementById('liveAnimCount'); if (countEl) countEl.textContent = activeAnims; + if (pktMeta && hopPositions.length >= 2) { + const latLngs = hopPositions.map(hp => hp.pos); + const hopNames = hopPositions.map(hp => hp.name || (hp.key ? hp.key.slice(0, 8) : '?')); + registerClickablePath(latLngs, typeName, color, hopNames, pktMeta.ts, pktMeta.hash); + } return; } if (!animLayer) return; @@ -3244,7 +3302,8 @@ } _navCleanup = null; } - nodesLayer = pathsLayer = animLayer = heatLayer = geoFilterLayer = null; + nodesLayer = pathsLayer = animLayer = heatLayer = geoFilterLayer = clickablePathsLayer = null; + clickablePaths = []; stopMatrixRain(); nodeMarkers = {}; nodeData = {}; activeNodeDetailKey = null; diff --git a/test-live.js b/test-live.js index 80017782..a872de77 100644 --- a/test-live.js +++ b/test-live.js @@ -978,6 +978,69 @@ console.log('\n=== live.js: node filter ==='); }); } +// ===== Clickable paths (M2 — #771) ===== +console.log('\n=== live.js: clickable paths ==='); +{ + const ctx = makeLiveSandbox(); + const buildPopupHtml = ctx.window._liveBuildClickablePathPopupHtml; + assert.ok(buildPopupHtml, '_liveBuildClickablePathPopupHtml must be exposed'); + + test('buildClickablePathPopupHtml includes type badge with color', () => { + const html = buildPopupHtml('GRP_TXT', '#22c55e', ['NodeA', 'Rpt1', 'NodeB'], Date.now() - 5000); + assert.ok(html.includes('GRP_TXT'), 'should include type name'); + assert.ok(html.includes('#22c55e'), 'should include type color'); + }); + + test('buildClickablePathPopupHtml includes hop chain', () => { + const html = buildPopupHtml('ADVERT', '#6b7280', ['Alpha', 'Beta', 'Gamma'], Date.now() - 3000); + assert.ok(html.includes('Alpha'), 'should include first hop'); + assert.ok(html.includes('Beta'), 'should include middle hop'); + assert.ok(html.includes('Gamma'), 'should include last hop'); + }); + + test('buildClickablePathPopupHtml includes packet link', () => { + const html = buildPopupHtml('GRP_TXT', '#22c55e', ['A', 'B'], Date.now() - 1000, 'abc123def'); + assert.ok(html.includes('abc123def'), 'should include packet hash link'); + }); + + test('buildClickablePathPopupHtml shows relative time', () => { + const html = buildPopupHtml('GRP_TXT', '#22c55e', ['A', 'B'], Date.now() - 10000); + assert.ok(html.includes('s ago') || html.includes('ago'), 'should show relative time'); + }); + + const pruneClickablePaths = ctx.window._livePruneClickablePaths; + assert.ok(pruneClickablePaths, '_livePruneClickablePaths must be exposed'); + + test('pruneClickablePaths removes entries older than TTL', () => { + const now = Date.now(); + const paths = [ + { addedAt: now - 35000, poly: { remove() {} } }, + { addedAt: now - 5000, poly: { remove() {} } }, + { addedAt: now - 1000, poly: { remove() {} } }, + ]; + const remaining = pruneClickablePaths(paths, now); + assert.strictEqual(remaining.length, 2, 'should remove paths older than 30s'); + }); + + test('pruneClickablePaths keeps all entries within TTL', () => { + const now = Date.now(); + const paths = [ + { addedAt: now - 5000, poly: { remove() {} } }, + { addedAt: now - 1000, poly: { remove() {} } }, + ]; + const remaining = pruneClickablePaths(paths, now); + assert.strictEqual(remaining.length, 2); + }); + + test('pruneClickablePaths enforces max 50 entries (FIFO eviction)', () => { + const now = Date.now(); + const paths = []; + for (let i = 0; i < 52; i++) paths.push({ addedAt: now - i * 100, poly: { remove() {} } }); + const remaining = pruneClickablePaths(paths, now); + assert.strictEqual(remaining.length, 50, 'should evict oldest beyond 50'); + }); +} + // ===== SUMMARY ===== Promise.allSettled(pendingTests).then(() => { console.log(`\n${'═'.repeat(40)}`); From 15ae7dc1c7747a3364b4e47593010e3bcddfee7a Mon Sep 17 00:00:00 2001 From: efiten Date: Mon, 27 Apr 2026 13:47:54 +0200 Subject: [PATCH 2/3] test(e2e): fix detached-element race in node detail tests page.$() captures an element handle at query time. When the nodes page WebSocket auto-refresh fires between the querySelector and the .click() call, the table is re-rendered and the element is detached, causing "Element is not attached to the DOM". Replace both occurrences with page.click(selector), which re-queries the DOM at click time and retries until the element is stable. Co-Authored-By: Claude Sonnet 4.6 --- test-e2e-playwright.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test-e2e-playwright.js b/test-e2e-playwright.js index 1783af27..5be966bc 100644 --- a/test-e2e-playwright.js +++ b/test-e2e-playwright.js @@ -225,6 +225,8 @@ async function run() { // Test 5: Node detail loads (reuses nodes page from test 2) await test('Node detail loads', async () => { await page.waitForSelector('table tbody tr:not([id^=vscroll])'); + // Use page.click() instead of an element handle to avoid detached-element races + // when the WebSocket auto-refresh re-renders the table between querySelector and click. await page.click('table tbody tr:not([id^=vscroll])'); // Wait for detail pane to appear await page.waitForSelector('.node-detail'); @@ -239,6 +241,7 @@ async function run() { await page.goto(`${BASE}/#/nodes`, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('[data-loaded="true"]', { timeout: 15000 }); await page.waitForSelector('table tbody tr:not([id^=vscroll])'); + // Use page.click() to avoid detached-element race with WebSocket auto-refresh. await page.click('table tbody tr:not([id^=vscroll])'); await page.waitForSelector('.node-detail'); // Find the Details link in the side panel From 29e2a46b81c8aa76bf29194250be5d84b79efa65 Mon Sep 17 00:00:00 2001 From: efiten Date: Sat, 2 May 2026 15:41:54 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix(live):=20address=20PR=20#923=20review?= =?UTF-8?q?=20=E2=80=94=20refactor=20prune,=20fuse=20loops,=20extract=20CS?= =?UTF-8?q?S,=20sharpen=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pruneClickablePaths: operates on module-level array directly (no params, no return); both call sites updated - Animation completion: fuse two .map() calls into single loop (one allocation) - buildClickablePathPopupHtml: move static styles to live.css classes; keep only dynamic background color inline; add comment that tsMs is packet receive time - Tests: assert exact '10s ago' string; rewrite prune tests to use exposed _liveClickablePaths array; add FIFO eviction order assertion Co-Authored-By: Claude Sonnet 4.6 --- public/live.css | 8 ++++++++ public/live.js | 44 +++++++++++++++++++++++++------------------- test-live.js | 37 ++++++++++++++++++++++++------------- 3 files changed, 57 insertions(+), 32 deletions(-) diff --git a/public/live.css b/public/live.css index c5e67ea7..d16e3876 100644 --- a/public/live.css +++ b/public/live.css @@ -956,3 +956,11 @@ transition: none; } } + +/* Clickable path popup */ +.lc-path-popup { font-size: 12px; line-height: 1.6; min-width: 160px; } +.lc-path-badge { color: #fff; border-radius: 3px; padding: 1px 5px; font-size: 11px; font-weight: 600; } +.lc-path-time { margin-top: 4px; color: var(--text-muted); font-size: 11px; } +.lc-path-chain { margin-top: 4px; word-break: break-word; } +.lc-path-link-wrap { margin-top: 4px; } +.lc-path-link { font-size: 11px; } diff --git a/public/live.js b/public/live.js index bd31f22c..a165f38e 100644 --- a/public/live.js +++ b/public/live.js @@ -428,29 +428,31 @@ } function buildClickablePathPopupHtml(typeName, color, hopNames, tsMs, hash) { + // tsMs is packet receive time — "ago" is relative to when the packet arrived, not when the animation ended const secsAgo = Math.round((Date.now() - tsMs) / 1000); const timeStr = secsAgo < 60 ? secsAgo + 's ago' : Math.round(secsAgo / 60) + 'm ago'; const chain = hopNames.join(' → '); - const link = hash ? `full detail →` : ''; - return `
- ${typeName} -
${timeStr}
-
${chain}
- ${link ? '
' + link + '
' : ''} + const link = hash ? `full detail →` : ''; + return `
+ ${typeName} +
${timeStr}
+
${chain}
+ ${link ? '' : ''}
`; } - function pruneClickablePaths(paths, now) { + function pruneClickablePaths(now) { const cutoff = now - CLICKABLE_PATH_TTL_MS; - const alive = paths.filter(p => { - if (p.addedAt < cutoff) { try { p.poly.remove(); } catch (_) {} return false; } - return true; - }); - while (alive.length > CLICKABLE_PATH_MAX) { - const evicted = alive.shift(); - try { evicted.poly.remove(); } catch (_) {} + for (let i = clickablePaths.length - 1; i >= 0; i--) { + if (clickablePaths[i].addedAt < cutoff) { + try { clickablePaths[i].poly.remove(); } catch (_) {} + clickablePaths.splice(i, 1); + } + } + while (clickablePaths.length > CLICKABLE_PATH_MAX) { + try { clickablePaths[0].poly.remove(); } catch (_) {} + clickablePaths.shift(); } - return alive; } function registerClickablePath(latLngs, typeName, color, hopNames, tsMs, hash) { @@ -458,7 +460,7 @@ const poly = L.polyline(latLngs, { weight: 12, opacity: 0, interactive: true }).addTo(clickablePathsLayer); const entry = { addedAt: Date.now(), poly }; clickablePaths.push(entry); - clickablePaths = pruneClickablePaths(clickablePaths, Date.now()); + pruneClickablePaths(Date.now()); let dismissTimer = null; poly.on('click', function(e) { if (dismissTimer) clearTimeout(dismissTimer); @@ -2155,13 +2157,14 @@ for (var aKey in nodeActivity) { if (!(aKey in nodeData)) delete nodeActivity[aKey]; } - clickablePaths = pruneClickablePaths(clickablePaths, Date.now()); + pruneClickablePaths(Date.now()); } // Expose for testing window._livePruneStaleNodes = pruneStaleNodes; window._liveBuildClickablePathPopupHtml = buildClickablePathPopupHtml; window._livePruneClickablePaths = pruneClickablePaths; + window._liveClickablePaths = clickablePaths; window._liveNodeMarkers = function() { return nodeMarkers; }; window._liveNodeData = function() { return nodeData; }; window._liveNodeActivity = function() { return nodeActivity; }; @@ -2531,8 +2534,11 @@ const countEl = document.getElementById('liveAnimCount'); if (countEl) countEl.textContent = activeAnims; if (pktMeta && hopPositions.length >= 2) { - const latLngs = hopPositions.map(hp => hp.pos); - const hopNames = hopPositions.map(hp => hp.name || (hp.key ? hp.key.slice(0, 8) : '?')); + const latLngs = [], hopNames = []; + for (const hp of hopPositions) { + latLngs.push(hp.pos); + hopNames.push(hp.name || (hp.key ? hp.key.slice(0, 8) : '?')); + } registerClickablePath(latLngs, typeName, color, hopNames, pktMeta.ts, pktMeta.hash); } return; diff --git a/test-live.js b/test-live.js index a872de77..0de5add2 100644 --- a/test-live.js +++ b/test-live.js @@ -1005,39 +1005,50 @@ console.log('\n=== live.js: clickable paths ==='); test('buildClickablePathPopupHtml shows relative time', () => { const html = buildPopupHtml('GRP_TXT', '#22c55e', ['A', 'B'], Date.now() - 10000); - assert.ok(html.includes('s ago') || html.includes('ago'), 'should show relative time'); + assert.ok(html.includes('10s ago'), 'should show 10s ago'); }); const pruneClickablePaths = ctx.window._livePruneClickablePaths; + const clickablePaths = ctx.window._liveClickablePaths; assert.ok(pruneClickablePaths, '_livePruneClickablePaths must be exposed'); + assert.ok(Array.isArray(clickablePaths), '_liveClickablePaths must be exposed'); + + function loadPaths(entries) { + clickablePaths.splice(0, clickablePaths.length, ...entries); + } test('pruneClickablePaths removes entries older than TTL', () => { const now = Date.now(); - const paths = [ + loadPaths([ { addedAt: now - 35000, poly: { remove() {} } }, { addedAt: now - 5000, poly: { remove() {} } }, { addedAt: now - 1000, poly: { remove() {} } }, - ]; - const remaining = pruneClickablePaths(paths, now); - assert.strictEqual(remaining.length, 2, 'should remove paths older than 30s'); + ]); + pruneClickablePaths(now); + assert.strictEqual(clickablePaths.length, 2, 'should remove paths older than 30s'); }); test('pruneClickablePaths keeps all entries within TTL', () => { const now = Date.now(); - const paths = [ + loadPaths([ { addedAt: now - 5000, poly: { remove() {} } }, { addedAt: now - 1000, poly: { remove() {} } }, - ]; - const remaining = pruneClickablePaths(paths, now); - assert.strictEqual(remaining.length, 2); + ]); + pruneClickablePaths(now); + assert.strictEqual(clickablePaths.length, 2); }); test('pruneClickablePaths enforces max 50 entries (FIFO eviction)', () => { const now = Date.now(); - const paths = []; - for (let i = 0; i < 52; i++) paths.push({ addedAt: now - i * 100, poly: { remove() {} } }); - const remaining = pruneClickablePaths(paths, now); - assert.strictEqual(remaining.length, 50, 'should evict oldest beyond 50'); + // Match production insertion order: oldest at front (index 0), newest at back + // entries[0].addedAt = now-5100 (oldest), entries[51].addedAt = now (newest) + const entries = []; + for (let i = 51; i >= 0; i--) entries.push({ addedAt: now - i * 100, poly: { remove() {} } }); + loadPaths(entries); + pruneClickablePaths(now); + assert.strictEqual(clickablePaths.length, 50, 'should evict oldest beyond 50'); + // FIFO: the 2 oldest (addedAt now-5100 and now-5000) were shifted off; now-4900 is oldest remaining + assert.strictEqual(clickablePaths[0].addedAt, now - 49 * 100, 'oldest remaining should have addedAt = now-4900'); }); }