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:
co-authored by
Claude Opus 5
parent
b8eb4f5d40
commit
0587f50142
@@ -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
@@ -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
@@ -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, '')
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user