From 2f59a546787fe6b7d06e9034a4e8413ab8e65245 Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Sun, 27 Sep 2026 20:11:03 -0700 Subject: [PATCH] A Network tab, with an internet speed test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per machine like every other tab: the interfaces as the kernel has them (addresses, MAC, MTU, negotiated link speed, carrier, totals, live rates from the sampler's own sensors), the default route and resolvers, what the internet sees of this connection, and a speed test with the last fifty results kept. The test runs in the daemon, not the browser, so the figure is the line of the machine in the switcher — the router's, if you switch to gate — and it carries on when the phone locks. It measures against Cloudflare's public speed endpoints, so there is nothing to install on any host, which is what makes it work the same on Debian and on OpenWrt where flash is scarce. What the measurement does and does not count: - Several streams, HTTP/2 deliberately off. One stream over one connection measures a congestion window, and HTTP/2 would put every stream back onto one connection and measure the same thing. - The first second of each phase is thrown away as the ramp. - A phase ends on 64 MiB as well as on four seconds, so a fast line says what it is without moving a gigabyte. A minimum window keeps a very fast line from filling the cap before there is anything to divide by. - Latency is the round trip less the far end's own Server-Timing. A zero-byte response is mostly Cloudflare's worker thinking: left in, it reported 31 ms for a line the edge itself measures at 3.5. Jitter is the mean change between consecutive probes, so one slow probe cannot run away with it. - The far end rate-limits by volume. So: a 20 s cooldown counted from the last attempt rather than the last success, streams that retry twice, the rate computed over the window in which bytes were actually still arriving, and a plain sentence when it was refused. Coasting to the end of the clock with nothing arriving reported a third of the truth. The last result is published as net.internet.* sensors, each stamped with when the test ran, so the line can be a widget, a graph, an alert or a Prometheus series like anything else. The public address is refreshed on its own quarter hour, which is worth having here: it is a DHCP lease and it moves. Tests are offline — the window, the byte cap, the refusal and the cancellation run against a local server; the route parser's byte order is pinned by a case that would have caught the bug it did. The live measurement is behind HAMLAB_NET_TEST=1. Co-Authored-By: Claude Opus 5 --- PLAN.md | 35 ++ README.md | 1 + SENSORS.md | 7 + internal/api/docker_events.go | 2 +- internal/api/network.go | 123 ++++++ internal/api/server.go | 11 + internal/api/space.go | 2 +- internal/network/network.go | 360 +++++++++++++++++ internal/network/network_test.go | 255 ++++++++++++ internal/network/speedtest.go | 648 +++++++++++++++++++++++++++++++ internal/store/store.go | 7 +- web/src/Network.tsx | 188 +++++++++ web/src/Shell.tsx | 15 +- web/src/api.ts | 22 ++ web/src/styles.css | 15 + 15 files changed, 1683 insertions(+), 8 deletions(-) create mode 100644 internal/api/network.go create mode 100644 internal/network/network.go create mode 100644 internal/network/network_test.go create mode 100644 internal/network/speedtest.go create mode 100644 web/src/Network.tsx diff --git a/PLAN.md b/PLAN.md index f2004fc..848fd79 100644 --- a/PLAN.md +++ b/PLAN.md @@ -587,6 +587,41 @@ something long in one, switch away and back, refresh — it's all still there. `POST /api/password`, current password required), Access tokens (list/revoke the tokens hubs hold), About (version, host, arch, uptime, certificate). +## Network tab (2026-09-27) + +A **Network** tab, per machine like every other: the interfaces as the kernel has them +(addresses, MAC, MTU, negotiated link speed, carrier, byte totals, live rates from the +sampler's own sensors), the default route and resolvers, what the internet sees of this +connection, and an **internet speed test**. + +Decisions worth keeping: + +- **Measured by the daemon, not the browser.** The test runs on the machine in the switcher, + so the figure is that machine's line — the router's, if you switch to `gate` — and it + carries on if the phone locks or the tab closes. The page polls a run it is not driving. +- **No tool to install.** It measures against Cloudflare's public speed endpoints + (`__down`, `__up`, `cdn-cgi/trace`), which is what makes it work identically on Debian and + on OpenWrt, where `opkg install` costs flash. That is the feature's one external + dependency, and it is named in `internal/network/speedtest.go`. +- **Several streams, HTTP/2 off, and the ramp thrown away.** One stream over one connection + measures a congestion window; HTTP/2 would put every stream back on one connection and do + the same. The first second of each phase is measured and discarded. +- **Latency is the round trip less the far end's own Server-Timing.** A zero-byte response + is mostly Cloudflare's worker running — 20 to 45 ms of it against a few ms of wire. Left + in, it reported 31 ms for a 7 ms line. Jitter is the mean change between consecutive + probes, not a standard deviation, so one slow probe doesn't run away with it. +- **A refused phase is not a number.** The far end rate-limits by volume, and a test moves a + few hundred megabytes; several in a row earn 429s. So: a 20-second cooldown between tests, + a stream that retries twice before giving up, the rate computed over the window in which + bytes were actually still arriving, and a plain sentence when it was cut short. Coasting to + the end of the clock with nothing arriving reported a third of the truth. +- **The last result is published as sensors** (`net.internet.*`), so the line is a widget, a + graph, an alert or a Prometheus series like anything else. Each carries the time the test + ran, not the time it was asked for. +- **Not scheduled.** Nothing takes a speed test on its own. A nightly one recorded to the + history is the obvious next step and deliberately not built yet — the cron shape for it + already exists twice over (ZFS snapshots, Docker image updates). + ## To do - **Grow the Files tab until copyparty can go** — asked for 2026-09-23 as two items (Files diff --git a/README.md b/README.md index 7642367..a075d1f 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ What adapts to the host, rather than assuming Debian: | Updates tab | apt, dnf, zypper, pacman, apk: list, refresh, upgrade, install (pacman: all or nothing) | | Services / timers | systemd of any age (JSON output where it exists, the plain table before v246); a clear message on hosts without systemd | | Disks | `lsblk`, or `/sys/block` when it's missing (busybox) or too old; SMART and ZFS only when their tools are there | +| Network | `/proc` and `/sys` for the interfaces, addresses, link speed and default route. The speed test needs no tool installed: it measures against Cloudflare's public speed endpoints, so it works the same on Debian and on the OpenWrt router | | Dashboard | `/proc` and `/sys` only: hwmon or thermal-zone temps, fans, NICs, disks, mounts; empty sections where the hardware has nothing to report. UPS from whichever of `pwrstat`, NUT (`upsc`) or `apcupsd` is installed | | Listen address | bound with `IP_FREEBIND`, so a LAN address that isn't up yet at boot doesn't fail the start | | Self-update | the new build must run and load this host's config before it replaces the old one; atomic rename; the daemon then re-execs itself, so it restarts the same way under systemd, OpenRC or nothing; `hamlab.prev` kept | diff --git a/SENSORS.md b/SENSORS.md index b3812c3..600dea9 100644 --- a/SENSORS.md +++ b/SENSORS.md @@ -16,6 +16,7 @@ cpu.usage cpu.core..usage cpu.temp load.1|5|15 uptime mem.total|used|avail|free|cached|buffers|used_pct swap.total|used|avail|used_pct temp..