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 52b5a04e..a165f38e 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,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); @@ -975,6 +1025,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 +2157,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; }; @@ -2330,6 +2385,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 +2404,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 +2412,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 +2521,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 +2533,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; @@ -3244,7 +3308,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 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 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)}`);