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
- 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; }