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) }