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>
393 lines
24 KiB
HTML
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|