From ec94cb8bff07dea8c2253f03dec1c3df8a1e30f5 Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Mon, 21 Sep 2026 10:54:30 -0700 Subject: [PATCH] =?UTF-8?q?Top:=20a=20graph's=20past=20holds=20still=20?= =?UTF-8?q?=E2=80=94=20columns=20draw=20the=20peak=20of=20their=20own=20fi?= =?UTF-8?q?xed=20slice=20of=20history?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- BUGS.md | 15 ++++++++++++ Makefile | 1 + web/package.json | 3 ++- web/scripts/test-graphcols.mjs | 42 ++++++++++++++++++++++++++++++++++ web/src/Top.tsx | 34 +++++++++++++-------------- web/src/graphcols.ts | 41 +++++++++++++++++++++++++++++++++ 6 files changed, 118 insertions(+), 18 deletions(-) create mode 100644 web/scripts/test-graphcols.mjs create mode 100644 web/src/graphcols.ts diff --git a/BUGS.md b/BUGS.md index 7e951b3..2d5a7dd 100644 --- a/BUGS.md +++ b/BUGS.md @@ -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 diff --git a/Makefile b/Makefile index cf1b813..a83249c 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/web/package.json b/web/package.json index 3d2046e..6fa8ed1 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/scripts/test-graphcols.mjs b/web/scripts/test-graphcols.mjs new file mode 100644 index 0000000..4f7beae --- /dev/null +++ b/web/scripts/test-graphcols.mjs @@ -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') diff --git a/web/src/Top.tsx b/web/src/Top.tsx index 2c43f4f..4e8ac73 100644 --- a/web/src/Top.tsx +++ b/web/src/Top.tsx @@ -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(null) const box = useRef(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
} @@ -196,6 +192,7 @@ function useCurves(live: Values | null, t: number, refreshMs: number, historySec const cur = useRef>(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(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 ( @@ -335,7 +335,7 @@ export default function Top() { }>
- +
up {upfor(uptime)}
@@ -389,12 +389,12 @@ export default function Top() {
- {rate(Math.max(1, ...(curves.get(`net.${segOf(iface.iface)}.rx`) ?? [0])))} - + {rate(peak(`net.${segOf(iface.iface)}.rx`))} +
- - {rate(Math.max(1, ...(curves.get(`net.${segOf(iface.iface)}.tx`) ?? [0])))} + + {rate(peak(`net.${segOf(iface.iface)}.tx`))}
diff --git a/web/src/graphcols.ts b/web/src/graphcols.ts new file mode 100644 index 0000000..22d5314 --- /dev/null +++ b/web/src/graphcols.ts @@ -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 +}