diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index adc9695..515a4cd 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -94,6 +94,11 @@ type FS struct { // sshfs, and so on. Its bytes are somebody else's disk, so a panel about this machine's // hardware leaves it out, while the sensor stays for anyone who does want it. Remote bool `json:"remote,omitempty"` + // Disk is the physical drive these bytes are on ("nvme0", "sda"), named as the sampler + // names it; empty when that cannot be + // said honestly: a ZFS pool or an md array spreads a filesystem over several drives, and + // a remote mount is on none of them. + Disk string `json:"disk,omitempty"` } type UPS struct { @@ -825,7 +830,7 @@ func filesystems() []FS { return } bs := uint64(st.Bsize) - done <- FS{Mount: mount, Type: fs[2], Size: st.Blocks * bs, Used: (st.Blocks - st.Bfree) * bs, Remote: remoteFS[fs[2]]} + done <- FS{Mount: mount, Type: fs[2], Size: st.Blocks * bs, Used: (st.Blocks - st.Bfree) * bs, Remote: remoteFS[fs[2]], Disk: diskOf(fs[0])} }() select { case r := <-done: @@ -839,6 +844,46 @@ func filesystems() []FS { return out } +// diskOf names the physical drive a mount's source device sits on — "/dev/nvme0n1p2" is on +// "nvme0n1". It follows one layer of device-mapper or md (LUKS on a partition, say) but +// gives up when a thing spans several drives, because then no single drive is the answer. +func diskOf(source string) string { + if !strings.HasPrefix(source, "/dev/") { + return "" // a ZFS pool, a network share, a tmpfs: not a block device at all + } + d := parentDisk(strings.TrimPrefix(source, "/dev/"), 0) + // the sampler reports one drive per NVMe controller, so name it the same way + if m := nsRe.FindStringSubmatch(d); m != nil { + return m[1] + } + return d +} + +func parentDisk(name string, depth int) string { + if name == "" || depth > 4 || strings.Contains(name, "/") { + return "" + } + base := "/sys/class/block/" + name + if _, err := os.Stat(base); err != nil { + return "" + } + // a partition: its parent directory in sysfs is the whole drive + if _, err := os.Stat(base + "/partition"); err == nil { + if target, err := os.Readlink(base); err == nil { + return filepath.Base(filepath.Dir(target)) + } + return "" + } + // device-mapper or md: one drive underneath is an answer, several is not + if ents, err := os.ReadDir(base + "/slaves"); err == nil && len(ents) > 0 { + if len(ents) > 1 { + return "" + } + return parentDisk(ents[0].Name(), depth+1) + } + return name // a whole drive, used without partitioning +} + // ---- UPS ---- func runOut(args ...string) (string, bool) { diff --git a/internal/metrics/sensors.go b/internal/metrics/sensors.go index f1b92fe..cf96c37 100644 --- a/internal/metrics/sensors.go +++ b/internal/metrics/sensors.go @@ -123,6 +123,9 @@ func flatten(s Sample, want func(id string) bool) []sensors.Sensor { if x.Remote { lbl = append(lbl, "remote", "1") } + if x.Disk != "" { + lbl = append(lbl, "disk", x.Disk) + } f.add(p+".size", x.Mount+" size", "bytes", "B", float64(x.Size), s.fsT, none, lbl...) f.add(p+".used", x.Mount+" used", "bytes", "B", float64(x.Used), s.fsT, upTo(float64(x.Size)), lbl...) f.add(p+".used_pct", x.Mount+" used", "usage", "%", ratio(x.Used, x.Size), s.fsT, pct, lbl...) diff --git a/web/src/Top.tsx b/web/src/Top.tsx index f1b5284..eabe246 100644 --- a/web/src/Top.tsx +++ b/web/src/Top.tsx @@ -250,6 +250,7 @@ export default function Top() { // the process table, and the CPU model, come from the same place the Processes page reads const [pt, setPt] = useState(null) + const [pickedIf, setPickedIf] = useState(null) // which NIC the net box draws const [err, setErr] = useState('') useEffect(() => { let stop = false @@ -319,7 +320,10 @@ export default function Top() { const free = (mem as { free?: number }).free ?? Math.max(0, mem.total - mem.used - cached) const swap = s?.swap ?? pt?.swap const net = (s?.net ?? pt?.net ?? []).filter((n) => n.up && n.iface !== 'lo') - const iface = net[0] + // the one that has actually carried traffic, not whichever the kernel listed first — wg0 + // and a dormant wlan should not push the real NIC out of the way. Tapping a name switches. + const busiest = net.length ? net.reduce((a, b) => (b.rx_total + b.tx_total > a.rx_total + a.tx_total ? b : a)) : undefined + const iface = net.find((n) => n.iface === pickedIf) ?? busiest // the disks box is this machine's own storage: a CIFS or NFS share is somebody else's disk const fs = (s?.fs ?? []).filter((f) => !f.remote) const pct = (a: number, b: number) => (b > 0 ? (a / b) * 100 : 0) @@ -379,18 +383,26 @@ export default function Top() {
- {swap && swap.total > 0 && } - {fs.map((f) => { - // which drive is behind a mount isn't in the sample, so only the root filesystem - // gets an IO line, against the machine's largest drive — which is the one it is on - const d = f.mount === '/' ? [...(s?.disks ?? [])].sort((a, b) => b.size - a.size)[0] : undefined - return + {(s?.disks ?? []).map((d) => { + // what is on this drive: the filesystems the sampler could attribute to it. A ZFS + // pool or an md array spans several drives, so nothing is attributed and the row + // shows what is true of the hardware — how busy it is, and how hot. + const on = fs.filter((f) => f.disk === d.name) + const used = on.reduce((a, f) => a + f.used, 0) + return f.mount)} /> })} - {fs.length === 0 &&
no filesystems reported
} + {(s?.disks ?? []).length === 0 &&
no drives reported
}
- {iface.iface} : undefined}> + + {net.map((n) => ( + + ))} + + ) : undefined}> {iface ? (
@@ -471,14 +483,29 @@ const Rows = memo(function Rows({ procs, sel, onSel, curves, empty }: { procs: P ) }) -function Disk({ name, size, used, io }: { name: string; size: number; used: number; io?: number }) { - const upct = size > 0 ? (used / size) * 100 : 0 +// One physical drive. `used` is the bytes of the filesystems on it, left out when none can +// be attributed to it — a pool member's share of a pool is not a number worth inventing. +function Disk({ name, note, size, used, io, temp, mounts }: { name: string; note?: string; size: number; used?: number; io?: number; temp?: number; mounts?: string[] }) { + const upct = used !== undefined && size > 0 ? (used / size) * 100 : 0 return (
-
{name}{bytes(size)}
+
+ {name} + {note && {note}} + + {temp !== undefined && temp > 0 && {Math.round(temp)}°C} + {bytes(size)} +
{io !== undefined &&
IO%
} -
Used:{Math.round(upct)}%{bytes(used, 1)}
-
Free:{Math.round(100 - upct)}%{bytes(Math.max(0, size - used), 1)}
+ {used !== undefined ? ( + <> +
Used:{Math.round(upct)}%{bytes(used, 1)}
+
Free:{Math.round(100 - upct)}%{bytes(Math.max(0, size - used), 1)}
+ {mounts && mounts.length > 0 &&
{mounts.join(' ')}
} + + ) : ( +
no filesystem of this machine is on it
+ )}
) } diff --git a/web/src/api.ts b/web/src/api.ts index 222810a..bb4bb43 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -70,7 +70,7 @@ export interface Temp { chip: string; label: string; c: number; max?: number; cr export interface Fan { chip: string; label: string; rpm: number } export interface NetIf { iface: string; up: boolean; rx_bps: number; tx_bps: number; rx_total: number; tx_total: number } export interface Disk { name: string; model?: string; size: number; read_bps: number; write_bps: number; util_pct: number; temp?: number } -export interface FS { mount: string; type: string; size: number; used: number; remote?: boolean } +export interface FS { mount: string; type: string; size: number; used: number; remote?: boolean; disk?: string } export interface UPS { driver: string; model: string; state: string; source: string; on_battery: boolean; in_v: number; out_v: number; battery_pct: number; runtime_min: number; load_w: number; load_pct: number; last_event?: string } export interface Proc { pid: number; ppid: number; name: string; cmd?: string; user: string; cpu: number; cpu_time: number; rss: number; threads: number; state: string; nice: number; started: number } export interface MemInfo { total: number; used: number; avail: number; free?: number; cached?: number; buffers?: number } diff --git a/web/src/sampleAdapter.ts b/web/src/sampleAdapter.ts index ec70caf..bb947b7 100644 --- a/web/src/sampleAdapter.ts +++ b/web/src/sampleAdapter.ts @@ -51,7 +51,7 @@ export function sampleOf(v: Values, cat: Catalog): Sample { fans: hw('fan.').map(({ id, s }) => ({ chip: s.labels!.chip, label: s.labels!.label, rpm: num(v, id) })), net: [...groups(v, cat, 'net.', 'iface')].map(([iface, p]) => ({ iface, up: v[p + '.up'] === true, rx_bps: num(v, p + '.rx'), tx_bps: num(v, p + '.tx'), rx_total: num(v, p + '.rx_total'), tx_total: num(v, p + '.tx_total') })), disks: [...groups(v, cat, 'disk.', 'device')].map(([name, p]) => ({ name, model: cat.get(p + '.size')?.labels?.model || undefined, size: num(v, p + '.size'), read_bps: num(v, p + '.read'), write_bps: num(v, p + '.write'), util_pct: num(v, p + '.util'), temp: num(v, p + '.temp') || undefined })), - fs: [...groups(v, cat, 'fs.', 'mount')].map(([mount, p]) => ({ mount, type: cat.get(p + '.size')?.labels?.fstype ?? '', size: num(v, p + '.size'), used: num(v, p + '.used'), remote: cat.get(p + '.size')?.labels?.remote === '1' })), + fs: [...groups(v, cat, 'fs.', 'mount')].map(([mount, p]) => ({ mount, type: cat.get(p + '.size')?.labels?.fstype ?? '', size: num(v, p + '.size'), used: num(v, p + '.used'), remote: cat.get(p + '.size')?.labels?.remote === '1', disk: cat.get(p + '.size')?.labels?.disk })), ups: v['ups.battery'] === undefined ? null : { driver: cat.get('ups.last_event')?.labels?.driver ?? '', model: cat.get('ups.battery')?.labels?.model ?? '', state: str(v, 'ups.state'), source: str(v, 'ups.source'), on_battery: v['ups.on_battery'] === true, in_v: num(v, 'ups.in_v'), out_v: num(v, 'ups.out_v'), battery_pct: num(v, 'ups.battery'), runtime_min: num(v, 'ups.runtime'), diff --git a/web/src/styles.css b/web/src/styles.css index 549928b..0ad4fbb 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -720,3 +720,12 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%, .space-row.file { grid-template-columns: minmax(120px, 1fr) 72px 80px; } .space-split { padding: 8px; } .space-tree { flex: 1 1 45%; } .space-map { flex: 1 1 55%; } } + +/* the Processes tab's drive rows and NIC switcher */ +.t-disk .t-model { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 14ch; margin-left: 6px; } +.t-disk .t-temp { margin-right: 8px; color: var(--t-blue, var(--accent2)); } +.t-disk .t-mounts { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.t-ifs { display: inline-flex; gap: 4px; flex-wrap: wrap; justify-content: flex-end; } +.t-ifs .t-if { padding: 0 4px; border-radius: 3px; color: var(--muted); } +.t-ifs .t-if:hover { color: var(--fg); background: var(--bg3); } +.t-ifs .t-if.on { color: var(--fg); box-shadow: inset 0 -2px 0 var(--accent); }