Files
hamlab/internal/api/space.go
T
Mitchell-JohnsonandClaude Fable 5.1 fc5236471e Space: what is using a disk, WizTree-style
Pick a disk, not a partition; the scan walks every filesystem it backs, as root when
the host allows, in a helper process that streams the tree back. A tree of folders
largest-first over a treemap of the selected folder, nested to the file and coloured by
kind, plus the thousand largest files. The rail no longer offers the hub-only pages
while a remote machine is being looked at.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 20:18:36 -07:00

180 lines
4.5 KiB
Go

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