mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-08-28 08:34:15 +00:00
Add Route Patterns subpage to analytics
New 'Route Patterns' analytics tab showing most common subpaths in the mesh, broken down by length (pairs, triples, quads, long chains). Reveals backbone routes, bottlenecks, and preferred relay chains. Each subpath shows occurrence count, % of all paths, and frequency bar.
This commit is contained in:
+48
-1
@@ -70,6 +70,7 @@
|
||||
<button class="tab-btn" data-tab="topology">Topology</button>
|
||||
<button class="tab-btn" data-tab="channels">Channels</button>
|
||||
<button class="tab-btn" data-tab="hashsizes">Hash Sizes</button>
|
||||
<button class="tab-btn" data-tab="subpaths">Route Patterns</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="analyticsContent" class="analytics-content">
|
||||
@@ -111,6 +112,7 @@
|
||||
case 'topology': renderTopology(el, d.topoData); break;
|
||||
case 'channels': renderChannels(el, d.chanData); break;
|
||||
case 'hashsizes': renderHashSizes(el, d.hashData); break;
|
||||
case 'subpaths': renderSubpaths(el); break;
|
||||
}
|
||||
// Auto-apply column resizing to all analytics tables
|
||||
requestAnimationFrame(() => {
|
||||
@@ -761,7 +763,52 @@
|
||||
} catch { el.innerHTML = '<div class="text-muted">Failed to load</div>'; }
|
||||
}
|
||||
|
||||
function destroy() { delete window._analyticsData; }
|
||||
async function renderSubpaths(el) {
|
||||
el.innerHTML = '<div class="text-center text-muted" style="padding:40px">Analyzing route patterns…</div>';
|
||||
try {
|
||||
const [d2, d3, d4, d5] = await Promise.all([
|
||||
api('/analytics/subpaths?minLen=2&maxLen=2&limit=50'),
|
||||
api('/analytics/subpaths?minLen=3&maxLen=3&limit=30'),
|
||||
api('/analytics/subpaths?minLen=4&maxLen=4&limit=20'),
|
||||
api('/analytics/subpaths?minLen=5&maxLen=8&limit=15')
|
||||
]);
|
||||
|
||||
function renderTable(data, title) {
|
||||
if (!data.subpaths.length) return `<h4>${title}</h4><div class="text-muted">No data</div>`;
|
||||
const maxCount = data.subpaths[0]?.count || 1;
|
||||
return `<h4>${title}</h4>
|
||||
<p class="text-muted" style="margin:4px 0 8px">From ${data.totalPaths.toLocaleString()} paths with 2+ hops</p>
|
||||
<table class="analytics-table"><thead><tr>
|
||||
<th>#</th><th>Route</th><th>Occurrences</th><th>% of paths</th><th>Frequency</th>
|
||||
</tr></thead><tbody>
|
||||
${data.subpaths.map((s, i) => {
|
||||
const barW = Math.max(2, Math.round(s.count / maxCount * 100));
|
||||
return `<tr>
|
||||
<td>${i + 1}</td>
|
||||
<td class="mono" style="white-space:nowrap">${escapeHtml(s.path)}</td>
|
||||
<td>${s.count.toLocaleString()}</td>
|
||||
<td>${s.pct}%</td>
|
||||
<td><div style="background:var(--accent,#3b82f6);height:14px;border-radius:3px;width:${barW}%;opacity:0.7"></div></td>
|
||||
</tr>`;
|
||||
}).join('')}
|
||||
</tbody></table>`;
|
||||
}
|
||||
|
||||
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>`;
|
||||
} catch (e) {
|
||||
el.innerHTML = `<div class="text-muted">Error loading subpath data: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() { delete window._analyticsData; }
|
||||
|
||||
registerPage('analytics', { init, destroy });
|
||||
})();
|
||||
|
||||
@@ -1104,6 +1104,59 @@ app.get('/api/nodes/:pubkey/health', (req, res) => {
|
||||
res.json(health);
|
||||
});
|
||||
|
||||
// Subpath frequency analysis
|
||||
app.get('/api/analytics/subpaths', (req, res) => {
|
||||
const minLen = Math.max(2, Number(req.query.minLen) || 2);
|
||||
const maxLen = Number(req.query.maxLen) || 8;
|
||||
const packets = db.db.prepare(`SELECT path_json 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();
|
||||
|
||||
// Simple resolver (no geographic context needed here — just first match)
|
||||
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 ? m.name : hop;
|
||||
return nameCache[hop];
|
||||
}
|
||||
|
||||
const subpathCounts = {};
|
||||
let totalPaths = 0;
|
||||
|
||||
for (const pkt of packets) {
|
||||
let hops;
|
||||
try { hops = JSON.parse(pkt.path_json); } catch { continue; }
|
||||
if (!Array.isArray(hops) || hops.length < 2) continue;
|
||||
totalPaths++;
|
||||
|
||||
// Resolve all hops to names
|
||||
const named = hops.map(h => resolveName(h));
|
||||
|
||||
// Extract all subpaths of length minLen..maxLen
|
||||
for (let len = minLen; len <= Math.min(maxLen, named.length); len++) {
|
||||
for (let start = 0; start <= named.length - len; start++) {
|
||||
const sub = named.slice(start, start + len).join(' → ');
|
||||
subpathCounts[sub] = (subpathCounts[sub] || 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by frequency, return top results
|
||||
const limit = Number(req.query.limit) || 100;
|
||||
const ranked = Object.entries(subpathCounts)
|
||||
.map(([path, count]) => ({
|
||||
path,
|
||||
count,
|
||||
hops: path.split(' → ').length,
|
||||
pct: totalPaths > 0 ? Math.round(count / totalPaths * 1000) / 10 : 0
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, limit);
|
||||
|
||||
res.json({ subpaths: ranked, totalPaths });
|
||||
});
|
||||
|
||||
// Static files + SPA fallback
|
||||
app.use(express.static(path.join(__dirname, 'public'), {
|
||||
etag: false,
|
||||
|
||||
Reference in New Issue
Block a user