added control page

This commit is contained in:
2026-07-09 19:40:33 +02:00
parent 19fec18cf7
commit 149ef202e3
9 changed files with 263 additions and 11 deletions

View File

@@ -8,7 +8,8 @@ Three Docker containers sharing a named volume (`speed-logger_sqlite-data`) that
- **speedtest** (`measurement/`) — Python + cron inside a Debian slim container. Runs measurements and writes results to SQLite.
- **ping** — Same image, runs `run_ping.py` as a long-lived loop (not cron) with `network_mode: host` so it can reach the LAN gateway. Pings the gateway plus a rotating pool of external DNS-resolver IPs every `PING_INTERVAL` (5s) and writes to `ping_checks`.
- **grafana** — Stock Grafana image with the `frser-sqlite-datasource` plugin. Reads from the shared SQLite volume. Dashboard and datasource are provisioned automatically from `grafana/provisioning/`.
- **control** — Same image, runs `control.py`: a stdlib `http.server` page on port 80 (host port `CONTROL_PORT`) where household members toggle speedtests on/off from a phone. Writes the flag to the `settings` table; talks to humans only, never to other containers.
- **grafana** — Stock Grafana image with the `frser-sqlite-datasource` plugin. Reads from the shared SQLite volume. Dashboard and datasource are provisioned automatically from `grafana/provisioning/`. Anonymous read-only access is enabled (`GF_AUTH_ANONYMOUS_*`); because any viewer can POST arbitrary SQL to Grafana's datasource query API, the volume is mounted `:ro` and the datasource path uses `?mode=ro`. Do not remove either when touching the compose file. This also means the DB must stay in the default rollback journal mode — WAL would require write access to the directory even for readers.
No message queues, no ORM, no external services. Everything is plain Python stdlib + subprocess calls.
@@ -18,8 +19,9 @@ No message queues, no ORM, no external services. Everything is plain Python stdl
|---|---|
| `measurement/run_speedtest.py` | Entry point. Shells out to `speedtest` CLI, parses JSON result, writes to DB. |
| `measurement/run_ping.py` | Long-running loop for the ping container. Detects the default gateway from `/proc/net/route`, pings it plus 3 external IPs per iteration. |
| `measurement/control.py` | Control page for the household speedtest toggle. Seeds the `settings` row from `SPEEDTEST_ENABLED` on first start; after that the DB row is the source of truth. |
| `measurement/db.py` | SQLite init and insert. All schema lives here. |
| `measurement/config.py` | Env vars: `DB_PATH`, `PING_INTERVAL`. |
| `measurement/config.py` | Env vars: `DB_PATH`, `PING_INTERVAL`, `SPEEDTEST_ENABLED` (initial toggle state only; the live state is the `speedtest_enabled` row in `settings`). When disabled, the cron job still fires but exits before running the test, and no failed row is written. |
| `measurement/crontab` | Cron schedule. Currently `4-59/15` (jittered to avoid running exactly on the quarter-hour, which gave flaky Ookla results). |
| `measurement/Dockerfile` | Installs Ookla `speedtest` CLI via packagecloud, sets up cron. |
| `grafana/provisioning/` | Auto-provisioned datasource and dashboard JSON. |
@@ -49,6 +51,8 @@ id, timestamp (unix float), target_ip, success (bool), latency_ms, is_gateway (i
`is_gateway=1` rows are pings to the local router; the gateway-vs-external split is what lets the dashboard distinguish "WiFi/router down" from "ISP down".
`settings` (key-value, currently only `speedtest_enabled` = "0"/"1"): shared state between the control page (writes), the speedtest cron job (reads) and Grafana (displays). This is the only cross-container communication channel besides the measurement tables.
## Dashboard
`grafana/provisioning/dashboards/speed_tests.json` (uid `speed-tests-v2`, title "Network Status"). Layout top to bottom: live UP/DOWN stats (last 60s, independent of the time picker via `strftime('%s','now')`), status-page-style state timelines (Internet/Router plus one row per ping-target provider), ping latency, speed tests, ISP SLA checks, hourly/weekday pattern charts, collapsed raw table.

View File

@@ -1,6 +1,13 @@
# speed-logger
Runs periodic internet speed tests using the Cloudflare speed test API and visualizes the results in Grafana. Tests run every 15 minutes and record download speed, upload speed, latency, and jitter. Results are stored in a SQLite database and displayed on a pre-configured Grafana dashboard.
Self-hosted network monitoring with a Grafana dashboard. Two measurements run side by side:
- **Ping checks** every 5 seconds against the local router and a pool of public DNS resolvers. The router/internet split shows whether an outage is your WiFi's fault or your ISP's.
- **Speedtests** every 15 minutes via the Ookla CLI, recording download, upload, latency, and jitter.
The dashboard shows live UP/DOWN status, status-page style uptime bars, ping latency, speed history, and whether your ISP delivers the speeds from its Produktinformationsblatt (thresholds are editable in the dashboard, defaults are Vodafone GigaZuhause 1000 Kabel).
A control page on port 80 lets anyone in the household turn speedtests off and on from their phone (a speedtest saturates the connection for a few seconds, which can cause lag in games or calls). The toggle takes effect at the next scheduled run, no restart needed.
![Dashboard](res/dashboard.png)
@@ -10,12 +17,22 @@ Runs periodic internet speed tests using the Cloudflare speed test API and visua
## Deployment
Create a `.env` file in the project root with the following variables:
Create a `.env` file in the project root:
```
GRAFANA_PORT=3000
GRAFANA_ADMIN_USER=admin
GRAFANA_ADMIN_PASSWORD=changeme
# Optional: host port of the control page (default 80)
#CONTROL_PORT=80
# Optional: initial speedtest state (default true). Only used until
# someone uses the toggle on the control page, which takes precedence.
#SPEEDTEST_ENABLED=false
# Optional: seconds between ping iterations (default 5)
#PING_INTERVAL=5
```
Start the containers:
@@ -24,5 +41,19 @@ Start the containers:
docker compose up -d
```
Grafana will be available at `http://localhost:3000`. The dashboard is provisioned automatically on first start.
Grafana runs at `http://localhost:3000` (dashboard provisioned automatically), the control page at `http://localhost`.
The dashboard is viewable without login (anonymous read-only access). The admin login is only needed to edit dashboards. Grafana mounts the database read-only, so viewers cannot modify data even through the query API.
The control page opens the dashboard in kiosk mode, which shows only the data without the Grafana UI. Press Escape to get the full interface.
Results are stored in a SQLite database on a named Docker volume, so history survives container rebuilds.
## Easy access without remembering IPs
Give the server a name so household members can use `http://<name>` instead of an IP:
- **mDNS**: install Avahi on the server (`avahi-daemon` plus `nss-mdns`, preinstalled on many distros) and it is reachable as `http://<hostname>.local` from phones and laptops on the LAN.
- **Router DNS**: most home routers let you name devices; a Fritz!Box for example makes the server reachable as `http://<name>.fritz.box`.
Keeping the control page on port 80 means no port number is needed in the URL. The control page links to the Grafana dashboard, so one address is enough to share.

View File

@@ -7,6 +7,7 @@ services:
- sqlite-data:/data
environment:
- DB_PATH=/data/speedtest.db
- SPEEDTEST_ENABLED=${SPEEDTEST_ENABLED:-true}
ping:
build: ./measurement
@@ -17,9 +18,23 @@ services:
- sqlite-data:/data
environment:
- DB_PATH=/data/speedtest.db
- PING_INTERVAL=5
- PING_INTERVAL=${PING_INTERVAL:-5}
command: python3 /app/run_ping.py
control:
build: ./measurement
container_name: control
restart: unless-stopped
ports:
- "${CONTROL_PORT:-80}:80"
volumes:
- sqlite-data:/data
environment:
- DB_PATH=/data/speedtest.db
- SPEEDTEST_ENABLED=${SPEEDTEST_ENABLED:-true}
- GRAFANA_PORT=${GRAFANA_PORT}
command: python3 /app/control.py
grafana:
image: grafana/grafana:latest
container_name: grafana
@@ -30,8 +45,14 @@ services:
- GF_SECURITY_ADMIN_USER=${GRAFANA_ADMIN_USER}
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD}
- GF_INSTALL_PLUGINS=frser-sqlite-datasource
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_NAME=Main Org.
- GF_AUTH_ANONYMOUS_ORG_ROLE=Viewer
- GF_AUTH_ANONYMOUS_HIDE_VERSION=true
volumes:
- sqlite-data:/data
# :ro because any Grafana viewer (incl. anonymous) can send arbitrary SQL
# to the datasource API; the mount and mode=ro keep that harmless.
- sqlite-data:/data:ro
- ./grafana/provisioning:/etc/grafana/provisioning
volumes:

View File

@@ -687,7 +687,7 @@
},
"overrides": []
},
"gridPos": { "h": 8, "w": 6, "x": 12, "y": 38 },
"gridPos": { "h": 8, "w": 4, "x": 12, "y": 38 },
"id": 32,
"options": {
"colorMode": "background",
@@ -732,7 +732,7 @@
},
"overrides": []
},
"gridPos": { "h": 8, "w": 6, "x": 18, "y": 38 },
"gridPos": { "h": 8, "w": 4, "x": 16, "y": 38 },
"id": 33,
"options": {
"colorMode": "background",
@@ -758,6 +758,66 @@
"title": "Failed Speedtests",
"type": "stat"
},
{
"datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" },
"description": "Whether scheduled speedtests are currently enabled. Toggle this on the control page served by the control container.",
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"mappings": [
{
"type": "value",
"options": {
"0": { "color": "orange", "index": 1, "text": "OFF" },
"1": { "color": "green", "index": 0, "text": "ON" }
}
},
{
"type": "special",
"options": {
"match": "null",
"result": { "color": "text", "index": 2, "text": "N/A" }
}
}
],
"noValue": "N/A",
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "orange", "value": 0 },
{ "color": "green", "value": 1 }
]
},
"unit": "none"
},
"overrides": []
},
"gridPos": { "h": 8, "w": 4, "x": 20, "y": 38 },
"id": 34,
"options": {
"colorMode": "background",
"graphMode": "none",
"justifyMode": "center",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": { "calcs": ["last"], "fields": "", "values": false },
"showPercentChange": false,
"textMode": "value",
"wideLayout": true
},
"pluginVersion": "12.3.3",
"targets": [
{
"datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" },
"queryText": "SELECT CASE value WHEN '1' THEN 1 ELSE 0 END AS value FROM settings WHERE key = 'speedtest_enabled'",
"queryType": "table",
"rawQueryText": "SELECT CASE value WHEN '1' THEN 1 ELSE 0 END AS value FROM settings WHERE key = 'speedtest_enabled'",
"refId": "A"
}
],
"title": "Speedtests",
"type": "stat"
},
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 46 },

View File

@@ -7,5 +7,8 @@ datasources:
access: proxy
isDefault: true
jsonData:
path: /data/speedtest.db
# mode=ro because any Grafana viewer (incl. anonymous) can send arbitrary
# SQL to the datasource API; together with the :ro volume mount this
# keeps the database safe from writes through Grafana.
path: /data/speedtest.db?mode=ro
editable: true

View File

@@ -2,4 +2,5 @@ import os
DB_PATH = os.getenv("DB_PATH") or "speedtest.db"
PING_INTERVAL = int(os.getenv("PING_INTERVAL", "5"))
SPEEDTEST_ENABLED = os.getenv("SPEEDTEST_ENABLED", "true").strip().lower() in ("1", "true", "yes")

96
measurement/control.py Normal file
View File

@@ -0,0 +1,96 @@
import os
from http.server import HTTPServer, BaseHTTPRequestHandler
from db import init_settings_db, get_setting, set_setting
from config import SPEEDTEST_ENABLED
GRAFANA_PORT = os.getenv("GRAFANA_PORT", "3000")
PAGE = """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Network Monitor</title>
<style>
body {{ font-family: sans-serif; max-width: 26rem; margin: 4rem auto; padding: 0 1rem; text-align: center; }}
.state {{ font-size: 1.4rem; margin: 1.5rem 0; }}
.on {{ color: #1a7f37; }}
.off {{ color: #b35900; }}
button {{ font-size: 1.3rem; padding: 1rem 2.5rem; border-radius: 0.5rem; border: none; color: white; cursor: pointer; }}
.turn-off {{ background: #b35900; }}
.turn-on {{ background: #1a7f37; }}
.hint {{ color: #666; margin-top: 1.5rem; }}
a {{ color: #3274d9; }}
</style>
</head>
<body>
<h1>Network Monitor</h1>
<p class="state">Speedtests are <strong class="{cls}">{state}</strong></p>
<form method="post" action="/toggle">
<button class="{btn_cls}" type="submit">{btn_label}</button>
</form>
<p class="hint">A speedtest briefly uses the full connection about every 15 minutes.
Turn them off while gaming or in calls, and back on afterwards.</p>
<p><a href="{grafana_url}">Open the dashboard</a></p>
</body>
</html>
"""
def speedtests_on() -> bool:
return get_setting("speedtest_enabled") == "1"
class Handler(BaseHTTPRequestHandler):
def grafana_url(self) -> str:
host = self.headers.get("Host", "localhost")
if host.startswith("["): # IPv6 literal like [::1]:80
host = host.split("]")[0] + "]"
else:
host = host.split(":")[0]
# kiosk mode shows just the dashboard without the Grafana UI around it;
# Escape exits it for anyone who wants the full interface
return f"http://{host}:{GRAFANA_PORT}/d/speed-tests-v2?kiosk"
def do_GET(self):
if self.path != "/":
self.send_error(404)
return
on = speedtests_on()
body = PAGE.format(
cls="on" if on else "off",
state="ON" if on else "OFF",
btn_cls="turn-off" if on else "turn-on",
btn_label="Turn off" if on else "Turn on",
grafana_url=self.grafana_url(),
).encode()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self):
if self.path != "/toggle":
self.send_error(404)
return
set_setting("speedtest_enabled", "0" if speedtests_on() else "1")
self.send_response(303)
self.send_header("Location", "/")
self.end_headers()
def run(port: int = 80):
init_settings_db()
# Seed the toggle from the env var on first start; after that the DB row
# written by the toggle button is the single source of truth.
if get_setting("speedtest_enabled") is None:
set_setting("speedtest_enabled", "1" if SPEEDTEST_ENABLED else "0")
server = HTTPServer(("", port), Handler)
print(f"Control page listening on :{port}", flush=True)
server.serve_forever()
if __name__ == "__main__":
run()

View File

@@ -44,6 +44,27 @@ def init_ping_db(db_path=DB_PATH):
pass # column already exists
conn.close()
def init_settings_db(db_path=DB_PATH):
conn = sqlite3.connect(db_path)
conn.execute("CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
conn.commit()
conn.close()
def get_setting(key: str, db_path=DB_PATH) -> str | None:
conn = sqlite3.connect(db_path)
row = conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
conn.close()
return row[0] if row else None
def set_setting(key: str, value: str, db_path=DB_PATH):
conn = sqlite3.connect(db_path)
conn.execute(
"INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(key, value)
)
conn.commit()
conn.close()
def insert_ping_result(target_ip: str, success: bool, latency_ms: float | None, is_gateway: bool = False, db_path=DB_PATH):
conn = sqlite3.connect(db_path)
c = conn.cursor()

View File

@@ -1,9 +1,24 @@
import subprocess
import json
import sys
import traceback
from db import init_db, insert_result
from db import init_db, insert_result, init_settings_db, get_setting
from config import SPEEDTEST_ENABLED
def speedtests_enabled() -> bool:
# The control page writes the live toggle to the settings table;
# the env var only covers the case where the toggle was never used.
init_settings_db()
value = get_setting("speedtest_enabled")
if value is None:
return SPEEDTEST_ENABLED
return value == "1"
def run_test_and_save():
if not speedtests_enabled():
print("Speedtests are disabled, skipping run")
sys.exit(0)
print("==== Running Ookla Speedtest ====")
init_db()