From 798f96f97d148e46b4162273e9b53ca47fed082d Mon Sep 17 00:00:00 2001 From: you Date: Thu, 19 Mar 2026 22:40:03 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20add=20Nodes=20tab=20to=20global=20analy?= =?UTF-8?q?tics=20=E2=80=94=20status=20overview,=20role=20breakdown,=20cla?= =?UTF-8?q?imed=20nodes,=20leaderboards=20(activity,=20signal,=20observers?= =?UTF-8?q?,=20recent)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/analytics.js | 165 ++++++++++++++++++++++++++++++++++++++++++++ public/index.html | 4 +- 2 files changed, 167 insertions(+), 2 deletions(-) diff --git a/public/analytics.js b/public/analytics.js index ec815f4c..79167a20 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -65,6 +65,7 @@ +
@@ -124,6 +125,7 @@ case 'hashsizes': renderHashSizes(el, d.hashData); break; case 'collisions': await renderCollisionTab(el, d.hashData); break; case 'subpaths': await renderSubpaths(el); break; + case 'nodes': await renderNodesTab(el); break; } // Auto-apply column resizing to all analytics tables requestAnimationFrame(() => { @@ -1135,6 +1137,169 @@ } } + async function renderNodesTab(el) { + el.innerHTML = '
Loading node analytics…
'; + try { + const nodes = await api('/nodes?limit=200&sortBy=lastSeen'); + const myNodes = JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]'); + const myKeys = new Set(myNodes.map(n => n.pubkey)); + + // Fetch health data for top nodes (limit to avoid hammering) + const topNodes = nodes.slice(0, 50); + const healthResults = await Promise.allSettled( + topNodes.map(n => api('/nodes/' + encodeURIComponent(n.public_key) + '/health').then(h => ({ ...n, health: h }))) + ); + const enriched = healthResults + .filter(r => r.status === 'fulfilled') + .map(r => r.value) + .filter(n => n.health); + + // Compute rankings + const byPackets = [...enriched].sort((a, b) => (b.health.stats.totalPackets || 0) - (a.health.stats.totalPackets || 0)); + const bySnr = [...enriched].filter(n => n.health.stats.avgSnr != null).sort((a, b) => b.health.stats.avgSnr - a.health.stats.avgSnr); + const byObservers = [...enriched].sort((a, b) => (b.health.observers?.length || 0) - (a.health.observers?.length || 0)); + const byRecent = [...enriched].filter(n => n.health.stats.lastHeard).sort((a, b) => new Date(b.health.stats.lastHeard) - new Date(a.health.stats.lastHeard)); + + // Status counts + const now = Date.now(); + let active = 0, degraded = 0, silent = 0; + enriched.forEach(n => { + const lh = n.health.stats.lastHeard; + const age = lh ? now - new Date(lh).getTime() : Infinity; + const role = (n.role || '').toLowerCase(); + const isInfra = role === 'repeater' || role === 'room'; + const degradedMs = isInfra ? 86400000 : 3600000; + const silentMs = isInfra ? 259200000 : 86400000; + if (age < degradedMs) active++; + else if (age < silentMs) degraded++; + else silent++; + }); + + // Role breakdown + const roleCounts = {}; + nodes.forEach(n => { const r = n.role || 'unknown'; roleCounts[r] = (roleCounts[r] || 0) + 1; }); + + function nodeLink(n) { + return `${esc(n.name || n.public_key.slice(0, 12))}`; + } + function claimedBadge(n) { + return myKeys.has(n.public_key) ? ' β˜… MINE' : ''; + } + + const ROLE_COLORS = { repeater: '#dc2626', companion: '#2563eb', room: '#16a34a', sensor: '#d97706' }; + + el.innerHTML = ` +
+

πŸ” Network Status

+
+
+
${active}
+
🟒 Active
+
+
+
${degraded}
+
🟑 Degraded
+
+
+
${silent}
+
πŸ”΄ Silent
+
+
+
${nodes.length}
+
Total Nodes
+
+
+ +

πŸ“Š Role Breakdown

+
+ ${Object.entries(roleCounts).sort((a,b) => b[1]-a[1]).map(([role, count]) => { + const c = ROLE_COLORS[role] || '#6b7280'; + return `${role}: ${count}`; + }).join('')} +
+ + ${myKeys.size ? `

⭐ My Claimed Nodes

+ + + + ${enriched.filter(n => myKeys.has(n.public_key)).map(n => { + const s = n.health.stats; + return ` + + + + + + + `; + }).join('') || ''} + +
NodeRolePacketsAvg SNRObserversLast Heard
${nodeLink(n)}${n.role}${s.totalPackets || 0}${s.avgSnr != null ? s.avgSnr.toFixed(1) + ' dB' : 'β€”'}${n.health.observers?.length || 0}${s.lastHeard ? timeAgo(s.lastHeard) : 'β€”'}
No claimed nodes have health data
` : ''} + +

πŸ† Most Active Nodes

+ + + + ${byPackets.slice(0, 15).map((n, i) => ` + + + + + + + `).join('')} + +
#NodeRoleTotal PacketsPackets TodayAnalytics
${i + 1}${nodeLink(n)}${claimedBadge(n)}${n.role}${n.health.stats.totalPackets || 0}${n.health.stats.packetsToday || 0}πŸ“Š
+ +

πŸ“Ά Best Signal Quality

+ + + + ${bySnr.slice(0, 15).map((n, i) => ` + + + + + + + `).join('')} + +
#NodeRoleAvg SNRObserversAnalytics
${i + 1}${nodeLink(n)}${claimedBadge(n)}${n.role}${n.health.stats.avgSnr.toFixed(1)} dB${n.health.observers?.length || 0}πŸ“Š
+ +

πŸ‘€ Most Observed Nodes

+ + + + ${byObservers.slice(0, 15).map((n, i) => ` + + + + + + + `).join('')} + +
#NodeRoleObserversAvg SNRAnalytics
${i + 1}${nodeLink(n)}${claimedBadge(n)}${n.role}${n.health.observers?.length || 0}${n.health.stats.avgSnr != null ? n.health.stats.avgSnr.toFixed(1) + ' dB' : 'β€”'}πŸ“Š
+ +

⏰ Recently Active

+ + + + ${byRecent.slice(0, 15).map(n => ` + + + + + + `).join('')} + +
NodeRoleLast HeardPackets TodayAnalytics
${nodeLink(n)}${claimedBadge(n)}${n.role}${timeAgo(n.health.stats.lastHeard)}${n.health.stats.packetsToday || 0}πŸ“Š
+
`; + } catch (e) { + el.innerHTML = `
Failed to load node analytics: ${esc(e.message)}
`; + } + } + function destroy() { _analyticsData = {}; } registerPage('analytics', { init, destroy }); diff --git a/public/index.html b/public/index.html index dfd21e21..ee0a43db 100644 --- a/public/index.html +++ b/public/index.html @@ -83,9 +83,9 @@ - + - +