Top wears the skin; the refresh rate reaches every machine; bug bash 4
The process table's columns fit the box they are in, the rate control saves, curves are timed on the viewer's clock and keep to the stride, the power icon is drawn, and a young history draws in from the right instead of being stretched. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
ef3dbdf543
commit
668e716601
+88
-42
@@ -3,33 +3,60 @@
|
||||
// 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 { 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, REFRESH_OPTIONS, setRefreshMs, strideMs, useHistorySec, useRefreshMs } from './prefs'
|
||||
import { fmtMs, nextPoint, REFRESH_OPTIONS, setRefreshMs, strideMs, useHistorySec, useRefreshMs } from './prefs'
|
||||
import { Loading, askYes, toast } from './ui'
|
||||
import { Theme, currentThemeId, themeById } from './themes'
|
||||
import { savePrefs } from './accents'
|
||||
|
||||
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
|
||||
// ---- 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 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))
|
||||
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 {
|
||||
@@ -40,9 +67,10 @@ function at(g: readonly string[], t: number): string {
|
||||
|
||||
// ---- 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 }) {
|
||||
function Graph({ values, tick, cap, max, grad, flip = false }: { values: number[]; tick: 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
|
||||
@@ -59,14 +87,21 @@ function Graph({ values, tick, max, grad, flip = false }: { values: number[]; ti
|
||||
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))])
|
||||
// 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 — a handful of samples
|
||||
// is not stretched into a bar chart.
|
||||
const n = Math.min(values.length, cap)
|
||||
const seen: number[] = []
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const i = Math.floor((c * cap) / cols) - (cap - n)
|
||||
if (i >= 0) seen.push(values[values.length - n + i])
|
||||
}
|
||||
const top = max ?? Math.max(1, ...seen)
|
||||
const stops = GRAD[grad]
|
||||
const stops = pal[grad]
|
||||
for (let i = 0; i < seen.length; i++) {
|
||||
const lit = Math.round((Math.max(0, seen[i]) / top) * rows)
|
||||
// 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
|
||||
@@ -75,14 +110,14 @@ function Graph({ values, tick, max, grad, flip = false }: { values: number[]; ti
|
||||
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>
|
||||
}, [values, tick, 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 = GRAD[grad]
|
||||
const g = useContext(Pal)[grad]
|
||||
return (
|
||||
<span className="tm">
|
||||
<i className="tm-t" />
|
||||
@@ -146,7 +181,7 @@ function braille(values: number[], cells = 7, max = 100): string {
|
||||
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)))
|
||||
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)
|
||||
@@ -166,18 +201,20 @@ function useCurves(live: Values | null, t: number, refreshMs: number, historySec
|
||||
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
|
||||
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])
|
||||
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 = lastT.current === 0 || t - lastT.current >= strideMs(historySec, refreshMs)
|
||||
const { fresh, start } = nextPoint(t, lastT.current, strideMs(historySec, refreshMs), refreshMs)
|
||||
binN.current = fresh ? 1 : binN.current + 1
|
||||
if (fresh) lastT.current = t
|
||||
lastT.current = start
|
||||
const n = binN.current
|
||||
for (const [id, v] of Object.entries(live)) {
|
||||
if (typeof v !== 'number') continue
|
||||
@@ -220,10 +257,17 @@ export default function Top() {
|
||||
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))
|
||||
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('')
|
||||
@@ -257,7 +301,7 @@ export default function Top() {
|
||||
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>
|
||||
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) ?? []
|
||||
@@ -279,18 +323,19 @@ export default function Top() {
|
||||
const pct = (a: number, b: number) => (b > 0 ? (a / b) * 100 : 0)
|
||||
|
||||
return (
|
||||
<div className="top">
|
||||
<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" onClick={() => setRefreshMs(REFRESH_OPTIONS[Math.min(REFRESH_OPTIONS.length - 1, REFRESH_OPTIONS.indexOf(refreshMs as never) + 1)] ?? 5000)}>-</button>
|
||||
<button title="slower" disabled={rateIdx >= REFRESH_OPTIONS.length - 1} onClick={() => setRate(rateIdx + 1)}>-</button>
|
||||
{fmtMs(refreshMs)}
|
||||
<button title="faster" onClick={() => setRefreshMs(REFRESH_OPTIONS[Math.max(0, REFRESH_OPTIONS.indexOf(refreshMs as never) - 1)] ?? 250)}>+</button>
|
||||
<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} max={100} grad="cpu" />
|
||||
<Graph values={curves.get('cpu.usage') ?? []} tick={tick} cap={cap} max={100} grad="cpu" />
|
||||
<div className="t-up">up {upfor(uptime)}</div>
|
||||
</div>
|
||||
<div className="t-cores">
|
||||
@@ -345,10 +390,10 @@ export default function Top() {
|
||||
<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" />
|
||||
<Graph values={curves.get(`net.${segOf(iface.iface)}.rx`) ?? []} tick={tick} cap={cap} grad="down" />
|
||||
</div>
|
||||
<div className="t-net-g">
|
||||
<Graph values={curves.get(`net.${segOf(iface.iface)}.tx`) ?? []} tick={tick} grad="up" flip />
|
||||
<Graph values={curves.get(`net.${segOf(iface.iface)}.tx`) ?? []} tick={tick} cap={cap} 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>
|
||||
@@ -391,6 +436,7 @@ export default function Top() {
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
</Pal.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user