// 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 & { vars: Record } function paletteOf(t: Theme): Palette { const x = t.xterm as Record 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).detail)) window.addEventListener('hamlab:theme', h) return () => window.removeEventListener('hamlab:theme', h) }, []) return pal } const Pal = createContext(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(null) const box = useRef(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
} // ---- 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 ( ) } // ---- 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 (
{n !== undefined && {'¹²³⁴⁵⁶'[n - 1]}}{name} {right !== undefined && {right}}
{children}
) } // ---- 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>(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() 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(new Map()) const refetching = useRef(false) const [ready, setReady] = useState(false) const stream = useSensorStream(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(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(null) // a short cpu curve per process, built from what we have seen since the page opened const pcurve = useRef>(new Map()) useEffect(() => { if (!pt) return const live = new Set() 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
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 (
{clock.toLocaleTimeString([], { hour12: false })} {fmtMs(refreshMs)} }>
up {upfor(uptime)}
{model || 'cpu'}{pt?.cpu_mhz ? `${Math.round(pt.cpu_mhz)} MHz` : ''}
CPU {Math.round(cpu)}% {cpuTemp !== undefined && <>{Math.round(cpuTemp)}°C}
{cores.map((v, i) => { const t = coreTemp(i) return (
C{i} {braille(curves.get(`cpu.core.${i}.usage`) ?? [], 9)} {Math.round(v)}% {t !== undefined && <>{Math.round(t)}°C}
) })}
Load AVG:{load.map((x) => x.toFixed(2)).join(' ')}
Total:{bytes(mem.total)}
{([['Used', mem.used, 'used'], ['Available', mem.avail, 'avail'], ['Cached', cached, 'cached'], ['Free', free, 'free']] as const).map(([k, v, g]) => (
{k}:{bytes(v)}
{Math.round(pct(v, mem.total))}%
))}
{swap && swap.total > 0 && } {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 })} {fs.length === 0 &&
no filesystems reported
}
{iface.iface} : undefined}> {iface ? (
{rate(peak(`net.${segOf(iface.iface)}.rx`))}
{rate(peak(`net.${segOf(iface.iface)}.tx`))}
download
▼ {rate(iface.rx_bps)}({bits(iface.rx_bps)})
▼ Top:({bits(Math.max(0, ...(curves.get(`net.${segOf(iface.iface)}.rx`) ?? [0])))})
▼ Total:{bytes(iface.rx_total)}
▲ {rate(iface.tx_bps)}({bits(iface.tx_bps)})
▲ Top:({bits(Math.max(0, ...(curves.get(`net.${segOf(iface.iface)}.tx`) ?? [0])))})
▲ Total:{bytes(iface.tx_total)}
upload
) :
no interface is up
} {(['pid', 'name', 'mem', 'cpu'] as const).map((k) => )} }>
setFilter(e.target.value)} placeholder="filter" spellCheck={false} autoCapitalize="off" autoCorrect="off" />
Pid:Program:Command: Threads:User:MemBCpu%
{sel !== null && procs.find((p) => p.pid === sel) ? (() => { const p = procs.find((x) => x.pid === sel)!; return <> {p.pid} {p.name} })() : <>select a process to signal it} {procs.length}/{pt?.total ?? 0}
) } // 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>; empty: string }) { return (
{procs.map((p) => { const c = curves.current?.get(p.pid) ?? [] return (
onSel(sel === p.pid ? null : p.pid)}> {p.pid} {p.name} {p.cmd || p.name} {p.threads} {p.user} {shortBytes(p.rss)} {braille(c, 7, Math.max(5, ...(c.length ? c : [0])))} {p.cpu.toFixed(1)}
) })} {procs.length === 0 &&
{empty}
}
) }) function Disk({ name, size, used, io }: { name: string; size: number; used: number; io?: number }) { const upct = size > 0 ? (used / size) * 100 : 0 return (
{name}{bytes(size)}
{io !== undefined &&
IO%
}
Used:{Math.round(upct)}%{bytes(used, 1)}
Free:{Math.round(100 - upct)}%{bytes(Math.max(0, size - used), 1)}
) } // 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, '')