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);