Space keeps its scans and refreshes them: zfs diff, folder mtimes, resume after cancel

A scan is saved beside the config and read back after a restart. The next scan starts
from it: on a pool with snapshots zfs diff names what changed and only those folders are
read; elsewhere a folder whose mtime is unchanged keeps its list and only its kept files
are stat'ed again. A cancelled scan keeps what it walked and Resume reads the rest.
Forget drops the saved result. The treemap is every file a tile, grouped by folder, with
no nested boxes: the folder chains of a Linux system made a staircase of empty headers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-23 00:43:30 -07:00
co-authored by Claude Fable 5.1
parent c96d6facad
commit 53d1da08a5
11 changed files with 869 additions and 145 deletions
+5 -4
View File
@@ -85,11 +85,11 @@ type Server struct {
func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *metrics.Collector, st *store.Store, svc *services.Manager, cr *cron.Manager, tm *tasks.Manager, up *updates.Manager, sto *storage.Manager, fsLocal, fsSudo files.FS, nm *nodes.Manager, fingerprint string, ui fs.FS, version string) *Server {
s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, metrics: col, sensors: sensors.NewRegistry(col), store: st, svc: svc, cron: cr, tasks: tm, updates: up, storage: sto, fsLocal: fsLocal, fsSudo: fsSudo, nodes: nm, fingerprint: fingerprint, ui: ui, version: version,
started: time.Now(), user: os.Getenv("USER")}
if self, err := os.Executable(); err == nil {
s.space = space.New(self)
} else {
s.space = space.New("hamlab")
self, err := os.Executable()
if err != nil {
self = "hamlab"
}
s.space = space.New(self, filepath.Join(cfg.DataDir(), "space"))
s.hostName() // read it once now so the first request doesn't have to
if u, err := user.Current(); err == nil && u.Username != "" {
s.user = u.Username
@@ -260,6 +260,7 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
m.HandleFunc("GET /api/space/{disk}", s.auth(s.spaceStatus))
m.HandleFunc("POST /api/space/{disk}/scan", s.auth(s.spaceScan))
m.HandleFunc("POST /api/space/{disk}/cancel", s.auth(s.spaceCancel))
m.HandleFunc("DELETE /api/space/{disk}", s.auth(s.spaceForget))
m.HandleFunc("GET /api/space/{disk}/tree", s.auth(s.spaceTree))
m.HandleFunc("GET /api/space/{disk}/largest", s.auth(s.spaceLargest))
m.HandleFunc("GET /api/procs", s.auth(s.procList))
+22 -11
View File
@@ -35,6 +35,8 @@ type spaceTarget struct {
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`)
@@ -54,9 +56,10 @@ func (s *Server) spaceTargets(ctx context.Context) []spaceTarget {
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{}
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)
@@ -101,19 +104,21 @@ func (s *Server) spaceTargets(ctx context.Context) []spaceTarget {
return out
}
// zfsMounts is every mounted filesystem of a pool.
func zfsMounts(ctx context.Context, pool string) []string {
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", "mountpoint,mounted", pool).Output()
out, err := exec.CommandContext(ctx, "zfs", "list", "-H", "-t", "filesystem", "-r", "-o", "name,mountpoint,mounted", pool).Output()
if err != nil {
return nil
}
var mounts []string
var mounts []zfsMount
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])
if len(f) == 3 && f[2] == "yes" && strings.HasPrefix(f[1], "/") {
mounts = append(mounts, zfsMount{mount: f[1], dataset: f[0]})
}
}
return mounts
@@ -152,7 +157,7 @@ func (s *Server) spaceScan(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
st, err := s.space.Start(t.ID, t.Roots)
st, err := s.space.Start(t.ID, t.Roots, t.datasets)
if err != nil {
jsonErr(w, 409, err.Error())
return
@@ -161,6 +166,12 @@ func (s *Server) spaceScan(w http.ResponseWriter, r *http.Request) {
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")
@@ -176,7 +187,7 @@ func (s *Server) spaceTree(w http.ResponseWriter, r *http.Request) {
path = "/"
}
depth, _ := strconv.Atoi(q.Get("depth"))
if depth < 1 || depth > 16 {
if depth < 1 || depth > 32 {
depth = 1
}
limit, _ := strconv.Atoi(q.Get("limit"))