Files
hamlab/web/src/Top.tsx
T
Mitchell-JohnsonandClaude Fable 5.1 b5cecce435 Never read sysfs through Go's poller, and a GPU that stops answering is skipped
On grid the sampler sat 42 minutes in runtime_pollWait on the Arc A380's
hwmon fan1_input: kernfs accepts epoll, so os.ReadFile made the fd
non-blocking, i915 answered EAGAIN once, and the poller waited for a
readiness kernfs only signals on sysfs_notify. Every graph on the machine
froze while the API carried on.

readStr now reads with plain blocking syscalls. Each GPU's sysfs reads run
under a three-second deadline; a device that does not answer is marked
stalled, logged, and left alone until restart, with the box saying so.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 23:41:22 -07:00

601 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// A whole machine on one screen, drawn the way btop draws it: black ground, boxes with their
// name set into the border, and every curve made of coloured dots that go green → yellow → red
// as they climb. The numbers are Ham Lab's own — the sensor stream for everything measured and
// /api/procs for the table — so this works on any machine the console can see, whether or not
// btop is installed on it.
import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { api, GPU, ProcTable, Proc, Sample } from './api'
import { Catalog, MONITOR_SHAPE, Values, mergeValues, sampleOf, stale } from './sampleAdapter'
import { fetchCatalog, fetchHistories, useSensorStream } from './sensors'
import { fmtMs, nextPoint, REFRESH_OPTIONS, setRefreshMs, strideMs, useHistorySec, useRefreshMs } from './prefs'
import { Loading, askYes, toast } from './ui'
import { Theme, currentThemeId, themeById } from './themes'
import { columnsOf } from './graphcols'
import { savePrefs } from './accents'
const MAX = 300 // points kept per curve, as the machines keep them
// ---- the palette: the skin's own terminal colours ----
// Every skin carries sixteen terminal colours chosen to be read on its terminal ground, which
// is exactly what this page is: so the ground is the skin's --term-bg and every curve and
// meter is built from its green, yellow, red, blue, magenta and cyan. Dark skins come out
// looking like btop; the light ones like btop on paper.
type GradName = 'cpu' | 'used' | 'avail' | 'cached' | 'free' | 'down' | 'up' | 'temp' | 'io'
type Palette = Record<GradName, string[]> & { vars: Record<string, string> }
function paletteOf(t: Theme): Palette {
const x = t.xterm as Record<string, string>
const c = (k: string, fb: string) => x[k] || fb
const green = c('green', '#00d35c'), yellow = c('yellow', '#c8e000'), red = c('red', '#ff3c3c'), blue = c('blue', '#4c8cff'), magenta = c('magenta', '#c850ff'), cyan = c('cyan', '#50d0ff')
return {
cpu: [green, yellow, red],
used: [red, c('brightRed', red), yellow],
avail: [yellow, c('brightYellow', yellow), c('brightYellow', yellow)],
cached: [blue, c('brightBlue', blue), cyan],
free: [green, c('brightGreen', green), c('brightGreen', green)],
down: [magenta, c('brightMagenta', magenta), c('brightMagenta', magenta)],
up: [blue, c('brightBlue', blue), cyan],
temp: [blue, c('brightBlue', blue), cyan],
io: [yellow, c('brightYellow', yellow), red],
vars: { '--t-green': green, '--t-blue': blue, '--t-magenta': magenta, '--t-red': red },
}
}
function usePalette(): Palette {
const [pal, setPal] = useState(() => paletteOf(themeById(currentThemeId())))
useEffect(() => {
const h = (e: Event) => setPal(paletteOf((e as CustomEvent<Theme>).detail))
window.addEventListener('hamlab:theme', h)
return () => window.removeEventListener('hamlab:theme', h)
}, [])
return pal
}
const Pal = createContext<Palette>(paletteOf(themeById('dark')))
// mix(a, b, t) on hex, so a dot's colour can fall anywhere along a three-stop gradient
function rgbOf(h: string): number[] {
if (h.startsWith('#')) { const x = h.length === 4 ? h.replace(/#(.)(.)(.)/, '#$1$1$2$2$3$3') : h; return [0, 1, 2].map((i) => parseInt(x.slice(1 + i * 2, 3 + i * 2), 16)) }
const m = h.match(/[\d.]+/g); return m ? m.slice(0, 3).map(Number) : [128, 128, 128]
}
function mix(a: string, b: string, t: number): string {
const x = rgbOf(a), y = rgbOf(b)
const c = [0, 1, 2].map((i) => Math.round(x[i] + (y[i] - x[i]) * t))
return `rgb(${c[0]},${c[1]},${c[2]})`
}
function at(g: readonly string[], t: number): string {
const x = Math.max(0, Math.min(1, t)) * (g.length - 1)
const i = Math.min(g.length - 2, Math.floor(x))
return mix(g[i], g[i + 1], x - i)
}
// ---- the dot graph ----
// One dot column per sample, filled from the bottom, each dot coloured by how high it sits.
function Graph({ values, tick, advance, cap, max, grad, flip = false }: { values: number[]; tick: number; advance: number; cap: number; max?: number; grad: GradName; flip?: boolean }) {
const ref = useRef<HTMLCanvasElement>(null)
const box = useRef<HTMLDivElement>(null)
const pal = useContext(Pal)
const [[w, height], setSize] = useState([0, 0])
useEffect(() => {
if (!box.current) return
const ro = new ResizeObserver((e) => setSize([Math.floor(e[0].contentRect.width), Math.floor(e[0].contentRect.height)]))
ro.observe(box.current)
return () => ro.disconnect()
}, [])
useEffect(() => {
const c = ref.current
if (!c || w < 4 || height < 4) return
const dpr = window.devicePixelRatio || 1
c.width = Math.round(w * dpr); c.height = Math.round(height * dpr)
const g = c.getContext('2d')!
g.setTransform(dpr, 0, 0, dpr, 0, 0)
g.clearRect(0, 0, w, height)
const step = 3, rows = Math.max(1, Math.floor(height / step)), cols = Math.max(1, Math.floor(w / step))
// The box is the whole window, always: `cap` points wide (what the window holds at this
// rate) whatever the box measures, so a full history fills it edge to edge and a machine
// that has only just started draws in from the right, as btop does. Which points each
// column draws — the peak of its own fixed slice of the history — is graphcols.ts.
const seen = columnsOf(values, advance, cap, cols)
const top = max ?? Math.max(1, ...seen)
const stops = pal[grad]
for (let i = 0; i < seen.length; i++) {
// anything above nothing lights the bottom row: the curve keeps a baseline, as btop's does
const lit = seen[i] > 0 ? Math.max(1, Math.round((seen[i] / top) * rows)) : 0
const x = w - (seen.length - i) * step
for (let r = 0; r < lit; r++) {
const t = rows > 1 ? r / (rows - 1) : 1
g.fillStyle = at(stops, t)
const y = flip ? r * step + 1 : height - (r + 1) * step + 1
g.fillRect(x, y, 2, 2)
}
}
}, [values, tick, advance, cap, w, height, max, grad, flip, pal]) // tick: the curve is appended to in place
return <div className="tg" ref={box} data-n={values.length}><canvas ref={ref} style={{ width: '100%', height: '100%' }} /></div>
}
// ---- the dotted meter ----
function Meter({ pct, grad }: { pct: number; grad: GradName }) {
const p = Math.max(0, Math.min(100, pct))
const g = useContext(Pal)[grad]
return (
<span className="tm">
<i className="tm-t" />
<i className="tm-f" style={{ background: `linear-gradient(90deg, ${g[0]}, ${g[1]}, ${g[2]})`, clipPath: `inset(0 ${100 - p}% 0 0)` }} />
</span>
)
}
// ---- a box, with its name set into the border ----
function Box({ n, name, right, className = '', children }: { n?: number; name: string; right?: React.ReactNode; className?: string; children: React.ReactNode }) {
return (
<section className={'tbox ' + className}>
<span className="tbox-name">{n !== undefined && <b>{'¹²³⁴⁵⁶'[n - 1]}</b>}{name}</span>
{right !== undefined && <span className="tbox-right">{right}</span>}
<div className="tbox-in">{children}</div>
</section>
)
}
// ---- formatting, btop's short units ----
const K = 1024
function bytes(b: number, digits = 2): string {
const u = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB']
let i = 0
while (b >= K && i < u.length - 1) { b /= K; i++ }
return `${i === 0 ? Math.round(b) : b.toFixed(digits)} ${u[i]}`
}
function shortBytes(b: number): string { // 720M, 9.4M, 0B — the process table's width is precious
const u = ['B', 'K', 'M', 'G', 'T']
let i = 0
while (b >= K && i < u.length - 1) { b /= K; i++ }
return `${b >= 100 || i === 0 ? Math.round(b) : b.toFixed(1)}${u[i]}`
}
function bits(bps: number): string {
const u = ['bps', 'Kibps', 'Mibps', 'Gibps']
let b = bps * 8, i = 0
while (b >= K && i < u.length - 1) { b /= K; i++ }
return `${i === 0 ? Math.round(b) : b.toFixed(2)} ${u[i]}`
}
function rate(bps: number): string {
const u = ['B/s', 'KiB/s', 'MiB/s', 'GiB/s']
let b = bps, i = 0
while (b >= K && i < u.length - 1) { b /= K; i++ }
return `${i === 0 ? Math.round(b) : b.toFixed(2)} ${u[i]}`
}
const pad = (s: string | number, n: number) => String(s).padStart(n)
function upfor(sec: number): string {
const d = Math.floor(sec / 86400), h = Math.floor((sec % 86400) / 3600), m = Math.floor((sec % 3600) / 60), s = Math.floor(sec % 60)
return (d ? `${d}d ` : '') + `${pad(h, 2)}:${pad(m, 2)}${d ? '' : ':' + pad(s, 2)}`.replace(/ /g, '0')
}
// A per-process curve, as braille: cheap enough to draw one for every row, every tick.
const BR = [0x01, 0x02, 0x04, 0x40, 0x08, 0x10, 0x20, 0x80] // dot order: (col,row) → bit
function braille(values: number[], cells = 7, max = 100): string {
const cols = cells * 2
const seen = values.slice(-cols)
const top = Math.max(1, max)
let out = ''
for (let c = 0; c < cells; c++) {
let bitsOn = 0
for (let half = 0; half < 2; half++) {
const i = seen.length - cols + c * 2 + half
const v = i >= 0 && i < seen.length ? seen[i] : 0
const lit = v > 0 ? Math.max(1, Math.min(4, Math.round((v / top) * 4))) : 0
for (let r = 0; r < lit; r++) bitsOn |= BR[half * 4 + (3 - r)]
}
out += String.fromCharCode(0x2800 + bitsOn)
}
return out
}
// ---- the curves this page keeps ----
const CURVES = 'cpu.usage,cpu.core.*.usage,net.*.rx,net.*.tx,disk.*.util,gpu.*.usage'
function useCurves(live: Values | null, t: number, refreshMs: number, historySec: number, resumed: number) {
const cur = useRef<Map<string, number[]>>(new Map())
const lastT = useRef(0)
const binN = useRef(1)
const adv = useRef(0) // the number of the newest point, counted since the page opened: what a graph's columns are cut by
const [ticks, bump] = useState(0)
const seed = useCallback(async () => {
try {
const hs = await fetchHistories(CURVES)
const m = new Map<string, number[]>()
for (const h of hs) m.set(h.id, h.v.slice(-MAX))
// The stride is timed on this clock only. The history's own timestamps are the machine's:
// measured against them, a viewer whose clock runs minutes slow would wait those minutes
// before its curves gained a single new point.
cur.current = m; lastT.current = Date.now(); binN.current = 1
bump((x) => x + 1)
} catch { /* the curves fill in live instead */ }
}, [])
useEffect(() => { seed() }, [seed, resumed, historySec]) // a new window is a new spacing: start from what the machine now keeps
useEffect(() => {
if (!live || !t) return
const { fresh, start } = nextPoint(t, lastT.current, strideMs(historySec, refreshMs), refreshMs)
binN.current = fresh ? 1 : binN.current + 1
lastT.current = start
if (fresh) adv.current++
const n = binN.current
for (const [id, v] of Object.entries(live)) {
if (typeof v !== 'number') continue
if (!/^cpu\.usage$|^cpu\.core\.\d+\.usage$|^net\..+\.(rx|tx)$|^disk\..+\.util$|^gpu\..+\.usage$/.test(id)) continue
const arr = cur.current.get(id) ?? []
if (!cur.current.has(id)) cur.current.set(id, arr)
if (fresh || arr.length === 0) { arr.push(v); if (arr.length > MAX) arr.shift() }
else arr[arr.length - 1] += (v - arr[arr.length - 1]) / n
}
bump((x) => x + 1)
}, [live, t, refreshMs, historySec])
return [cur.current, ticks, adv.current] as const
}
// ---- the page ----
export default function Top() {
const refreshMs = useRefreshMs()
const historySec = useHistorySec()
const [clock, setClock] = useState(() => new Date())
useEffect(() => { const i = window.setInterval(() => setClock(new Date()), 1000); return () => window.clearInterval(i) }, [])
const catalog = useRef<Catalog>(new Map())
const refetching = useRef(false)
const [ready, setReady] = useState(false)
const stream = useSensorStream<unknown[]>(MONITOR_SHAPE, { full: false, everyMs: refreshMs })
useEffect(() => { fetchCatalog().then((c) => { catalog.current = new Map(c.map((x) => [x.id, x])); setReady(true) }).catch(() => setReady(true)) }, [])
const values = useMemo(() => (stream.data ? mergeValues(stream.data) : null), [stream.data])
useEffect(() => {
if (!values || catalog.current.size === 0 || refetching.current || !stale(values, catalog.current)) return
refetching.current = true
fetchCatalog().then((c) => { catalog.current = new Map(c.map((x) => [x.id, x])) }).catch(() => {}).finally(() => window.setTimeout(() => { refetching.current = false }, 10000))
}, [values])
const s: Sample | null = useMemo(() => (values && ready ? sampleOf(values, catalog.current) : null), [values, ready])
const [curves, tick, advance] = useCurves(values, s?.t ?? 0, refreshMs, historySec, stream.resumed)
// 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
// Which pool each drive belongs to, asked once: a drive in a ZFS pool has no filesystem of
// its own, and "nothing is on it" would be a lie about eight drives holding a NAS.
const [pools, setPools] = useState<Record<string, string>>({})
useEffect(() => {
let live = true
api.storage().then((r) => {
if (!live) return
const m: Record<string, string> = {}
for (const d of r.devices) for (const part of d.parts) if (part.fstype === 'zfs_member' && part.label) m[d.name] = part.label
setPools(m)
}).catch(() => { /* the rows simply say less */ })
return () => { live = false }
}, [])
const [err, setErr] = useState('')
useEffect(() => {
let stop = false
const poll = async () => { try { const r = await api.procs(); if (!stop) { setPt(r); setErr('') } } catch (e) { if (!stop) setErr(e instanceof Error ? e.message : 'failed') } }
poll()
const i = window.setInterval(poll, refreshMs) // exactly the rate, like every other page: nothing is throttled behind his back
return () => { stop = true; window.clearInterval(i) }
}, [refreshMs])
// the same global rate Settings sets: saved on the hub, so it sticks and every machine's sampler follows
const rateIdx = (() => { const i = REFRESH_OPTIONS.findIndex((x) => x >= refreshMs); return i < 0 ? REFRESH_OPTIONS.length - 1 : i })()
const setRate = (i: number) => { setRefreshMs(REFRESH_OPTIONS[Math.max(0, Math.min(REFRESH_OPTIONS.length - 1, i))]); savePrefs() }
const pal = usePalette()
// how many points the history window holds at this rate: the width of every graph
const cap = Math.max(2, Math.min(MAX, Math.round((historySec * 1000) / strideMs(historySec, refreshMs))))
const [sort, setSort] = useState<'cpu' | 'gpu' | 'mem' | 'pid' | 'name'>('cpu')
const [rev, setRev] = useState(false)
const [filter, setFilter] = useState('')
const [sel, setSel] = useState<number | null>(null)
// a short cpu curve per process, built from what we have seen since the page opened
const pcurve = useRef<Map<number, number[]>>(new Map())
useEffect(() => {
if (!pt) return
const live = new Set<number>()
for (const p of pt.procs) {
live.add(p.pid)
const a = pcurve.current.get(p.pid) ?? []
a.push(p.cpu); if (a.length > 14) a.shift()
pcurve.current.set(p.pid, a)
}
for (const pid of pcurve.current.keys()) if (!live.has(pid)) pcurve.current.delete(pid)
}, [pt])
const procs = useMemo(() => {
if (!pt) return []
const f = filter.trim().toLowerCase()
const list = pt.procs.filter((p) => !f || p.name.toLowerCase().includes(f) || (p.cmd ?? '').toLowerCase().includes(f) || p.user.toLowerCase().includes(f) || String(p.pid) === f)
const key = (p: Proc) => (sort === 'cpu' ? p.cpu : sort === 'gpu' ? (p.gpu ?? 0) : sort === 'mem' ? p.rss : sort === 'pid' ? p.pid : 0)
list.sort((a, b) => (sort === 'name' ? a.name.localeCompare(b.name) : key(b) - key(a) || b.cpu - a.cpu))
return rev ? list.reverse() : list
}, [pt, filter, sort, rev])
const signal = async (p: Proc, sig: 'TERM' | 'KILL') => {
if (!(await askYes(`Send SIG${sig} to ${p.name} (pid ${p.pid})?`))) return
try { await api.procSignal(p.pid, sig); toast(`SIG${sig} sent to ${p.pid}`) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
}
if (!s && !pt) return <div className="top" style={pal.vars as React.CSSProperties}><Loading label={err || 'Reading the machine…'} /></div>
const cpu = s?.cpu ?? pt?.cpu ?? 0
const cores = (s?.cores?.length ? s.cores : pt?.cores) ?? []
const load = s?.load ?? pt?.load ?? [0, 0, 0]
const mem = s?.mem ?? pt?.mem ?? { total: 0, used: 0, avail: 0 }
const uptime = s?.uptime ?? pt?.uptime ?? 0
// "11th Gen Intel(R) Core(TM) i7-11700T @ 1.40GHz" is a whole line of border to itself; the
// clock is already beside it, and the generation is in the model number
const model = (pt?.cpu_model ?? '').replace(/\(R\)|\(TM\)|CPU|Processor/g, '').replace(/@.*$/, '').replace(/^\d+th Gen\s*/i, '').replace(/\s+/g, ' ').trim()
// the hottest thing that looks like a CPU sensor, and one per core where the chip names them;
// a second thread of a core (CPU 8 on an 8-core chip with hyper-threading) wears its core's
const coreTemp = (i: number) => { const id = pt?.core_ids?.[i] ?? i; return s?.temps.find((t) => new RegExp(`core ${id}\\b`, 'i').test(t.label))?.c }
const cpuTemp = pt?.cpu_temp ?? s?.temps.find((t) => /package|tctl|tdie|cpu/i.test(t.label))?.c ?? coreTemp(0)
// the temperature columns are there for every row once any row has one, so the % column lines up
const anyTemp = cpuTemp !== undefined || cores.some((_, i) => coreTemp(i) !== undefined)
const cached = (mem as { cached?: number }).cached ?? 0
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')
// 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)
// the top of a graph's scale: the peak of what it is drawing — the window, not everything kept
const peak = (id: string) => Math.max(1, ...(curves.get(id) ?? []).slice(-cap))
// the GPUs, with each one's sensor stem: the same rule the server uses, so two identical
// cards are gpu.<name> and gpu.<name>_2 here as there
const gpus = (pt?.gpus ?? []).map((g, i, all) => {
const seg = segOf(g.name), n = all.slice(0, i).filter((o) => segOf(o.name) === seg).length
return { g, stem: 'gpu.' + seg + (n ? `_${n + 1}` : '') }
})
return (
<Pal.Provider value={pal}>
<div className={'top' + (gpus.length ? ' has-gpu' : '')} style={pal.vars as React.CSSProperties}>
<Box n={1} name="cpu" className="top-cpu" right={<>
<span className="t-clock">{clock.toLocaleTimeString([], { hour12: false })}</span>
<span className="t-rate">
<button title="slower" disabled={rateIdx >= REFRESH_OPTIONS.length - 1} onClick={() => setRate(rateIdx + 1)}>-</button>
{fmtMs(refreshMs)}
<button title="faster" disabled={rateIdx <= 0} onClick={() => setRate(rateIdx - 1)}>+</button>
</span>
</>}>
<div className="t-cpu">
<div className="t-cpu-graph">
<Graph values={curves.get('cpu.usage') ?? []} tick={tick} advance={advance} cap={cap} max={100} grad="cpu" />
<div className="t-up">up {upfor(uptime)}</div>
</div>
<div className="t-cores">
<div className="t-cores-head"><b>{model || 'cpu'}</b><span className="spacer" />{pt?.cpu_mhz ? `${Math.round(pt.cpu_mhz)} MHz` : ''}</div>
<div className="t-core">
<span className="t-core-n">CPU</span>
<Meter pct={cpu} grad="cpu" />
<span className="t-core-p">{Math.round(cpu)}%</span>
{anyTemp && <><span className="t-core-t">{cpuTemp !== undefined && <Meter pct={Math.min(100, (cpuTemp / 100) * 100)} grad="temp" />}</span><span className="t-core-c">{cpuTemp !== undefined ? `${Math.round(cpuTemp)}°C` : ''}</span></>}
</div>
{cores.map((v, i) => {
const t = coreTemp(i)
return (
<div className="t-core" key={i}>
<span className="t-core-n">C{i}</span>
<span className="t-core-g" title={pt?.core_ids && pt.core_ids[i] !== i ? `cpu ${i} · a thread of core ${pt.core_ids[i]}` : `core ${i}`}>{braille(curves.get(`cpu.core.${i}.usage`) ?? [], 9)}</span>
<span className="t-core-p">{Math.round(v)}%</span>
{anyTemp && <><span className="t-core-t">{t !== undefined && <Meter pct={Math.min(100, t)} grad="temp" />}</span><span className="t-core-c">{t !== undefined ? `${Math.round(t)}°C` : ''}</span></>}
</div>
)
})}
<div className="t-load">Load AVG:<span className="spacer" />{load.map((x) => x.toFixed(2)).join(' ')}</div>
</div>
</div>
</Box>
{gpus.length > 0 && (
<div className="top-gpus">
{gpus.map(({ g, stem }, i) => (
<Box key={stem} name={`gpu${i}`} className="top-gpu" right={<span className="t-gpu-name" title={[g.name, g.driver, g.bus].filter(Boolean).join(' · ')}>{g.name}</span>}>
<GPUBox g={g} values={curves.get(stem + '.usage') ?? []} tick={tick} advance={advance} cap={cap} />
</Box>
))}
</div>
)}
<Box n={2} name="mem" className="top-mem">
<div className="t-row"><b>Total:</b><span className="spacer" />{bytes(mem.total)}</div>
{([['Used', mem.used, 'used'], ['Available', mem.avail, 'avail'], ['Cached', cached, 'cached'], ['Free', free, 'free']] as const).map(([k, v, g]) => (
<div className="t-mem" key={k}>
<div className="t-row"><b>{k}:</b><span className="spacer" />{bytes(v)}</div>
<div className="t-row t-sub"><span className="t-pct">{Math.round(pct(v, mem.total))}%</span></div>
<Meter pct={pct(v, mem.total)} grad={g as GradName} />
</div>
))}
</Box>
<Box n={3} name="disks" className="top-disks" right="io">
<div className="t-disks">
{(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)} pool={pools[d.name]} />
})}
{(s?.disks ?? []).length === 0 && <div className="t-dim">no drives reported</div>}
</div>
</Box>
<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">
<div className="t-net-g">
<span className="t-scale">{rate(peak(`net.${segOf(iface.iface)}.rx`))}</span>
<Graph values={curves.get(`net.${segOf(iface.iface)}.rx`) ?? []} tick={tick} advance={advance} cap={cap} grad="down" />
</div>
<div className="t-net-g">
<Graph values={curves.get(`net.${segOf(iface.iface)}.tx`) ?? []} tick={tick} advance={advance} cap={cap} grad="up" flip />
<span className="t-scale t-scale-b">{rate(peak(`net.${segOf(iface.iface)}.tx`))}</span>
</div>
</div>
<div className="t-net-nums">
<div className="t-dim">download</div>
<div className="t-row"><span className="t-dl">▼</span> {rate(iface.rx_bps)}<span className="spacer" />({bits(iface.rx_bps)})</div>
<div className="t-row"><span className="t-dl">▼</span> Top:<span className="spacer" />({bits(Math.max(0, ...(curves.get(`net.${segOf(iface.iface)}.rx`) ?? [0])))})</div>
<div className="t-row"><span className="t-dl">▼</span> Total:<span className="spacer" />{bytes(iface.rx_total)}</div>
<div className="t-gap" />
<div className="t-row"><span className="t-ul">▲</span> {rate(iface.tx_bps)}<span className="spacer" />({bits(iface.tx_bps)})</div>
<div className="t-row"><span className="t-ul">▲</span> Top:<span className="spacer" />({bits(Math.max(0, ...(curves.get(`net.${segOf(iface.iface)}.tx`) ?? [0])))})</div>
<div className="t-row"><span className="t-ul">▲</span> Total:<span className="spacer" />{bytes(iface.tx_total)}</div>
<div className="t-dim t-up-label">upload</div>
</div>
</div>
) : <div className="t-dim">no interface is up</div>}
</Box>
<Box n={5} name="proc" className="top-proc" right={<>
<span className="t-sorts">
{(['pid', 'name', 'mem', ...(gpus.length ? ['gpu' as const] : []), 'cpu'] as const).map((k) => <button key={k} className={sort === k ? 'on' : ''} onClick={() => setSort(k)}>{k}</button>)}
<button className={rev ? 'on' : ''} onClick={() => setRev(!rev)}>reverse</button>
</span>
</>}>
<div className={'t-proc' + (gpus.length ? ' has-gpu' : '')}>
<input className="t-filter" value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="filter" spellCheck={false} autoCapitalize="off" autoCorrect="off" />
<div className="t-thead">
<span className="c-pid">Pid:</span><span className="c-name">Program:</span><span className="c-cmd">Command:</span>
<span className="c-thr">Threads:</span><span className="c-user">User:</span><span className="c-mem">MemB</span><span className="c-g" />{gpus.length > 0 && <span className="c-gpu">Gpu%</span>}<span className="c-cpu">Cpu%</span>
</div>
<Rows procs={procs} sel={sel} onSel={setSel} curves={pcurve} gpu={gpus.length > 0} empty={err || 'no process matches'} />
<div className="t-foot">
{sel !== null && procs.find((p) => p.pid === sel) ? (() => { const p = procs.find((x) => x.pid === sel)!; return <>
<span className="t-dim">{p.pid} {p.name}</span><span className="spacer" />
<button onClick={() => signal(p, 'TERM')}>terminate</button>
<button onClick={() => signal(p, 'KILL')}>kill</button>
</> })() : <><span className="t-dim">select a process to signal it</span><span className="spacer" /></>}
<span className="t-count">{procs.length}/{pt?.total ?? 0}</span>
</div>
</div>
</Box>
</div>
</Pal.Provider>
)
}
// The rows are the heaviest thing on the page — hundreds of them — so they are redrawn when the
// table is read again, not on every tick of the curves above them.
const Rows = memo(function Rows({ procs, sel, onSel, curves, gpu, empty }: { procs: Proc[]; sel: number | null; onSel: (p: number | null) => void; curves: React.RefObject<Map<number, number[]>>; gpu: boolean; empty: string }) {
return (
<div className="t-rows">
{procs.map((p) => {
const c = curves.current?.get(p.pid) ?? []
return (
<div key={p.pid} className={'t-prow' + (sel === p.pid ? ' on' : '')} onClick={() => onSel(sel === p.pid ? null : p.pid)}>
<span className="c-pid">{p.pid}</span>
<span className="c-name">{p.name}</span>
<span className="c-cmd" title={p.cmd || p.name}>{p.cmd || p.name}</span>
<span className="c-thr">{p.threads}</span>
<span className="c-user">{p.user}</span>
<span className="c-mem">{shortBytes(p.rss)}</span>
<span className="c-g">{braille(c, 7, Math.max(5, ...(c.length ? c : [0])))}</span>
{gpu && <span className={'c-gpu' + (p.gpu ? ' lit' : '')}>{p.gpu ? p.gpu.toFixed(1) : ''}</span>}
<span className="c-cpu">{p.cpu.toFixed(1)}</span>
</div>
)
})}
{procs.length === 0 && <div className="t-dim" style={{ padding: 8 }}>{empty}</div>}
</div>
)
})
// One GPU, laid out like the cpu box: the busy curve on the left; on the right the device's
// meters — busy, each engine, temperature — then clock, power and memory, then the processes
// that have it open with their share. What cannot be measured on this card is left out rather
// than shown as zero: an Arc on i915 has no temperature sensor, Asahi has no busy counter.
const ENGINES: [string, string][] = [['render', 'render'], ['compute', 'compute'], ['video', 'video'], ['video-enhance', 'enhance'], ['copy', 'copy']]
function GPUBox({ g, values, tick, advance, cap }: { g: GPU; values: number[]; tick: number; advance: number; cap: number }) {
const engines = ENGINES.filter(([k]) => g.engines && g.engines[k] !== undefined)
const temp = g.temp !== undefined && g.temp > 0 ? g.temp : undefined
const how = g.stalled ? 'a sysfs read of this card stopped answering; its readings are paused until the console restarts'
: g.usage_src === 'idle' ? 'busy, from idle residency' : g.usage_src === 'busy' ? 'busy, as the driver reports it' : g.usage_src === 'clients' ? 'the busiest engine, summed over its clients' : 'this driver exposes no activity counter'
return (
<div className="t-gpu">
<div className="t-gpu-graph">
<Graph values={values} tick={tick} advance={advance} cap={cap} max={100} grad="cpu" />
<div className="t-up">{g.usage_src ? `${Math.round(g.usage)}% ` : ''}{how}</div>
</div>
<div className="t-gpu-stats">
<div className="t-cores-head"><b>{g.name}</b><span className="spacer" />{g.driver}{g.integrated ? ' · integrated' : ''}</div>
<div className="t-core">
<span className="t-core-n">GPU</span>
<Meter pct={g.usage_src ? g.usage : 0} grad="cpu" />
<span className="t-core-p">{g.usage_src ? `${Math.round(g.usage)}%` : '—'}</span>
<span className="t-core-t">{temp !== undefined && <Meter pct={Math.min(100, temp)} grad="temp" />}</span>
<span className="t-core-c" title={temp !== undefined ? (g.temp_src === 'package' ? 'the CPU package: an integrated GPU shares the die, and this is its only sensor' : "the card's own sensor") : 'no temperature sensor on this device'}>{temp !== undefined ? `${Math.round(temp)}°C${g.temp_src === 'package' ? '*' : ''}` : ''}</span>
</div>
{engines.map(([k, label]) => (
<div className="t-core" key={k}>
<span className="t-core-n t-eng">{label}</span>
<Meter pct={g.engines![k]} grad="cpu" />
<span className="t-core-p">{Math.round(g.engines![k])}%</span>
<span className="t-core-t" /><span className="t-core-c" />
</div>
))}
<div className="t-gpu-facts">
{(g.mhz !== undefined || g.max_mhz) ? <div className="t-row t-sub"><span>Clock</span><span className="spacer" />
<span className="t-fact" title={g.req_mhz ? `${Math.round(g.req_mhz)} MHz requested${g.max_mhz ? `, ${Math.round(g.max_mhz)} MHz max` : ''}` : undefined}>{Math.round(g.mhz ?? 0)}{g.max_mhz ? ` / ${Math.round(g.max_mhz)}` : ''} MHz</span></div> : null}
{(g.power_w !== undefined && g.power_w > 0) || g.power_cap_w ? <div className="t-row t-sub"><span>Power</span><span className="spacer" />
<span className="t-fact">{(g.power_w ?? 0).toFixed(1)}{g.power_cap_w ? ` / ${Math.round(g.power_cap_w)}` : ''} W</span></div> : null}
{g.fan_rpm ? <div className="t-row t-sub"><span>Fan</span><span className="spacer" /><span className="t-fact">{g.fan_rpm} rpm</span></div> : null}
{g.mem_total ? <div className="t-row t-sub"><span>Mem</span><span className="t-pct">{Math.round(((g.mem_used ?? 0) / g.mem_total) * 100)}%</span><Meter pct={((g.mem_used ?? 0) / g.mem_total) * 100} grad="used" /><span className="t-amt">{bytes(g.mem_used ?? 0, 1)}</span></div>
: g.mem_used ? <div className="t-row t-sub"><span>Mem</span><span className="spacer" /><span className="t-fact" title="resident memory of the processes that have it open">{bytes(g.mem_used, 1)}</span></div> : null}
</div>
<div className="t-gpu-clients">
<div className="t-thead"><span className="c-pid">Pid:</span><span className="c-name">Program:</span><span className="c-mem">MemB</span><span className="c-cpu">Gpu%</span></div>
{g.clients.map((c) => (
<div className="t-crow" key={c.pid} title={c.engines ? Object.entries(c.engines).map(([k, v]) => `${k} ${v.toFixed(1)}%`).join(' ') : undefined}>
<span className="c-pid">{c.pid}</span><span className="c-name">{c.name}</span><span className="c-mem">{c.mem ? shortBytes(c.mem) : ''}</span><span className="c-cpu">{c.usage.toFixed(1)}</span>
</div>
))}
{g.clients.length === 0 && <div className="t-dim t-crow-none">{g.clients_ok ? 'no process has it open' : 'other users’ processes are hidden: the console has no root here'}</div>}
</div>
</div>
</div>
)
}
// 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, pool }: { name: string; note?: string; size: number; used?: number; io?: number; temp?: number; mounts?: string[]; pool?: string }) {
const upct = used !== undefined && size > 0 ? (used / size) * 100 : 0
return (
<div className="t-disk">
<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">{pool ? `member of ZFS pool ${pool}` : 'nothing mounted from it'}</div>
)}
</div>
)
}
// the sensor ID segment for an interface name, the same rule the server uses
const segOf = (name: string) => name.toLowerCase().replace(/[^a-z0-9-]+/g, '_').replace(/^_|_$/g, '')