Files
meshcore-bot/modules/web_viewer/static/js/dashboard.js
T
agessaman f68da5287c fix(web-viewer): derive neighbours and hop distance from path evidence
307 direct neighbours was not plausible, and it was not real.

complete_contact_tracking.hop_count claims 800 zero-hop contacts. Only
68 of them have any one-hop path in observed_paths to corroborate that.
Their stored SNR piles up in a 1.5 dB band — 655 of 800 between 11.25
and 12.75 dB — and their RSSI clusters at -39..-48 dBm. Hundreds of
radios at different distances and terrain cannot land in a 10 dB window.
That is the signature of one strong local link being recorded against
every node whose traffic happened to arrive through it. Their return
paths agree: these "direct" contacts have out_path_len of 3 to 11.

The writer's intent is sound — repeater_manager only stores RSSI/SNR
when signal_info reports hops == 0 — so the field being fed to it does
not mean what the surrounding code assumes. Left as is; this change
stops the dashboard depending on it.

Neighbour membership now comes from path evidence: an advert whose
path_length equals its bytes_per_hop travelled exactly one hop. That
yields 38 nodes in 24h and 124 in 7d, with a plausible spread. Signal is
shown only where the path evidence and the stored hop count agree, which
is 5 and 12 nodes respectively; the rest read "no signal reading" rather
than borrowing a measurement taken on somebody else's link. A 24h/7d
selector bounds the window, capped well under observed_paths' 90-day
retention because a month-old link says nothing about today.

Separately, this fixes a bug I introduced. path_length is a BYTE count,
and with 2- or 3-byte hop encoding a 3-hop path is 6 or 9 bytes long. The
path-length histogram plotted that raw value on an axis readers would
take as hops, overstating distance two- to threefold on a mesh that is
~95% multibyte. It is replaced by a single hops-away chart computed as
path_length / bytes_per_hop, which also retires the histogram built on
the untrustworthy stored hop count. The result is unimodal, peaking at 3
hops and decaying — the shape a mesh should have, and not the bimodal
one the old chart drew.
2026-07-29 22:23:43 -07:00

1123 lines
47 KiB
JavaScript
Raw 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',
];
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(),
};
}
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.renderHopsHistogram(mesh.hops_histogram);
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.renderMultibyteTrend(series.multibyte_share);
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);
}
);
}
renderHopsHistogram(points) {
const canvasId = 'hopsHistogramChart';
const canvas = el(canvasId);
if (!canvas || typeof Chart === 'undefined') return;
const bins = Array.isArray(points) ? points : [];
const colors = themeColors();
if (this.charts[canvasId]) {
this.charts[canvasId].destroy();
delete this.charts[canvasId];
}
if (bins.length === 0) return;
this.charts[canvasId] = new Chart(canvas.getContext('2d'), {
type: 'bar',
data: {
labels: bins.map((b) => b[0]),
datasets: [{
label: 'Nodes',
data: bins.map((b) => b[1]),
backgroundColor: COLOR.accent,
borderRadius: 3,
}],
},
options: noAnimation({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
title: (items) => items[0].label + ' hops away',
label: (ctx) => formatNumber(ctx.parsed.y) + ' nodes',
},
},
},
scales: {
x: {
title: { display: true, text: 'Hops away', color: colors.muted,
font: { size: 10 } },
ticks: { color: colors.muted, font: { size: 10 } },
grid: { display: false },
},
y: {
beginAtZero: true,
ticks: { color: colors.muted, font: { size: 10 }, precision: 0 },
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%',
}),
});
}
renderMultibyteTrend(points) {
const canvasId = 'multibyteTrendChart';
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];
}
const empty = el('multibyte-trend-empty');
const hasData = list.some((p) => p.value !== null && p.value !== undefined);
if (empty) empty.style.display = hasData ? 'none' : '';
canvas.style.display = hasData ? '' : 'none';
if (!hasData) return;
this.charts[canvasId] = new Chart(canvas.getContext('2d'), {
type: 'line',
data: {
labels: list.map((p) => p.date),
datasets: [{
label: 'Multibyte share',
data: list.map((p) => p.value),
borderColor: COLOR.multibyte,
backgroundColor: COLOR.multibyte + '33',
fill: true,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
spanGaps: false,
}],
},
options: noAnimation({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (ctx) => (ctx.parsed.y === null
? 'no data for this day'
: ctx.parsed.y.toFixed(1) + '% of adverts'),
},
},
},
scales: {
x: { ticks: { color: colors.muted, font: { size: 10 }, maxTicksLimit: 8 }, grid: { display: false } },
y: {
min: 0,
max: 100,
ticks: { 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;
}
const max = Math.max(...list.map((entry) => Number(entry[1]) || 0), 1);
container.replaceChildren();
list.slice(0, 8).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) : '');
},
},
};
// ── live activity feed ───────────────────────────────────────────────
function initLiveFeed() {
const feed = el('live-feed');
const dot = el('live-dot');
const countBadge = el('live-count');
const placeholder = el('live-placeholder');
if (!feed || !window.connectionManager) return;
const TYPE_COLORS = { packet: '#fd7e14', command: '#198754', message: '#0dcaf0' };
const activeFilters = { packet: true, command: true, message: true };
const MAX_ENTRIES = 100;
let paused = false;
let total = 0;
function applyFilters() {
Array.from(feed.children).forEach((node) => {
const type = node.dataset.type;
if (type) node.style.display = activeFilters[type] ? '' : 'none';
});
}
document.querySelectorAll('.live-filter-cb').forEach((checkbox) => {
checkbox.addEventListener('change', () => {
activeFilters[checkbox.dataset.type] = checkbox.checked;
applyFilters();
});
});
function addEntry(label, text, type) {
if (paused) return;
if (placeholder && placeholder.parentNode) placeholder.remove();
const node = document.createElement('div');
node.dataset.type = type;
node.className = 'live-feed__entry';
node.style.borderLeftColor = TYPE_COLORS[type] || '#6c757d';
node.append(
makeTextElement('span', new Date().toLocaleTimeString(), 'text-muted me-2'),
makeTextElement('strong', label),
makeTextElement('span', ' ' + String(text ?? ''), 'text-muted')
);
if (!activeFilters[type]) node.style.display = 'none';
feed.insertBefore(node, feed.firstChild);
total += 1;
if (countBadge) countBadge.textContent = total;
while (feed.children.length > MAX_ENTRIES) feed.removeChild(feed.lastChild);
}
el('live-pause-btn')?.addEventListener('click', () => {
paused = !paused;
const icon = el('live-pause-icon');
if (icon) icon.className = paused ? 'fas fa-play' : 'fas fa-pause';
});
el('live-clear-btn')?.addEventListener('click', () => {
feed.replaceChildren();
total = 0;
if (countBadge) countBadge.textContent = '0';
});
el('live-scroll-top')?.addEventListener('click', () => { feed.scrollTop = 0; });
el('live-scroll-bottom')?.addEventListener('click', () => { feed.scrollTop = feed.scrollHeight; });
const socket = window.connectionManager.socket;
socket.on('connect', () => {
if (dot) dot.className = 'status-indicator status-connected ms-2';
socket.emit('subscribe_packets');
socket.emit('subscribe_commands');
socket.emit('subscribe_messages');
});
socket.on('disconnect', () => {
if (dot) dot.className = 'status-indicator status-disconnected ms-2';
});
socket.on('packet_data', (d) => {
addEntry(d.payload_type_name || d.type_name || 'Packet', d.from_name || d.pubkey_prefix || '', 'packet');
});
socket.on('command_data', (d) => {
addEntry('Cmd: ' + (d.command || '?'), (d.user || '') + ' → ' + (d.channel || ''), 'command');
});
socket.on('message_data', (d) => {
const channel = d.channel ? '[' + d.channel + '] ' : (d.is_dm ? '[DM] ' : '');
addEntry(d.sender || '?', channel + (d.content || ''), 'message');
});
}
// ── 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':
'Share of daily adverts from nodes classified as multibyte capable. ' +
'Classification is only knowable as of now, so only the last few days are ' +
'recomputed and older days are frozen at the value recorded then. Days before ' +
'the rollup existed have no value and render as a gap, not as zero.',
'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':
'Nodes by the fewest hops any of their adverts took to reach this radio, over the ' +
'last 7 days. Hops are derived as path length divided by bytes per hop: path ' +
'length is a byte count, so with 2- or 3-byte hop encoding the raw value would ' +
'overstate the distance by two to three times.',
};
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();
});
initLiveFeed();
initTooltips();
});
window.addEventListener('beforeunload', () => {
if (window.dashboard) window.dashboard.destroy();
});
})();