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>
601 lines
35 KiB
TypeScript
601 lines
35 KiB
TypeScript
// 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, '')
|