From c704e87065414e1497bc379d3a19a2cf6197e88c Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Thu, 17 Sep 2026 22:48:08 -0700 Subject: [PATCH] Disks tab: lsblk devices with partitions/usage/live IO, SMART via smartctl (standby-aware, self-tests, install button), ZFS pools/datasets/snapshots with scrub and snapshot-now Co-Authored-By: Claude Fable 5.1 --- PLAN.md | 17 +- cmd/hamlab/main.go | 4 +- internal/api/server.go | 88 ++++- internal/storage/storage.go | 639 ++++++++++++++++++++++++++++++++++++ internal/updates/updates.go | 14 + web/src/Disks.tsx | 228 +++++++++++++ web/src/Shell.tsx | 8 +- web/src/api.ts | 14 + web/src/styles.css | 20 ++ 9 files changed, 1023 insertions(+), 9 deletions(-) create mode 100644 internal/storage/storage.go create mode 100644 web/src/Disks.tsx diff --git a/PLAN.md b/PLAN.md index 12adfef..bf7e75a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -148,9 +148,20 @@ something long in one, switch away and back, refresh — it's all still there. with `crontab -`, system files written via `sudo tee`), Cron log, and Output file when the command redirects to one. Timers: Run now (starts the activated service), start/stop/enable/disable, live logs of the service via the Services tab's tail. -- **ZFS tab** — pools, datasets (`used/avail/refer/usedbysnapshots`), snapshot list, - scrub status, and the `freeing` counter. `grid` is the target, which implies - multi-host. +- **Disks tab** — **shipped 2026-09-17** (`internal/storage`, `Disks.tsx`), replacing + the planned ZFS tab: block devices from `lsblk -J` (kind, model, size, temperature + from the device's hwmon node, partitions with filesystem usage, live read/write/busy + from the metrics sampler); SMART via `smartctl -j` when smartmontools is present (one + button installs it as a task otherwise): health, temperature, power-on time, self-test + status, ATA attribute table with the failure-predicting IDs flagged, NVMe endurance and + error counters, short/long self-tests. Spinning disks are read with `-n standby` so + the tab never wakes a sleeping drive; "Wake and read" is explicit. ZFS section when + the tools and module are there: pools with health, capacity, fragmentation, last scan + and vdev tree; scrub start/pause/stop; datasets tree with used/refer/in-snapshots/ + snapshot count/compressratio/mount; per-dataset snapshot list and "Snapshot now". + Verified against `grid` (raidz2 `tank`, 10 datasets, 515 snapshots, 8 WD40EZAZ + 970 + EVO Plus SMART). Snapshot destroy and dataset property editing are deliberately not + there yet. - **Dashboard** — **first cut shipped 2026-09-17** (`internal/metrics`, `Dashboard.tsx`): CPU (total + per core), memory/swap, load, uptime, hwmon temps and fans read straight from `/sys` (no lm-sensors), per-interface network rates, per-disk read/write/busy diff --git a/cmd/hamlab/main.go b/cmd/hamlab/main.go index 3e91e44..cafca60 100644 --- a/cmd/hamlab/main.go +++ b/cmd/hamlab/main.go @@ -24,6 +24,7 @@ import ( "hamlab/internal/cron" "hamlab/internal/metrics" "hamlab/internal/services" + "hamlab/internal/storage" "hamlab/internal/store" "hamlab/internal/tasks" hterm "hamlab/internal/term" @@ -149,7 +150,8 @@ func serve(cfg *config.Config) error { cr := cron.New(svc.Sudo(), svc.JournalSudo()) tm := tasks.New() up := updates.New(svc.Sudo(), tm) - srv := api.New(cfg, sessions, mgr, col, st, svc, cr, tm, up, web.FS(), version) + sto := storage.New(svc.Sudo()) + srv := api.New(cfg, sessions, mgr, col, st, svc, cr, tm, up, sto, web.FS(), version) hs := &http.Server{ Addr: cfg.Listen, Handler: srv.Handler(), diff --git a/internal/api/server.go b/internal/api/server.go index 81e0567..b3870c2 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -22,6 +22,7 @@ import ( "hamlab/internal/cron" "hamlab/internal/metrics" "hamlab/internal/services" + "hamlab/internal/storage" "hamlab/internal/store" "hamlab/internal/tasks" "hamlab/internal/term" @@ -41,6 +42,7 @@ type Server struct { cron *cron.Manager tasks *tasks.Manager updates *updates.Manager + storage *storage.Manager version string started time.Time user string @@ -49,9 +51,9 @@ type Server struct { mux *http.ServeMux } -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, ui fs.FS, version string) *Server { +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, ui fs.FS, version string) *Server { host, _ := os.Hostname() - s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, metrics: col, store: st, svc: svc, cron: cr, tasks: tm, updates: up, ui: ui, version: version, + s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, metrics: col, store: st, svc: svc, cron: cr, tasks: tm, updates: up, storage: sto, ui: ui, version: version, started: time.Now(), host: host, user: os.Getenv("USER")} if u, err := user.Current(); err == nil && u.Username != "" { s.user = u.Username @@ -99,6 +101,13 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me m.HandleFunc("POST /api/updates/upgrade", s.auth(s.updatesUpgrade)) m.HandleFunc("GET /api/updates/changelog", s.auth(s.updatesChangelog)) m.HandleFunc("POST /api/power/{action}", s.auth(s.power)) + m.HandleFunc("POST /api/updates/install", s.auth(s.updatesInstall)) + m.HandleFunc("GET /api/storage", s.auth(s.storageOverview)) + m.HandleFunc("GET /api/storage/smart/{dev}", s.auth(s.storageSmart)) + m.HandleFunc("POST /api/storage/smart/{dev}/test/{kind}", s.auth(s.storageSmartTest)) + 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/prefs/dashboard", s.auth(s.getDashPrefs)) m.HandleFunc("PUT /api/prefs/dashboard", s.auth(s.putDashPrefs)) m.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) { jsonErr(w, 404, "not found") }) @@ -588,6 +597,81 @@ func (s *Server) power(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) } +// ---- storage (Disks tab) ---- + +func (s *Server) updatesInstall(w http.ResponseWriter, r *http.Request) { + var body struct { + Packages []string `json:"packages"` + } + _ = json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&body) + t, err := s.updates.Install(body.Packages) + if err != nil { + jsonErr(w, 400, err.Error()) + return + } + log.Printf("updates: %s started as task %s (from %s)", t.Title, t.ID, clientIP(r)) + jsonOK(w, 202, t.Snapshot(false)) +} + +func (s *Server) storageOverview(w http.ResponseWriter, r *http.Request) { + devs, err := storage.Devices(r.Context()) + if err != nil { + jsonErr(w, 500, err.Error()) + return + } + jsonOK(w, 200, map[string]any{"devices": devs, "zfs": s.storage.ZFS(r.Context()), "smartctl": s.storage.HasSmartctl()}) +} + +func (s *Server) storageSmart(w http.ResponseWriter, r *http.Request) { + sm := s.storage.Smart(r.Context(), r.PathValue("dev"), r.URL.Query().Get("force") == "1") + if sm.Error == "unknown device" { + jsonErr(w, 404, sm.Error) + return + } + jsonOK(w, 200, sm) +} + +func (s *Server) storageSmartTest(w http.ResponseWriter, r *http.Request) { + if err := s.storage.SelfTest(r.Context(), r.PathValue("dev"), r.PathValue("kind")); err != nil { + jsonErr(w, 400, err.Error()) + return + } + log.Printf("smart: %s test on %s (from %s)", r.PathValue("kind"), r.PathValue("dev"), clientIP(r)) + w.WriteHeader(204) +} + +func (s *Server) zfsSnapshots(w http.ResponseWriter, r *http.Request) { + snaps, err := s.storage.Snapshots(r.Context(), r.URL.Query().Get("dataset")) + if err != nil { + jsonErr(w, 400, err.Error()) + return + } + jsonOK(w, 200, snaps) +} + +func (s *Server) zfsScrub(w http.ResponseWriter, r *http.Request) { + if err := s.storage.Scrub(r.Context(), r.PathValue("pool"), r.PathValue("action")); err != nil { + jsonErr(w, 400, err.Error()) + return + } + log.Printf("zfs: scrub %s %s (from %s)", r.PathValue("action"), r.PathValue("pool"), clientIP(r)) + w.WriteHeader(204) +} + +func (s *Server) zfsSnapshot(w http.ResponseWriter, r *http.Request) { + var body struct { + Dataset string `json:"dataset"` + Name string `json:"name"` + } + _ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body) + if err := s.storage.CreateSnapshot(r.Context(), body.Dataset, body.Name); err != nil { + jsonErr(w, 400, err.Error()) + return + } + log.Printf("zfs: snapshot %s@%s (from %s)", body.Dataset, body.Name, clientIP(r)) + w.WriteHeader(204) +} + // ---- dashboard layout prefs (opaque JSON object, owned by the UI) ---- func (s *Server) getDashPrefs(w http.ResponseWriter, _ *http.Request) { diff --git a/internal/storage/storage.go b/internal/storage/storage.go new file mode 100644 index 0000000..5748781 --- /dev/null +++ b/internal/storage/storage.go @@ -0,0 +1,639 @@ +// Package storage is the Disks tab: block devices from lsblk, SMART health via +// smartctl (optional; the tab explains how to get it), and ZFS pools/datasets/snapshots +// when the zfs tools and module are present. Spinning disks are never woken just to +// read SMART (-n standby). +package storage + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "syscall" + "time" +) + +// ---- block devices ---- + +type Part struct { + Name string `json:"name"` + Type string `json:"type"` // part | crypt | lvm | md | … + FSType string `json:"fstype,omitempty"` + Mount string `json:"mount,omitempty"` + Label string `json:"label,omitempty"` + Size uint64 `json:"size"` + Used uint64 `json:"used,omitempty"` + FSSize uint64 `json:"fs_size,omitempty"` +} + +type Device struct { + Name string `json:"name"` + Kind string `json:"kind"` // nvme | ssd | hdd | usb | other + Model string `json:"model,omitempty"` + Vendor string `json:"vendor,omitempty"` + Serial string `json:"serial,omitempty"` + Tran string `json:"tran,omitempty"` + Size uint64 `json:"size"` + Rotational bool `json:"rotational"` + Removable bool `json:"removable"` + Temp float64 `json:"temp,omitempty"` + Parts []Part `json:"parts"` +} + +type lsblkNode struct { + Name string `json:"name"` + Type string `json:"type"` + Size uint64 `json:"size"` + Model *string `json:"model"` + Serial *string `json:"serial"` + Vendor *string `json:"vendor"` + Rota bool `json:"rota"` + Tran *string `json:"tran"` + FSType *string `json:"fstype"` + Mountpoint *string `json:"mountpoint"` + Label *string `json:"label"` + RM bool `json:"rm"` + Children []lsblkNode `json:"children"` +} + +func str(p *string) string { + if p == nil { + return "" + } + return strings.TrimSpace(*p) +} + +func Devices(ctx context.Context) ([]Device, error) { + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, "lsblk", "-J", "-b", "-o", "NAME,TYPE,SIZE,MODEL,SERIAL,VENDOR,ROTA,TRAN,FSTYPE,MOUNTPOINT,LABEL,RM").Output() + if err != nil { + return nil, errors.New("lsblk: " + err.Error()) + } + var root struct { + Blockdevices []lsblkNode `json:"blockdevices"` + } + if err := json.Unmarshal(out, &root); err != nil { + return nil, err + } + devs := []Device{} + for _, n := range root.Blockdevices { + if n.Type != "disk" || n.Size == 0 || strings.HasPrefix(n.Name, "zd") { + continue + } + d := Device{Name: n.Name, Model: str(n.Model), Vendor: str(n.Vendor), Serial: str(n.Serial), Tran: str(n.Tran), + Size: n.Size, Rotational: n.Rota, Removable: n.RM, Temp: blockTemp(n.Name), Parts: []Part{}} + switch { + case d.Tran == "usb": + d.Kind = "usb" + case d.Tran == "nvme" || strings.HasPrefix(n.Name, "nvme"): + d.Kind = "nvme" + case n.Rota: + d.Kind = "hdd" + case d.Tran == "sata" || d.Tran == "sas" || strings.HasPrefix(n.Name, "sd") || strings.HasPrefix(n.Name, "mmcblk"): + d.Kind = "ssd" + default: + d.Kind = "other" + } + var walk func(nodes []lsblkNode) + walk = func(nodes []lsblkNode) { + for _, c := range nodes { + p := Part{Name: c.Name, Type: c.Type, FSType: str(c.FSType), Mount: str(c.Mountpoint), Label: str(c.Label), Size: c.Size} + if p.Mount != "" && p.Mount != "[SWAP]" { + var st syscall.Statfs_t + if syscall.Statfs(p.Mount, &st) == nil { + bs := uint64(st.Bsize) + p.FSSize, p.Used = st.Blocks*bs, (st.Blocks-st.Bfree)*bs + } + } + d.Parts = append(d.Parts, p) + walk(c.Children) + } + } + walk(n.Children) + devs = append(devs, d) + } + sort.Slice(devs, func(i, j int) bool { return devs[i].Name < devs[j].Name }) + return devs, nil +} + +func blockTemp(name string) float64 { + for _, pat := range []string{"/sys/block/" + name + "/device/hwmon*", "/sys/block/" + name + "/device/hwmon/hwmon*"} { + dirs, _ := filepath.Glob(pat) + for _, d := range dirs { + if b, err := os.ReadFile(filepath.Join(d, "temp1_input")); err == nil { + if v, err := strconv.Atoi(strings.TrimSpace(string(b))); err == nil { + return float64(v) / 1000 + } + } + } + } + return 0 +} + +var devRe = regexp.MustCompile(`^[a-z][a-z0-9]*$`) + +func validDevice(name string) bool { + if !devRe.MatchString(name) { + return false + } + _, err := os.Stat("/sys/block/" + name) + return err == nil +} + +// ---- SMART ---- + +type Attr struct { + ID int `json:"id"` + Name string `json:"name"` + Value int `json:"value"` + Worst int `json:"worst"` + Thresh int `json:"thresh"` + Raw string `json:"raw"` + Failed string `json:"failed,omitempty"` + Warn bool `json:"warn"` +} + +type Smart struct { + Device string `json:"device"` + Available bool `json:"available"` // smartctl installed + Supported bool `json:"supported"` // device answered + Standby bool `json:"standby"` // spun down; not read + Passed *bool `json:"passed"` + Temp int `json:"temp,omitempty"` + PowerOnHours int `json:"power_on_hours,omitempty"` + PowerCycles int `json:"power_cycles,omitempty"` + Model string `json:"model,omitempty"` + Serial string `json:"serial,omitempty"` + Firmware string `json:"firmware,omitempty"` + Attrs []Attr `json:"attrs"` + NVMe *NVMeLog `json:"nvme,omitempty"` + SelfTest string `json:"self_test,omitempty"` + SelfTestPct int `json:"self_test_remaining,omitempty"` + Warnings []string `json:"warnings"` + Error string `json:"error,omitempty"` + FetchedAt int64 `json:"fetched_at"` +} + +type NVMeLog struct { + CriticalWarning int `json:"critical_warning"` + PercentageUsed int `json:"percentage_used"` + AvailableSpare int `json:"available_spare"` + SpareThreshold int `json:"spare_threshold"` + MediaErrors int64 `json:"media_errors"` + ErrorLogEntries int64 `json:"error_log_entries"` + DataUnitsRead uint64 `json:"data_read_bytes"` + DataUnitsWritten uint64 `json:"data_written_bytes"` + UnsafeShutdowns int64 `json:"unsafe_shutdowns"` +} + +type Manager struct { + sudo bool + smartctl bool + mu sync.Mutex + cache map[string]Smart +} + +// lookPath also checks the sbin directories, which a user's PATH often lacks. +func lookPath(bin string) (string, bool) { + if p, err := exec.LookPath(bin); err == nil { + return p, true + } + for _, d := range []string{"/usr/sbin", "/sbin", "/usr/local/sbin"} { + if _, err := os.Stat(d + "/" + bin); err == nil { + return d + "/" + bin, true + } + } + return "", false +} + +func New(sudo bool) *Manager { + _, ok := lookPath("smartctl") + return &Manager{sudo: sudo, smartctl: ok, cache: map[string]Smart{}} +} + +func (m *Manager) HasSmartctl() bool { return m.smartctl } + +func (m *Manager) run(ctx context.Context, args ...string) ([]byte, int, error) { + if p, ok := lookPath(args[0]); ok { + args[0] = p + } + if m.sudo { + args = append([]string{"sudo", "-n"}, args...) + } + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, args[0], args[1:]...) + var out, errb bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &errb + err := cmd.Run() + code := 0 + var ee *exec.ExitError + if errors.As(err, &ee) { + code = ee.ExitCode() + err = nil // smartctl and zpool use exit codes as status; the caller decides + if len(out.Bytes()) == 0 && errb.Len() > 0 { + err = errors.New(strings.TrimSpace(strings.Split(errb.String(), "\n")[0])) // nothing usable came back; say why + } + } + if err != nil { + msg := strings.TrimSpace(errb.String()) + if msg == "" { + msg = err.Error() + } + return out.Bytes(), code, errors.New(msg) + } + return out.Bytes(), code, nil +} + +func num(v any) int64 { + switch x := v.(type) { + case float64: + return int64(x) + case json.Number: + n, _ := x.Int64() + return n + } + return 0 +} + +// Smart reads SMART for one device. Cached for 10 minutes; spinning disks in standby are +// left asleep and reported as such. force re-reads (and will wake the disk). +func (m *Manager) Smart(ctx context.Context, dev string, force bool) Smart { + s := Smart{Device: dev, Available: m.smartctl, Attrs: []Attr{}, Warnings: []string{}, FetchedAt: time.Now().UnixMilli()} + if !validDevice(dev) { + s.Error = "unknown device" + return s + } + if !m.smartctl { + return s + } + m.mu.Lock() + if c, ok := m.cache[dev]; ok && !force && time.Since(time.UnixMilli(c.FetchedAt)) < 10*time.Minute { + m.mu.Unlock() + return c + } + m.mu.Unlock() + + args := []string{"smartctl", "-j", "-a"} + if !force { + args = append(args, "-n", "standby") + } + args = append(args, "/dev/"+dev) + out, code, err := m.run(ctx, args...) + if err != nil && len(out) == 0 { + s.Error = err.Error() + return s + } + var raw map[string]any + if json.Unmarshal(out, &raw) != nil { + s.Error = "smartctl: unreadable output" + return s + } + if code&2 != 0 { // -n standby: device is asleep (also "open failed", but JSON will say) + if msgs, ok := raw["smartctl"].(map[string]any); ok { + for _, mm := range asList(msgs["messages"]) { + if strings.Contains(strings.ToLower(asString(mm["string"])), "standby") { + s.Standby = true + } + } + } + if s.Standby { + m.mu.Lock() + m.cache[dev] = s + m.mu.Unlock() + return s + } + } + if st, ok := raw["smart_status"].(map[string]any); ok { + p := st["passed"] == true + s.Passed = &p + s.Supported = true + } + s.Model = asString(raw["model_name"]) + s.Serial = asString(raw["serial_number"]) + s.Firmware = asString(raw["firmware_version"]) + if t, ok := raw["temperature"].(map[string]any); ok { + s.Temp = int(num(t["current"])) + } + if t, ok := raw["power_on_time"].(map[string]any); ok { + s.PowerOnHours = int(num(t["hours"])) + } + s.PowerCycles = int(num(raw["power_cycle_count"])) + + if a, ok := raw["ata_smart_attributes"].(map[string]any); ok { + for _, row := range asList(a["table"]) { + at := Attr{ID: int(num(row["id"])), Name: asString(row["name"]), Value: int(num(row["value"])), Worst: int(num(row["worst"])), Thresh: int(num(row["thresh"])), Failed: asString(row["when_failed"])} + if rw, ok := row["raw"].(map[string]any); ok { + at.Raw = asString(rw["string"]) + rv := num(rw["value"]) + switch at.ID { + case 5, 187, 188, 196, 197, 198: + at.Warn = rv > 0 + case 199: + at.Warn = rv > 0 + case 10, 11: + at.Warn = rv > 100 + } + if at.Warn { + s.Warnings = append(s.Warnings, at.Name+" = "+at.Raw) + } + } + if at.Failed != "" { + at.Warn = true + s.Warnings = append(s.Warnings, at.Name+" failed ("+at.Failed+")") + } + s.Attrs = append(s.Attrs, at) + } + } + if n, ok := raw["nvme_smart_health_information_log"].(map[string]any); ok { + l := &NVMeLog{CriticalWarning: int(num(n["critical_warning"])), PercentageUsed: int(num(n["percentage_used"])), + AvailableSpare: int(num(n["available_spare"])), SpareThreshold: int(num(n["available_spare_threshold"])), + MediaErrors: num(n["media_errors"]), ErrorLogEntries: num(n["num_err_log_entries"]), UnsafeShutdowns: num(n["unsafe_shutdowns"]), + DataUnitsRead: uint64(num(n["data_units_read"])) * 512000, DataUnitsWritten: uint64(num(n["data_units_written"])) * 512000} + s.NVMe = l + s.Supported = true + if l.CriticalWarning != 0 { + s.Warnings = append(s.Warnings, "critical warning flags set") + } + if l.MediaErrors > 0 { + s.Warnings = append(s.Warnings, "media errors: "+strconv.FormatInt(l.MediaErrors, 10)) + } + if l.SpareThreshold > 0 && l.AvailableSpare < l.SpareThreshold { + s.Warnings = append(s.Warnings, "available spare below threshold") + } + if l.PercentageUsed >= 90 { + s.Warnings = append(s.Warnings, "endurance "+strconv.Itoa(l.PercentageUsed)+"% used") + } + } + if d, ok := raw["ata_smart_data"].(map[string]any); ok { + if st, ok := d["self_test"].(map[string]any); ok { + if ss, ok := st["status"].(map[string]any); ok { + s.SelfTest = asString(ss["string"]) + s.SelfTestPct = int(num(ss["remaining_percent"])) + } + } + } + if s.Passed != nil && !*s.Passed { + s.Warnings = append([]string{"SMART overall health: FAILED"}, s.Warnings...) + } + if !s.Supported && s.Error == "" { + s.Error = "device does not report SMART" + if msgs, ok := raw["smartctl"].(map[string]any); ok { + for _, mm := range asList(msgs["messages"]) { + if asString(mm["severity"]) == "error" { + s.Error = asString(mm["string"]) + } + } + } + } + m.mu.Lock() + m.cache[dev] = s + m.mu.Unlock() + return s +} + +func asList(v any) []map[string]any { + var out []map[string]any + if l, ok := v.([]any); ok { + for _, x := range l { + if mm, ok := x.(map[string]any); ok { + out = append(out, mm) + } + } + } + return out +} + +func asString(v any) string { + if s, ok := v.(string); ok { + return s + } + return "" +} + +// SelfTest starts a short or long self-test. +func (m *Manager) SelfTest(ctx context.Context, dev, kind string) error { + if !validDevice(dev) { + return errors.New("unknown device") + } + if kind != "short" && kind != "long" { + return errors.New("test must be short or long") + } + if !m.smartctl { + return errors.New("smartctl is not installed") + } + out, code, err := m.run(ctx, "smartctl", "-t", kind, "/dev/"+dev) + if err != nil { + return err + } + if code != 0 { + return errors.New(strings.TrimSpace(lastLine(string(out)))) + } + m.mu.Lock() + delete(m.cache, dev) + m.mu.Unlock() + return nil +} + +func lastLine(s string) string { + lines := strings.Split(strings.TrimSpace(s), "\n") + return lines[len(lines)-1] +} + +// ---- ZFS ---- + +type Pool struct { + Name string `json:"name"` + Size uint64 `json:"size"` + Alloc uint64 `json:"alloc"` + Free uint64 `json:"free"` + Frag int `json:"frag"` + Cap int `json:"cap"` + Health string `json:"health"` + Dedup string `json:"dedup"` + Scan string `json:"scan,omitempty"` + Errors string `json:"errors,omitempty"` + Config string `json:"config,omitempty"` // vdev tree, verbatim +} + +type Dataset struct { + Name string `json:"name"` + Type string `json:"type"` + Used uint64 `json:"used"` + Avail uint64 `json:"avail"` + Refer uint64 `json:"refer"` + UsedBySnap uint64 `json:"used_by_snapshots"` + Mount string `json:"mount,omitempty"` + Compress string `json:"compressratio"` + Snapshots int `json:"snapshots"` +} + +type Snapshot struct { + Name string `json:"name"` + Used uint64 `json:"used"` + Created int64 `json:"created"` // unix ms +} + +type ZFS struct { + Available bool `json:"available"` + Message string `json:"message,omitempty"` + Pools []Pool `json:"pools"` + Datasets []Dataset `json:"datasets"` +} + +var poolRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_.-]*$`) +var datasetRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_.:/-]*$`) +var snapRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.:-]*$`) + +func u64(s string) uint64 { v, _ := strconv.ParseUint(s, 10, 64); return v } +func atoi(s string) int { v, _ := strconv.Atoi(strings.TrimSuffix(s, "%")); return v } + +func (m *Manager) ZFS(ctx context.Context) ZFS { + z := ZFS{Pools: []Pool{}, Datasets: []Dataset{}} + if _, ok := lookPath("zpool"); !ok { + z.Message = "ZFS tools not installed" + return z + } + out, code, err := m.run(ctx, "zpool", "list", "-Hp", "-o", "name,size,alloc,free,frag,cap,health,dedup") + if err != nil || code != 0 { + msg := "zpool list failed" + if err != nil { + msg = err.Error() + } + z.Message = msg + return z + } + z.Available = true + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + f := strings.Split(line, "\t") + if len(f) < 8 { + continue + } + p := Pool{Name: f[0], Size: u64(f[1]), Alloc: u64(f[2]), Free: u64(f[3]), Frag: atoi(f[4]), Cap: atoi(f[5]), Health: f[6], Dedup: f[7]} + if st, _, err := m.run(ctx, "zpool", "status", p.Name); err == nil { + p.Scan, p.Errors, p.Config = parseStatus(string(st)) + } + z.Pools = append(z.Pools, p) + } + snapCount := map[string]int{} + if so, _, err := m.run(ctx, "zfs", "list", "-t", "snapshot", "-H", "-o", "name"); err == nil { + for _, line := range strings.Split(strings.TrimSpace(string(so)), "\n") { + if ds, _, ok := strings.Cut(line, "@"); ok { + snapCount[ds]++ + } + } + } + if do, _, err := m.run(ctx, "zfs", "list", "-Hp", "-t", "filesystem,volume", "-o", "name,type,used,avail,refer,usedbysnapshots,mountpoint,compressratio"); err == nil { + for _, line := range strings.Split(strings.TrimSpace(string(do)), "\n") { + f := strings.Split(line, "\t") + if len(f) < 8 { + continue + } + d := Dataset{Name: f[0], Type: f[1], Used: u64(f[2]), Avail: u64(f[3]), Refer: u64(f[4]), UsedBySnap: u64(f[5]), Compress: f[7], Snapshots: snapCount[f[0]]} + if f[6] != "-" && f[6] != "none" && f[6] != "legacy" { + d.Mount = f[6] + } + z.Datasets = append(z.Datasets, d) + } + } + return z +} + +// parseStatus pulls the scan line, the errors line and the vdev table out of zpool status. +func parseStatus(text string) (scan, errs, config string) { + lines := strings.Split(text, "\n") + inConfig := false + var cfg []string + for i := 0; i < len(lines); i++ { + l := lines[i] + t := strings.TrimSpace(l) + switch { + case strings.HasPrefix(t, "scan:"): + scan = strings.TrimSpace(strings.TrimPrefix(t, "scan:")) + for i+1 < len(lines) && strings.HasPrefix(lines[i+1], "\t") && !strings.Contains(lines[i+1], ":") { + i++ + scan += " " + strings.TrimSpace(lines[i]) + } + case strings.HasPrefix(t, "errors:"): + errs = strings.TrimSpace(strings.TrimPrefix(t, "errors:")) + inConfig = false + case strings.HasPrefix(t, "config:"): + inConfig = true + case inConfig && t != "": + cfg = append(cfg, strings.TrimPrefix(l, "\t")) + } + } + return scan, errs, strings.Join(cfg, "\n") +} + +func (m *Manager) Snapshots(ctx context.Context, dataset string) ([]Snapshot, error) { + if !datasetRe.MatchString(dataset) { + return nil, errors.New("bad dataset name") + } + out, code, err := m.run(ctx, "zfs", "list", "-t", "snapshot", "-Hp", "-d", "1", "-o", "name,used,creation", "-s", "creation", dataset) + if err != nil { + return nil, err + } + if code != 0 { + return nil, errors.New("zfs list failed") + } + snaps := []Snapshot{} + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + f := strings.Split(line, "\t") + if len(f) < 3 { + continue + } + c, _ := strconv.ParseInt(f[2], 10, 64) + snaps = append(snaps, Snapshot{Name: f[0], Used: u64(f[1]), Created: c * 1000}) + } + return snaps, nil +} + +// Scrub starts, pauses or stops a scrub. action: start | pause | stop. +func (m *Manager) Scrub(ctx context.Context, pool, action string) error { + if !poolRe.MatchString(pool) { + return errors.New("bad pool name") + } + args := []string{"zpool", "scrub"} + switch action { + case "start": + case "pause": + args = append(args, "-p") + case "stop": + args = append(args, "-s") + default: + return errors.New("unknown scrub action") + } + out, code, err := m.run(ctx, append(args, pool)...) + if err != nil { + return err + } + if code != 0 { + return errors.New(strings.TrimSpace(string(out))) + } + return nil +} + +// CreateSnapshot takes dataset@name. +func (m *Manager) CreateSnapshot(ctx context.Context, dataset, name string) error { + if !datasetRe.MatchString(dataset) || !snapRe.MatchString(name) { + return errors.New("bad snapshot name") + } + out, code, err := m.run(ctx, "zfs", "snapshot", dataset+"@"+name) + if err != nil { + return err + } + if code != 0 { + return errors.New(strings.TrimSpace(string(out))) + } + return nil +} diff --git a/internal/updates/updates.go b/internal/updates/updates.go index 1d2eef6..99fafb0 100644 --- a/internal/updates/updates.go +++ b/internal/updates/updates.go @@ -315,6 +315,20 @@ func (m *Manager) Upgrade(pkgs []string) (*tasks.Task, error) { return m.start("apt-get upgrade "+strings.Join(pkgs, " "), args...) } +// Install installs packages (used by other tabs for optional tooling like smartmontools). +func (m *Manager) Install(pkgs []string) (*tasks.Task, error) { + if len(pkgs) == 0 { + return nil, errors.New("no packages") + } + for _, p := range pkgs { + if !pkgNameRe.MatchString(p) { + return nil, errors.New("bad package name: " + p) + } + } + args := append([]string{"apt-get", "-y", "--no-install-recommends", "install"}, pkgs...) + return m.start("apt-get install "+strings.Join(pkgs, " "), args...) +} + func (m *Manager) Changelog(ctx context.Context, pkg string) (string, error) { if !pkgNameRe.MatchString(pkg) { return "", errors.New("bad package name") diff --git a/web/src/Disks.tsx b/web/src/Disks.tsx new file mode 100644 index 0000000..98bd664 --- /dev/null +++ b/web/src/Disks.tsx @@ -0,0 +1,228 @@ +import { useCallback, useEffect, useState } from 'react' +import { api, Sample, SmartInfo, StorageDevice, ZFSInfo, ZFSSnapshot } from './api' +import { fmtBytes, fmtRate } from './Dashboard' +import TaskOutput from './TaskOutput' +import { Btn, Loading, Spinner, toast } from './ui' + +const pct = (v: number) => `${Math.round(v)}%` +const KIND: Record = { nvme: 'NVMe', ssd: 'SSD', hdd: 'HDD', usb: 'USB', other: 'disk' } +const hours = (h: number) => (h >= 24 * 365 ? `${(h / 8760).toFixed(1)} y` : h >= 48 ? `${Math.round(h / 24)} d` : `${h} h`) +const ago = (ms: number) => { const s = (Date.now() - ms) / 1000; return s < 3600 ? `${Math.round(s / 60)}m ago` : s < 86400 ? `${Math.round(s / 3600)}h ago` : `${Math.round(s / 86400)}d ago` } + +function Meter({ frac, cls }: { frac: number; cls?: string }) { + const c = cls ?? (frac >= 0.95 ? 'bad' : frac >= 0.85 ? 'warn' : '') + return
+} + +// ---- SMART panel ---- +function SmartPanel({ dev, rotational }: { dev: string; rotational: boolean }) { + const [s, setS] = useState(null) + const [err, setErr] = useState('') + const load = useCallback(async (force = false) => { + setS(null) + try { setS(await api.smart(dev, force)); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } + }, [dev]) + useEffect(() => { load() }, [load]) + + if (err) return
{err}
+ if (!s) return + if (!s.available) return
smartmontools isn't installed, so there's no SMART data. Install it from the button above.
+ if (s.standby) return
Disk is spun down; SMART wasn't read so it stays asleep. load(true)}>Wake and read
+ if (s.error) return
{s.error}
+ const ok = s.passed === true && s.warnings.length === 0 + return ( +
+
+
Health{s.passed === null ? 'unknown' : s.passed ? 'PASSED' : 'FAILED'}{s.warnings.length > 0 && ` · ${s.warnings.length} warning${s.warnings.length > 1 ? 's' : ''}`}
+ {s.temp > 0 &&
Temperature{s.temp}°C
} + {s.power_on_hours > 0 &&
Powered on{hours(s.power_on_hours)} · {s.power_cycles} cycles
} + {s.firmware &&
Firmware{s.firmware}
} + {s.serial &&
Serial{s.serial}
} + {s.self_test &&
Self-test{s.self_test}{s.self_test_remaining ? ` (${s.self_test_remaining}% left)` : ''}
} +
Read{ago(s.fetched_at)}
+
+ {s.warnings.length > 0 &&
{s.warnings.join(' · ')}
} + {s.nvme && ( +
+
Endurance{s.nvme.percentage_used}% used · spare {s.nvme.available_spare}% (threshold {s.nvme.spare_threshold}%)
+
Written{fmtBytes(s.nvme.data_written_bytes)} · read {fmtBytes(s.nvme.data_read_bytes)}
+
Errors{s.nvme.media_errors} media · {s.nvme.error_log_entries} logged · {s.nvme.unsafe_shutdowns} unsafe shutdowns
+
+ )} + {s.attrs.length > 0 && ( + + + + {s.attrs.map((a) => ( + + + + + ))} + +
IDAttributeRawValueWorstThresh
{a.id}{a.name.replace(/_/g, ' ')}{a.failed && {a.failed}}{a.raw}{a.value}{a.worst}{a.thresh}
+ )} +
+ { if (await api.smartTest(dev, 'short').then(() => true, (e) => { toast(String(e.message ?? e), 'err'); return false })) { toast('short test started'); load(true) } }}>Short test + { if (await api.smartTest(dev, 'long').then(() => true, (e) => { toast(String(e.message ?? e), 'err'); return false })) { toast('long test started'); load(true) } }}>Long test +
+
+ ) +} + +// ---- device card ---- +function DeviceCard({ d, live }: { d: StorageDevice; live?: { read_bps: number; write_bps: number; util_pct: number } }) { + const [open, setOpen] = useState(false) + return ( +
+
+ {KIND[d.kind] ?? d.kind} + {d.name} + {[d.vendor, d.model].filter(Boolean).join(' ')} + + {fmtBytes(d.size)}{d.temp ? ` · ${d.temp.toFixed(0)}°C` : ''} +
+ {live &&
read {fmtRate(live.read_bps)} · write {fmtRate(live.write_bps)} · {pct(live.util_pct)} busy
} + {d.parts.map((p) => ( +
+ {p.name}{p.fstype || p.type}{p.label ? ` · ${p.label}` : ''} + {p.fs_size ? : {p.mount === '[SWAP]' ? 'swap' : p.mount ? '' : 'not mounted'}} + {p.fs_size ? `${fmtBytes(p.used ?? 0)} / ${fmtBytes(p.fs_size)} · ${p.mount}` : fmtBytes(p.size)} +
+ ))} + {d.parts.length === 0 &&
No partitions.
} +
+ {open && } +
+ ) +} + +// ---- ZFS ---- +function PoolCard({ p, onChanged }: { p: ZFSInfo['pools'][number]; onChanged: () => void }) { + const scrubbing = /scrub in progress/i.test(p.scan ?? '') + const paused = /scrub paused/i.test(p.scan ?? '') + const [cfg, setCfg] = useState(false) + const act = (a: 'start' | 'pause' | 'stop') => async () => { + try { await api.zfsScrub(p.name, a); toast(`scrub ${a}: ok`); onChanged() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } + } + return ( +
+
+ + {p.name} + {p.health} + + {fmtBytes(p.alloc)} / {fmtBytes(p.size)} · {p.cap}% +
+ = 90 ? 'bad' : p.cap >= 80 ? 'warn' : ''} /> +
+
Free{fmtBytes(p.free)} · fragmentation {p.frag}% · dedup {p.dedup}
+ {p.scan &&
Scan{p.scan}
} + {p.errors &&
Errors{p.errors}
} +
+
+ {!scrubbing && {paused ? 'Resume scrub' : 'Scrub'}} + {scrubbing && Pause scrub} + {(scrubbing || paused) && Stop scrub} + +
+ {cfg &&
{p.config}
} +
+ ) +} + +function DatasetRow({ d, onChanged }: { d: ZFSInfo['datasets'][number]; onChanged: () => void }) { + const [snaps, setSnaps] = useState(null) + const [open, setOpen] = useState(false) + async function toggle() { + setOpen(!open) + if (!open && !snaps) { try { setSnaps(await api.zfsSnapshots(d.name)) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err'); setSnaps([]) } } + } + async function snapshot() { + const def = 'hamlab-' + new Date().toISOString().slice(0, 16).replace(/[-:T]/g, '').replace(/(\d{8})(\d{4})/, '$1-$2') + const name = window.prompt(`Snapshot name for ${d.name}@`, def) + if (!name) return + try { await api.zfsSnapshot(d.name, name.trim()); toast(`${d.name}@${name.trim()} created`); setSnaps(null); onChanged() } + catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } + } + const depth = d.name.split('/').length - 1 + return ( + <> +
+ {open ? '▾' : '▸'}{d.name.split('/').pop()}{d.type === 'volume' && zvol} + {fmtBytes(d.used)} + {fmtBytes(d.refer)} + {d.used_by_snapshots ? fmtBytes(d.used_by_snapshots) : '—'} + {d.snapshots} + {d.compressratio} + {d.mount} +
+ {open && ( +
+
Snapshot nowavail {fmtBytes(d.avail)}
+ {snaps === null ?
listing…
: snaps.length === 0 ?
No snapshots.
: ( +
{snaps.slice().reverse().map((s) =>
{s.name.split('@')[1]}{new Date(s.created).toLocaleString()} · {fmtBytes(s.used)}
)}
+ )} +
+ )} + + ) +} + +// ---- page ---- +export default function Disks() { + const [devs, setDevs] = useState(null) + const [zfs, setZfs] = useState(null) + const [hasSmart, setHasSmart] = useState(true) + const [live, setLive] = useState(null) + const [err, setErr] = useState('') + const [task, setTask] = useState(null) + + const load = useCallback(async () => { + try { + const r = await api.storage() + setDevs(r.devices); setZfs(r.zfs); setHasSmart(r.smartctl); setErr('') + } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } + }, []) + useEffect(() => { load() }, [load]) + useEffect(() => { + let stop = false + const tick = async () => { if (!stop && document.visibilityState === 'visible') { try { setLive(await api.metrics()) } catch { /* ignore */ } } } + tick() + const t = setInterval(tick, 2000) + return () => { stop = true; clearInterval(t) } + }, []) + + if (!devs) return
{err ?
{err}
: }
+ const liveFor = (n: string) => live?.disks.find((d) => d.name === n) + + return ( +
+ {err &&
{err}
} +
+

Devices · {devs.length}

+ + {!hasSmart && { try { const t = await api.installPackages(['smartmontools']); setTask(t.id) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } }}>Install smartmontools} + ↻ +
+ {!hasSmart &&
SMART health needs smartmontools on this host. Install it with the button, then reload.
} + {task && { toast(`${t.title}: ${t.exit_code === 0 ? 'finished' : `exited ${t.exit_code}`}`, t.exit_code === 0 ? 'ok' : 'err'); load() }} />} +
+ {devs.map((d) => )} +
+ +

ZFS

+ {zfs && !zfs.available &&
{zfs.message || 'No ZFS pools on this host.'}
} + {zfs?.available && zfs.pools.length === 0 &&
ZFS is present but no pools are imported.
} + {zfs?.available && zfs.pools.length > 0 && ( + <> +
{zfs.pools.map((p) => )}
+
+
DatasetUsedReferIn snapsSnapsRatioMount
+ {zfs.datasets.map((d) => )} +
+ + )} +
+ ) +} diff --git a/web/src/Shell.tsx b/web/src/Shell.tsx index 37f2f0e..f68fffb 100644 --- a/web/src/Shell.tsx +++ b/web/src/Shell.tsx @@ -7,6 +7,7 @@ import Dashboard from './Dashboard' import Services from './Services' import Cron from './Cron' import Updates from './Updates' +import Disks from './Disks' import Power from './Power' import { Btn, NetBar, Toasts, toast } from './ui' @@ -16,10 +17,10 @@ const GROUPS: Group[] = [ { id: 'services', label: 'Services', icon: '⚙', page: true }, { id: 'cron', label: 'Cron', icon: '⏱', page: true }, { id: 'updates', label: 'Updates', icon: '⇪', page: true }, - { id: 'zfs', label: 'ZFS', icon: '▤', soon: true }, + { id: 'disks', label: 'Disks', icon: '▤', page: true }, ] -type View = 'terminal' | 'dashboard' | 'services' | 'cron' | 'updates' +type View = 'terminal' | 'dashboard' | 'services' | 'cron' | 'updates' | 'disks' function fmtUptime(s: number): string { const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60) @@ -97,6 +98,7 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () => {view === 'services' && } {view === 'cron' && } {view === 'updates' && } + {view === 'disks' && } {termView && terms.length === 0 && (
No terminals.
@@ -119,7 +121,7 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () => {me.version.startsWith('v') ? me.version : 'v' + me.version} up {fmtUptime(me.uptime)} - {view === 'dashboard' ? 'Dashboard' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : activeTerm ? activeTerm.name : ''} + {view === 'dashboard' ? 'Dashboard' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : activeTerm ? activeTerm.name : ''}
) diff --git a/web/src/api.ts b/web/src/api.ts index 0e82729..e1def03 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -69,7 +69,21 @@ export interface TaskInfo { id: string; kind: string; title: string; command: st export interface UpdatePackage { name: string; current: string; candidate: string; suite?: string; security: boolean; description?: string } export interface UpdatesStatus { manager: string; can_upgrade: boolean; packages: UpdatePackage[]; security_count: number; last_refresh?: number; last_upgrade?: number; reboot_needed: boolean; reboot_pkgs: string[]; auto_upgrades: boolean; running_task?: string; sudo: boolean } +export interface StoragePart { name: string; type: string; fstype?: string; mount?: string; label?: string; size: number; used?: number; fs_size?: number } +export interface StorageDevice { name: string; kind: string; model?: string; vendor?: string; serial?: string; tran?: string; size: number; rotational: boolean; removable: boolean; temp?: number; parts: StoragePart[] } +export interface SmartAttr { id: number; name: string; value: number; worst: number; thresh: number; raw: string; failed?: string; warn: boolean } +export interface SmartInfo { device: string; available: boolean; supported: boolean; standby: boolean; passed: boolean | null; temp: number; power_on_hours: number; power_cycles: number; model?: string; serial?: string; firmware?: string; attrs: SmartAttr[]; nvme?: { critical_warning: number; percentage_used: number; available_spare: number; spare_threshold: number; media_errors: number; error_log_entries: number; data_read_bytes: number; data_written_bytes: number; unsafe_shutdowns: number }; self_test?: string; self_test_remaining?: number; warnings: string[]; error?: string; fetched_at: number } +export interface ZFSSnapshot { name: string; used: number; created: number } +export interface ZFSInfo { available: boolean; message?: string; pools: { name: string; size: number; alloc: number; free: number; frag: number; cap: number; health: string; dedup: string; scan?: string; errors?: string; config?: string }[]; datasets: { name: string; type: string; used: number; avail: number; refer: number; used_by_snapshots: number; mount?: string; compressratio: string; snapshots: number }[] } + export const api = { + storage: () => req<{ devices: StorageDevice[]; zfs: ZFSInfo; smartctl: boolean }>('GET', '/api/storage'), + smart: (dev: string, force = false) => req('GET', `/api/storage/smart/${dev}${force ? '?force=1' : ''}`), + smartTest: (dev: string, kind: 'short' | 'long') => req('POST', `/api/storage/smart/${dev}/test/${kind}`), + zfsSnapshots: (dataset: string) => req('GET', `/api/storage/zfs/snapshots?dataset=${encodeURIComponent(dataset)}`), + zfsScrub: (pool: string, action: 'start' | 'pause' | 'stop') => req('POST', `/api/storage/zfs/scrub/${encodeURIComponent(pool)}/${action}`), + zfsSnapshot: (dataset: string, name: string) => req('POST', '/api/storage/zfs/snapshot', { dataset, name }), + installPackages: (packages: string[]) => req('POST', '/api/updates/install', { packages }), tasks: () => req('GET', '/api/tasks'), task: (id: string) => req('GET', `/api/tasks/${id}`), cancelTask: (id: string) => req('POST', `/api/tasks/${id}/cancel`), diff --git a/web/src/styles.css b/web/src/styles.css index 982c8c5..dd6fa2f 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -229,3 +229,23 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi .task.running .logs-bar { background: rgba(57,135,229,.12); } .task.failed .logs-bar { background: rgba(255,123,114,.12); } .task.done .logs-bar { background: rgba(63,185,80,.10); } + +/* disks */ +h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; } +.disk .dname, .card .dname { font-weight: 600; } +.badge { font-size: 10px; text-transform: uppercase; border: 1px solid var(--border); border-radius: 3px; padding: 0 4px; color: var(--muted); } +.good { color: var(--ok); } .warn-text { color: var(--s2); } +.smart { display: flex; flex-direction: column; gap: 8px; padding-top: 8px; border-top: 1px solid var(--border); } +.attrs { width: 100%; border-collapse: collapse; font-size: 12px; } +.attrs th { text-align: left; color: var(--muted); font-weight: 500; padding: 4px 6px; border-bottom: 1px solid var(--border); } +.attrs td { padding: 3px 6px; border-bottom: 1px solid var(--border); } .attrs td.num, .attrs th:not(:nth-child(2)) { text-align: right; } +.attrs tr.warn td { color: var(--s2); } +.datasets { padding: 0; overflow-x: auto; } +.ds-row { display: grid; grid-template-columns: minmax(160px, 1fr) 80px 80px 80px 50px 50px minmax(120px, 1fr); gap: 8px; align-items: center; padding: 6px 12px; border-bottom: 1px solid var(--border); font-size: 13px; min-width: 640px; } +.ds-row.head { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .5px; } +.ds-row .num { text-align: right; } +.ds-name { cursor: pointer; display: flex; align-items: center; gap: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.ds-mount { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, monospace; font-size: 12px; } +.ds-snaps { padding: 8px 12px 10px 40px; border-bottom: 1px solid var(--border); background: var(--bg); display: flex; flex-direction: column; gap: 6px; } +.snap-list { display: flex; flex-direction: column; gap: 2px; max-height: 40vh; overflow-y: auto; } +.snap { display: flex; justify-content: space-between; gap: 12px; font-size: 12px; }