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
+
+ | Node | Role | Packets | Avg SNR | Observers | Last Heard |
+
+ ${enriched.filter(n => myKeys.has(n.public_key)).map(n => {
+ const s = n.health.stats;
+ return `
+ | ${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) : 'β'} |
+
`;
+ }).join('') || '| No claimed nodes have health data |
'}
+
+
` : ''}
+
+
π Most Active Nodes
+
+ | # | Node | Role | Total Packets | Packets Today | Analytics |
+
+ ${byPackets.slice(0, 15).map((n, i) => `
+ | ${i + 1} |
+ ${nodeLink(n)}${claimedBadge(n)} |
+ ${n.role} |
+ ${n.health.stats.totalPackets || 0} |
+ ${n.health.stats.packetsToday || 0} |
+ π |
+
`).join('')}
+
+
+
+
πΆ Best Signal Quality
+
+ | # | Node | Role | Avg SNR | Observers | Analytics |
+
+ ${bySnr.slice(0, 15).map((n, i) => `
+ | ${i + 1} |
+ ${nodeLink(n)}${claimedBadge(n)} |
+ ${n.role} |
+ ${n.health.stats.avgSnr.toFixed(1)} dB |
+ ${n.health.observers?.length || 0} |
+ π |
+
`).join('')}
+
+
+
+
π Most Observed Nodes
+
+ | # | Node | Role | Observers | Avg SNR | Analytics |
+
+ ${byObservers.slice(0, 15).map((n, i) => `
+ | ${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' : 'β'} |
+ π |
+
`).join('')}
+
+
+
+
β° Recently Active
+
+ | Node | Role | Last Heard | Packets Today | Analytics |
+
+ ${byRecent.slice(0, 15).map(n => `
+ | ${nodeLink(n)}${claimedBadge(n)} |
+ ${n.role} |
+ ${timeAgo(n.health.stats.lastHeard)} |
+ ${n.health.stats.packetsToday || 0} |
+ π |
+
`).join('')}
+
+
+
`;
+ } 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 @@
-
+
-
+