A single-page, self-hosted home network dashboard. One glance answers "is it my internet, my WiFi, or the site?" — and keeps a timestamped record so you can answer it after the fact too.
Built for a low-power always-on box (in my case an old laptop running Pi-hole and a Tailscale exit node). One Python process, SQLite, no external assets — the page still renders when the internet is down, which is precisely when you'll be looking at it.
Screenshots taken with ?demo=1, which anonymises hostnames and MACs — see below.
The verdict — one sentence in the header that says whose problem it is, synthesised from everything below:
Netflix is down — your line is fine, that's on them line saturated — 17 of 18 Mbps in use, anything else will buffer Internet down — your router is fine, the line to the ISP is out
It's careful about blame: a service that fails while your own line is saturated or lossy is reported as "probably the saturated line, not them."
Top row — the glance
- Internet status pill (turns red and pulses on outage)
- Latency + jitter, 24h packet loss, last speed test
- Line usage right now — whole house, from the router's WAN counters
- Pi-hole block rate, router status, services up (n/n), host machine temperature / load / fan
Service status — the classic status-page strip for the services your house actually uses. The list maintains itself: a catalog of ~35 known services is cross-referenced against Pi-hole's permitted domains every hour, and anything the house has resolved recently gets checked. Start watching TikTok, it appears; stop using Stremio for a fortnight, it drops off. Every 2 minutes each service gets an HTTP check with the timing split by phase — DNS → TCP → TLS → first byte — so a failure says where it failed. 24h uptime %, current response time, last issue. Any HTTP answer below 500 counts as reachable (bot-protection 403s are still "up").
Incidents — the raw down/up events grouped into outages with a duration and a cause, the probe's view and the router's merged into one row:
Sep 14 01:49 PM – 01:59 PM · 10 min · ISP — gateway was fine, line was out · seen by router
…plus a 7-day summary: count, total minutes, longest, uptime %. That's the table you put in front of your ISP.
When the line is saturated the verdict names the device responsible: "line saturated — 19 Mbps, that's all of it, Laptop 1 alone is pulling 15."
Last 30 days — the numbers for the ISP conversation: uptime %, outage count and longest, delivered vs plan (set PLAN_DOWN_MBPS / PLAN_UP_MBPS), slowest-10% speed, upload, worst day, packet loss, WiFi disconnects. It only counts the time it was actually watching (based on N days of data), so a fresh install doesn't report a fictional month.
Click any device in the Devices or WiFi tables to expand its last 24h: signal strength, download rate, and each disconnect drawn as a red bar whose height is the signal at the moment it dropped. ?device=AA:BB:CC:DD:EE:FF deep-links straight to one. This is how you answer "was the phone on weak signal during that call?"
Device names, automatically. Every 10 minutes each device with an IP gets a reverse mDNS lookup — Apple devices, Macs, Linux boxes and smart TVs answer with their real name (Sams-iPhone, Living-Room-TV). Anything that doesn't answer (IoT plugs, most Android) you can name yourself: hover a row, click the pencil, type, Enter. Manual names win over discovered ones and are never overwritten. Names flow into every panel, including the verdict: "line saturated — Sams iPhone alone is pulling 19."
Data used — approximate bytes per device over 30 days, from the router's per-client rate integrated over time. It's rate × interval, not a meter, so treat it as a ranking rather than a bill — but "the TV used 41 GB" explains a lot of buffering evenings.
Copy for ISP — one button on the report builds a plain-text summary (uptime, outage list with timestamps, delivered vs plan, worst day) and puts it on the clipboard for the support chat.
Test now — runs a speed test on demand from the throughput tile. Still refuses if the line is busy in either direction: a test that competes with a stream measures the wrong thing and ruins the stream.
Upload — off by default, since on an asymmetric line it measures little and saturating the uplink hurts everything else in the house. Set UPLOAD_TEST=1 and PLAN_UP_MBPS on a symmetric line and the tile reads 27 / 29 Mbps with 90% / 97% of your 30/30 plan, the chart gains an upload series, and the 30-day report gains an Upload card. The tile colours on the worse of the two directions — a healthy download shouldn't hide a broken upload.
vs last period — every report tile carries a ▲/▼ against the previous 30 days once there's enough history, so you can tell whether fixing the mesh or phoning the ISP actually changed anything.
Sees its own absence. If netdash finds a gap in its own probes on startup, it logs "netdash stopped reporting — HTPC off, asleep or rebooted?" as an incident; if the router's uptime counter goes backwards, it logs "router rebooted." Both are failures that previously left no trace at all.
When it goes wrong — a day × hour heatmap of loss, latency or jitter. A 7-day line chart hides time-of-day patterns; a grid makes them obvious at a glance, and the caption names the worst hour. On my line it immediately surfaced a nightly 03:00 spike alongside the expected evening contention — two different problems that look identical in a summary figure.
Hung-process detection. Processes are scored on lifetime average CPU, not instantaneous: a busy app peaks and settles, a hung one averages 60%+ for days. Anything over 40% for more than an hour appears on the host tile and in the verdict — "stremio has averaged 64% CPU for 168h — likely hung." Kernel threads and the compositor are excluded. This exists because a hung media player quietly ate a CPU core here for a week before anyone noticed.
New devices — Pi-hole's first-seen data surfaced as events ("iPhone joined the network at 192.168.10.54"). The first run seeds silently, so only genuinely new arrivals appear.
Exit node — if this box relays for a Tailscale tailnet, its traffic is tracked separately from ordinary LAN use: live in/out, GB relayed over the window, and per-peer totals when peers are active. Without it, relayed traffic shows up as mystery usage attributed to the host itself.
History — raw samples are kept 14 days; hourly rollups for 90. Every chart offers 6h / 24h / 7d / 30d / 90d and switches source automatically.
Charts (6h / 24h / 7d)
- WAN health: latency, jitter, and packet loss as thin red spikes, with a gateway line so you can tell LAN trouble from ISP trouble
- Line usage and throughput: continuous WAN download/upload from the router plus periodic speed tests
- Pi-hole queries (total / blocked / cached)
- Host health (package temp, load, fan)
Tables
- Devices: every known device with live online state, band, WiFi signal, real-time per-device down/up, vendor, DNS activity
- WiFi stability: per client, disconnects in 24h and the signal strength at the moment of each drop — the thing that finds the phone at the edge of coverage
- Events: internet down/up (attributed LAN vs WAN), packet-loss events, router WAN state changes, Pi-hole warnings — all in one feed
| Source | How | Interval |
|---|---|---|
| WAN health | ping -c10 -i0.2 to gateway, ISP box, and 1.1.1.1 |
60s |
| Throughput | Download from Cloudflare, plus an upload test when UPLOAD_TEST=1 — skipped if the line is busy in either direction, so it never fights a stream or misreports |
15 min |
| Host machine | /proc, thermal zones, sensors, NIC byte deltas |
60s |
| Services | curl per service with %{time_namelookup}/connect/starttransfer and %{exitcode} |
2 min |
| Pi-hole v6 | REST API over loopback HTTP, proxied live (session cached, re-auth on 401) | on page load |
| Cudy router | Stock-firmware web endpoints (see below) | 60s; syslog every 5 min |
Everything time-series lands in netdash.db (SQLite): raw for 14 days, hourly rollups for 90 (RAW_DAYS, RETENTION_DAYS). Rollups are rebuilt for the last 3 hours every hour and backfilled on startup, so nothing is lost across restarts.
Tested on Pop!_OS / Ubuntu 22.04, Python 3.10.
sudo mkdir -p /opt/netdash && sudo chown $USER /opt/netdash
git clone https://github.com/dnaidoo621/netdash.git /opt/netdash
cd /opt/netdash
python3 -m venv venv && ./venv/bin/pip install -r requirements.txt
cp config.env.example config.env
chmod 600 config.env
$EDITOR config.env # passwords, targets, interface names
sudo install -m 644 netdash.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now netdashThen open http://<host>:8080. Edit netdash.service if your user isn't darren or you want a different port.
Requirements on the host: ping (unprivileged ICMP — default on Ubuntu), curl ≥ 7.75 (for %{exitcode}), avahi-utils for automatic device names (optional; you can still name devices by hand), and lm-sensors for the fan reading (optional; shows ? without it).
You mostly don't. The catalog in app.py (SERVICE_CATALOG) maps domain patterns to services, and detection picks whichever ones Pi-hole has seen ≥25 queries for in the last 24h, keeping them for 14 days after they were last seen (AUTO_DETECT_MIN_QUERIES, AUTO_DETECT_DAYS). Detected entries are tagged auto in the panel.
To force a service regardless of traffic, or to override a check URL, copy services.json.example to services.json — those are tagged pinned. Pick endpoints that answer plainly: https://www.google.com/generate_204 is ideal; a homepage that 302s a few times is fine since checks follow redirects. Without Pi-hole, the first eleven catalog entries are used. Restart the service after changing it.
For a 1080p TV viewed from the sofa, open the page once as
http://<host>:8080/?tv=1
It persists in that browser's localStorage. Bigger type, four tiles per row, denser panels hidden. There's also a TV toggle in the header. Press F11 for full-screen.
http://<host>:8080/?demo=1
Replaces hostnames with generic labels by vendor (Laptop 1, iPhone 2, Smart plug 1…), masks MAC addresses, and hides the top-permitted-domains panel (which fingerprints what you use). Per-URL only — never persisted, so the live dashboard is unaffected. The dashboard API also never exposes the WAN public IP or router MAC, regardless of mode.
The router panels come from a Cudy M1800 (v2.0, fw 2.1.3) without flashing it. The stock firmware is LuCI underneath, but Cudy strips the ubus ACL to nothing and replaces the standard controllers — so router.py logs in exactly as the browser does and reads the HTML fragments their own UI polls.
- Login:
luci_password = sha256(sha256(password + salt) + token)with_csrf,salt,tokentaken fresh from the login page each time (which is served with HTTP 403). Username isadmin. - Endpoints used:
admin/status/bandwidth?iface=eth1.2(WAN counters),admin/network/devices/devlist?detail=1(per-device band, signal, rate, duration),admin/network/{wan,mesh,system}/status?detail=1,admin/system/status/syslog. - Syslog is parsed for
pingcheck … changed to ONLINE/OFFLINE(WAN state) andraN disassoc: MAC, rssi: N(WiFi drops with signal).
Things that bit me, so you don't have to:
- Re-login must clear the cookie jar first. A still-valid
sysauthcookie makes the router serve the dashboard instead of the login form, so the re-auth "fails" and every router panel goes dark after ~25 minutes. - The rate cell lists upload before download. Anchor on the
arrow-up/arrow-downicons, never on position. - Status cells render twice (desktop + mobile copy).
_undouble()collapses"X X"→"X". - Signal is a positive SNR-style dB, higher is better. I colour ≥40 good, <25 poor.
It's unofficial. A firmware update could change the HTML, in which case the router panels go empty rather than anything breaking. The probes in tools/ are what I used to reverse it — useful if you need to re-map endpoints or adapt to another Cudy model.
Everything the page uses is plain JSON, handy for curl when something looks wrong:
| Endpoint | Returns |
|---|---|
/api/overview |
Headline state: verdict, internet up, latest probes, throughput, machine, Pi-hole summary, router summary, services up/down |
/api/services?hours=24 |
Per service: latest result, uptime %, 48-bucket strip, last failure and reason, source (auto / pinned) |
/api/incidents?hours=168 |
Outages grouped with duration, LAN-vs-ISP attribution, which sources saw it, services that failed during it; plus a summary |
/api/report?days=30 |
Uptime, outages, speed avg/p10/worst day, delivered vs plan, loss, WiFi drops — clipped to the time actually covered |
/api/router/signal?mac=…&hours=24 |
One device's signal + rate points and its disconnects |
/api/devices/usage?days=30 |
Approximate GB down/up per device |
PUT /api/devices/name {mac, name} |
Set a manual name (empty name clears it) |
POST /api/speedtest |
Run a speed test now (both directions when enabled); {skipped, reason} if the line is busy |
/api/heatmap?days=7&metric=loss |
day × hour cells plus the worst hours (loss / rtt / jitter) |
/api/hogs?hours=24 |
Processes over the CPU threshold now, and any seen in the window |
/api/tailscale?hours=24 |
Exit-node relay traffic, GB over the window, per-peer totals |
/api/wan?hours=24 |
Probe timeseries (loss, rtt, jitter) per target |
/api/throughput?hours=24 |
Speed-test samples |
/api/router/wan?hours=24 |
Router WAN Mbps timeseries |
/api/router/devices |
Live per-device band / signal / rate |
/api/router/wifi?hours=24 |
Per-client drops and signal aggregates |
/api/router/signal?mac=… |
One client's signal + rate history |
/api/devices |
Merged device table (Pi-hole + ARP + router) |
/api/events?hours=72 |
Event feed |
/api/pihole/{history,top,messages} |
Pi-hole passthroughs |
- The page has no authentication. It's intended for a home LAN (and Tailscale). Don't expose port 8080 to the internet. The only things it can change are its own device names and running a speed test — nothing touches the router or Pi-hole.
config.envholds the Pi-hole and router passwords. It's gitignored and should bechmod 600. Nothing in the repo contains a secret.- Router access is read-only —
router.pyonly ever issues GETs after login.
app.py FastAPI app: collector loop, API, static serving
router.py Cudy stock-firmware client
static/index.html the whole UI — vanilla JS, canvas charts, no dependencies
netdash.service systemd unit
config.env.example configuration template
services.json.example which services to health-check
tools/ the probes used to reverse-engineer the Cudy endpoints







