Top: a graph's past holds still — columns draw the peak of their own fixed slice of history

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-21 10:54:30 -07:00
co-authored by Claude Fable 5.1
parent be0f2a5aaf
commit ec94cb8bff
6 changed files with 118 additions and 18 deletions
+15
View File
@@ -486,6 +486,21 @@ is 11px wide" at 1392); run after, nothing, in both browsers.
and history window and corrects only what differs (logged as `fleet: grid had ms=1000,
now 250`).
11. ✅ **The net graph redrew its own past every second** (Mitchell, two screenshots a second
apart: one tall spike in the first, a comb of medium ones in the second). That box is
about 75 dot-columns wide and holds 300 points, and each column drew *the nearest point*
— one in four, and a different one in four each time the curve advanced, so spikes came
and went and the whole shape changed while the scale stayed put. The CPU graph did the
same on a phone. Now a column draws the **peak** of all the points in it, and columns are
cut from the data, not from the box (`web/src/graphcols.ts`): a finished column never
changes, the graph only slides left by whole columns. Measured by reading every column's
height off the canvas once a second under bursty traffic: on the live build the past
changed shape in 18 of 47 frame pairs; after, 0 of 46 — and 0 of 45 on a young history.
`npm test` (now part of `make test`) scrolls a spiky curve through both rules: nearest-
point shows its three spikes as "900,500", then "700", then none; the new rule shows all
three, at their heights, on every frame. The scale label now covers what is drawn (the
window), not everything kept.
## Skins
✅ Top now wears the skin. The ground is the skin's terminal background and every curve and
+1
View File
@@ -49,6 +49,7 @@ release: web
test:
$(GO) vet ./... && $(GO) test ./...
sh -n install.sh
cd web && npm test --silent
# installs here (keeping the config, user and password), then updates every managed machine
deploy: build cross
+2 -1
View File
@@ -6,7 +6,8 @@
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"icons": "node scripts/icons.mjs"
"icons": "node scripts/icons.mjs",
"test": "node scripts/test-graphcols.mjs"
},
"dependencies": {
"@xterm/addon-fit": "^0.10.0",
+42
View File
@@ -0,0 +1,42 @@
// node scripts/test-graphcols.mjs — the rule in src/graphcols.ts, on a curve scrolled one point at a time.
// (No test runner in this project: esbuild, which vite already brings, turns the one file into something node can load.)
import { buildSync } from 'esbuild'
import { pathToFileURL } from 'node:url'
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
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' })
const { columnsOf } = await import(pathToFileURL(out).href)
const fails = []
const check = (ok, what, detail) => { if (!ok) fails.push(what + (detail !== undefined ? ' — ' + JSON.stringify(detail).slice(0, 200) : '')) }
const CAP = 300, COLS = 90 // the net graph: 300 points of history in about 90 dot-columns
const base = Array.from({ length: CAP }, (_, i) => (i === 100 ? 900 : i === 180 ? 500 : i === 251 ? 700 : 10))
const spikes = (cols) => cols.filter((v) => v > 100).sort((a, b) => b - a).join(',')
// what Top drew before 2026-09-21: the nearest point for each column, counted from the right edge
const nearest = (values, cap, cols) => { const n = Math.min(values.length, cap), seen = []; 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]) } return seen }
let values = base.slice(), advance = CAP - 1, prev = null
const oldShapes = new Set(), newShapes = new Set()
for (let step = 0; step < 40; step++) {
oldShapes.add(spikes(nearest(values, CAP, COLS)))
const cols = columnsOf(values, advance, CAP, COLS)
newShapes.add(spikes(cols))
if (prev) check([0, 1].some((sh) => cols.slice(0, -1).every((v, j) => prev[j + sh] === undefined || prev[j + sh] === v)), `frame ${step} is the one before, slid left by a column at most`)
prev = cols
values.push(10); values.shift(); advance++
}
check(oldShapes.size > 1, 'the test reproduces the fault it guards against (spikes flickering under nearest-point drawing)', [...oldShapes])
check(newShapes.size === 1 && [...newShapes][0] === '900,700,500', 'every spike is drawn, at its height, on every frame', [...newShapes])
check(columnsOf(base, CAP - 1, CAP, COLS).length === COLS, 'a full curve fills the box')
const young = columnsOf(base.slice(-30), 29, CAP, COLS).length
check(young >= 8 && young <= 10, 'a tenth of a history takes a tenth of the box, from the right', young)
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(columnsOf([], 0, CAP, COLS).length === 0 && columnsOf([5], 0, 2, 50).length >= 1, 'empty and single-point curves')
if (fails.length) { console.error('graphcols: FAIL\n ' + fails.join('\n ')); process.exit(1) }
console.log('graphcols: ok')
+17 -17
View File
@@ -10,6 +10,7 @@ import { fetchCatalog, fetchHistories, useSensorStream } from './sensors'
import { fmtMs, nextPoint, REFRESH_OPTIONS, setRefreshMs, strideMs, useHistorySec, useRefreshMs } from './prefs'
import { Loading, askYes, toast } from './ui'
import { Theme, currentThemeId, themeById } from './themes'
import { columnsOf } from './graphcols'
import { savePrefs } from './accents'
const MAX = 300 // points kept per curve, as the machines keep them
@@ -67,7 +68,7 @@ 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, cap, max, grad, flip = false }: { values: number[]; tick: number; cap: number; max?: number; grad: GradName; flip?: boolean }) {
function Graph({ values, tick, advance, cap, max, grad, flip = false }: { values: number[]; tick: number; advance: number; cap: number; max?: number; grad: GradName; flip?: boolean }) {
const ref = useRef<HTMLCanvasElement>(null)
const box = useRef<HTMLDivElement>(null)
const pal = useContext(Pal)
@@ -89,14 +90,9 @@ function Graph({ values, tick, cap, max, grad, flip = false }: { values: number[
const step = 3, rows = Math.max(1, Math.floor(height / step)), cols = Math.max(1, Math.floor(w / step))
// 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])
}
// 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.
const seen = columnsOf(values, advance, cap, cols)
const top = max ?? Math.max(1, ...seen)
const stops = pal[grad]
for (let i = 0; i < seen.length; i++) {
@@ -110,7 +106,7 @@ function Graph({ values, tick, cap, max, grad, flip = false }: { values: number[
g.fillRect(x, y, 2, 2)
}
}
}, [values, tick, cap, w, height, max, grad, flip, pal]) // tick: the curve is appended to in place
}, [values, tick, advance, 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>
}
@@ -196,6 +192,7 @@ function useCurves(live: Values | null, t: number, refreshMs: number, historySec
const cur = useRef<Map<string, number[]>>(new Map())
const lastT = useRef(0)
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 [ticks, bump] = useState(0)
const seed = useCallback(async () => {
try {
@@ -215,6 +212,7 @@ function useCurves(live: Values | null, t: number, refreshMs: number, historySec
const { fresh, start } = nextPoint(t, lastT.current, strideMs(historySec, refreshMs), refreshMs)
binN.current = fresh ? 1 : binN.current + 1
lastT.current = start
if (fresh) adv.current++
const n = binN.current
for (const [id, v] of Object.entries(live)) {
if (typeof v !== 'number') continue
@@ -226,7 +224,7 @@ function useCurves(live: Values | null, t: number, refreshMs: number, historySec
}
bump((x) => x + 1)
}, [live, t, refreshMs, historySec])
return [cur.current, ticks] as const
return [cur.current, ticks, adv.current] as const
}
// ---- the page ----
@@ -248,7 +246,7 @@ export default function Top() {
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)
const [curves, tick, advance] = 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)
@@ -321,6 +319,8 @@ export default function Top() {
const iface = net[0]
const fs = s?.fs ?? []
const pct = (a: number, b: number) => (b > 0 ? (a / b) * 100 : 0)
// the top of a graph's scale: the peak of what it is drawing — the window, not everything kept
const peak = (id: string) => Math.max(1, ...(curves.get(id) ?? []).slice(-cap))
return (
<Pal.Provider value={pal}>
@@ -335,7 +335,7 @@ export default function Top() {
</>}>
<div className="t-cpu">
<div className="t-cpu-graph">
<Graph values={curves.get('cpu.usage') ?? []} tick={tick} cap={cap} max={100} grad="cpu" />
<Graph values={curves.get('cpu.usage') ?? []} tick={tick} advance={advance} cap={cap} max={100} grad="cpu" />
<div className="t-up">up {upfor(uptime)}</div>
</div>
<div className="t-cores">
@@ -389,12 +389,12 @@ export default function Top() {
<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} cap={cap} grad="down" />
<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" />
</div>
<div className="t-net-g">
<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>
<Graph values={curves.get(`net.${segOf(iface.iface)}.tx`) ?? []} tick={tick} advance={advance} cap={cap} grad="up" flip />
<span className="t-scale t-scale-b">{rate(peak(`net.${segOf(iface.iface)}.tx`))}</span>
</div>
</div>
<div className="t-net-nums">
+41
View File
@@ -0,0 +1,41 @@
// Which points of a curve does each dot-column of a graph draw?
//
// A curve holds up to `cap` points (the whole history window); a box is `cols` columns wide,
// and a narrow one — the net graph, a phone — has fewer columns than points. Taking "the
// nearest point" for each column draws one point in three and, worse, a *different* one in
// three every time the curve advances: a spike is there, gone, there again, and the whole
// graph reshapes once a second. So:
//
// - a column draws the PEAK of all the points that fall in it, so nothing is ever skipped; and
// - columns are cut from the data, not from the box: point number `a` (counted since the page
// opened, `advance` being the newest) always belongs to column ⌊a·cols/cap⌋, whatever has
// arrived since. A finished column therefore never changes — the graph only ever slides
// left by whole columns, and only its newest column is still moving.
//
// With more columns than points (a wide box) each point simply spans several 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.
export function columnsOf(values: number[], advance: number, cap: number, cols: number): number[] {
const n = Math.min(values.length, cap)
if (n === 0 || cols < 1 || cap < 1) return []
const at = (a: number) => values[values.length - 1 - (advance - a)] // the point numbered a
const oldest = advance - n + 1
const out: number[] = []
if (cols >= cap) {
const kLast = Math.ceil(((advance + 1) * cols) / cap) - 1
for (let k = kLast - cols + 1; k <= kLast; k++) {
const a = Math.floor((k * cap) / cols)
if (a >= oldest && a <= advance) out.push(at(a))
}
return out
}
const kLast = Math.floor((advance * cols) / cap)
for (let k = kLast - cols + 1; k <= kLast; k++) {
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
let peak = at(a0)
for (let a = a0 + 1; a <= a1; a++) peak = Math.max(peak, at(a))
out.push(peak)
}
return out
}