From e9fa21768e8a70c78fc769cea592a9635e75aaef Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Tue, 22 Sep 2026 20:26:03 -0700 Subject: [PATCH] Space: the unit is a disk on its own or a ZFS pool as a whole, picked from a dropdown A pool's member disks are listed under it, not offered apart; the picker shows the layout, members, size and scan state. Co-Authored-By: Claude Fable 5.1 --- internal/api/space.go | 126 +++++++++++++++++++++++++++--------------- 1 file changed, 80 insertions(+), 46 deletions(-) diff --git a/internal/api/space.go b/internal/api/space.go index 3c4d6ba..3c0d9b5 100644 --- a/internal/api/space.go +++ b/internal/api/space.go @@ -1,14 +1,15 @@ package api -// The Space tab: what is using a disk. A disk is picked, not a partition: the scan covers -// every filesystem the disk backs — its partitions' mountpoints, and for a disk in a ZFS -// pool, every mounted dataset of that pool (so any of the pool's disks shows the pool). +// 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" @@ -18,51 +19,84 @@ import ( "hamlab/internal/storage" ) -type spaceDisk struct { - Name string `json:"name"` - Kind string `json:"kind"` - Model string `json:"model,omitempty"` - Size uint64 `json:"size"` - Pool string `json:"pool,omitempty"` // the ZFS pool this disk is part of - Roots []string `json:"roots"` // the mountpoints a scan walks - Scan *space.Scan `json:"scan,omitempty"` // the last scan, if any +// 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 } -// spaceDisks maps every disk to the mountpoints it backs. -func (s *Server) spaceDisks(ctx context.Context) []spaceDisk { +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 []spaceDisk - poolMounts := map[string][]string{} + var out []spaceTarget + pools := map[string]*spaceTarget{} + var poolOrder []string for _, d := range devs { - sd := spaceDisk{Name: d.Name, Kind: d.Kind, Model: d.Model, Size: d.Size, Roots: []string{}} + 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: zfsMounts(ctx, p.Label)} + if pt.Roots == nil { + pt.Roots = []string{} + } + 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 - sd.Roots = append(sd.Roots, p.Mount) - } - if p.FSType == "zfs_member" && p.Label != "" && sd.Pool == "" { - sd.Pool = p.Label + disk.Roots = append(disk.Roots, p.Mount) } } - if sd.Pool != "" { - mounts, ok := poolMounts[sd.Pool] - if !ok { - mounts = zfsMounts(ctx, sd.Pool) - poolMounts[sd.Pool] = mounts - } - for _, m := range mounts { - if !seen[m] { - seen[m] = true - sd.Roots = append(sd.Roots, m) + 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" } } } - sort.Strings(sd.Roots) - out = append(out, sd) + 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 = []spaceDisk{} + out = []spaceTarget{} } return out } @@ -86,22 +120,22 @@ func zfsMounts(ctx context.Context, pool string) []string { } func (s *Server) spaceOverview(w http.ResponseWriter, r *http.Request) { - disks := s.spaceDisks(r.Context()) + targets := s.spaceTargets(r.Context()) scans := s.space.All() - for i := range disks { - disks[i].Scan = scans[disks[i].Name] + for i := range targets { + targets[i].Scan = scans[targets[i].ID] } - jsonOK(w, 200, map[string]any{"disks": disks}) + jsonOK(w, 200, map[string]any{"targets": targets}) } -func (s *Server) spaceDisk(w http.ResponseWriter, r *http.Request, name string) (spaceDisk, bool) { - for _, d := range s.spaceDisks(r.Context()) { - if d.Name == name { - return d, true +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") - return spaceDisk{}, false + jsonErr(w, 404, "no such disk or pool") + return spaceTarget{}, false } func (s *Server) spaceStatus(w http.ResponseWriter, r *http.Request) { @@ -114,16 +148,16 @@ func (s *Server) spaceStatus(w http.ResponseWriter, r *http.Request) { } func (s *Server) spaceScan(w http.ResponseWriter, r *http.Request) { - d, ok := s.spaceDisk(w, r, r.PathValue("disk")) + t, ok := s.spaceTarget(w, r, r.PathValue("disk")) if !ok { return } - st, err := s.space.Start(d.Name, d.Roots) + st, err := s.space.Start(t.ID, t.Roots) if err != nil { jsonErr(w, 409, err.Error()) return } - log.Printf("space: scan of %s (%s) started by %s", d.Name, strings.Join(d.Roots, " "), clientIP(r)) + log.Printf("space: scan of %s (%s) started by %s", t.ID, strings.Join(t.Roots, " "), clientIP(r)) jsonOK(w, 202, st) }