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:
co-authored by
Claude Fable 5.1
parent
7e3e916f25
commit
fc5236471e
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user