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>
This commit is contained in:
Mitchell-Johnson
2026-09-22 20:18:36 -07:00
co-authored by Claude Fable 5.1
parent 7e3e916f25
commit fc5236471e
11 changed files with 1476 additions and 4 deletions
+13
View File
@@ -39,6 +39,7 @@ import (
"hamlab/internal/sensors"
"hamlab/internal/services"
"hamlab/internal/setup"
"hamlab/internal/space"
"hamlab/internal/storage"
"hamlab/internal/store"
"hamlab/internal/tasks"
@@ -66,6 +67,7 @@ type Server struct {
tasks *tasks.Manager
updates *updates.Manager
storage *storage.Manager
space *space.Manager
fsLocal files.FS
fsSudo files.FS // nil without passwordless sudo
nodes *nodes.Manager
@@ -83,6 +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")
}
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
@@ -249,6 +256,12 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
m.HandleFunc("GET /api/storage/zfs/snapshots", s.auth(s.zfsSnapshots))
m.HandleFunc("POST /api/storage/zfs/scrub/{pool}/{action}", s.auth(s.zfsScrub))
m.HandleFunc("POST /api/storage/zfs/snapshot", s.auth(s.zfsSnapshot))
m.HandleFunc("GET /api/space", s.auth(s.spaceOverview))
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("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))
m.HandleFunc("POST /api/procs/{pid}/signal", s.auth(s.procSignal))
m.HandleFunc("POST /api/password", s.auth(s.changePassword))
+179
View File
@@ -0,0 +1,179 @@
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)
}
+140
View File
@@ -0,0 +1,140 @@
package space
import (
"bufio"
"encoding/binary"
"errors"
"io"
)
// The tree crosses from the helper process to the daemon as a preorder stream of varints:
// a few bytes per node, decoded without reflection, so a million nodes take well under a
// second either way.
const codecMagic = "HLSPACE1"
func Encode(w io.Writer, root *Node) error {
bw := bufio.NewWriterSize(w, 1<<20)
if _, err := bw.WriteString(codecMagic); err != nil {
return err
}
var buf [binary.MaxVarintLen64]byte
putU := func(v uint64) error { n := binary.PutUvarint(buf[:], v); _, err := bw.Write(buf[:n]); return err }
putI := func(v int64) error { n := binary.PutVarint(buf[:], v); _, err := bw.Write(buf[:n]); return err }
var enc func(n *Node) error
enc = func(n *Node) error {
if err := putU(uint64(len(n.Name))); err != nil {
return err
}
if _, err := bw.WriteString(n.Name); err != nil {
return err
}
flags := uint64(0)
if n.Dir {
flags |= 1
}
if n.Mount {
flags |= 2
}
if n.Denied {
flags |= 4
}
for _, f := range []func() error{
func() error { return putU(flags) },
func() error { return putI(n.Size) },
func() error { return putI(n.Apparent) },
func() error { return putI(n.MTime) },
func() error { return putU(uint64(n.Files)) },
func() error { return putU(uint64(n.Dirs)) },
func() error { return putU(uint64(n.SmallN)) },
func() error { return putI(n.SmallSize) },
func() error { return putU(uint64(len(n.Children))) },
} {
if err := f(); err != nil {
return err
}
}
for _, c := range n.Children {
if err := enc(c); err != nil {
return err
}
}
return nil
}
if err := enc(root); err != nil {
return err
}
return bw.Flush()
}
func Decode(r io.Reader) (*Node, error) {
br := bufio.NewReaderSize(r, 1<<20)
magic := make([]byte, len(codecMagic))
if _, err := io.ReadFull(br, magic); err != nil || string(magic) != codecMagic {
return nil, errors.New("space: not a scan stream")
}
var dec func() (*Node, error)
dec = func() (*Node, error) {
l, err := binary.ReadUvarint(br)
if err != nil {
return nil, err
}
if l > 1<<16 {
return nil, errors.New("space: bad name length")
}
name := make([]byte, l)
if _, err := io.ReadFull(br, name); err != nil {
return nil, err
}
n := &Node{Name: string(name)}
flags, err := binary.ReadUvarint(br)
if err != nil {
return nil, err
}
n.Dir, n.Mount, n.Denied = flags&1 != 0, flags&2 != 0, flags&4 != 0
if n.Size, err = binary.ReadVarint(br); err != nil {
return nil, err
}
if n.Apparent, err = binary.ReadVarint(br); err != nil {
return nil, err
}
if n.MTime, err = binary.ReadVarint(br); err != nil {
return nil, err
}
u, err := binary.ReadUvarint(br)
if err != nil {
return nil, err
}
n.Files = uint32(u)
if u, err = binary.ReadUvarint(br); err != nil {
return nil, err
}
n.Dirs = uint32(u)
if u, err = binary.ReadUvarint(br); err != nil {
return nil, err
}
n.SmallN = uint32(u)
if n.SmallSize, err = binary.ReadVarint(br); err != nil {
return nil, err
}
cnt, err := binary.ReadUvarint(br)
if err != nil {
return nil, err
}
if cnt > 1<<24 {
return nil, errors.New("space: bad child count")
}
if cnt > 0 {
n.Children = make([]*Node, 0, cnt)
for i := uint64(0); i < cnt; i++ {
c, err := dec()
if err != nil {
return nil, err
}
n.Children = append(n.Children, c)
}
}
return n, nil
}
return dec()
}
+289
View File
@@ -0,0 +1,289 @@
package space
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"os"
"os/exec"
"os/signal"
"strings"
"sync"
"syscall"
"time"
"hamlab/internal/priv"
)
// ---- the helper: `hamlab space-walk <root>…`, run by the daemon (as root when it can) ----
//
// The walk runs in its own process so that root can read every directory, so that the
// memory of the walk is given back when it ends, and so that a runaway scan can be killed
// without touching the console. Progress goes to stderr as JSON lines; the tree to stdout.
func Helper(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprintln(stderr, "usage: hamlab space-walk <mountpoint>...")
return 2
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP)
go func() { <-sig; cancel() }()
go func() { _, _ = io.Copy(io.Discard, os.Stdin); cancel() }() // the daemon went away
enc := json.NewEncoder(stderr)
root, err := Walk(ctx, args, func(p Progress) { _ = enc.Encode(p) })
if err != nil && !errors.Is(err, context.Canceled) {
fmt.Fprintln(stderr, "error:", err)
return 1
}
if ctx.Err() != nil {
return 3
}
if err := Encode(stdout, root); err != nil {
fmt.Fprintln(stderr, "error:", err)
return 1
}
return 0
}
// ---- the daemon side: one scan per disk, the last result kept ----
type Scan struct {
Disk string `json:"disk"`
Roots []string `json:"roots"`
Started time.Time `json:"started"`
Finished time.Time `json:"finished,omitempty"`
Running bool `json:"running"`
Error string `json:"error,omitempty"`
Progress Progress `json:"progress"`
AsRoot bool `json:"as_root"`
// once finished:
Files uint32 `json:"files"`
Dirs uint32 `json:"dirs"`
Size int64 `json:"size"`
Denied int64 `json:"denied"`
root *Node
largest []File
cancel context.CancelFunc
}
type Manager struct {
self string // this binary, for the helper
mu sync.Mutex
scans map[string]*Scan
}
func New(self string) *Manager { return &Manager{self: self, scans: map[string]*Scan{}} }
// Status is the scan of disk (a copy without the tree), or nil.
func (m *Manager) Status(disk string) *Scan {
m.mu.Lock()
defer m.mu.Unlock()
s := m.scans[disk]
if s == nil {
return nil
}
c := *s
c.root, c.largest, c.cancel = nil, nil, nil
return &c
}
func (m *Manager) All() map[string]*Scan {
m.mu.Lock()
defer m.mu.Unlock()
out := map[string]*Scan{}
for k, s := range m.scans {
c := *s
c.root, c.largest, c.cancel = nil, nil, nil
out[k] = &c
}
return out
}
// Start scans the roots of disk in the background. One scan per disk at a time.
func (m *Manager) Start(disk string, roots []string) (*Scan, error) {
if len(roots) == 0 {
return nil, errors.New("nothing is mounted from this disk")
}
m.mu.Lock()
if s := m.scans[disk]; s != nil && s.Running {
m.mu.Unlock()
return nil, errors.New("a scan of this disk is already running")
}
ctx, cancel := context.WithCancel(context.Background())
s := &Scan{Disk: disk, Roots: roots, Started: time.Now(), Running: true, AsRoot: priv.Available(), cancel: cancel}
m.scans[disk] = s
m.mu.Unlock()
go m.run(ctx, s)
return m.Status(disk), nil
}
func (m *Manager) run(ctx context.Context, s *Scan) {
args := append([]string{m.self, "space-walk"}, s.Roots...)
if s.AsRoot {
args = priv.Wrap(args...)
}
cmd := exec.Command(args[0], args[1:]...)
stdin, _ := cmd.StdinPipe() // held open: closing it tells the helper the daemon is gone
stdout, _ := cmd.StdoutPipe()
stderr, _ := cmd.StderrPipe()
finish := func(err error) {
m.mu.Lock()
defer m.mu.Unlock()
s.Running = false
s.Finished = time.Now()
if err != nil {
s.Error = err.Error()
}
}
if err := cmd.Start(); err != nil {
finish(err)
return
}
go func() { <-ctx.Done(); _ = cmd.Process.Signal(syscall.SIGTERM); stdin.Close() }()
var lastErr string
go func() {
sc := bufio.NewScanner(stderr)
sc.Buffer(make([]byte, 64<<10), 1<<20)
for sc.Scan() {
line := sc.Text()
var p Progress
if strings.HasPrefix(line, "{") && json.Unmarshal([]byte(line), &p) == nil {
m.mu.Lock()
s.Progress = p
m.mu.Unlock()
continue
}
if line != "" {
lastErr = line
}
}
}()
root, decErr := Decode(stdout)
waitErr := cmd.Wait()
if ctx.Err() != nil {
finish(errors.New("cancelled"))
return
}
if waitErr != nil {
if lastErr != "" {
finish(errors.New(strings.TrimPrefix(lastErr, "error: ")))
} else {
finish(waitErr)
}
return
}
if decErr != nil {
finish(decErr)
return
}
m.mu.Lock()
s.root = root
s.Files, s.Dirs, s.Size, s.Denied = root.Files, root.Dirs, root.Size, s.Progress.Denied
s.Running = false
s.Finished = time.Now()
m.mu.Unlock()
log.Printf("space: %s scanned: %d files, %d dirs, %d bytes in %s%s", s.Disk, root.Files, root.Dirs, root.Size, s.Finished.Sub(s.Started).Round(time.Millisecond), map[bool]string{true: " (as root)", false: ""}[s.AsRoot])
}
func (m *Manager) Cancel(disk string) bool {
m.mu.Lock()
s := m.scans[disk]
m.mu.Unlock()
if s == nil || !s.Running || s.cancel == nil {
return false
}
s.cancel()
return true
}
// Tree is the subtree at path, cut to depth levels of at most limit children each, leaving
// out children smaller than min bytes; what is left out is summed into MoreN/MoreSize. A
// treemap asks deep with a min the size of the smallest tile it could draw; a folder list
// asks one level with no min.
type Tree struct {
Node
Path string `json:"path"`
Children []*Tree `json:"children,omitempty"`
MoreN int `json:"more_n,omitempty"`
MoreSize int64 `json:"more_size,omitempty"`
Total int64 `json:"total"` // the whole scan's size, for percentages
}
func (m *Manager) Tree(disk, path string, depth, limit int, min int64) (*Tree, error) {
m.mu.Lock()
s := m.scans[disk]
var root *Node
if s != nil {
root = s.root
}
m.mu.Unlock()
if root == nil {
return nil, errors.New("no scan of this disk yet")
}
n := Find(root, path)
if n == nil {
return nil, errors.New("no such path in the scan")
}
if path == "" {
path = "/"
}
t := cut(n, path, depth, limit, min)
t.Total = root.Size
return t, nil
}
func cut(n *Node, path string, depth, limit int, min int64) *Tree {
t := &Tree{Node: *n, Path: path}
t.Node.Children = nil
if depth <= 0 || !n.Dir {
return t
}
for i, c := range n.Children {
if i >= limit || (c.Size < min && i > 0) { // children are sorted largest first: the first is always kept
t.MoreN++
t.MoreSize += c.Size
continue
}
p := path + "/" + c.Name
if path == "/" {
p = "/" + c.Name
}
t.Children = append(t.Children, cut(c, p, depth-1, limit, min))
}
return t
}
// Largest is the n biggest files of the last scan of disk.
func (m *Manager) Largest(disk string, n int) ([]File, error) {
m.mu.Lock()
s := m.scans[disk]
var root *Node
var have []File
if s != nil {
root, have = s.root, s.largest
}
m.mu.Unlock()
if root == nil {
return nil, errors.New("no scan of this disk yet")
}
if len(have) < n {
have = Largest(root, n) // the tree is never changed once made, so no lock while walking it
m.mu.Lock()
if s.root == root {
s.largest = have
}
m.mu.Unlock()
}
if len(have) > n {
return have[:n], nil
}
return have, nil
}
+356
View File
@@ -0,0 +1,356 @@
// Package space measures what is using a disk: a parallel walk of the filesystems a disk
// backs, kept as a tree of directories and the files worth seeing, the way WizTree does it.
//
// The walk never crosses a filesystem boundary by itself — /proc, /sys, another disk's
// mount — but every mountpoint the disk backs is a root of the same scan, and the roots
// are grafted into one tree at their paths, so a ZFS pool of many datasets, or a disk with
// / and /home on separate partitions, reads as one picture.
//
// Files below KeepMin are not kept one by one: a directory of a million small files is
// one "smaller files" entry with their count and total, which is all a treemap can show
// of them anyway, and what keeps a scan of a few million files within a few hundred MB.
package space
import (
"context"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
)
// KeepMin is the smallest file kept as its own node.
const KeepMin = 128 << 10
// Node is a directory or a file. Sizes are bytes allocated on disk (what fills the disk);
// Apparent is the length. For a directory both include everything beneath it.
type Node struct {
Name string `json:"name"`
Dir bool `json:"dir"`
Size int64 `json:"size"`
Apparent int64 `json:"apparent"`
MTime int64 `json:"mtime"` // unix seconds
Files uint32 `json:"files"` // beneath a directory (kept and small alike)
Dirs uint32 `json:"dirs"`
SmallN uint32 `json:"small_n,omitempty"` // files here below KeepMin, not listed
SmallSize int64 `json:"small_size,omitempty"` // their allocated bytes
Mount bool `json:"mount,omitempty"` // another filesystem starts here (not this disk's, or a root of its own)
Denied bool `json:"denied,omitempty"` // could not be read
Children []*Node `json:"children,omitempty"` // directories: sorted by Size, largest first
}
// Progress is how far a running walk has got.
type Progress struct {
Files int64 `json:"files"`
Dirs int64 `json:"dirs"`
Bytes int64 `json:"bytes"`
Denied int64 `json:"denied"`
Current string `json:"current"`
}
type walker struct {
ctx context.Context
mu sync.Mutex
queue []job
cond *sync.Cond
pending int // dirs queued or being read
files atomic.Int64
dirs atomic.Int64
bytes atomic.Int64
denied atomic.Int64
curMu sync.Mutex
current string
stopping bool
}
type job struct {
path string
node *Node
dev uint64 // the filesystem this root is on; a child on another is a mountpoint, not descended
}
// Walk measures roots (mountpoints, absolute) and returns them grafted into one tree whose
// root is "/". progress, if set, is called about four times a second.
func Walk(ctx context.Context, roots []string, progress func(Progress)) (*Node, error) {
w := &walker{ctx: ctx}
w.cond = sync.NewCond(&w.mu)
root := &Node{Name: "/", Dir: true}
sort.Strings(roots)
trees := map[string]*Node{}
for _, r := range roots {
r = filepath.Clean(r)
if !filepath.IsAbs(r) {
continue
}
var st syscall.Stat_t
if err := syscall.Lstat(r, &st); err != nil || st.Mode&syscall.S_IFMT != syscall.S_IFDIR {
continue
}
n := &Node{Name: filepath.Base(r), Dir: true, MTime: st.Mtim.Sec}
if r == "/" {
n = root
}
trees[r] = n
w.push(job{path: r, node: n, dev: uint64(st.Dev)})
}
if len(trees) == 0 {
return root, os.ErrNotExist
}
workers := 16
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() { defer wg.Done(); w.run() }()
}
done := make(chan struct{})
go func() { wg.Wait(); close(done) }()
tick := time.NewTicker(250 * time.Millisecond)
defer tick.Stop()
for waiting := true; waiting; {
select {
case <-done:
waiting = false
case <-tick.C:
if progress != nil {
progress(w.snapshot())
}
case <-ctx.Done():
w.mu.Lock()
w.stopping = true
w.queue = nil
w.cond.Broadcast()
w.mu.Unlock()
<-done
return root, ctx.Err()
}
}
if progress != nil {
progress(w.snapshot())
}
// graft each root at its path; deepest last so parents exist first
for _, r := range roots {
n, ok := trees[filepath.Clean(r)]
if !ok || n == root {
continue
}
graft(root, filepath.Clean(r), n)
}
total(root)
return root, nil
}
func (w *walker) snapshot() Progress {
w.curMu.Lock()
cur := w.current
w.curMu.Unlock()
return Progress{Files: w.files.Load(), Dirs: w.dirs.Load(), Bytes: w.bytes.Load(), Denied: w.denied.Load(), Current: cur}
}
func (w *walker) push(j job) {
w.mu.Lock()
w.queue = append(w.queue, j)
w.pending++
w.cond.Signal()
w.mu.Unlock()
}
// run takes directories off the queue until there are none left and none being read.
func (w *walker) run() {
for {
w.mu.Lock()
for len(w.queue) == 0 && w.pending > 0 && !w.stopping {
w.cond.Wait()
}
if w.stopping || (len(w.queue) == 0 && w.pending == 0) {
w.cond.Broadcast()
w.mu.Unlock()
return
}
j := w.queue[len(w.queue)-1] // depth first: keeps the queue short and the disk head local
w.queue = w.queue[:len(w.queue)-1]
w.mu.Unlock()
w.readDir(j)
w.mu.Lock()
w.pending--
if w.pending == 0 {
w.cond.Broadcast()
}
w.mu.Unlock()
}
}
func (w *walker) readDir(j job) {
w.curMu.Lock()
w.current = j.path
w.curMu.Unlock()
w.dirs.Add(1)
f, err := os.Open(j.path)
if err != nil {
j.node.Denied = true
w.denied.Add(1)
return
}
names, err := f.Readdirnames(-1)
f.Close()
if err != nil {
j.node.Denied = true
w.denied.Add(1)
}
var st syscall.Stat_t
for _, name := range names {
p := j.path + "/" + name
if j.path == "/" {
p = "/" + name
}
if err := syscall.Lstat(p, &st); err != nil {
continue
}
switch st.Mode & syscall.S_IFMT {
case syscall.S_IFDIR:
c := &Node{Name: name, Dir: true, MTime: st.Mtim.Sec}
j.node.Children = append(j.node.Children, c)
if uint64(st.Dev) != j.dev {
c.Mount = true // something else is mounted here; if it is this disk's too, it is a root of its own
continue
}
w.push(job{path: p, node: c, dev: j.dev})
case syscall.S_IFREG, syscall.S_IFLNK:
alloc := st.Blocks * 512
w.files.Add(1)
w.bytes.Add(alloc)
if alloc >= KeepMin || st.Size >= KeepMin {
j.node.Children = append(j.node.Children, &Node{Name: name, Size: alloc, Apparent: st.Size, MTime: st.Mtim.Sec})
} else {
j.node.SmallN++
j.node.SmallSize += alloc
j.node.Apparent += st.Size // added to the directory's own apparent total below
}
}
}
}
// graft puts a root's tree at path under root, making any directories in between.
func graft(root *Node, path string, tree *Node) {
parts := strings.Split(strings.Trim(path, "/"), "/")
cur := root
for i, part := range parts {
var next *Node
for _, c := range cur.Children {
if c.Name == part && c.Dir {
next = c
break
}
}
if i == len(parts)-1 {
if next == nil {
cur.Children = append(cur.Children, tree)
} else {
// the parent's walk saw this directory as a mountpoint; the walk of the mount fills it
next.Children, next.SmallN, next.SmallSize, next.Apparent, next.Denied = tree.Children, tree.SmallN, tree.SmallSize, tree.Apparent, tree.Denied
next.Mount = true
}
return
}
if next == nil {
next = &Node{Name: part, Dir: true}
cur.Children = append(cur.Children, next)
}
cur = next
}
}
// total sums every directory from its children and sorts them largest first.
func total(n *Node) {
if !n.Dir {
return
}
n.Size = n.SmallSize
n.Files = n.SmallN
n.Dirs = 0
apparent := n.Apparent // the small files' lengths
for _, c := range n.Children {
total(c)
n.Size += c.Size
apparent += c.Apparent
if c.Dir {
n.Dirs += 1 + c.Dirs
n.Files += c.Files
} else {
n.Files++
}
}
n.Apparent = apparent
sort.Slice(n.Children, func(i, j int) bool { return n.Children[i].Size > n.Children[j].Size })
}
// Find follows path (absolute, "/" for the root) down the tree.
func Find(root *Node, path string) *Node {
cur := root
for _, part := range strings.Split(strings.Trim(filepath.Clean(path), "/"), "/") {
if part == "" {
continue
}
var next *Node
for _, c := range cur.Children {
if c.Name == part {
next = c
break
}
}
if next == nil {
return nil
}
cur = next
}
return cur
}
// File is one of the largest files, with its full path.
type File struct {
Path string `json:"path"`
Size int64 `json:"size"`
Apparent int64 `json:"apparent"`
MTime int64 `json:"mtime"`
}
// Largest is the n biggest files in the tree, largest first.
func Largest(root *Node, n int) []File {
var out []File
minIdx := -1
var walk func(n *Node, path string)
walk = func(node *Node, path string) {
for _, c := range node.Children {
p := path + "/" + c.Name
if path == "/" {
p = "/" + c.Name
}
if c.Dir {
if c.Size > 0 && (len(out) < n || c.Size > out[minIdx].Size) {
walk(c, p)
}
continue
}
if len(out) < n {
out = append(out, File{Path: p, Size: c.Size, Apparent: c.Apparent, MTime: c.MTime})
if minIdx < 0 || c.Size < out[minIdx].Size {
minIdx = len(out) - 1
}
} else if c.Size > out[minIdx].Size {
out[minIdx] = File{Path: p, Size: c.Size, Apparent: c.Apparent, MTime: c.MTime}
minIdx = 0
for i := range out {
if out[i].Size < out[minIdx].Size {
minIdx = i
}
}
}
}
}
walk(root, "/")
sort.Slice(out, func(i, j int) bool { return out[i].Size > out[j].Size })
return out
}