/* === MeshCore Analyzer — node-analytics.js === */ 'use strict'; (function () { const PAYLOAD_LABELS = { 0: 'Request', 1: 'Response', 2: 'Direct Msg', 3: 'ACK', 4: 'Advert', 5: 'Channel Msg', 7: 'Anon Req', 8: 'Path', 9: 'Trace', 11: 'Control' }; const CHART_COLORS = ['#4a9eff', '#ff6b6b', '#51cf66', '#fcc419', '#cc5de8', '#20c997', '#ff922b', '#845ef7', '#f06595', '#339af0']; const GRADE_COLORS = { A: '#51cf66', 'A-': '#51cf66', 'B+': '#339af0', B: '#339af0', C: '#fcc419', D: '#ff6b6b' }; const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; let charts = []; let currentDays = 7; let currentPubkey = null; function destroyCharts() { charts.forEach(c => { try { c.destroy(); } catch {} }); charts = []; } function chartDefaults() { const style = getComputedStyle(document.documentElement); Chart.defaults.color = style.getPropertyValue('--text-muted').trim() || '#6b7280'; Chart.defaults.borderColor = style.getPropertyValue('--border').trim() || '#e2e5ea'; } function formatSilence(ms) { if (!ms) return '—'; const h = Math.floor(ms / 3600000); const m = Math.floor((ms % 3600000) / 60000); if (h > 24) return Math.floor(h / 24) + 'd ' + (h % 24) + 'h'; if (h > 0) return h + 'h ' + m + 'm'; return m + 'm'; } async function loadAnalytics(container, pubkey, days) { currentPubkey = pubkey; currentDays = days; destroyCharts(); chartDefaults(); container.innerHTML = '
Loading analytics…
'; let data; try { data = await api('/nodes/' + encodeURIComponent(pubkey) + '/analytics?days=' + days); } catch (e) { container.innerHTML = '
Failed to load analytics: ' + escapeHtml(e.message) + '
'; return; } const n = data.node; const s = data.computedStats; const nodeName = escapeHtml(n.name || n.public_key.slice(0, 12)); container.innerHTML = `
← Back to ${nodeName}

📊 ${nodeName} — Analytics

${n.role || 'Unknown role'} · ${s.totalPackets} packets in ${days}d window
Availability
${s.availabilityPct}%
Signal Grade
${s.signalGrade}
Packets / Day
${s.avgPacketsPerDay}
Observers
${s.uniqueObservers}
Relay %
${s.relayPct}%
Longest Silence
${formatSilence(s.longestSilenceMs)}

Activity Timeline

SNR Trend

Packet Types

Observer Coverage

Hop Distribution

Uptime Heatmap

${data.peerInteractions.length ? `

Peer Interactions

${data.peerInteractions.map(p => ``).join('')}
PeerMessagesLast Contact
${escapeHtml(p.peer_name)} ${p.messageCount} ${timeAgo(p.lastContact)}
` : ''}
`; // Time range buttons container.querySelectorAll('#timeRangeBtns button').forEach(btn => { btn.addEventListener('click', () => { const d = Number(btn.dataset.days); loadAnalytics(container, pubkey, d); }); }); // Build charts buildActivityChart(data); buildSnrChart(data); buildPacketTypeChart(data); buildObserverChart(data); buildHopChart(data); buildHeatmap(data); } function buildActivityChart(data) { const ctx = document.getElementById('activityChart'); if (!ctx) return; const tl = data.activityTimeline; const c = new Chart(ctx, { type: 'bar', data: { labels: tl.map(b => { const d = new Date(b.bucket); return currentDays <= 3 ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : d.toLocaleDateString([], { month: 'short', day: 'numeric' }); }), datasets: [{ label: 'Packets', data: tl.map(b => b.count), backgroundColor: 'rgba(74,158,255,0.5)', borderColor: '#4a9eff', borderWidth: 1 }] }, options: { responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { maxTicksAutoSkip: true, maxRotation: 45 } }, y: { beginAtZero: true } } } }); charts.push(c); } function buildSnrChart(data) { const ctx = document.getElementById('snrChart'); if (!ctx) return; // Group by observer const byObs = {}; data.snrTrend.forEach(p => { const key = p.observer_id || 'unknown'; if (!byObs[key]) byObs[key] = { name: p.observer_name || key, points: [] }; byObs[key].points.push({ x: new Date(p.timestamp), y: p.snr }); }); const datasets = Object.values(byObs).map((obs, i) => ({ label: obs.name, data: obs.points.map(p => p.y), borderColor: CHART_COLORS[i % CHART_COLORS.length], backgroundColor: 'transparent', pointRadius: 1, borderWidth: 1.5, tension: 0.3 })); // Use labels from the observer with most points const longestObs = Object.values(byObs).sort((a, b) => b.points.length - a.points.length)[0]; const labels = longestObs ? longestObs.points.map(p => { const d = p.x; return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); }) : []; const c = new Chart(ctx, { type: 'line', data: { labels, datasets }, options: { responsive: true, scales: { x: { display: false }, y: { title: { display: true, text: 'SNR (dB)' } } }, plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, font: { size: 10 } } } } } }); charts.push(c); } function buildPacketTypeChart(data) { const ctx = document.getElementById('packetTypeChart'); if (!ctx) return; const items = data.packetTypeBreakdown; const c = new Chart(ctx, { type: 'doughnut', data: { labels: items.map(i => PAYLOAD_LABELS[i.payload_type] || 'Type ' + i.payload_type), datasets: [{ data: items.map(i => i.count), backgroundColor: items.map((_, i) => CHART_COLORS[i % CHART_COLORS.length]) }] }, options: { responsive: true, plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, font: { size: 10 } } } } } }); charts.push(c); } function buildObserverChart(data) { const ctx = document.getElementById('observerChart'); if (!ctx) return; const obs = data.observerCoverage; const c = new Chart(ctx, { type: 'bar', data: { labels: obs.map(o => (o.observer_name || o.observer_id || '?').slice(0, 20)), datasets: [{ label: 'Packets', data: obs.map(o => o.packetCount), backgroundColor: obs.map(o => { const snr = o.avgSnr || 0; const alpha = Math.min(1, Math.max(0.3, snr / 20)); return `rgba(74,158,255,${alpha})`; }) }] }, options: { indexAxis: 'y', responsive: true, plugins: { legend: { display: false } }, scales: { x: { beginAtZero: true } } } }); charts.push(c); } function buildHopChart(data) { const ctx = document.getElementById('hopChart'); if (!ctx) return; const hops = data.hopDistribution; const c = new Chart(ctx, { type: 'bar', data: { labels: hops.map(h => h.hops + ' hop' + (h.hops !== '1' ? 's' : '')), datasets: [{ label: 'Packets', data: hops.map(h => h.count), backgroundColor: 'rgba(81,207,102,0.6)', borderColor: '#51cf66', borderWidth: 1 }] }, options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true } } } }); charts.push(c); } function buildHeatmap(data) { const grid = document.getElementById('heatmapGrid'); if (!grid) return; // Build lookup const lookup = {}; let maxCount = 1; data.uptimeHeatmap.forEach(h => { const key = h.dayOfWeek + '-' + h.hour; lookup[key] = h.count; if (h.count > maxCount) maxCount = h.count; }); // Header row grid.innerHTML = '
'; for (let h = 0; h < 24; h++) { grid.innerHTML += `
${h}
`; } // Day rows for (let d = 0; d < 7; d++) { grid.innerHTML += `
${DAY_NAMES[d]}
`; for (let h = 0; h < 24; h++) { const count = lookup[d + '-' + h] || 0; const intensity = count / maxCount; const bg = count === 0 ? 'var(--card-bg)' : `rgba(74,158,255,${0.15 + intensity * 0.85})`; grid.innerHTML += `
`; } } } function init(container, routeParam) { // routeParam is "PUBKEY/analytics" if (!routeParam || !routeParam.endsWith('/analytics')) { container.innerHTML = '
Invalid analytics URL
'; return; } const pubkey = routeParam.slice(0, -'/analytics'.length); loadAnalytics(container, pubkey, 7); } function destroy() { destroyCharts(); currentPubkey = null; } registerPage('node-analytics', { init, destroy }); })();