The disks box is drives; the net box lets you pick the interface
Disks: one row per physical drive with its model, size, temperature and IO, and a usage bar built from the filesystems the sampler can attribute to it. Attribution is new — a mount's source device is resolved through sysfs to the drive behind it, following one layer of device-mapper or md and folding NVMe namespaces the way the sampler does. A pool member gets no usage bar, because one drive's share of a raidz pool is not a real number. Net: every interface that is up is listed and tappable, defaulting to the one that has actually carried traffic rather than whichever the kernel listed first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
bf0bdd54af
commit
e0625be684
@@ -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) {
|
||||
|
||||
@@ -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...)
|
||||
|
||||
+39
-12
@@ -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<ProcTable | null>(null)
|
||||
const [pickedIf, setPickedIf] = useState<string | null>(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() {
|
||||
|
||||
<Box n={3} name="disks" className="top-disks" right="io">
|
||||
<div className="t-disks">
|
||||
{swap && swap.total > 0 && <Disk name="swap" size={swap.total} used={swap.used} />}
|
||||
{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 <Disk key={f.mount} name={f.mount === '/' ? 'root' : f.mount.split('/').filter(Boolean).pop() ?? f.mount} size={f.size} used={f.used} io={d ? d.util_pct : undefined} />
|
||||
{(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 <Disk key={d.name} name={d.name} note={d.model} size={d.size} used={on.length ? used : undefined} io={d.util_pct} temp={d.temp} mounts={on.map((f) => f.mount)} />
|
||||
})}
|
||||
{fs.length === 0 && <div className="t-dim">no filesystems reported</div>}
|
||||
{(s?.disks ?? []).length === 0 && <div className="t-dim">no drives reported</div>}
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box n={4} name="net" className="top-net" right={iface ? <span className="t-if">{iface.iface}</span> : undefined}>
|
||||
<Box n={4} name="net" className="top-net" right={net.length ? (
|
||||
<span className="t-ifs">
|
||||
{net.map((n) => (
|
||||
<button key={n.iface} className={'t-if' + (iface && n.iface === iface.iface ? ' on' : '')}
|
||||
title={`${n.iface} — ${bytes(n.rx_total)} in, ${bytes(n.tx_total)} out`} onClick={() => setPickedIf(n.iface)}>{n.iface}</button>
|
||||
))}
|
||||
</span>
|
||||
) : undefined}>
|
||||
{iface ? (
|
||||
<div className="t-net">
|
||||
<div className="t-net-graphs">
|
||||
@@ -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 (
|
||||
<div className="t-disk">
|
||||
<div className="t-row"><b>{name}</b><span className="spacer" />{bytes(size)}</div>
|
||||
<div className="t-row">
|
||||
<b>{name}</b>
|
||||
{note && <span className="t-dim t-model" title={note}>{note}</span>}
|
||||
<span className="spacer" />
|
||||
{temp !== undefined && temp > 0 && <span className="t-temp">{Math.round(temp)}°C</span>}
|
||||
{bytes(size)}
|
||||
</div>
|
||||
{io !== undefined && <div className="t-row t-sub"><span>IO%</span><Meter pct={io} grad="io" /></div>}
|
||||
{used !== undefined ? (
|
||||
<>
|
||||
<div className="t-row t-sub"><span>Used:</span><span className="t-pct">{Math.round(upct)}%</span><Meter pct={upct} grad="used" /><span className="t-amt">{bytes(used, 1)}</span></div>
|
||||
<div className="t-row t-sub"><span>Free:</span><span className="t-pct">{Math.round(100 - upct)}%</span><Meter pct={100 - upct} grad="free" /><span className="t-amt">{bytes(Math.max(0, size - used), 1)}</span></div>
|
||||
{mounts && mounts.length > 0 && <div className="t-row t-sub t-dim t-mounts" title={mounts.join(' ')}>{mounts.join(' ')}</div>}
|
||||
</>
|
||||
) : (
|
||||
<div className="t-row t-sub t-dim">no filesystem of this machine is on it</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -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 }
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user