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). import ( "context" "log" "net/http" "os/exec" "sort" "strconv" "strings" "time" "hamlab/internal/space" "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 } // spaceDisks maps every disk to the mountpoints it backs. func (s *Server) spaceDisks(ctx context.Context) []spaceDisk { devs, _ := storage.Devices(ctx) var out []spaceDisk poolMounts := map[string][]string{} for _, d := range devs { sd := spaceDisk{Name: d.Name, Kind: d.Kind, Model: d.Model, Size: d.Size, Roots: []string{}} seen := map[string]bool{} for _, p := range d.Parts { 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 } } 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) } } } sort.Strings(sd.Roots) out = append(out, sd) } if out == nil { out = []spaceDisk{} } return out } // zfsMounts is every mounted filesystem of a pool. func zfsMounts(ctx context.Context, pool string) []string { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() out, err := exec.CommandContext(ctx, "zfs", "list", "-H", "-t", "filesystem", "-r", "-o", "mountpoint,mounted", pool).Output() if err != nil { return nil } var mounts []string for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { f := strings.Split(line, "\t") if len(f) == 2 && f[1] == "yes" && strings.HasPrefix(f[0], "/") { mounts = append(mounts, f[0]) } } return mounts } func (s *Server) spaceOverview(w http.ResponseWriter, r *http.Request) { disks := s.spaceDisks(r.Context()) scans := s.space.All() for i := range disks { disks[i].Scan = scans[disks[i].Name] } jsonOK(w, 200, map[string]any{"disks": disks}) } 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 } } jsonErr(w, 404, "no such disk") return spaceDisk{}, 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) { d, ok := s.spaceDisk(w, r, r.PathValue("disk")) if !ok { return } st, err := s.space.Start(d.Name, d.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)) jsonOK(w, 202, st) } 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 > 16 { 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) }