From 19fec18cf7da90751534bc19b38083bd2e7ed8a9 Mon Sep 17 00:00:00 2001 From: cato447 Date: Thu, 9 Jul 2026 19:05:59 +0200 Subject: [PATCH] added ping uptime test --- CLAUDE.md | 78 + docker-compose.yml | 12 + .../provisioning/dashboards/speed_tests.json | 2047 ++++++++--------- measurement/Dockerfile | 4 +- measurement/config.py | 1 + measurement/db.py | 30 + measurement/run_ping.py | 82 + 7 files changed, 1138 insertions(+), 1116 deletions(-) create mode 100644 CLAUDE.md create mode 100644 measurement/run_ping.py diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f52a984 --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/docker-compose.yml b/docker-compose.yml index 824a68e..4c1a222 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/grafana/provisioning/dashboards/speed_tests.json b/grafana/provisioning/dashboards/speed_tests.json index c8bae9f..f92a6ce 100644 --- a/grafana/provisioning/dashboards/speed_tests.json +++ b/grafana/provisioning/dashboards/speed_tests.json @@ -23,56 +23,47 @@ "panels": [ { "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 0 - }, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, "id": 100, "panels": [], - "title": "Summary", + "title": "Right Now", "type": "row" }, { - "datasource": { - "type": "frser-sqlite-datasource", - "uid": "speed-tests-sqlite" - }, + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, + "description": "Shows UP if at least one ping to an external target succeeded in the last 60 seconds. If this is down while the router is up, the problem is on the ISP side.", "fieldConfig": { "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 1, - "mappings": [], + "color": { "mode": "thresholds" }, + "mappings": [ + { + "type": "value", + "options": { + "0": { "color": "red", "index": 1, "text": "DOWN" }, + "1": { "color": "green", "index": 0, "text": "UP" } + } + }, + { + "type": "special", + "options": { + "match": "null", + "result": { "color": "text", "index": 2, "text": "NO DATA" } + } + } + ], + "noValue": "NO DATA", "thresholds": { "mode": "absolute", "steps": [ - { - "color": "red", - "value": 0 - }, - { - "color": "yellow", - "value": 80 - }, - { - "color": "green", - "value": 95 - } + { "color": "red", "value": 0 }, + { "color": "green", "value": 1 } ] }, - "unit": "percent" + "unit": "none" }, "overrides": [] }, - "gridPos": { - "h": 4, - "w": 4, - "x": 0, - "y": 1 - }, + "gridPos": { "h": 5, "w": 6, "x": 0, "y": 1 }, "id": 1, "options": { "colorMode": "background", @@ -80,72 +71,59 @@ "justifyMode": "center", "orientation": "auto", "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, + "reduceOptions": { "calcs": ["last"], "fields": "", "values": false }, "showPercentChange": false, - "textMode": "auto", + "textMode": "value", "wideLayout": true }, "pluginVersion": "12.3.3", "targets": [ { - "datasource": { - "type": "frser-sqlite-datasource", - "uid": "speed-tests-sqlite" - }, - "queryText": "SELECT ROUND(100.0 * SUM(CASE WHEN failed=0 THEN 1 ELSE 0 END) / COUNT(*), 1) AS value FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000", + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, + "queryText": "SELECT MAX(success) AS value FROM ping_checks WHERE is_gateway = 0 AND timestamp >= strftime('%s','now') - 60", "queryType": "table", - "rawQueryText": "SELECT ROUND(100.0 * SUM(CASE WHEN failed=0 THEN 1 ELSE 0 END) / COUNT(*), 1) AS value FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000", + "rawQueryText": "SELECT MAX(success) AS value FROM ping_checks WHERE is_gateway = 0 AND timestamp >= strftime('%s','now') - 60", "refId": "A" } ], - "title": "Uptime", + "title": "Internet", "type": "stat" }, { - "datasource": { - "type": "frser-sqlite-datasource", - "uid": "speed-tests-sqlite" - }, + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, + "description": "Shows UP if at least one ping to the local gateway succeeded in the last 60 seconds. If this is down, the problem is in the local network or the router itself.", "fieldConfig": { "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 1, - "mappings": [], + "color": { "mode": "thresholds" }, + "mappings": [ + { + "type": "value", + "options": { + "0": { "color": "red", "index": 1, "text": "DOWN" }, + "1": { "color": "green", "index": 0, "text": "UP" } + } + }, + { + "type": "special", + "options": { + "match": "null", + "result": { "color": "text", "index": 2, "text": "NO DATA" } + } + } + ], + "noValue": "NO DATA", "thresholds": { "mode": "absolute", "steps": [ - { - "color": "red", - "value": 0 - }, - { - "color": "yellow", - "value": 50 - }, - { - "color": "green", - "value": 100 - } + { "color": "red", "value": 0 }, + { "color": "green", "value": 1 } ] }, - "unit": "Mbits" + "unit": "none" }, "overrides": [] }, - "gridPos": { - "h": 4, - "w": 4, - "x": 4, - "y": 1 - }, + "gridPos": { "h": 5, "w": 6, "x": 6, "y": 1 }, "id": 2, "options": { "colorMode": "background", @@ -153,72 +131,46 @@ "justifyMode": "center", "orientation": "auto", "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, + "reduceOptions": { "calcs": ["last"], "fields": "", "values": false }, "showPercentChange": false, - "textMode": "auto", + "textMode": "value", "wideLayout": true }, "pluginVersion": "12.3.3", "targets": [ { - "datasource": { - "type": "frser-sqlite-datasource", - "uid": "speed-tests-sqlite" - }, - "queryText": "SELECT ROUND(AVG(down_90th), 1) AS value FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=0 AND down_90th IS NOT NULL", + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, + "queryText": "SELECT MAX(success) AS value FROM ping_checks WHERE is_gateway = 1 AND timestamp >= strftime('%s','now') - 60", "queryType": "table", - "rawQueryText": "SELECT ROUND(AVG(down_90th), 1) AS value FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=0 AND down_90th IS NOT NULL", + "rawQueryText": "SELECT MAX(success) AS value FROM ping_checks WHERE is_gateway = 1 AND timestamp >= strftime('%s','now') - 60", "refId": "A" } ], - "title": "Avg Download (90th pct)", + "title": "Router", "type": "stat" }, { - "datasource": { - "type": "frser-sqlite-datasource", - "uid": "speed-tests-sqlite" - }, + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, + "description": "Average latency of successful external pings in the last 60 seconds.", "fieldConfig": { "defaults": { - "color": { - "mode": "thresholds" - }, + "color": { "mode": "thresholds" }, "decimals": 1, "mappings": [], + "noValue": "NO DATA", "thresholds": { "mode": "absolute", "steps": [ - { - "color": "red", - "value": 0 - }, - { - "color": "yellow", - "value": 20 - }, - { - "color": "green", - "value": 50 - } + { "color": "green", "value": 0 }, + { "color": "yellow", "value": 50 }, + { "color": "red", "value": 100 } ] }, - "unit": "Mbits" + "unit": "ms" }, "overrides": [] }, - "gridPos": { - "h": 4, - "w": 4, - "x": 8, - "y": 1 - }, + "gridPos": { "h": 5, "w": 4, "x": 12, "y": 1 }, "id": 3, "options": { "colorMode": "background", @@ -226,13 +178,7 @@ "justifyMode": "center", "orientation": "auto", "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, + "reduceOptions": { "calcs": ["last"], "fields": "", "values": false }, "showPercentChange": false, "textMode": "auto", "wideLayout": true @@ -240,58 +186,38 @@ "pluginVersion": "12.3.3", "targets": [ { - "datasource": { - "type": "frser-sqlite-datasource", - "uid": "speed-tests-sqlite" - }, - "queryText": "SELECT ROUND(AVG(up_90th), 1) AS value FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=0 AND up_90th IS NOT NULL", + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, + "queryText": "SELECT ROUND(AVG(latency_ms), 1) AS value FROM ping_checks WHERE is_gateway = 0 AND success = 1 AND timestamp >= strftime('%s','now') - 60", "queryType": "table", - "rawQueryText": "SELECT ROUND(AVG(up_90th), 1) AS value FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=0 AND up_90th IS NOT NULL", + "rawQueryText": "SELECT ROUND(AVG(latency_ms), 1) AS value FROM ping_checks WHERE is_gateway = 0 AND success = 1 AND timestamp >= strftime('%s','now') - 60", "refId": "A" } ], - "title": "Avg Upload (90th pct)", + "title": "Current Latency", "type": "stat" }, { - "datasource": { - "type": "frser-sqlite-datasource", - "uid": "speed-tests-sqlite" - }, + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, + "description": "Percentage of successful external pings over the last 24 hours.", "fieldConfig": { "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 1, + "color": { "mode": "thresholds" }, + "decimals": 2, "mappings": [], + "noValue": "NO DATA", "thresholds": { "mode": "absolute", "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "yellow", - "value": 30 - }, - { - "color": "red", - "value": 80 - } + { "color": "red", "value": 0 }, + { "color": "yellow", "value": 99 }, + { "color": "green", "value": 99.9 } ] }, - "unit": "ms" + "unit": "percent" }, "overrides": [] }, - "gridPos": { - "h": 4, - "w": 4, - "x": 12, - "y": 1 - }, + "gridPos": { "h": 5, "w": 4, "x": 16, "y": 1 }, "id": 4, "options": { "colorMode": "background", @@ -299,13 +225,7 @@ "justifyMode": "center", "orientation": "auto", "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, + "reduceOptions": { "calcs": ["last"], "fields": "", "values": false }, "showPercentChange": false, "textMode": "auto", "wideLayout": true @@ -313,58 +233,38 @@ "pluginVersion": "12.3.3", "targets": [ { - "datasource": { - "type": "frser-sqlite-datasource", - "uid": "speed-tests-sqlite" - }, - "queryText": "SELECT ROUND(AVG(latency), 1) AS value FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=0 AND latency IS NOT NULL", + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, + "queryText": "SELECT ROUND(100.0 * SUM(success) / COUNT(*), 2) AS value FROM ping_checks WHERE is_gateway = 0 AND timestamp >= strftime('%s','now') - 86400", "queryType": "table", - "rawQueryText": "SELECT ROUND(AVG(latency), 1) AS value FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=0 AND latency IS NOT NULL", + "rawQueryText": "SELECT ROUND(100.0 * SUM(success) / COUNT(*), 2) AS value FROM ping_checks WHERE is_gateway = 0 AND timestamp >= strftime('%s','now') - 86400", "refId": "A" } ], - "title": "Avg Latency", + "title": "Uptime (24h)", "type": "stat" }, { - "datasource": { - "type": "frser-sqlite-datasource", - "uid": "speed-tests-sqlite" - }, + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, + "description": "Seconds since the last recorded ping. If this turns red, the ping container has stopped writing data and the other panels can no longer be trusted.", "fieldConfig": { "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 1, + "color": { "mode": "thresholds" }, + "decimals": 0, "mappings": [], + "noValue": "NO DATA", "thresholds": { "mode": "absolute", "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "yellow", - "value": 10 - }, - { - "color": "red", - "value": 30 - } + { "color": "green", "value": 0 }, + { "color": "yellow", "value": 15 }, + { "color": "red", "value": 60 } ] }, - "unit": "ms" + "unit": "s" }, "overrides": [] }, - "gridPos": { - "h": 4, - "w": 4, - "x": 16, - "y": 1 - }, + "gridPos": { "h": 5, "w": 4, "x": 20, "y": 1 }, "id": 5, "options": { "colorMode": "background", @@ -372,13 +272,7 @@ "justifyMode": "center", "orientation": "auto", "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, + "reduceOptions": { "calcs": ["last"], "fields": "", "values": false }, "showPercentChange": false, "textMode": "auto", "wideLayout": true @@ -386,247 +280,150 @@ "pluginVersion": "12.3.3", "targets": [ { - "datasource": { - "type": "frser-sqlite-datasource", - "uid": "speed-tests-sqlite" - }, - "queryText": "SELECT ROUND(AVG(jitter), 1) AS value FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=0 AND jitter IS NOT NULL", + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, + "queryText": "SELECT CAST(strftime('%s','now') - MAX(timestamp) AS INTEGER) AS value FROM ping_checks", "queryType": "table", - "rawQueryText": "SELECT ROUND(AVG(jitter), 1) AS value FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=0 AND jitter IS NOT NULL", + "rawQueryText": "SELECT CAST(strftime('%s','now') - MAX(timestamp) AS INTEGER) AS value FROM ping_checks", "refId": "A" } ], - "title": "Avg Jitter", - "type": "stat" - }, - { - "datasource": { - "type": "frser-sqlite-datasource", - "uid": "speed-tests-sqlite" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 1, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "yellow", - "value": 3 - }, - { - "color": "red", - "value": 10 - } - ] - }, - "unit": "none" - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 4, - "x": 20, - "y": 1 - }, - "id": 6, - "options": { - "colorMode": "background", - "graphMode": "none", - "justifyMode": "center", - "orientation": "auto", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "auto", - "wideLayout": true - }, - "pluginVersion": "12.3.3", - "targets": [ - { - "datasource": { - "type": "frser-sqlite-datasource", - "uid": "speed-tests-sqlite" - }, - "queryText": "SELECT COUNT(*) AS value FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=1", - "queryType": "table", - "rawQueryText": "SELECT COUNT(*) AS value FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=1", - "refId": "A" - } - ], - "title": "Failed Tests", + "title": "Last Check", "type": "stat" }, { "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 5 - }, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 6 }, "id": 101, "panels": [], - "title": "Speed Over Time", + "title": "Status History", "type": "row" }, { - "datasource": { - "type": "frser-sqlite-datasource", - "uid": "speed-tests-sqlite" - }, + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, + "description": "A time bucket counts as up if at least one ping in it succeeded. A single dropped packet therefore does not show as an outage.", "fieldConfig": { "defaults": { - "color": { - "mode": "palette-classic" - }, + "color": { "mode": "thresholds" }, "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 10, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, + "fillOpacity": 70, + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 2, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "showValues": false, - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } + "lineWidth": 0, + "spanNulls": false }, - "mappings": [], + "mappings": [ + { + "type": "value", + "options": { + "0": { "color": "red", "index": 1, "text": "Down" }, + "1": { "color": "green", "index": 0, "text": "Up" } + } + } + ], "thresholds": { "mode": "absolute", "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "Mbits" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Download 90th" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#3274D9", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Upload 90th" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#56A64B", - "mode": "fixed" - } - } + { "color": "red", "value": 0 }, + { "color": "green", "value": 1 } ] } - ] - }, - "gridPos": { - "h": 10, - "w": 24, - "x": 0, - "y": 6 + }, + "overrides": [] }, + "gridPos": { "h": 5, "w": 24, "x": 0, "y": 7 }, "id": 10, "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "mode": "multi", - "sort": "none" - } + "alignValue": "left", + "legend": { "displayMode": "list", "placement": "bottom", "showLegend": true }, + "mergeValues": true, + "rowHeight": 0.85, + "showValue": "never", + "tooltip": { "hideZeros": false, "mode": "single", "sort": "none" } }, "pluginVersion": "12.3.3", "targets": [ { - "datasource": { - "type": "frser-sqlite-datasource", - "uid": "speed-tests-sqlite" - }, - "queryText": "SELECT timestamp AS time, down_90th AS 'Download 90th', up_90th AS 'Upload 90th' FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=0 ORDER BY timestamp", + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, + "queryText": "SELECT CAST(timestamp / MAX(60, ($__to - $__from) / 1000 / 500) AS INTEGER) * MAX(60, ($__to - $__from) / 1000 / 500) AS time, MAX(CASE WHEN is_gateway = 0 THEN success END) AS 'Internet', MAX(CASE WHEN is_gateway = 1 THEN success END) AS 'Router' FROM ping_checks WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 GROUP BY 1 ORDER BY 1", "queryType": "table", - "rawQueryText": "SELECT timestamp AS time, down_90th AS 'Download 90th', up_90th AS 'Upload 90th' FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=0 ORDER BY timestamp", + "rawQueryText": "SELECT CAST(timestamp / MAX(60, ($__to - $__from) / 1000 / 500) AS INTEGER) * MAX(60, ($__to - $__from) / 1000 / 500) AS time, MAX(CASE WHEN is_gateway = 0 THEN success END) AS 'Internet', MAX(CASE WHEN is_gateway = 1 THEN success END) AS 'Router' FROM ping_checks WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 GROUP BY 1 ORDER BY 1", "refId": "A", - "timeColumns": [ - "time" - ] + "timeColumns": ["time"] } ], - "title": "Download & Upload Speed (90th Percentile)", - "type": "timeseries" + "title": "Connectivity", + "type": "state-timeline" }, { - "datasource": { - "type": "frser-sqlite-datasource", - "uid": "speed-tests-sqlite" - }, + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, + "description": "One row per ping target provider. If all rows are red the internet connection is down. If only one row is red, only that provider is unreachable.", "fieldConfig": { "defaults": { - "color": { - "mode": "palette-classic" + "color": { "mode": "thresholds" }, + "custom": { + "fillOpacity": 70, + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineWidth": 0, + "spanNulls": false }, + "mappings": [ + { + "type": "value", + "options": { + "0": { "color": "red", "index": 1, "text": "Down" }, + "1": { "color": "green", "index": 0, "text": "Up" } + } + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": 0 }, + { "color": "green", "value": 1 } + ] + } + }, + "overrides": [] + }, + "gridPos": { "h": 7, "w": 24, "x": 0, "y": 12 }, + "id": 11, + "options": { + "alignValue": "left", + "legend": { "displayMode": "list", "placement": "bottom", "showLegend": true }, + "mergeValues": true, + "rowHeight": 0.85, + "showValue": "never", + "tooltip": { "hideZeros": false, "mode": "single", "sort": "none" } + }, + "pluginVersion": "12.3.3", + "targets": [ + { + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, + "queryText": "SELECT CAST(timestamp / MAX(60, ($__to - $__from) / 1000 / 500) AS INTEGER) * MAX(60, ($__to - $__from) / 1000 / 500) AS time, MAX(CASE WHEN target_ip IN ('1.1.1.1','1.0.0.1') THEN success END) AS 'Cloudflare', MAX(CASE WHEN target_ip IN ('8.8.8.8','8.8.4.4') THEN success END) AS 'Google', MAX(CASE WHEN target_ip IN ('9.9.9.9','149.112.112.112') THEN success END) AS 'Quad9', MAX(CASE WHEN target_ip IN ('208.67.222.222','208.67.220.220') THEN success END) AS 'OpenDNS', MAX(CASE WHEN target_ip = '64.6.64.6' THEN success END) AS 'Verisign', MAX(CASE WHEN target_ip = '84.200.69.80' THEN success END) AS 'DNS.WATCH' FROM ping_checks WHERE is_gateway = 0 AND timestamp BETWEEN $__from/1000 AND $__to/1000 GROUP BY 1 ORDER BY 1", + "queryType": "table", + "rawQueryText": "SELECT CAST(timestamp / MAX(60, ($__to - $__from) / 1000 / 500) AS INTEGER) * MAX(60, ($__to - $__from) / 1000 / 500) AS time, MAX(CASE WHEN target_ip IN ('1.1.1.1','1.0.0.1') THEN success END) AS 'Cloudflare', MAX(CASE WHEN target_ip IN ('8.8.8.8','8.8.4.4') THEN success END) AS 'Google', MAX(CASE WHEN target_ip IN ('9.9.9.9','149.112.112.112') THEN success END) AS 'Quad9', MAX(CASE WHEN target_ip IN ('208.67.222.222','208.67.220.220') THEN success END) AS 'OpenDNS', MAX(CASE WHEN target_ip = '64.6.64.6' THEN success END) AS 'Verisign', MAX(CASE WHEN target_ip = '84.200.69.80' THEN success END) AS 'DNS.WATCH' FROM ping_checks WHERE is_gateway = 0 AND timestamp BETWEEN $__from/1000 AND $__to/1000 GROUP BY 1 ORDER BY 1", + "refId": "A", + "timeColumns": ["time"] + } + ], + "title": "Ping Targets", + "type": "state-timeline" + }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 19 }, + "id": 102, + "panels": [], + "title": "Ping Latency", + "type": "row" + }, + { + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, + "description": "Average ping round trip time per time bucket. Rising latency can indicate connection problems before pings start to fail.", + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, @@ -638,127 +435,76 @@ "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 2, "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, + "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "showValues": false, "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } + { "color": "green", "value": 0 }, + { "color": "red", "value": 80 } ] }, "unit": "ms" }, "overrides": [ { - "matcher": { - "id": "byName", - "options": "Latency" - }, + "matcher": { "id": "byName", "options": "Internet" }, "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#FF9830", - "mode": "fixed" - } - } + { "id": "color", "value": { "fixedColor": "#3274D9", "mode": "fixed" } } ] }, { - "matcher": { - "id": "byName", - "options": "Jitter" - }, + "matcher": { "id": "byName", "options": "Router" }, "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E02F44", - "mode": "fixed" - } - } + { "id": "color", "value": { "fixedColor": "#56A64B", "mode": "fixed" } } ] } ] }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 16 - }, - "id": 11, + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 20 }, + "id": 20, "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "mode": "multi", - "sort": "none" - } + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "hideZeros": false, "mode": "multi", "sort": "none" } }, "pluginVersion": "12.3.3", "targets": [ { - "datasource": { - "type": "frser-sqlite-datasource", - "uid": "speed-tests-sqlite" - }, - "queryText": "SELECT timestamp AS time, latency AS 'Latency', jitter AS 'Jitter' FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=0 ORDER BY timestamp", + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, + "queryText": "SELECT CAST(timestamp / MAX(60, ($__to - $__from) / 1000 / 500) AS INTEGER) * MAX(60, ($__to - $__from) / 1000 / 500) AS time, ROUND(AVG(CASE WHEN is_gateway = 0 AND success = 1 THEN latency_ms END), 2) AS 'Internet', ROUND(AVG(CASE WHEN is_gateway = 1 AND success = 1 THEN latency_ms END), 2) AS 'Router' FROM ping_checks WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 GROUP BY 1 ORDER BY 1", "queryType": "table", - "rawQueryText": "SELECT timestamp AS time, latency AS 'Latency', jitter AS 'Jitter' FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=0 ORDER BY timestamp", + "rawQueryText": "SELECT CAST(timestamp / MAX(60, ($__to - $__from) / 1000 / 500) AS INTEGER) * MAX(60, ($__to - $__from) / 1000 / 500) AS time, ROUND(AVG(CASE WHEN is_gateway = 0 AND success = 1 THEN latency_ms END), 2) AS 'Internet', ROUND(AVG(CASE WHEN is_gateway = 1 AND success = 1 THEN latency_ms END), 2) AS 'Router' FROM ping_checks WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 GROUP BY 1 ORDER BY 1", "refId": "A", - "timeColumns": [ - "time" - ] + "timeColumns": ["time"] } ], - "title": "Latency & Jitter Over Time", + "title": "Ping Latency Over Time", "type": "timeseries" }, { - "datasource": { - "type": "frser-sqlite-datasource", - "uid": "speed-tests-sqlite" - }, + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 28 }, + "id": 103, + "panels": [], + "title": "Speed Tests", + "type": "row" + }, + { + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, "fieldConfig": { "defaults": { - "color": { - "mode": "palette-classic" - }, + "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, @@ -770,109 +516,551 @@ "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 2, "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, + "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "showValues": false, "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } + { "color": "green", "value": 0 }, + { "color": "red", "value": 80 } ] }, "unit": "Mbits" }, - "overrides": [] + "overrides": [ + { + "matcher": { "id": "byName", "options": "Download" }, + "properties": [ + { "id": "color", "value": { "fixedColor": "#3274D9", "mode": "fixed" } } + ] + }, + { + "matcher": { "id": "byName", "options": "Upload" }, + "properties": [ + { "id": "color", "value": { "fixedColor": "#56A64B", "mode": "fixed" } } + ] + }, + { + "matcher": { "id": "byName", "options": "Promised Download" }, + "properties": [ + { "id": "color", "value": { "fixedColor": "#3274D9", "mode": "fixed" } }, + { "id": "custom.lineStyle", "value": { "dash": [10, 10], "fill": "dash" } }, + { "id": "custom.lineWidth", "value": 1 }, + { "id": "custom.fillOpacity", "value": 0 }, + { "id": "custom.showPoints", "value": "never" } + ] + }, + { + "matcher": { "id": "byName", "options": "Promised Upload" }, + "properties": [ + { "id": "color", "value": { "fixedColor": "#56A64B", "mode": "fixed" } }, + { "id": "custom.lineStyle", "value": { "dash": [10, 10], "fill": "dash" } }, + { "id": "custom.lineWidth", "value": 1 }, + { "id": "custom.fillOpacity", "value": 0 }, + { "id": "custom.showPoints", "value": "never" } + ] + } + ] }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 16 - }, - "id": 12, + "gridPos": { "h": 9, "w": 24, "x": 0, "y": 29 }, + "id": 30, "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "mode": "multi", - "sort": "none" - } + "legend": { "calcs": ["mean", "max", "lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "hideZeros": false, "mode": "multi", "sort": "none" } }, "pluginVersion": "12.3.3", "targets": [ { - "datasource": { - "type": "frser-sqlite-datasource", - "uid": "speed-tests-sqlite" - }, - "queryText": "SELECT timestamp AS time, down_100kB AS '100kB', down_1MB AS '1MB', down_10MB AS '10MB', down_25MB AS '25MB', down_90th AS '90th pct' FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=0 ORDER BY timestamp", + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, + "queryText": "SELECT timestamp AS time, down_90th AS 'Download', up_90th AS 'Upload', ${sla_down_normal} AS 'Promised Download', ${sla_up_normal} AS 'Promised Upload' FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=0 ORDER BY timestamp", "queryType": "table", - "rawQueryText": "SELECT timestamp AS time, down_100kB AS '100kB', down_1MB AS '1MB', down_10MB AS '10MB', down_25MB AS '25MB', down_90th AS '90th pct' FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=0 ORDER BY timestamp", + "rawQueryText": "SELECT timestamp AS time, down_90th AS 'Download', up_90th AS 'Upload', ${sla_down_normal} AS 'Promised Download', ${sla_up_normal} AS 'Promised Upload' FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=0 ORDER BY timestamp", "refId": "A", - "timeColumns": [ - "time" - ] + "timeColumns": ["time"] } ], - "title": "Download Speed Breakdown", + "title": "Download & Upload Speed (90th Percentile)", "type": "timeseries" }, { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 24 + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": 0 }, + { "color": "red", "value": 80 } + ] + }, + "unit": "ms" + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "Latency" }, + "properties": [ + { "id": "color", "value": { "fixedColor": "#FF9830", "mode": "fixed" } } + ] + }, + { + "matcher": { "id": "byName", "options": "Jitter" }, + "properties": [ + { "id": "color", "value": { "fixedColor": "#E02F44", "mode": "fixed" } } + ] + } + ] }, - "id": 102, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 38 }, + "id": 31, + "options": { + "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true }, + "tooltip": { "hideZeros": false, "mode": "multi", "sort": "none" } + }, + "pluginVersion": "12.3.3", + "targets": [ + { + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, + "queryText": "SELECT timestamp AS time, latency AS 'Latency', jitter AS 'Jitter' FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=0 ORDER BY timestamp", + "queryType": "table", + "rawQueryText": "SELECT timestamp AS time, latency AS 'Latency', jitter AS 'Jitter' FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=0 ORDER BY timestamp", + "refId": "A", + "timeColumns": ["time"] + } + ], + "title": "Speedtest Latency & Jitter", + "type": "timeseries" + }, + { + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, + "description": "Percentage of speedtest runs that completed in the selected time range.", + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "decimals": 1, + "mappings": [], + "noValue": "NO DATA", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": 0 }, + { "color": "yellow", "value": 90 }, + { "color": "green", "value": 98 } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 6, "x": 12, "y": 38 }, + "id": 32, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { "calcs": ["last"], "fields": "", "values": false }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.3.3", + "targets": [ + { + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, + "queryText": "SELECT ROUND(100.0 * SUM(CASE WHEN failed=0 THEN 1 ELSE 0 END) / COUNT(*), 1) AS value FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000", + "queryType": "table", + "rawQueryText": "SELECT ROUND(100.0 * SUM(CASE WHEN failed=0 THEN 1 ELSE 0 END) / COUNT(*), 1) AS value FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000", + "refId": "A" + } + ], + "title": "Speedtest Success", + "type": "stat" + }, + { + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "decimals": 0, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": 0 }, + { "color": "yellow", "value": 3 }, + { "color": "red", "value": 10 } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 6, "x": 18, "y": 38 }, + "id": 33, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { "calcs": ["last"], "fields": "", "values": false }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.3.3", + "targets": [ + { + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, + "queryText": "SELECT COUNT(*) AS value FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=1", + "queryType": "table", + "rawQueryText": "SELECT COUNT(*) AS value FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=1", + "refId": "A" + } + ], + "title": "Failed Speedtests", + "type": "stat" + }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 46 }, + "id": 106, "panels": [], - "title": "Time-of-Day & Weekday Patterns", + "title": "ISP SLA", "type": "row" }, { - "datasource": { - "type": "frser-sqlite-datasource", - "uid": "speed-tests-sqlite" - }, + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, + "description": "Share of speedtests that reached the contractual 'normally available' download speed. German TKG rules require this in at least 90% of measurements.", "fieldConfig": { "defaults": { - "color": { - "mode": "palette-classic" + "color": { "mode": "thresholds" }, + "decimals": 1, + "mappings": [], + "noValue": "NO DATA", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": 0 }, + { "color": "yellow", "value": 80 }, + { "color": "green", "value": 90 } + ] }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { "h": 5, "w": 4, "x": 0, "y": 47 }, + "id": 60, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { "calcs": ["last"], "fields": "", "values": false }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.3.3", + "targets": [ + { + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, + "queryText": "SELECT ROUND(100.0 * SUM(CASE WHEN down_90th >= ${sla_down_normal} THEN 1 ELSE 0 END) / COUNT(*), 1) AS value FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=0 AND down_90th IS NOT NULL", + "queryType": "table", + "rawQueryText": "SELECT ROUND(100.0 * SUM(CASE WHEN down_90th >= ${sla_down_normal} THEN 1 ELSE 0 END) / COUNT(*), 1) AS value FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=0 AND down_90th IS NOT NULL", + "refId": "A" + } + ], + "title": "Down ≥ ${sla_down_normal} Mbit/s", + "type": "stat" + }, + { + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, + "description": "Share of speedtests that reached the contractual 'normally available' upload speed. German TKG rules require this in at least 90% of measurements.", + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "decimals": 1, + "mappings": [], + "noValue": "NO DATA", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": 0 }, + { "color": "yellow", "value": 80 }, + { "color": "green", "value": 90 } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { "h": 5, "w": 4, "x": 4, "y": 47 }, + "id": 61, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { "calcs": ["last"], "fields": "", "values": false }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.3.3", + "targets": [ + { + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, + "queryText": "SELECT ROUND(100.0 * SUM(CASE WHEN up_90th >= ${sla_up_normal} THEN 1 ELSE 0 END) / COUNT(*), 1) AS value FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=0 AND up_90th IS NOT NULL", + "queryType": "table", + "rawQueryText": "SELECT ROUND(100.0 * SUM(CASE WHEN up_90th >= ${sla_up_normal} THEN 1 ELSE 0 END) / COUNT(*), 1) AS value FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=0 AND up_90th IS NOT NULL", + "refId": "A" + } + ], + "title": "Up ≥ ${sla_up_normal} Mbit/s", + "type": "stat" + }, + { + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, + "description": "Share of days where at least one speedtest reached 90% of the contractual maximum download speed. The BNetzA methodology requires this on 2 of 3 measurement days.", + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "decimals": 1, + "mappings": [], + "noValue": "NO DATA", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": 0 }, + { "color": "yellow", "value": 34 }, + { "color": "green", "value": 67 } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { "h": 5, "w": 4, "x": 8, "y": 47 }, + "id": 62, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { "calcs": ["last"], "fields": "", "values": false }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.3.3", + "targets": [ + { + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, + "queryText": "SELECT ROUND(100.0 * SUM(hit) / COUNT(*), 1) AS value FROM (SELECT MAX(CASE WHEN down_90th >= 0.9 * ${sla_down_max} THEN 1 ELSE 0 END) AS hit FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=0 AND down_90th IS NOT NULL GROUP BY strftime('%Y-%m-%d', datetime(timestamp,'unixepoch','localtime')))", + "queryType": "table", + "rawQueryText": "SELECT ROUND(100.0 * SUM(hit) / COUNT(*), 1) AS value FROM (SELECT MAX(CASE WHEN down_90th >= 0.9 * ${sla_down_max} THEN 1 ELSE 0 END) AS hit FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=0 AND down_90th IS NOT NULL GROUP BY strftime('%Y-%m-%d', datetime(timestamp,'unixepoch','localtime')))", + "refId": "A" + } + ], + "title": "Days Hitting 90% of Down Max", + "type": "stat" + }, + { + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, + "description": "Share of days where at least one speedtest reached 90% of the contractual maximum upload speed.", + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "decimals": 1, + "mappings": [], + "noValue": "NO DATA", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": 0 }, + { "color": "yellow", "value": 34 }, + { "color": "green", "value": 67 } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { "h": 5, "w": 4, "x": 12, "y": 47 }, + "id": 63, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { "calcs": ["last"], "fields": "", "values": false }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.3.3", + "targets": [ + { + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, + "queryText": "SELECT ROUND(100.0 * SUM(hit) / COUNT(*), 1) AS value FROM (SELECT MAX(CASE WHEN up_90th >= 0.9 * ${sla_up_max} THEN 1 ELSE 0 END) AS hit FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=0 AND up_90th IS NOT NULL GROUP BY strftime('%Y-%m-%d', datetime(timestamp,'unixepoch','localtime')))", + "queryType": "table", + "rawQueryText": "SELECT ROUND(100.0 * SUM(hit) / COUNT(*), 1) AS value FROM (SELECT MAX(CASE WHEN up_90th >= 0.9 * ${sla_up_max} THEN 1 ELSE 0 END) AS hit FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=0 AND up_90th IS NOT NULL GROUP BY strftime('%Y-%m-%d', datetime(timestamp,'unixepoch','localtime')))", + "refId": "A" + } + ], + "title": "Days Hitting 90% of Up Max", + "type": "stat" + }, + { + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, + "description": "Number of speedtests below the contractual minimum download speed.", + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "decimals": 0, + "mappings": [], + "noValue": "0", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": 0 }, + { "color": "yellow", "value": 1 }, + { "color": "red", "value": 3 } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { "h": 5, "w": 4, "x": 16, "y": 47 }, + "id": 64, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { "calcs": ["last"], "fields": "", "values": false }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.3.3", + "targets": [ + { + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, + "queryText": "SELECT COUNT(*) AS value FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=0 AND down_90th IS NOT NULL AND down_90th < ${sla_down_min}", + "queryType": "table", + "rawQueryText": "SELECT COUNT(*) AS value FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=0 AND down_90th IS NOT NULL AND down_90th < ${sla_down_min}", + "refId": "A" + } + ], + "title": "Tests Below Down Min (${sla_down_min})", + "type": "stat" + }, + { + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, + "description": "Number of speedtests below the contractual minimum upload speed.", + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "decimals": 0, + "mappings": [], + "noValue": "0", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": 0 }, + { "color": "yellow", "value": 1 }, + { "color": "red", "value": 3 } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { "h": 5, "w": 4, "x": 20, "y": 47 }, + "id": 65, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { "calcs": ["last"], "fields": "", "values": false }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.3.3", + "targets": [ + { + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, + "queryText": "SELECT COUNT(*) AS value FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=0 AND up_90th IS NOT NULL AND up_90th < ${sla_up_min}", + "queryType": "table", + "rawQueryText": "SELECT COUNT(*) AS value FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=0 AND up_90th IS NOT NULL AND up_90th < ${sla_up_min}", + "refId": "A" + } + ], + "title": "Tests Below Up Min (${sla_up_min})", + "type": "stat" + }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 52 }, + "id": 104, + "panels": [], + "title": "Patterns", + "type": "row" + }, + { + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, @@ -881,63 +1069,35 @@ "axisPlacement": "auto", "fillOpacity": 60, "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "lineWidth": 1, - "scaleDistribution": { - "type": "linear" - }, - "thresholdsStyle": { - "mode": "off" - } + "scaleDistribution": { "type": "linear" }, + "thresholdsStyle": { "mode": "off" } }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } + { "color": "green", "value": 0 }, + { "color": "red", "value": 80 } ] }, "unit": "Mbits" }, "overrides": [] }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 25 - }, - "id": 20, + "gridPos": { "h": 9, "w": 12, "x": 0, "y": 53 }, + "id": 40, "options": { "barRadius": 0, "barWidth": 0.7, "fullHighlight": false, "groupWidth": 0.7, - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, + "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true }, "orientation": "auto", "showValue": "auto", "stacking": "none", - "tooltip": { - "hideZeros": false, - "mode": "multi", - "sort": "none" - }, + "tooltip": { "hideZeros": false, "mode": "multi", "sort": "none" }, "xField": "Hour", "xTickLabelRotation": -45, "xTickLabelSpacing": 0 @@ -945,33 +1105,21 @@ "pluginVersion": "12.3.3", "targets": [ { - "datasource": { - "type": "frser-sqlite-datasource", - "uid": "speed-tests-sqlite" - }, - "queryText": "SELECT strftime('%H:00', datetime(timestamp,'unixepoch','localtime')) AS 'Hour', AVG(down_90th) AS 'Download 90th', AVG(up_90th) AS 'Upload 90th' FROM speed_tests WHERE timestamp BETWEEN 1767177837097/1000 AND 1769083876509/1000 AND failed=0 AND down_90th IS NOT NULL AND strftime('%w', datetime(timestamp,'unixepoch','localtime')) NOT IN ('0','6') GROUP BY 1 ORDER BY 1", + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, + "queryText": "SELECT strftime('%H:00', datetime(timestamp,'unixepoch','localtime')) AS 'Hour', AVG(down_90th) AS 'Download 90th', AVG(up_90th) AS 'Upload 90th' FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=0 AND down_90th IS NOT NULL AND strftime('%w', datetime(timestamp,'unixepoch','localtime')) NOT IN ('0','6') GROUP BY 1 ORDER BY 1", "queryType": "table", "rawQueryText": "SELECT strftime('%H:00', datetime(timestamp,'unixepoch','localtime')) AS 'Hour', AVG(down_90th) AS 'Download 90th', AVG(up_90th) AS 'Upload 90th' FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=0 AND down_90th IS NOT NULL AND strftime('%w', datetime(timestamp,'unixepoch','localtime')) NOT IN ('0','6') GROUP BY 1 ORDER BY 1", - "refId": "A", - "timeColumns": [ - "time", - "ts" - ] + "refId": "A" } ], "title": "Avg Speed by Hour (Weekdays)", "type": "barchart" }, { - "datasource": { - "type": "frser-sqlite-datasource", - "uid": "speed-tests-sqlite" - }, + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, "fieldConfig": { "defaults": { - "color": { - "mode": "palette-classic" - }, + "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, @@ -980,63 +1128,35 @@ "axisPlacement": "auto", "fillOpacity": 60, "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "lineWidth": 1, - "scaleDistribution": { - "type": "linear" - }, - "thresholdsStyle": { - "mode": "off" - } + "scaleDistribution": { "type": "linear" }, + "thresholdsStyle": { "mode": "off" } }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } + { "color": "green", "value": 0 }, + { "color": "red", "value": 80 } ] }, "unit": "Mbits" }, "overrides": [] }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 25 - }, - "id": 21, + "gridPos": { "h": 9, "w": 12, "x": 12, "y": 53 }, + "id": 41, "options": { "barRadius": 0, "barWidth": 0.7, "fullHighlight": false, "groupWidth": 0.7, - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, + "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true }, "orientation": "auto", "showValue": "auto", "stacking": "none", - "tooltip": { - "hideZeros": false, - "mode": "multi", - "sort": "none" - }, + "tooltip": { "hideZeros": false, "mode": "multi", "sort": "none" }, "xField": "Hour", "xTickLabelRotation": -45, "xTickLabelSpacing": 0 @@ -1044,33 +1164,21 @@ "pluginVersion": "12.3.3", "targets": [ { - "datasource": { - "type": "frser-sqlite-datasource", - "uid": "speed-tests-sqlite" - }, - "queryText": "SELECT strftime('%H:00', datetime(timestamp,'unixepoch','localtime')) AS 'Hour', AVG(down_90th) AS 'Download 90th', AVG(up_90th) AS 'Upload 90th' FROM speed_tests WHERE timestamp BETWEEN 1767177837097/1000 AND 1769083876509/1000 AND failed=0 AND down_90th IS NOT NULL AND strftime('%w', datetime(timestamp,'unixepoch','localtime')) IN ('0','6') GROUP BY 1 ORDER BY 1", + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, + "queryText": "SELECT strftime('%H:00', datetime(timestamp,'unixepoch','localtime')) AS 'Hour', AVG(down_90th) AS 'Download 90th', AVG(up_90th) AS 'Upload 90th' FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=0 AND down_90th IS NOT NULL AND strftime('%w', datetime(timestamp,'unixepoch','localtime')) IN ('0','6') GROUP BY 1 ORDER BY 1", "queryType": "table", "rawQueryText": "SELECT strftime('%H:00', datetime(timestamp,'unixepoch','localtime')) AS 'Hour', AVG(down_90th) AS 'Download 90th', AVG(up_90th) AS 'Upload 90th' FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=0 AND down_90th IS NOT NULL AND strftime('%w', datetime(timestamp,'unixepoch','localtime')) IN ('0','6') GROUP BY 1 ORDER BY 1", - "refId": "A", - "timeColumns": [ - "time", - "ts" - ] + "refId": "A" } ], "title": "Avg Speed by Hour (Weekends)", "type": "barchart" }, { - "datasource": { - "type": "frser-sqlite-datasource", - "uid": "speed-tests-sqlite" - }, + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, "fieldConfig": { "defaults": { - "color": { - "mode": "palette-classic" - }, + "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, @@ -1079,63 +1187,35 @@ "axisPlacement": "auto", "fillOpacity": 60, "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "lineWidth": 1, - "scaleDistribution": { - "type": "linear" - }, - "thresholdsStyle": { - "mode": "off" - } + "scaleDistribution": { "type": "linear" }, + "thresholdsStyle": { "mode": "off" } }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } + { "color": "green", "value": 0 }, + { "color": "red", "value": 80 } ] }, "unit": "Mbits" }, "overrides": [] }, - "gridPos": { - "h": 9, - "w": 12, - "x": 0, - "y": 35 - }, - "id": 22, + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 62 }, + "id": 42, "options": { "barRadius": 0, "barWidth": 0.7, "fullHighlight": false, "groupWidth": 0.7, - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, + "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true }, "orientation": "auto", "showValue": "auto", "stacking": "none", - "tooltip": { - "hideZeros": false, - "mode": "multi", - "sort": "none" - }, + "tooltip": { "hideZeros": false, "mode": "multi", "sort": "none" }, "xField": "Day", "xTickLabelRotation": 0, "xTickLabelSpacing": 0 @@ -1143,10 +1223,7 @@ "pluginVersion": "12.3.3", "targets": [ { - "datasource": { - "type": "frser-sqlite-datasource", - "uid": "speed-tests-sqlite" - }, + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, "queryText": "SELECT CASE strftime('%w', datetime(timestamp,'unixepoch','localtime')) WHEN '0' THEN 'Sun' WHEN '1' THEN 'Mon' WHEN '2' THEN 'Tue' WHEN '3' THEN 'Wed' WHEN '4' THEN 'Thu' WHEN '5' THEN 'Fri' WHEN '6' THEN 'Sat' END AS 'Day', AVG(down_90th) AS 'Download 90th', AVG(up_90th) AS 'Upload 90th' FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=0 AND down_90th IS NOT NULL GROUP BY 1 ORDER BY CASE strftime('%w', datetime(timestamp,'unixepoch','localtime')) WHEN '1' THEN 1 WHEN '2' THEN 2 WHEN '3' THEN 3 WHEN '4' THEN 4 WHEN '5' THEN 5 WHEN '6' THEN 6 WHEN '0' THEN 7 END", "queryType": "table", "rawQueryText": "SELECT CASE strftime('%w', datetime(timestamp,'unixepoch','localtime')) WHEN '0' THEN 'Sun' WHEN '1' THEN 'Mon' WHEN '2' THEN 'Tue' WHEN '3' THEN 'Wed' WHEN '4' THEN 'Thu' WHEN '5' THEN 'Fri' WHEN '6' THEN 'Sat' END AS 'Day', AVG(down_90th) AS 'Download 90th', AVG(up_90th) AS 'Upload 90th' FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=0 AND down_90th IS NOT NULL GROUP BY 1 ORDER BY CASE strftime('%w', datetime(timestamp,'unixepoch','localtime')) WHEN '1' THEN 1 WHEN '2' THEN 2 WHEN '3' THEN 3 WHEN '4' THEN 4 WHEN '5' THEN 5 WHEN '6' THEN 6 WHEN '0' THEN 7 END", @@ -1157,442 +1234,184 @@ "type": "barchart" }, { - "datasource": { - "type": "frser-sqlite-datasource", - "uid": "speed-tests-sqlite" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "fillOpacity": 60, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false + "collapsed": true, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 70 }, + "id": 105, + "panels": [ + { + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { "type": "auto" }, + "footer": { "reducers": [] }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": 0 }, + { "color": "red", "value": 80 } + ] + } }, - "lineWidth": 1, - "scaleDistribution": { - "type": "linear" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ + "overrides": [ { - "color": "green", - "value": 0 + "matcher": { "id": "byName", "options": "Time" }, + "properties": [ + { "id": "custom.width", "value": 200 } + ] }, { - "color": "red", - "value": 80 - } - ] - }, - "unit": "Mbits" - }, - "overrides": [] - }, - "gridPos": { - "h": 9, - "w": 12, - "x": 12, - "y": 35 - }, - "id": 23, - "options": { - "barRadius": 0, - "barWidth": 0.7, - "fullHighlight": false, - "groupWidth": 0.7, - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "orientation": "auto", - "showValue": "auto", - "stacking": "none", - "tooltip": { - "hideZeros": false, - "mode": "multi", - "sort": "none" - }, - "xField": "Block", - "xTickLabelRotation": 0, - "xTickLabelSpacing": 0 - }, - "pluginVersion": "12.3.3", - "targets": [ - { - "datasource": { - "type": "frser-sqlite-datasource", - "uid": "speed-tests-sqlite" - }, - "queryText": "SELECT CASE WHEN CAST(strftime('%H', datetime(timestamp,'unixepoch','localtime')) AS INTEGER) BETWEEN 0 AND 5 THEN 'Midnight-6am' WHEN CAST(strftime('%H', datetime(timestamp,'unixepoch','localtime')) AS INTEGER) BETWEEN 6 AND 8 THEN 'Early Morning' WHEN CAST(strftime('%H', datetime(timestamp,'unixepoch','localtime')) AS INTEGER) BETWEEN 9 AND 11 THEN 'Morning' WHEN CAST(strftime('%H', datetime(timestamp,'unixepoch','localtime')) AS INTEGER) BETWEEN 12 AND 13 THEN 'Lunch' WHEN CAST(strftime('%H', datetime(timestamp,'unixepoch','localtime')) AS INTEGER) BETWEEN 14 AND 17 THEN 'Afternoon' WHEN CAST(strftime('%H', datetime(timestamp,'unixepoch','localtime')) AS INTEGER) BETWEEN 18 AND 21 THEN 'Evening' ELSE 'Late Night' END AS 'Block', AVG(down_90th) AS 'Download 90th', AVG(up_90th) AS 'Upload 90th' FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=0 AND down_90th IS NOT NULL AND strftime('%w', datetime(timestamp,'unixepoch','localtime')) NOT IN ('0','6') GROUP BY 1 ORDER BY CASE WHEN CAST(strftime('%H', datetime(timestamp,'unixepoch','localtime')) AS INTEGER) BETWEEN 0 AND 5 THEN 1 WHEN CAST(strftime('%H', datetime(timestamp,'unixepoch','localtime')) AS INTEGER) BETWEEN 6 AND 8 THEN 2 WHEN CAST(strftime('%H', datetime(timestamp,'unixepoch','localtime')) AS INTEGER) BETWEEN 9 AND 11 THEN 3 WHEN CAST(strftime('%H', datetime(timestamp,'unixepoch','localtime')) AS INTEGER) BETWEEN 12 AND 13 THEN 4 WHEN CAST(strftime('%H', datetime(timestamp,'unixepoch','localtime')) AS INTEGER) BETWEEN 14 AND 17 THEN 5 WHEN CAST(strftime('%H', datetime(timestamp,'unixepoch','localtime')) AS INTEGER) BETWEEN 18 AND 21 THEN 6 ELSE 7 END", - "queryType": "table", - "rawQueryText": "SELECT CASE WHEN CAST(strftime('%H', datetime(timestamp,'unixepoch','localtime')) AS INTEGER) BETWEEN 0 AND 5 THEN 'Midnight-6am' WHEN CAST(strftime('%H', datetime(timestamp,'unixepoch','localtime')) AS INTEGER) BETWEEN 6 AND 8 THEN 'Early Morning' WHEN CAST(strftime('%H', datetime(timestamp,'unixepoch','localtime')) AS INTEGER) BETWEEN 9 AND 11 THEN 'Morning' WHEN CAST(strftime('%H', datetime(timestamp,'unixepoch','localtime')) AS INTEGER) BETWEEN 12 AND 13 THEN 'Lunch' WHEN CAST(strftime('%H', datetime(timestamp,'unixepoch','localtime')) AS INTEGER) BETWEEN 14 AND 17 THEN 'Afternoon' WHEN CAST(strftime('%H', datetime(timestamp,'unixepoch','localtime')) AS INTEGER) BETWEEN 18 AND 21 THEN 'Evening' ELSE 'Late Night' END AS 'Block', AVG(down_90th) AS 'Download 90th', AVG(up_90th) AS 'Upload 90th' FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 AND failed=0 AND down_90th IS NOT NULL AND strftime('%w', datetime(timestamp,'unixepoch','localtime')) NOT IN ('0','6') GROUP BY 1 ORDER BY CASE WHEN CAST(strftime('%H', datetime(timestamp,'unixepoch','localtime')) AS INTEGER) BETWEEN 0 AND 5 THEN 1 WHEN CAST(strftime('%H', datetime(timestamp,'unixepoch','localtime')) AS INTEGER) BETWEEN 6 AND 8 THEN 2 WHEN CAST(strftime('%H', datetime(timestamp,'unixepoch','localtime')) AS INTEGER) BETWEEN 9 AND 11 THEN 3 WHEN CAST(strftime('%H', datetime(timestamp,'unixepoch','localtime')) AS INTEGER) BETWEEN 12 AND 13 THEN 4 WHEN CAST(strftime('%H', datetime(timestamp,'unixepoch','localtime')) AS INTEGER) BETWEEN 14 AND 17 THEN 5 WHEN CAST(strftime('%H', datetime(timestamp,'unixepoch','localtime')) AS INTEGER) BETWEEN 18 AND 21 THEN 6 ELSE 7 END", - "refId": "A" - } - ], - "title": "Avg Speed by Time Block (Weekdays)", - "type": "barchart" - }, - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 44 - }, - "id": 103, - "panels": [], - "title": "Uptime & Failures", - "type": "row" - }, - { - "datasource": { - "type": "frser-sqlite-datasource", - "uid": "speed-tests-sqlite" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "fillOpacity": 60, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineWidth": 1, - "scaleDistribution": { - "type": "linear" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 + "matcher": { "id": "byName", "options": "Download 90th (Mbps)" }, + "properties": [ + { "id": "unit", "value": "Mbits" }, + { "id": "decimals", "value": 2 }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": 0 }, + { "color": "yellow", "value": 600 }, + { "color": "green", "value": 700 } + ] + } + }, + { "id": "custom.width", "value": 200 }, + { "id": "custom.cellOptions", "value": { "mode": "gradient", "type": "color-background" } } + ] }, { - "color": "red", - "value": 80 + "matcher": { "id": "byName", "options": "Upload 90th (Mbps)" }, + "properties": [ + { "id": "unit", "value": "Mbits" }, + { "id": "decimals", "value": 2 }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": 0 }, + { "color": "yellow", "value": 35 }, + { "color": "green", "value": 50 } + ] + } + }, + { "id": "custom.width", "value": 200 }, + { "id": "custom.cellOptions", "value": { "mode": "gradient", "type": "color-background" } } + ] } ] }, - "unit": "none" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Failed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E02F44", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Successful" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#56A64B", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 8, - "w": 14, - "x": 0, - "y": 45 - }, - "id": 30, - "options": { - "barRadius": 0, - "barWidth": 0.85, - "fullHighlight": false, - "groupWidth": 0.7, - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "orientation": "auto", - "showValue": "auto", - "stacking": "normal", - "tooltip": { - "hideZeros": false, - "mode": "multi", - "sort": "none" - }, - "xField": "Day", - "xTickLabelRotation": 0, - "xTickLabelSpacing": 0 - }, - "pluginVersion": "12.3.3", - "targets": [ - { - "datasource": { - "type": "frser-sqlite-datasource", - "uid": "speed-tests-sqlite" - }, - "queryText": "SELECT strftime('%d.%m.', datetime(timestamp,'unixepoch','localtime')) AS 'Day', SUM(CASE WHEN failed=0 THEN 1 ELSE 0 END) AS 'Successful', SUM(CASE WHEN failed=1 THEN 1 ELSE 0 END) AS 'Failed' FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 GROUP BY strftime('%Y-%m-%d', datetime(timestamp,'unixepoch','localtime')) ORDER BY strftime('%Y-%m-%d', datetime(timestamp,'unixepoch','localtime'))", - "queryType": "table", - "rawQueryText": "SELECT strftime('%d.%m.', datetime(timestamp,'unixepoch','localtime')) AS 'Day', SUM(CASE WHEN failed=0 THEN 1 ELSE 0 END) AS 'Successful', SUM(CASE WHEN failed=1 THEN 1 ELSE 0 END) AS 'Failed' FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 GROUP BY strftime('%Y-%m-%d', datetime(timestamp,'unixepoch','localtime')) ORDER BY strftime('%Y-%m-%d', datetime(timestamp,'unixepoch','localtime'))", - "refId": "A" + "gridPos": { "h": 14, "w": 24, "x": 0, "y": 71 }, + "id": 50, + "options": { "cellHeight": "sm", "showHeader": true }, + "pluginVersion": "12.3.3", + "targets": [ + { + "datasource": { "type": "frser-sqlite-datasource", "uid": "speed-tests-sqlite" }, + "queryText": "SELECT strftime('%d.%m.%Y %H:%M', datetime(timestamp,'unixepoch','localtime')) AS 'Time', ROUND(down_90th, 2) AS 'Download 90th (Mbps)', ROUND(up_90th, 2) AS 'Upload 90th (Mbps)' FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 ORDER BY timestamp DESC", + "queryType": "table", + "rawQueryText": "SELECT strftime('%d.%m.%Y %H:%M', datetime(timestamp,'unixepoch','localtime')) AS 'Time', ROUND(down_90th, 2) AS 'Download 90th (Mbps)', ROUND(up_90th, 2) AS 'Upload 90th (Mbps)' FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 ORDER BY timestamp DESC", + "refId": "A", + "timeColumns": [] + } + ], + "title": "Raw Measurements", + "type": "table" } ], - "title": "Test Results per Day", - "type": "barchart" - }, - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 53 - }, - "id": 104, - "panels": [], "title": "Raw Data", "type": "row" - }, - { - "datasource": { - "type": "frser-sqlite-datasource", - "uid": "speed-tests-sqlite" - }, - "fieldConfig": { - "defaults": { - "custom": { - "align": "auto", - "cellOptions": { - "type": "auto" - }, - "footer": { - "reducers": [] - }, - "inspect": false - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Time" - }, - "properties": [ - { - "id": "custom.width", - "value": 200 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Download 90th (Mbps)" - }, - "properties": [ - { - "id": "unit", - "value": "Mbits" - }, - { - "id": "decimals", - "value": 2 - }, - { - "id": "thresholds", - "value": { - "mode": "absolute", - "steps": [ - { - "color": "red", - "value": 0 - }, - { - "color": "yellow", - "value": 600 - }, - { - "color": "green", - "value": 700 - } - ] - } - }, - { - "id": "custom.width", - "value": 200 - }, - { - "id": "custom.cellOptions", - "value": { - "mode": "gradient", - "type": "color-background" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Upload 90th (Mbps)" - }, - "properties": [ - { - "id": "unit", - "value": "Mbits" - }, - { - "id": "decimals", - "value": 2 - }, - { - "id": "thresholds", - "value": { - "mode": "absolute", - "steps": [ - { - "color": "red", - "value": 0 - }, - { - "color": "yellow", - "value": 35 - }, - { - "color": "green", - "value": 50 - } - ] - } - }, - { - "id": "custom.width", - "value": 200 - }, - { - "id": "custom.cellOptions", - "value": { - "mode": "gradient", - "type": "color-background" - } - } - ] - } - ] - }, - "gridPos": { - "h": 14, - "w": 24, - "x": 0, - "y": 54 - }, - "id": 40, - "options": { - "cellHeight": "sm", - "showHeader": true - }, - "pluginVersion": "12.3.3", - "targets": [ - { - "datasource": { - "type": "frser-sqlite-datasource", - "uid": "speed-tests-sqlite" - }, - "queryText": "SELECT strftime('%d.%m.%Y %H:%M', datetime(timestamp,'unixepoch','localtime')) AS 'Time', ROUND(down_90th, 2) AS 'Download 90th (Mbps)', ROUND(up_90th, 2) AS 'Upload 90th (Mbps)' FROM speed_tests WHERE timestamp BETWEEN 1761951600000/1000 AND 1771887599000/1000 ORDER BY timestamp DESC", - "queryType": "table", - "rawQueryText": "SELECT strftime('%d.%m.%Y %H:%M', datetime(timestamp,'unixepoch','localtime')) AS 'Time', ROUND(down_90th, 2) AS 'Download 90th (Mbps)', ROUND(up_90th, 2) AS 'Upload 90th (Mbps)' FROM speed_tests WHERE timestamp BETWEEN $__from/1000 AND $__to/1000 ORDER BY timestamp DESC", - "refId": "A", - "timeColumns": [ - "time", - "ts" - ] - } - ], - "title": "Raw Measurements", - "type": "table" } ], "preload": false, - "refresh": "5m", + "refresh": "30s", "schemaVersion": 42, "tags": [ "network", "speed-test" ], "templating": { - "list": [] + "list": [ + { + "current": { "text": "1000", "value": "1000" }, + "description": "Contractual maximum download speed (Mbit/s) from your ISP's Produktinformationsblatt.", + "hide": 0, + "label": "Down max (Mbit/s)", + "name": "sla_down_max", + "options": [{ "selected": true, "text": "1000", "value": "1000" }], + "query": "1000", + "skipUrlSync": false, + "type": "textbox" + }, + { + "current": { "text": "850", "value": "850" }, + "description": "Contractual 'normally available' download speed (Mbit/s).", + "hide": 0, + "label": "Down normal (Mbit/s)", + "name": "sla_down_normal", + "options": [{ "selected": true, "text": "850", "value": "850" }], + "query": "850", + "skipUrlSync": false, + "type": "textbox" + }, + { + "current": { "text": "600", "value": "600" }, + "description": "Contractual minimum download speed (Mbit/s).", + "hide": 0, + "label": "Down min (Mbit/s)", + "name": "sla_down_min", + "options": [{ "selected": true, "text": "600", "value": "600" }], + "query": "600", + "skipUrlSync": false, + "type": "textbox" + }, + { + "current": { "text": "50", "value": "50" }, + "description": "Contractual maximum upload speed (Mbit/s).", + "hide": 0, + "label": "Up max (Mbit/s)", + "name": "sla_up_max", + "options": [{ "selected": true, "text": "50", "value": "50" }], + "query": "50", + "skipUrlSync": false, + "type": "textbox" + }, + { + "current": { "text": "35", "value": "35" }, + "description": "Contractual 'normally available' upload speed (Mbit/s).", + "hide": 0, + "label": "Up normal (Mbit/s)", + "name": "sla_up_normal", + "options": [{ "selected": true, "text": "35", "value": "35" }], + "query": "35", + "skipUrlSync": false, + "type": "textbox" + }, + { + "current": { "text": "15", "value": "15" }, + "description": "Contractual minimum upload speed (Mbit/s).", + "hide": 0, + "label": "Up min (Mbit/s)", + "name": "sla_up_min", + "options": [{ "selected": true, "text": "15", "value": "15" }], + "query": "15", + "skipUrlSync": false, + "type": "textbox" + } + ] }, "time": { - "from": "now-7d", + "from": "now-24h", "to": "now" }, "timepicker": {}, "timezone": "browser", - "title": "Internet Speed Tests", + "title": "Network Status", "uid": "speed-tests-v2", - "version": 2 + "version": 4 } diff --git a/measurement/Dockerfile b/measurement/Dockerfile index 13dfe65..6bbe555 100644 --- a/measurement/Dockerfile +++ b/measurement/Dockerfile @@ -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/* diff --git a/measurement/config.py b/measurement/config.py index 69996c7..3a24d84 100644 --- a/measurement/config.py +++ b/measurement/config.py @@ -1,4 +1,5 @@ import os DB_PATH = os.getenv("DB_PATH") or "speedtest.db" +PING_INTERVAL = int(os.getenv("PING_INTERVAL", "5")) diff --git a/measurement/db.py b/measurement/db.py index 8957292..e752614 100644 --- a/measurement/db.py +++ b/measurement/db.py @@ -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() diff --git a/measurement/run_ping.py b/measurement/run_ping.py new file mode 100644 index 0000000..6e11220 --- /dev/null +++ b/measurement/run_ping.py @@ -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()