mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-23 13:52:23 +00:00
Add subpath detail sidebar with minimap and analytics
Click any route in Route Patterns to open detail sidebar showing: - Minimap with nodes and route line (green=origin, red=dest, amber=hops) - Signal quality (avg SNR/RSSI) - Activity by hour (UTC) bar chart - First/last seen timestamps - Observers that captured this route - Full parent paths containing this subpath Split layout with scrollable list + fixed sidebar, responsive on mobile.
This commit is contained in:
1 parent
f2631980e2
commit
a0c4290535
3 files changed
+219
-8
No files matched your search
+107
-8
@@ -788,7 +788,7 @@
|
||||
const hasSelfLoop = hops.some((h, j) => j > 0 && h === hops[j - 1]);
|
||||
const routeDisplay = hops.map(h => esc(h)).join(' → ');
|
||||
const prefixDisplay = rawHops.join(' → ');
|
||||
return `<tr${hasSelfLoop ? ' class="subpath-selfloop"' : ''}>
|
||||
return `<tr data-hops="${esc(rawHops.join(','))}" ${hasSelfLoop ? 'class="subpath-selfloop"' : ''} style="cursor:pointer">
|
||||
<td>${i + 1}</td>
|
||||
<td style="white-space:nowrap">${routeDisplay}${hasSelfLoop ? ' <span title="Contains self-loop — likely 1-byte prefix collision" style="cursor:help">🔄</span>' : ''}<br><span class="hop-prefix mono">${esc(prefixDisplay)}</span></td>
|
||||
<td>${s.count.toLocaleString()}</td>
|
||||
@@ -800,19 +800,118 @@
|
||||
}
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="analytics-section">
|
||||
<h3>🛤️ Route Pattern Analysis</h3>
|
||||
<p>Most common subpaths in the mesh — reveals backbone routes, bottlenecks, and preferred relay chains regardless of where they appear in the full path.</p>
|
||||
${renderTable(d2, 'Pairs (2-hop links)')}
|
||||
${renderTable(d3, 'Triples (3-hop chains)')}
|
||||
${renderTable(d4, 'Quads (4-hop chains)')}
|
||||
${renderTable(d5, 'Long chains (5+ hops)')}
|
||||
<div class="subpath-layout">
|
||||
<div class="subpath-list" id="subpathList">
|
||||
<h3>🛤️ Route Pattern Analysis</h3>
|
||||
<p>Click a route to see details. Most common subpaths — reveals backbone routes, bottlenecks, and preferred relay chains.</p>
|
||||
${renderTable(d2, 'Pairs (2-hop links)')}
|
||||
${renderTable(d3, 'Triples (3-hop chains)')}
|
||||
${renderTable(d4, 'Quads (4-hop chains)')}
|
||||
${renderTable(d5, 'Long chains (5+ hops)')}
|
||||
</div>
|
||||
<div class="subpath-detail" id="subpathDetail">
|
||||
<div class="text-muted" style="padding:40px;text-align:center">Select a route to view details</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Click handler for rows
|
||||
el.addEventListener('click', e => {
|
||||
const tr = e.target.closest('tr[data-hops]');
|
||||
if (!tr) return;
|
||||
el.querySelectorAll('tr.subpath-selected').forEach(r => r.classList.remove('subpath-selected'));
|
||||
tr.classList.add('subpath-selected');
|
||||
loadSubpathDetail(tr.dataset.hops);
|
||||
});
|
||||
} catch (e) {
|
||||
el.innerHTML = `<div class="text-muted">Error loading subpath data: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSubpathDetail(hopsStr) {
|
||||
const panel = document.getElementById('subpathDetail');
|
||||
panel.innerHTML = '<div class="text-center text-muted" style="padding:40px">Loading…</div>';
|
||||
try {
|
||||
const data = await api('/analytics/subpath-detail?hops=' + encodeURIComponent(hopsStr));
|
||||
renderSubpathDetail(panel, data);
|
||||
} catch (e) {
|
||||
panel.innerHTML = `<div class="text-muted">Error: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSubpathDetail(panel, data) {
|
||||
const nodesWithLoc = data.nodes.filter(n => n.lat && n.lon && !(n.lat === 0 && n.lon === 0));
|
||||
const hasMap = nodesWithLoc.length >= 2;
|
||||
const maxHour = Math.max(...data.hourDistribution, 1);
|
||||
|
||||
panel.innerHTML = `
|
||||
<div class="subpath-detail-inner">
|
||||
<h4>${data.nodes.map(n => esc(n.name)).join(' → ')}</h4>
|
||||
<div class="subpath-meta">
|
||||
<span class="hop-prefix mono">${data.hops.join(' → ')}</span>
|
||||
<span>${data.totalMatches.toLocaleString()} occurrences</span>
|
||||
</div>
|
||||
|
||||
${hasMap ? '<div id="subpathMap" style="height:200px;border-radius:8px;margin:12px 0;border:1px solid var(--border,#e5e7eb)"></div>' : ''}
|
||||
|
||||
<div class="subpath-section">
|
||||
<h5>📡 Signal Quality</h5>
|
||||
${data.signal.avgSnr != null
|
||||
? `<div>Avg SNR: <strong>${data.signal.avgSnr} dB</strong> · Avg RSSI: <strong>${data.signal.avgRssi} dBm</strong> · ${data.signal.samples} samples</div>`
|
||||
: '<div class="text-muted">No signal data</div>'}
|
||||
</div>
|
||||
|
||||
<div class="subpath-section">
|
||||
<h5>🕐 Activity by Hour (UTC)</h5>
|
||||
<div class="hour-chart">
|
||||
${data.hourDistribution.map((c, h) => `<div class="hour-bar" title="${h}:00 UTC — ${c} packets" style="height:${Math.max(2, c / maxHour * 100)}%"></div>`).join('')}
|
||||
</div>
|
||||
<div class="hour-labels"><span>0</span><span>6</span><span>12</span><span>18</span><span>23</span></div>
|
||||
</div>
|
||||
|
||||
<div class="subpath-section">
|
||||
<h5>⏱️ Timeline</h5>
|
||||
<div>First seen: ${data.firstSeen ? new Date(data.firstSeen).toLocaleString() : '—'}</div>
|
||||
<div>Last seen: ${data.lastSeen ? new Date(data.lastSeen).toLocaleString() : '—'}</div>
|
||||
</div>
|
||||
|
||||
${data.observers.length ? `
|
||||
<div class="subpath-section">
|
||||
<h5>👁️ Observers</h5>
|
||||
${data.observers.map(o => `<div>${esc(o.name)}: ${o.count}</div>`).join('')}
|
||||
</div>` : ''}
|
||||
|
||||
${data.parentPaths.length ? `
|
||||
<div class="subpath-section">
|
||||
<h5>🔗 Full Paths Containing This Route</h5>
|
||||
<div class="parent-paths">
|
||||
${data.parentPaths.map(p => `<div class="parent-path"><span class="mono" style="font-size:0.85em">${esc(p.path)}</span> <span class="text-muted">×${p.count}</span></div>`).join('')}
|
||||
</div>
|
||||
</div>` : ''}
|
||||
</div>`;
|
||||
|
||||
// Render minimap
|
||||
if (hasMap && typeof L !== 'undefined') {
|
||||
const map = L.map('subpathMap', { zoomControl: false, attributionControl: false });
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { maxZoom: 18 }).addTo(map);
|
||||
|
||||
const latlngs = [];
|
||||
nodesWithLoc.forEach((n, i) => {
|
||||
const ll = [n.lat, n.lon];
|
||||
latlngs.push(ll);
|
||||
const isEnd = i === 0 || i === nodesWithLoc.length - 1;
|
||||
L.circleMarker(ll, {
|
||||
radius: isEnd ? 8 : 5,
|
||||
color: isEnd ? (i === 0 ? '#22c55e' : '#ef4444') : '#f59e0b',
|
||||
fillColor: isEnd ? (i === 0 ? '#22c55e' : '#ef4444') : '#f59e0b',
|
||||
fillOpacity: 0.9, weight: 2
|
||||
}).bindTooltip(n.name, { permanent: false }).addTo(map);
|
||||
});
|
||||
|
||||
L.polyline(latlngs, { color: '#f59e0b', weight: 3, dashArray: '8,6', opacity: 0.8 }).addTo(map);
|
||||
map.fitBounds(L.latLngBounds(latlngs).pad(0.3));
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() { delete window._analyticsData; }
|
||||
|
||||
registerPage('analytics', { init, destroy });
|
||||
|
||||
@@ -1153,3 +1153,28 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
|
||||
|
||||
/* Hop prefix in subpath routes */
|
||||
.hop-prefix { color: #9ca3af; font-size: 0.8em; }
|
||||
|
||||
/* Subpath split layout */
|
||||
.subpath-layout { display: flex; gap: 0; height: calc(100vh - 160px); }
|
||||
.subpath-list { flex: 1; overflow-y: auto; padding: 16px; }
|
||||
.subpath-detail { width: 380px; min-width: 320px; border-left: 1px solid var(--border, #e5e7eb); overflow-y: auto; padding: 16px; }
|
||||
.subpath-detail-inner h4 { margin: 0 0 4px; }
|
||||
.subpath-meta { display: flex; flex-direction: column; gap: 2px; margin-bottom: 12px; color: #9ca3af; font-size: 0.9em; }
|
||||
.subpath-section { margin: 16px 0; }
|
||||
.subpath-section h5 { margin: 0 0 6px; font-size: 0.9em; }
|
||||
.subpath-selected { background: var(--accent, #3b82f6) !important; color: #fff; }
|
||||
.subpath-selected .hop-prefix { color: rgba(255,255,255,0.6); }
|
||||
tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
|
||||
|
||||
/* Hour distribution chart */
|
||||
.hour-chart { display: flex; align-items: flex-end; gap: 2px; height: 60px; }
|
||||
.hour-bar { flex: 1; background: var(--accent, #3b82f6); border-radius: 2px 2px 0 0; min-width: 4px; }
|
||||
.hour-labels { display: flex; justify-content: space-between; font-size: 0.7em; color: #9ca3af; }
|
||||
|
||||
/* Parent paths */
|
||||
.parent-path { padding: 3px 0; border-bottom: 1px solid var(--border, #e5e7eb); }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.subpath-layout { flex-direction: column; height: auto; }
|
||||
.subpath-detail { width: 100%; border-left: none; border-top: 1px solid var(--border, #e5e7eb); }
|
||||
}
|
||||
@@ -1160,6 +1160,93 @@ app.get('/api/analytics/subpaths', (req, res) => {
|
||||
res.json({ subpaths: ranked, totalPaths });
|
||||
});
|
||||
|
||||
// Subpath detail — stats for a specific subpath (by raw hop prefixes)
|
||||
app.get('/api/analytics/subpath-detail', (req, res) => {
|
||||
const rawHops = (req.query.hops || '').split(',').filter(Boolean);
|
||||
if (rawHops.length < 2) return res.json({ error: 'Need at least 2 hops' });
|
||||
|
||||
const packets = db.db.prepare(`SELECT path_json, snr, rssi, timestamp, decoded_json, observer_name FROM packets WHERE path_json IS NOT NULL AND path_json != '[]'`).all();
|
||||
const allNodes = db.db.prepare('SELECT public_key, name, lat, lon FROM nodes WHERE name IS NOT NULL').all();
|
||||
|
||||
const nameCache = {};
|
||||
function resolveName(hop) {
|
||||
if (nameCache[hop] !== undefined) return nameCache[hop];
|
||||
const h = hop.toLowerCase();
|
||||
const m = allNodes.find(n => n.public_key.toLowerCase().startsWith(h));
|
||||
nameCache[hop] = m ? { name: m.name, lat: m.lat, lon: m.lon, pubkey: m.public_key } : null;
|
||||
return nameCache[hop];
|
||||
}
|
||||
|
||||
const matching = [];
|
||||
const parentPaths = {};
|
||||
const hourBuckets = new Array(24).fill(0);
|
||||
let snrSum = 0, snrCount = 0, rssiSum = 0, rssiCount = 0;
|
||||
const observers = {};
|
||||
|
||||
for (const pkt of packets) {
|
||||
let hops;
|
||||
try { hops = JSON.parse(pkt.path_json); } catch { continue; }
|
||||
if (!Array.isArray(hops) || hops.length < rawHops.length) continue;
|
||||
|
||||
// Check if rawHops appears as a contiguous subsequence
|
||||
let found = false;
|
||||
for (let i = 0; i <= hops.length - rawHops.length; i++) {
|
||||
let match = true;
|
||||
for (let j = 0; j < rawHops.length; j++) {
|
||||
if (hops[i + j].toLowerCase() !== rawHops[j].toLowerCase()) { match = false; break; }
|
||||
}
|
||||
if (match) { found = true; break; }
|
||||
}
|
||||
if (!found) continue;
|
||||
|
||||
matching.push(pkt);
|
||||
const hr = new Date(pkt.timestamp).getUTCHours();
|
||||
hourBuckets[hr]++;
|
||||
if (pkt.snr != null) { snrSum += pkt.snr; snrCount++; }
|
||||
if (pkt.rssi != null) { rssiSum += pkt.rssi; rssiCount++; }
|
||||
if (pkt.observer_name) observers[pkt.observer_name] = (observers[pkt.observer_name] || 0) + 1;
|
||||
|
||||
// Track full parent paths (resolved names)
|
||||
const fullPath = hops.map(h => {
|
||||
const r = resolveName(h);
|
||||
return r ? r.name : h;
|
||||
}).join(' → ');
|
||||
parentPaths[fullPath] = (parentPaths[fullPath] || 0) + 1;
|
||||
}
|
||||
|
||||
// Resolve hop nodes for map
|
||||
const nodes = rawHops.map(h => {
|
||||
const r = resolveName(h);
|
||||
return { hop: h, name: r?.name || h, lat: r?.lat || null, lon: r?.lon || null, pubkey: r?.pubkey || null };
|
||||
});
|
||||
|
||||
const topParents = Object.entries(parentPaths)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 15)
|
||||
.map(([path, count]) => ({ path, count }));
|
||||
|
||||
const topObservers = Object.entries(observers)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([name, count]) => ({ name, count }));
|
||||
|
||||
res.json({
|
||||
hops: rawHops,
|
||||
nodes,
|
||||
totalMatches: matching.length,
|
||||
firstSeen: matching.length ? matching[0].timestamp : null,
|
||||
lastSeen: matching.length ? matching[matching.length - 1].timestamp : null,
|
||||
signal: {
|
||||
avgSnr: snrCount ? Math.round(snrSum / snrCount * 10) / 10 : null,
|
||||
avgRssi: rssiCount ? Math.round(rssiSum / rssiCount) : null,
|
||||
samples: snrCount
|
||||
},
|
||||
hourDistribution: hourBuckets,
|
||||
parentPaths: topParents,
|
||||
observers: topObservers
|
||||
});
|
||||
});
|
||||
|
||||
// Static files + SPA fallback
|
||||
app.use(express.static(path.join(__dirname, 'public'), {
|
||||
etag: false,
|
||||
|
||||
Reference in new issue
Block a user