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>
225 lines
6.6 KiB
Go
225 lines
6.6 KiB
Go
package api
|
|
|
|
// The Space tab: what is using a disk or a pool. The unit is a whole volume, never a
|
|
// partition: a disk on its own is scanned through every filesystem it backs, and a ZFS pool
|
|
// through every mounted dataset, as one thing — its member disks are not scanned apart.
|
|
|
|
import (
|
|
"context"
|
|
"log"
|
|
"net/http"
|
|
"os/exec"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"hamlab/internal/space"
|
|
"hamlab/internal/storage"
|
|
)
|
|
|
|
// spaceTarget is one thing a scan can measure: a disk on its own, or a ZFS pool as a whole.
|
|
// A disk that is a member of a pool is not a target of its own — its share of the pool means
|
|
// nothing by itself — it is listed under the pool. (A disk with a pool partition *and* a
|
|
// filesystem of its own, root on one partition and a pool on another, is both.)
|
|
type spaceTarget struct {
|
|
ID string `json:"id"` // "nvme0" or "pool:tank"
|
|
Kind string `json:"kind"` // disk | pool
|
|
Name string `json:"name"`
|
|
Model string `json:"model,omitempty"` // disk: its model
|
|
Layout string `json:"layout,omitempty"` // pool: raidz2, mirror, …
|
|
Health string `json:"health,omitempty"` // pool: ONLINE, DEGRADED, …
|
|
Size uint64 `json:"size"`
|
|
Used uint64 `json:"used,omitempty"` // pool: allocated
|
|
Disks []string `json:"disks,omitempty"` // pool: its member disks
|
|
Roots []string `json:"roots"` // the mountpoints a scan walks
|
|
Scan *space.Scan `json:"scan,omitempty"` // the last scan, if any
|
|
|
|
datasets map[string]string // root → ZFS dataset, for the ones that are
|
|
}
|
|
|
|
var layoutRe = regexp.MustCompile(`\b(draid\d*|raidz[123]?|mirror)\b`)
|
|
|
|
// spaceTargets lists the disks and pools a scan can measure.
|
|
func (s *Server) spaceTargets(ctx context.Context) []spaceTarget {
|
|
devs, _ := storage.Devices(ctx)
|
|
var out []spaceTarget
|
|
pools := map[string]*spaceTarget{}
|
|
var poolOrder []string
|
|
for _, d := range devs {
|
|
disk := spaceTarget{ID: d.Name, Kind: "disk", Name: d.Name, Model: d.Model, Size: d.Size, Roots: []string{}}
|
|
seen := map[string]bool{}
|
|
inPool := false
|
|
for _, p := range d.Parts {
|
|
if p.FSType == "zfs_member" && p.Label != "" {
|
|
inPool = true
|
|
pt := pools[p.Label]
|
|
if pt == nil {
|
|
pt = &spaceTarget{ID: "pool:" + p.Label, Kind: "pool", Name: p.Label, Roots: []string{}, datasets: map[string]string{}}
|
|
for _, zm := range zfsMounts(ctx, p.Label) {
|
|
pt.Roots = append(pt.Roots, zm.mount)
|
|
pt.datasets[zm.mount] = zm.dataset
|
|
}
|
|
pools[p.Label] = pt
|
|
poolOrder = append(poolOrder, p.Label)
|
|
}
|
|
pt.Disks = append(pt.Disks, d.Name)
|
|
continue
|
|
}
|
|
if p.Mount != "" && p.Mount != "[SWAP]" && !seen[p.Mount] {
|
|
seen[p.Mount] = true
|
|
disk.Roots = append(disk.Roots, p.Mount)
|
|
}
|
|
}
|
|
if !inPool || len(disk.Roots) > 0 {
|
|
sort.Strings(disk.Roots)
|
|
out = append(out, disk)
|
|
}
|
|
}
|
|
if len(pools) > 0 {
|
|
// size, health and layout from zpool; the vdev tree names the layout
|
|
for _, p := range s.storage.ZFS(ctx).Pools {
|
|
if pt := pools[p.Name]; pt != nil {
|
|
pt.Size, pt.Used, pt.Health = p.Size, p.Alloc, p.Health
|
|
if m := layoutRe.FindString(p.Config); m != "" {
|
|
pt.Layout = m
|
|
} else if len(pt.Disks) == 1 {
|
|
pt.Layout = "single disk"
|
|
} else if len(pt.Disks) > 1 {
|
|
pt.Layout = "stripe"
|
|
}
|
|
}
|
|
}
|
|
var ps []spaceTarget
|
|
for _, name := range poolOrder {
|
|
sort.Strings(pools[name].Roots)
|
|
ps = append(ps, *pools[name])
|
|
}
|
|
out = append(ps, out...) // pools first: they are what a NAS is
|
|
}
|
|
if out == nil {
|
|
out = []spaceTarget{}
|
|
}
|
|
return out
|
|
}
|
|
|
|
type zfsMount struct{ mount, dataset string }
|
|
|
|
// zfsMounts is every mounted filesystem of a pool, with its dataset.
|
|
func zfsMounts(ctx context.Context, pool string) []zfsMount {
|
|
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
out, err := exec.CommandContext(ctx, "zfs", "list", "-H", "-t", "filesystem", "-r", "-o", "name,mountpoint,mounted", pool).Output()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var mounts []zfsMount
|
|
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
|
f := strings.Split(line, "\t")
|
|
if len(f) == 3 && f[2] == "yes" && strings.HasPrefix(f[1], "/") {
|
|
mounts = append(mounts, zfsMount{mount: f[1], dataset: f[0]})
|
|
}
|
|
}
|
|
return mounts
|
|
}
|
|
|
|
func (s *Server) spaceOverview(w http.ResponseWriter, r *http.Request) {
|
|
targets := s.spaceTargets(r.Context())
|
|
scans := s.space.All()
|
|
for i := range targets {
|
|
targets[i].Scan = scans[targets[i].ID]
|
|
}
|
|
jsonOK(w, 200, map[string]any{"targets": targets})
|
|
}
|
|
|
|
func (s *Server) spaceTarget(w http.ResponseWriter, r *http.Request, id string) (spaceTarget, bool) {
|
|
for _, t := range s.spaceTargets(r.Context()) {
|
|
if t.ID == id {
|
|
return t, true
|
|
}
|
|
}
|
|
jsonErr(w, 404, "no such disk or pool")
|
|
return spaceTarget{}, false
|
|
}
|
|
|
|
func (s *Server) spaceStatus(w http.ResponseWriter, r *http.Request) {
|
|
st := s.space.Status(r.PathValue("disk"))
|
|
if st == nil {
|
|
jsonErr(w, 404, "not scanned")
|
|
return
|
|
}
|
|
jsonOK(w, 200, st)
|
|
}
|
|
|
|
func (s *Server) spaceScan(w http.ResponseWriter, r *http.Request) {
|
|
t, ok := s.spaceTarget(w, r, r.PathValue("disk"))
|
|
if !ok {
|
|
return
|
|
}
|
|
st, err := s.space.Start(t.ID, t.Roots, t.datasets)
|
|
if err != nil {
|
|
jsonErr(w, 409, err.Error())
|
|
return
|
|
}
|
|
log.Printf("space: scan of %s (%s) started by %s", t.ID, strings.Join(t.Roots, " "), clientIP(r))
|
|
jsonOK(w, 202, st)
|
|
}
|
|
|
|
func (s *Server) spaceForget(w http.ResponseWriter, r *http.Request) {
|
|
s.space.Forget(r.PathValue("disk"))
|
|
log.Printf("space: scan of %s forgotten by %s", r.PathValue("disk"), clientIP(r))
|
|
w.WriteHeader(204)
|
|
}
|
|
|
|
func (s *Server) spaceCancel(w http.ResponseWriter, r *http.Request) {
|
|
if !s.space.Cancel(r.PathValue("disk")) {
|
|
jsonErr(w, 404, "no scan running")
|
|
return
|
|
}
|
|
w.WriteHeader(204)
|
|
}
|
|
|
|
func (s *Server) spaceTree(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
path := q.Get("path")
|
|
if path == "" {
|
|
path = "/"
|
|
}
|
|
depth, _ := strconv.Atoi(q.Get("depth"))
|
|
if depth < 1 || depth > 32 {
|
|
depth = 1
|
|
}
|
|
limit, _ := strconv.Atoi(q.Get("limit"))
|
|
if limit < 1 || limit > 2000 {
|
|
limit = 300
|
|
}
|
|
min, _ := strconv.ParseInt(q.Get("min"), 10, 64)
|
|
if min < 0 {
|
|
min = 0
|
|
}
|
|
t, err := s.space.Tree(r.PathValue("disk"), path, depth, limit, min)
|
|
if err != nil {
|
|
jsonErr(w, 404, err.Error())
|
|
return
|
|
}
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
jsonOK(w, 200, t)
|
|
}
|
|
|
|
func (s *Server) spaceLargest(w http.ResponseWriter, r *http.Request) {
|
|
n, _ := strconv.Atoi(r.URL.Query().Get("n"))
|
|
if n < 1 || n > 5000 {
|
|
n = 500
|
|
}
|
|
files, err := s.space.Largest(r.PathValue("disk"), n)
|
|
if err != nil {
|
|
jsonErr(w, 404, err.Error())
|
|
return
|
|
}
|
|
if files == nil {
|
|
files = []space.File{}
|
|
}
|
|
jsonOK(w, 200, files)
|
|
}
|