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
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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user