From 9713e972f9292bb05cd2ec3f9657bd233575cb2c Mon Sep 17 00:00:00 2001 From: you Date: Fri, 20 Mar 2026 23:19:37 +0000 Subject: [PATCH] traces: clickable observer names, path graph, remove redundant observer table - API returns observer_name + path_json per observation - Timeline shows resolved names with links to #/observers/ - Path Visualization replaced with SVG graph showing all observed paths - Removed redundant Observer Details table (same data as timeline) --- public/index.html | 2 +- public/traces.js | 190 +++++++++++++++++++++++++++++++++++----------- server.js | 9 ++- 3 files changed, 155 insertions(+), 46 deletions(-) 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

+
+ + ${edgesSvg} + ${nodesSvg} +
-
${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

- - - ${rows.join('')} -
#ObserverTimestampSNRRSSI
-
`; - } - 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 }); });