From 067fe3c595e5460749ceccdccf2f36b32a41954e Mon Sep 17 00:00:00 2001 From: you Date: Thu, 19 Mar 2026 01:18:20 +0000 Subject: [PATCH] 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. --- public/analytics.js | 49 ++++++++++++++++++++++++++++++++++++++++- server.js | 53 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/public/analytics.js b/public/analytics.js index 52b1f400..986aef57 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -70,6 +70,7 @@ +
@@ -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 = '
Failed to load
'; } } - function destroy() { delete window._analyticsData; } + async function renderSubpaths(el) { + el.innerHTML = '
Analyzing route patterns…
'; + 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 `

${title}

No data
`; + const maxCount = data.subpaths[0]?.count || 1; + return `

${title}

+

From ${data.totalPaths.toLocaleString()} paths with 2+ hops

+ + + + ${data.subpaths.map((s, i) => { + const barW = Math.max(2, Math.round(s.count / maxCount * 100)); + return ` + + + + + + `; + }).join('')} +
#RouteOccurrences% of pathsFrequency
${i + 1}${escapeHtml(s.path)}${s.count.toLocaleString()}${s.pct}%
`; + } + + el.innerHTML = ` +
+

🛤️ Route Pattern Analysis

+

Most common subpaths in the mesh — reveals backbone routes, bottlenecks, and preferred relay chains regardless of where they appear in the full path.

+ ${renderTable(d2, 'Pairs (2-hop links)')} + ${renderTable(d3, 'Triples (3-hop chains)')} + ${renderTable(d4, 'Quads (4-hop chains)')} + ${renderTable(d5, 'Long chains (5+ hops)')} +
`; + } catch (e) { + el.innerHTML = `
Error loading subpath data: ${e.message}
`; + } + } + +function destroy() { delete window._analyticsData; } registerPage('analytics', { init, destroy }); })(); diff --git a/server.js b/server.js index b6721229..d49c75c9 100644 --- a/server.js +++ b/server.js @@ -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,