/* === CoreScope — 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, { ttl: CLIENT_TTL.nodeAnalytics });
} 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.totalTransmissions || s.totalPackets} packets in ${days}d window
24h
7d
30d
All
Availability
${s.availabilityPct}%
% of time windows with at least one packet
Signal Grade
${s.signalGrade}
A–F based on average SNR across all observers
Packets / Day
${s.avgPacketsPerDay}
Average daily packet volume in this window
Observers
${s.uniqueObservers}
Distinct stations that heard this node
Relay %
${s.relayPct}%
Packets forwarded through repeaters vs direct
Longest Silence
${formatSilence(s.longestSilenceMs)}
Longest gap between consecutive packets
Activity Timeline
Packet count per time bucket — shows when this node is most active
SNR Trend
Signal-to-noise ratio over time — higher is better reception
Packet Types
Breakdown of advert, position, text, and other packet types
Observer Coverage
Which stations hear this node and how often
Hop Distribution
How many repeater hops packets take — 0 means direct
Relay Hop-Count (tuning flood.max)
Hop count at this node when it relayed each packet — the value MeshCore firmware compares against flood.max/flood.max.advert/flood.max.unscoped before forwarding. Not the same as Hop Distribution above, which measures distance to the observer instead.
No relayed traffic with a resolvable path through this node in this window.
Battery Voltage
Battery voltage over time from observer status reports — flat line means full, downward slope means draining
No battery telemetry recorded for this node in this window.
Uptime Heatmap
Hour-by-hour activity grid — darker = more packets in that slot
${data.peerInteractions.length ? `
Peer Interactions
Nodes this device has exchanged messages with
` : ''}
`;
// 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);
loadBatteryChart(pubkey, currentDays);
loadHopAnalyticsChart(pubkey, currentDays);
}
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 (typeof formatChartAxisLabel === 'function') ? formatChartAxisLabel(d, currentDays <= 3) : (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 (typeof formatChartAxisLabel === 'function') ? formatChartAxisLabel(d, false) : 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 += `
`;
}
}
}
async function loadBatteryChart(pubkey, days) {
let data;
try {
data = await api('/nodes/' + encodeURIComponent(pubkey) + '/battery?days=' + days);
} catch (e) {
const empty = document.getElementById('batteryEmpty');
if (empty) { empty.style.display = 'block'; empty.textContent = 'Battery data unavailable: ' + e.message; }
return;
}
const ctx = document.getElementById('batteryChart');
const empty = document.getElementById('batteryEmpty');
const badge = document.getElementById('batteryStatusBadge');
const samples = (data && data.samples) || [];
const thr = (data && data.thresholds) || { low_mv: 3300, critical_mv: 3000 };
if (badge) {
const STATUS_COLOR = { ok: '#51cf66', low: '#fcc419', critical: '#ff6b6b', unknown: 'var(--text-muted)' };
const label = data && data.status === 'ok' ? ' OK'
: data && data.status === 'low' ? ' Low'
: data && data.status === 'critical' ? ' Critical'
: 'No data';
const mv = data && data.latest_mv ? ' · ' + data.latest_mv + ' mV' : '';
badge.textContent = label + mv;
badge.style.color = STATUS_COLOR[(data && data.status) || 'unknown'];
}
if (!ctx || samples.length === 0) {
if (ctx) ctx.style.display = 'none';
if (empty) empty.style.display = 'block';
return;
}
if (empty) empty.style.display = 'none';
ctx.style.display = '';
const labels = samples.map(p => {
const d = new Date(p.timestamp);
return (typeof formatChartAxisLabel === 'function')
? formatChartAxisLabel(d, days <= 3)
: (days <= 3 ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
: d.toLocaleDateString([], { month: 'short', day: 'numeric' }));
});
const values = samples.map(p => p.battery_mv);
const c = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{ label: 'Battery (mV)', data: values, borderColor: '#4a9eff', backgroundColor: 'rgba(74,158,255,0.15)', tension: 0.25, pointRadius: 2, fill: true },
{ label: 'Low threshold', data: values.map(() => thr.low_mv), borderColor: '#fcc419', borderDash: [6, 4], pointRadius: 0, fill: false },
{ label: 'Critical', data: values.map(() => thr.critical_mv), borderColor: '#ff6b6b', borderDash: [6, 4], pointRadius: 0, fill: false }
]
},
options: {
responsive: true,
plugins: { legend: { display: true, position: 'bottom' } },
scales: { x: { ticks: { maxTicksAutoSkip: true, maxRotation: 45 } }, y: { title: { display: true, text: 'mV' } } }
}
});
charts.push(c);
}
// ─── Relay Hop-Count (issue #1812) ────────────────────────────────────────
// Hop count HERE is the node's own 0-based index within a packet's
// resolved relay path (set server-side, GetNodeHopAnalytics) -- the value
// MeshCore firmware compares against flood_max/flood_max_advert/
// flood_max_unscoped before forwarding. Distinct from the Hop
// Distribution chart above, which is path length to the reporting
// observer, a different and unrelated number.
const HOP_TRANSPORT_CHIPS = [
{ key: 'flood', label: 'Flood' },
{ key: 'flood_advert', label: 'Flood Adverts' },
{ key: 'flood_unscoped', label: 'Flood Unscoped' },
{ key: 'direct', label: 'Direct' },
];
let hopAnalyticsPackets = [];
let hopAnalyticsFilter = 'flood';
let hopAnalyticsHistChart = null;
async function loadHopAnalyticsChart(pubkey, days) {
hopAnalyticsFilter = 'flood';
try {
const data = await api('/nodes/' + encodeURIComponent(pubkey) + '/hop_analytics?days=' + days);
hopAnalyticsPackets = (data && data.packets) || [];
} catch (e) {
hopAnalyticsPackets = [];
const empty = document.getElementById('hopAnalyticsEmpty');
if (empty) { empty.style.display = 'block'; empty.textContent = 'Hop-count data unavailable: ' + e.message; }
return;
}
renderHopAnalyticsChips();
renderHopAnalyticsChart();
}
function renderHopAnalyticsChips() {
const container = document.getElementById('hopAnalyticsChips');
if (!container) return;
container.innerHTML = HOP_TRANSPORT_CHIPS.map(c =>
'' + c.label + ' '
).join('');
container.querySelectorAll('[data-hop-transport]').forEach(btn => {
btn.addEventListener('click', () => {
hopAnalyticsFilter = btn.dataset.hopTransport;
renderHopAnalyticsChips();
renderHopAnalyticsChart();
});
});
}
// Linear-interpolation percentile (same convention as numpy's default) —
// `sorted` must already be ascending.
function hopQuantile(sortedVals, p) {
const idx = p * (sortedVals.length - 1);
const lo = Math.floor(idx), hi = Math.ceil(idx);
if (lo === hi) return sortedVals[lo];
return sortedVals[lo] + (sortedVals[hi] - sortedVals[lo]) * (idx - lo);
}
function renderHopAnalyticsChart() {
const empty = document.getElementById('hopAnalyticsEmpty');
const boxCanvas = document.getElementById('hopAnalyticsBoxplot');
const histCanvas = document.getElementById('hopAnalyticsHistogram');
const summary = document.getElementById('hopAnalyticsSummary');
if (hopAnalyticsHistChart) { try { hopAnalyticsHistChart.destroy(); } catch (e) {} hopAnalyticsHistChart = null; }
const filtered = hopAnalyticsPackets.filter(p => p.transport === hopAnalyticsFilter);
if (filtered.length === 0) {
if (boxCanvas) boxCanvas.style.display = 'none';
if (histCanvas) histCanvas.style.display = 'none';
if (summary) summary.textContent = '';
if (empty) empty.style.display = 'block';
return;
}
if (empty) empty.style.display = 'none';
if (boxCanvas) boxCanvas.style.display = '';
if (histCanvas) histCanvas.style.display = '';
const hopsSorted = filtered.map(p => p.hops).sort((a, b) => a - b);
const q = {
min: hopsSorted[0],
q1: hopQuantile(hopsSorted, 0.25),
median: hopQuantile(hopsSorted, 0.5),
q3: hopQuantile(hopsSorted, 0.75),
max: hopsSorted[hopsSorted.length - 1],
};
if (summary) {
summary.textContent = filtered.length.toLocaleString() + ' packets — min ' + q.min +
', Q1 ' + q.q1.toFixed(1) + ', median ' + q.median.toFixed(1) +
', Q3 ' + q.q3.toFixed(1) + ', max ' + q.max;
}
drawHopBoxplot(boxCanvas, q, q.max);
const buckets = new Array(q.max + 1).fill(0);
hopsSorted.forEach(h => buckets[h]++);
hopAnalyticsHistChart = new Chart(histCanvas, {
type: 'bar',
data: {
labels: buckets.map((_, i) => String(i)),
datasets: [{ label: 'Packets', data: buckets, backgroundColor: 'rgba(74,158,255,0.5)', borderColor: '#4a9eff', borderWidth: 1 }]
},
options: {
responsive: true,
plugins: { legend: { display: false } },
scales: {
x: { title: { display: true, text: 'Hop count at this node' } },
y: { beginAtZero: true, title: { display: true, text: 'Packets' } }
}
}
});
charts.push(hopAnalyticsHistChart);
}
// Hand-drawn boxplot — Chart.js has no built-in boxplot type and this
// project doesn't load a boxplot plugin. Draws horizontal box+whiskers
// over [0, maxHops], positioned directly above the histogram with a
// best-effort matching left padding so the two roughly share an x-axis
// (exact pixel alignment with Chart.js's own computed padding isn't
// attempted — close enough to read visually).
function drawHopBoxplot(canvas, q, maxHops) {
if (!canvas) return;
const dpr = window.devicePixelRatio || 1;
const cssWidth = (canvas.parentElement && canvas.parentElement.clientWidth) || 300;
const cssHeight = 50;
canvas.style.width = cssWidth + 'px';
canvas.style.height = cssHeight + 'px';
canvas.width = cssWidth * dpr;
canvas.height = cssHeight * dpr;
const ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, cssWidth, cssHeight);
const style = getComputedStyle(document.documentElement);
const lineColor = style.getPropertyValue('--text').trim() || '#333';
const padLeft = 36, padRight = 12;
const plotW = Math.max(10, cssWidth - padLeft - padRight);
const domainMax = Math.max(1, maxHops);
const xFor = v => padLeft + (v / domainMax) * plotW;
const midY = cssHeight / 2;
const boxH = 20;
ctx.strokeStyle = lineColor;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(xFor(q.min), midY);
ctx.lineTo(xFor(q.q1), midY);
ctx.moveTo(xFor(q.q3), midY);
ctx.lineTo(xFor(q.max), midY);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(xFor(q.min), midY - boxH / 4); ctx.lineTo(xFor(q.min), midY + boxH / 4);
ctx.moveTo(xFor(q.max), midY - boxH / 4); ctx.lineTo(xFor(q.max), midY + boxH / 4);
ctx.stroke();
ctx.fillStyle = 'rgba(74,158,255,0.35)';
ctx.strokeStyle = '#4a9eff';
ctx.lineWidth = 1.5;
const boxX = xFor(q.q1), boxW = Math.max(1, xFor(q.q3) - xFor(q.q1));
ctx.fillRect(boxX, midY - boxH / 2, boxW, boxH);
ctx.strokeRect(boxX, midY - boxH / 2, boxW, boxH);
ctx.beginPath();
ctx.moveTo(xFor(q.median), midY - boxH / 2);
ctx.lineTo(xFor(q.median), midY + boxH / 2);
ctx.strokeStyle = '#4a9eff';
ctx.lineWidth = 2;
ctx.stroke();
}
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;
}
// Expose for testing
if (typeof window !== 'undefined') {
window._nodeAnalyticsHopQuantile = hopQuantile;
window._nodeAnalyticsLoadHopChart = loadHopAnalyticsChart;
window._nodeAnalyticsGetHopFilter = function() { return hopAnalyticsFilter; };
window._nodeAnalyticsSetHopFilter = function(v) { hopAnalyticsFilter = v; };
window._nodeAnalyticsRenderHopChart = renderHopAnalyticsChart;
window._nodeAnalyticsGetHopPackets = function() { return hopAnalyticsPackets; };
}
registerPage('node-analytics', { init, destroy });
})();