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:
Milan PandurovandClaude Fable 5.1 committed 2026-09-17 15:42:09 +02:00
1 parent aace768660
commit 7206e0c4db
13 files changed
+1509 -6

No files matched your search

+106
View File
@@ -0,0 +1,106 @@
import os
import sqlite3
import time
from contextlib import closing
SCHEMA = """
CREATE TABLE IF NOT EXISTS samples (
ts REAL NOT NULL,
metric TEXT NOT NULL,
value REAL
);
CREATE INDEX IF NOT EXISTS samples_metric_ts ON samples(metric, ts);
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts REAL NOT NULL,
kind TEXT NOT NULL,
message TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS events_ts ON events(ts);
CREATE TABLE IF NOT EXISTS kv (
key TEXT PRIMARY KEY,
value TEXT
);
"""
def _like_prefix(prefix):
escaped = prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
return escaped + "%"
class Storage:
def __init__(self, path):
directory = os.path.dirname(path)
if directory:
os.makedirs(directory, exist_ok=True)
self.path = path
with closing(self._connect()) as conn:
conn.executescript(SCHEMA)
def _connect(self):
conn = sqlite3.connect(self.path, timeout=10)
conn.execute("PRAGMA journal_mode=WAL")
return conn
def add_samples(self, pairs, ts=None):
ts = ts or time.time()
with closing(self._connect()) as conn, conn:
conn.executemany(
"INSERT INTO samples (ts, metric, value) VALUES (?, ?, ?)",
[(ts, metric, value) for metric, value in pairs],
)
def add_sample(self, metric, value, ts=None):
self.add_samples([(metric, value)], ts)
def add_event(self, kind, message, ts=None):
with closing(self._connect()) as conn, conn:
conn.execute(
"INSERT INTO events (ts, kind, message) VALUES (?, ?, ?)",
(ts or time.time(), kind, message),
)
def events(self, limit=200):
with closing(self._connect()) as conn:
rows = conn.execute(
"SELECT ts, kind, message FROM events ORDER BY ts DESC, id DESC LIMIT ?",
(limit,),
).fetchall()
return [{"ts": ts, "kind": kind, "message": message} for ts, kind, message in rows]
def samples_by_prefix(self, prefix, since, bucket):
bucket = max(1, int(bucket))
with closing(self._connect()) as conn:
rows = conn.execute(
"""
SELECT metric, CAST(ts / ? AS INTEGER) * ? AS bucket, AVG(value)
FROM samples
WHERE metric LIKE ? ESCAPE '\\' AND ts >= ?
GROUP BY metric, bucket
ORDER BY metric, bucket
""",
(bucket, bucket, _like_prefix(prefix), since),
).fetchall()
series = {}
for metric, ts, value in rows:
series.setdefault(metric, []).append([ts, value])
return series
def get(self, key):
with closing(self._connect()) as conn:
row = conn.execute("SELECT value FROM kv WHERE key = ?", (key,)).fetchone()
return row[0] if row else None
def set(self, key, value):
with closing(self._connect()) as conn, conn:
conn.execute(
"INSERT INTO kv (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(key, value),
)
def prune_samples(self, days):
cutoff = time.time() - days * 86400
with closing(self._connect()) as conn, conn:
conn.execute("DELETE FROM samples WHERE ts < ?", (cutoff,))