Files
healthcheck-container/templates/index.html
T
Milan PandurovandClaude Fable 5.1 62b57a7e56 Add MQTT diagnostics, broker connection probe, and switch to Debian base
The MQTT publisher now tracks connection state, publish counters, discovery
and availability timestamps, and the last error, exposed at /api/mqtt and on
the dashboard as an MQTT card and a Home Assistant section. Connection
failures, disconnects, and dropped publishes are logged; paho's
on_connect_fail callback was not registered before, so failed attempts were
silent. MQTT connect, disconnect, and unreachable events go to the timeline.

When a connection attempt fails the service runs a probe from inside the
container: system resolver, each nameserver from resolv.conf queried directly,
/etc/hosts, and a TCP connect to every address found. The result is shown as
a summary and raw JSON on the dashboard and can be rerun via
POST /api/mqtt/probe.

The image base moves from Alpine to Debian slim. musl queries all
nameservers in parallel and accepts the first reply, so a fast public
NXDOMAIN beats a slower local server that knows the name. glibc asks the
nameservers in order.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 12:08:48 +02:00

393 lines
24 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Health check</title>
<style>
:root {
--bg: #0f1419; --panel: #171d26; --border: #262f3b; --text: #e6e9ef; --muted: #8b97a8;
--ok: #3fb950; --bad: #f85149; --warn: #d29922; --accent: #58a6ff;
--c1: #58a6ff; --c2: #3fb950; --c3: #d29922; --c4: #bc8cff; --c5: #f85149;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
main { max-width: 1200px; margin: 0 auto; padding: 20px 16px 40px; }
header { display: flex; flex-wrap: wrap; align-items: baseline; gap: 12px 20px; margin-bottom: 20px; }
header h1 { font-size: 20px; margin: 0; }
header .meta { color: var(--muted); }
h2 { font-size: 15px; margin: 28px 0 10px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; }
.card .label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
.card .value { font-size: 22px; font-weight: 600; margin: 4px 0 2px; word-break: break-all; }
.card .sub { color: var(--muted); font-size: 12px; }
.dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 8px; vertical-align: middle; background: var(--muted); }
.dot.ok { background: var(--ok); } .dot.bad { background: var(--bad); } .dot.warn { background: var(--warn); }
.error { color: var(--bad); font-size: 12px; margin-top: 6px; }
.notice { background: rgba(210,153,34,.12); border: 1px solid rgba(210,153,34,.4); color: #e3b341; border-radius: 6px; padding: 10px 12px; margin: 10px 0; }
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); white-space: nowrap; }
th { color: var(--muted); font-weight: 500; }
td.num { text-align: right; font-variant-numeric: tabular-nums; }
.table-wrap { overflow-x: auto; }
.badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 12px; background: var(--border); color: var(--text); margin-right: 6px; }
.badge.wireless { background: rgba(88,166,255,.2); color: var(--accent); }
.badge.selected { outline: 1px solid var(--ok); }
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px; font-size: 13px; }
.kv dt { color: var(--muted); } .kv dd { margin: 0; }
.range { display: flex; gap: 6px; margin-bottom: 12px; }
.range button { background: var(--panel); border: 1px solid var(--border); color: var(--text); padding: 4px 12px; border-radius: 6px; cursor: pointer; }
.range button.active { border-color: var(--accent); color: var(--accent); }
.charts { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 12px; }
.chart h3 { font-size: 13px; font-weight: 500; margin: 0 0 8px; color: var(--muted); }
.chart canvas { width: 100%; height: 180px; display: block; }
.legend { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 12px; color: var(--muted); margin-top: 6px; }
.legend i { display: inline-block; width: 10px; height: 3px; margin-right: 6px; vertical-align: middle; }
.empty { color: var(--muted); font-size: 13px; padding: 12px 0; }
ul.timeline { list-style: none; margin: 0; padding: 0; }
ul.timeline li { display: grid; grid-template-columns: 170px 1fr; gap: 12px; padding: 7px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
ul.timeline time { color: var(--muted); font-variant-numeric: tabular-nums; }
ul.timeline .kind { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 8px; background: var(--muted); }
.kind.up, .kind.wifi_connected, .kind.public_ip { background: var(--ok); }
.kind.down, .kind.wifi_disconnected, .kind.reboot { background: var(--bad); }
.kind.public_ip_changed, .kind.wifi_roamed, .kind.service_started { background: var(--warn); }
.kind.mqtt_connected { background: var(--ok); }
.kind.mqtt_disconnected, .kind.mqtt_connect_failed { background: var(--bad); }
.btn { background: var(--panel); border: 1px solid var(--border); color: var(--text); padding: 4px 12px; border-radius: 6px; cursor: pointer; }
.btn:disabled { opacity: .5; cursor: default; }
ul.probe { list-style: none; margin: 0 0 8px; padding: 0; font-size: 13px; }
ul.probe li { padding: 4px 0; border-bottom: 1px solid var(--border); }
ul.probe li.bad { color: var(--bad); } ul.probe li.ok { color: var(--ok); }
pre { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 10px 12px; font-size: 12px; overflow-x: auto; margin: 8px 0 0; }
@media (max-width: 600px) { ul.timeline li { grid-template-columns: 1fr; gap: 2px; } }
</style>
</head>
<body>
<main>
<header>
<h1 id="device">Health check</h1>
<span class="meta" id="uptime"></span>
<span class="meta" id="updated"></span>
</header>
<section class="cards">
<div class="card" id="card-internet"><div class="label">Internet</div><div class="value">…</div><div class="sub"></div></div>
<div class="card" id="card-dns"><div class="label">DNS</div><div class="value">…</div><div class="sub"></div></div>
<div class="card" id="card-ip"><div class="label">Public IP</div><div class="value">…</div><div class="sub"></div></div>
<div class="card" id="card-wifi"><div class="label">Wi-Fi</div><div class="value">…</div><div class="sub"></div></div>
<div class="card" id="card-mqtt"><div class="label">MQTT</div><div class="value">…</div><div class="sub"></div></div>
</section>
<h2>History</h2>
<div class="range" id="range">
<button data-range="3600">1h</button>
<button data-range="21600" class="active">6h</button>
<button data-range="86400">24h</button>
<button data-range="604800">7d</button>
</div>
<div class="charts">
<div class="panel chart"><h3>Reachability latency (ms)</h3><canvas id="ch-reach"></canvas><div class="legend" id="lg-reach"></div></div>
<div class="panel chart"><h3>DNS latency (ms)</h3><canvas id="ch-dns"></canvas><div class="legend" id="lg-dns"></div></div>
</div>
<h2>Timeline</h2>
<div class="panel"><ul class="timeline" id="timeline"></ul></div>
<h2>Wi-Fi</h2>
<div class="panel">
<div id="wifi-interfaces"></div>
<div id="wifi-notice"></div>
<div id="wifi-link" style="margin-top:12px"></div>
</div>
<div class="panel" style="margin-top:12px">
<div style="display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;margin-bottom:8px">
<strong>Networks in range</strong>
<span class="meta" id="scan-meta" style="color:var(--muted);font-size:12px"></span>
</div>
<div id="scan-error"></div>
<div class="table-wrap" id="scan-table"></div>
</div>
<div class="charts" style="margin-top:12px">
<div class="panel chart"><h3>Wi-Fi signal (dBm)</h3><canvas id="ch-signal"></canvas><div class="legend" id="lg-signal"></div></div>
<div class="panel chart"><h3>Wi-Fi bitrate (Mbit/s)</h3><canvas id="ch-bitrate"></canvas><div class="legend" id="lg-bitrate"></div></div>
<div class="panel chart"><h3>Wi-Fi retries / failed / beacon loss per interval</h3><canvas id="ch-errors"></canvas><div class="legend" id="lg-errors"></div></div>
</div>
<h2>Home Assistant (MQTT)</h2>
<div class="panel">
<div id="mqtt-notice"></div>
<div id="mqtt-details"></div>
<details id="mqtt-payload-wrap" style="margin-top:12px">
<summary style="cursor:pointer;color:var(--muted)">Last state payload</summary>
<pre id="mqtt-payload"></pre>
</details>
</div>
<div class="panel" id="probe-panel" style="margin-top:12px">
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:8px">
<strong>Broker connection probe</strong>
<span style="display:flex;align-items:center;gap:10px">
<span class="meta" id="probe-meta" style="color:var(--muted);font-size:12px"></span>
<button id="probe-run" class="btn">Run probe now</button>
</span>
</div>
<ul id="probe-summary" class="probe"></ul>
<details id="probe-json-wrap">
<summary style="cursor:pointer;color:var(--muted)">Raw JSON</summary>
<pre id="probe-json"></pre>
</details>
</div>
</main>
<script>
const $ = (id) => document.getElementById(id);
const COLORS = ['#58a6ff', '#3fb950', '#d29922', '#bc8cff', '#f85149', '#79c0ff'];
let range = 21600;
function esc(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function ago(ts, now) {
if (!ts) return '';
const s = Math.max(0, Math.round((now ?? Date.now() / 1000) - ts));
if (s < 90) return s + 's ago';
if (s < 5400) return Math.round(s / 60) + 'm ago';
if (s < 172800) return Math.round(s / 3600) + 'h ago';
return Math.round(s / 86400) + 'd ago';
}
function duration(s) {
if (s == null) return '';
const d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600), m = Math.floor(s % 3600 / 60);
return (d ? d + 'd ' : '') + (h || d ? h + 'h ' : '') + m + 'm';
}
function fmtTime(ts) {
const d = new Date(ts * 1000);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' + d.toLocaleTimeString(undefined, { hour12: false });
}
function card(id, cls, value, sub, error) {
const el = $(id);
el.querySelector('.value').innerHTML = `<span class="dot ${cls}"></span>${value}`;
el.querySelector('.sub').innerHTML = sub + (error ? `<div class="error">${esc(error)}</div>` : '');
}
async function loadStatus() {
const s = await (await fetch('/api/status')).json();
$('device').textContent = s.config.device_name;
$('uptime').textContent = s.uptime_s != null ? 'host up ' + duration(s.uptime_s) : (s.host_error ? 'uptime unavailable' : '');
$('updated').textContent = 'refreshed ' + new Date().toLocaleTimeString(undefined, { hour12: false });
if (s.internet_up == null) card('card-internet', '', 'checking', '', s.internet_error);
else card('card-internet', s.internet_up ? 'ok' : 'bad', s.internet_up ? 'reachable' : 'unreachable',
(s.internet_latency_ms != null ? `best ${s.internet_latency_ms} ms · ` : '') +
Object.entries(s.reach || {}).map(([t, ms]) => `${esc(t)}: ${ms == null ? 'fail' : ms + ' ms'}`).join(' · '), s.internet_error);
if (s.dns_up == null) card('card-dns', '', 'checking', '', s.dns_error);
else card('card-dns', s.dns_up ? 'ok' : 'bad', s.dns_up ? 'resolving' : 'failing',
`${esc(s.config.dns_name)}${s.dns_latency_ms != null ? ' in ' + s.dns_latency_ms + ' ms' : ''}`, s.dns_error);
card('card-ip', s.public_ip ? (s.public_ip_error ? 'warn' : 'ok') : '', s.public_ip ? esc(s.public_ip) : 'unknown',
(s.public_ip_since ? 'since ' + fmtTime(s.public_ip_since) : '') + (s.public_ip_checked_at ? ' · checked ' + ago(s.public_ip_checked_at, s.now) : ''),
s.public_ip_error);
const link = s.wifi_link;
if (s.wifi_status !== 'ok') card('card-wifi', s.wifi_status === 'not_configured' ? 'warn' : 'bad',
s.wifi_status === 'not_configured' ? 'not configured' : 'unavailable', esc(s.wifi_interface || ''), s.wifi_error || s.wifi_link_error);
else if (!link) card('card-wifi', '', 'checking', esc(s.wifi_interface), s.wifi_link_error);
else if (!link.connected) card('card-wifi', 'bad', 'disconnected', esc(s.wifi_interface), s.wifi_link_error);
else card('card-wifi', link.signal_dbm > -67 ? 'ok' : 'warn', esc(link.ssid || '(hidden)'),
`${link.signal_dbm ?? '?'} dBm · ch ${link.channel ?? '?'} (${link.band ?? '?'}) · ${link.tx_bitrate_mbps ?? '?'} Mbit/s`, s.wifi_link_error);
}
async function loadWifi() {
const w = await (await fetch('/api/wifi')).json();
const ifaces = w.wifi_interfaces || [];
$('wifi-interfaces').innerHTML = '<span style="color:var(--muted)">Interfaces visible: </span>' +
(ifaces.length ? ifaces.map(i => `<span class="badge ${i.wireless ? 'wireless' : ''} ${i.name === w.wifi_interface ? 'selected' : ''}">${esc(i.name)}${i.wireless ? ' · wireless' : ''}</span>`).join('') : '<em>none</em>') +
`<span style="color:var(--muted);margin-left:8px">selected: <code>${esc(w.wifi_interface || 'none')}</code></span>`;
$('wifi-notice').innerHTML = w.wifi_error ? `<div class="notice">${esc(w.wifi_error)}</div>` : '';
const link = w.wifi_link;
if (w.wifi_status === 'ok' && link) {
const rows = link.connected ? [
['SSID', link.ssid || '(hidden)'], ['BSSID', link.bssid], ['Channel', `${link.channel ?? '?'} (${link.freq_mhz ?? '?'} MHz, ${link.band ?? '?'})`],
['Signal', `${link.signal_dbm ?? '?'} dBm` + (link.signal_avg_dbm != null ? ` (avg ${link.signal_avg_dbm})` : '')],
['TX / RX bitrate', `${link.tx_bitrate_mbps ?? '?'} / ${link.rx_bitrate_mbps ?? '?'} Mbit/s`],
['TX retries / failed', `${link.tx_retries ?? '?'} / ${link.tx_failed ?? '?'}`], ['Beacon loss', link.beacon_loss ?? '?'],
['Connected for', duration(link.connected_s)],
] : [['Link', 'not connected']];
$('wifi-link').innerHTML = '<dl class="kv">' + rows.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join('') + '</dl>' +
(w.wifi_link_error ? `<div class="error">${esc(w.wifi_link_error)}</div>` : '');
} else {
$('wifi-link').innerHTML = w.wifi_link_error ? `<div class="error">${esc(w.wifi_link_error)}</div>` : '';
}
const scan = w.wifi_scan;
$('scan-error').innerHTML = w.wifi_scan_error ? `<div class="error">${esc(w.wifi_scan_error)}</div>` : '';
if (w.wifi_status !== 'ok') { $('scan-meta').textContent = ''; $('scan-table').innerHTML = '<div class="empty">Scanning is disabled until a wireless interface is selected.</div>'; return; }
if (!scan) { $('scan-meta').textContent = `scanning every ${w.scan_interval}s`; $('scan-table').innerHTML = '<div class="empty">No scan results yet.</div>'; return; }
$('scan-meta').textContent = `${scan.count} networks · scanned ${ago(scan.ts, w.now)} · every ${w.scan_interval}s · not stored`;
$('scan-table').innerHTML = `<table><thead><tr><th>SSID</th><th>BSSID</th><th>Band</th><th class="num">Ch</th><th class="num">Signal</th><th>Security</th></tr></thead><tbody>` +
scan.aps.map(ap => `<tr${ap.associated ? ' style="color:var(--ok)"' : ''}><td>${esc(ap.ssid || '(hidden)')}${ap.associated ? ' ●' : ''}</td><td><code>${esc(ap.bssid)}</code></td><td>${esc(ap.band ?? '?')}</td><td class="num">${ap.channel ?? '?'}</td><td class="num">${ap.signal_dbm ?? '?'} dBm</td><td>${esc(ap.security)}</td></tr>`).join('') +
'</tbody></table>';
}
const MQTT_STATUS = {
connected: ['ok', 'connected'], connecting: ['warn', 'connecting'], failed: ['bad', 'unreachable'],
refused: ['bad', 'refused'], disconnected: ['bad', 'disconnected'], not_started: ['', 'not started'],
};
async function loadMqtt() {
const m = await (await fetch('/api/mqtt')).json();
if (!m.enabled) {
card('card-mqtt', 'warn', 'not configured', 'set MQTT_HOST to publish to Home Assistant');
$('mqtt-notice').innerHTML = '';
$('mqtt-details').innerHTML = '<div class="empty">MQTT is disabled. Set <code>MQTT_HOST</code> to enable Home Assistant publishing.</div>';
$('mqtt-payload-wrap').hidden = true;
$('probe-panel').hidden = true;
return;
}
const [cls, label] = MQTT_STATUS[m.status] || ['', m.status];
const since = m.connected ? m.connected_at : (m.disconnected_at || m.last_attempt_at);
card('card-mqtt', cls, label,
`${esc(m.host)}:${m.port}` + (since ? ` · ${m.connected ? 'since' : 'for'} ${ago(since, m.now).replace(' ago', '')}` : '') +
(m.last_publish_at ? ` · published ${ago(m.last_publish_at, m.now)}` : ''),
m.connected ? m.last_publish_error : m.last_error);
let notice = '';
if (!m.connected) notice = `Not connected to the broker (${esc(m.last_error || m.status)}). Home Assistant shows the device as unavailable because the broker sent the retained "offline" will message and nothing has replaced it.`;
else if (m.publish_dropped && m.last_publish_error) notice = `Connected, but the last publish failed: ${esc(m.last_publish_error)}.`;
else if (!m.availability_at) notice = 'Connected, but availability has not been published yet.';
else if (!m.last_payload) notice = 'Connected and discovery sent, waiting for the first monitor result to publish state.';
$('mqtt-notice').innerHTML = notice ? `<div class="notice">${notice}</div>` : '';
const when = (ts) => ts ? `${fmtTime(ts)} (${ago(ts, m.now)})` : 'never';
const rows = [
['Broker', `${m.host}:${m.port}` + (m.username ? ` as ${m.username}` : ' (no auth)')],
['Client id', m.client_id],
['Status', label + (m.last_error ? ` · ${m.last_error}` : '')],
['Publisher started', when(m.started_at)],
['Connected', when(m.connected_at)],
['Disconnected', when(m.disconnected_at) + (m.disconnects ? ` · ${m.disconnects} total` : '')],
['Connect failures', String(m.connect_failures) + (m.connect_failures ? ` · last attempt ${when(m.last_attempt_at)}` : '')],
['Discovery sent', when(m.discovery_at) + (m.discovery_count ? ` · ${m.discovery_count} times` : '')],
['Availability "online" sent', when(m.availability_at)],
['Home Assistant status', m.ha_status ? `${m.ha_status} at ${when(m.ha_status_at)}` : 'nothing received on ' + m.discovery_prefix + '/status'],
['Messages published', `${m.publish_count} ok · ${m.publish_dropped} dropped` + (m.last_publish_error ? ` · last error: ${m.last_publish_error}` : '')],
['Last publish', when(m.last_publish_at)],
['State topic', m.state_topic],
['Availability topic', m.availability_topic],
['Discovery prefix', m.discovery_prefix],
];
$('mqtt-details').innerHTML = '<dl class="kv">' + rows.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join('') + '</dl>';
$('mqtt-payload-wrap').hidden = !m.last_payload;
$('mqtt-payload').textContent = m.last_payload ? JSON.stringify(m.last_payload, null, 2) : '';
$('probe-panel').hidden = false;
renderProbe(m.probe, m.now, m.probe_running);
}
function renderProbe(p, now, running) {
$('probe-run').disabled = !!running;
$('probe-run').textContent = running ? 'Running…' : 'Run probe now';
if (!p) {
$('probe-meta').textContent = 'not run yet; runs automatically when a connection attempt fails';
$('probe-summary').innerHTML = ''; $('probe-json-wrap').hidden = true; return;
}
$('probe-meta').textContent = `ran ${fmtTime(p.ts)} (${ago(p.ts, now)})` + (p.ms != null ? ` in ${p.ms} ms` : '');
$('probe-summary').innerHTML = p.summary.map(line => {
const cls = /fail|no reply|NXDOMAIN|SERVFAIL|REFUSED|disagree|No address|No nameservers|crashed/.test(line) ? 'bad' : /connected in/.test(line) ? 'ok' : '';
return `<li class="${cls}">${esc(line)}</li>`;
}).join('');
$('probe-json-wrap').hidden = false;
$('probe-json').textContent = JSON.stringify(p, null, 2);
}
$('probe-run').addEventListener('click', async () => {
renderProbe(null, null, true);
try {
const r = await fetch('/api/mqtt/probe', { method: 'POST' });
const p = await r.json();
if (!r.ok) { $('probe-meta').textContent = p.error || 'probe failed'; $('probe-run').disabled = false; $('probe-run').textContent = 'Run probe now'; return; }
renderProbe(p, Date.now() / 1000, false);
} catch (e) { console.error(e); $('probe-run').disabled = false; $('probe-run').textContent = 'Run probe now'; }
});
async function loadEvents() {
const events = await (await fetch('/api/events?limit=300')).json();
$('timeline').innerHTML = events.length ? events.map(e =>
`<li><time>${fmtTime(e.ts)}</time><span><span class="kind ${esc(e.kind.replace(/^(internet|dns)_/, ''))}"></span>${esc(e.message)}</span></li>`).join('')
: '<li class="empty">No events yet.</li>';
}
function drawChart(canvas, legendEl, series, opts = {}) {
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth, h = canvas.clientHeight;
canvas.width = w * dpr; canvas.height = h * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, w, h);
const pad = { l: 44, r: 8, t: 8, b: 22 };
const now = Date.now() / 1000, x0 = now - range;
let ys = series.flatMap(s => s.points.map(p => p[1]).filter(v => v != null));
legendEl.innerHTML = series.map((s, i) => `<span><i style="background:${COLORS[i % COLORS.length]}"></i>${esc(s.name)}</span>`).join('');
if (!ys.length) { ctx.fillStyle = '#8b97a8'; ctx.font = '12px sans-serif'; ctx.fillText('no data in range', pad.l, h / 2); return; }
let ymin = Math.min(...ys), ymax = Math.max(...ys);
if (opts.min != null) ymin = Math.min(ymin, opts.min);
if (opts.zero) ymin = Math.min(ymin, 0);
if (ymax === ymin) ymax = ymin + 1;
const span = ymax - ymin; ymin -= span * 0.05; ymax += span * 0.1;
const X = t => pad.l + (t - x0) / range * (w - pad.l - pad.r);
const Y = v => pad.t + (ymax - v) / (ymax - ymin) * (h - pad.t - pad.b);
ctx.strokeStyle = '#262f3b'; ctx.fillStyle = '#8b97a8'; ctx.font = '11px sans-serif'; ctx.lineWidth = 1;
for (let i = 0; i <= 4; i++) {
const v = ymin + (ymax - ymin) * i / 4, y = Y(v);
ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(w - pad.r, y); ctx.stroke();
ctx.textAlign = 'right'; ctx.fillText(Math.abs(v) >= 100 ? Math.round(v) : v.toFixed(1), pad.l - 6, y + 4);
}
ctx.textAlign = 'center';
for (let i = 0; i <= 4; i++) {
const t = x0 + range * i / 4, d = new Date(t * 1000);
const label = range > 86400 ? d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false });
ctx.fillText(label, X(t), h - 6);
}
series.forEach((s, i) => {
ctx.strokeStyle = COLORS[i % COLORS.length]; ctx.lineWidth = 1.5; ctx.beginPath();
let pen = false;
for (const [t, v] of s.points) {
if (v == null) { pen = false; continue; }
const x = X(t), y = Y(v);
if (pen) ctx.lineTo(x, y); else ctx.moveTo(x, y);
pen = true;
}
ctx.stroke();
});
}
async function loadCharts() {
const fetchSeries = async (prefix) => (await (await fetch(`/api/samples?prefix=${encodeURIComponent(prefix)}&range=${range}`)).json()).series;
const [reach, dns, wifi] = await Promise.all([fetchSeries('reach.'), fetchSeries('dns.'), fetchSeries('wifi.')]);
const toSeries = (obj, strip) => Object.entries(obj).map(([m, points]) => ({ name: m.replace(strip, ''), points }));
drawChart($('ch-reach'), $('lg-reach'), toSeries(reach, 'reach.'), { zero: true });
drawChart($('ch-dns'), $('lg-dns'), toSeries(dns, 'dns.').map(s => ({ ...s, name: 'latency' })), { zero: true });
const pick = (...keys) => keys.filter(k => wifi[k]).map(k => ({ name: k.replace('wifi.', ''), points: wifi[k] }));
drawChart($('ch-signal'), $('lg-signal'), pick('wifi.signal_dbm'), { min: -90 });
drawChart($('ch-bitrate'), $('lg-bitrate'), pick('wifi.tx_bitrate_mbps', 'wifi.rx_bitrate_mbps'), { zero: true });
drawChart($('ch-errors'), $('lg-errors'), pick('wifi.tx_retries', 'wifi.tx_failed', 'wifi.beacon_loss'), { zero: true });
}
$('range').addEventListener('click', (e) => {
const b = e.target.closest('button'); if (!b) return;
range = +b.dataset.range;
document.querySelectorAll('#range button').forEach(x => x.classList.toggle('active', x === b));
loadCharts();
});
window.addEventListener('resize', () => loadCharts());
async function refreshFast() { try { await Promise.all([loadStatus(), loadWifi(), loadMqtt(), loadEvents()]); } catch (e) { console.error(e); } }
refreshFast(); loadCharts();
setInterval(refreshFast, 10000);
setInterval(loadCharts, 60000);
</script>
</body>
</html>