/* === 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;
// A time-range click is a new load on the same visit: only the newest may
// render (#2218).
let loadSeq = 0;
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';
}
// #748: the server only holds its in-memory window. When that starts
// after the requested range, say from when the data runs, why, and what
// the rates cover; '' otherwise. stats.availabilityPct is null when the
// window is too short for rates.
function coverageNotice(tr, stats) {
if (!tr || !tr.partial || !tr.dataFrom || !tr.to) return '';
const from = Date.parse(tr.dataFrom);
const to = Date.parse(tr.to);
if (isNaN(from) || isNaN(to)) return '';
const spanDays = Math.max(1, Math.round((to - from) / 86400000));
let why;
if (tr.dataFromReason === 'database') why = 'the database holds no older packets';
else if (tr.dataFromReason === 'loading') why = 'the server is still loading older packets';
else why = 'the server keeps about ' + spanDays + ' day' + (spanDays === 1 ? '' : 's') + ' of packets in memory';
const rates = stats && stats.availabilityPct == null
? 'That is under a day, too short for availability and packets/day.'
: 'Availability and packets/day cover that period, not the full ' + tr.days + ' days.';
return '
';
}
// A rate the server left out (window too short, #748) reads as a dash.
function rateText(v, suffix) {
return v == null ? '—' : v + (suffix || '');
}
window.NodeAnalyticsCoverage = { notice: coverageNotice, rateText: rateText };
// #2260: split each timeline bucket into packets that can only be this
// node (certain) and packets attributed to it through a hop prefix it
// shares with other nodes (uncertain). A server without the certain field
// counts everything as certain, as before.
function timelineSeries(tl) {
const certain = [], uncertain = [];
(tl || []).forEach(b => {
const count = Number(b.count) || 0;
const c = b.certain == null ? count : Math.min(Number(b.certain) || 0, count);
certain.push(c);
uncertain.push(count - c);
});
return { certain, uncertain };
}
window.NodeAnalyticsTimeline = { series: timelineSeries };
async function loadAnalytics(container, pubkey, days) {
const gen = viewGeneration();
const seq = ++loadSeq;
const isCurrent = () => gen === viewGeneration() && seq === loadSeq;
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) {
if (!isCurrent()) return;
container.innerHTML = '
Failed to load analytics: ' + escapeHtml(e.message) + '
';
return;
}
if (!isCurrent()) return;
const n = data.node;
const s = data.computedStats;
const nodeName = escapeHtml(n.name || n.public_key.slice(0, 12));
container.innerHTML = `
Longest gap without a packet certainly from this node, including one still running
Activity Timeline
Packet count per time bucket. Certain: can only be this node. Uncertain: a hop prefix this node shares with others, so possibly another node.
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
Hop Count at This Node
For each flood packet this node forwarded: how many hops it already had, the number the flood.max, flood.max.unscoped and flood.max.advert limits are checked against. Bars count packets per hop count; the box above spans the middle half of the packets with a line at the median, and the whiskers reach the furthest packet within 1.5 box widths of the box.
Loading hop counts...
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