Files
hamlab/web/src/Top.tsx
T
Mitchell-JohnsonandClaude Opus 5 4b255fe63f A drive is a drive: NVMe namespaces fold into their controller
An Apple SSD presents three namespaces and the kernel lists each as a whole disk, so the
dashboard was showing one drive as three. The Disks page has always folded them; now the
dashboard, the Top tab and the disk.* sensors do too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 09:11:03 -07:00

484 lines
26 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, 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'
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$/.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 [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' | '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 === 'mem' ? p.rss : sort === 'pid' ? p.pid : 0)
list.sort((a, b) => (sort === 'name' ? a.name.localeCompare(b.name) : key(b) - key(a)))
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
const coreTemp = (i: number) => s?.temps.find((t) => new RegExp(`core ${i}\\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)
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')
const iface = net[0]
const fs = s?.fs ?? []
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))
return (
<Pal.Provider value={pal}>
<div className="top" 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>
{cpuTemp !== undefined && <><span className="t-core-t"><Meter pct={Math.min(100, (cpuTemp / 100) * 100)} grad="temp" /></span><span className="t-core-c">{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={`core ${i}`}>{braille(curves.get(`cpu.core.${i}.usage`) ?? [], 9)}</span>
<span className="t-core-p">{Math.round(v)}%</span>
{t !== undefined && <><span className="t-core-t"><Meter pct={Math.min(100, t)} grad="temp" /></span><span className="t-core-c">{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>
<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">
{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} />
})}
{fs.length === 0 && <div className="t-dim">no filesystems reported</div>}
</div>
</Box>
<Box n={4} name="net" className="top-net" right={iface ? <span className="t-if">{iface.iface}</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', '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">
<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" /><span className="c-cpu">Cpu%</span>
</div>
<Rows procs={procs} sel={sel} onSel={setSel} curves={pcurve} 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, empty }: { procs: Proc[]; sel: number | null; onSel: (p: number | null) => void; curves: React.RefObject<Map<number, number[]>>; 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>
<span className="c-cpu">{p.cpu.toFixed(1)}</span>
</div>
)
})}
{procs.length === 0 && <div className="t-dim" style={{ padding: 8 }}>{empty}</div>}
</div>
)
})
function Disk({ name, size, used, io }: { name: string; size: number; used: number; io?: number }) {
const upct = size > 0 ? (used / size) * 100 : 0
return (
<div className="t-disk">
<div className="t-row"><b>{name}</b><span className="spacer" />{bytes(size)}</div>
{io !== undefined && <div className="t-row t-sub"><span>IO%</span><Meter pct={io} grad="io" /></div>}
<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>
</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, '')