A Network tab, with an internet speed test
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a0987c940f
commit
2f59a54678
@@ -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
|
`POST /api/password`, current password required), Access tokens (list/revoke the tokens
|
||||||
hubs hold), About (version, host, arch, uptime, certificate).
|
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
|
## To do
|
||||||
|
|
||||||
- **Grow the Files tab until copyparty can go** — asked for 2026-09-23 as two items (Files
|
- **Grow the Files tab until copyparty can go** — asked for 2026-09-23 as two items (Files
|
||||||
|
|||||||
@@ -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) |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ cpu.usage cpu.core.<n>.usage cpu.temp load.1|5|15 uptime
|
|||||||
mem.total|used|avail|free|cached|buffers|used_pct swap.total|used|avail|used_pct
|
mem.total|used|avail|free|cached|buffers|used_pct swap.total|used|avail|used_pct
|
||||||
temp.<chip>.<label> fan.<chip>.<label>
|
temp.<chip>.<label> fan.<chip>.<label>
|
||||||
net.<iface>.rx|tx|rx_total|tx_total|up
|
net.<iface>.rx|tx|rx_total|tx_total|up
|
||||||
|
net.internet.download|upload|latency|jitter|tested|ip|edge
|
||||||
disk.<dev>.read|write|util|size|temp
|
disk.<dev>.read|write|util|size|temp
|
||||||
fs.<mount>.size|used|used_pct
|
fs.<mount>.size|used|used_pct
|
||||||
ups.battery|runtime|load|load_pct|in_v|out_v|on_battery|state|source
|
ups.battery|runtime|load|load_pct|in_v|out_v|on_battery|state|source
|
||||||
@@ -26,6 +27,12 @@ ups.battery|runtime|load|load_pct|in_v|out_v|on_battery|state|source
|
|||||||
- Built from names the hardware reports, never enumeration order (`hwmon3` moves between
|
- Built from names the hardware reports, never enumeration order (`hwmon3` moves between
|
||||||
boots). Two identical chips collide; the second gets `_2`.
|
boots). Two identical chips collide; the second gets `_2`.
|
||||||
- Which sensors exist differs per machine. That is the point of the catalog.
|
- Which sensors exist differs per machine. That is the point of the catalog.
|
||||||
|
- `net.internet.*` is the Network tab's speed test: `download` and `upload` in Mbps,
|
||||||
|
`latency` and `jitter` in ms, `tested` the age of the reading in seconds, and `ip` and
|
||||||
|
`edge` the public address and the Cloudflare edge as strings. They appear only once a test
|
||||||
|
has run on that machine, and each one's `t` is when it ran — so a widget or an alert on
|
||||||
|
them is reading a measurement from that moment, not a live value. Nothing takes a speed
|
||||||
|
test on its own; something has to press the button.
|
||||||
|
|
||||||
## A sensor
|
## A sensor
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// The Network tab: the machine's interfaces, how the internet looks from here, and the speed
|
||||||
|
// test. One machine at a time, like every other tab — the test runs on the machine in the
|
||||||
|
// switcher, so the number is that machine's line and not the hub's.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlab/internal/network"
|
||||||
|
"hamlab/internal/sensors"
|
||||||
|
"hamlab/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// networkOverview is everything the tab draws at once: the interfaces, the outside view, the
|
||||||
|
// last test and the ones before it.
|
||||||
|
func (s *Server) networkOverview(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
run := s.net.Test.State()
|
||||||
|
jsonOK(w, 200, map[string]any{
|
||||||
|
"report": network.Look(s.hostName()),
|
||||||
|
"outside": s.net.Outside(),
|
||||||
|
"run": run,
|
||||||
|
"history": s.net.Test.History(),
|
||||||
|
"method": network.Method,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) speedtestState(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
jsonOK(w, 200, s.net.Test.State())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) speedtestStart(w http.ResponseWriter, r *http.Request) {
|
||||||
|
run, err := s.net.Test.Start()
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 409, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("speed test started on %s (by %s)", s.hostName(), clientIP(r))
|
||||||
|
jsonOK(w, 200, run)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) speedtestCancel(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.net.Test.Cancel() {
|
||||||
|
jsonErr(w, 404, "no speed test is running")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("speed test cancelled on %s (by %s)", s.hostName(), clientIP(r))
|
||||||
|
w.WriteHeader(204)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- keeping the results ----
|
||||||
|
|
||||||
|
// savedNetwork is what survives a restart: the recent results, so the history is a history
|
||||||
|
// rather than whatever has happened since the daemon last started.
|
||||||
|
type savedNetwork struct {
|
||||||
|
Results []network.Result `json:"results,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) loadNetwork() []network.Result {
|
||||||
|
var saved savedNetwork
|
||||||
|
var raw json.RawMessage
|
||||||
|
s.store.View(func(st *store.State) { raw = st.Network })
|
||||||
|
if len(raw) > 0 {
|
||||||
|
if err := json.Unmarshal(raw, &saved); err != nil {
|
||||||
|
log.Printf("network: kept results unreadable (%v); starting a fresh history", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return saved.Results
|
||||||
|
}
|
||||||
|
|
||||||
|
// keepResult is called when a test finishes: append it to what is on disk.
|
||||||
|
func (s *Server) keepResult(r network.Result) {
|
||||||
|
if r.Error != "" {
|
||||||
|
log.Printf("speed test on %s failed: %s", s.hostName(), r.Error)
|
||||||
|
} else {
|
||||||
|
log.Printf("speed test on %s: down %s, up %s, latency %.1f ms via %s",
|
||||||
|
s.hostName(), network.Mbits(r.DownMbps), network.Mbits(r.UpMbps), r.LatencyMs, r.Colo)
|
||||||
|
}
|
||||||
|
results := s.net.Test.History()
|
||||||
|
b, err := json.Marshal(savedNetwork{Results: results})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.Update(func(st *store.State) { st.Network = b }); err != nil {
|
||||||
|
log.Printf("network: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the speed test as sensors ----
|
||||||
|
|
||||||
|
// netSensors publishes the last speed test as ordinary sensors, so the line can be a widget,
|
||||||
|
// a graph, an alert or a Prometheus series like anything else Ham Lab measures. Each one's
|
||||||
|
// timestamp is when the test ran, which is how a stale reading says so.
|
||||||
|
type netSensors struct{ w *network.Watcher }
|
||||||
|
|
||||||
|
func (n netSensors) Sensors() []sensors.Sensor {
|
||||||
|
var out []sensors.Sensor
|
||||||
|
if o := n.w.Outside(); o.IP != "" {
|
||||||
|
at := o.At.UnixMilli()
|
||||||
|
out = append(out, sensors.Sensor{ID: "net.internet.ip", Name: "Public address", Kind: "state", Value: o.IP, T: at})
|
||||||
|
if o.Colo != "" {
|
||||||
|
out = append(out, sensors.Sensor{ID: "net.internet.edge", Name: "Internet edge", Kind: "state", Value: o.Colo, T: at})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run := n.w.Test.State()
|
||||||
|
if run.Result == nil || run.Result.Error != "" {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
r := run.Result
|
||||||
|
at := r.At.UnixMilli()
|
||||||
|
return append(out,
|
||||||
|
sensors.Sensor{ID: "net.internet.download", Name: "Download", Kind: "rate", Unit: "Mbps", Value: r.DownMbps, T: at, Min: sensors.F(0)},
|
||||||
|
sensors.Sensor{ID: "net.internet.upload", Name: "Upload", Kind: "rate", Unit: "Mbps", Value: r.UpMbps, T: at, Min: sensors.F(0)},
|
||||||
|
sensors.Sensor{ID: "net.internet.latency", Name: "Latency", Kind: "duration", Unit: "ms", Value: r.LatencyMs, T: at, Min: sensors.F(0)},
|
||||||
|
sensors.Sensor{ID: "net.internet.jitter", Name: "Jitter", Kind: "duration", Unit: "ms", Value: r.JitterMs, T: at, Min: sensors.F(0)},
|
||||||
|
sensors.Sensor{ID: "net.internet.tested", Name: "Speed test age", Kind: "duration", Unit: "s", Value: round1(time.Since(r.At).Seconds()), T: at, Min: sensors.F(0)},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func round1(v float64) float64 { return float64(int64(v*10+0.5)) / 10 }
|
||||||
@@ -35,6 +35,7 @@ import (
|
|||||||
"hamlab/internal/files"
|
"hamlab/internal/files"
|
||||||
"hamlab/internal/hardware"
|
"hamlab/internal/hardware"
|
||||||
"hamlab/internal/metrics"
|
"hamlab/internal/metrics"
|
||||||
|
"hamlab/internal/network"
|
||||||
"hamlab/internal/nodes"
|
"hamlab/internal/nodes"
|
||||||
"hamlab/internal/priv"
|
"hamlab/internal/priv"
|
||||||
"hamlab/internal/sensors"
|
"hamlab/internal/sensors"
|
||||||
@@ -70,6 +71,7 @@ type Server struct {
|
|||||||
storage *storage.Manager
|
storage *storage.Manager
|
||||||
hardware *hardware.Manager
|
hardware *hardware.Manager
|
||||||
space *space.Manager
|
space *space.Manager
|
||||||
|
net *network.Watcher
|
||||||
update updateState
|
update updateState
|
||||||
events eventLog
|
events eventLog
|
||||||
kvms kvmProxies
|
kvms kvmProxies
|
||||||
@@ -103,6 +105,11 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
|||||||
s.user = "?"
|
s.user = "?"
|
||||||
}
|
}
|
||||||
s.migrateUsers()
|
s.migrateUsers()
|
||||||
|
// The speed test and the outside view. Its history outlives a restart, and its last result
|
||||||
|
// is published as sensors, so the line is graphable and alertable like anything else.
|
||||||
|
s.net = network.NewWatcher(s.keepResult)
|
||||||
|
s.net.Test.Load(s.loadNetwork())
|
||||||
|
s.sensors.Add(netSensors{s.net})
|
||||||
s.sensors.Add(newStatusSensors(s))
|
s.sensors.Add(newStatusSensors(s))
|
||||||
s.alerts = newAlertEngine(s)
|
s.alerts = newAlertEngine(s)
|
||||||
// The saved dashboard layout carries the sampling interval; apply it on boot.
|
// The saved dashboard layout carries the sampling interval; apply it on boot.
|
||||||
@@ -273,6 +280,10 @@ func (s *Server) routes() *http.ServeMux {
|
|||||||
m.HandleFunc("POST /api/files/delete", s.auth(s.filesDelete))
|
m.HandleFunc("POST /api/files/delete", s.auth(s.filesDelete))
|
||||||
m.HandleFunc("POST /api/files/chmod", s.auth(s.filesChmod))
|
m.HandleFunc("POST /api/files/chmod", s.auth(s.filesChmod))
|
||||||
m.HandleFunc("GET /api/hardware", s.auth(s.hardwareReport))
|
m.HandleFunc("GET /api/hardware", s.auth(s.hardwareReport))
|
||||||
|
m.HandleFunc("GET /api/network", s.auth(s.networkOverview))
|
||||||
|
m.HandleFunc("GET /api/network/speedtest", s.auth(s.speedtestState))
|
||||||
|
m.HandleFunc("POST /api/network/speedtest", s.auth(s.speedtestStart))
|
||||||
|
m.HandleFunc("POST /api/network/speedtest/cancel", s.auth(s.speedtestCancel))
|
||||||
m.HandleFunc("GET /api/storage", s.auth(s.storageOverview))
|
m.HandleFunc("GET /api/storage", s.auth(s.storageOverview))
|
||||||
m.HandleFunc("GET /api/storage/smart/{dev}", s.auth(s.storageSmart))
|
m.HandleFunc("GET /api/storage/smart/{dev}", s.auth(s.storageSmart))
|
||||||
m.HandleFunc("POST /api/storage/smart/{dev}/test/{kind}", s.auth(s.storageSmartTest))
|
m.HandleFunc("POST /api/storage/smart/{dev}/test/{kind}", s.auth(s.storageSmartTest))
|
||||||
|
|||||||
@@ -0,0 +1,360 @@
|
|||||||
|
// Package network is what the Network tab shows: the machine's interfaces as the kernel has
|
||||||
|
// them, how the internet looks from out there, and a speed test that measures the line.
|
||||||
|
//
|
||||||
|
// Everything here is read from /proc and /sys, or measured against Cloudflare's public speed
|
||||||
|
// endpoints — no tool to install, on any of the machines. That matters for the router, where
|
||||||
|
// flash is small and `opkg install` is a decision.
|
||||||
|
package network
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Addr is one address on an interface.
|
||||||
|
type Addr struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Prefix int `json:"prefix"` // network bits, as in /24
|
||||||
|
V6 bool `json:"v6,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iface is one network interface: what it is, whether it is up, and what it has moved.
|
||||||
|
type Iface struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Kind string `json:"kind"` // ethernet | wifi | bridge | tunnel | loopback | virtual
|
||||||
|
MAC string `json:"mac,omitempty"`
|
||||||
|
MTU int `json:"mtu"`
|
||||||
|
// Up is the administrative state, Carrier whether a cable (or an association) is actually
|
||||||
|
// there. Up without Carrier is the usual "it's plugged into nothing".
|
||||||
|
Up bool `json:"up"`
|
||||||
|
Carrier bool `json:"carrier"`
|
||||||
|
// SpeedMbps is what the link negotiated, where the driver says: 0 for wireless, virtual
|
||||||
|
// and tunnel devices, which have no such number.
|
||||||
|
SpeedMbps int `json:"speed_mbps,omitempty"`
|
||||||
|
Duplex string `json:"duplex,omitempty"`
|
||||||
|
Driver string `json:"driver,omitempty"`
|
||||||
|
SignalDbm *float64 `json:"signal_dbm,omitempty"` // wireless only, from /proc/net/wireless
|
||||||
|
Addrs []Addr `json:"addrs"`
|
||||||
|
RxTotal uint64 `json:"rx_total"`
|
||||||
|
TxTotal uint64 `json:"tx_total"`
|
||||||
|
// Default marks the interface the default route leaves by — the one that matters when
|
||||||
|
// the question is about the internet.
|
||||||
|
Default bool `json:"default,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report is the machine's network as it stands.
|
||||||
|
type Report struct {
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
Host string `json:"host,omitempty"`
|
||||||
|
Ifaces []Iface `json:"ifaces"`
|
||||||
|
Gateway string `json:"gateway,omitempty"`
|
||||||
|
GatewayVia string `json:"gateway_via,omitempty"` // the interface it is reached through
|
||||||
|
DNS []string `json:"dns,omitempty"`
|
||||||
|
Search []string `json:"search,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func readStr(path string) string {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
func readInt(path string) int {
|
||||||
|
n, err := strconv.Atoi(readStr(path))
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// kindOf names what sort of device this is, from what the kernel exposes about it rather
|
||||||
|
// than from its name — a bridge is a bridge whatever it is called.
|
||||||
|
func kindOf(name string, flags net.Flags) string {
|
||||||
|
sys := "/sys/class/net/" + name + "/"
|
||||||
|
switch {
|
||||||
|
case flags&net.FlagLoopback != 0:
|
||||||
|
return "loopback"
|
||||||
|
case dirExists(sys + "wireless"), dirExists(sys + "phy80211"):
|
||||||
|
return "wifi"
|
||||||
|
case dirExists(sys + "bridge"):
|
||||||
|
return "bridge"
|
||||||
|
case dirExists(sys + "bonding"):
|
||||||
|
return "bond"
|
||||||
|
}
|
||||||
|
// DEVTYPE says vlan, bridge, wlan… for the devices that have one
|
||||||
|
for _, line := range strings.Split(readStr(sys+"uevent"), "\n") {
|
||||||
|
if v, ok := strings.CutPrefix(line, "DEVTYPE="); ok && v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !dirExists(sys + "device") { // no bus device behind it: veth, tun, wireguard, docker's own
|
||||||
|
return "virtual"
|
||||||
|
}
|
||||||
|
return "ethernet"
|
||||||
|
}
|
||||||
|
|
||||||
|
func dirExists(p string) bool {
|
||||||
|
st, err := os.Stat(p)
|
||||||
|
return err == nil && st.IsDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
// driverOf is the module behind an interface, which is what names the hardware in one word.
|
||||||
|
func driverOf(name string) string {
|
||||||
|
l, err := os.Readlink("/sys/class/net/" + name + "/device/driver")
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return l[strings.LastIndexByte(l, '/')+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// wirelessSignal is the signal level of each wireless interface, in dBm.
|
||||||
|
func wirelessSignal() map[string]float64 {
|
||||||
|
out := map[string]float64{}
|
||||||
|
f, err := os.Open("/proc/net/wireless")
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
for sc.Scan() {
|
||||||
|
name, rest, ok := strings.Cut(sc.Text(), ":")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fs := strings.Fields(rest)
|
||||||
|
if len(fs) < 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if v, err := strconv.ParseFloat(strings.TrimSuffix(fs[2], "."), 64); err == nil {
|
||||||
|
out[strings.TrimSpace(name)] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// counters is every interface's byte totals, from the same file the sampler reads.
|
||||||
|
func counters() map[string][2]uint64 {
|
||||||
|
out := map[string][2]uint64{}
|
||||||
|
f, err := os.Open("/proc/net/dev")
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
for sc.Scan() {
|
||||||
|
name, rest, ok := strings.Cut(sc.Text(), ":")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fs := strings.Fields(rest)
|
||||||
|
if len(fs) < 16 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rx, _ := strconv.ParseUint(fs[0], 10, 64)
|
||||||
|
tx, _ := strconv.ParseUint(fs[8], 10, 64)
|
||||||
|
out[strings.TrimSpace(name)] = [2]uint64{rx, tx}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultRoute is the gateway the machine sends the internet to, and the interface it
|
||||||
|
// leaves by. Read from /proc/net/route, whose addresses are little-endian hex.
|
||||||
|
func defaultRoute() (gw, via string) {
|
||||||
|
f, err := os.Open("/proc/net/route")
|
||||||
|
if err != nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
best := -1
|
||||||
|
for sc.Scan() {
|
||||||
|
fs := strings.Fields(sc.Text())
|
||||||
|
if len(fs) < 8 || fs[0] == "Iface" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if fs[1] != "00000000" { // not a default route
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
metric, _ := strconv.Atoi(fs[6])
|
||||||
|
if best >= 0 && metric >= best {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ip, err := hexIP(fs[2])
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
best, gw, via = metric, ip, fs[0]
|
||||||
|
}
|
||||||
|
return gw, via
|
||||||
|
}
|
||||||
|
|
||||||
|
// hexIP reads one address out of /proc/net/route, where each is printed as a 32-bit word in
|
||||||
|
// the host's byte order: 10.0.0.1 appears as "0100000A" on a little-endian machine. So the
|
||||||
|
// text is parsed as a plain number, then written back out in native order to recover the four
|
||||||
|
// bytes — which is right on either sort of machine, without asking which this is.
|
||||||
|
func hexIP(h string) (string, error) {
|
||||||
|
v, err := strconv.ParseUint(h, 16, 32)
|
||||||
|
if err != nil || len(h) != 8 {
|
||||||
|
return "", fmt.Errorf("not an address: %q", h)
|
||||||
|
}
|
||||||
|
var b [4]byte
|
||||||
|
binary.NativeEndian.PutUint32(b[:], uint32(v))
|
||||||
|
return net.IPv4(b[0], b[1], b[2], b[3]).String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolvers is what /etc/resolv.conf points at.
|
||||||
|
func resolvers() (servers, search []string) {
|
||||||
|
f, err := os.Open("/etc/resolv.conf")
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
for sc.Scan() {
|
||||||
|
fs := strings.Fields(sc.Text())
|
||||||
|
if len(fs) < 2 || strings.HasPrefix(fs[0], "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch fs[0] {
|
||||||
|
case "nameserver":
|
||||||
|
servers = append(servers, fs[1])
|
||||||
|
case "search", "domain":
|
||||||
|
search = append(search, fs[1:]...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return servers, search
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interfaces is every interface the kernel has, with its addresses and totals.
|
||||||
|
func Interfaces() []Iface {
|
||||||
|
list, err := net.Interfaces()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, via := defaultRoute()
|
||||||
|
sig, cnt := wirelessSignal(), counters()
|
||||||
|
out := make([]Iface, 0, len(list))
|
||||||
|
for _, in := range list {
|
||||||
|
sys := "/sys/class/net/" + in.Name + "/"
|
||||||
|
f := Iface{
|
||||||
|
Name: in.Name, Kind: kindOf(in.Name, in.Flags), MTU: in.MTU,
|
||||||
|
MAC: in.HardwareAddr.String(), Up: in.Flags&net.FlagUp != 0,
|
||||||
|
Driver: driverOf(in.Name), Default: in.Name == via, Addrs: []Addr{},
|
||||||
|
}
|
||||||
|
f.Carrier = readStr(sys+"carrier") == "1"
|
||||||
|
if f.Kind == "loopback" {
|
||||||
|
f.Carrier = f.Up
|
||||||
|
}
|
||||||
|
if sp := readInt(sys + "speed"); sp > 0 { // -1 when the driver doesn't know
|
||||||
|
f.SpeedMbps = sp
|
||||||
|
}
|
||||||
|
if d := readStr(sys + "duplex"); d != "" && d != "unknown" {
|
||||||
|
f.Duplex = d
|
||||||
|
}
|
||||||
|
if v, ok := sig[in.Name]; ok {
|
||||||
|
f.SignalDbm = &v
|
||||||
|
}
|
||||||
|
if c, ok := cnt[in.Name]; ok {
|
||||||
|
f.RxTotal, f.TxTotal = c[0], c[1]
|
||||||
|
}
|
||||||
|
addrs, _ := in.Addrs()
|
||||||
|
for _, a := range addrs {
|
||||||
|
n, ok := a.(*net.IPNet)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ones, _ := n.Mask.Size()
|
||||||
|
f.Addrs = append(f.Addrs, Addr{IP: n.IP.String(), Prefix: ones, V6: n.IP.To4() == nil})
|
||||||
|
}
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
// the interface the internet leaves by first, then the ones carrying addresses, then the rest
|
||||||
|
sort.SliceStable(out, func(i, j int) bool {
|
||||||
|
a, b := out[i], out[j]
|
||||||
|
if a.Default != b.Default {
|
||||||
|
return a.Default
|
||||||
|
}
|
||||||
|
ai, bi := len(a.Addrs) > 0 && a.Kind != "loopback", len(b.Addrs) > 0 && b.Kind != "loopback"
|
||||||
|
if ai != bi {
|
||||||
|
return ai
|
||||||
|
}
|
||||||
|
return a.Name < b.Name
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look is the machine's network now. Cheap: a handful of small files.
|
||||||
|
func Look(host string) Report {
|
||||||
|
gw, via := defaultRoute()
|
||||||
|
dns, search := resolvers()
|
||||||
|
return Report{At: time.Now(), Host: host, Ifaces: Interfaces(), Gateway: gw, GatewayVia: via, DNS: dns, Search: search}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- how the internet sees this machine ----
|
||||||
|
|
||||||
|
// Outside is what the far end of the line says about this connection: the address the
|
||||||
|
// internet sees, and where it came ashore. Refreshed on its own clock, because the public
|
||||||
|
// address here is a DHCP lease and moves without warning.
|
||||||
|
type Outside struct {
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
IP string `json:"ip,omitempty"`
|
||||||
|
Colo string `json:"colo,omitempty"` // the Cloudflare edge that answered — "SEA"
|
||||||
|
Country string `json:"country,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watcher keeps the outside view fresh and holds the speed tester.
|
||||||
|
type Watcher struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
out Outside
|
||||||
|
Test *Tester
|
||||||
|
}
|
||||||
|
|
||||||
|
const outsideEvery = 15 * time.Minute
|
||||||
|
|
||||||
|
func NewWatcher(onResult func(Result)) *Watcher {
|
||||||
|
w := &Watcher{Test: NewTester(onResult)}
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
w.refresh()
|
||||||
|
time.Sleep(outsideEvery)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Watcher) refresh() {
|
||||||
|
o := probeOutside()
|
||||||
|
w.mu.Lock()
|
||||||
|
// a failed probe does not throw away a good answer; it is noted beside it
|
||||||
|
if o.IP == "" && w.out.IP != "" {
|
||||||
|
w.out.Error, w.out.At = o.Error, o.At
|
||||||
|
} else {
|
||||||
|
w.out = o
|
||||||
|
}
|
||||||
|
w.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outside is the last known view from outside, asking again if it has gone stale.
|
||||||
|
func (w *Watcher) Outside() Outside {
|
||||||
|
w.mu.RLock()
|
||||||
|
o := w.out
|
||||||
|
w.mu.RUnlock()
|
||||||
|
if time.Since(o.At) > outsideEvery {
|
||||||
|
w.refresh()
|
||||||
|
w.mu.RLock()
|
||||||
|
o = w.out
|
||||||
|
w.mu.RUnlock()
|
||||||
|
}
|
||||||
|
return o
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
package network
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHexIP(t *testing.T) {
|
||||||
|
// /proc/net/route prints each address as a host-order word: on a little-endian machine
|
||||||
|
// 10.0.0.1 is written "0100000A". These cases are that machine, which is all of ours.
|
||||||
|
for hex, want := range map[string]string{
|
||||||
|
"0100000A": "10.0.0.1",
|
||||||
|
"00000000": "0.0.0.0",
|
||||||
|
"0101A8C0": "192.168.1.1",
|
||||||
|
} {
|
||||||
|
got, err := hexIP(hex)
|
||||||
|
if err != nil || got != want {
|
||||||
|
t.Errorf("hexIP(%q) = %q, %v; want %q", hex, got, err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := hexIP("nonsense"); err == nil {
|
||||||
|
t.Error("hexIP accepted nonsense")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerTimeIsNotLatency(t *testing.T) {
|
||||||
|
h := http.Header{}
|
||||||
|
h.Add("Server-Timing", "cfSpeedEdge;dur=7, cfSpeedWorker;dur=36")
|
||||||
|
h.Add("Server-Timing", `cfL4;desc="?proto=TCP&rtt=3505&min_rtt=2856"`)
|
||||||
|
if got := serverMs(h); got != 43 {
|
||||||
|
t.Errorf("serverMs = %v, want 43 (7+36, and nothing from the cfL4 description)", got)
|
||||||
|
}
|
||||||
|
if got := serverMs(http.Header{}); got != 0 {
|
||||||
|
t.Errorf("serverMs with no header = %v, want 0", got)
|
||||||
|
}
|
||||||
|
h2 := http.Header{"Cf-Ray": {"a41f6720f966a36c-SEA"}, "Cf-Meta-Ip": {"50.47.252.221"}}
|
||||||
|
if m := readMeta(h2); m.Colo != "SEA" || m.IP != "50.47.252.221" {
|
||||||
|
t.Errorf("readMeta = %+v, want the edge SEA and the address", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRates(t *testing.T) {
|
||||||
|
// a gibibyte in eight seconds is 1073.7 Mbps
|
||||||
|
if got := mbps(1<<30, 8); math.Abs(got-1073.7) > 0.1 {
|
||||||
|
t.Errorf("mbps(1GiB, 8s) = %v, want ~1073.7", got)
|
||||||
|
}
|
||||||
|
if got := best([]float64{12.5, 9.2, 40, 9.9}); got != 9.2 {
|
||||||
|
t.Errorf("best = %v, want 9.2 (the least of them)", got)
|
||||||
|
}
|
||||||
|
if got := jitter([]float64{10, 10, 10}); got != 0 {
|
||||||
|
t.Errorf("jitter of a steady line = %v, want 0", got)
|
||||||
|
}
|
||||||
|
if got := jitter([]float64{10}); got != 0 {
|
||||||
|
t.Errorf("jitter of one sample = %v, want 0", got)
|
||||||
|
}
|
||||||
|
if got := jitter([]float64{5, 50, 5, 50}); got != 45 {
|
||||||
|
t.Errorf("jitter of a line alternating by 45 = %v, want 45", got)
|
||||||
|
}
|
||||||
|
// one slow probe among steady ones moves it a little, not enormously
|
||||||
|
if got := jitter([]float64{10, 10, 10, 60, 10, 10}); got > 25 {
|
||||||
|
t.Errorf("jitter with a single outlier = %v, want it not to run away", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The body an upload sends is a fixed size, counts itself, and costs nothing to make.
|
||||||
|
func TestZerosBodyCountsItself(t *testing.T) {
|
||||||
|
var n atomic.Int64
|
||||||
|
z := &zeros{left: 100, n: &n}
|
||||||
|
buf := make([]byte, 30)
|
||||||
|
total := 0
|
||||||
|
for {
|
||||||
|
got, err := z.Read(buf)
|
||||||
|
total += got
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if total != 100 || n.Load() != 100 {
|
||||||
|
t.Errorf("read %d bytes and counted %d, want 100 and 100", total, n.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interfaces has to work on whatever machine this runs on, and always name a loopback.
|
||||||
|
func TestInterfacesHasLoopback(t *testing.T) {
|
||||||
|
ifs := Interfaces()
|
||||||
|
if len(ifs) == 0 {
|
||||||
|
t.Fatal("no interfaces at all")
|
||||||
|
}
|
||||||
|
lo := false
|
||||||
|
for _, f := range ifs {
|
||||||
|
if f.Kind == "loopback" {
|
||||||
|
lo = true
|
||||||
|
if !f.Up || len(f.Addrs) == 0 {
|
||||||
|
t.Errorf("loopback %s looks wrong: up=%v addrs=%v", f.Name, f.Up, f.Addrs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f.Name == "" {
|
||||||
|
t.Error("an interface with no name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !lo {
|
||||||
|
t.Error("no loopback among the interfaces")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The real thing, against the real endpoint: skipped by default, because `make test` must
|
||||||
|
// not need the internet. HAMLAB_NET_TEST=1 go test ./internal/network/ -run Live -v
|
||||||
|
func TestLiveSpeedTest(t *testing.T) {
|
||||||
|
if os.Getenv("HAMLAB_NET_TEST") == "" {
|
||||||
|
t.Skip("set HAMLAB_NET_TEST=1 to measure the actual line")
|
||||||
|
}
|
||||||
|
tr := NewTester(nil)
|
||||||
|
if _, err := tr.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := tr.Start(); err == nil {
|
||||||
|
t.Error("a second test started while the first was running")
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(90 * time.Second)
|
||||||
|
for tr.Running() && time.Now().Before(deadline) {
|
||||||
|
time.Sleep(250 * time.Millisecond)
|
||||||
|
}
|
||||||
|
st := tr.State()
|
||||||
|
if st.Result == nil {
|
||||||
|
t.Fatal("no result")
|
||||||
|
}
|
||||||
|
r := *st.Result
|
||||||
|
t.Logf("down %s up %s latency %.1f ms jitter %.1f ms via %s ip %s %.0f MB moved in %.1fs",
|
||||||
|
Mbits(r.DownMbps), Mbits(r.UpMbps), r.LatencyMs, r.JitterMs, r.Colo, r.IP,
|
||||||
|
float64(r.DownBytes+r.UpBytes)/1e6, r.TookSec)
|
||||||
|
if r.Error != "" {
|
||||||
|
t.Fatalf("test failed: %s", r.Error)
|
||||||
|
}
|
||||||
|
if r.DownMbps <= 0 || r.UpMbps <= 0 || r.LatencyMs <= 0 {
|
||||||
|
t.Errorf("a figure came back zero: %+v", r)
|
||||||
|
}
|
||||||
|
if r.IP == "" {
|
||||||
|
t.Error("no public address reported")
|
||||||
|
}
|
||||||
|
if len(tr.History()) != 1 {
|
||||||
|
t.Errorf("history has %d results, want 1", len(tr.History()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A phase measured against a local server, at a rate the test controls. This is the part of
|
||||||
|
// the speed test with arithmetic in it — the window after the ramp, the byte cap that ends it
|
||||||
|
// early, and the rate that comes out — and none of it needs the internet to check.
|
||||||
|
func TestPhaseMeasuresTheWindow(t *testing.T) {
|
||||||
|
const perTick = 1 << 20 // a mebibyte every 5 ms ≈ 1678 Mbps, whatever the machine is doing
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/up" {
|
||||||
|
_, _ = io.Copy(io.Discard, r.Body)
|
||||||
|
w.WriteHeader(204)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
buf := make([]byte, perTick)
|
||||||
|
fl, _ := w.(http.Flusher)
|
||||||
|
for {
|
||||||
|
if _, err := w.Write(buf); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if fl != nil {
|
||||||
|
fl.Flush()
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-time.After(5 * time.Millisecond):
|
||||||
|
case <-r.Context().Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
restore := func(u, up string, w, d, mw time.Duration, m int64) {
|
||||||
|
downURL, upURL, warmUp, downFor, minWindow, measureBytes = u, up, w, d, mw, m
|
||||||
|
}
|
||||||
|
defer restore(downURL, upURL, warmUp, downFor, minWindow, measureBytes)
|
||||||
|
downURL, upURL = srv.URL+"/down?bytes=%d", srv.URL+"/up"
|
||||||
|
warmUp, downFor, minWindow, measureBytes = 100*time.Millisecond, 3*time.Second, 200*time.Millisecond, 8<<20
|
||||||
|
|
||||||
|
tr := NewTester(nil)
|
||||||
|
began := time.Now()
|
||||||
|
mbps, moved, note, err := tr.phase(context.Background(), srv.Client(), "download", 2, downFor, 0, 1)
|
||||||
|
took := time.Since(began)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("phase: %v", err)
|
||||||
|
}
|
||||||
|
if note != "" {
|
||||||
|
t.Errorf("note = %q, want none — nothing went wrong", note)
|
||||||
|
}
|
||||||
|
// The cap must have ended it early rather than the clock: the phase was allowed three
|
||||||
|
// seconds and should be done in well under one, having moved at least the cap.
|
||||||
|
if moved < measureBytes {
|
||||||
|
t.Errorf("moved %d bytes, want at least the %d-byte cap", moved, measureBytes)
|
||||||
|
}
|
||||||
|
if took > time.Second {
|
||||||
|
t.Errorf("phase took %v of the %v it was allowed; the byte cap should have ended it", took.Round(time.Millisecond), downFor)
|
||||||
|
}
|
||||||
|
// The server hands out a mebibyte per 5 ms per stream, so the rate is in the hundreds of
|
||||||
|
// Mbps. The point is that it is a real quotient of what moved over how long, not that it
|
||||||
|
// hits a particular figure on a loaded test machine.
|
||||||
|
if mbps < 50 || mbps > 20000 {
|
||||||
|
t.Errorf("mbps = %v, which is not a believable quotient", mbps)
|
||||||
|
}
|
||||||
|
t.Logf("measured %.0f Mbps over %.1f MiB", mbps, float64(moved)/(1<<20))
|
||||||
|
|
||||||
|
// A phase whose server refuses outright fails, and says what it was told.
|
||||||
|
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(429) }))
|
||||||
|
defer bad.Close()
|
||||||
|
downURL = bad.URL + "/down?bytes=%d"
|
||||||
|
if _, _, _, err := tr.phase(context.Background(), bad.Client(), "download", 2, 300*time.Millisecond, 0, 1); err == nil {
|
||||||
|
t.Error("a phase against a server that refuses everything should fail")
|
||||||
|
} else if !strings.Contains(err.Error(), "rate-limiting") {
|
||||||
|
t.Errorf("error = %q, want it to name the rate limit", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stopping a test mid-flight is reported as cancelled, not as whatever the streams said on
|
||||||
|
// their way out.
|
||||||
|
func TestCancelIsReportedAsCancelled(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
buf := make([]byte, 1<<16)
|
||||||
|
for {
|
||||||
|
if _, err := w.Write(buf); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-time.After(time.Millisecond):
|
||||||
|
case <-r.Context().Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
defer func(u string, w, d time.Duration) { downURL, warmUp, downFor = u, w, d }(downURL, warmUp, downFor)
|
||||||
|
downURL, warmUp, downFor = srv.URL+"/down?bytes=%d", 10*time.Millisecond, 10*time.Second
|
||||||
|
|
||||||
|
tr := NewTester(nil)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
go func() { time.Sleep(200 * time.Millisecond); cancel() }()
|
||||||
|
_, _, _, err := tr.phase(ctx, srv.Client(), "download", 1, downFor, 0, 1)
|
||||||
|
// Cancelled after bytes moved: the figure stands, and the phase does not claim an error.
|
||||||
|
if err != nil && !errors.Is(err, context.Canceled) {
|
||||||
|
t.Errorf("err = %v, want nil or a cancellation", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,648 @@
|
|||||||
|
package network
|
||||||
|
|
||||||
|
// The speed test. It measures against Cloudflare's public speed endpoints, the same ones
|
||||||
|
// speed.cloudflare.com uses: a plain GET of N bytes down, a plain POST of N bytes up, and a
|
||||||
|
// zero-byte GET for the round trip. No account, no API key, nothing to install, and an edge
|
||||||
|
// close to every one of these machines.
|
||||||
|
//
|
||||||
|
// The method, since a number without one means little — it is stated in the UI too:
|
||||||
|
//
|
||||||
|
// - Latency is the round trip of a zero-byte response on a connection that is already open,
|
||||||
|
// less the time the far end says it spent on the request (its Server-Timing header). That
|
||||||
|
// subtraction is the difference between measuring the line and measuring Cloudflare's
|
||||||
|
// thinking time: a zero-byte response is almost entirely the latter — tens of
|
||||||
|
// milliseconds of worker, against a few of actual wire. The figure reported is the best of
|
||||||
|
// several probes; jitter is the average change from one to the next.
|
||||||
|
// - Download and upload each run several streams at once for a fixed few seconds. One
|
||||||
|
// stream measures one TCP connection's congestion window as much as the line, which is
|
||||||
|
// why every speed test ever made uses more than one.
|
||||||
|
// - The first second of each phase is thrown away. That is the ramp: counting it reports a
|
||||||
|
// number nobody's connection ever actually did.
|
||||||
|
// - Each phase is capped in bytes as well as seconds, so a very fast line cannot run away
|
||||||
|
// with the meter.
|
||||||
|
//
|
||||||
|
// The one thing to know about the far end: it rate-limits by volume, and a test moves a few
|
||||||
|
// hundred megabytes. Several in a row will start earning 429s, which is why there is a
|
||||||
|
// cooldown between tests and why a phase that gets refused says so instead of reporting the
|
||||||
|
// decayed figure it would otherwise compute.
|
||||||
|
//
|
||||||
|
// HTTP/2 is deliberately off: over one HTTP/2 connection the streams would share a single
|
||||||
|
// congestion window, which measures the window, not the line.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The endpoints, and the shape of a phase. Variables rather than constants only so the tests
|
||||||
|
// can point them at a local server and shrink the windows; nothing else rebinds them.
|
||||||
|
var (
|
||||||
|
downURL = "https://speed.cloudflare.com/__down?bytes=%d"
|
||||||
|
upURL = "https://speed.cloudflare.com/__up"
|
||||||
|
// traceURL is Cloudflare's standard trace: a few lines of key=value naming the address it
|
||||||
|
// sees, the edge that answered and the country. A few hundred bytes.
|
||||||
|
traceURL = "https://speed.cloudflare.com/cdn-cgi/trace"
|
||||||
|
|
||||||
|
warmUp = time.Second
|
||||||
|
downFor = 4 * time.Second
|
||||||
|
upFor = 4 * time.Second
|
||||||
|
// measureBytes ends a phase early once this much has moved inside the window. A fast line
|
||||||
|
// says what it is in well under the time allowed, and stopping there is what keeps a test
|
||||||
|
// down to a couple of hundred megabytes — which is the difference between the far end
|
||||||
|
// serving this happily and rate-limiting it. A slow line never reaches it and is timed out
|
||||||
|
// by downFor/upFor instead, which is the honest way round.
|
||||||
|
measureBytes = int64(64) << 20
|
||||||
|
// minWindow is how long the window must last whatever the byte cap says. Without it a very
|
||||||
|
// fast line fills the cap in a few hundredths of a second and the phase would be over
|
||||||
|
// before there was anything to divide by.
|
||||||
|
minWindow = time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
latencyProbes = 8
|
||||||
|
downStreams = 4
|
||||||
|
upStreams = 3
|
||||||
|
downChunk = 25 << 20 // bytes per download request; the far end refuses much more
|
||||||
|
upChunk = 25 << 20 // and per upload request
|
||||||
|
// phaseBudget is the runaway guard, counting the ramp too: nothing measures forever.
|
||||||
|
phaseBudget = int64(512) << 20
|
||||||
|
httpTimeout = 90 * time.Second
|
||||||
|
// Cooldown is how long the line is left alone between tests. The far end rate-limits by
|
||||||
|
// volume, and two back-to-back tests are the surest way to be refused by it.
|
||||||
|
Cooldown = 20 * time.Second
|
||||||
|
|
||||||
|
// Method is what the UI tells the reader about the numbers it is showing.
|
||||||
|
Method = "Cloudflare's speed endpoints · 4 streams down, 3 up · a 1 s ramp, then up to 4 s or 64 MiB each way"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Result is one finished speed test.
|
||||||
|
type Result struct {
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
DownMbps float64 `json:"down_mbps"`
|
||||||
|
UpMbps float64 `json:"up_mbps"`
|
||||||
|
LatencyMs float64 `json:"latency_ms"`
|
||||||
|
JitterMs float64 `json:"jitter_ms"`
|
||||||
|
DownBytes int64 `json:"down_bytes"`
|
||||||
|
UpBytes int64 `json:"up_bytes"`
|
||||||
|
TookSec float64 `json:"took_sec"`
|
||||||
|
// Colo is the Cloudflare edge that answered — "SEA" — and IP is this connection's public
|
||||||
|
// address as that edge saw it.
|
||||||
|
Colo string `json:"colo,omitempty"`
|
||||||
|
IP string `json:"ip,omitempty"`
|
||||||
|
// Note is set when the figures are real but something went wrong along the way — a stream
|
||||||
|
// that was refused part-way, most often. The number is what was actually seen; the note is
|
||||||
|
// why it may be low.
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run is a test in progress, or the last one to have finished.
|
||||||
|
type Run struct {
|
||||||
|
Started time.Time `json:"started"`
|
||||||
|
Running bool `json:"running"`
|
||||||
|
// Phase is where it has got to: latency, download, upload, done.
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
// Done is how far through the whole test, 0–1, so one bar can show all of it.
|
||||||
|
Done float64 `json:"done"`
|
||||||
|
// Mbps is the live figure for the phase under way — what the needle shows.
|
||||||
|
Mbps float64 `json:"mbps"`
|
||||||
|
Bytes int64 `json:"bytes"`
|
||||||
|
Result *Result `json:"result,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tester runs one speed test at a time and remembers what the last ones said.
|
||||||
|
type Tester struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
run Run
|
||||||
|
hist []Result
|
||||||
|
// lastTry is when a test was last *started*, which is what the cooldown counts from — a
|
||||||
|
// test that was refused still used the line, and hammering retries at an endpoint that has
|
||||||
|
// just rate-limited us is the one thing guaranteed not to help.
|
||||||
|
lastTry time.Time
|
||||||
|
cancel context.CancelFunc
|
||||||
|
onDone func(Result)
|
||||||
|
maxHist int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTester(onDone func(Result)) *Tester {
|
||||||
|
return &Tester{onDone: onDone, maxHist: 50}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load takes the results kept from before a restart, oldest first.
|
||||||
|
func (t *Tester) Load(rs []Result) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
t.hist = append(t.hist[:0], rs...)
|
||||||
|
if n := len(t.hist); n > 0 {
|
||||||
|
last := t.hist[n-1]
|
||||||
|
t.run = Run{Started: last.At, Phase: "done", Done: 1, Result: &last}
|
||||||
|
t.lastTry = last.At
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tester) State() Run {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
return t.run
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tester) History() []Result {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
return append([]Result{}, t.hist...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tester) Running() bool {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
return t.run.Running
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel stops a test in progress. Reports whether there was one.
|
||||||
|
func (t *Tester) Cancel() bool {
|
||||||
|
t.mu.Lock()
|
||||||
|
c := t.cancel
|
||||||
|
t.mu.Unlock()
|
||||||
|
if c == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
c()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrBusy = errors.New("a speed test is already running")
|
||||||
|
|
||||||
|
// Start begins a test in the background and returns its opening state.
|
||||||
|
func (t *Tester) Start() (Run, error) {
|
||||||
|
t.mu.Lock()
|
||||||
|
if t.run.Running {
|
||||||
|
r := t.run
|
||||||
|
t.mu.Unlock()
|
||||||
|
return r, ErrBusy
|
||||||
|
}
|
||||||
|
if since := time.Since(t.lastTry); since < Cooldown && !t.lastTry.IsZero() {
|
||||||
|
r := t.run
|
||||||
|
t.mu.Unlock()
|
||||||
|
return r, fmt.Errorf("give the line %d more seconds — testing back to back is what gets this connection rate-limited", int((Cooldown-since).Seconds())+1)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
|
t.cancel = cancel
|
||||||
|
t.lastTry = time.Now()
|
||||||
|
t.run = Run{Started: t.lastTry, Running: true, Phase: "latency"}
|
||||||
|
r := t.run
|
||||||
|
t.mu.Unlock()
|
||||||
|
go t.measure(ctx, cancel)
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tester) set(fn func(*Run)) {
|
||||||
|
t.mu.Lock()
|
||||||
|
fn(&t.run)
|
||||||
|
t.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tester) measure(ctx context.Context, cancel context.CancelFunc) {
|
||||||
|
defer cancel()
|
||||||
|
started := time.Now()
|
||||||
|
res := Result{At: started}
|
||||||
|
|
||||||
|
tr := newTransport(downStreams)
|
||||||
|
defer tr.CloseIdleConnections()
|
||||||
|
client := &http.Client{Transport: tr, Timeout: httpTimeout}
|
||||||
|
|
||||||
|
// latency, and the meta the same responses carry
|
||||||
|
lat, meta, err := t.latency(ctx, client)
|
||||||
|
if err == nil {
|
||||||
|
res.LatencyMs, res.JitterMs = best(lat), jitter(lat)
|
||||||
|
res.Colo, res.IP = meta.Colo, meta.IP
|
||||||
|
}
|
||||||
|
var note string
|
||||||
|
if err == nil {
|
||||||
|
t.set(func(r *Run) { r.Phase, r.Done, r.Mbps = "download", 0.1, 0 })
|
||||||
|
res.DownMbps, res.DownBytes, note, err = t.phase(ctx, client, "download", downStreams, downFor, 0.1, 0.6)
|
||||||
|
res.Note = join(res.Note, note)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
t.set(func(r *Run) { r.Phase, r.Done, r.Mbps = "upload", 0.7, 0 })
|
||||||
|
res.UpMbps, res.UpBytes, note, err = t.phase(ctx, client, "upload", upStreams, upFor, 0.7, 1.0)
|
||||||
|
res.Note = join(res.Note, note)
|
||||||
|
}
|
||||||
|
res.TookSec = round(time.Since(started).Seconds(), 1)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
res.Error = "cancelled"
|
||||||
|
} else {
|
||||||
|
res.Error = err.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.mu.Lock()
|
||||||
|
t.cancel = nil
|
||||||
|
t.run = Run{Started: started, Phase: "done", Done: 1, Result: &res, Bytes: res.DownBytes + res.UpBytes}
|
||||||
|
if res.Error == "" || res.DownMbps > 0 { // a cancelled test that got a number is still a reading
|
||||||
|
t.hist = append(t.hist, res)
|
||||||
|
if len(t.hist) > t.maxHist {
|
||||||
|
t.hist = t.hist[len(t.hist)-t.maxHist:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
done, out := t.onDone, res
|
||||||
|
t.mu.Unlock()
|
||||||
|
if done != nil {
|
||||||
|
done(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// meta is what the edge says about the connection. The address comes from a header of the
|
||||||
|
// speed endpoint's own; the edge's name is the tail of the ray ID, which every Cloudflare
|
||||||
|
// response carries ("a41f6720f966a36c-SEA").
|
||||||
|
type meta struct{ IP, Colo string }
|
||||||
|
|
||||||
|
func readMeta(h http.Header) meta {
|
||||||
|
m := meta{IP: h.Get("cf-meta-ip")}
|
||||||
|
if ray := h.Get("cf-ray"); ray != "" {
|
||||||
|
if i := strings.LastIndexByte(ray, '-'); i >= 0 {
|
||||||
|
m.Colo = ray[i+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// serverMs is how long the far end says it spent on this request, from Server-Timing. The
|
||||||
|
// cfL4 entry carries no dur= and contributes nothing, which is what we want — it is a
|
||||||
|
// description of the connection, not time spent.
|
||||||
|
func serverMs(h http.Header) float64 {
|
||||||
|
var total float64
|
||||||
|
for _, v := range h.Values("Server-Timing") {
|
||||||
|
for _, part := range strings.Split(v, ",") {
|
||||||
|
_, after, ok := strings.Cut(part, "dur=")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
end := 0
|
||||||
|
for end < len(after) && (after[end] == '.' || (after[end] >= '0' && after[end] <= '9')) {
|
||||||
|
end++
|
||||||
|
}
|
||||||
|
if f, err := strconv.ParseFloat(after[:end], 64); err == nil {
|
||||||
|
total += f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// latency times several zero-byte responses on an already-open connection.
|
||||||
|
func (t *Tester) latency(ctx context.Context, client *http.Client) ([]float64, meta, error) {
|
||||||
|
var m meta
|
||||||
|
var out []float64
|
||||||
|
for i := 0; i <= latencyProbes; i++ { // the first is the handshake, and is not counted
|
||||||
|
start := time.Now()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf(downURL, 0), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, m, err
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, m, err
|
||||||
|
}
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, m, fmt.Errorf("speed endpoint answered %s", resp.Status)
|
||||||
|
}
|
||||||
|
if i == 0 {
|
||||||
|
m = readMeta(resp.Header)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rtt := float64(time.Since(start).Microseconds())/1000 - serverMs(resp.Header)
|
||||||
|
if rtt < 0.01 { // the far end claimed more time than the whole request took
|
||||||
|
rtt = 0.01
|
||||||
|
}
|
||||||
|
out = append(out, rtt)
|
||||||
|
t.set(func(r *Run) { r.Done = 0.02 + 0.08*float64(i)/latencyProbes })
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, m, errors.New("no reply from the speed endpoint")
|
||||||
|
}
|
||||||
|
return out, m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// phase pushes traffic with several streams for a fixed spell and reports the rate over the
|
||||||
|
// window after the ramp. from and to are where this phase sits in the whole test's progress.
|
||||||
|
//
|
||||||
|
// The window ends when the traffic does, not when the clock runs out: if the far end starts
|
||||||
|
// refusing part-way, the figure is what was actually moved while it was moving, and the note
|
||||||
|
// says what happened. Coasting to the end of the clock with no bytes arriving would report a
|
||||||
|
// number a third of the truth.
|
||||||
|
func (t *Tester) phase(parent context.Context, client *http.Client, dir string, streams int, forHow time.Duration, from, to float64) (float64, int64, string, error) {
|
||||||
|
// parent is the test's own context, which is cancelled when somebody presses Stop. ctx is
|
||||||
|
// this phase's, which *this function* cancels to shut the streams down — so the two are
|
||||||
|
// kept apart: asking ctx whether the test was cancelled would always say yes by then, and
|
||||||
|
// every failure would be reported as "context canceled" whatever really happened.
|
||||||
|
ctx, stop := context.WithCancel(parent)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
var moved atomic.Int64
|
||||||
|
var firstErr error
|
||||||
|
var errOnce sync.Once
|
||||||
|
var alive atomic.Int32
|
||||||
|
alive.Store(int32(streams))
|
||||||
|
dead := make(chan struct{})
|
||||||
|
var deadOnce sync.Once
|
||||||
|
|
||||||
|
for i := 0; i < streams; i++ {
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if alive.Add(-1) == 0 {
|
||||||
|
deadOnce.Do(func() { close(dead) })
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
tries := 0
|
||||||
|
for ctx.Err() == nil && moved.Load() < phaseBudget {
|
||||||
|
var err error
|
||||||
|
if dir == "download" {
|
||||||
|
err = stream(ctx, client, &moved)
|
||||||
|
} else {
|
||||||
|
err = upstream(ctx, client, &moved)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
tries = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
errOnce.Do(func() { firstErr = err })
|
||||||
|
if tries++; tries > 2 { // refused three times: this stream is finished
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-time.After(400 * time.Millisecond):
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// the ramp, measured and thrown away
|
||||||
|
select {
|
||||||
|
case <-time.After(warmUp):
|
||||||
|
case <-dead:
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
mark, at := moved.Load(), time.Now()
|
||||||
|
last, lastAt := mark, at // the last time bytes were still arriving
|
||||||
|
deadline := at.Add(forHow)
|
||||||
|
tick := time.NewTicker(200 * time.Millisecond)
|
||||||
|
defer tick.Stop()
|
||||||
|
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
select {
|
||||||
|
case <-tick.C:
|
||||||
|
case <-dead:
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
if parent.Err() != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
now := moved.Load()
|
||||||
|
if now > last {
|
||||||
|
last, lastAt = now, time.Now()
|
||||||
|
}
|
||||||
|
// enough has moved to know, and it has been long enough to mean it
|
||||||
|
if (now-mark >= measureBytes && time.Since(at) >= minWindow) || now >= phaseBudget {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if secs := time.Since(at).Seconds(); secs > 0 {
|
||||||
|
live := mbps(now-mark, secs)
|
||||||
|
part := math.Min(1, secs/forHow.Seconds())
|
||||||
|
t.set(func(r *Run) { r.Mbps, r.Bytes, r.Done = live, now, from+(to-from)*part })
|
||||||
|
}
|
||||||
|
// every stream has given up and nothing more is coming
|
||||||
|
if alive.Load() == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
total, secs := last-mark, lastAt.Sub(at).Seconds()
|
||||||
|
stop()
|
||||||
|
|
||||||
|
if total <= 0 {
|
||||||
|
switch {
|
||||||
|
case firstErr != nil: // what the far end said explains it better than anything else
|
||||||
|
return 0, moved.Load(), "", firstErr
|
||||||
|
case parent.Err() != nil:
|
||||||
|
return 0, moved.Load(), "", parent.Err()
|
||||||
|
}
|
||||||
|
return 0, moved.Load(), "", fmt.Errorf("nothing moved in the %s phase", dir)
|
||||||
|
}
|
||||||
|
if secs < 0.1 { // too short a window to divide by
|
||||||
|
return 0, moved.Load(), "", fmt.Errorf("the %s phase was over before it could be measured", dir)
|
||||||
|
}
|
||||||
|
note := ""
|
||||||
|
if firstErr != nil {
|
||||||
|
note = fmt.Sprintf("the %s phase was cut short: %s", dir, firstErr)
|
||||||
|
}
|
||||||
|
return mbps(total, secs), moved.Load(), note, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// join puts two notes together without leaving an empty one dangling.
|
||||||
|
func join(a, b string) string {
|
||||||
|
switch {
|
||||||
|
case a == "":
|
||||||
|
return b
|
||||||
|
case b == "":
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return a + "; " + b
|
||||||
|
}
|
||||||
|
|
||||||
|
// stream fetches one chunk, counting bytes as they arrive.
|
||||||
|
func stream(ctx context.Context, client *http.Client, n *atomic.Int64) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf(downURL, downChunk), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return answered(resp.StatusCode, resp.Status)
|
||||||
|
}
|
||||||
|
buf := make([]byte, 64<<10)
|
||||||
|
for {
|
||||||
|
got, err := resp.Body.Read(buf)
|
||||||
|
if got > 0 {
|
||||||
|
n.Add(int64(got))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if err == io.EOF {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// upstream posts one chunk of nothing in particular, counting bytes as they go out.
|
||||||
|
func upstream(ctx context.Context, client *http.Client, n *atomic.Int64) error {
|
||||||
|
body := &zeros{left: upChunk, n: n}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", upURL, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.ContentLength = upChunk
|
||||||
|
req.Header.Set("Content-Type", "application/octet-stream")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != 200 && resp.StatusCode != 204 {
|
||||||
|
return answered(resp.StatusCode, resp.Status)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// answered turns the far end's refusal into something worth reading. It rate-limits by
|
||||||
|
// volume: enough testing in a short while and it stops serving the big transfers, which is
|
||||||
|
// the only failure of this test anyone here is likely to meet.
|
||||||
|
func answered(code int, status string) error {
|
||||||
|
if code == 429 || code == 403 {
|
||||||
|
return errors.New("the speed endpoint is rate-limiting this connection — it does that after a lot of testing; leave it a few minutes")
|
||||||
|
}
|
||||||
|
return fmt.Errorf("the speed endpoint answered %s", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// zeros is a request body of a fixed size that costs nothing to produce, counting itself as
|
||||||
|
// it is read out onto the wire.
|
||||||
|
type zeros struct {
|
||||||
|
left int
|
||||||
|
n *atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (z *zeros) Read(p []byte) (int, error) {
|
||||||
|
if z.left <= 0 {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
if len(p) > z.left {
|
||||||
|
p = p[:z.left]
|
||||||
|
}
|
||||||
|
for i := range p {
|
||||||
|
p[i] = 0
|
||||||
|
}
|
||||||
|
z.left -= len(p)
|
||||||
|
z.n.Add(int64(len(p)))
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTransport(streams int) *http.Transport {
|
||||||
|
return &http.Transport{
|
||||||
|
Proxy: http.ProxyFromEnvironment,
|
||||||
|
DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext,
|
||||||
|
TLSHandshakeTimeout: 10 * time.Second,
|
||||||
|
MaxIdleConns: streams * 2,
|
||||||
|
MaxIdleConnsPerHost: streams * 2,
|
||||||
|
// An empty map turns HTTP/2 off: see the note at the top of this file.
|
||||||
|
TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeOutside asks the far end who it thinks we are. One small request.
|
||||||
|
func probeOutside() Outside {
|
||||||
|
tr := newTransport(1)
|
||||||
|
defer tr.CloseIdleConnections()
|
||||||
|
client := &http.Client{Transport: tr, Timeout: 15 * time.Second}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", traceURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return Outside{At: time.Now(), Error: err.Error()}
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return Outside{At: time.Now(), Error: shortErr(err)}
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return Outside{At: time.Now(), Error: "the trace endpoint answered " + resp.Status}
|
||||||
|
}
|
||||||
|
o := Outside{At: time.Now()}
|
||||||
|
for _, line := range strings.Split(string(body), "\n") {
|
||||||
|
k, v, ok := strings.Cut(line, "=")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch k {
|
||||||
|
case "ip":
|
||||||
|
o.IP = v
|
||||||
|
case "colo":
|
||||||
|
o.Colo = v
|
||||||
|
case "loc":
|
||||||
|
o.Country = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if o.IP == "" {
|
||||||
|
o.Error = "the trace endpoint said nothing about this connection"
|
||||||
|
}
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
// shortErr keeps the part of a network error a person can act on.
|
||||||
|
func shortErr(err error) string {
|
||||||
|
s := err.Error()
|
||||||
|
if i := strings.LastIndex(s, ": "); i > 0 && len(s)-i < 60 {
|
||||||
|
return s[i+2:]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func mbps(bytes int64, secs float64) float64 {
|
||||||
|
return round(float64(bytes)*8/secs/1e6, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func best(v []float64) float64 {
|
||||||
|
m := v[0]
|
||||||
|
for _, x := range v {
|
||||||
|
if x < m {
|
||||||
|
m = x
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return round(m, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// jitter is how much the line wobbles: the average change from one round trip to the next,
|
||||||
|
// which is the usual definition and, unlike a standard deviation, is not run away with by a
|
||||||
|
// single slow probe.
|
||||||
|
func jitter(v []float64) float64 {
|
||||||
|
if len(v) < 2 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
var sum float64
|
||||||
|
for i := 1; i < len(v); i++ {
|
||||||
|
sum += math.Abs(v[i] - v[i-1])
|
||||||
|
}
|
||||||
|
return round(sum/float64(len(v)-1), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func round(v float64, places int) float64 {
|
||||||
|
p := math.Pow(10, float64(places))
|
||||||
|
return math.Round(v*p) / p
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mbits renders a rate the way the line is sold, for a log line.
|
||||||
|
func Mbits(v float64) string { return strconv.FormatFloat(v, 'f', 1, 64) + " Mbps" }
|
||||||
@@ -64,6 +64,7 @@ type State struct {
|
|||||||
Alerts json.RawMessage `json:"alerts,omitempty"` // rules, channels and recent events (internal/api/alerts.go)
|
Alerts json.RawMessage `json:"alerts,omitempty"` // rules, channels and recent events (internal/api/alerts.go)
|
||||||
ZFS json.RawMessage `json:"zfs,omitempty"` // snapshot schedules (internal/api/zfs.go)
|
ZFS json.RawMessage `json:"zfs,omitempty"` // snapshot schedules (internal/api/zfs.go)
|
||||||
DockerJobs json.RawMessage `json:"docker_jobs,omitempty"` // image-update schedules (internal/api/dockersched.go)
|
DockerJobs json.RawMessage `json:"docker_jobs,omitempty"` // image-update schedules (internal/api/dockersched.go)
|
||||||
|
Network json.RawMessage `json:"network,omitempty"` // speed test results (internal/api/network.go)
|
||||||
// Mounts remembers where a container's bind mounts pointed, so a stack deployed by an
|
// Mounts remembers where a container's bind mounts pointed, so a stack deployed by an
|
||||||
// orchestrator that has since been removed can still be found on disk. See
|
// orchestrator that has since been removed can still be found on disk. See
|
||||||
// internal/api/stacks.go.
|
// internal/api/stacks.go.
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
// Network: how this machine reaches the internet, and how fast the line actually is.
|
||||||
|
//
|
||||||
|
// The speed test runs on the machine in the switcher, not in this browser — so it measures
|
||||||
|
// that machine's line, and it keeps running if the phone is locked or the tab is closed. What
|
||||||
|
// is drawn here is what the daemon reports about a test it is running itself.
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { api, Iface, NetworkInfo, SpeedResult, SpeedRun } from './api'
|
||||||
|
import { Sensor, fmtValue, useSensorStream } from './sensors'
|
||||||
|
import { fmtBytes } from './fmt'
|
||||||
|
import { Btn, Loading, toast } from './ui'
|
||||||
|
import { useRefreshMs } from './prefs'
|
||||||
|
import Sparkline from './Sparkline'
|
||||||
|
|
||||||
|
// Docker's per-network bridges, and the veth ends inside them, are real interfaces and
|
||||||
|
// nobody wants to read nineteen of them. They fold away unless asked for.
|
||||||
|
const scenery = (f: Iface) =>
|
||||||
|
!f.default && (/^br-[0-9a-f]{12}$/.test(f.name) || /^(veth|docker)/.test(f.name) || f.kind === 'loopback')
|
||||||
|
|
||||||
|
const KIND: Record<string, string> = { ethernet: 'wired', wifi: 'Wi-Fi', bridge: 'bridge', bond: 'bond', loopback: 'loopback', virtual: 'virtual', vlan: 'VLAN', wlan: 'Wi-Fi' }
|
||||||
|
|
||||||
|
// A round number of megabits, which is how a line is sold and how it should be read.
|
||||||
|
const mbps = (v: number) => (v >= 100 ? v.toFixed(0) : v.toFixed(1))
|
||||||
|
const ago = (iso: string) => {
|
||||||
|
const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000)
|
||||||
|
if (s < 60) return 'just now'
|
||||||
|
if (s < 3600) return `${Math.floor(s / 60)} min ago`
|
||||||
|
if (s < 86400) return `${Math.floor(s / 3600)} h ago`
|
||||||
|
return `${Math.floor(s / 86400)} d ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
// The four figures a speed test produces, big enough to read from across the room.
|
||||||
|
function Figures({ r, live }: { r?: SpeedResult; live?: SpeedRun }) {
|
||||||
|
const running = !!live?.running
|
||||||
|
const phase = live?.phase
|
||||||
|
const downNow = running && phase === 'download' ? live!.mbps : undefined
|
||||||
|
const upNow = running && phase === 'upload' ? live!.mbps : undefined
|
||||||
|
return (
|
||||||
|
<div className="speedgrid">
|
||||||
|
<Figure label="Download" unit="Mbps" value={downNow ?? r?.down_mbps} live={downNow !== undefined} waiting={running && phase === 'latency'} />
|
||||||
|
<Figure label="Upload" unit="Mbps" value={upNow ?? r?.up_mbps} live={upNow !== undefined} waiting={running && phase !== 'upload'} />
|
||||||
|
<Figure label="Latency" unit="ms" value={r?.latency_ms} waiting={running && phase === 'latency'} />
|
||||||
|
<Figure label="Jitter" unit="ms" value={r?.jitter_ms} waiting={running && phase === 'latency'} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Figure({ label, unit, value, live, waiting }: { label: string; unit: string; value?: number; live?: boolean; waiting?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className={'speedfig' + (live ? ' live' : '')}>
|
||||||
|
<div className="label">{label}</div>
|
||||||
|
<div className="big">{value === undefined || value === null ? (waiting ? '·' : '—') : mbps(value)}<i>{unit}</i></div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Network() {
|
||||||
|
const [d, setD] = useState<NetworkInfo | null>(null)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [all, setAll] = useState(false)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const rate = useRefreshMs()
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try { setD(await api.network()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||||
|
}, [])
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
const run = d?.run
|
||||||
|
const running = !!run?.running
|
||||||
|
// While a test is running the state is polled hard, because the needle should move; at rest
|
||||||
|
// the page is a read and asks again only now and then.
|
||||||
|
useEffect(() => {
|
||||||
|
const every = running ? Math.max(400, Math.min(rate, 1000)) : Math.max(5000, rate)
|
||||||
|
const t = setInterval(() => {
|
||||||
|
if (document.visibilityState !== 'visible') return
|
||||||
|
if (running) api.speedtest().then((r) => setD((old) => (old ? { ...old, run: r } : old))).catch(() => {})
|
||||||
|
else load()
|
||||||
|
}, every)
|
||||||
|
return () => clearInterval(t)
|
||||||
|
}, [running, rate, load])
|
||||||
|
// The run that just ended brings a result and a history entry with it.
|
||||||
|
const phase = run?.phase
|
||||||
|
useEffect(() => { if (phase === 'done') load() }, [phase, load])
|
||||||
|
|
||||||
|
const live = useSensorStream<{ n: Record<string, Sensor> }>({ n: 'net.*' }, { everyMs: Math.max(1000, rate) })
|
||||||
|
const sens = live.data?.n ?? {}
|
||||||
|
const rateOf = (name: string, dir: 'rx' | 'tx') => sens[`net.${name.toLowerCase().replace(/[^a-z0-9_-]+/g, '_')}.${dir}`]?.value
|
||||||
|
|
||||||
|
const ifaces = d?.report.ifaces ?? []
|
||||||
|
const shown = all ? ifaces : ifaces.filter((f) => !scenery(f))
|
||||||
|
const hidden = ifaces.length - shown.length
|
||||||
|
const hist = useMemo(() => (d?.history ?? []).filter((r) => !r.error), [d])
|
||||||
|
const last = run?.result && !run.result.error ? run.result : hist[hist.length - 1]
|
||||||
|
|
||||||
|
const start = async () => {
|
||||||
|
setBusy(true)
|
||||||
|
try { const r = await api.startSpeedtest(); setD((old) => (old ? { ...old, run: r } : old)) }
|
||||||
|
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||||
|
finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
const cancel = async () => {
|
||||||
|
try { await api.cancelSpeedtest(); load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!d) return <div className="dash">{err ? <div className="notice">{err === 'not found' ? "This machine's Ham Lab is older than the Network tab. Update it from Machines." : err}</div> : <Loading label="Reading the network…" />}</div>
|
||||||
|
const o = d.outside
|
||||||
|
const failed = run?.result?.error
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dash net">
|
||||||
|
<div className="dash-bar"><h2 className="section">Internet speed</h2><span className="spacer" />
|
||||||
|
<span className="muted small">{last ? `measured ${ago(last.at)}` : 'not measured yet'}</span></div>
|
||||||
|
<section className="card speedtest">
|
||||||
|
<Figures r={last} live={run} />
|
||||||
|
{running && (
|
||||||
|
<div className="speedbar">
|
||||||
|
<div className="meter"><div className="fill" style={{ width: `${Math.round((run!.done || 0) * 100)}%` }} /></div>
|
||||||
|
<span className="muted small">{run!.phase === 'latency' ? 'measuring the round trip' : run!.phase === 'download' ? 'pulling down' : 'pushing up'} · {fmtBytes(run!.bytes || 0)} moved</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="svc-actions">
|
||||||
|
{running
|
||||||
|
? <><Btn className="btn danger" onClick={cancel}>Stop</Btn><span className="muted small">running on {d.report.host} — it carries on if you close this</span></>
|
||||||
|
: <><Btn className="btn on" busy={busy} onClick={start}>Run speed test</Btn>
|
||||||
|
<span className="muted small">{last ? `${fmtBytes((last.down_bytes ?? 0) + (last.up_bytes ?? 0))} moved in ${last.took_sec}s${last.colo ? ` via ${last.colo}` : ''}` : 'about 15 seconds'}</span></>}
|
||||||
|
</div>
|
||||||
|
{failed && <div className="error">{failed === 'cancelled' ? 'Stopped.' : failed}</div>}
|
||||||
|
{!failed && last?.note && <div className="notice">{last.note}</div>}
|
||||||
|
<div className="muted small method">{d.method}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{hist.length > 1 && (
|
||||||
|
<>
|
||||||
|
<div className="dash-bar"><h2 className="section">Past tests</h2><span className="spacer" /><span className="muted small">{hist.length} kept</span></div>
|
||||||
|
<section className="card">
|
||||||
|
<Sparkline height={60} format={(v) => `${mbps(v)} Mbps`}
|
||||||
|
series={[{ name: 'down', values: hist.map((r) => r.down_mbps), color: 'var(--s1)' },
|
||||||
|
{ name: 'up', values: hist.map((r) => r.up_mbps), color: 'var(--s2)' }]} />
|
||||||
|
<div className="props" style={{ marginTop: 8 }}>
|
||||||
|
{[...hist].reverse().slice(0, 8).map((r) => (
|
||||||
|
<div key={r.at}><span className="k">{new Date(r.at).toLocaleString()}</span>
|
||||||
|
<span className="mono">↓ {mbps(r.down_mbps)} · ↑ {mbps(r.up_mbps)} Mbps · {r.latency_ms} ms</span></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="dash-bar"><h2 className="section">Out there</h2></div>
|
||||||
|
<section className="card">
|
||||||
|
<div className="props">
|
||||||
|
<div><span className="k">Public address</span><span className="mono">{o.ip || '—'}{o.error ? ` · ${o.error}` : ''}</span></div>
|
||||||
|
{o.colo && <div><span className="k">Reached via</span><span>{o.colo}{o.country ? ` · ${o.country}` : ''}</span></div>}
|
||||||
|
{d.report.gateway && <div><span className="k">Gateway</span><span className="mono">{d.report.gateway}{d.report.gateway_via ? ` via ${d.report.gateway_via}` : ''}</span></div>}
|
||||||
|
{!!d.report.dns?.length && <div><span className="k">DNS</span><span className="mono">{d.report.dns.join(', ')}</span></div>}
|
||||||
|
{!!d.report.search?.length && <div><span className="k">Search</span><span className="mono">{d.report.search.join(' ')}</span></div>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="dash-bar"><h2 className="section">Interfaces</h2><span className="spacer" />
|
||||||
|
{hidden > 0 && <button className="link" onClick={() => setAll(true)}>show {hidden} more</button>}
|
||||||
|
{all && <button className="link" onClick={() => setAll(false)}>hide the scenery</button>}</div>
|
||||||
|
{shown.map((f) => {
|
||||||
|
const rx = rateOf(f.name, 'rx'), tx = rateOf(f.name, 'tx')
|
||||||
|
return (
|
||||||
|
<section className="card iface" key={f.name}>
|
||||||
|
<div className="sub-head">
|
||||||
|
<span className="dname mono">{f.name}</span>
|
||||||
|
{f.default && <span className="badge ok" title="the default route leaves by this one">internet</span>}
|
||||||
|
<span className="badge">{KIND[f.kind] ?? f.kind}</span>
|
||||||
|
{!f.up ? <span className="badge bad">down</span> : !f.carrier && <span className="badge bad">no link</span>}
|
||||||
|
<span className="spacer" />
|
||||||
|
<span className="muted small mono">{typeof rx === 'number' ? `↓ ${fmtValue(rx, 'B/s')}` : ''} {typeof tx === 'number' ? `↑ ${fmtValue(tx, 'B/s')}` : ''}</span>
|
||||||
|
</div>
|
||||||
|
<div className="props">
|
||||||
|
{f.addrs.length > 0 && <div><span className="k">Address</span><span className="mono">{f.addrs.filter((a) => !a.v6).map((a) => `${a.ip}/${a.prefix}`).join(', ') || '—'}</span></div>}
|
||||||
|
{f.addrs.some((a) => a.v6) && <div><span className="k">IPv6</span><span className="mono" title={f.addrs.filter((a) => a.v6).map((a) => `${a.ip}/${a.prefix}`).join('\n')}>{f.addrs.filter((a) => a.v6).length} addresses</span></div>}
|
||||||
|
{!!f.speed_mbps && <div><span className="k">Link</span><span>{f.speed_mbps >= 1000 ? `${f.speed_mbps / 1000} Gbps` : `${f.speed_mbps} Mbps`}{f.duplex ? ` ${f.duplex} duplex` : ''}</span></div>}
|
||||||
|
{f.signal_dbm !== undefined && <div><span className="k">Signal</span><span>{f.signal_dbm} dBm</span></div>}
|
||||||
|
{f.mac && <div><span className="k">MAC</span><span className="mono">{f.mac}</span></div>}
|
||||||
|
<div><span className="k">MTU</span><span>{f.mtu}{f.driver ? ` · ${f.driver}` : ''}</span></div>
|
||||||
|
<div><span className="k">Moved</span><span className="mono">↓ {fmtBytes(f.rx_total)} · ↑ {fmtBytes(f.tx_total)}</span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+12
-3
@@ -12,6 +12,7 @@ import Jobs from './Jobs'
|
|||||||
import Updates from './Updates'
|
import Updates from './Updates'
|
||||||
import Disks from './Disks'
|
import Disks from './Disks'
|
||||||
import Hardware from './Hardware'
|
import Hardware from './Hardware'
|
||||||
|
import Network from './Network'
|
||||||
import ZFS from './ZFS'
|
import ZFS from './ZFS'
|
||||||
import KVM from './KVM'
|
import KVM from './KVM'
|
||||||
import Files from './Files'
|
import Files from './Files'
|
||||||
@@ -53,6 +54,12 @@ const BellIcon = (
|
|||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const GlobeIcon = (
|
||||||
|
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" aria-hidden="true">
|
||||||
|
<circle cx="8" cy="8" r="6.2" /><path d="M1.9 8h12.2M8 1.8c1.7 1.8 2.6 3.9 2.6 6.2S9.7 12.4 8 14.2C6.3 12.4 5.4 10.3 5.4 8S6.3 3.6 8 1.8z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
const GROUPS: Group[] = [
|
const GROUPS: Group[] = [
|
||||||
{ id: 'procs', label: 'Processes', icon: '▚' },
|
{ id: 'procs', label: 'Processes', icon: '▚' },
|
||||||
{ id: 'terminal', label: 'Terminal', icon: '>_' },
|
{ id: 'terminal', label: 'Terminal', icon: '>_' },
|
||||||
@@ -64,6 +71,7 @@ const GROUPS: Group[] = [
|
|||||||
{ id: 'zfs', label: 'ZFS', icon: '⛁' },
|
{ id: 'zfs', label: 'ZFS', icon: '⛁' },
|
||||||
{ id: 'docker', label: 'Docker', icon: DockerIcon },
|
{ id: 'docker', label: 'Docker', icon: DockerIcon },
|
||||||
{ id: 'kvm', label: 'KVM', icon: '⌨' },
|
{ id: 'kvm', label: 'KVM', icon: '⌨' },
|
||||||
|
{ id: 'network', label: 'Network', icon: GlobeIcon },
|
||||||
{ id: 'hardware', label: 'Hardware', icon: '⬡' },
|
{ id: 'hardware', label: 'Hardware', icon: '⬡' },
|
||||||
{ id: 'updates', label: 'Updates', icon: '⇪' },
|
{ id: 'updates', label: 'Updates', icon: '⇪' },
|
||||||
{ id: 'alerts', label: 'Alerts', icon: BellIcon },
|
{ id: 'alerts', label: 'Alerts', icon: BellIcon },
|
||||||
@@ -73,8 +81,8 @@ const GROUPS: Group[] = [
|
|||||||
{ id: 'settings', label: 'Settings', icon: '⚙' },
|
{ id: 'settings', label: 'Settings', icon: '⚙' },
|
||||||
]
|
]
|
||||||
|
|
||||||
type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'hardware' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'kvm' | 'users' | 'settings'
|
type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'hardware' | 'network' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'kvm' | 'users' | 'settings'
|
||||||
const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'hardware', 'zfs', 'usage', 'files', 'procs', 'machines', 'kvm', 'users', 'settings']
|
const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'hardware', 'network', 'zfs', 'usage', 'files', 'procs', 'machines', 'kvm', 'users', 'settings']
|
||||||
const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts']
|
const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts']
|
||||||
|
|
||||||
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and
|
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and
|
||||||
@@ -304,6 +312,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
|||||||
{view === 'updates' && <Updates />}
|
{view === 'updates' && <Updates />}
|
||||||
{view === 'disks' && <Disks />}
|
{view === 'disks' && <Disks />}
|
||||||
{view === 'hardware' && <Hardware />}
|
{view === 'hardware' && <Hardware />}
|
||||||
|
{view === 'network' && <Network />}
|
||||||
{view === 'zfs' && <ZFS />}
|
{view === 'zfs' && <ZFS />}
|
||||||
{view === 'files' && <Files onOpenInTerminal={runInTerminal} />}
|
{view === 'files' && <Files onOpenInTerminal={runInTerminal} />}
|
||||||
{termView && <TermTabs terms={ordered} active={active} creating={creating}
|
{termView && <TermTabs terms={ordered} active={active} creating={creating}
|
||||||
@@ -331,7 +340,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
|||||||
<span>{machine ? `${title} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
|
<span>{machine ? `${title} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
|
||||||
<span>up {fmtUptime(me.uptime)}</span>
|
<span>up {fmtUptime(me.uptime)}</span>
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
<span className="muted">{view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'jobs' ? 'Jobs' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'hardware' ? 'Hardware' : view === 'zfs' ? 'ZFS' : view === 'usage' ? 'Usage' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'kvm' ? 'KVM' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span>
|
<span className="muted">{view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'jobs' ? 'Jobs' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'hardware' ? 'Hardware' : view === 'network' ? 'Network' : view === 'zfs' ? 'ZFS' : view === 'usage' ? 'Usage' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'kvm' ? 'KVM' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -171,6 +171,24 @@ export interface SpaceFile { path: string; size: number; apparent: number; mtime
|
|||||||
|
|
||||||
export interface KVM { id: string; name: string; url: string; machine: string; added: string }
|
export interface KVM { id: string; name: string; url: string; machine: string; added: string }
|
||||||
|
|
||||||
|
// ---- the Network tab ----
|
||||||
|
export interface IfAddr { ip: string; prefix: number; v6?: boolean }
|
||||||
|
export interface Iface {
|
||||||
|
name: string; kind: string; mac?: string; mtu: number; up: boolean; carrier: boolean
|
||||||
|
speed_mbps?: number; duplex?: string; driver?: string; signal_dbm?: number
|
||||||
|
addrs: IfAddr[]; rx_total: number; tx_total: number; default?: boolean
|
||||||
|
}
|
||||||
|
export interface NetReport { at: string; host?: string; ifaces: Iface[]; gateway?: string; gateway_via?: string; dns?: string[]; search?: string[] }
|
||||||
|
export interface Outside { at: string; ip?: string; colo?: string; country?: string; error?: string }
|
||||||
|
export interface SpeedResult {
|
||||||
|
at: string; down_mbps: number; up_mbps: number; latency_ms: number; jitter_ms: number
|
||||||
|
down_bytes: number; up_bytes: number; took_sec: number; colo?: string; ip?: string; note?: string; error?: string
|
||||||
|
}
|
||||||
|
// A test in progress, or the last one to have finished. `done` is 0–1 across the whole test
|
||||||
|
// and `mbps` is the live figure for the phase under way.
|
||||||
|
export interface SpeedRun { started: string; running: boolean; phase: string; done: number; mbps: number; bytes: number; result?: SpeedResult }
|
||||||
|
export interface NetworkInfo { report: NetReport; outside: Outside; run?: SpeedRun; history: SpeedResult[]; method: string }
|
||||||
|
|
||||||
export interface ZFSPolicy { id: string; name: string; dataset: string; recursive: boolean; every: 'hourly' | 'daily' | 'weekly' | 'monthly' | 'custom'; at: string; weekday: number; day: number; cron?: string; keep_for: string; keep_min: number; prefix?: string; enabled: boolean }
|
export interface ZFSPolicy { id: string; name: string; dataset: string; recursive: boolean; every: 'hourly' | 'daily' | 'weekly' | 'monthly' | 'custom'; at: string; weekday: number; day: number; cron?: string; keep_for: string; keep_min: number; prefix?: string; enabled: boolean }
|
||||||
export interface ZFSRun { at?: string; taken?: string; pruned?: string[]; error?: string }
|
export interface ZFSRun { at?: string; taken?: string; pruned?: string[]; error?: string }
|
||||||
export interface ZFSExternal { tool: string; schedule: string; command: string; user: string; source: string; id: string; next?: number; last?: number }
|
export interface ZFSExternal { tool: string; schedule: string; command: string; user: string; source: string; id: string; next?: number; last?: number }
|
||||||
@@ -284,6 +302,10 @@ export const api = {
|
|||||||
tokens: () => req<TokenInfo[]>('GET', '/api/tokens', undefined, true),
|
tokens: () => req<TokenInfo[]>('GET', '/api/tokens', undefined, true),
|
||||||
createReadToken: (name: string, sensors: string[] = []) => req<{ token: string; name: string }>('POST', '/api/tokens/read', { name, sensors }, true),
|
createReadToken: (name: string, sensors: string[] = []) => req<{ token: string; name: string }>('POST', '/api/tokens/read', { name, sensors }, true),
|
||||||
hardware: (refresh = false) => req<Hardware>('GET', `/api/hardware${refresh ? '?refresh=1' : ''}`),
|
hardware: (refresh = false) => req<Hardware>('GET', `/api/hardware${refresh ? '?refresh=1' : ''}`),
|
||||||
|
network: () => req<NetworkInfo>('GET', '/api/network'),
|
||||||
|
speedtest: () => req<SpeedRun>('GET', '/api/network/speedtest'),
|
||||||
|
startSpeedtest: () => req<SpeedRun>('POST', '/api/network/speedtest'),
|
||||||
|
cancelSpeedtest: () => req<void>('POST', '/api/network/speedtest/cancel'),
|
||||||
docker: () => req<DockerInfo>('GET', '/api/docker'),
|
docker: () => req<DockerInfo>('GET', '/api/docker'),
|
||||||
dockerAction: (id: string, action: 'start' | 'stop' | 'restart' | 'remove') => req<void>('POST', `/api/docker/containers/${encodeURIComponent(id)}/${action}`),
|
dockerAction: (id: string, action: 'start' | 'stop' | 'restart' | 'remove') => req<void>('POST', `/api/docker/containers/${encodeURIComponent(id)}/${action}`),
|
||||||
dockerLogs: (id: string, since = '') => req<{ lines: DockerLogLine[] }>('GET', `/api/docker/containers/${encodeURIComponent(id)}/logs${since ? `?since=${encodeURIComponent(since)}` : '?n=300'}`),
|
dockerLogs: (id: string, since = '') => req<{ lines: DockerLogLine[] }>('GET', `/api/docker/containers/${encodeURIComponent(id)}/logs${since ? `?since=${encodeURIComponent(since)}` : '?n=300'}`),
|
||||||
|
|||||||
@@ -422,6 +422,21 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
|
|||||||
.mitem { width: 100%; display: flex; align-items: center; gap: 8px; padding: 9px 12px; text-align: left; }
|
.mitem { width: 100%; display: flex; align-items: center; gap: 8px; padding: 9px 12px; text-align: left; }
|
||||||
.mitem:hover { background: var(--bg3); } .mitem.on { box-shadow: inset 3px 0 0 var(--accent); }
|
.mitem:hover { background: var(--bg3); } .mitem.on { box-shadow: inset 3px 0 0 var(--accent); }
|
||||||
.mitem .mname { flex: 1; } .mitem.manage { color: var(--accent2); }
|
.mitem .mname { flex: 1; } .mitem.manage { color: var(--accent2); }
|
||||||
|
/* Network tab: the speed test's four figures, large enough to read at arm's length, and the
|
||||||
|
bar that fills while one is running. */
|
||||||
|
.net .speedgrid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
|
||||||
|
@media (max-width: 560px) { .net .speedgrid { grid-template-columns: repeat(2, 1fr); } }
|
||||||
|
.net .speedfig { min-width: 0; }
|
||||||
|
.net .speedfig .label { font-size: 11px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; }
|
||||||
|
.net .speedfig .big { font-size: 28px; font-weight: 600; line-height: 1.15; font-variant-numeric: tabular-nums; }
|
||||||
|
.net .speedfig .big i { font-size: 12px; font-weight: 400; font-style: normal; color: var(--muted); margin-left: 4px; }
|
||||||
|
.net .speedfig.live .big { color: var(--accent); }
|
||||||
|
.net .speedbar { display: flex; flex-direction: column; gap: 4px; margin-top: 10px; }
|
||||||
|
.net .card.speedtest .svc-actions { margin-top: 10px; }
|
||||||
|
.net .card.speedtest .method { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); }
|
||||||
|
.net .card.iface { margin-top: 8px; }
|
||||||
|
.net .card + .dash-bar { margin-top: 14px; }
|
||||||
|
|
||||||
.addform { display: flex; flex-direction: column; gap: 10px; }
|
.addform { display: flex; flex-direction: column; gap: 10px; }
|
||||||
.addform label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
|
.addform label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
|
||||||
.addform input, .addform select { padding: 8px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 15px; }
|
.addform input, .addform select { padding: 8px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 15px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user