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>
225 lines
6.6 KiB
Go
225 lines
6.6 KiB
Go
package api
|
|
|
|
// The Space tab: what is using a disk or a pool. The unit is a whole volume, never a
|
|
// partition: a disk on its own is scanned through every filesystem it backs, and a ZFS pool
|
|
// through every mounted dataset, as one thing — its member disks are not scanned apart.
|
|
|
|
import (
|
|
"context"
|
|
"log"
|
|
"net/http"
|
|
"os/exec"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"hamlab/internal/space"
|
|
"hamlab/internal/storage"
|
|
)
|
|
|
|
// spaceTarget is one thing a scan can measure: a disk on its own, or a ZFS pool as a whole.
|
|
// A disk that is a member of a pool is not a target of its own — its share of the pool means
|
|
// nothing by itself — it is listed under the pool. (A disk with a pool partition *and* a
|
|
// filesystem of its own, root on one partition and a pool on another, is both.)
|
|
type spaceTarget struct {
|
|
ID string `json:"id"` // "nvme0" or "pool:tank"
|
|
Kind string `json:"kind"` // disk | pool
|
|
Name string `json:"name"`
|
|
Model string `json:"model,omitempty"` // disk: its model
|
|
Layout string `json:"layout,omitempty"` // pool: raidz2, mirror, …
|
|
Health string `json:"health,omitempty"` // pool: ONLINE, DEGRADED, …
|
|
Size uint64 `json:"size"`
|
|
Used uint64 `json:"used,omitempty"` // pool: allocated
|
|
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`)
|
|
|
|
// spaceTargets lists the disks and pools a scan can measure.
|
|
func (s *Server) spaceTargets(ctx context.Context) []spaceTarget {
|
|
devs, _ := storage.Devices(ctx)
|
|
var out []spaceTarget
|
|
pools := map[string]*spaceTarget{}
|
|
var poolOrder []string
|
|
for _, d := range devs {
|
|
disk := spaceTarget{ID: d.Name, Kind: "disk", Name: d.Name, Model: d.Model, Size: d.Size, Roots: []string{}}
|
|
seen := map[string]bool{}
|
|
inPool := false
|
|
for _, p := range d.Parts {
|
|
if p.FSType == "zfs_member" && p.Label != "" {
|
|
inPool = true
|
|
pt := pools[p.Label]
|
|
if pt == nil {
|
|
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)
|
|
}
|
|
pt.Disks = append(pt.Disks, d.Name)
|
|
continue
|
|
}
|
|
if p.Mount != "" && p.Mount != "[SWAP]" && !seen[p.Mount] {
|
|
seen[p.Mount] = true
|
|
disk.Roots = append(disk.Roots, p.Mount)
|
|
}
|
|
}
|
|
if !inPool || len(disk.Roots) > 0 {
|
|
sort.Strings(disk.Roots)
|
|
out = append(out, disk)
|
|
}
|
|
}
|
|
if len(pools) > 0 {
|
|
// size, health and layout from zpool; the vdev tree names the layout
|
|
for _, p := range s.storage.ZFS(ctx).Pools {
|
|
if pt := pools[p.Name]; pt != nil {
|
|
pt.Size, pt.Used, pt.Health = p.Size, p.Alloc, p.Health
|
|
if m := layoutRe.FindString(p.Config); m != "" {
|
|
pt.Layout = m
|
|
} else if len(pt.Disks) == 1 {
|
|
pt.Layout = "single disk"
|
|
} else if len(pt.Disks) > 1 {
|
|
pt.Layout = "stripe"
|
|
}
|
|
}
|
|
}
|
|
var ps []spaceTarget
|
|
for _, name := range poolOrder {
|
|
sort.Strings(pools[name].Roots)
|
|
ps = append(ps, *pools[name])
|
|
}
|
|
out = append(ps, out...) // pools first: they are what a NAS is
|
|
}
|
|
if out == nil {
|
|
out = []spaceTarget{}
|
|
}
|
|
return out
|
|
}
|
|
|
|
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", "name,mountpoint,mounted", pool).Output()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var mounts []zfsMount
|
|
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
|
f := strings.Split(line, "\t")
|
|
if len(f) == 3 && f[2] == "yes" && strings.HasPrefix(f[1], "/") {
|
|
mounts = append(mounts, zfsMount{mount: f[1], dataset: f[0]})
|
|
}
|
|
}
|
|
return mounts
|
|
}
|
|
|
|
func (s *Server) spaceOverview(w http.ResponseWriter, r *http.Request) {
|
|
targets := s.spaceTargets(r.Context())
|
|
scans := s.space.All()
|
|
for i := range targets {
|
|
targets[i].Scan = scans[targets[i].ID]
|
|
}
|
|
jsonOK(w, 200, map[string]any{"targets": targets})
|
|
}
|
|
|
|
func (s *Server) spaceTarget(w http.ResponseWriter, r *http.Request, id string) (spaceTarget, bool) {
|
|
for _, t := range s.spaceTargets(r.Context()) {
|
|
if t.ID == id {
|
|
return t, true
|
|
}
|
|
}
|
|
jsonErr(w, 404, "no such disk or pool")
|
|
return spaceTarget{}, 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) {
|
|
t, ok := s.spaceTarget(w, r, r.PathValue("disk"))
|
|
if !ok {
|
|
return
|
|
}
|
|
st, err := s.space.Start(t.ID, t.Roots, t.datasets)
|
|
if err != nil {
|
|
jsonErr(w, 409, err.Error())
|
|
return
|
|
}
|
|
log.Printf("space: scan of %s (%s) started by %s", t.ID, strings.Join(t.Roots, " "), clientIP(r))
|
|
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")
|
|
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 > 32 {
|
|
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)
|
|
}
|