Disks: one card per physical drive (NVMe namespaces folded under their controller), partitions summarised on one line instead of listed

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-18 14:18:20 -07:00
co-authored by Claude Fable 5.1
parent a372378098
commit 0af24f41c3
4 changed files with 112 additions and 23 deletions
+95 -12
View File
@@ -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
}
+14 -10
View File
@@ -87,14 +87,13 @@ function DeviceCard({ d, live, forceOpen }: { d: StorageDevice; live?: { read_bp
<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="muted small disk-summary">
{d.serial && <span>S/N {d.serial}</span>}
{d.namespaces && d.namespaces.length > 1 && <span>{d.namespaces.length} namespaces</span>}
<span>{d.parts.length} partition{d.parts.length === 1 ? '' : 's'}</span>
{d.parts.filter((p) => p.mount && p.mount !== '[SWAP]').length > 0 && <span>mounted at {d.parts.filter((p) => p.mount && p.mount !== '[SWAP]').map((p) => p.mount).join(', ')}</span>}
{d.parts.some((p) => p.mount === '[SWAP]') && <span>swap</span>}
</div>
{forceOpen === null && <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>
@@ -200,7 +199,12 @@ export default function Disks() {
}, [rate])
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)
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 (
<div className="dash disks">
@@ -215,7 +219,7 @@ export default function Disks() {
{!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)} forceOpen={allSmart ? true : null} />)}
{devs.map((d) => <DeviceCard key={d.name} d={d} live={liveFor(d)} forceOpen={allSmart ? true : null} />)}
</div>
<div className="dash-bar"><h2 className="section">ZFS</h2></div>
+1 -1
View File
@@ -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 }
+2
View File
@@ -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; }