Keep the theme redraw from wiping a chart's x scale

Reaching Home from another page builds its charts in the same commit their
theme effect first fires in, with the readings already cached. uPlot ranges its
scales in a microtask, so `redraw()` re-set the x scale from the chart's own —
still empty — bounds before that ran, and the pending range taken from the data
was lost: axes without ticks, no lines, and no way back but the range control,
which rebuilds the chart. Redrawing without the paths is all a colour swap
needs and leaves the scales alone.

The cards also carried "Nothing has run yet." while the first readings were
still on their way; they now carry the skeleton the rest of Home uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SKL7sUgNWhukDEz95vSMQv
This commit is contained in:
2026-08-20 14:33:17 +02:00
co-authored by Claude Opus 5
parent a74fe70de7
commit dfe7aae4d2
3 changed files with 28 additions and 7 deletions
-1
View File
@@ -13,7 +13,6 @@ should reopen it.
### To be sorted ### To be sorted
- BUG/UI the "Failures and Timing" and "Throughput per Minute" figures don't show any data upon intial load (and no loading indicator). Selecting a time range seems to trigger redraw and resolves it
- BUG/UI when enlarging the code editor of a node, the code editor should enlarge to the left (node settings remain on the right) so that the code editor fills the center of the screen with the node properties available next to it - BUG/UI when enlarging the code editor of a node, the code editor should enlarge to the left (node settings remain on the right) so that the code editor fills the center of the screen with the node properties available next to it
- BUG/UX the console/log panel does not show print output of nodes - BUG/UX the console/log panel does not show print output of nodes
- CHORE/UI: loop lag on Home reads a real number with no flows, and that is right — `LoopWatchdog` times how late `asyncio.sleep(1.0)` wakes on the API's event loop and is started unconditionally, so it measures the engine process rather than any flow, and it is what turns the health badge `degraded`. Nothing to fix; recorded so it is not reopened. - CHORE/UI: loop lag on Home reads a real number with no flows, and that is right — `LoopWatchdog` times how late `asyncio.sleep(1.0)` wakes on the API's event loop and is started unconditionally, so it measures the engine process rather than any flow, and it is what turns the health badge `degraded`. Nothing to fix; recorded so it is not reopened.
+20 -5
View File
@@ -4,6 +4,7 @@ import "uplot/dist/uPlot.min.css"
import type { HistoryPoint } from "@/client" import type { HistoryPoint } from "@/client"
import { useTheme } from "@/components/theme-provider" import { useTheme } from "@/components/theme-provider"
import { Skeleton } from "@/components/ui/skeleton"
import { si } from "@/lib/utils" import { si } from "@/lib/utils"
/** /**
@@ -74,6 +75,7 @@ export function UplotChart({
labels, labels,
plots, plots,
empty = "Nothing has come through yet.", empty = "Nothing has come through yet.",
pending = false,
unit, unit,
yRange, yRange,
yLabel, yLabel,
@@ -85,6 +87,9 @@ export function UplotChart({
/** The points of each series, in the same order as `labels`. */ /** The points of each series, in the same order as `labels`. */
plots: HistoryPoint[][] plots: HistoryPoint[][]
empty?: string empty?: string
/** No readings yet because none have arrived: a chart with nothing in it
* says "nothing has run", which is a different thing to say. */
pending?: boolean
/** Written after every reading, on the axis and in the legend. */ /** Written after every reading, on the axis and in the legend. */
unit?: string unit?: string
/** A y axis fixed to these bounds; unset lets it follow the data. */ /** A y axis fixed to these bounds; unset lets it follow the data. */
@@ -240,10 +245,16 @@ export function UplotChart({
chart.current.setData(table(plots)) chart.current.setData(table(plots))
}, [points, key]) }, [points, key])
// The canvas cannot follow a CSS variable, so a theme swap is a redraw. // The canvas cannot follow a CSS variable, so a theme swap is a redraw. The
// paths are geometry and stay as they are — and leaving them alone is what
// keeps this safe on the mount it also fires on: a full redraw re-sets the x
// scale from the chart's own bounds, which are still empty when the chart was
// built in this same commit (uPlot ranges its scales in a microtask). That
// overwrites the range it was about to take from the data, and the chart is
// left with no x axis and no lines until something sets its data again.
// biome-ignore lint/correctness/useExhaustiveDependencies: the theme is the signal, not something the effect reads. // biome-ignore lint/correctness/useExhaustiveDependencies: the theme is the signal, not something the effect reads.
useEffect(() => { useEffect(() => {
chart.current?.redraw() chart.current?.redraw(false)
}, [resolvedTheme]) }, [resolvedTheme])
return ( return (
@@ -251,9 +262,13 @@ export function UplotChart({
<div className="relative min-h-0 flex-1"> <div className="relative min-h-0 flex-1">
<div ref={host} className="absolute inset-0" /> <div ref={host} className="absolute inset-0" />
{points === 0 ? ( {points === 0 ? (
<p className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground"> pending ? (
{empty} <Skeleton className="absolute inset-0" />
</p> ) : (
<p className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
{empty}
</p>
)
) : null} ) : null}
</div> </div>
{/* Kept at the legend's resting height, so the plot does not resize {/* Kept at the legend's resting height, so the plot does not resize
@@ -100,11 +100,13 @@ function Chart({
title, title,
labels, labels,
plots, plots,
pending,
moment, moment,
}: { }: {
title: string title: string
labels: string[] labels: string[]
plots: HistoryPoint[][] plots: HistoryPoint[][]
pending: boolean
moment: Moment moment: Moment
}) { }) {
return ( return (
@@ -127,6 +129,7 @@ function Chart({
labels={labels} labels={labels}
plots={plots} plots={plots}
empty="Nothing has run yet." empty="Nothing has run yet."
pending={pending}
onCursor={moment.onCursor} onCursor={moment.onCursor}
onSelect={moment.onSelect} onSelect={moment.onSelect}
/> />
@@ -205,7 +208,9 @@ export function HealthActivity({ range }: { range: Range }) {
const runsAt = useMoment() const runsAt = useMoment()
const failuresAt = useMoment() const failuresAt = useMoment()
const { data: series } = useQuery(timeseriesQueryOptions(range)) const { data: series, isPending: seriesPending } = useQuery(
timeseriesQueryOptions(range),
)
const { data: runs } = useQuery(runsQueryOptions(range)) const { data: runs } = useQuery(runsQueryOptions(range))
const { data: pinnedRuns } = useQuery(minuteRunsQueryOptions(runsAt.pinned)) const { data: pinnedRuns } = useQuery(minuteRunsQueryOptions(runsAt.pinned))
const { data: failures } = useQuery(failuresQueryOptions(range)) const { data: failures } = useQuery(failuresQueryOptions(range))
@@ -267,6 +272,7 @@ export function HealthActivity({ range }: { range: Range }) {
at((point) => point.messages), at((point) => point.messages),
at((point) => point.executions), at((point) => point.executions),
]} ]}
pending={seriesPending}
moment={runsAt} moment={runsAt}
/> />
<Chart <Chart
@@ -277,6 +283,7 @@ export function HealthActivity({ range }: { range: Range }) {
at((point) => point.avg_ms), at((point) => point.avg_ms),
at((point) => point.avg_lag_ms), at((point) => point.avg_lag_ms),
]} ]}
pending={seriesPending}
moment={failuresAt} moment={failuresAt}
/> />
</section> </section>