diff --git a/public/app.js b/public/app.js index 50d03145..e3d2ea46 100644 --- a/public/app.js +++ b/public/app.js @@ -1080,6 +1080,7 @@ window.addEventListener('DOMContentLoaded', () => { // Belt-and-braces null guards (#1105 MINOR 4): the outer block measures // and mutates all of these; if any are missing the layout math throws // before we can fall back gracefully. + let navPriorityFn = null; if (navMoreBtn && navMoreMenu && navMoreWrap && navLeft && navRightEl && linksContainer && navTop) { // Measure available room and decide which links overflow. // Algorithm: try to fit all links inline. If the link strip doesn't @@ -1244,6 +1245,7 @@ window.addEventListener('DOMContentLoaded', () => { // Run once on load, again after fonts settle (label widths shift), // and on resize (debounced via rAF). + navPriorityFn = applyNavPriority; applyNavPriority(); if (document.fonts && document.fonts.ready) { document.fonts.ready.then(applyNavPriority); @@ -1467,6 +1469,7 @@ window.addEventListener('DOMContentLoaded', () => { el.innerHTML = `${stats.totalPackets} pkts · ${stats.totalNodes} nodes · ${stats.totalObservers} obs${formatVersionBadge(stats.version, stats.commit, stats.engine, stats.buildTime)}`; el.querySelectorAll('.stat-val').forEach(s => s.classList.add('updated')); setTimeout(() => { el.querySelectorAll('.stat-val').forEach(s => s.classList.remove('updated')); }, 600); + if (navPriorityFn) requestAnimationFrame(navPriorityFn); } } catch {} } diff --git a/public/traces.js b/public/traces.js index 7b78eeaf..3adb5be4 100644 --- a/public/traces.js +++ b/public/traces.js @@ -69,14 +69,19 @@ return; } - // Extract ALL unique paths from observations - const allPaths = []; + // Extract unique paths from observations. + // Drop partial paths that are a prefix of a longer observed path — these are + // the same packet seen at intermediate relay nodes before it reached the final + // hop, not genuinely different routes. Keeping them creates confusing long + // "shortcut" edges in the path graph that visually obscure the actual route. + const allPathsRaw = []; for (const t of traceData) { try { const hops = JSON.parse(t.path_json || '[]'); - if (hops.length > 0) allPaths.push({ hops, observer: obsLabel(t) }); + if (hops.length > 0) allPathsRaw.push({ hops, observer: obsLabel(t) }); } catch {} } + const allPaths = dedupePrefixPaths(allPathsRaw); // Fallback to packet-level path if (allPaths.length === 0) { for (const p of packets) { @@ -94,13 +99,13 @@ try { decoded = JSON.parse(packetMeta.decoded_json); } catch {} } - renderResults(results, allPaths, decoded); + renderResults(results, allPaths, allPathsRaw, decoded); } catch (e) { results.innerHTML = `
Error: ${e.message}
`; } } - function renderResults(container, allPaths, decoded) { + function renderResults(container, allPaths, allPathsRaw, decoded) { const uniqueObservers = [...new Set(traceData.map(t => t.observer))]; const typeName = packetMeta ? payloadTypeName(packetMeta.payload_type) : '—'; const typeClass = packetMeta ? payloadTypeColor(packetMeta.payload_type) : 'unknown'; @@ -136,12 +141,12 @@ - ${allPaths.length > 0 ? renderPathGraph(allPaths) : ''} + ${allPaths.length > 0 ? renderPathGraph(allPaths, allPathsRaw) : ''} ${traceData.length > 0 ? renderTimeline(t0, spreadMs) : ''} `; } - function renderPathGraph(allPaths) { + function renderPathGraph(allPaths, allPathsRaw) { // Collect unique nodes and edges across all observed paths const nodeSet = new Set(); const edgeMap = new Map(); // "from→to" => Set of observer labels @@ -159,6 +164,20 @@ } } + // Attribute prefix observers to the edges they witnessed. + // Prefix paths are dropped from allPaths to avoid spurious layout edges, but + // their observers still corroborated the shared prefix segment — credit them + // to the edges that exist in the full-path graph. + const allPathsSet = new Set(allPaths); + for (const entry of allPathsRaw) { + if (allPathsSet.has(entry)) continue; + const chain = ['Origin', ...entry.hops]; // no 'Dest': prefix stopped here + for (let i = 0; i < chain.length - 1; i++) { + const key = chain[i] + '→' + chain[i + 1]; + if (edgeMap.has(key)) edgeMap.get(key).add(entry.observer); + } + } + const nodes = [...nodeSet]; // Assign positions: lay out nodes left to right by their earliest appearance in any path const order = new Map(); @@ -185,7 +204,7 @@ const colCount = maxCol + 1; const svgW = Math.max(600, colCount * 200); const maxRows = Math.max(...[...colGroups.values()].map(g => g.length)); - const svgH = Math.max(120, maxRows * 60 + 40); + const svgH = Math.max(160, maxRows * 80 + 60); const colSpacing = svgW / (colCount + 1); // Compute node positions @@ -238,12 +257,16 @@ let nodesSvg = ''; for (const [node, pos] of nodePos) { const isEndpoint = node === 'Origin' || node === 'Dest'; - const r = isEndpoint ? 18 : 14; - const fill = isEndpoint ? 'var(--accent, #3b82f6)' : 'var(--surface-2, #374151)'; - const stroke = isEndpoint ? 'var(--accent, #3b82f6)' : 'var(--border, #4b5563)'; - const label = isEndpoint ? node : node; + const r = isEndpoint ? 18 : 16; + const fill = isEndpoint ? 'var(--accent, #3b82f6)' : 'var(--accent-bg, rgba(59,130,246,0.12))'; + const stroke = isEndpoint ? 'var(--accent, #3b82f6)' : 'var(--accent, #3b82f6)'; nodesSvg += ``; - nodesSvg += `${escapeHtml(label)}`; + if (isEndpoint) { + nodesSvg += `${escapeHtml(node)}`; + } else { + // Label below the circle so it doesn't fight for space inside the small node + nodesSvg += `${escapeHtml(node)}`; + } } // Legend: unique paths @@ -295,5 +318,19 @@ `; } + function dedupePrefixPaths(rawPaths) { + return rawPaths.filter(({ hops }) => { + const sig = JSON.stringify(hops); + return !rawPaths.some(other => { + if (other.hops.length <= hops.length) return false; + return JSON.stringify(other.hops.slice(0, hops.length)) === sig; + }); + }); + } + + if (typeof window !== 'undefined') { + window.TracesHelpers = { dedupePrefixPaths }; + } + registerPage('traces', { init, destroy }); })(); diff --git a/test-all.sh b/test-all.sh index 2208a643..0f366352 100755 --- a/test-all.sh +++ b/test-all.sh @@ -25,6 +25,7 @@ node test-channel-qr-wiring.js node test-channel-issue-1087.js node test-analytics-channels-integration.js node test-observers-headings.js +node test-traces.js echo "" echo "═══════════════════════════════════════" diff --git a/test-traces.js b/test-traces.js new file mode 100644 index 00000000..a5d36ff0 --- /dev/null +++ b/test-traces.js @@ -0,0 +1,126 @@ +/* Unit tests for traces.js helpers (tested via VM sandbox) */ +'use strict'; +const vm = require('vm'); +const fs = require('fs'); +const assert = require('assert'); + +let passed = 0, failed = 0; +function test(name, fn) { + try { + fn(); + passed++; + console.log(` ✅ ${name}`); + } catch (e) { + failed++; + console.log(` ❌ ${name}: ${e.message}`); + } +} + +function makeSandbox() { + const ctx = { + window: { addEventListener: () => {}, dispatchEvent: () => {} }, + document: { + readyState: 'complete', + createElement: () => ({ id: '', textContent: '', innerHTML: '', addEventListener() {} }), + head: { appendChild: () => {} }, + getElementById: () => null, + addEventListener: () => {}, + querySelectorAll: () => [], + querySelector: () => null, + }, + console, + Date, Infinity, Math, Array, Object, String, Number, JSON, RegExp, Error, + parseInt, parseFloat, isNaN, isFinite, + encodeURIComponent, decodeURIComponent, + setTimeout: () => {}, clearTimeout: () => {}, + setInterval: () => {}, clearInterval: () => {}, + fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }), + performance: { now: () => Date.now() }, + localStorage: (() => { + const store = {}; + return { + getItem: k => store[k] || null, + setItem: (k, v) => { store[k] = String(v); }, + removeItem: k => { delete store[k]; }, + }; + })(), + location: { hash: '' }, + CustomEvent: class CustomEvent {}, + Map, Set, Promise, URLSearchParams, + addEventListener: () => {}, + dispatchEvent: () => {}, + requestAnimationFrame: (cb) => setTimeout(cb, 0), + registerPage: () => {}, + payloadTypeName: () => '', + payloadTypeColor: () => '', + escapeHtml: s => s, + }; + vm.createContext(ctx); + return ctx; +} + +function loadTracesJs(ctx) { + vm.runInContext(fs.readFileSync('public/traces.js', 'utf8'), ctx); + for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; +} + +// ===== dedupePrefixPaths tests ===== +console.log('\n=== traces.js: dedupePrefixPaths ==='); +{ + const ctx = makeSandbox(); + loadTracesJs(ctx); + const { dedupePrefixPaths } = ctx.TracesHelpers; + + test('two strict-prefix observations: only longer kept', () => { + const a = { hops: ['x', 'y'], observer: 'A' }; + const b = { hops: ['x', 'y', 'z'], observer: 'B' }; + const result = dedupePrefixPaths([a, b]); + assert.deepStrictEqual(result, [b]); + }); + + test('two identical-length identical-path observations: both kept', () => { + const a = { hops: ['x', 'y'], observer: 'A' }; + const b = { hops: ['x', 'y'], observer: 'B' }; + const result = dedupePrefixPaths([a, b]); + assert.deepStrictEqual(result, [a, b]); + }); + + test('two divergent paths: both kept', () => { + const a = { hops: ['x', 'y'], observer: 'A' }; + const b = { hops: ['x', 'z'], observer: 'B' }; + const result = dedupePrefixPaths([a, b]); + assert.deepStrictEqual(result, [a, b]); + }); + + test('empty hops array: not dropped (no superseder possible)', () => { + const a = { hops: [], observer: 'A' }; + const b = { hops: ['x'], observer: 'B' }; + const result = dedupePrefixPaths([a, b]); + // a has length 0, b has length 1; b.slice(0,0) = [] === [] so a IS a prefix of b + // a should be dropped + assert.ok(!result.includes(a), 'empty-hops path should be dropped when superseded'); + assert.ok(result.includes(b)); + }); + + test('three-level prefix chain (A⊂B⊂C): only C kept', () => { + const a = { hops: ['x'], observer: 'A' }; + const b = { hops: ['x', 'y'], observer: 'B' }; + const c = { hops: ['x', 'y', 'z'], observer: 'C' }; + const result = dedupePrefixPaths([a, b, c]); + assert.deepStrictEqual(result, [c]); + }); + + test('multiple observers on identical full path: all kept', () => { + const a = { hops: ['x', 'y', 'z'], observer: 'A' }; + const b = { hops: ['x', 'y', 'z'], observer: 'B' }; + const c = { hops: ['x', 'y', 'z'], observer: 'C' }; + const result = dedupePrefixPaths([a, b, c]); + assert.deepStrictEqual(result, [a, b, c]); + }); +} + +// ===== SUMMARY ===== +console.log(`\n${'═'.repeat(40)}`); +console.log(` traces.js: ${passed} passed, ${failed} failed`); +console.log(`${'═'.repeat(40)}\n`); +if (failed > 0) process.exit(1);