added ping uptime test
This commit is contained in:
78
CLAUDE.md
Normal file
78
CLAUDE.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# speed-logger
|
||||
|
||||
A self-hosted WiFi quality reporter. Clone and run `docker compose up -d` — no other setup required beyond a `.env` file.
|
||||
|
||||
## Architecture
|
||||
|
||||
Three Docker containers sharing a named volume (`speed-logger_sqlite-data`) that holds a single SQLite database at `/data/speedtest.db`:
|
||||
|
||||
- **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/`.
|
||||
|
||||
No message queues, no ORM, no external services. Everything is plain Python stdlib + subprocess calls.
|
||||
|
||||
## Key files
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `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/db.py` | SQLite init and insert. All schema lives here. |
|
||||
| `measurement/config.py` | Env vars: `DB_PATH`, `PING_INTERVAL`. |
|
||||
| `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. |
|
||||
| `docker-compose.yml` | Wires everything together. Requires `.env` with `GRAFANA_PORT`, `GRAFANA_ADMIN_USER`, `GRAFANA_ADMIN_PASSWORD`. |
|
||||
|
||||
## Database schema
|
||||
|
||||
Two tables in SQLite.
|
||||
|
||||
`speed_tests`:
|
||||
|
||||
```sql
|
||||
id, timestamp (unix float), failed (bool),
|
||||
isp, ip, location_code, location_city, location_region,
|
||||
latency, jitter,
|
||||
down_100kB, down_1MB, down_10MB, down_25MB, down_90th,
|
||||
up_100kB, up_1MB, up_10MB, up_90th
|
||||
```
|
||||
|
||||
Currently only `down_90th` and `up_90th` are populated (Ookla provides bandwidth as a single bandwidth figure, mapped to the 90th-percentile columns). `failed=True` rows have no other fields — they represent a run where the speedtest binary itself failed.
|
||||
|
||||
`ping_checks` (one row per ping, written by the ping container):
|
||||
|
||||
```sql
|
||||
id, timestamp (unix float), target_ip, success (bool), latency_ms, is_gateway (int 0/1)
|
||||
```
|
||||
|
||||
`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".
|
||||
|
||||
## 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.
|
||||
|
||||
Conventions used throughout:
|
||||
|
||||
- A time bucket counts as "up" if ≥1 ping in it succeeded; bucket width adapts to the zoom level via `MAX(60, ($__to - $__from) / 1000 / 500)` in SQL so queries stay fast on long ranges.
|
||||
- **SLA thresholds are dashboard textbox variables** (`sla_down_max/normal/min`, `sla_up_max/normal/min`) so users can enter their own contract values in the UI without editing files. Defaults are Vodafone GigaZuhause 1000 Kabel (1000/850/600 down, 50/35/15 up, per its Produktinformationsblatt). The SLA panels mirror the German TKG/BNetzA deviation criteria: normal speed in ≥90% of tests, 90% of max reached on ≥2/3 of days, minimum never undercut.
|
||||
|
||||
## Design principles
|
||||
|
||||
- **Python stdlib only** inside containers. No pip dependencies, no `uv`, no third-party packages. Use `subprocess`, `socket`, `sqlite3`.
|
||||
- **Simple is correct.** Do not add abstractions, config layers, or retry logic beyond what's needed for the specific failure mode being addressed.
|
||||
- **Cron for scheduling.** No Python scheduler libraries. The crontab file is the schedule.
|
||||
- **One DB, one volume.** Both containers share the same named Docker volume. Do not introduce a second database or a network API between containers.
|
||||
- **Comments only when the WHY is non-obvious.** The crontab offset (`4-59/15` instead of `*/15`) is a good example — it exists to prevent false failures from Ookla's rate limiting at exact quarter-hours and is worth a comment.
|
||||
|
||||
## Development
|
||||
|
||||
There is no test suite. To iterate:
|
||||
|
||||
1. Edit files in `measurement/`.
|
||||
2. `docker compose build speedtest && docker compose up -d speedtest` to redeploy the measurement container.
|
||||
3. Trigger a manual run: `docker exec speedtest python3 /app/run_speedtest.py`
|
||||
4. Inspect DB: `docker exec speedtest python3 -c "import sqlite3; ..."`
|
||||
|
||||
The Grafana dashboard JSON is in `grafana/provisioning/dashboards/speed_tests.json`. Edit it in the Grafana UI (enable "Allow UI updates" is already set in `dashboard.yml`), then export and overwrite the JSON file to persist changes.
|
||||
@@ -8,6 +8,18 @@ services:
|
||||
environment:
|
||||
- DB_PATH=/data/speedtest.db
|
||||
|
||||
ping:
|
||||
build: ./measurement
|
||||
container_name: ping
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
volumes:
|
||||
- sqlite-data:/data
|
||||
environment:
|
||||
- DB_PATH=/data/speedtest.db
|
||||
- PING_INTERVAL=5
|
||||
command: python3 /app/run_ping.py
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:latest
|
||||
container_name: grafana
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
|
||||
# Install cron, curl, and the official Ookla Speedtest repository
|
||||
RUN apt-get update && apt-get install -y cron curl \
|
||||
# Install cron, curl, iputils-ping, and the official Ookla Speedtest repository
|
||||
RUN apt-get update && apt-get install -y cron curl iputils-ping \
|
||||
&& curl -s https://packagecloud.io/install/repositories/ookla/speedtest-cli/script.deb.sh | bash \
|
||||
&& apt-get install -y speedtest \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
|
||||
DB_PATH = os.getenv("DB_PATH") or "speedtest.db"
|
||||
PING_INTERVAL = int(os.getenv("PING_INTERVAL", "5"))
|
||||
|
||||
|
||||
@@ -24,6 +24,36 @@ def init_db(db_path=DB_PATH):
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def init_ping_db(db_path=DB_PATH):
|
||||
conn = sqlite3.connect(db_path)
|
||||
c = conn.cursor()
|
||||
c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS ping_checks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp REAL NOT NULL,
|
||||
target_ip TEXT NOT NULL,
|
||||
success BOOLEAN NOT NULL,
|
||||
latency_ms REAL
|
||||
)
|
||||
''')
|
||||
conn.commit()
|
||||
try:
|
||||
conn.execute("ALTER TABLE ping_checks ADD COLUMN is_gateway INTEGER DEFAULT 0")
|
||||
conn.commit()
|
||||
except sqlite3.OperationalError:
|
||||
pass # column already exists
|
||||
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()
|
||||
c.execute(
|
||||
"INSERT INTO ping_checks (timestamp, target_ip, success, latency_ms, is_gateway) VALUES (?, ?, ?, ?, ?)",
|
||||
(time.time(), target_ip, success, latency_ms, int(is_gateway))
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def insert_result(data: dict|None, db_path=DB_PATH):
|
||||
conn = sqlite3.connect(db_path)
|
||||
c = conn.cursor()
|
||||
|
||||
82
measurement/run_ping.py
Normal file
82
measurement/run_ping.py
Normal file
@@ -0,0 +1,82 @@
|
||||
import subprocess
|
||||
import re
|
||||
import time
|
||||
import itertools
|
||||
from db import init_ping_db, insert_ping_result
|
||||
from config import PING_INTERVAL
|
||||
|
||||
# Curated pool of well-known, stable IPs across DNS resolvers, CDNs, and captive-portal check targets.
|
||||
# Cycling 3 at a time means each IP is hit at most once every ~(len(pool)/3 * PING_INTERVAL) seconds.
|
||||
IP_POOL = [
|
||||
# DNS resolvers
|
||||
"1.1.1.1", # Cloudflare
|
||||
"1.0.0.1", # Cloudflare
|
||||
"8.8.8.8", # Google
|
||||
"8.8.4.4", # Google
|
||||
"9.9.9.9", # Quad9
|
||||
"149.112.112.112", # Quad9
|
||||
"208.67.222.222", # OpenDNS
|
||||
"208.67.220.220", # OpenDNS
|
||||
"64.6.64.6", # Verisign
|
||||
"84.200.69.80", # DNS.WATCH
|
||||
]
|
||||
|
||||
TARGETS_PER_ITERATION = 3
|
||||
|
||||
_LATENCY_RE = re.compile(r"time[=<](\d+(?:\.\d+)?)\s*ms")
|
||||
|
||||
|
||||
def detect_gateway() -> str | None:
|
||||
try:
|
||||
with open("/proc/net/route") as f:
|
||||
for line in f.readlines()[1:]: # skip header row
|
||||
fields = line.strip().split()
|
||||
# Destination == "00000000" means 0.0.0.0 — the default/catch-all route
|
||||
if fields[1] == "00000000":
|
||||
addr = int(fields[2], 16) # little-endian hex
|
||||
return ".".join(str((addr >> (8 * i)) & 0xFF) for i in range(4))
|
||||
except Exception as e:
|
||||
print(f"WARNING: gateway detection failed: {e}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def ping_once(ip: str) -> tuple[bool, float | None]:
|
||||
result = subprocess.run(
|
||||
["ping", "-c", "1", "-W", "2", ip],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False, None
|
||||
m = _LATENCY_RE.search(result.stdout)
|
||||
latency = float(m.group(1)) if m else None
|
||||
return True, latency
|
||||
|
||||
|
||||
def run():
|
||||
init_ping_db()
|
||||
gateway_ip = detect_gateway()
|
||||
if gateway_ip:
|
||||
print(f"Gateway detected: {gateway_ip}", flush=True)
|
||||
else:
|
||||
print("WARNING: No gateway detected, skipping gateway checks", flush=True)
|
||||
|
||||
pool = itertools.cycle(IP_POOL)
|
||||
|
||||
while True:
|
||||
if gateway_ip:
|
||||
success, latency_ms = ping_once(gateway_ip)
|
||||
insert_ping_result(gateway_ip, success, latency_ms, is_gateway=True)
|
||||
status = f"{latency_ms:.1f}ms" if latency_ms is not None else "FAIL"
|
||||
print(f"[GW] {gateway_ip}: {status}", flush=True)
|
||||
|
||||
targets = [next(pool) for _ in range(TARGETS_PER_ITERATION)]
|
||||
for ip in targets:
|
||||
success, latency_ms = ping_once(ip)
|
||||
insert_ping_result(ip, success, latency_ms)
|
||||
status = f"{latency_ms:.1f}ms" if latency_ms is not None else "FAIL"
|
||||
print(f"{ip}: {status}", flush=True)
|
||||
time.sleep(PING_INTERVAL)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
Reference in New Issue
Block a user