2 Commits
Author SHA1 Message Date
Mitchell-JohnsonandClaude Opus 5.5 f5f1cd79ba Top: curves and canvases update before paint, in the numbers' frame
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 01:16:28 -07:00
Mitchell-JohnsonandClaude Opus 5.5 70bebcb18b Top: one beat — graphs, numbers and the process table change on the same tick
Graphs stepped once a history point (1 s at a 5 min window) while the numbers changed every
tick; they now slide a fraction of a column each tick. The process table (and the GPU boxes,
MHz and watts it carries) was polled on its own timer; it is now read per sample and committed
with it. GPU clients are walked every sample, and the 1–2 s floors on the gallery, Network,
Docker and ZFS ARC live views are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 01:13:19 -07:00
10 changed files with 153 additions and 56 deletions
+24
View File
@@ -752,3 +752,27 @@ before its own login).
404 — harmless, but a stray. 404 — harmless, but a stray.
12. **Guest logins can open Users and Settings** — by design ("a guest can do everything you 12. **Guest logins can open Users and Settings** — by design ("a guest can do everything you
can"), but worth a second look now that Users includes removing accounts. can"), but worth a second look now that Users includes removing accounts.
# 2026-10-23: the rate obeyed, but not in step
**Graphs and numbers on Top moved on different beats, whatever the rate.** Every machine was
sampling at exactly the set rate (measured: 500 ms between samples on all five nodes), so the
sampler was not the fault. Four things in the console were:
1. ✅ **Graphs stepped once a point, not once a tick.** A point is the history window split 300
ways — at 5 min that is 1 s — so at 250 or 500 ms the numbers changed two to four times for
every move of a graph, and a narrow box (phone, net graph) moved once a *column*, every few
seconds. The columns now slide by a fraction of one on every tick (`columnsAt` in
`graphcols.ts`), the newest growing in from the right; a finished column still never changes.
The per-core strips keep the last 18 ticks rather than stride points.
2. ✅ **The process table, MHz, CPU watts and every GPU box ran on their own timer** — a
`setInterval` on `/api/procs` with an arbitrary phase against the sensor stream, so half the
page changed up to a tick after the other half. The table is now read on each sample and the
two are committed together: one render per tick for the whole page.
3. ✅ **GPU clients were walked at most once a second** (`gpuScanEvery`), so per-process GPU% and
the engine meters lagged the device's numbers. Walked every sample now.
4. ✅ **Hidden floors on other live views:** Sensors gallery (1 s), Network live rates (1 s) and
speed-test needle (0.4–1 s), Docker live (2 s), ZFS ARC (2 s). All at the rate now.
Not changed: container status sensors are read every 15 s (`docker ps` plus an inspect) and
filesystem usage every 30 s — slow-moving facts behind an exec or a statfs of every mount.
+8 -6
View File
@@ -85,8 +85,8 @@ type gpuState struct {
devs []*gpuDev devs []*gpuDev
listedAt time.Time listedAt time.Time
clients map[string]clientPrev // device key + client id → engine totals at the previous scan clients map[string]clientPrev // device key + client id → engine totals at the previous scan
last []drmFD // the newest client walk, reused between walks last []drmFD // the newest client walk
scanAt time.Time scan func() ([]drmFD, bool) // the walk; nil is scanClients (tests stand a fake list in)
helper *drmHelper helper *drmHelper
ok bool // the last walk saw every process ok bool // the last walk saw every process
} }
@@ -97,7 +97,6 @@ type clientPrev struct {
} }
const gpuRelist = time.Minute // how often the card list is re-read: hotplug is rare, the readdir is cheap const gpuRelist = time.Minute // how often the card list is re-read: hotplug is rare, the readdir is cheap
const gpuScanEvery = time.Second // engine shares over less than this are noise
// gpus is the GPU section of a sample. cpuTemp stands in for an integrated GPU's // gpus is the GPU section of a sample. cpuTemp stands in for an integrated GPU's
// temperature: it is the same die. // temperature: it is the same die.
@@ -112,10 +111,13 @@ func (c *Collector) gpus(now time.Time, cpuTemp float64) []GPU {
if g.clients == nil { if g.clients == nil {
g.clients = map[string]clientPrev{} g.clients = map[string]clientPrev{}
} }
if g.scanAt.IsZero() || now.Sub(g.scanAt) >= gpuScanEvery { // The clients are read on every sample, like everything else in it: their engine shares
g.last, g.ok = c.scanClients() // move on the same tick as the device's own numbers, at whatever rate is set.
g.scanAt = now scan := c.scanClients
if g.scan != nil {
scan = g.scan
} }
g.last, g.ok = scan()
out := make([]GPU, 0, len(g.devs)) out := make([]GPU, 0, len(g.devs))
live := map[string]bool{} live := map[string]bool{}
for _, d := range g.devs { for _, d := range g.devs {
+1 -2
View File
@@ -61,7 +61,7 @@ func TestGPUClientsShare(t *testing.T) {
c.gpu.clients = map[string]clientPrev{} c.gpu.clients = map[string]clientPrev{}
t0 := time.Now() t0 := time.Now()
c.gpu.last = []drmFD{{PID: 7, Comm: "ffmpeg", Info: fdinfoA}} c.gpu.last = []drmFD{{PID: 7, Comm: "ffmpeg", Info: fdinfoA}}
c.gpu.scanAt = t0 // no walk: the fake list stands c.gpu.scan = func() ([]drmFD, bool) { return c.gpu.last, true } // no walk: the fake list stands
first := c.gpus(t0, 40) first := c.gpus(t0, 40)
if len(first) != 1 || len(first[0].Clients) != 1 || first[0].Clients[0].Usage != 0 { if len(first) != 1 || len(first[0].Clients) != 1 || first[0].Clients[0].Usage != 0 {
t.Fatalf("first pass: %+v", first) t.Fatalf("first pass: %+v", first)
@@ -73,7 +73,6 @@ func TestGPUClientsShare(t *testing.T) {
later = replace(later, "drm-engine-render: 1000000000 ns", "drm-engine-render: 1500000000 ns") later = replace(later, "drm-engine-render: 1000000000 ns", "drm-engine-render: 1500000000 ns")
later = replace(later, "drm-engine-video: 2000000000 ns", "drm-engine-video: 4000000000 ns") later = replace(later, "drm-engine-video: 2000000000 ns", "drm-engine-video: 4000000000 ns")
c.gpu.last = []drmFD{{PID: 7, Comm: "ffmpeg", Info: later}} c.gpu.last = []drmFD{{PID: 7, Comm: "ffmpeg", Info: later}}
c.gpu.scanAt = t0.Add(time.Second)
got := c.gpus(t0.Add(time.Second), 40) got := c.gpus(t0.Add(time.Second), 40)
cl := got[0].Clients[0] cl := got[0].Clients[0]
if cl.Engines["render"] != 50 || cl.Engines["video"] != 100 || cl.Usage != 100 { if cl.Engines["render"] != 50 || cl.Engines["video"] != 100 || cl.Usage != 100 {
+24 -1
View File
@@ -8,7 +8,7 @@ import { join } from 'node:path'
const out = join(mkdtempSync(join(tmpdir(), 'graphcols-')), 'graphcols.mjs') const out = join(mkdtempSync(join(tmpdir(), 'graphcols-')), 'graphcols.mjs')
buildSync({ entryPoints: [new URL('../src/graphcols.ts', import.meta.url).pathname], outfile: out, format: 'esm', logLevel: 'error' }) buildSync({ entryPoints: [new URL('../src/graphcols.ts', import.meta.url).pathname], outfile: out, format: 'esm', logLevel: 'error' })
const { columnsOf } = await import(pathToFileURL(out).href) const { columnsOf, columnsAt } = await import(pathToFileURL(out).href)
const fails = [] const fails = []
const check = (ok, what, detail) => { if (!ok) fails.push(what + (detail !== undefined ? ' — ' + JSON.stringify(detail).slice(0, 200) : '')) } const check = (ok, what, detail) => { if (!ok) fails.push(what + (detail !== undefined ? ' — ' + JSON.stringify(detail).slice(0, 200) : '')) }
@@ -38,5 +38,28 @@ const wide = columnsOf(base, CAP - 1, CAP, 390)
check(wide.length === 390 && Math.max(...wide) === 900, 'a box wider than the history is filled, nothing invented', wide.length) check(wide.length === 390 && Math.max(...wide) === 900, 'a box wider than the history is filled, nothing invented', wide.length)
check(columnsOf([], 0, CAP, COLS).length === 0 && columnsOf([5], 0, 2, 50).length >= 1, 'empty and single-point curves') check(columnsOf([], 0, CAP, COLS).length === 0 && columnsOf([5], 0, 2, 50).length >= 1, 'empty and single-point curves')
// Sliding with the ticks: four ticks to a point (a 250 ms rate, a 1 s stride). Every tick moves
// the graph left by the same fraction of a column, the newest column grows in from the right, and
// a column, once finished, keeps its value wherever it has slid to.
for (const cols of [COLS, 390]) {
let v = base.slice(), adv = CAP - 1, prevAt = null, back = 0, jump = 0, held = 0
const was = new Map() // a finished column's value, by its number
for (let t = 0; t < 4 * 30; t++) {
const now = adv + ((t % 4) + 1) / 4
const { cols: seen, edge, last } = columnsAt(v, adv, now, CAP, cols)
check(edge > 0 && edge <= 1, `edge in (0, 1] (${cols} cols)`, edge)
check(seen.length >= cols - 2 && seen.length <= cols + 1, `the box is filled, give or take the sliver (${cols} cols)`, seen.length)
const at = last + edge // how far the graph has slid, in columns
if (prevAt !== null) { if (at < prevAt - 1e-9) back++; if (at > prevAt + 1 + 1e-9) jump++; if (Math.abs(at - prevAt) < 1e-9) held++ }
prevAt = at
seen.forEach((x, i) => { const k = last - (seen.length - 1 - i); if (k < last - 1) { if (was.has(k) && was.get(k) !== x) fails.push(`column ${k} changed after it was finished`); was.set(k, x) } })
if ((t % 4) === 3) { v.push(10); v.shift(); adv++ }
}
check(back === 0 && jump === 0, `the graph only slides left, less than a column a tick (${cols} cols)`, { back, jump })
check(held < 4 * 30 / 4, `the graph moves on almost every tick, not once a point (${cols} cols)`, held)
}
// a point per tick (stride == rate) is the old layout exactly
check(JSON.stringify(columnsAt(base, CAP - 1, CAP, CAP, COLS).cols.slice(-COLS)) === JSON.stringify(columnsOf(base, CAP - 1, CAP, COLS)), 'columnsOf is columnsAt at a whole point')
if (fails.length) { console.error('graphcols: FAIL\n ' + fails.join('\n ')); process.exit(1) } if (fails.length) { console.error('graphcols: FAIL\n ' + fails.join('\n ')); process.exit(1) }
console.log('graphcols: ok') console.log('graphcols: ok')
+1 -1
View File
@@ -402,7 +402,7 @@ export default function Docker() {
const rate = useRefreshMs() const rate = useRefreshMs()
const load = useCallback(async () => { try { setD(await api.docker()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } api.stacks().then((r) => setStacks(r.stacks)).catch(() => setStacks([])) }, []) const load = useCallback(async () => { try { setD(await api.docker()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } api.stacks().then((r) => setStacks(r.stacks)).catch(() => setStacks([])) }, [])
useEffect(() => { load(); const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, Math.max(5000, rate)); return () => clearInterval(t) }, [load, rate]) useEffect(() => { load(); const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, Math.max(5000, rate)); return () => clearInterval(t) }, [load, rate])
const live = useSensorStream<{ c: Record<string, Sensor> }>({ c: 'container.*' }, { everyMs: Math.max(2000, rate) }) const live = useSensorStream<{ c: Record<string, Sensor> }>({ c: 'container.*' }, { everyMs: rate })
const sensors = live.data?.c ?? {} const sensors = live.data?.c ?? {}
const byStack = useMemo(() => { const byStack = useMemo(() => {
const f = filter.trim().toLowerCase(); const out = new Map<string, DockerContainer[]>() const f = filter.trim().toLowerCase(); const out = new Map<string, DockerContainer[]>()
+2 -2
View File
@@ -37,8 +37,8 @@ export default function Gallery() {
const rate = useRefreshMs() const rate = useRefreshMs()
const [find, setFind] = useState('') const [find, setFind] = useState('')
useEffect(() => { fetchCatalog().then(setCat).catch((e) => setErr(e instanceof Error ? e.message : 'failed')) }, []) useEffect(() => { fetchCatalog().then(setCat).catch((e) => setErr(e instanceof Error ? e.message : 'failed')) }, [])
// Values only, at most once a second: the catalog already holds everything that doesn't change. // Values only, at the rate: the catalog already holds everything that doesn't change.
const live = useSensorStream<[Record<string, Sensor['value']>]>(['*'], { full: false, everyMs: Math.max(1000, rate) }) const live = useSensorStream<[Record<string, Sensor['value']>]>(['*'], { full: false, everyMs: rate })
const now = Date.now() const now = Date.now()
const sensors = useMemo(() => { const sensors = useMemo(() => {
const v = live.data?.[0] const v = live.data?.[0]
+2 -2
View File
@@ -81,7 +81,7 @@ export default function Network() {
// While a test is running the state is polled hard, because the needle should move; at rest // While a test is running the state is polled hard, because the needle should move; at rest
// the page is a read and asks again only now and then. // the page is a read and asks again only now and then.
useEffect(() => { useEffect(() => {
const every = running ? Math.max(400, Math.min(rate, 1000)) : Math.max(5000, rate) const every = running ? rate : Math.max(5000, rate)
const t = setInterval(() => { const t = setInterval(() => {
if (document.visibilityState !== 'visible') return if (document.visibilityState !== 'visible') return
if (running) api.speedtest().then((r) => setD((old) => (old ? { ...old, run: r } : old))).catch(() => {}) if (running) api.speedtest().then((r) => setD((old) => (old ? { ...old, run: r } : old))).catch(() => {})
@@ -93,7 +93,7 @@ export default function Network() {
const phase = run?.phase const phase = run?.phase
useEffect(() => { if (phase === 'done') load() }, [phase, load]) useEffect(() => { if (phase === 'done') load() }, [phase, load])
const live = useSensorStream<{ n: Record<string, Sensor> }>({ n: 'net.*' }, { everyMs: Math.max(1000, rate) }) const live = useSensorStream<{ n: Record<string, Sensor> }>({ n: 'net.*' }, { everyMs: rate })
const sens = live.data?.n ?? {} const sens = live.data?.n ?? {}
const rateOf = (name: string, dir: 'rx' | 'tx') => sens[`net.${name.toLowerCase().replace(/[^a-z0-9_-]+/g, '_')}.${dir}`]?.value const rateOf = (name: string, dir: 'rx' | 'tx') => sens[`net.${name.toLowerCase().replace(/[^a-z0-9_-]+/g, '_')}.${dir}`]?.value
+68 -33
View File
@@ -3,14 +3,14 @@
// as they climb. The numbers are Ham Lab's own — the sensor stream for everything measured and // 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 // /api/procs for the table — so this works on any machine the console can see, whether or not
// btop is installed on it. // btop is installed on it.
import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import { createContext, memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { api, GPU, ProcTable, Proc, Sample } from './api' import { api, GPU, ProcTable, Proc, Sample } from './api'
import { Catalog, MONITOR_SHAPE, Values, mergeValues, sampleOf, stale } from './sampleAdapter' import { Catalog, MONITOR_SHAPE, Values, mergeValues, sampleOf, stale } from './sampleAdapter'
import { fetchCatalog, fetchHistories, useSensorStream } from './sensors' import { fetchCatalog, fetchHistories, useSensorStream } from './sensors'
import { fmtMs, nextPoint, 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 { Loading, askYes, toast } from './ui'
import { Theme, currentThemeId, themeById } from './themes' import { Theme, currentThemeId, themeById } from './themes'
import { columnsOf } from './graphcols' import { columnsAt } from './graphcols'
import { savePrefs } from './accents' import { savePrefs } from './accents'
const MAX = 300 // points kept per curve, as the machines keep them const MAX = 300 // points kept per curve, as the machines keep them
@@ -68,7 +68,7 @@ function at(g: readonly string[], t: number): string {
// ---- the dot graph ---- // ---- the dot graph ----
// One dot column per sample, filled from the bottom, each dot coloured by how high it sits. // One dot column per sample, filled from the bottom, each dot coloured by how high it sits.
function Graph({ values, tick, advance, cap, max, grad, flip = false }: { values: number[]; tick: number; advance: number; cap: number; max?: number; grad: GradName; flip?: boolean }) { function Graph({ values, tick, advance, now, cap, max, grad, flip = false }: { values: number[]; tick: number; advance: number; now: number; cap: number; max?: number; grad: GradName; flip?: boolean }) {
const ref = useRef<HTMLCanvasElement>(null) const ref = useRef<HTMLCanvasElement>(null)
const box = useRef<HTMLDivElement>(null) const box = useRef<HTMLDivElement>(null)
const pal = useContext(Pal) const pal = useContext(Pal)
@@ -79,7 +79,8 @@ function Graph({ values, tick, advance, cap, max, grad, flip = false }: { values
ro.observe(box.current) ro.observe(box.current)
return () => ro.disconnect() return () => ro.disconnect()
}, []) }, [])
useEffect(() => { // drawn before the browser paints, so a graph changes in the same frame as the numbers
useLayoutEffect(() => {
const c = ref.current const c = ref.current
if (!c || w < 4 || height < 4) return if (!c || w < 4 || height < 4) return
const dpr = window.devicePixelRatio || 1 const dpr = window.devicePixelRatio || 1
@@ -91,14 +92,16 @@ function Graph({ values, tick, advance, cap, max, grad, flip = false }: { values
// The box is the whole window, always: `cap` points wide (what the window holds at this // 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 // rate) whatever the box measures, so a full history fills it edge to edge and a machine
// that has only just started draws in from the right, as btop does. Which points each // that has only just started draws in from the right, as btop does. Which points each
// column draws — the peak of its own fixed slice of the history — is graphcols.ts. // column draws — the peak of its own fixed slice of the history — is graphcols.ts. The
const seen = columnsOf(values, advance, cap, cols) // columns slide by a fraction of one on every tick, so the graph moves with the numbers
const top = max ?? Math.max(1, ...seen) // beside it even when its points are several ticks apart.
const { cols: seen, edge } = columnsAt(values, advance, now, cap, cols)
const top = max ?? Math.max(1, ...seen.slice(-cols))
const stops = pal[grad] const stops = pal[grad]
for (let i = 0; i < seen.length; i++) { for (let i = 0; i < seen.length; i++) {
// anything above nothing lights the bottom row: the curve keeps a baseline, as btop's does // 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 lit = seen[i] > 0 ? Math.max(1, Math.round((seen[i] / top) * rows)) : 0
const x = w - (seen.length - i) * step const x = Math.round((w - (seen.length - 1 - i + edge) * step) * dpr) / dpr
for (let r = 0; r < lit; r++) { for (let r = 0; r < lit; r++) {
const t = rows > 1 ? r / (rows - 1) : 1 const t = rows > 1 ? r / (rows - 1) : 1
g.fillStyle = at(stops, t) g.fillStyle = at(stops, t)
@@ -106,7 +109,7 @@ function Graph({ values, tick, advance, cap, max, grad, flip = false }: { values
g.fillRect(x, y, 2, 2) g.fillRect(x, y, 2, 2)
} }
} }
}, [values, tick, advance, cap, w, height, max, grad, flip, pal]) // tick: the curve is appended to in place }, [values, tick, advance, now, 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> return <div className="tg" ref={box} data-n={values.length}><canvas ref={ref} style={{ width: '100%', height: '100%' }} /></div>
} }
@@ -187,12 +190,17 @@ function braille(values: number[], cells = 7, max = 100): string {
// ---- the curves this page keeps ---- // ---- the curves this page keeps ----
const CURVES = 'cpu.usage,cpu.core.*.usage,net.*.rx,net.*.tx,disk.*.util,gpu.*.usage' const CURVES = 'cpu.usage,cpu.core.*.usage,net.*.rx,net.*.tx,disk.*.util,gpu.*.usage'
const RECENT = 18 // ticks a per-core strip keeps: its nine braille cells, two dots each
function useCurves(live: Values | null, t: number, refreshMs: number, historySec: number, resumed: number) { function useCurves(live: Values | null, t: number, refreshMs: number, historySec: number, resumed: number) {
const cur = useRef<Map<string, number[]>>(new Map()) const cur = useRef<Map<string, number[]>>(new Map())
const lastT = useRef(0) const lastT = useRef(0)
const binN = useRef(1) const binN = useRef(1)
const adv = useRef(0) // the number of the newest point, counted since the page opened: what a graph's columns are cut by const adv = useRef(0) // the number of the newest point, counted since the page opened: what a graph's columns are cut by
const now = useRef(1) // how far along the curves are, in points: adv plus the newest point's share of its stride gone
// The per-core strips are a few seconds wide: they keep every tick, not the stride's points,
// so they move with the numbers beside them whatever the history window is.
const recent = useRef<Map<string, number[]>>(new Map())
const [ticks, bump] = useState(0) const [ticks, bump] = useState(0)
const seed = useCallback(async () => { const seed = useCallback(async () => {
try { try {
@@ -202,21 +210,30 @@ function useCurves(live: Values | null, t: number, refreshMs: number, historySec
// The stride is timed on this clock only. The history's own timestamps are the machine's: // 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 // measured against them, a viewer whose clock runs minutes slow would wait those minutes
// before its curves gained a single new point. // before its curves gained a single new point.
cur.current = m; lastT.current = Date.now(); binN.current = 1 cur.current = m; lastT.current = Date.now(); binN.current = 1; now.current = adv.current + 1
bump((x) => x + 1) bump((x) => x + 1)
} catch { /* the curves fill in live instead */ } } catch { /* the curves fill in live instead */ }
}, []) }, [])
useEffect(() => { seed() }, [seed, resumed, historySec]) // a new window is a new spacing: start from what the machine now keeps useEffect(() => { seed() }, [seed, resumed, historySec]) // a new window is a new spacing: start from what the machine now keeps
useEffect(() => { // before paint: the curves take the sample in the same frame that shows its numbers
useLayoutEffect(() => {
if (!live || !t) return if (!live || !t) return
const { fresh, start } = nextPoint(t, lastT.current, strideMs(historySec, refreshMs), refreshMs) const stride = strideMs(historySec, refreshMs)
const { fresh, start } = nextPoint(t, lastT.current, stride, refreshMs)
binN.current = fresh ? 1 : binN.current + 1 binN.current = fresh ? 1 : binN.current + 1
lastT.current = start lastT.current = start
if (fresh) adv.current++ if (fresh) adv.current++
// this tick's share of the point: one tick in at its start, the whole point by its last
now.current = adv.current + Math.max(refreshMs / stride, Math.min(1, (t - start + refreshMs) / stride))
const n = binN.current const n = binN.current
for (const [id, v] of Object.entries(live)) { for (const [id, v] of Object.entries(live)) {
if (typeof v !== 'number') continue if (typeof v !== 'number') continue
if (!/^cpu\.usage$|^cpu\.core\.\d+\.usage$|^net\..+\.(rx|tx)$|^disk\..+\.util$|^gpu\..+\.usage$/.test(id)) continue if (!/^cpu\.usage$|^cpu\.core\.\d+\.usage$|^net\..+\.(rx|tx)$|^disk\..+\.util$|^gpu\..+\.usage$/.test(id)) continue
if (id.startsWith('cpu.core.')) {
const r = recent.current.get(id) ?? []
r.push(v); if (r.length > RECENT) r.shift()
recent.current.set(id, r)
}
const arr = cur.current.get(id) ?? [] const arr = cur.current.get(id) ?? []
if (!cur.current.has(id)) cur.current.set(id, arr) if (!cur.current.has(id)) cur.current.set(id, arr)
if (fresh || arr.length === 0) { arr.push(v); if (arr.length > MAX) arr.shift() } if (fresh || arr.length === 0) { arr.push(v); if (arr.length > MAX) arr.shift() }
@@ -224,7 +241,7 @@ function useCurves(live: Values | null, t: number, refreshMs: number, historySec
} }
bump((x) => x + 1) bump((x) => x + 1)
}, [live, t, refreshMs, historySec]) }, [live, t, refreshMs, historySec])
return [cur.current, ticks, adv.current] as const return [cur.current, ticks, adv.current, now.current, recent.current] as const
} }
// ---- the page ---- // ---- the page ----
@@ -239,17 +256,43 @@ export default function Top() {
const [ready, setReady] = useState(false) const [ready, setReady] = useState(false)
const stream = useSensorStream<unknown[]>(MONITOR_SHAPE, { full: false, everyMs: refreshMs }) 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)) }, []) 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])
// One beat for the whole page. Every sample the stream pushes is held until the process
// table has been read for it, and the two land together: the curves, the meters, the numbers,
// the GPU boxes and the table all change on the same tick, at the rate, never on two clocks
// that drift past each other. A sample that arrives while the table is still being read
// replaces the held one, so a slow read costs ticks, never order.
const [frame, setFrame] = useState<{ data: unknown[] | null; pt: ProcTable | null }>({ data: null, pt: null })
const [err, setErr] = useState('')
const held = useRef<unknown[] | null>(null)
const reading = useRef(false)
const mounted = useRef(true)
useEffect(() => { mounted.current = true; return () => { mounted.current = false } }, [])
useEffect(() => {
if (!stream.data) return
held.current = stream.data
if (reading.current) return
reading.current = true
api.procs().then((pt) => ({ pt, e: '' }), (e) => ({ pt: null, e: e instanceof Error ? e.message : 'failed' })).then(({ pt, e }) => {
reading.current = false
if (!mounted.current) return
const data = held.current
setFrame((f) => ({ data, pt: pt ?? f.pt }))
setErr(e)
})
}, [stream.data])
const pt = frame.pt
const values = useMemo(() => (frame.data ? mergeValues(frame.data) : null), [frame.data])
useEffect(() => { useEffect(() => {
if (!values || catalog.current.size === 0 || refetching.current || !stale(values, catalog.current)) return if (!values || catalog.current.size === 0 || refetching.current || !stale(values, catalog.current)) return
refetching.current = true refetching.current = true
fetchCatalog().then((c) => { catalog.current = new Map(c.map((x) => [x.id, x])) }).catch(() => {}).finally(() => window.setTimeout(() => { refetching.current = false }, 10000)) fetchCatalog().then((c) => { catalog.current = new Map(c.map((x) => [x.id, x])) }).catch(() => {}).finally(() => window.setTimeout(() => { refetching.current = false }, 10000))
}, [values]) }, [values])
const s: Sample | null = useMemo(() => (values && ready ? sampleOf(values, catalog.current) : null), [values, ready]) const s: Sample | null = useMemo(() => (values && ready ? sampleOf(values, catalog.current) : null), [values, ready])
const [curves, tick, advance] = useCurves(values, s?.t ?? 0, refreshMs, historySec, stream.resumed) const [curves, tick, advance, now, recent] = 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 // the process table, and the CPU model, come from the same place the Processes page reads,
const [pt, setPt] = useState<ProcTable | null>(null) // on the sample's beat (above)
const [pickedIf, setPickedIf] = useState<string | null>(null) // which NIC the net box draws const [pickedIf, setPickedIf] = useState<string | null>(null) // which NIC the net box draws
// Which pool each drive belongs to, asked once: a drive in a ZFS pool has no filesystem of // Which pool each drive belongs to, asked once: a drive in a ZFS pool has no filesystem of
// its own, and "nothing is on it" would be a lie about eight drives holding a NAS. // its own, and "nothing is on it" would be a lie about eight drives holding a NAS.
@@ -264,14 +307,6 @@ export default function Top() {
}).catch(() => { /* the rows simply say less */ }) }).catch(() => { /* the rows simply say less */ })
return () => { live = false } return () => { live = false }
}, []) }, [])
const [err, setErr] = useState('')
useEffect(() => {
let stop = false
const poll = async () => { try { const r = await api.procs(); if (!stop) { setPt(r); setErr('') } } catch (e) { if (!stop) setErr(e instanceof Error ? e.message : 'failed') } }
poll()
const i = window.setInterval(poll, refreshMs) // exactly the rate, like every other page: nothing is throttled behind his back
return () => { stop = true; window.clearInterval(i) }
}, [refreshMs])
// the same global rate Settings sets: saved on the hub, so it sticks and every machine's sampler follows // 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 rateIdx = (() => { const i = REFRESH_OPTIONS.findIndex((x) => x >= refreshMs); return i < 0 ? REFRESH_OPTIONS.length - 1 : i })()
@@ -313,7 +348,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') } 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" style={pal.vars as React.CSSProperties}><Loading label={err || 'Reading the machine…'} /></div> if (!s && !pt) return <div className="top" style={pal.vars as React.CSSProperties}><Loading label={err || stream.error || 'Reading the machine…'} /></div>
const cpu = s?.cpu ?? pt?.cpu ?? 0 const cpu = s?.cpu ?? pt?.cpu ?? 0
const cores = (s?.cores?.length ? s.cores : pt?.cores) ?? [] const cores = (s?.cores?.length ? s.cores : pt?.cores) ?? []
@@ -365,7 +400,7 @@ export default function Top() {
</>}> </>}>
<div className="t-cpu"> <div className="t-cpu">
<div className="t-cpu-graph"> <div className="t-cpu-graph">
<Graph values={curves.get('cpu.usage') ?? []} tick={tick} advance={advance} cap={cap} max={100} grad="cpu" /> <Graph values={curves.get('cpu.usage') ?? []} tick={tick} advance={advance} now={now} cap={cap} max={100} grad="cpu" />
<div className="t-up">up {upfor(uptime)}</div> <div className="t-up">up {upfor(uptime)}</div>
</div> </div>
<div className="t-cores"> <div className="t-cores">
@@ -382,7 +417,7 @@ export default function Top() {
return ( return (
<div className="t-core" key={i}> <div className="t-core" key={i}>
<span className="t-core-n">C{i}</span> <span className="t-core-n">C{i}</span>
<span className="t-core-g" title={pt?.core_ids && pt.core_ids[i] !== i ? `cpu ${i} · a thread of core ${pt.core_ids[i]}` : `core ${i}`}>{braille(curves.get(`cpu.core.${i}.usage`) ?? [], 9)}</span> <span className="t-core-g" title={pt?.core_ids && pt.core_ids[i] !== i ? `cpu ${i} · a thread of core ${pt.core_ids[i]}` : `core ${i}`}>{braille(recent.get(`cpu.core.${i}.usage`) ?? [], 9)}</span>
<span className="t-core-p">{Math.round(v)}%</span> <span className="t-core-p">{Math.round(v)}%</span>
{anyTemp && <><span className="t-core-t">{t !== undefined && <Meter pct={Math.min(100, t)} grad="temp" />}</span><span className="t-core-c">{t !== undefined ? `${Math.round(t)}°C` : ''}</span></>} {anyTemp && <><span className="t-core-t">{t !== undefined && <Meter pct={Math.min(100, t)} grad="temp" />}</span><span className="t-core-c">{t !== undefined ? `${Math.round(t)}°C` : ''}</span></>}
</div> </div>
@@ -397,7 +432,7 @@ export default function Top() {
<div className="top-gpus"> <div className="top-gpus">
{gpus.map(({ g, stem }, i) => ( {gpus.map(({ g, stem }, i) => (
<Box key={stem} name={`gpu${i}`} className={'top-gpu' + (unmeasured(g) ? ' slim' : '')} right={<span className="t-gpu-name" title={[g.name, g.driver, g.bus].filter(Boolean).join(' · ')}>{g.name}</span>}> <Box key={stem} name={`gpu${i}`} className={'top-gpu' + (unmeasured(g) ? ' slim' : '')} right={<span className="t-gpu-name" title={[g.name, g.driver, g.bus].filter(Boolean).join(' · ')}>{g.name}</span>}>
{unmeasured(g) ? <GPULine g={g} /> : <GPUBox g={g} values={curves.get(stem + '.usage') ?? []} tick={tick} advance={advance} cap={cap} />} {unmeasured(g) ? <GPULine g={g} /> : <GPUBox g={g} values={curves.get(stem + '.usage') ?? []} tick={tick} advance={advance} now={now} cap={cap} />}
</Box> </Box>
))} ))}
</div> </div>
@@ -441,10 +476,10 @@ export default function Top() {
<div className="t-net-graphs"> <div className="t-net-graphs">
<div className="t-net-g"> <div className="t-net-g">
<span className="t-scale">{rate(peak(`net.${segOf(iface.iface)}.rx`))}</span> <span className="t-scale">{rate(peak(`net.${segOf(iface.iface)}.rx`))}</span>
<Graph values={curves.get(`net.${segOf(iface.iface)}.rx`) ?? []} tick={tick} advance={advance} cap={cap} grad="down" /> <Graph values={curves.get(`net.${segOf(iface.iface)}.rx`) ?? []} tick={tick} advance={advance} now={now} cap={cap} grad="down" />
</div> </div>
<div className="t-net-g"> <div className="t-net-g">
<Graph values={curves.get(`net.${segOf(iface.iface)}.tx`) ?? []} tick={tick} advance={advance} cap={cap} grad="up" flip /> <Graph values={curves.get(`net.${segOf(iface.iface)}.tx`) ?? []} tick={tick} advance={advance} now={now} cap={cap} grad="up" flip />
<span className="t-scale t-scale-b">{rate(peak(`net.${segOf(iface.iface)}.tx`))}</span> <span className="t-scale t-scale-b">{rate(peak(`net.${segOf(iface.iface)}.tx`))}</span>
</div> </div>
</div> </div>
@@ -522,7 +557,7 @@ const Rows = memo(function Rows({ procs, sel, onSel, curves, gpu, empty }: { pro
// that have it open with their share. What cannot be measured on this card is left out rather // that have it open with their share. What cannot be measured on this card is left out rather
// than shown as zero: an Arc on i915 has no temperature sensor, Asahi has no busy counter. // than shown as zero: an Arc on i915 has no temperature sensor, Asahi has no busy counter.
const ENGINES: [string, string][] = [['render', 'render'], ['compute', 'compute'], ['video', 'video'], ['video-enhance', 'enhance'], ['copy', 'copy']] const ENGINES: [string, string][] = [['render', 'render'], ['compute', 'compute'], ['video', 'video'], ['video-enhance', 'enhance'], ['copy', 'copy']]
function GPUBox({ g, values, tick, advance, cap }: { g: GPU; values: number[]; tick: number; advance: number; cap: number }) { function GPUBox({ g, values, tick, advance, now, cap }: { g: GPU; values: number[]; tick: number; advance: number; now: number; cap: number }) {
const engines = ENGINES.filter(([k]) => g.engines && g.engines[k] !== undefined) const engines = ENGINES.filter(([k]) => g.engines && g.engines[k] !== undefined)
const temp = g.temp !== undefined && g.temp > 0 ? g.temp : undefined const temp = g.temp !== undefined && g.temp > 0 ? g.temp : undefined
const how = g.stalled ? 'a sysfs read of this card stopped answering; its readings are paused until the console restarts' const how = g.stalled ? 'a sysfs read of this card stopped answering; its readings are paused until the console restarts'
@@ -530,7 +565,7 @@ function GPUBox({ g, values, tick, advance, cap }: { g: GPU; values: number[]; t
return ( return (
<div className="t-gpu"> <div className="t-gpu">
<div className="t-gpu-graph"> <div className="t-gpu-graph">
<Graph values={values} tick={tick} advance={advance} cap={cap} max={100} grad="cpu" /> <Graph values={values} tick={tick} advance={advance} now={now} cap={cap} max={100} grad="cpu" />
<div className="t-up">{g.usage_src ? `${Math.round(g.usage)}% ` : ''}{how}</div> <div className="t-up">{g.usage_src ? `${Math.round(g.usage)}% ` : ''}{how}</div>
</div> </div>
<div className="t-gpu-stats"> <div className="t-gpu-stats">
+1 -1
View File
@@ -382,7 +382,7 @@ function ARCSection() {
} catch { /* the sparklines stay empty; the numbers still show */ } } catch { /* the sparklines stay empty; the numbers still show */ }
} }
poll() poll()
const t = setInterval(poll, Math.max(2000, rate)) const t = setInterval(poll, rate)
return () => { stop = true; clearInterval(t) } return () => { stop = true; clearInterval(t) }
}, [rate]) }, [rate])
+20 -6
View File
@@ -16,26 +16,40 @@
// Returns one value per column, oldest first, right-aligned: a young curve has fewer columns // Returns one value per column, oldest first, right-aligned: a young curve has fewer columns
// than the box and draws in from the right. // than the box and draws in from the right.
export function columnsOf(values: number[], advance: number, cap: number, cols: number): number[] { export function columnsOf(values: number[], advance: number, cap: number, cols: number): number[] {
return columnsAt(values, advance, advance + 1, cap, cols).cols.slice(-cols)
}
// The same columns, placed in time. `now` is how far the curve has got in points: `advance`
// plus the share of the newest point's stride already gone (0–1], so a curve whose points are
// several ticks apart still moves on every tick instead of jumping once a point. `edge` is how
// much of the newest column is showing (0–1]: the box draws it that far in from the right and
// everything else behind it, so the whole graph slides a fraction of a column each tick. One
// more column than the box holds comes back, to fill the sliver the slide opens on the left.
// `last` is the newest column's number, for a caller that follows a column as it slides.
export function columnsAt(values: number[], advance: number, now: number, cap: number, cols: number): { cols: number[]; edge: number; last: number } {
const n = Math.min(values.length, cap) const n = Math.min(values.length, cap)
if (n === 0 || cols < 1 || cap < 1) return [] if (n === 0 || cols < 1 || cap < 1) return { cols: [], edge: 1, last: 0 }
const at = (a: number) => values[values.length - 1 - (advance - a)] // the point numbered a const at = (a: number) => values[values.length - 1 - (advance - a)] // the point numbered a
const oldest = advance - n + 1 const oldest = advance - n + 1
const r = cols / cap
const out: number[] = [] const out: number[] = []
if (cols >= cap) { if (cols >= cap) {
const kLast = Math.ceil(((advance + 1) * cols) / cap) - 1 const kLast = Math.ceil(now * r) - 1
for (let k = kLast - cols + 1; k <= kLast; k++) { for (let k = kLast - cols; k <= kLast; k++) {
const a = Math.floor((k * cap) / cols) const a = Math.floor((k * cap) / cols)
if (a >= oldest && a <= advance) out.push(at(a)) if (a >= oldest && a <= advance) out.push(at(a))
} }
return out return { cols: out, edge: Math.min(1, Math.max(0, now * r - kLast)) || 1, last: kLast }
} }
// the newest column is the newest point's; until the next point lands in the next column the
// slide waits at its edge, rather than show a column with nothing in it
const kLast = Math.floor((advance * cols) / cap) const kLast = Math.floor((advance * cols) / cap)
for (let k = kLast - cols + 1; k <= kLast; k++) { for (let k = kLast - cols; k <= kLast; k++) {
const a0 = Math.max(oldest, Math.ceil((k * cap) / cols)), a1 = Math.min(advance, Math.ceil(((k + 1) * cap) / cols) - 1) const a0 = Math.max(oldest, Math.ceil((k * cap) / cols)), a1 = Math.min(advance, Math.ceil(((k + 1) * cap) / cols) - 1)
if (a1 < a0) continue if (a1 < a0) continue
let peak = at(a0) let peak = at(a0)
for (let a = a0 + 1; a <= a1; a++) peak = Math.max(peak, at(a)) for (let a = a0 + 1; a <= a1; a++) peak = Math.max(peak, at(a))
out.push(peak) out.push(peak)
} }
return out return { cols: out, edge: Math.min(1, Math.max(0, now * r - kLast)) || 1, last: kLast }
} }