Fix work recovery and reduce engine and chart overhead
Docs / docs (push) Successful in 33s
Playwright Tests / test-playwright (1, 2) (push) Failing after 1m37s
Playwright Tests / test-playwright (2, 2) (push) Failing after 27s
pre-commit / pre-commit (push) Failing after 2m37s
Test Backend / test-backend (3.12) (push) Successful in 5m7s
Test Backend / test-backend (3.14) (push) Successful in 5m2s
Compose Smoke Test / test-compose (push) Failing after 28s
Playwright Tests / merge-reports (push) Failing after 2m29s

This commit is contained in:
2026-10-29 14:24:17 +01:00
parent 20a0853f69
commit 75b3a93094
25 changed files with 1316 additions and 301 deletions
+45 -30
View File
@@ -125,8 +125,8 @@ export const CURSOR: uPlot.Cursor = {
} as unknown as uPlot.Cursor.Bind,
drag: {
// Drag across the plot to read a stretch of it closer. `setScale` stays
// off because the chart owns its x range itself: `setData` runs on every
// render and would re-range the scales from the data, so a held window is
// off because the chart owns its x range itself: `setData` would re-range
// the scales from the data, so a held window is
// what tells it to leave them alone. Without that this only ever flashed a
// selection box over a live chart, which is why it used to be off.
x: true,
@@ -190,17 +190,35 @@ function tickLabels(ticks: number[]): string[] {
return ticks.map((value) => value.toFixed(decimals))
}
/** The series joined onto one x axis, which is what uPlot draws. */
function table(plots: HistoryPoint[][]): uPlot.AlignedData {
return uPlot.join(
plots.map(
(plot) =>
[
plot.map((point) => point.ts),
plot.map((point) => point.value),
] as uPlot.AlignedData,
),
)
/** Numeric snapshots catch both rebuilt arrays and mutable caller buffers. */
export class ChartData {
private columns: uPlot.AlignedData[] = []
private data: uPlot.AlignedData = [[]]
update(plots: HistoryPoint[][]): uPlot.AlignedData {
let changed = plots.length !== this.columns.length
this.columns.length = plots.length
plots.forEach((plot, index) => {
const previous = this.columns[index]
if (
previous?.[0].length === plot.length &&
plot.every(
(point, i) =>
Object.is(point.ts, previous[0][i]) &&
Object.is(point.value, previous[1][i]),
)
) {
return
}
this.columns[index] = [
plot.map((point) => point.ts),
plot.map((point) => point.value),
]
changed = true
})
if (changed) this.data = plots.length ? uPlot.join(this.columns) : [[]]
return this.data
}
}
/**
@@ -264,12 +282,16 @@ export function UplotChart({
const host = useRef<HTMLDivElement>(null)
const legend = useRef<HTMLDivElement>(null)
const chart = useRef<uPlot | null>(null)
const prepared = useRef<ChartData | null>(null)
if (prepared.current === null) prepared.current = new ChartData()
// A dragged x window, held so the next reading does not wash it away. The
// ref is what the data effect reads; the state is only what draws the way
// back out, and the two are set together.
const zoomed = useRef(false)
const resetRange = useRef(false)
const [showReset, setShowReset] = useState(false)
const clearZoom = () => {
if (zoomed.current) resetRange.current = true
zoomed.current = false
setShowReset(false)
}
@@ -432,10 +454,11 @@ export function UplotChart({
},
// Built with the readings it already has: uPlot's axes are only half
// initialised while its scales have no range.
table(plots),
prepared.current!.update(plots),
element,
)
chart.current = plot
resetRange.current = false
const observer = new ResizeObserver(() => {
plot.setSize({
@@ -455,27 +478,19 @@ export function UplotChart({
}
}, [key, ready])
// Every render, deliberately. `plots` is rebuilt on every render in both
// consumers, so no memo can hit, and any signature short of hashing shares
// the blind spot a point count has: once a rolling window is full, a refetch
// carrying different readings leaves the count where it was and never fires.
// Safe to run this often because `setData` is idempotent and re-ranges the
// scales *from the data* — the opposite of the `redraw(false)` below. That
// re-ranging is exactly what a dragged window has to be spared, so while one
// is held the data goes in and the scales stay where they were put. Clearing
// the window renders, which brings the next pass through here with the reset
// back on: that is what puts the whole range back.
//
// A live chart keeps a count of points rather than a span, so a held window
// scrolls off the left as readings arrive. Once its last point has gone it
// is let go, or the chart would sit empty until somebody reset it.
// Check contents every render: callers may rebuild arrays or mutate buffers.
// Only changed readings need joining and drawing. A zoom reset still ranges
// the current data, and a held window expires once all its points roll off.
useEffect(() => {
const plot = chart.current
if (!plot || plots.length === 0) return
const data = table(plots)
const data = prepared.current!.update(plots)
const held = plot.scales.x.max
if (zoomed.current && held != null && held < data[0][0]) clearZoom()
plot.setData(data, !zoomed.current)
if (data !== plot.data || resetRange.current) {
plot.setData(data, !zoomed.current)
resetRange.current = false
}
})
// The canvas cannot follow a CSS variable, so a theme swap is a redraw. The
@@ -0,0 +1,68 @@
/** Chart data stays current without joining unchanged series on every render. */
import assert from "node:assert/strict"
import uPlot from "uplot"
import { chartTails } from "../Dashboard/ChartWidget"
import { ChartData } from "./UplotChart"
const cache = new ChartData()
const plots = Array.from({ length: 5 }, (_, series) =>
Array.from({ length: 500 }, (_, i) => ({
ts: i + series / 10,
value: i * (series + 1),
})),
)
const join = uPlot.join
let joins = 0
uPlot.join = (...args) => {
joins++
return join(...args)
}
try {
const initial = cache.update(plots)
for (let render = 0; render < 100; render++) {
const rebuilt = plots.map((plot) => plot.map((point) => ({ ...point })))
assert.equal(cache.update(rebuilt), initial)
}
assert.equal(joins, 1)
// A correction in the middle of an existing mutable buffer is still data.
plots[0][200].value = -7
const corrected = cache.update(plots)
assert.notEqual(corrected, initial)
assert.equal(corrected[1][corrected[0].indexOf(200)], -7)
assert.equal(initial[1][initial[0].indexOf(200)], 200)
// A full window rolls forward without changing its point count.
plots[0].shift()
plots[0].push({ ts: 500, value: 12 })
const rolled = cache.update(plots)
assert.notEqual(rolled, corrected)
assert.equal(rolled[0][0], 0.1)
assert.equal(rolled[1][rolled[0].indexOf(500)], 12)
const reordered = cache.update([plots[1], plots[0]])
assert.equal(reordered.length, 3)
assert.equal(reordered[1][reordered[0].indexOf(200.1)], 400)
assert.deepEqual(cache.update([]), [[]])
} finally {
uPlot.join = join
}
let tails = chartTails({}, ["a"], [{ ts: 1, value: 10 }], 3)
assert.equal(chartTails(tails, ["a"], [{ ts: 1, value: 10 }], 3), tails)
tails = chartTails(tails, ["a"], [{ ts: 1, value: 11 }], 3)
assert.deepEqual(tails.a, [{ ts: 1, value: 11 }])
tails = chartTails(tails, ["a"], [{ ts: 2, value: 12 }], 3)
tails = chartTails(tails, ["a"], [{ ts: 3, value: 13 }], 3)
tails = chartTails(tails, ["a"], [{ ts: 3, value: 13 }], 2)
assert.deepEqual(tails.a, [
{ ts: 2, value: 12 },
{ ts: 3, value: 13 },
])
tails = chartTails(tails, ["b"], [undefined], 2)
assert.deepEqual(tails, { b: [] })
assert.equal(chartTails(tails, ["b"], [undefined], 2), tails)
assert.equal(chartTails(tails, ["b", "b"], [undefined, undefined], 2), tails)
console.log("chart data: ok (101 unchanged renders, one join)")
@@ -8,7 +8,11 @@ import {
type Range,
} from "@/components/Common/RangePicker"
import { MAX_SERIES, UplotChart } from "@/components/Common/UplotChart"
import { useLiveValue, useLiveValues } from "@/components/Flow/liveStore"
import {
type LiveValue,
useLiveValue,
useLiveValues,
} from "@/components/Flow/liveStore"
import {
compareQueryOptions,
NO_CURVE,
@@ -91,6 +95,36 @@ function merge(fetched: HistoryPoint[], tail: HistoryPoint[], cap: number) {
return [...fetched, ...tail.filter((point) => point.ts > since)].slice(-cap)
}
/** Advance the bounded live tails without scheduling a stale chart commit. */
export function chartTails(
previous: Record<string, HistoryPoint[]>,
names: string[],
live: (LiveValue | undefined)[],
cap: number,
): Record<string, HistoryPoint[]> {
const next: Record<string, HistoryPoint[]> = {}
let changed = false
names.forEach((name, index) => {
const kept = previous[name] ?? []
const point = live[index]
const last = kept[kept.length - 1]
let tail = kept
if (typeof point?.value === "number" && point.ts) {
if (last?.ts !== point.ts) {
tail = [...kept, { ts: point.ts, value: point.value }]
} else if (!Object.is(last.value, point.value)) {
tail = [...kept.slice(0, -1), { ts: point.ts, value: point.value }]
}
}
if (tail.length > cap) tail = tail.slice(-cap)
next[name] = tail
if (tail !== previous[name]) changed = true
})
return changed || Object.keys(previous).length !== Object.keys(next).length
? next
: previous
}
/**
* A chart, drawn from whichever source it was pointed at.
*
@@ -275,34 +309,10 @@ function LiveChart({ widget }: WidgetProps) {
// What arrived over the socket since the history was fetched. Appending beats
// refetching the whole series every time a value lands.
const [tails, setTails] = useState<Record<string, HistoryPoint[]>>({})
const key = names.join(" ")
const stamps = live.map((value) => value?.ts ?? 0).join(",")
// biome-ignore lint/correctness/useExhaustiveDependencies: the timestamps are what makes a tail longer; `live` is a fresh array every render.
useEffect(() => {
setTails((previous) => {
const next: Record<string, HistoryPoint[]> = {}
let changed = false
names.forEach((name, index) => {
const point = live[index]
const kept = previous[name] ?? []
const ts = point?.ts
if (
typeof point?.value !== "number" ||
!ts ||
kept[kept.length - 1]?.ts === ts
) {
next[name] = kept
return
}
next[name] = [...kept, { ts, value: point.value }].slice(-cap)
changed = true
})
// Dropping a series is a change too, even without a new reading.
return changed || Object.keys(previous).length !== names.length
? next
: previous
})
}, [key, stamps, cap])
const nextTails = chartTails(tails, names, live, cap)
// React retries this component before rendering its children. An effect
// would commit the old chart first, then render and draw it a second time.
if (nextTails !== tails) setTails(nextTails)
const plots = names.map((name, index) =>
merge(