Add network monitoring, dashboard, and Home Assistant MQTT publishing
Keep /alive unchanged and serve a dashboard at / with status cards, latency graphs, an event timeline, and a Wi-Fi section. Background monitors on configurable intervals: TCP reachability, DNS resolution, public IP change detection, host reboot detection, Wi-Fi link quality, and Wi-Fi scan of networks in range via iw. The Wi-Fi interface is selected with WIFI_INTERFACE; the page lists visible interfaces and reports when none is available. Scan results stay in memory, other metrics go to SQLite on a mounted volume. Optional MQTT publishing with Home Assistant discovery groups all sensors under one device per host. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TdvJYoY8kc8bmBck89U2i6
This commit is contained in:
1 parent
aace768660
commit
7206e0c4db
13 files changed
+1509
-6
No files matched your search
@@ -0,0 +1,279 @@
|
||||
<!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); }
|
||||
@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>
|
||||
</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>
|
||||
|
||||
</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>';
|
||||
}
|
||||
|
||||
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(), loadEvents()]); } catch (e) { console.error(e); } }
|
||||
refreshFast(); loadCharts();
|
||||
setInterval(refreshFast, 10000);
|
||||
setInterval(loadCharts, 60000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user