diff --git a/public/index.html b/public/index.html
index b4032989..56e0053b 100644
--- a/public/index.html
+++ b/public/index.html
@@ -86,7 +86,7 @@
-
+
diff --git a/public/traces.js b/public/traces.js
index 2781db74..c47d1297 100644
--- a/public/traces.js
+++ b/public/traces.js
@@ -37,6 +37,16 @@
packetMeta = null;
}
+ function obsLabel(t) {
+ return t.observer_name || (t.observer && t.observer.length > 16 ? t.observer.slice(0, 12) + '…' : t.observer) || '—';
+ }
+
+ function obsLink(t) {
+ const label = escapeHtml(obsLabel(t));
+ if (!t.observer) return label;
+ return `${label}`;
+ }
+
async function doTrace() {
const input = document.getElementById('traceHashInput');
const hash = input.value.trim();
@@ -60,14 +70,23 @@
return;
}
- // Extract path from first packet that has it
- let pathHops = [];
- for (const p of packets) {
+ // Extract ALL unique paths from observations
+ const allPaths = [];
+ for (const t of traceData) {
try {
- const hops = JSON.parse(p.path_json || '[]');
- if (hops.length > 0) { pathHops = hops; break; }
+ const hops = JSON.parse(t.path_json || '[]');
+ if (hops.length > 0) allPaths.push({ hops, observer: obsLabel(t) });
} catch {}
}
+ // Fallback to packet-level path
+ if (allPaths.length === 0) {
+ for (const p of packets) {
+ try {
+ const hops = JSON.parse(p.path_json || '[]');
+ if (hops.length > 0) { allPaths.push({ hops, observer: 'packet' }); break; }
+ } catch {}
+ }
+ }
// Get packet type info from first packet
packetMeta = packets[0] || null;
@@ -76,13 +95,13 @@
try { decoded = JSON.parse(packetMeta.decoded_json); } catch {}
}
- renderResults(results, pathHops, decoded);
+ renderResults(results, allPaths, decoded);
} catch (e) {
results.innerHTML = `
Error: ${e.message}
`;
}
}
- function renderResults(container, pathHops, decoded) {
+ function renderResults(container, allPaths, 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';
@@ -118,31 +137,136 @@
- ${pathHops.length > 0 ? renderPathViz(pathHops) : ''}
+ ${allPaths.length > 0 ? renderPathGraph(allPaths) : ''}
${traceData.length > 0 ? renderTimeline(t0, spreadMs) : ''}
- ${renderObserverTable()}
`;
- makeColumnsResizable('#traceObsTable', 'meshcore-trace-col-widths');
}
- function renderPathViz(hops) {
- const arrows = hops.map(h => `${h}`).join('→');
+ function renderPathGraph(allPaths) {
+ // Collect unique nodes and edges across all observed paths
+ const nodeSet = new Set();
+ const edgeMap = new Map(); // "from→to" => Set of observer labels
+ nodeSet.add('Origin');
+ nodeSet.add('Dest');
+
+ for (const { hops, observer } of allPaths) {
+ const chain = ['Origin', ...hops, 'Dest'];
+ for (let i = 0; i < chain.length - 1; i++) {
+ nodeSet.add(chain[i]);
+ nodeSet.add(chain[i + 1]);
+ const key = chain[i] + '→' + chain[i + 1];
+ if (!edgeMap.has(key)) edgeMap.set(key, new Set());
+ edgeMap.get(key).add(observer);
+ }
+ }
+
+ const nodes = [...nodeSet];
+ // Assign positions: lay out nodes left to right by their earliest appearance in any path
+ const order = new Map();
+ order.set('Origin', 0);
+ let maxCol = 0;
+ for (const { hops } of allPaths) {
+ const chain = ['Origin', ...hops, 'Dest'];
+ for (let i = 0; i < chain.length; i++) {
+ if (!order.has(chain[i])) {
+ order.set(chain[i], i);
+ }
+ maxCol = Math.max(maxCol, i);
+ }
+ }
+ order.set('Dest', maxCol);
+
+ // Group nodes by column for vertical stacking
+ const colGroups = new Map();
+ for (const [node, col] of order) {
+ if (!colGroups.has(col)) colGroups.set(col, []);
+ colGroups.get(col).push(node);
+ }
+
+ const colCount = maxCol + 1;
+ const svgW = Math.max(600, colCount * 140);
+ const maxRows = Math.max(...[...colGroups.values()].map(g => g.length));
+ const svgH = Math.max(120, maxRows * 60 + 40);
+ const colSpacing = svgW / (colCount + 1);
+
+ // Compute node positions
+ const nodePos = new Map();
+ for (const [col, group] of colGroups) {
+ const rowSpacing = svgH / (group.length + 1);
+ group.forEach((node, i) => {
+ nodePos.set(node, { x: (col + 1) * colSpacing, y: (i + 1) * rowSpacing });
+ });
+ }
+
+ // Colors for edges (cycle through)
+ const edgeColors = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16'];
+ const observerColorMap = new Map();
+ let colorIdx = 0;
+ for (const obsSet of edgeMap.values()) {
+ for (const obs of obsSet) {
+ if (!observerColorMap.has(obs)) {
+ observerColorMap.set(obs, edgeColors[colorIdx % edgeColors.length]);
+ colorIdx++;
+ }
+ }
+ }
+
+ // Build SVG
+ let edgesSvg = '';
+ for (const [key, observers] of edgeMap) {
+ const [from, to] = key.split('→');
+ const p1 = nodePos.get(from);
+ const p2 = nodePos.get(to);
+ if (!p1 || !p2) continue;
+ const obsArr = [...observers];
+ const thickness = Math.min(obsArr.length, 6);
+ // Use first observer's color, show count as tooltip
+ const color = observerColorMap.get(obsArr[0]) || '#6b7280';
+ const title = obsArr.length > 1 ? `${obsArr.length} observers: ${obsArr.join(', ')}` : obsArr[0];
+ edgesSvg += `${escapeHtml(title)}`;
+ // Arrowhead
+ const angle = Math.atan2(p2.y - p1.y, p2.x - p1.x);
+ const arrowLen = 8;
+ const ax = p2.x - 20 * Math.cos(angle);
+ const ay = p2.y - 20 * Math.sin(angle);
+ const a1x = ax - arrowLen * Math.cos(angle - 0.4);
+ const a1y = ay - arrowLen * Math.sin(angle - 0.4);
+ const a2x = ax - arrowLen * Math.cos(angle + 0.4);
+ const a2y = ay - arrowLen * Math.sin(angle + 0.4);
+ edgesSvg += ``;
+ }
+
+ let nodesSvg = '';
+ for (const [node, pos] of nodePos) {
+ const isEndpoint = node === 'Origin' || node === 'Dest';
+ const r = isEndpoint ? 18 : 14;
+ const fill = isEndpoint ? 'var(--primary, #3b82f6)' : 'var(--surface-2, #374151)';
+ const stroke = isEndpoint ? 'var(--primary, #3b82f6)' : 'var(--border, #4b5563)';
+ const label = isEndpoint ? node : node;
+ nodesSvg += ``;
+ nodesSvg += `${escapeHtml(label)}`;
+ }
+
+ // Legend: unique paths
+ const uniquePaths = [...new Set(allPaths.map(p => p.hops.join('→')))];
+ const legendHtml = uniquePaths.length > 1
+ ? `${uniquePaths.length} unique path${uniquePaths.length > 1 ? 's' : ''} observed by ${allPaths.length} observer${allPaths.length > 1 ? 's' : ''}
`
+ : `${allPaths[0].hops.length} hop${allPaths[0].hops.length !== 1 ? 's' : ''} in relay path
`;
+
return `
-
Path Visualization
-
-
Origin
-
→
- ${arrows}
-
→
-
Dest
+
Path Graph
+
+
-
${hops.length} hop${hops.length !== 1 ? 's' : ''} in relay path
+ ${legendHtml}
`;
}
function renderTimeline(t0, spreadMs) {
- // Build timeline bars
const barWidth = spreadMs > 0 ? spreadMs : 1;
const rows = traceData.map((t, i) => {
const time = new Date(t.time);
@@ -152,7 +276,7 @@
const delta = spreadMs > 0 ? `+${(offsetMs / 1000).toFixed(3)}s` : '';
return `
-
${truncate(t.observer || '—', 20)}
+
${obsLink(t)}
@@ -172,27 +296,5 @@
`;
}
- function renderObserverTable() {
- const rows = traceData.map((t, i) => {
- const snrClass = t.snr != null ? (t.snr >= 0 ? 'good' : t.snr >= -10 ? 'ok' : 'bad') : '';
- return `
- | ${i + 1} |
- ${t.observer || '—'} |
- ${t.time ? new Date(t.time).toLocaleString() : '—'} |
- ${t.snr != null ? t.snr.toFixed(1) + ' dB' : '—'} |
- ${t.rssi != null ? t.rssi.toFixed(0) + ' dBm' : '—'} |
-
`;
- });
-
- return `
-
-
Observer Details
-
- | # | Observer | Timestamp | SNR | RSSI |
- ${rows.join('')}
-
-
`;
- }
-
registerPage('traces', { init, destroy });
})();
diff --git a/server.js b/server.js
index bc3b355e..614c1ca9 100644
--- a/server.js
+++ b/server.js
@@ -1847,7 +1847,14 @@ app.get('/api/observers/:id/analytics', (req, res) => {
app.get('/api/traces/:hash', (req, res) => {
const packets = (pktStore.getSiblings(req.params.hash) || []).sort((a,b) => a.timestamp > b.timestamp ? 1 : -1);
- const traces = packets.map(p => ({ observer: p.observer_id, time: p.timestamp, snr: p.snr, rssi: p.rssi }));
+ const traces = packets.map(p => ({
+ observer: p.observer_id,
+ observer_name: p.observer_name || null,
+ time: p.timestamp,
+ snr: p.snr,
+ rssi: p.rssi,
+ path_json: p.path_json || null
+ }));
res.json({ traces });
});