mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-08-28 12:44:12 +00:00
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/<id> - Path Visualization replaced with SVG graph showing all observed paths - Removed redundant Observer Details table (same data as timeline)
This commit is contained in:
+1
-1
@@ -86,7 +86,7 @@
|
||||
<script src="map.js?v=1774028201" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=1774028201" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="nodes.js?v=1774042199" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=1773972187" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=1774048777" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="analytics.js?v=1774042199" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="live.js?v=1774046040" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observers.js?v=1774018095" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
|
||||
+146
-44
@@ -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 `<a href="#/observers/${encodeURIComponent(t.observer)}" style="color:var(--accent);text-decoration:none;" title="${escapeHtml(t.observer)}">${label}</a>`;
|
||||
}
|
||||
|
||||
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 = `<div class="trace-empty" style="color:#ef4444">Error: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
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 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${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 => `<span class="trace-path-hop">${h}</span>`).join('<span class="trace-path-arrow">→</span>');
|
||||
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 += `<line x1="${p1.x}" y1="${p1.y}" x2="${p2.x}" y2="${p2.y}" stroke="${color}" stroke-width="${thickness}" stroke-opacity="0.6"><title>${escapeHtml(title)}</title></line>`;
|
||||
// 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 += `<polygon points="${ax},${ay} ${a1x},${a1y} ${a2x},${a2y}" fill="${color}" opacity="0.8"/>`;
|
||||
}
|
||||
|
||||
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 += `<circle cx="${pos.x}" cy="${pos.y}" r="${r}" fill="${fill}" stroke="${stroke}" stroke-width="2"/>`;
|
||||
nodesSvg += `<text x="${pos.x}" y="${pos.y + 4}" text-anchor="middle" fill="white" font-size="${isEndpoint ? 10 : 9}" font-weight="${isEndpoint ? 700 : 500}">${escapeHtml(label)}</text>`;
|
||||
}
|
||||
|
||||
// Legend: unique paths
|
||||
const uniquePaths = [...new Set(allPaths.map(p => p.hops.join('→')))];
|
||||
const legendHtml = uniquePaths.length > 1
|
||||
? `<div class="trace-path-info" style="margin-top:8px">${uniquePaths.length} unique path${uniquePaths.length > 1 ? 's' : ''} observed by ${allPaths.length} observer${allPaths.length > 1 ? 's' : ''}</div>`
|
||||
: `<div class="trace-path-info">${allPaths[0].hops.length} hop${allPaths[0].hops.length !== 1 ? 's' : ''} in relay path</div>`;
|
||||
|
||||
return `
|
||||
<div class="trace-section">
|
||||
<h3>Path Visualization</h3>
|
||||
<div class="trace-path-viz">
|
||||
<span class="trace-path-label">Origin</span>
|
||||
<span class="trace-path-arrow">→</span>
|
||||
${arrows}
|
||||
<span class="trace-path-arrow">→</span>
|
||||
<span class="trace-path-label">Dest</span>
|
||||
<h3>Path Graph</h3>
|
||||
<div style="overflow-x:auto;">
|
||||
<svg width="${svgW}" height="${svgH}" style="display:block;margin:0 auto;">
|
||||
${edgesSvg}
|
||||
${nodesSvg}
|
||||
</svg>
|
||||
</div>
|
||||
<div class="trace-path-info">${hops.length} hop${hops.length !== 1 ? 's' : ''} in relay path</div>
|
||||
${legendHtml}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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 `<div class="tl-row">
|
||||
<div class="tl-observer">${truncate(t.observer || '—', 20)}</div>
|
||||
<div class="tl-observer">${obsLink(t)}</div>
|
||||
<div class="tl-bar-container">
|
||||
<div class="tl-marker" style="left:${pct}%" title="${time.toISOString()}"></div>
|
||||
</div>
|
||||
@@ -172,27 +296,5 @@
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderObserverTable() {
|
||||
const rows = traceData.map((t, i) => {
|
||||
const snrClass = t.snr != null ? (t.snr >= 0 ? 'good' : t.snr >= -10 ? 'ok' : 'bad') : '';
|
||||
return `<tr>
|
||||
<td>${i + 1}</td>
|
||||
<td class="mono">${t.observer || '—'}</td>
|
||||
<td>${t.time ? new Date(t.time).toLocaleString() : '—'}</td>
|
||||
<td class="tl-snr ${snrClass}">${t.snr != null ? t.snr.toFixed(1) + ' dB' : '—'}</td>
|
||||
<td>${t.rssi != null ? t.rssi.toFixed(0) + ' dBm' : '—'}</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
return `
|
||||
<div class="trace-section">
|
||||
<h3>Observer Details</h3>
|
||||
<table class="data-table" id="traceObsTable">
|
||||
<thead><tr><th>#</th><th>Observer</th><th>Timestamp</th><th>SNR</th><th>RSSI</th></tr></thead>
|
||||
<tbody>${rows.join('')}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
registerPage('traces', { init, destroy });
|
||||
})();
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user