A Top tab: the whole machine on one screen, in btop's style

Boxes with their name in the border, dot curves that climb green to red, per-core meters and
temperatures, memory and filesystems, per-interface traffic and the process table. Drawn from
the sensor stream and /api/procs, so it works on every machine the console can see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-20 23:52:29 -07:00
co-authored by Claude Opus 5
parent b8eb4f5d40
commit 0587f50142
4 changed files with 539 additions and 3 deletions
+9
View File
@@ -199,6 +199,15 @@ GET /api/fleet/sensors/query?machines=grid,wyse&cpu=cpu.usage&failed=services.f
→ {"core": {"cpu": 4.8, "up": 50211}, "grid": {"cpu": 0.4, "up": 437177}, "mac": {"error": "timeout"}}
```
## The Top tab
Ham Lab → **Top** is a whole machine on one screen in the style of btop: black ground, boxes
with their name set into the border, curves drawn as dots that climb green → yellow → red.
It reads nothing of its own — the sensor stream for everything measured, `/api/procs` for the
table — so it works on any machine the console can reach, whether or not btop is installed
there, and it follows the same refresh rate and history window as the rest of the console.
(The look is btop's, `web/src/Top.tsx` is ours; btop itself is Apache-2.0.)
## How far back a history reaches
Every machine keeps **300 points per curve**, whatever the window — so the window sets how
+6 -3
View File
@@ -14,6 +14,7 @@ import Updates from './Updates'
import Disks from './Disks'
import Files from './Files'
import Procs from './Procs'
import Top from './Top'
import Machines from './Machines'
import Settings from './Settings'
import MachineSwitcher from './MachineSwitcher'
@@ -50,6 +51,7 @@ const BellIcon = (
const GROUPS: Group[] = [
{ id: 'dashboard', label: 'Dashboard', icon: '◔' },
{ id: 'top', label: 'Top', icon: '▚' },
{ id: 'procs', label: 'Processes', icon: '≡' },
{ id: 'terminal', label: 'Terminal', icon: '>_' },
{ id: 'services', label: 'Services', icon: '◈' },
@@ -64,8 +66,8 @@ const GROUPS: Group[] = [
{ id: 'settings', label: 'Settings', icon: '⚙' },
]
type View = 'terminal' | 'dashboard' | 'widgets' | 'docker' | 'alerts' | 'services' | 'cron' | 'updates' | 'disks' | 'files' | 'procs' | 'machines' | 'settings'
const VIEWS: View[] = ['terminal', 'dashboard', 'widgets', 'docker', 'alerts', 'services', 'cron', 'updates', 'disks', 'files', 'procs', 'machines', 'settings']
type View = 'terminal' | 'dashboard' | 'top' | 'widgets' | 'docker' | 'alerts' | 'services' | 'cron' | 'updates' | 'disks' | 'files' | 'procs' | 'machines' | 'settings'
const VIEWS: View[] = ['terminal', 'dashboard', 'top', 'widgets', 'docker', 'alerts', 'services', 'cron', 'updates', 'disks', 'files', 'procs', 'machines', 'settings']
const HUB_ONLY: View[] = ['machines', 'settings', 'alerts']
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and
@@ -252,6 +254,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
{view === 'widgets' && <Gallery demoUrl={hubMe.demo_url} />}
{view === 'docker' && <Docker />}
{view === 'alerts' && <Alerts machines={machines.map((m) => m.name)} />}
{view === 'top' && <Top />}
{view === 'procs' && <Procs />}
{view === 'services' && <Services />}
{view === 'cron' && <Cron />}
@@ -283,7 +286,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
<span>{machine ? `${machine} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
<span>up {fmtUptime(me.uptime)}</span>
<span className="spacer" />
<span className="muted">{view === 'dashboard' ? 'Dashboard' : view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span>
<span className="muted">{view === 'top' ? 'Top' : view === 'dashboard' ? 'Dashboard' : view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span>
</footer>
</div>
)
+433
View File
@@ -0,0 +1,433 @@
// 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 { memo, useCallback, 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, REFRESH_OPTIONS, setRefreshMs, strideMs, useHistorySec, useRefreshMs } from './prefs'
import { Loading, askYes, toast } from './ui'
const MAX = 300 // points kept per curve, as the machines keep them
// ---- the palette, btop's default theme ----
const GRAD = {
cpu: ['#00d35c', '#c8e000', '#ff3c3c'],
used: ['#ff5c5c', '#ff8c5c', '#ffc65c'],
avail: ['#ffb05c', '#ffd05c', '#fff05c'],
cached: ['#3ca0ff', '#5cc8ff', '#8ce4ff'],
free: ['#00d35c', '#4ce07a', '#8cf0a0'],
down: ['#c850ff', '#e050d0', '#ff50a0'],
up: ['#5060ff', '#50a0ff', '#50d0ff'],
temp: ['#4c8cff', '#7cb0ff', '#a0d0ff'],
io: ['#ffb05c', '#ff8c5c', '#ff5c5c'],
} as const
type GradName = keyof typeof GRAD
// mix(a, b, t) on hex, so a dot's colour can fall anywhere along a three-stop gradient
function mix(a: string, b: string, t: number): string {
const p = (h: string, i: number) => parseInt(h.slice(1 + i * 2, 3 + i * 2), 16)
const c = [0, 1, 2].map((i) => Math.round(p(a, i) + (p(b, i) - p(a, 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, max, grad, flip = false }: { values: number[]; tick: number; max?: number; grad: GradName; flip?: boolean }) {
const ref = useRef<HTMLCanvasElement>(null)
const box = useRef<HTMLDivElement>(null)
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 curve always spans the box: more points than columns and the oldest fall off the
// left, fewer and they are stretched, so the width always means the whole window
const have = values.length > cols ? values.slice(-cols) : values
const seen = have.length >= cols ? have : Array.from({ length: have.length ? cols : 0 }, (_, i) => have[Math.min(have.length - 1, Math.floor((i * have.length) / cols))])
const top = max ?? Math.max(1, ...seen)
const stops = GRAD[grad]
for (let i = 0; i < seen.length; i++) {
const lit = Math.round((Math.max(0, seen[i]) / top) * rows)
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, w, height, max, grad, flip]) // tick: the curve is appended to in place
return <div className="tg" ref={box}><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 = GRAD[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 = Math.max(0, Math.min(4, Math.round((v / top) * 4)))
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 [ticks, bump] = useState(0)
const seed = useCallback(async () => {
try {
const hs = await fetchHistories(CURVES)
const m = new Map<string, number[]>()
let newest = 0
for (const h of hs) { m.set(h.id, h.v.slice(-MAX)); newest = Math.max(newest, h.t[h.t.length - 1] ?? 0) }
cur.current = m; lastT.current = newest; binN.current = 1
bump((x) => x + 1)
} catch { /* the curves fill in live instead */ }
}, [])
useEffect(() => { seed() }, [seed, resumed])
useEffect(() => {
if (!live || !t) return
const fresh = lastT.current === 0 || t - lastT.current >= strideMs(historySec, refreshMs)
binN.current = fresh ? 1 : binN.current + 1
if (fresh) lastT.current = t
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] 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] = 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, Math.max(1000, refreshMs))
return () => { stop = true; window.clearInterval(i) }
}, [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"><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
const model = (pt?.cpu_model ?? '').replace(/\(R\)|\(TM\)|CPU|Processor/g, '').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)
return (
<div className="top">
<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" onClick={() => setRefreshMs(REFRESH_OPTIONS[Math.min(REFRESH_OPTIONS.length - 1, REFRESH_OPTIONS.indexOf(refreshMs as never) + 1)] ?? 5000)}>-</button>
{fmtMs(refreshMs)}
<button title="faster" onClick={() => setRefreshMs(REFRESH_OPTIONS[Math.max(0, REFRESH_OPTIONS.indexOf(refreshMs as never) - 1)] ?? 250)}>+</button>
</span>
</>}>
<div className="t-cpu">
<div className="t-cpu-graph">
<Graph values={curves.get('cpu.usage') ?? []} tick={tick} 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) => {
const d = s?.disks.find((x) => f.mount === '/' ? /^(nvme0n1|sda|vda|mmcblk0)$/.test(x.name) : false)
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(Math.max(1, ...(curves.get(`net.${segOf(iface.iface)}.rx`) ?? [0])))}</span>
<Graph values={curves.get(`net.${segOf(iface.iface)}.rx`) ?? []} tick={tick} grad="down" />
</div>
<div className="t-net-g">
<Graph values={curves.get(`net.${segOf(iface.iface)}.tx`) ?? []} tick={tick} grad="up" flip />
<span className="t-scale t-scale-b">{rate(Math.max(1, ...(curves.get(`net.${segOf(iface.iface)}.tx`) ?? [0])))}</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>
)
}
// 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, '')
+91
View File
@@ -534,3 +534,94 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
.clash { color: var(--s2); font-size: 12px; overflow-wrap: anywhere; }
@media (max-width: 800px) { .stack-new .two { grid-template-columns: 1fr; } }
.dtable td.row-end { text-align: right; white-space: nowrap; width: 1%; } .dtable .badge { margin-left: 0; }
/* ---- Top: a machine drawn the way btop draws it ----
Its own palette, not the skin's: black ground, dim blue hairlines, dotted curves that climb
green → red. Fixed-width everywhere, because that is the look. */
.top { position: absolute; inset: 0; background: #000; color: #cccccc; font-family: var(--mono); font-size: 13px; line-height: 1.35;
padding: 6px; display: grid; gap: 4px; grid-template-columns: 1fr 1fr 1.6fr; grid-template-rows: minmax(200px, 1fr) auto minmax(170px, 1fr); overflow-y: auto; }
/* every box says how little it can be: a window too short to hold them all scrolls, rather than
cutting the bottom off whichever box the grid ran out of room for */
.t-cpu-graph { min-height: 120px; } .t-net-graphs { min-height: 130px; } .top-proc { min-height: 300px; }
.top-mem, .top-disks { min-height: 165px; } /* a grid row set to auto will otherwise squeeze them to nothing */
.top-cpu { grid-column: 1 / -1; } .top-mem { grid-column: 1; grid-row: 2; } .top-disks { grid-column: 2; grid-row: 2; }
.top-net { grid-column: 1 / 3; grid-row: 3; } .top-proc { grid-column: 3; grid-row: 2 / 4; }
.tbox { position: relative; border: 1px solid #303a4a; border-radius: 3px; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
.tbox-in { flex: 1; min-height: 0; min-width: 0; padding: 9px 8px 6px; display: flex; flex-direction: column; overflow: hidden; }
.tbox-name, .tbox-right { position: absolute; top: -9px; background: #000; padding: 0 4px; font-size: 12px; white-space: nowrap; z-index: 1; }
.tbox-name { left: 8px; color: #dddddd; } .tbox-name b { color: #ee4b4b; font-weight: 400; }
.tbox-right { right: 8px; color: #7b8496; display: flex; align-items: center; gap: 8px; }
.tbox-right button { background: none; border: 0; color: #7b8496; font-family: var(--mono); font-size: 12px; padding: 0 3px; cursor: pointer; }
.tbox-right button:hover, .tbox-right button.on { color: #ee4b4b; }
.t-clock { color: #dddddd; } .t-rate { display: inline-flex; align-items: center; gap: 2px; }
.t-dim { color: #6b7280; } .top .spacer { flex: 1; }
/* cpu */
.t-cpu { display: flex; gap: 8px; min-width: 0; flex: 1; min-height: 0; }
.t-cpu-graph { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
.t-up { color: #6b7280; font-size: 12px; padding-top: 2px; }
.t-cores { width: 310px; flex-shrink: 0; border: 1px solid #303a4a; border-radius: 3px; padding: 4px 6px; display: flex; flex-direction: column; gap: 1px; overflow-y: auto; scrollbar-width: thin; }
.t-cores-head { display: flex; gap: 8px; color: #7b8496; font-size: 12px; } .t-cores-head b { color: #dddddd; font-weight: 400; }
.t-core { display: flex; align-items: center; gap: 6px; }
.t-core-n { width: 30px; color: #cccccc; } .t-core-p { width: 34px; text-align: right; color: #dddddd; }
.t-core-g { flex: 1; color: #00d35c; letter-spacing: 0; overflow: hidden; white-space: nowrap; }
.t-core .tm { flex: 1; } .t-core-t { flex: 0 0 62px; display: flex; } .t-core-c { width: 42px; text-align: right; color: #4c8cff; }
.t-load { display: flex; margin-top: 2px; color: #cccccc; }
/* the dot graph and the dotted meter */
.tg { width: 100%; height: 100%; flex: 1; min-height: 0; overflow: hidden; }
.tm { position: relative; display: inline-block; height: 9px; flex: 1; min-width: 24px; vertical-align: middle; }
.tm-t, .tm-f { position: absolute; inset: 0; -webkit-mask-image: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 5px); mask-image: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 5px); }
.tm-t { background: #23293a; }
/* mem and disks */
.t-row { display: flex; align-items: center; gap: 6px; } .t-row b { color: #dddddd; font-weight: 400; }
.t-sub { color: #7b8496; font-size: 12px; }
.t-mem { margin-bottom: 4px; } .t-mem .t-sub { margin-top: -2px; } .t-pct { width: 34px; color: #dddddd; }
.t-mem > .tm { display: block; width: 100%; }
.t-amt { width: 76px; text-align: right; color: #cccccc; }
.t-disks { overflow-y: auto; display: flex; flex-direction: column; gap: 6px; }
.t-disk .t-row + .t-row { margin-top: -1px; }
/* net */
.t-net { display: flex; gap: 10px; min-width: 0; flex: 1; min-height: 0; }
.t-net-graphs { flex: 1; min-width: 0; display: flex; flex-direction: column; min-height: 140px; }
.t-net-g { position: relative; flex: 1; min-height: 0; display: flex; } .t-scale { position: absolute; left: 2px; top: 0; font-size: 11px; color: #6b7280; z-index: 1; }
.t-scale-b { top: auto; bottom: 0; }
.t-net-nums { width: 300px; flex-shrink: 0; border: 1px solid #303a4a; border-radius: 3px; padding: 4px 8px; }
.t-dl { color: #e050d0; } .t-ul { color: #50a0ff; } .t-gap { height: 6px; } .t-up-label { text-align: right; }
.t-if { color: #dddddd; }
/* proc */
.t-proc { display: flex; flex-direction: column; min-height: 0; flex: 1; }
.t-filter { background: #0a0d14; border: 1px solid #303a4a; border-radius: 3px; color: #dddddd; font-family: var(--mono); font-size: 12px; padding: 3px 6px; margin-bottom: 4px; }
.t-thead, .t-prow { display: grid; grid-template-columns: 58px 116px minmax(0, 1fr) 54px 58px 50px 56px 42px; gap: 7px; align-items: center; white-space: nowrap; }
.t-thead { padding-right: 8px; } /* the rows scroll under a scrollbar; keep the headings over their columns */
.t-thead { color: #7b8496; font-size: 12px; border-bottom: 1px solid #23293a; padding-bottom: 2px; }
.t-rows { flex: 1; min-height: 0; overflow-y: auto; scrollbar-width: thin; }
.t-prow { cursor: pointer; } .t-prow:hover { background: #12161f; } .t-prow.on { background: #1b2230; }
.t-prow .c-name { color: #00d35c; overflow: hidden; text-overflow: ellipsis; }
.t-prow .c-cmd { color: #9aa4b2; overflow: hidden; text-overflow: ellipsis; }
.t-prow .c-user { color: #cccccc; overflow: hidden; text-overflow: ellipsis; }
.c-pid, .c-thr, .c-mem, .c-cpu { text-align: right; } .t-thead .c-pid, .t-thead .c-name, .t-thead .c-cmd, .t-thead .c-user { text-align: left; }
.c-g { color: #6b7280; text-align: right; overflow: hidden; }
.t-foot { display: flex; align-items: center; gap: 8px; border-top: 1px solid #23293a; padding-top: 3px; margin-top: 2px; font-size: 12px; }
.t-foot button { background: none; border: 1px solid #303a4a; border-radius: 3px; color: #cccccc; font-family: var(--mono); font-size: 12px; padding: 1px 6px; cursor: pointer; }
.t-foot button:hover { border-color: #ee4b4b; color: #ee4b4b; } .t-count { color: #7b8496; }
.t-sorts { display: inline-flex; gap: 2px; }
@media (max-width: 1100px) {
.top { grid-template-columns: 1fr 1fr; grid-template-rows: minmax(230px, auto) minmax(220px, auto) minmax(200px, auto) minmax(420px, 1fr); }
.top-mem, .top-disks { min-height: 220px; }
.top-cpu { grid-column: 1 / -1; grid-row: 1; } .top-mem { grid-column: 1; grid-row: 2; } .top-disks { grid-column: 2; grid-row: 2; }
.top-net { grid-column: 1 / -1; grid-row: 3; } .top-proc { grid-column: 1 / -1; grid-row: 4; }
}
@media (max-width: 800px) {
.top { display: flex; flex-direction: column; font-size: 12px; }
.top > .tbox { flex: 0 0 auto; }
.t-cpu { flex-direction: column; } .t-cpu-graph { height: 130px; flex: 0 0 130px; } .t-cores { width: auto; max-height: 40vh; }
.t-net { flex-direction: column; } .t-net-nums { width: auto; } .t-net-graphs { flex: 0 0 130px; }
.top-proc { height: 70vh; min-height: 420px; }
.t-thead, .t-prow { grid-template-columns: 52px 1fr 46px 46px 40px; }
.t-thead .c-cmd, .t-prow .c-cmd, .t-thead .c-thr, .t-prow .c-thr, .t-thead .c-g, .t-prow .c-g { display: none; }
}