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,