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 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-17 22:48:08 -07:00
co-authored by Claude Fable 5.1
parent 724f30c02b
commit c704e87065
9 changed files with 1023 additions and 9 deletions
+14 -3
View File
@@ -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
+3 -1
View File
@@ -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(),
+86 -2
View File
@@ -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) {
+639
View File
@@ -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
}
+14
View File
@@ -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")
+228
View File
@@ -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<string, string> = { 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 <div className="meter"><div className={'fill ' + c} style={{ width: `${Math.min(100, Math.max(0, frac * 100))}%` }} /></div>
}
// ---- SMART panel ----
function SmartPanel({ dev, rotational }: { dev: string; rotational: boolean }) {
const [s, setS] = useState<SmartInfo | null>(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 <div className="error">{err}</div>
if (!s) return <Loading label="Reading SMART…" />
if (!s.available) return <div className="notice">smartmontools isn't installed, so there's no SMART data. Install it from the button above.</div>
if (s.standby) return <div className="notice">Disk is spun down; SMART wasn't read so it stays asleep. <Btn className="btn" onClick={() => load(true)}>Wake and read</Btn></div>
if (s.error) return <div className="notice">{s.error}</div>
const ok = s.passed === true && s.warnings.length === 0
return (
<div className="smart">
<div className="props">
<div><span className="k">Health</span><span className={ok ? 'good' : 'warn-text'}>{s.passed === null ? 'unknown' : s.passed ? 'PASSED' : 'FAILED'}{s.warnings.length > 0 && ` · ${s.warnings.length} warning${s.warnings.length > 1 ? 's' : ''}`}</span></div>
{s.temp > 0 && <div><span className="k">Temperature</span><span>{s.temp}°C</span></div>}
{s.power_on_hours > 0 && <div><span className="k">Powered on</span><span>{hours(s.power_on_hours)} · {s.power_cycles} cycles</span></div>}
{s.firmware && <div><span className="k">Firmware</span><span className="mono">{s.firmware}</span></div>}
{s.serial && <div><span className="k">Serial</span><span className="mono">{s.serial}</span></div>}
{s.self_test && <div><span className="k">Self-test</span><span>{s.self_test}{s.self_test_remaining ? ` (${s.self_test_remaining}% left)` : ''}</span></div>}
<div><span className="k">Read</span><span className="muted">{ago(s.fetched_at)} <button className="link" onClick={() => load(true)}>refresh</button></span></div>
</div>
{s.warnings.length > 0 && <div className="notice attn">{s.warnings.join(' · ')}</div>}
{s.nvme && (
<div className="props">
<div><span className="k">Endurance</span><span>{s.nvme.percentage_used}% used · spare {s.nvme.available_spare}% (threshold {s.nvme.spare_threshold}%)</span></div>
<div><span className="k">Written</span><span>{fmtBytes(s.nvme.data_written_bytes)} · read {fmtBytes(s.nvme.data_read_bytes)}</span></div>
<div><span className="k">Errors</span><span>{s.nvme.media_errors} media · {s.nvme.error_log_entries} logged · {s.nvme.unsafe_shutdowns} unsafe shutdowns</span></div>
</div>
)}
{s.attrs.length > 0 && (
<table className="attrs">
<thead><tr><th>ID</th><th>Attribute</th><th>Raw</th><th>Value</th><th>Worst</th><th>Thresh</th></tr></thead>
<tbody>
{s.attrs.map((a) => (
<tr key={a.id} className={a.warn ? 'warn' : ''}>
<td className="num">{a.id}</td><td>{a.name.replace(/_/g, ' ')}{a.failed && <span className="badge bad">{a.failed}</span>}</td>
<td className="num">{a.raw}</td><td className="num">{a.value}</td><td className="num">{a.worst}</td><td className="num">{a.thresh}</td>
</tr>
))}
</tbody>
</table>
)}
<div className="svc-actions">
<Btn className="btn" confirm={`Start a short self-test on ${dev}?`} onClick={async () => { 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</Btn>
<Btn className="btn" confirm={`Start a long self-test on ${dev}? ${rotational ? 'This can take hours on a spinning disk.' : ''}`} onClick={async () => { 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</Btn>
</div>
</div>
)
}
// ---- device card ----
function DeviceCard({ d, live }: { d: StorageDevice; live?: { read_bps: number; write_bps: number; util_pct: number } }) {
const [open, setOpen] = useState(false)
return (
<section className="card disk">
<div className="sub-head">
<span className="badge">{KIND[d.kind] ?? d.kind}</span>
<span className="dname">{d.name}</span>
<span className="model">{[d.vendor, d.model].filter(Boolean).join(' ')}</span>
<span className="spacer" />
<span className="num">{fmtBytes(d.size)}{d.temp ? ` · ${d.temp.toFixed(0)}°C` : ''}</span>
</div>
{live && <div className="sub-head num muted">read {fmtRate(live.read_bps)} · write {fmtRate(live.write_bps)} · {pct(live.util_pct)} busy</div>}
{d.parts.map((p) => (
<div className="row" key={p.name}>
<span className="k">{p.name}<small>{p.fstype || p.type}{p.label ? ` · ${p.label}` : ''}</small></span>
{p.fs_size ? <Meter frac={(p.used ?? 0) / p.fs_size} /> : <span className="muted">{p.mount === '[SWAP]' ? 'swap' : p.mount ? '' : 'not mounted'}</span>}
<span className="num">{p.fs_size ? `${fmtBytes(p.used ?? 0)} / ${fmtBytes(p.fs_size)} · ${p.mount}` : fmtBytes(p.size)}</span>
</div>
))}
{d.parts.length === 0 && <div className="muted">No partitions.</div>}
<div className="svc-actions"><button className="btn" onClick={() => setOpen(!open)}>{open ? 'Hide SMART' : 'SMART'}</button></div>
{open && <SmartPanel dev={d.name} rotational={d.rotational} />}
</section>
)
}
// ---- 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 (
<section className="card">
<div className="sub-head">
<span className={'dot ' + (p.health === 'ONLINE' ? 'ok' : p.health === 'DEGRADED' ? 'warn' : 'bad')} />
<span className="dname">{p.name}</span>
<span className="badge">{p.health}</span>
<span className="spacer" />
<span className="num">{fmtBytes(p.alloc)} / {fmtBytes(p.size)} · {p.cap}%</span>
</div>
<Meter frac={p.cap / 100} cls={p.cap >= 90 ? 'bad' : p.cap >= 80 ? 'warn' : ''} />
<div className="props" style={{ marginTop: 8 }}>
<div><span className="k">Free</span><span>{fmtBytes(p.free)} · fragmentation {p.frag}% · dedup {p.dedup}</span></div>
{p.scan && <div><span className="k">Scan</span><span style={{ whiteSpace: 'normal' }}>{p.scan}</span></div>}
{p.errors && <div><span className="k">Errors</span><span className={p.errors.startsWith('No known') ? '' : 'warn-text'}>{p.errors}</span></div>}
</div>
<div className="svc-actions">
{!scrubbing && <Btn className="btn" confirm={`Start a scrub of ${p.name}? It reads the whole pool and can take many hours.`} onClick={act('start')}>{paused ? 'Resume scrub' : 'Scrub'}</Btn>}
{scrubbing && <Btn className="btn" onClick={act('pause')}>Pause scrub</Btn>}
{(scrubbing || paused) && <Btn className="btn" confirm="Stop the scrub?" onClick={act('stop')}>Stop scrub</Btn>}
<button className="btn" onClick={() => setCfg(!cfg)}>{cfg ? 'Hide vdevs' : 'Vdevs'}</button>
</div>
{cfg && <pre className="unit-file">{p.config}</pre>}
</section>
)
}
function DatasetRow({ d, onChanged }: { d: ZFSInfo['datasets'][number]; onChanged: () => void }) {
const [snaps, setSnaps] = useState<ZFSSnapshot[] | null>(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 (
<>
<div className="ds-row" style={{ paddingLeft: 12 + depth * 16 }}>
<span className="ds-name" onClick={toggle}><span className="chev">{open ? '▾' : '▸'}</span>{d.name.split('/').pop()}{d.type === 'volume' && <span className="badge">zvol</span>}</span>
<span className="num">{fmtBytes(d.used)}</span>
<span className="num muted">{fmtBytes(d.refer)}</span>
<span className="num muted">{d.used_by_snapshots ? fmtBytes(d.used_by_snapshots) : '—'}</span>
<span className="num muted">{d.snapshots}</span>
<span className="num muted">{d.compressratio}</span>
<span className="ds-mount muted">{d.mount}</span>
</div>
{open && (
<div className="ds-snaps">
<div className="svc-actions"><Btn className="btn" onClick={snapshot}>Snapshot now</Btn><span className="muted">avail {fmtBytes(d.avail)}</span></div>
{snaps === null ? <div className="muted"><Spinner /> listing…</div> : snaps.length === 0 ? <div className="muted">No snapshots.</div> : (
<div className="snap-list">{snaps.slice().reverse().map((s) => <div key={s.name} className="snap"><span className="mono">{s.name.split('@')[1]}</span><span className="num muted">{new Date(s.created).toLocaleString()} · {fmtBytes(s.used)}</span></div>)}</div>
)}
</div>
)}
</>
)
}
// ---- page ----
export default function Disks() {
const [devs, setDevs] = useState<StorageDevice[] | null>(null)
const [zfs, setZfs] = useState<ZFSInfo | null>(null)
const [hasSmart, setHasSmart] = useState(true)
const [live, setLive] = useState<Sample | null>(null)
const [err, setErr] = useState('')
const [task, setTask] = useState<string | null>(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 <div className="dash">{err ? <div className="error">{err}</div> : <Loading label="Scanning disks…" />}</div>
const liveFor = (n: string) => live?.disks.find((d) => d.name === n)
return (
<div className="dash disks">
{err && <div className="error">{err}</div>}
<div className="dash-bar">
<h2 className="section">Devices · {devs.length}</h2>
<span className="spacer" />
{!hasSmart && <Btn className="btn" onClick={async () => { try { const t = await api.installPackages(['smartmontools']); setTask(t.id) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } }}>Install smartmontools</Btn>}
<Btn className="btn" title="reload" onClick={load}>↻</Btn>
</div>
{!hasSmart && <div className="notice">SMART health needs smartmontools on this host. Install it with the button, then reload.</div>}
{task && <TaskOutput id={task} onDone={(t) => { toast(`${t.title}: ${t.exit_code === 0 ? 'finished' : `exited ${t.exit_code}`}`, t.exit_code === 0 ? 'ok' : 'err'); load() }} />}
<div className="cards">
{devs.map((d) => <DeviceCard key={d.name} d={d} live={liveFor(d.name)} />)}
</div>
<div className="dash-bar"><h2 className="section">ZFS</h2></div>
{zfs && !zfs.available && <div className="notice">{zfs.message || 'No ZFS pools on this host.'}</div>}
{zfs?.available && zfs.pools.length === 0 && <div className="notice">ZFS is present but no pools are imported.</div>}
{zfs?.available && zfs.pools.length > 0 && (
<>
<div className="cards">{zfs.pools.map((p) => <PoolCard key={p.name} p={p} onChanged={load} />)}</div>
<section className="card datasets">
<div className="ds-row head"><span>Dataset</span><span className="num">Used</span><span className="num">Refer</span><span className="num">In snaps</span><span className="num">Snaps</span><span className="num">Ratio</span><span>Mount</span></div>
{zfs.datasets.map((d) => <DatasetRow key={d.name} d={d} onChanged={load} />)}
</section>
</>
)}
</div>
)
}
+5 -3
View File
@@ -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' && <Services />}
{view === 'cron' && <Cron />}
{view === 'updates' && <Updates />}
{view === 'disks' && <Disks />}
{termView && terms.length === 0 && (
<div className="center muted">
<div>No terminals.</div>
@@ -119,7 +121,7 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
<span>{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
<span>up {fmtUptime(me.uptime)}</span>
<span className="spacer" />
<span className="muted">{view === 'dashboard' ? 'Dashboard' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : activeTerm ? activeTerm.name : ''}</span>
<span className="muted">{view === 'dashboard' ? 'Dashboard' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : activeTerm ? activeTerm.name : ''}</span>
</footer>
</div>
)
+14
View File
@@ -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<SmartInfo>('GET', `/api/storage/smart/${dev}${force ? '?force=1' : ''}`),
smartTest: (dev: string, kind: 'short' | 'long') => req<void>('POST', `/api/storage/smart/${dev}/test/${kind}`),
zfsSnapshots: (dataset: string) => req<ZFSSnapshot[]>('GET', `/api/storage/zfs/snapshots?dataset=${encodeURIComponent(dataset)}`),
zfsScrub: (pool: string, action: 'start' | 'pause' | 'stop') => req<void>('POST', `/api/storage/zfs/scrub/${encodeURIComponent(pool)}/${action}`),
zfsSnapshot: (dataset: string, name: string) => req<void>('POST', '/api/storage/zfs/snapshot', { dataset, name }),
installPackages: (packages: string[]) => req<TaskInfo>('POST', '/api/updates/install', { packages }),
tasks: () => req<TaskInfo[]>('GET', '/api/tasks'),
task: (id: string) => req<TaskInfo>('GET', `/api/tasks/${id}`),
cancelTask: (id: string) => req<void>('POST', `/api/tasks/${id}/cancel`),
+20
View File
@@ -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; }