Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions public/live.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
75 changes: 70 additions & 5 deletions public/live.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 ? `<a class="lc-path-link" href="#/packets/${hash}" style="color:${color}">full detail →</a>` : '';
return `<div class="lc-path-popup">
<span class="lc-path-badge" style="background:${color}">${typeName}</span>
<div class="lc-path-time">${timeStr}</div>
<div class="lc-path-chain">${chain}</div>
${link ? '<div class="lc-path-link-wrap">' + link + '</div>' : ''}
</div>`;
}

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);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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; };
Expand Down Expand Up @@ -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;
Expand All @@ -2348,15 +2404,15 @@
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);
}
if (remainingPositions.length >= 2) {
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);
}
}
}
Expand Down Expand Up @@ -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++;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions test-e2e-playwright.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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
Expand Down
74 changes: 74 additions & 0 deletions test-live.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`);
Expand Down