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:
Mitchell-Johnson
2026-09-21 10:12:45 -07:00
co-authored by Claude Fable 5.1
parent ef3dbdf543
commit 668e716601
13 changed files with 394 additions and 107 deletions
+9 -4
View File
@@ -2,7 +2,7 @@ import { ReactNode, useCallback, useEffect, useRef, useState } from 'react'
import { api, DashPrefs, Sample, Series, Temp } from './api'
import Sparkline from './Sparkline'
import { Loading, Spinner, askYes, toast } from './ui'
import { fmtMs, fmtWindowSec, strideMs, useHistorySec, useRefreshMs } from './prefs'
import { fmtMs, fmtWindowSec, nextPoint, strideMs, useHistorySec, useRefreshMs } from './prefs'
import { fetchCatalog, fetchHistories, useSensorStream } from './sensors'
import { Catalog, MONITOR_HISTORY, MONITOR_SHAPE, Values, mergeValues, sampleOf, seriesOf, stale } from './sampleAdapter'
import { Widget, WidgetData, WidgetSpec, shapeOf } from './widgets'
@@ -314,11 +314,13 @@ export default function Dashboard() {
const catalog = useRef<Catalog>(new Map())
const refetching = useRef(false)
const binN = useRef(1) // values averaged into the point currently being built
const lastPush = useRef(0) // when that point was started, by this browser's clock
const stock = useSensorStream<unknown[]>(MONITOR_SHAPE, { full: false, everyMs: refreshMs })
const seed = useCallback(async () => {
try {
catalog.current = new Map((await fetchCatalog()).map((x) => [x.id, x]))
hist.current = seriesOf(await fetchHistories(MONITOR_HISTORY), catalog.current)
lastPush.current = Date.now(); binN.current = 1
setErr('')
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
}, [])
@@ -333,9 +335,12 @@ export default function Dashboard() {
const s = sampleOf(v, catalog.current)
const h = hist.current
if (h) {
// the point being built keeps the time it started at, so the next one is a whole stride later
const last = h.t[h.t.length - 1] ?? 0
const fresh = h.t.length === 0 || s.t - last >= strideMs(historySec, refreshMs)
// The stride is timed on this clock alone (lastPush), never against the history's own
// timestamps: those are the machine's, and a viewer whose clock runs minutes slow would
// otherwise wait those minutes before its curves gained a point.
const np = nextPoint(s.t, h.t.length === 0 ? 0 : lastPush.current, strideMs(historySec, refreshMs), refreshMs)
const fresh = np.fresh
lastPush.current = np.start
binN.current = fresh ? 1 : binN.current + 1
if (fresh) pushPoint(h.t, s.t, true, 1)
const n = h.t.length
+4 -1
View File
@@ -20,7 +20,10 @@ export default function Power({ host, label, rebootNeeded }: { host: string; lab
return (
<>
<button className={'icon-btn power' + (rebootNeeded ? ' attn' : '')} title={rebootNeeded ? 'reboot required' : 'power'} onClick={() => { setOpen(true); setTyped(''); setSent(''); setErr('') }}>⏻</button>
<button className={'icon-btn power' + (rebootNeeded ? ' attn' : '')} title={rebootNeeded ? 'reboot required' : 'power'} onClick={() => { setOpen(true); setTyped(''); setSent(''); setErr('') }}>
{/* drawn, not typed: ⏻ (U+23FB) is missing from Windows' fonts and shows as a box of hex digits */}
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" aria-hidden="true"><path d="M8 1.8v6" /><path d="M4.6 4.2a5.2 5.2 0 1 0 6.8 0" /></svg>
</button>
{open && (
<div className="modal-back" onClick={() => !sent && setOpen(false)}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
+88 -42
View File
@@ -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>
)
}
+1 -1
View File
@@ -66,7 +66,7 @@ export default function Updates() {
{st.manager && !st.sudo && <div className="notice">Read-only: refreshing and upgrading need root. Run Ham Lab as root, or give its user passwordless sudo or doas.</div>}
{st.reboot_needed && (
<div className="notice attn">
<b>Reboot required</b>{st.reboot_pkgs.length > 0 && <> · {st.reboot_pkgs.join(', ')}</>} — use the ⏻ button in the header.
<b>Reboot required</b>{st.reboot_pkgs.length > 0 && <> · {st.reboot_pkgs.join(', ')}</>} — use the power button in the header.
</div>
)}
{err && <div className="error">{err}</div>}
+9
View File
@@ -59,6 +59,15 @@ export function strideMs(windowSec: number, refreshMs: number): number {
return Math.max(refreshMs, Math.round((windowSec * 1000) / HISTORY_POINTS))
}
// Is it time for a curve's next point? `last` is when the current one was started (this
// browser's clock; the caller keeps it). Half a tick of slack, because ticks never land exactly
// on time: without it a tick 5 ms early is "not yet" and every other point is skipped. The
// returned start keeps to the stride's own beat rather than drifting with each late tick.
export function nextPoint(now: number, last: number, stride: number, tickMs: number): { fresh: boolean; start: number } {
if (last === 0 || now - last >= stride - tickMs / 2) return { fresh: true, start: last === 0 || now - last > 2 * stride ? now : last + stride }
return { fresh: false, start: last }
}
export function fmtWindowSec(sec: number): string {
if (sec >= 3600) { const h = sec / 3600; return `${Number.isInteger(h) ? h : h.toFixed(1)} hour${h === 1 ? '' : 's'}` }
if (sec >= 60) { const m = sec / 60; return `${Number.isInteger(m) ? m : m.toFixed(1)} min` }
+62 -47
View File
@@ -536,82 +536,99 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
.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; }
The layout and the dots are btop's; the colours are the skin's. The ground is the skin's
terminal background and everything on it comes from the skin's variables and its sixteen
terminal colours (Top.tsx hands the few it needs over as --t-*), so switching skin reskins
this page like any other. Fixed-width everywhere, because that is the look. */
.top { position: absolute; inset: 0; background: var(--term-bg); color: var(--fg); font-family: var(--mono); font-size: 13px; line-height: 1.35;
padding: 6px; display: grid; gap: 4px; grid-template-columns: 1fr 1fr 2.2fr; 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 { position: relative; border: 1px solid var(--border); 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; }
.tbox-name, .tbox-right { position: absolute; top: -9px; background: var(--term-bg); padding: 0 4px; font-size: 12px; white-space: nowrap; z-index: 1; }
.tbox-name { left: 8px; color: var(--fg); } .tbox-name b { color: var(--accent); font-weight: 400; }
.tbox-right { right: 8px; color: var(--muted); display: flex; align-items: center; gap: 8px; }
.tbox-right button { background: none; border: 0; color: var(--muted); font-family: var(--mono); font-size: 12px; padding: 0 3px; cursor: pointer; }
.tbox-right button:hover:not(:disabled), .tbox-right button.on { color: var(--accent); } .tbox-right button:disabled { opacity: .35; cursor: default; }
.t-clock { color: var(--fg); } .t-rate { display: inline-flex; align-items: center; gap: 2px; }
.t-dim { color: var(--muted); } .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; white-space: nowrap; }
.t-cores-head b { color: #dddddd; font-weight: 400; overflow: hidden; text-overflow: ellipsis; }
.t-up { color: var(--muted); font-size: 12px; padding-top: 2px; }
.t-cores { width: 310px; flex-shrink: 0; border: 1px solid var(--border); 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: var(--muted); font-size: 12px; white-space: nowrap; }
.t-cores-head b { color: var(--fg); font-weight: 400; overflow: hidden; text-overflow: ellipsis; }
.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; }
.t-core-n { width: 30px; } .t-core-p { width: 34px; text-align: right; }
.t-core-g { flex: 1; color: var(--ok); 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: var(--t-blue); }
.t-load { display: flex; margin-top: 2px; }
/* 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; }
.tm-t { background: var(--border); }
/* 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-row { display: flex; align-items: center; gap: 6px; } .t-row b { font-weight: 400; }
.t-sub { color: var(--muted); font-size: 12px; }
.t-mem { margin-bottom: 4px; } .t-mem .t-sub { margin-top: -2px; } .t-pct { width: 34px; color: var(--fg); }
.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; }
.t-amt { width: 76px; text-align: right; color: var(--fg); white-space: nowrap; }
.t-disks { overflow-y: auto; display: flex; flex-direction: column; gap: 6px; scrollbar-width: thin; }
.t-disk .t-row + .t-row { margin-top: -1px; } .t-disk b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* 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-net-g { position: relative; flex: 1; min-height: 0; display: flex; } .t-scale { position: absolute; left: 2px; top: 0; font-size: 11px; color: var(--muted); z-index: 1; background: var(--term-bg); padding-right: 3px; }
.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; }
.t-net-nums { width: 300px; flex-shrink: 0; border: 1px solid var(--border); border-radius: 3px; padding: 4px 8px; white-space: nowrap; overflow: hidden; }
.t-dl { color: var(--t-magenta); } .t-ul { color: var(--t-blue); } .t-gap { height: 6px; } .t-up-label { text-align: right; }
.t-if { color: var(--fg); }
/* proc */
/* proc — the columns that fit are the columns shown: the box measures itself (it is a third of
a wide window and all of a narrow one, so the window's width says nothing) and drops the
curve, then the threads, then the command, rather than crushing Command to one letter */
.top-proc { container: tproc / inline-size; }
.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-filter { background: var(--bg); border: 1px solid var(--border); border-radius: 3px; color: var(--fg); 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) 62px 58px 50px 56px 42px; gap: 7px; align-items: center; white-space: nowrap; }
.t-thead > span, .t-prow > span { min-width: 0; overflow: hidden; } /* a heading never runs into its neighbour */
.t-thead { padding-right: 8px; color: var(--muted); font-size: 12px; border-bottom: 1px solid var(--border); 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; }
.t-prow { cursor: pointer; } .t-prow:hover { background: var(--bg2); } .t-prow.on { background: var(--bg3); }
.t-prow .c-name { color: var(--ok); text-overflow: ellipsis; } /* --ok, not the terminal's green: it is the one each skin was measured readable with */
.t-prow .c-cmd { color: var(--muted); text-overflow: ellipsis; }
.t-prow .c-user { 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; }
.c-g { color: var(--muted); text-align: right; }
@container tproc (max-width: 640px) {
.t-thead, .t-prow { grid-template-columns: 58px 116px minmax(0, 1fr) 62px 58px 50px 42px; }
.t-thead .c-g, .t-prow .c-g { display: none; }
}
@container tproc (max-width: 560px) {
.t-thead, .t-prow { grid-template-columns: 58px 110px minmax(0, 1fr) 58px 50px 42px; }
.t-thead .c-thr, .t-prow .c-thr { display: none; }
}
@container tproc (max-width: 470px) {
.t-thead, .t-prow { grid-template-columns: 52px minmax(0, 1fr) 52px 48px 40px; gap: 6px; }
.t-thead .c-cmd, .t-prow .c-cmd { display: none; }
}
.t-foot { display: flex; align-items: center; gap: 8px; border-top: 1px solid var(--border); padding-top: 3px; margin-top: 2px; font-size: 12px; }
.t-foot button { background: none; border: 1px solid var(--border); border-radius: 3px; color: var(--fg); font-family: var(--mono); font-size: 12px; padding: 1px 6px; cursor: pointer; }
.t-foot button:hover { border-color: var(--bad); color: var(--bad); } .t-count { color: var(--muted); }
.t-sorts { display: inline-flex; gap: 2px; }
@media (max-width: 1100px) {
@media (max-width: 1280px) {
.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; }
@@ -623,6 +640,4 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
.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; }
}