Act on a run, and read Home top-down
Docs / docs (push) Successful in 23s
Playwright Tests / test-playwright (1, 2) (push) Successful in 3m18s
Playwright Tests / test-playwright (2, 2) (push) Successful in 1m49s
pre-commit / pre-commit (push) Failing after 2m5s
Test Backend / test-backend (push) Successful in 2m39s
Compose Smoke Test / test-compose (push) Successful in 33s
Playwright Tests / merge-reports (push) Successful in 1m11s

Runs: a run can now be deleted (DELETE /runs/{id}, cancelling a live one
first), exported as csv from the screen's own filters, and its flow label
opens the flow. Its "Parameters" panel became "Inputs" and lists every
input the flow declares, marking the ones that took the flow's own value
rather than the run's — the comparison table resolves the same defaults
instead of printing "unset".

Home reads brain, dashboards, health, flows: the mosaic is one full-width
scrolling strip, and the flows list and the flow-activity rollups merged
into a single left-joined table so a flow's state and its numbers sit on
one row.

Charts take a drag to narrow the x window and a double click or tap to
come back out. UplotChart holds the scale and passes resetScales:false
while a window is held, which is what the old comment said made this
impossible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LrWVRguqbk33YzfEeUx5W
This commit is contained in:
2026-08-29 08:37:11 +02:00
co-authored by Claude Opus 5
parent 4215e057d1
commit 7e506b26c0
18 changed files with 1082 additions and 365 deletions
+83 -10
View File
@@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect, useRef } from "react"
import { useEffect, useLayoutEffect, useRef, useState } from "react"
import uPlot from "uplot"
import "uplot/dist/uPlot.min.css"
@@ -146,15 +146,23 @@ export const CURSOR: uPlot.Cursor = {
mousemove: binder(false),
} as unknown as uPlot.Cursor.Bind,
drag: {
// No drag-to-zoom. `setData` re-ranges the scales from the data and runs
// on every render, so a dragged range was erased by the next reading — all
// it ever did here was flash a selection box over a live chart.
x: false,
// 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
// 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,
y: false,
setScale: false,
},
}
/** Below this a drag is a click that moved, not a window. In pixels. */
const DRAG_FLOOR = 4
/** How close two taps have to be to count as one gesture. */
const DOUBLE_TAP_MS = 300
/** Room for the axis ticks; uPlot measures the rest of the box itself. */
const PADDING: uPlot.Padding = [10, 12, 0, 0]
@@ -278,6 +286,15 @@ export function UplotChart({
const host = useRef<HTMLDivElement>(null)
const legend = useRef<HTMLDivElement>(null)
const chart = useRef<uPlot | null>(null)
// 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 [showReset, setShowReset] = useState(false)
const clearZoom = () => {
zoomed.current = false
setShowReset(false)
}
// The chart outlives a render, so its handlers are read through a ref
// rather than baked into the config it was built with.
const report = useRef({ onCursor, onSelect })
@@ -302,6 +319,9 @@ export function UplotChart({
useLayoutEffect(() => {
const element = host.current
if (!element || labels.length === 0 || !ready) return
// A different set of series is a different picture; the window that was
// held over the old one means nothing on it.
clearZoom()
const axis = {
stroke: () => token("--muted-foreground", element),
@@ -312,6 +332,8 @@ export function UplotChart({
/** The x value the page was last told about, so a move within one bucket
* does not re-render it. */
let told: number | null = null
/** Whether the click about to arrive is the end of a drag. */
let dragging = false
// Resolved once for the whole chart: how many lines there are is part of
// which slots they take, when nothing named them.
const slots = slotsFor(labels.length, palette)
@@ -343,11 +365,47 @@ export function UplotChart({
report.current.onCursor?.(ts)
},
],
setSelect: [
(self) => {
// uPlot fires this for a plain click too. A few pixels is a
// slip of the hand, not a window anybody meant to ask for.
if (self.select.width <= DRAG_FLOOR) return
const from = self.posToVal(self.select.left, "x")
const to = self.posToVal(
self.select.left + self.select.width,
"x",
)
// The box has done its job; the scale is what holds the window
// from here. `false` so this hook does not fire on itself.
self.setSelect({ left: 0, width: 0, top: 0, height: 0 }, false)
self.setScale("x", { min: from, max: to })
dragging = true
zoomed.current = true
setShowReset(true)
},
],
ready: [
(self) => {
self.over.addEventListener("click", () =>
report.current.onSelect?.(under(self)),
)
self.over.addEventListener("click", () => {
// The mouseup that ended a drag arrives here as a click as
// well; pinning a moment is not what it was asking for.
if (dragging) {
dragging = false
return
}
report.current.onSelect?.(under(self))
})
self.over.addEventListener("dblclick", clearZoom)
// ponytail: a touch screen gets no dblclick from every browser,
// and uPlot has no dbltap of its own. Two taps in a moment is
// the whole of the gesture.
let lastTap = 0
self.over.addEventListener("pointerup", (event) => {
if (event.pointerType !== "touch") return
const now = event.timeStamp
if (now - lastTap < DOUBLE_TAP_MS) clearZoom()
lastTap = now
})
},
],
},
@@ -426,10 +484,14 @@ export function UplotChart({
// 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.
// 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.
useEffect(() => {
if (!chart.current || plots.length === 0) return
chart.current.setData(table(plots))
chart.current.setData(table(plots), !zoomed.current)
})
// The canvas cannot follow a CSS variable, so a theme swap is a redraw. The
@@ -448,6 +510,17 @@ export function UplotChart({
<div className="flex min-h-0 flex-1 flex-col">
<div className="relative min-h-0 flex-1">
<div ref={host} className="absolute inset-0" />
{/* Double-clicking does the same thing, but nothing says so. */}
{showReset ? (
<button
type="button"
onClick={clearZoom}
data-testid="chart-reset-zoom"
className="absolute top-1 right-1 z-10 rounded-md border border-border bg-card/90 px-1.5 py-0.5 text-muted-foreground text-xs hover:text-foreground"
>
Reset zoom
</button>
) : null}
{points === 0 ? (
pending ? (
<Skeleton className="absolute inset-0" />