Files
Mitchell-JohnsonandClaude Fable 5.1 8ee2a2d293 Bug bash 2026-09-19: 17 bugs fixed and verified (BUGS.md)
Widgets: a bad pattern no longer silences every widget; malformed layouts load.
Shell: lost session returns to login at once; tab titles; Escape closes menus and dialogs.
Files: names are one path segment; unsaved edits ask before page/machine/tab changes;
only folders that opened are remembered. Terminals: unique default names; 64-char names.
API: queries capped at 10 000 values; 32 streams per client, 512 in all; only
/embed/widget is frameable. ZFS sensors: one unprivileged zpool list, no sudo journal spam.
Phone: 36 px inputs; dataset rows as wide as their cells. Machines: valid name pattern.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 10:18:23 -07:00

927 lines
29 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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/hex"
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"syscall"
"time"
"hamlab/internal/priv"
)
// ---- 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 | emmc | sd | 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"`
Namespaces []string `json:"namespaces,omitempty"` // NVMe: the block devices behind this controller
}
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()
var root struct {
Blockdevices []lsblkNode `json:"blockdevices"`
}
// lsblk knows labels and stacked devices (LVM, LUKS, md); without it (Alpine and other
// busybox systems don't ship it) or with one too old for JSON, /sys has the essentials.
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 || json.Unmarshal(out, &root) != nil {
root.Blockdevices = sysBlockDevices()
}
devs := []Device{}
for _, n := range root.Blockdevices {
if n.Type != "disk" || n.Size == 0 || strings.HasPrefix(n.Name, "zd") || mmcAreaRe.MatchString(n.Name) {
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 strings.HasPrefix(n.Name, "mmcblk"):
d.Kind = mmcKind(n.Name)
if d.Model == "" {
d.Model = strings.TrimSpace(readStrFile("/sys/block/" + n.Name + "/device/name"))
}
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"):
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)
}
devs = foldNVMe(devs)
sort.Slice(devs, func(i, j int) bool { return devs[i].Name < devs[j].Name })
return devs, nil
}
// sysBlockDevices builds the same tree lsblk would from /sys/block and /proc/mounts.
func sysBlockDevices() []lsblkNode {
type mnt struct{ point, fstype string }
mounts := map[string]mnt{}
if b, err := os.ReadFile("/proc/mounts"); err == nil {
for _, line := range strings.Split(string(b), "\n") {
if f := strings.Fields(line); len(f) >= 3 && strings.HasPrefix(f[0], "/dev/") {
dev := f[0]
if real, err := filepath.EvalSymlinks(dev); err == nil { // /dev/disk/by-uuid/…, /dev/mapper/…
dev = real
}
if _, seen := mounts[filepath.Base(dev)]; !seen {
mounts[filepath.Base(dev)] = mnt{strings.ReplaceAll(f[1], `\040`, " "), f[2]}
}
}
}
}
if b, err := os.ReadFile("/proc/swaps"); err == nil {
for _, line := range strings.Split(string(b), "\n") {
if f := strings.Fields(line); len(f) > 0 && strings.HasPrefix(f[0], "/dev/") {
mounts[filepath.Base(f[0])] = mnt{"[SWAP]", "swap"}
}
}
}
sp := func(v string) *string {
if v = strings.TrimSpace(v); v == "" {
return nil
}
return &v
}
node := func(dir, name, typ string) lsblkNode {
n := lsblkNode{Name: name, Type: typ, Size: u64(strings.TrimSpace(readStrFile(dir+"/size"))) * 512}
if m, ok := mounts[name]; ok {
n.Mountpoint, n.FSType = sp(m.point), sp(m.fstype)
}
return n
}
var out []lsblkNode
ents, _ := os.ReadDir("/sys/block")
for _, e := range ents {
name := e.Name()
dir := "/sys/block/" + name
if strings.HasPrefix(name, "loop") || strings.HasPrefix(name, "ram") || strings.HasPrefix(name, "zram") ||
strings.HasPrefix(name, "dm-") || strings.HasPrefix(name, "md") || strings.HasPrefix(name, "sr") || strings.HasPrefix(name, "nbd") {
continue
}
n := node(dir, name, "disk")
n.Model, n.Vendor = sp(readStrFile(dir+"/device/model")), sp(readStrFile(dir+"/device/vendor"))
n.Serial = sp(readStrFile(dir + "/device/serial"))
n.Rota = strings.TrimSpace(readStrFile(dir+"/queue/rotational")) == "1"
n.RM = strings.TrimSpace(readStrFile(dir+"/removable")) == "1"
real, _ := filepath.EvalSymlinks(dir)
switch {
case strings.HasPrefix(name, "nvme"):
n.Tran = sp("nvme")
case strings.Contains(real, "/usb"):
n.Tran = sp("usb")
case strings.Contains(real, "/ata"):
n.Tran = sp("sata")
}
subs, _ := os.ReadDir(dir)
for _, sub := range subs {
if _, err := os.Stat(dir + "/" + sub.Name() + "/partition"); err == nil {
n.Children = append(n.Children, node(dir+"/"+sub.Name(), sub.Name(), "part"))
}
}
out = append(out, n)
}
return out
}
// An eMMC chip's boot and RPMB areas are hardware regions of the same chip, not drives;
// the kernel lists them as disks of their own (mmcblk0boot0, mmcblk0boot1, mmcblk0rpmb).
var mmcAreaRe = regexp.MustCompile(`^mmcblk\d+(boot\d+|rpmb|gp\d+)$`)
func mmcKind(name string) string {
if strings.TrimSpace(readStrFile("/sys/block/"+name+"/device/type")) == "MMC" {
return "emmc"
}
return "sd"
}
// mmcHealth is the eMMC answer to SMART: the chip's own wear and end-of-life registers,
// which the kernel publishes in /sys. SD cards have neither; smartctl speaks to neither.
func mmcHealth(s Smart) Smart {
dir := "/sys/block/" + s.Device + "/device/"
s.Available, s.Supported = true, true
s.SelfTestNote = "not a feature of eMMC or SD"
s.Serial = strings.TrimSpace(readStrFile(dir + "serial"))
s.Firmware = strings.TrimSpace(readStrFile(dir + "fwrev"))
// fwrev is eight raw bytes in hex; vendors usually put ASCII in them ("0001")
if b, err := hex.DecodeString(strings.TrimPrefix(s.Firmware, "0x")); err == nil {
if t := strings.TrimRight(string(b), "\x00 "); t != "" && strings.IndexFunc(t, func(r rune) bool { return r < 0x20 || r > 0x7e }) < 0 {
s.Firmware = t
}
}
s.Model = strings.TrimSpace(readStrFile(dir + "name"))
hx := func(v string) int { n, _ := strconv.ParseInt(strings.TrimPrefix(v, "0x"), 16, 32); return int(n) }
life := strings.Fields(readStrFile(dir + "life_time"))
eol := strings.TrimSpace(readStrFile(dir + "pre_eol_info"))
if len(life) != 2 || eol == "" {
s.Error = "This card reports no health data (SD cards don't; eMMC 5.0 and later does)."
return s
}
m := &MMCHealth{LifeA: hx(life[0]), LifeB: hx(life[1]), PreEOL: hx(eol)}
s.MMC = m
ok := m.PreEOL <= 1 && m.LifeA < 10 && m.LifeB < 10
s.Passed = &ok
if m.PreEOL == 2 {
s.Warnings = append(s.Warnings, "80% of reserved blocks consumed")
} else if m.PreEOL >= 3 {
s.Warnings = append(s.Warnings, "90% of reserved blocks consumed — replace soon")
}
if m.LifeA >= 10 || m.LifeB >= 10 {
s.Warnings = append(s.Warnings, "estimated lifetime reached")
}
return s
}
var nsRe = regexp.MustCompile(`^(nvme\d+)n\d+$`)
// foldNVMe merges nvme0n1, nvme0n2, … into one physical device "nvme0": one SSD, one
// SMART record, however many namespaces the controller presents (Apple exposes three).
func foldNVMe(devs []Device) []Device {
byCtrl := map[string]*Device{}
var out []Device
for _, d := range devs {
m := nsRe.FindStringSubmatch(d.Name)
if m == nil {
out = append(out, d)
continue
}
ctrl := m[1]
c, ok := byCtrl[ctrl]
if !ok {
nd := d
nd.Name = ctrl
nd.Parts = []Part{}
nd.Namespaces = nil
if mdl := strings.TrimSpace(readStrFile("/sys/class/nvme/" + ctrl + "/model")); mdl != "" {
nd.Model = mdl
}
if sn := strings.TrimSpace(readStrFile("/sys/class/nvme/" + ctrl + "/serial")); sn != "" {
nd.Serial = sn
}
if t := ctrlTemp(ctrl); t > 0 {
nd.Temp = t
}
nd.Size = 0
c = &nd
byCtrl[ctrl] = c
out = append(out, Device{}) // placeholder keeps order; filled below
c.Namespaces = []string{}
}
c.Size += d.Size
c.Namespaces = append(c.Namespaces, d.Name)
c.Parts = append(c.Parts, d.Parts...)
}
// replace placeholders with the folded controllers, in first-seen order
res := out[:0]
seen := map[string]bool{}
for _, d := range out {
if d.Name != "" {
res = append(res, d)
continue
}
for _, c := range byCtrl {
if !seen[c.Name] {
seen[c.Name] = true
res = append(res, *c)
break
}
}
}
return res
}
func readStrFile(p string) string {
b, err := os.ReadFile(p)
if err != nil {
return ""
}
return string(b)
}
func ctrlTemp(ctrl string) float64 {
dirs, _ := filepath.Glob("/sys/class/nvme/" + ctrl + "/hwmon*")
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
}
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
}
if _, err := os.Stat("/sys/block/" + name); err == nil {
return true
}
_, err := os.Stat("/sys/class/nvme/" + name) // NVMe controller (nvme0), which is what smartctl wants anyway
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"`
MMC *MMCHealth `json:"mmc,omitempty"`
SelfTest string `json:"self_test,omitempty"`
SelfTestPct int `json:"self_test_remaining,omitempty"`
SelfTestOK bool `json:"self_test_supported"`
SelfTestNote string `json:"self_test_note,omitempty"`
Warnings []string `json:"warnings"`
Error string `json:"error,omitempty"`
FetchedAt int64 `json:"fetched_at"`
}
// MMCHealth is the eMMC EXT_CSD wear report. Life values are tenths: 1 = 0–10% of rated
// life used, 10 = 90–100%, 11 = exceeded, 0 = not reported. A is the SLC area, B the MLC.
type MMCHealth struct {
LifeA int `json:"life_a"`
LifeB int `json:"life_b"`
PreEOL int `json:"pre_eol"` // 1 normal · 2 warning · 3 urgent
}
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{}}
}
// HasSmartctl re-probes when it was missing at startup, so an install from the Disks
// tab takes effect without a daemon restart.
func (m *Manager) HasSmartctl() bool {
if !m.smartctl {
_, m.smartctl = lookPath("smartctl")
}
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 = priv.Wrap(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 strings.HasPrefix(dev, "mmcblk") {
return mmcHealth(s)
}
if !m.HasSmartctl() {
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")
}
}
// Can this drive run self-tests from here? ATA says so outright; NVMe drives prove it
// by exposing the self-test log, and some (WD SN580 among them) advertise the command
// but reject the log page, which makes the whole feature unusable.
if d, ok := raw["ata_smart_data"].(map[string]any); ok {
if caps, ok := d["capabilities"].(map[string]any); ok {
s.SelfTestOK = caps["self_tests_supported"] == true
if !s.SelfTestOK {
s.SelfTestNote = "drive reports no self-test support"
}
}
}
if _, ok := raw["nvme_self_test_log"]; ok {
s.SelfTestOK = true
} else if s.NVMe != nil {
s.SelfTestNote = "this drive's firmware doesn't expose the NVMe self-test log, so tests can't be run or read from here"
}
if msgs, ok := raw["smartctl"].(map[string]any); ok {
for _, mm := range asList(msgs["messages"]) {
if strings.Contains(asString(mm["string"]), "Self-test Log failed") {
s.SelfTestOK = false
s.SelfTestNote = "firmware rejects the self-test log page (" + asString(mm["string"]) + ")"
}
}
}
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")
}
if sm := m.Smart(ctx, dev, false); sm.Supported && !sm.SelfTestOK {
return errors.New("self-tests aren't available on this drive: " + sm.SelfTestNote)
}
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 }
// Pools is the pool list alone, for callers that poll: one `zpool list`, tried without
// root first (it needs none on Linux) so polling writes nothing to the auth log.
func (m *Manager) Pools(ctx context.Context) []Pool {
bin, ok := lookPath("zpool")
if !ok {
return nil
}
args := []string{"list", "-Hp", "-o", "name,size,alloc,free,frag,cap,health,dedup"}
out, err := exec.CommandContext(ctx, bin, args...).Output()
if err != nil {
var code int
if out, code, err = m.run(ctx, append([]string{"zpool"}, args...)...); err != nil || code != 0 {
return nil
}
}
return parsePools(out)
}
func parsePools(out []byte) []Pool {
pools := []Pool{}
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
f := strings.Split(line, "\t")
if len(f) < 8 {
continue
}
pools = append(pools, 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]})
}
return pools
}
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
}