From 5cc733258367bc2edc70b57b0152cbcfcade66f4 Mon Sep 17 00:00:00 2001 From: efiten Date: Thu, 21 May 2026 05:56:58 +0200 Subject: [PATCH] =?UTF-8?q?feat(live):=20clickable=20path=20overlay=20?= =?UTF-8?q?=E2=80=94=20packet=20info=20popup=20(closes=20#771=20M2)=20(#92?= =?UTF-8?q?3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a path animation completes, keeps an invisible clickable polyline on the map for 30s. Clicking it shows a compact Leaflet popup with type badge, hop chain, relative time, and a link to the full packets page. Popup auto-dismisses after 20s. ## Changes - `clickablePathsLayer`: new Leaflet layer for invisible hit-target polylines - `buildClickablePathPopupHtml()`: pure function generating popup HTML (type badge, hop chain, time, hash link) - `pruneClickablePaths()`: TTL (30s) + FIFO eviction (max 50); runs on existing `_pruneInterval` - `registerClickablePath()`: adds invisible polyline with click → popup handler - `animatePath()`: accepts optional `pktMeta` (`hash`, `ts`); calls `registerClickablePath` on completion - Teardown clears `clickablePathsLayer` and `clickablePaths` ## Tests 7 new unit tests; 77 pass, 0 regressions. Closes #771 (M2 of 3) --------- Co-authored-by: Claude Sonnet 4.6 --- public/live.css | 8 +++++ public/live.js | 75 +++++++++++++++++++++++++++++++++++++++--- test-e2e-playwright.js | 3 ++ test-live.js | 74 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 155 insertions(+), 5 deletions(-) diff --git a/public/live.css b/public/live.css index b4b2477b..7d7676bb 100644 --- a/public/live.css +++ b/public/live.css @@ -1267,3 +1267,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 afe360bc..c7104977 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; @@ -529,6 +533,52 @@ if (VCR.replayTimer) { clearTimeout(VCR.replayTimer); VCR.replayTimer = null; } } + 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 ? '' : ''} +
`; + } + + function pruneClickablePaths(now) { + const cutoff = now - CLICKABLE_PATH_TTL_MS; + 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(); + } + } + + 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); + pruneClickablePaths(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); @@ -1125,6 +1175,7 @@ nodesLayer = L.layerGroup().addTo(map); pathsLayer = L.layerGroup().addTo(map); animLayer = L.layerGroup().addTo(map); + clickablePathsLayer = L.layerGroup().addTo(map); injectSVGFilters(); await loadNodes(); @@ -2379,10 +2430,14 @@ for (var aKey in nodeActivity) { if (!(aKey in nodeData)) delete nodeActivity[aKey]; } + 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; }; @@ -2611,6 +2666,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; @@ -2629,7 +2685,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); } @@ -2637,7 +2693,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); } } } @@ -2746,7 +2802,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++; @@ -2758,6 +2814,14 @@ activeAnims = Math.max(0, activeAnims - 1); const countEl = document.getElementById('liveAnimCount'); if (countEl) countEl.textContent = activeAnims; + if (pktMeta && hopPositions.length >= 2) { + 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; } if (!animLayer) return; @@ -3532,7 +3596,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-e2e-playwright.js b/test-e2e-playwright.js index 31643c86..15958b83 100644 --- a/test-e2e-playwright.js +++ b/test-e2e-playwright.js @@ -261,6 +261,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'); @@ -275,6 +277,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 diff --git a/test-live.js b/test-live.js index 80017782..0de5add2 100644 --- a/test-live.js +++ b/test-live.js @@ -978,6 +978,80 @@ 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('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(); + loadPaths([ + { addedAt: now - 35000, poly: { remove() {} } }, + { addedAt: now - 5000, poly: { remove() {} } }, + { addedAt: now - 1000, poly: { remove() {} } }, + ]); + pruneClickablePaths(now); + assert.strictEqual(clickablePaths.length, 2, 'should remove paths older than 30s'); + }); + + test('pruneClickablePaths keeps all entries within TTL', () => { + const now = Date.now(); + loadPaths([ + { addedAt: now - 5000, poly: { remove() {} } }, + { addedAt: now - 1000, poly: { remove() {} } }, + ]); + pruneClickablePaths(now); + assert.strictEqual(clickablePaths.length, 2); + }); + + test('pruneClickablePaths enforces max 50 entries (FIFO eviction)', () => { + const now = Date.now(); + // 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'); + }); +} + // ===== SUMMARY ===== Promise.allSettled(pendingTests).then(() => { console.log(`\n${'═'.repeat(40)}`);