Every DRM device with a real GPU driver gets a box under the cpu box, laid out like it: the busy curve, a meter for the device and one per engine, temperature, clock, power and fan where the card has them, and the processes holding it open with their share. The process table gains a Gpu% column and sort. Device busy is Intel's RC6 idle residency inverted (what intel_gpu_top shows, and readable without root) or amdgpu's gpu_busy_percent. Per-process shares are DRM fdinfo deltas, as nvtop reads them; other users' fdinfo needs root, so a daemon running as a user keeps one root child (hamlab drm-clients) over a pipe rather than paying a sudo a second. Everything is also a gpu.* sensor. What cannot be measured is left out, not shown as zero: an Arc A380 on i915 6.12 has no temperature sensor, an integrated GPU wears the package temperature marked *, and Asahi has neither a busy counter nor fdinfo yet. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
65 lines
4.8 KiB
TypeScript
65 lines
4.8 KiB
TypeScript
// The stock Dashboard modules were written against one Sample object. They now read the
|
|
// sensor API like everything else; this file is the seam — it rebuilds that object from
|
|
// sensor values (the live stream) and sensor metadata (the catalog: labels, thresholds).
|
|
import { Sample } from './api'
|
|
import { Sensor } from './sensors'
|
|
|
|
export type Values = Record<string, number | boolean | string>
|
|
export type Catalog = Map<string, Sensor>
|
|
|
|
// Everything the modules draw, as one stream query.
|
|
export const MONITOR_SHAPE = ['cpu.*', 'load.*', 'uptime', 'hamlab.*', 'mem.*', 'swap.*', 'temp.*', 'fan.*', 'net.*', 'disk.*', 'fs.*', 'ups.*', 'gpu.*']
|
|
// The stream answers a list with a list; the modules want one flat map.
|
|
export function mergeValues(parts: unknown[]): Values {
|
|
const out: Values = {}
|
|
parts.forEach((part, i) => {
|
|
if (part && typeof part === 'object') Object.assign(out, part)
|
|
else if (part !== null && part !== undefined) out[MONITOR_SHAPE[i]] = part as Values[string]
|
|
})
|
|
return out
|
|
}
|
|
// The curves they keep: CPU, memory, per-NIC and per-disk rates, every temperature.
|
|
|
|
const num = (v: Values, id: string) => (typeof v[id] === 'number' ? (v[id] as number) : 0)
|
|
const str = (v: Values, id: string) => (typeof v[id] === 'string' ? (v[id] as string) : '')
|
|
|
|
// Groups ids like net.<x>.rx by the real name in the catalog's labels, keeping catalog order.
|
|
function groups(v: Values, cat: Catalog, prefix: string, label: string): Map<string, string> {
|
|
const out = new Map<string, string>() // real name → id stem ("net.enp3s0")
|
|
for (const id of Object.keys(v)) {
|
|
if (!id.startsWith(prefix)) continue
|
|
const real = cat.get(id)?.labels?.[label]
|
|
if (real && !out.has(real)) out.set(real, id.slice(0, id.lastIndexOf('.')))
|
|
}
|
|
return new Map([...out].sort((a, b) => a[0].localeCompare(b[0]))) // by real name: "/" leads the filesystems
|
|
}
|
|
|
|
// Every id in the values must be known to the catalog, or the catalog is stale (a disk
|
|
// or interface appeared) and wants refetching.
|
|
export const stale = (v: Values, cat: Catalog) => Object.keys(v).some((id) => !cat.has(id))
|
|
|
|
export function sampleOf(v: Values, cat: Catalog): Sample {
|
|
const cores = Object.keys(v).map((id) => /^cpu\.core\.(\d+)\.usage$/.exec(id)).filter(Boolean).map((m) => [Number(m![1]), v[m![0]] as number] as const).sort((a, b) => a[0] - b[0]).map((x) => x[1])
|
|
const mem = (p: string) => ({ total: num(v, p + '.total'), used: num(v, p + '.used'), avail: num(v, p + '.avail'), free: num(v, p + '.free'), cached: num(v, p + '.cached'), buffers: num(v, p + '.buffers') })
|
|
const hw = (prefix: string) => Object.keys(v).filter((id) => id.startsWith(prefix) && cat.get(id)?.labels?.chip !== undefined).map((id) => ({ id, s: cat.get(id)! }))
|
|
.sort((a, b) => a.s.labels!.chip.localeCompare(b.s.labels!.chip) || a.s.labels!.label.localeCompare(b.s.labels!.label))
|
|
return {
|
|
t: Date.now(), interval_ms: num(v, 'hamlab.sample_interval'),
|
|
cpu: num(v, 'cpu.usage'), cores, load: [num(v, 'load.1'), num(v, 'load.5'), num(v, 'load.15')],
|
|
mem: mem('mem'), swap: mem('swap'), uptime: num(v, 'uptime'),
|
|
temps: hw('temp.').map(({ id, s }) => ({ chip: s.labels!.chip, label: s.labels!.label, c: num(v, id), max: s.warn, crit: s.crit })),
|
|
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', 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'),
|
|
load_w: num(v, 'ups.load'), load_pct: num(v, 'ups.load_pct'), last_event: str(v, 'ups.last_event') || undefined,
|
|
},
|
|
}
|
|
}
|
|
|
|
// The modules' history: one shared time axis (the CPU's), every other curve right-aligned
|
|
// to it and zero-padded on the left where the sensor is younger than the ring.
|