diff --git a/internal/storage/storage.go b/internal/storage/storage.go index ec69e3a..656cbff 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -35,17 +35,18 @@ type Part struct { } 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"` + 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"` + Namespaces []string `json:"namespaces,omitempty"` // NVMe: the block devices behind this controller } type lsblkNode struct { @@ -121,10 +122,89 @@ func Devices(ctx context.Context) ([]Device, error) { 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 } +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) @@ -145,7 +225,10 @@ func validDevice(name string) bool { if !devRe.MatchString(name) { return false } - _, err := os.Stat("/sys/block/" + name) + 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 } diff --git a/web/src/Disks.tsx b/web/src/Disks.tsx index a74f76b..25b1604 100644 --- a/web/src/Disks.tsx +++ b/web/src/Disks.tsx @@ -87,14 +87,13 @@ function DeviceCard({ d, live, forceOpen }: { d: StorageDevice; live?: { read_bp {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.
} +
+ {d.serial && S/N {d.serial}} + {d.namespaces && d.namespaces.length > 1 && {d.namespaces.length} namespaces} + {d.parts.length} partition{d.parts.length === 1 ? '' : 's'} + {d.parts.filter((p) => p.mount && p.mount !== '[SWAP]').length > 0 && mounted at {d.parts.filter((p) => p.mount && p.mount !== '[SWAP]').map((p) => p.mount).join(', ')}} + {d.parts.some((p) => p.mount === '[SWAP]') && swap} +
{forceOpen === null &&
} {open && } @@ -200,7 +199,12 @@ export default function Disks() { }, [rate]) if (!devs) return
{err ?
{err}
: }
- const liveFor = (n: string) => live?.disks.find((d) => d.name === n) + const liveFor = (dev: StorageDevice) => { + const names = dev.namespaces?.length ? dev.namespaces : [dev.name] + const rows = (live?.disks ?? []).filter((x) => names.includes(x.name)) + if (rows.length === 0) return undefined + return { read_bps: rows.reduce((a, x) => a + x.read_bps, 0), write_bps: rows.reduce((a, x) => a + x.write_bps, 0), util_pct: Math.max(...rows.map((x) => x.util_pct)) } + } return (
@@ -215,7 +219,7 @@ export default function Disks() { {!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) => )} + {devs.map((d) => )}

ZFS

diff --git a/web/src/api.ts b/web/src/api.ts index 307bcf6..d2c9d52 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -82,7 +82,7 @@ export interface UpdatePackage { name: string; current: string; candidate: strin 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 StorageDevice { name: string; kind: string; model?: string; vendor?: string; serial?: string; tran?: string; size: number; rotational: boolean; removable: boolean; temp?: number; parts: StoragePart[]; namespaces?: string[] } 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; self_test_supported: boolean; self_test_note?: string; warnings: string[]; error?: string; fetched_at: number } export interface ZFSSnapshot { name: string; used: number; created: number } diff --git a/web/src/styles.css b/web/src/styles.css index 9b44063..26f0c73 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -377,3 +377,5 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted); [data-theme="light"] .term-overlay { background: rgba(255,255,255,.6); } [data-theme="light"] .toast { box-shadow: 0 4px 16px rgba(0,0,0,.15); } [data-theme="light"] .modal-back { background: rgba(0,0,0,.35); } + +.disk-summary { display: flex; flex-wrap: wrap; gap: 4px 14px; margin: 2px 0 6px; }