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 `
`;
+ }
+
+ 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)}`);