Files
agessaman 75fda424c2 feat(web-viewer): implement per-payload-type multibyte share tracking
- Added a new feature to track and display the multibyte share of packets by payload type in the dashboard.
- Introduced a new database migration to store per-payload-type multibyte encoding data in the daily rollup.
- Updated the dashboard to visualize the multibyte share as a stacked bar chart, reflecting the share of each day's packets that took a multibyte path.
- Enhanced the API to provide raw counts for each payload type, ensuring accurate representation in the dashboard.
- Adjusted the frontend to maintain consistent color coding for payload types and improve the overall user experience.
- Updated tests to validate the new multibyte share functionality and ensure data integrity.
2026-07-31 21:23:51 -07:00

1290 lines
58 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* MeshCore Bot — landing page dashboard.
*
* Served as a static file so `script-src 'self'` covers it and it needs no CSP
* nonce. Server-provided configuration arrives in a
* <script type="application/json" id="dashboard-boot"> block, which is data
* rather than code and therefore also nonce-free.
*
* Everything on the page is driven by /api/dashboard/summary, a single
* snapshot read. Polling sends If-None-Match and normally gets a bodyless
* 304, and pauses entirely while the tab is hidden — the old page kept
* recomputing ~50 aggregate queries every 30s in background tabs forever.
*/
(function () {
'use strict';
const BOOT = readBoot();
const SUMMARY_POLL_MS = 30000;
const HEALTH_POLL_MS = 15000;
// Palette shared with the multibyte rollout page so the same concept keeps
// the same colour across screens.
const COLOR = {
multibyte: '#0891b2',
singleByte: '#a8a29e',
flood: '#0d6efd',
direct: '#20c997',
accent: '#6f42c1',
series: '#0d6efd',
};
const MIX_COLORS = [
'#0d6efd', '#20c997', '#fd7e14', '#6f42c1',
'#d63384', '#198754', '#0dcaf0', '#adb5bd',
];
// Payload buckets on the encoding chart. Must stay in the same order as
// PACKET_ENCODING_BUCKETS server-side: the slot index picks the colour, so a
// type keeps its colour even on a day when it has no traffic and drops out
// of the chart. Colour by rank instead and one quiet day repaints the lot.
// Append new types; inserting one would recolour everything after it.
const PACKET_ENCODING_TYPES = [
'GRP_TXT', 'RESPONSE', 'REQ', 'PATH', 'TXT_MSG', 'ANON_REQ', 'GRP_DATA', 'ADVERT',
// The uncharted tail (ACK, TRACE, unmapped ordinals). Present so the
// bars measure the whole day's traffic, not just the named types.
'OTHER',
];
// Eight categorical slots, each mode stepped for its own card surface
// (#ffffff light, #2d2d2d dark) rather than flipped from the other. Both
// columns are validated for lightness band, chroma floor, colour-vision
// separation (worst adjacent pair ΔE 9.1 light / 8.4 dark) and
// normal-vision separation (19.6 / 19.3). Several sit under 3:1 against
// the surface, which is why the legend is not optional here — identity has
// to survive without the colour. Eight is the ceiling for hues: the ninth
// slot is deliberately a neutral, because "everything else" is not a
// category and must not compete with the ones that are.
const SERIES_PALETTE = {
light: ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#008300', '#4a3aa7', '#e34948', '#a8a29e'],
dark: ['#3987e5', '#d95926', '#199e70', '#c98500', '#d55181', '#008300', '#9085e9', '#e66767', '#87817c'],
};
// Segments thinner than this fraction of the y-axis range are drawn without
// the hairline that separates stacked bands — below it the border is
// thicker than the band and erases the colour instead of framing it.
const MIN_SEGMENT_FRACTION_FOR_GAP = 0.01;
// The y-axis tops out at the tallest bar rounded up to the next multiple of
// this, so a 24% peak gets a 25% axis and a 26% peak gets 30%.
const AXIS_STEP_PCT = 5;
function readBoot() {
const el = document.getElementById('dashboard-boot');
if (!el) return {};
try {
return JSON.parse(el.textContent) || {};
} catch (err) {
console.error('Invalid dashboard boot config', err);
return {};
}
}
// ── small DOM/format helpers ─────────────────────────────────────────
function el(id) {
return document.getElementById(id);
}
function setText(id, value) {
const node = el(id);
if (node) node.textContent = String(value ?? '');
}
function makeTextElement(tagName, value, className) {
const node = document.createElement(tagName);
if (className) node.className = className;
node.textContent = String(value ?? '');
return node;
}
function makeBadge(value, classes) {
return makeTextElement('span', value, 'badge ' + classes);
}
/** Em dash for absent data. Zero is a real value and must not become one. */
function formatNumber(value) {
if (value === null || value === undefined || Number.isNaN(value)) return '—';
const n = Number(value);
if (!Number.isFinite(n)) return '—';
return n.toLocaleString();
}
function formatPercent(value, digits) {
if (value === null || value === undefined) return '—';
const n = Number(value);
if (!Number.isFinite(n)) return '—';
return n.toFixed(digits === undefined ? 1 : digits) + '%';
}
function formatSigned(value) {
if (value === null || value === undefined) return '—';
const n = Number(value);
if (!Number.isFinite(n)) return '—';
return n.toFixed(1);
}
function formatAge(seconds) {
if (seconds === null || seconds === undefined) return '—';
const s = Math.max(0, Math.round(seconds));
if (s < 60) return s + 's ago';
if (s < 3600) return Math.round(s / 60) + 'm ago';
return Math.round(s / 3600) + 'h ago';
}
function formatUptime(seconds) {
if (seconds === null || seconds === undefined || seconds <= 0) return 'bot down?';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days) return days + 'd ' + hours + 'h';
if (hours) return hours + 'h ' + minutes + 'm';
return minutes + 'm';
}
function formatBytes(bytes) {
if (!bytes && bytes !== 0) return '—';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let value = Number(bytes);
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
return value.toFixed(value >= 10 || unit === 0 ? 0 : 1) + ' ' + units[unit];
}
function isDark() {
return document.documentElement.getAttribute('data-theme') === 'dark';
}
function themeColors() {
const cs = getComputedStyle(document.documentElement);
return {
text: (cs.getPropertyValue('--text-color') || '#212529').trim(),
muted: (cs.getPropertyValue('--text-muted') || '#6c757d').trim(),
grid: isDark() ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)',
empty: (cs.getPropertyValue('--bg-tertiary') || '#dee2e6').trim(),
// The card the chart sits on. Stacked segments are separated by a
// hairline of the surface itself rather than a drawn border, so the
// gap reads as space instead of as an extra category.
surface: (cs.getPropertyValue('--card-bg') || '#ffffff').trim(),
};
}
function noAnimation(options) {
options.animation = false;
options.animations = false;
options.transitions = {
active: { animation: { duration: 0 } },
resize: { animation: { duration: 0 } },
show: { animation: { duration: 0 } },
hide: { animation: { duration: 0 } },
};
return options;
}
// ── dashboard ────────────────────────────────────────────────────────
class ModernDashboard {
constructor() {
this.charts = {};
this.summaryEtag = null;
this.lastGeneratedAt = null;
this.botUptime = null;
this.timers = [];
this.windows = null;
this.summary = null;
this.init();
}
init() {
this.wireSelectors();
this.wireRefreshButton();
this.watchTheme();
this.loadWindows();
this.loadHealth();
this.loadSummary();
this.timers.push(setInterval(() => this.whenVisible(() => this.loadHealth()), HEALTH_POLL_MS));
this.timers.push(setInterval(() => this.whenVisible(() => this.loadSummary()), SUMMARY_POLL_MS));
this.timers.push(setInterval(() => this.tickUptime(), 1000));
// A tab returning to the foreground may have missed several polls.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
this.loadHealth();
this.loadSummary();
}
});
}
/** Skip polling in a hidden tab: nobody is reading it and it costs SQLite. */
whenVisible(fn) {
if (document.visibilityState === 'visible') fn();
}
destroy() {
this.timers.forEach(clearInterval);
this.timers = [];
Object.values(this.charts).forEach((chart) => chart && chart.destroy());
this.charts = {};
}
// -- theme --------------------------------------------------------
/**
* DarkModeManager.applyTheme() emits no event, so observe the attribute
* it writes and rebuild chart colours when it flips.
*/
watchTheme() {
const observer = new MutationObserver((mutations) => {
if (mutations.some((m) => m.attributeName === 'data-theme')) {
this.rebuildCharts();
}
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
this.themeObserver = observer;
}
rebuildCharts() {
Object.values(this.charts).forEach((chart) => chart && chart.destroy());
this.charts = {};
if (this.summary) this.render(this.summary);
}
// -- data ---------------------------------------------------------
async loadHealth() {
try {
const response = await fetch('/api/health');
if (!response.ok) throw new Error('HTTP ' + response.status);
const data = await response.json();
this.botUptime = data.bot_uptime > 0 ? data.bot_uptime : null;
setText('health-clients', data.connected_clients ?? 0);
setText('health-uptime', formatUptime(this.botUptime));
const radio = el('health-radio');
if (radio) {
const degraded = Boolean(data.radio_zombie);
radio.textContent = degraded ? 'radio degraded' : 'radio ok';
radio.className = 'health-strip__item ' + (degraded ? 'text-warning' : '');
}
this.setStatusDot('health-status-dot', data.status === 'healthy' ? 'connected' : 'warning');
setText('health-status-text', data.status === 'healthy' ? 'Online' : 'Degraded');
} catch (err) {
this.setStatusDot('health-status-dot', 'disconnected');
setText('health-status-text', 'Offline');
}
}
async loadSummary() {
try {
const headers = {};
if (this.summaryEtag) headers['If-None-Match'] = this.summaryEtag;
const response = await fetch('/api/dashboard/summary', { headers });
if (response.status === 304) {
this.updateSnapshotAge();
return;
}
if (response.status === 503) {
setText('health-snapshot', 'building…');
return;
}
if (!response.ok) throw new Error('HTTP ' + response.status);
this.summaryEtag = response.headers.get('ETag');
const data = await response.json();
this.summary = data;
this.lastGeneratedAt = data.generated_at;
this.render(data);
this.setDatabaseStatus(true);
} catch (err) {
console.error('Dashboard summary failed', err);
this.setDatabaseStatus(false);
}
}
async loadWindows() {
try {
const response = await fetch('/api/dashboard/windows');
if (!response.ok) return;
this.windows = await response.json();
Object.entries(TOP_LISTS).forEach(([kind, config]) => {
this.populateWindowOptions(config.select, kind);
});
} catch (err) {
console.warn('Window options unavailable', err);
} finally {
// Load the lists whether or not option derivation succeeded.
Object.keys(TOP_LISTS).forEach((kind) => this.loadTop(kind));
}
}
/**
* Build the <option>s from retention rather than hard-coding them: the
* old page offered "30d" and "All" against tables pruned at 7 days, so
* three of its four choices returned the same number under a label that
* claimed otherwise.
*/
populateWindowOptions(selectId, kind) {
const select = el(selectId);
const options = this.windows && this.windows.windows && this.windows.windows[kind];
if (!select || !options) return;
const previous = select.value;
select.replaceChildren();
options.forEach((option) => {
const node = document.createElement('option');
node.value = option.value;
node.textContent = option.label;
select.appendChild(node);
});
const stillValid = options.some((option) => option.value === previous);
select.value = stillValid ? previous : options[options.length - 1].value;
}
async loadTop(kind) {
const config = TOP_LISTS[kind];
if (!config) return;
const container = el(config.container);
if (!container) return;
const select = el(config.select);
const window_ = select ? select.value : 'all';
try {
const response = await fetch(
'/api/dashboard/top?kind=' + encodeURIComponent(kind) +
'&window=' + encodeURIComponent(window_) +
'&limit=' + config.limit
);
if (!response.ok) throw new Error('HTTP ' + response.status);
const data = await response.json();
if (config.onLoad) config.onLoad(data);
if (!data.items || data.items.length === 0) {
container.replaceChildren(
makeTextElement('div', config.empty, 'dashboard-empty')
);
return;
}
container.replaceChildren();
data.items.forEach((item, index) => {
container.appendChild(config.render(item, index));
});
} catch (err) {
container.replaceChildren(
makeTextElement('div', 'Failed to load.', 'dashboard-empty')
);
}
}
// -- rendering ----------------------------------------------------
render(data) {
const mesh = data.mesh || {};
const bot = data.bot || {};
const coverage = data.coverage || {};
const series = data.series || {};
const deltas = data.deltas || {};
this.updateSnapshotAge();
// Mesh tiles
this.tile('nodes-24h', mesh.nodes_24h, deltas.nodes, series.nodes);
this.tile('adverts-24h', mesh.adverts_24h, deltas.adverts, series.adverts);
this.tile('new-nodes', mesh.new_nodes_24h, deltas.new_nodes, series.new_nodes);
setText('new-nodes-7d', formatNumber(mesh.new_nodes_7d) + ' in 7d');
setText('gone-quiet', formatNumber(mesh.gone_quiet_7d));
setText('coverage-countries', formatNumber(mesh.countries));
setText('coverage-known', formatNumber(mesh.contacts_known));
setText('coverage-states', formatNumber(mesh.states));
setText('coverage-cities', formatNumber(mesh.cities));
// Bot tiles
this.tile('messages-24h', bot.messages_24h, deltas.messages, series.messages);
this.tile('commands-24h', bot.commands_24h, deltas.commands, series.commands);
const replyRate = el('reply-rate-24h');
if (replyRate) replyRate.textContent = formatPercent(bot.reply_rate_24h);
setText('unique-users-24h', formatNumber(bot.unique_users_24h));
setText('unique-channels-24h', formatNumber(bot.unique_channels_24h) + ' channels');
// Routing, encoding, distributions
this.renderRouteMix(mesh.route_mix, coverage.packets_window_label);
this.renderMix('payload-mix', mesh.payload_mix);
this.renderHopsHistogram(mesh.hops, coverage.packets_window_label);
this.renderDoughnut('contactsEncodingChart', 'contacts-encoding-summary',
(mesh.encoding || {}).contacts_7d, 'contacts');
this.renderDoughnut('packetsEncodingChart', 'packets-encoding-summary',
(mesh.encoding || {}).packets, 'packets');
setText('packets-window-label', coverage.packets_window_label || 'no data');
this.renderPacketEncodingTrend(data.packet_encoding);
this.renderMix('role-mix', mesh.role_mix);
this.renderSourceNotes(coverage);
}
/** Value + delta chip + sparkline for one tile. */
tile(id, value, changePct, points) {
const valueNode = el(id + '-value');
if (valueNode) {
valueNode.textContent = formatNumber(value);
valueNode.classList.toggle('stat-tile__value--muted',
value === null || value === undefined);
}
this.renderDelta(id + '-delta', changePct);
this.renderSparkline(id + '-spark', points);
}
renderDelta(id, changePct) {
const node = el(id);
if (!node) return;
if (changePct === null || changePct === undefined) {
node.className = 'delta-chip';
node.textContent = '';
return;
}
const up = changePct >= 0;
node.className = 'delta-chip ' + (up ? 'delta-chip--up' : 'delta-chip--down');
node.textContent = (up ? '↑' : '↓') + Math.abs(changePct).toFixed(1) + '%';
}
/**
* Shared 48px sparkline. Null points stay null so Chart.js leaves a gap
* — a backfilled day rendered as zero looks like an outage.
*/
renderSparkline(canvasId, points) {
const canvas = el(canvasId);
if (!canvas || typeof Chart === 'undefined') return;
const list = Array.isArray(points) ? points : [];
const colors = themeColors();
if (this.charts[canvasId]) {
this.charts[canvasId].destroy();
delete this.charts[canvasId];
}
if (list.length === 0) return;
this.charts[canvasId] = new Chart(canvas.getContext('2d'), {
type: 'line',
data: {
labels: list.map((p) => p.date),
datasets: [{
data: list.map((p) => p.value),
borderColor: COLOR.series,
backgroundColor: COLOR.series + '22',
borderWidth: 1.5,
fill: true,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 3,
spanGaps: false,
}],
},
options: noAnimation({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
displayColors: false,
callbacks: {
label: (ctx) => (ctx.parsed.y === null
? 'no data'
: formatNumber(ctx.parsed.y)),
},
},
},
scales: {
x: { display: false },
y: { display: false, beginAtZero: true },
},
elements: { line: { borderJoinStyle: 'round' } },
layout: { padding: 0 },
color: colors.muted,
}),
});
}
renderRouteMix(routeMix, windowLabel) {
const bar = el('route-mix-bar');
const legend = el('route-mix-legend');
if (!bar || !legend) return;
const flood = Number((routeMix || {}).flood) || 0;
const direct = Number((routeMix || {}).direct) || 0;
const total = flood + direct;
setText('route-mix-window', windowLabel || 'no data');
bar.replaceChildren();
legend.replaceChildren();
if (total === 0) {
bar.appendChild(makeTextElement('div', '', 'stacked-bar__segment'));
legend.appendChild(makeTextElement('span', 'No dimensioned packets yet.'));
return;
}
[['FLOOD', flood, COLOR.flood], ['DIRECT', direct, COLOR.direct]].forEach(
([label, value, color]) => {
const pct = (value / total) * 100;
const segment = makeTextElement(
'div', pct >= 12 ? pct.toFixed(0) + '%' : '', 'stacked-bar__segment'
);
segment.style.width = pct + '%';
segment.style.backgroundColor = color;
segment.title = label + ': ' + formatNumber(value);
bar.appendChild(segment);
const entry = document.createElement('span');
const swatch = document.createElement('span');
swatch.className = 'stacked-bar__swatch';
swatch.style.backgroundColor = color;
entry.append(swatch, document.createTextNode(
label + ' ' + formatNumber(value) + ' (' + pct.toFixed(1) + '%)'
));
legend.appendChild(entry);
}
);
}
/**
* Hop distance, two ways: where nodes sit, and where flood traffic
* comes from.
*
* The series count different things — 2.8k nodes against 74k packets —
* so plotting raw counts together would flatten the node series into
* the axis. Both are shown as a share of their own total, which is what
* makes the two shapes comparable; absolute counts stay in the tooltip.
*/
renderHopsHistogram(hops, packetWindowLabel) {
const canvasId = 'hopsHistogramChart';
const canvas = el(canvasId);
if (!canvas || typeof Chart === 'undefined') return;
const nodes = Array.isArray((hops || {}).nodes) ? hops.nodes : [];
const flood = Array.isArray((hops || {}).flood_packets) ? hops.flood_packets : [];
const colors = themeColors();
if (this.charts[canvasId]) {
this.charts[canvasId].destroy();
delete this.charts[canvasId];
}
const withheld = (hops || {}).flood_hidden || {};
setText('hops-hidden-note', withheld.packets
? formatNumber(withheld.packets) + ' further flood packets (' +
withheld.share_pct + '%) sit in ' + withheld.buckets +
' hop buckets below ' + withheld.threshold_pct + '% each, and are not drawn.'
: '');
if (nodes.length === 0 && flood.length === 0) return;
const labels = (nodes.length ? nodes : flood).map((b) => b[0]);
const totals = (hops || {}).totals || {};
// Percentages are shares of the whole series, not of the bars that
// survived the display threshold — otherwise hiding the tail would
// silently inflate every remaining bar.
const share = (series, total) => series.map(
(b) => (b[1] === null || !total ? null : (b[1] / total) * 100)
);
const nodeShare = share(nodes, totals.nodes);
const floodShare = share(flood, totals.flood_packets);
// A path can be 64 hops at one byte per hop, so the axis may carry
// 64 grouped categories. Rounded corners and default bar padding
// both disappear at that width, so drop them and let the bars touch.
const dense = labels.length > 24;
const datasets = [];
if (nodes.length) {
datasets.push({
label: 'Nodes (adverts, 7d)',
data: nodeShare,
counts: nodes.map((b) => b[1]),
unit: 'nodes',
backgroundColor: COLOR.accent,
borderRadius: dense ? 0 : 3,
barPercentage: dense ? 1 : 0.9,
categoryPercentage: dense ? 1 : 0.8,
});
}
if (flood.length) {
datasets.push({
label: 'Flood packets (' + (packetWindowLabel || 'retained') + ')',
data: floodShare,
counts: flood.map((b) => b[1]),
unit: 'packets',
backgroundColor: COLOR.flood,
borderRadius: dense ? 0 : 3,
barPercentage: dense ? 1 : 0.9,
categoryPercentage: dense ? 1 : 0.8,
});
}
this.charts[canvasId] = new Chart(canvas.getContext('2d'), {
type: 'bar',
data: { labels, datasets },
options: noAnimation({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: { color: colors.muted, boxWidth: 12, font: { size: 10 } },
},
tooltip: {
callbacks: {
title: (items) => items[0].label + ' hops away',
label: (ctx) => {
const count = ctx.dataset.counts[ctx.dataIndex];
if (count === null || count === undefined) return null;
return ctx.dataset.label + ': ' + formatNumber(count) + ' ' +
ctx.dataset.unit + ' (' + ctx.parsed.y.toFixed(2) + '%)';
},
},
},
},
scales: {
x: {
title: { display: true, text: 'Hops away', color: colors.muted,
font: { size: 10 } },
ticks: {
color: colors.muted,
font: { size: 10 },
autoSkip: true,
maxTicksLimit: 12,
maxRotation: 0,
},
grid: { display: false },
},
y: {
beginAtZero: true,
title: { display: true, text: '% of series', color: colors.muted,
font: { size: 10 } },
ticks: {
color: colors.muted,
font: { size: 10 },
callback: (v) => v + '%',
},
grid: { color: colors.grid },
},
},
}),
});
}
renderDoughnut(canvasId, summaryId, encoding, noun) {
const canvas = el(canvasId);
const summary = el(summaryId);
if (!canvas || typeof Chart === 'undefined') return;
const total = Number((encoding || {}).total) || 0;
let multibyte = Number((encoding || {}).multibyte);
if (!Number.isFinite(multibyte)) multibyte = 0;
multibyte = Math.max(0, Math.min(multibyte, total));
const colors = themeColors();
if (this.charts[canvasId]) {
this.charts[canvasId].destroy();
delete this.charts[canvasId];
}
if (total === 0) {
if (summary) summary.textContent = 'No ' + noun + ' in this window.';
this.charts[canvasId] = new Chart(canvas.getContext('2d'), {
type: 'doughnut',
data: {
labels: ['—'],
datasets: [{ data: [1], backgroundColor: [colors.empty], borderWidth: 0 }],
},
options: noAnimation({
responsive: true,
maintainAspectRatio: true,
plugins: { legend: { display: false }, tooltip: { enabled: false } },
cutout: '55%',
}),
});
return;
}
const pct = Math.round((multibyte / total) * 1000) / 10;
if (summary) {
summary.textContent = pct + '% multibyte (' +
formatNumber(multibyte) + ' of ' + formatNumber(total) + ')';
}
this.charts[canvasId] = new Chart(canvas.getContext('2d'), {
type: 'doughnut',
data: {
labels: ['Multibyte paths', 'Other'],
datasets: [{
data: [multibyte, total - multibyte],
backgroundColor: [COLOR.multibyte, COLOR.singleByte],
borderWidth: 0,
}],
},
options: noAnimation({
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: {
position: 'bottom',
labels: { color: colors.muted, boxWidth: 12, font: { size: 11 } },
},
tooltip: {
callbacks: {
label: (ctx) => {
const n = ctx.raw;
const p = total ? Math.round((n / total) * 1000) / 10 : 0;
return ' ' + formatNumber(n) + ' (' + p + '%)';
},
},
},
},
cutout: '55%',
}),
});
}
/**
* One bar per day. The bar's full height is the share of that day's
* packets that took a multibyte path, and it is divided by the payload
* type that carried them — so a day where 24% of traffic went multibyte
* draws a bar 24% tall, split eight ways.
*
* Every segment is a fraction of the same denominator, the day's whole
* traffic. That is what makes the stack legitimate: each type's *own*
* multibyte share is a ratio over its own packet count, and eight such
* ratios share no denominator, so stacking those would sum to whatever
* it happened to sum to. The per-type adoption figure is not lost —
* the tooltip quotes both readings.
*/
renderPacketEncodingTrend(days) {
const canvasId = 'multibyteTrendChart';
const canvas = el(canvasId);
if (!canvas || typeof Chart === 'undefined') return;
const list = Array.isArray(days) ? days : [];
const colors = themeColors();
const palette = SERIES_PALETTE[isDark() ? 'dark' : 'light'];
if (this.charts[canvasId]) {
this.charts[canvasId].destroy();
delete this.charts[canvasId];
}
const counts = (day, name) => (day.types || {})[name] || null;
// The denominator is every packet the day classified, including the
// OTHER bucket and every single-byte packet — not just the
// multibyte ones, which is what makes bar height a share of traffic.
const dayPackets = list.map((day) => Object.values(day.types || {}).reduce(
(sum, entry) => sum + ((entry && entry.total) || 0), 0
));
const dayMultibyte = list.map((day) => Object.values(day.types || {}).reduce(
(sum, entry) => sum + ((entry && entry.mb) || 0), 0
));
const segment = (day, name, i) => {
const entry = counts(day, name);
if (!entry || !dayPackets[i]) return null;
return (entry.mb / dayPackets[i]) * 100;
};
const barHeights = list.map((day, i) => PACKET_ENCODING_TYPES.reduce(
(sum, name) => sum + (segment(day, name, i) || 0), 0
));
// Round the tallest bar up to the next step so the bars fill the
// plot: a 24% peak against a 0-100 axis wastes three quarters of the
// card and flattens every difference worth seeing.
const peak = barHeights.length ? Math.max(...barHeights) : 0;
const axisMax = Math.min(100, Math.max(
AXIS_STEP_PCT,
// Nudge down before rounding so a peak landing exactly on a step
// keeps that step rather than jumping to the next one.
Math.ceil((peak - 1e-9) / AXIS_STEP_PCT) * AXIS_STEP_PCT
));
const datasets = [];
PACKET_ENCODING_TYPES.forEach((name, index) => {
// A type that carried no multibyte traffic all window would take
// a legend slot to describe an invisible segment.
if (!list.some((day, i) => segment(day, name, i) > 0)) return;
const color = palette[index % palette.length];
datasets.push({
label: name === 'OTHER' ? 'Other' : name,
data: list.map((day, i) => segment(day, name, i)),
// Raw figures ride along so the tooltip can quote packet
// counts and each type's own adoption without a second fetch.
rawCounts: list.map((day) => counts(day, name)),
backgroundColor: color,
// A hairline of the card colour between segments; without it
// two adjacent hues read as one band. Dropped on segments
// too thin to separate — GRP_DATA runs well under 1% of a
// day, and a border on a 1px band erases the colour
// entirely, turning a real category into a surface-coloured
// gap. Measured against the axis, not the value, because
// the axis is what decides how many pixels a percent buys.
borderColor: colors.surface,
borderWidth: (ctx) => (
(ctx.raw ?? 0) / axisMax >= MIN_SEGMENT_FRACTION_FOR_GAP
? { top: 1, bottom: 1 }
: 0
),
borderSkipped: false,
// Now that the tooltip describes one segment, something has
// to say which. Chart.js would answer by saturating the
// fill to a hue outside the validated palette; outline it
// instead, so identity keeps its colour and the cue reads as
// a pointer. It also gives the sub-pixel slices a mark big
// enough to see once they are the ones being described.
hoverBackgroundColor: color,
hoverBorderColor: colors.text,
hoverBorderWidth: 1,
});
});
const empty = el('multibyte-trend-empty');
const hasData = datasets.length > 0 && barHeights.some((height) => height > 0);
if (empty) empty.style.display = hasData ? 'none' : '';
canvas.style.display = hasData ? '' : 'none';
if (!hasData) return;
this.charts[canvasId] = new Chart(canvas.getContext('2d'), {
type: 'bar',
data: { labels: list.map((day) => day.date), datasets },
options: noAnimation({
responsive: true,
maintainAspectRatio: false,
// One segment at a time. 'nearest' rather than 'point'
// because intersect would make the thin slices unhittable —
// GRP_DATA is under a pixel tall on most days, so requiring
// the cursor to land inside it hides the very rows a reader
// most needs the tooltip for. This way the column picks the
// segment whose band the cursor is closest to.
interaction: { mode: 'nearest', intersect: false, axis: 'xy' },
plugins: {
legend: {
position: 'bottom',
labels: {
color: colors.muted,
boxWidth: 10,
boxHeight: 10,
padding: 8,
font: { size: 10 },
usePointStyle: true,
pointStyle: 'rect',
},
},
tooltip: {
filter: (ctx) => ctx.parsed.y !== null && ctx.parsed.y > 0,
callbacks: {
// Room to spell both readings out now that only
// the hovered type is described.
label: (ctx) => {
const entry = (ctx.dataset.rawCounts || [])[ctx.dataIndex];
const name = ctx.dataset.label;
const slice = ctx.parsed.y.toFixed(1) + '% of the day\'s packets';
if (!entry) return [' ' + name, ' ' + slice];
const adoption = entry.total
? (entry.mb / entry.total * 100).toFixed(1) +
'% of all ' + name
: 'no ' + name + ' traffic';
return [
' ' + name + ' — ' + slice,
' ' + formatNumber(entry.mb) + ' of ' +
formatNumber(entry.total) + ' packets · ' + adoption,
];
},
// The bar's own height, so the segment reads
// against the whole it is part of.
footer: (items) => {
if (!items.length) return '';
const i = items[0].dataIndex;
return 'Whole bar: ' + barHeights[i].toFixed(1) +
'% multibyte (' + formatNumber(dayMultibyte[i]) +
' of ' + formatNumber(dayPackets[i]) + ')';
},
},
},
},
scales: {
x: {
stacked: true,
ticks: { color: colors.muted, font: { size: 10 }, maxTicksLimit: 8 },
grid: { display: false },
},
y: {
stacked: true,
min: 0,
max: axisMax,
ticks: {
// Gridlines on the same step the ceiling is
// rounded to, so every label is a round number
// and the top one is the ceiling itself.
stepSize: axisMax <= 40 ? AXIS_STEP_PCT : undefined,
maxTicksLimit: 8,
color: colors.muted,
font: { size: 10 },
callback: (v) => v + '%',
},
grid: { color: colors.grid },
},
},
}),
});
}
renderMix(containerId, entries) {
const container = el(containerId);
if (!container) return;
const list = Array.isArray(entries) ? entries : [];
if (list.length === 0) {
container.replaceChildren(makeTextElement('div', 'No data.', 'dashboard-empty'));
return;
}
// No slicing here: the server already rolls the tail into "Other",
// so dropping rows now would leave bars that no longer sum to the
// total shown beside them.
const max = Math.max(...list.map((entry) => Number(entry[1]) || 0), 1);
container.replaceChildren();
list.forEach((entry, index) => {
const [name, count] = entry;
const row = document.createElement('div');
row.className = 'mix-row';
const track = document.createElement('div');
track.className = 'mix-row__track';
const fill = document.createElement('div');
fill.className = 'mix-row__fill';
fill.style.width = ((Number(count) || 0) / max * 100).toFixed(1) + '%';
fill.style.backgroundColor = MIX_COLORS[index % MIX_COLORS.length];
track.appendChild(fill);
row.append(
makeTextElement('div', name, 'mix-row__name'),
track,
makeTextElement('div', formatNumber(count), 'mix-row__count')
);
container.appendChild(row);
});
}
/** Say plainly which sources are missing rather than showing zeros. */
renderSourceNotes(coverage) {
const present = new Set(coverage.sources_present || []);
const note = el('bot-source-note');
if (!note) return;
const missing = ['message_stats', 'command_stats', 'path_stats']
.filter((name) => !present.has(name));
if (missing.length === 0) {
note.textContent = '';
note.style.display = 'none';
return;
}
note.style.display = '';
note.textContent =
'Bot statistics are unavailable: ' + missing.join(', ') +
' not present (set collect_stats = true in [Stats_Command] to record them).';
}
updateSnapshotAge() {
if (this.lastGeneratedAt === null) return;
const age = Date.now() / 1000 - this.lastGeneratedAt;
setText('health-snapshot', 'snapshot ' + formatAge(age));
}
tickUptime() {
if (this.botUptime === null) return;
this.botUptime += 1;
setText('health-uptime', formatUptime(this.botUptime));
}
setStatusDot(id, state) {
const node = el(id);
if (node) node.className = 'status-indicator status-' + state;
}
setDatabaseStatus(ok) {
const node = el('health-db');
if (!node) return;
node.textContent = ok
? 'DB ok' + (BOOT.database_size ? ' · ' + formatBytes(BOOT.database_size) : '')
: 'DB error';
node.className = 'health-strip__item' + (ok ? '' : ' text-danger');
}
// -- interaction --------------------------------------------------
wireSelectors() {
Object.entries(TOP_LISTS).forEach(([kind, config]) => {
const select = el(config.select);
if (select) select.addEventListener('change', () => this.loadTop(kind));
});
}
wireRefreshButton() {
const button = el('snapshot-refresh');
if (!button) return;
button.addEventListener('click', async () => {
button.disabled = true;
try {
await fetch('/api/dashboard/refresh', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
});
this.summaryEtag = null;
await this.loadSummary();
} catch (err) {
console.error('Manual refresh failed', err);
} finally {
button.disabled = false;
}
});
}
}
// ── leaderboard configuration ────────────────────────────────────────
/**
* Link-quality colour for an SNR value. The boundaries are LoRa-practical
* rather than arbitrary: below 0 dB the signal is under the noise floor and
* only spreading recovers it, and below about -7 dB even that starts to
* fail at the higher spreading factors.
*/
function snrColor(snr) {
if (snr === null || snr === undefined) return '#adb5bd';
if (snr < -7) return '#dc3545';
if (snr < 0) return '#fd7e14';
if (snr < 6) return '#ffc107';
return '#198754';
}
/**
* One one-hop neighbour: name, SNR bar on a fixed scale, values.
*
* Signal is shown only where the stored hop count corroborates the path
* evidence. For the rest the row says "no signal reading" rather than
* borrowing a number measured on somebody else's link.
*/
function neighborRow(item) {
const row = document.createElement('div');
row.className = 'neighbor-row';
const name = makeTextElement('div', item.name, 'neighbor-row__name');
name.title = String(item.name ?? '') + ' (' + String(item.role ?? '') + ')';
const track = document.createElement('div');
track.className = 'neighbor-row__track';
const values = document.createElement('div');
values.className = 'neighbor-row__values';
if (item.signal_corroborated) {
// Fixed -12..+14 dB scale so bars are comparable between refreshes
// rather than rescaling to whatever the current worst link happens to be.
const fill = document.createElement('div');
fill.className = 'neighbor-row__fill';
fill.style.width = (Math.max(0, Math.min(1, (Number(item.snr) + 12) / 26)) * 100)
.toFixed(1) + '%';
fill.style.backgroundColor = snrColor(item.snr);
track.appendChild(fill);
values.append(
makeTextElement('span', formatSigned(item.snr) + ' dB', 'neighbor-row__snr'),
makeTextElement('span',
item.rssi === null || item.rssi === undefined
? ''
: ' / ' + Math.round(item.rssi) + ' dBm',
'neighbor-row__rssi')
);
} else {
track.classList.add('neighbor-row__track--unknown');
const unknown = makeTextElement('span', 'no signal reading', 'neighbor-row__rssi');
unknown.title = 'Heard one hop away, but no corroborated direct-reception '
+ 'measurement is stored for this node.';
values.appendChild(unknown);
}
row.append(name, track, values);
return row;
}
function rankedRow(rank, name, badgeText, badgeClass) {
const row = document.createElement('div');
row.className = 'top-list-row';
const identity = document.createElement('div');
identity.className = 'd-flex align-items-center gap-2 top-list-row__name';
const label = makeTextElement('span', name, 'fw-semibold top-list-row__name');
label.title = String(name ?? '');
identity.append(makeBadge(rank, 'bg-secondary'), label);
row.append(identity, makeBadge(badgeText, badgeClass));
return row;
}
const TOP_LISTS = {
commands: {
select: 'top-commands-window',
container: 'top-commands-list',
limit: 15,
empty: 'No commands recorded.',
render: (item, i) => rankedRow(i + 1, item.name, formatNumber(item.count) + ' uses', 'bg-primary'),
},
users: {
select: 'top-users-window',
container: 'top-users-list',
limit: 15,
empty: 'No users recorded.',
render: (item, i) => rankedRow(i + 1, item.name, formatNumber(item.count) + ' msgs', 'bg-info'),
},
channels: {
select: 'top-channels-window',
container: 'top-channels-list',
limit: 10,
empty: 'No channels recorded.',
render: (item, i) => {
const row = rankedRow(i + 1, item.name, formatNumber(item.count) + ' msgs', 'bg-secondary');
row.appendChild(makeBadge(formatNumber(item.users) + ' users', 'bg-info'));
return row;
},
},
paths: {
select: 'top-paths-window',
container: 'top-paths-list',
limit: 5,
empty: 'No paths recorded.',
render: (item, i) => {
const card = document.createElement('div');
card.className = 'mb-2 pb-2 border-bottom';
const heading = document.createElement('div');
heading.className = 'd-flex align-items-center gap-2 mb-1';
const name = makeTextElement('span', item.name, 'fw-semibold top-list-row__name');
name.title = String(item.name ?? '');
heading.append(makeBadge(i + 1, 'bg-warning text-dark'), name,
makeBadge(item.path_length + ' hops', 'bg-danger'));
const path = makeTextElement('code', item.path_string, 'small text-break');
path.style.wordBreak = 'break-all';
const when = makeTextElement(
'div',
item.timestamp ? new Date(Number(item.timestamp) * 1000).toLocaleString() : '',
'dashboard-note'
);
card.append(heading, path, when);
return card;
},
},
repeaters: {
select: 'top-repeaters-window',
container: 'top-repeaters-list',
limit: 10,
empty: 'No repeater adverts recorded.',
render: (item, i) => rankedRow(i + 1, item.name, formatNumber(item.count) + ' adverts', 'bg-success'),
},
neighbors: {
select: 'neighbors-window',
container: 'neighbors-list',
limit: 10,
empty: 'No one-hop adverts heard in this window.',
render: (item) => neighborRow(item),
onLoad: (data) => {
setText('neighbors-count', formatNumber(data.total));
const shown = Math.min(data.total || 0, (data.items || []).length);
setText('neighbors-shown',
data.total > shown ? 'showing ' + shown + ' of ' + formatNumber(data.total) : '');
},
},
};
// ── connected clients modal ──────────────────────────────────────────
async function loadConnectedClients() {
const loading = el('connected-clients-loading');
const empty = el('connected-clients-empty');
const table = el('connected-clients-table');
const tbody = el('connected-clients-tbody');
if (!loading) return;
loading.style.display = '';
empty.style.display = 'none';
table.style.display = 'none';
tbody.replaceChildren();
try {
const response = await fetch('/api/connected_clients');
const clients = await response.json();
loading.style.display = 'none';
if (!Array.isArray(clients) || clients.length === 0) {
empty.style.display = '';
return;
}
table.style.display = '';
clients.forEach((client) => {
const row = document.createElement('tr');
const idCell = document.createElement('td');
idCell.appendChild(makeTextElement('code', client.client_id));
row.append(
idCell,
makeTextElement('td', client.connected_at
? new Date(client.connected_at * 1000).toLocaleTimeString() : '—'),
makeTextElement('td', client.last_activity
? new Date(client.last_activity * 1000).toLocaleTimeString() : '—')
);
tbody.appendChild(row);
});
} catch (err) {
loading.style.display = 'none';
empty.style.display = '';
empty.textContent = 'Failed to load client list.';
}
}
// ── tooltips ─────────────────────────────────────────────────────────
const TOOLTIPS = {
'contacts-encoding-info':
'Contacts heard in the last 7 days count as multibyte capable if any apply: ' +
'their stored path encoding (out bytes per hop) is 2 or 3; we saw an advert from ' +
'them in that window on a path with multibyte hops; or, for repeaters and room ' +
'servers, their public key prefix matches a hop prefix from a multibyte advert path.',
'multibyte-trend-info':
'Each bar\'s height is the share of that day\'s packets that arrived on a path ' +
'with 2- or 3-byte hop hashes, divided by the payload type that carried them. A ' +
'segment is that type\'s share of the whole day\'s traffic, not of its own ' +
'packets — hover for both figures. The axis tops out at the tallest bar rounded ' +
'up to the next 5%, so it is not comparable between screenshots taken on ' +
'different days. Counted from the packet stream as each day is rolled up: the ' +
'stream itself is pruned within days, so the chart accumulates forward and days ' +
'before it existed are blank rather than zero. Packets whose encoding has not ' +
'been classified yet are left out rather than counted as single-byte.',
'delta-info':
'Change compares the last complete calendar day against the day before it — ' +
'not a rolling 24 hours. The headline number above it is a rolling 24-hour count.',
'neighbors-info':
'Nodes whose advert reached this radio in a single hop, newest evidence first, ' +
'with the weakest measured links promoted to the top. Membership comes from the ' +
'observed path length divided by its bytes-per-hop encoding, not from the stored ' +
'hop count — that field claims far more direct neighbours than the path evidence ' +
'supports. SNR is shown only where both agree; a relayed packet\'s SNR measures ' +
'the last hop into this radio rather than the link to whoever sent it, so the ' +
'rest are left blank instead of borrowing another link\'s number.',
'hops-info':
'Two distributions on one axis. Nodes: the fewest hops any of a node\'s adverts ' +
'took to reach this radio, over 7 days. Flood packets: how far each flood packet ' +
'had already travelled when it arrived, over whatever the packet stream retains ' +
'(typically 3 days). Because one counts nodes and the other counts packets, both ' +
'are drawn as a share of their own total; the tooltip gives the raw counts. ' +
'Hop buckets holding under 0.1% of the flood series are omitted — that tail ' +
'decays for around twenty hops in bars under a pixel tall — and the amount left ' +
'out is stated beneath the chart. Percentages stay shares of the full series, ' +
'not of the drawn subset. Flood packets carry no sender identity, so they cannot ' +
'be reduced to a shortest path per node the way adverts can.',
};
function initTooltips() {
if (typeof bootstrap === 'undefined' || !bootstrap.Tooltip) return;
Object.entries(TOOLTIPS).forEach(([id, title]) => {
const node = el(id);
if (!node) return;
const existing = bootstrap.Tooltip.getInstance(node);
if (existing) existing.dispose();
new bootstrap.Tooltip(node, { title, html: false, customClass: 'dashboard-hint' });
});
}
// ── bootstrap ────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
window.dashboard = new ModernDashboard();
el('health-clients-link')?.addEventListener('click', (event) => {
event.preventDefault();
loadConnectedClients();
});
initTooltips();
});
window.addEventListener('beforeunload', () => {
if (window.dashboard) window.dashboard.destroy();
});
})();