Pin a chart to the last few runs of a flow

This commit is contained in:
2026-08-25 11:52:34 +02:00
parent 5ece544b14
commit 77754936c6
7 changed files with 331 additions and 12 deletions
@@ -1,4 +1,4 @@
import { useQueries } from "@tanstack/react-query"
import { useQueries, useQuery } from "@tanstack/react-query"
import { useEffect, useRef, useState } from "react"
import type { HistoryPoint } from "@/client"
@@ -9,7 +9,12 @@ import {
} from "@/components/Common/RangePicker"
import { MAX_SERIES, UplotChart } from "@/components/Common/UplotChart"
import { useLiveValue, useLiveValues } from "@/components/Flow/liveStore"
import { NO_CURVE } from "@/components/Runs/queries"
import {
compareQueryOptions,
NO_CURVE,
runsListQueryOptions,
shortenRunLabel,
} from "@/components/Runs/queries"
import { type DataContext, useDataContext } from "./dataContext"
import { messageHistoryQueryOptions, usePublishMessage } from "./queries"
import { usePalette } from "./settings"
@@ -96,13 +101,86 @@ export function ChartWidget(props: WidgetProps) {
// draws from that instead, whichever source it was pointed at otherwise.
const context = useDataContext()
if (context) return <ContextChart {...props} context={context} />
return config(props.widget).source === "query" ? (
const source = config(props.widget).source
if (source === "runs") return <RunsChart {...props} />
return source === "query" ? (
<QueryChart {...props} />
) : (
<LiveChart {...props} />
)
}
/**
* The last few runs of something, side by side, always.
*
* A tile rather than a way of looking at the page: what a panel over a lab
* bench shows without anyone picking runs first. The comparison endpoint
* already answers in the shape this draws, so the widget only has to decide
* which runs it means.
*/
function RunsChart({ widget }: WidgetProps) {
const cfg = (widget.config ?? {}) as {
runs?: {
metric?: string
flow?: string
group?: string
ids?: string[]
latest?: number
}
refresh_s?: number
}
const pick = cfg.runs ?? {}
const metric = pick.metric ?? ""
const explicit = pick.ids ?? []
const refreshMs = Math.max(15, Number(cfg.refresh_s) || 60) * 1000
// Named runs need no lookup. Otherwise the newest of a sweep, or of a flow —
// and of a flow only the ones that finished, since a panel comparing curves
// wants results rather than the crash from ten minutes ago.
const found = useQuery({
...runsListQueryOptions(
pick.group
? { group: pick.group }
: {
flow: pick.flow,
status: "ok",
limit: Math.min(Math.max(Number(pick.latest) || 3, 1), MAX_SERIES),
},
),
enabled: explicit.length === 0 && Boolean(pick.group || pick.flow),
})
const ids = explicit.length
? explicit.slice(0, MAX_SERIES)
: (found.data ?? []).slice(0, MAX_SERIES).map((run) => run.id)
const { data, isPending } = useQuery(
compareQueryOptions(ids, metric, refreshMs),
)
const palette = usePalette()
const lines = (data?.lines ?? []).slice(0, MAX_SERIES)
const points = lines.reduce(
(total, line) => total + (line.points?.length ?? 0),
0,
)
if (!metric) return <p className="text-muted-foreground">Pick a metric.</p>
return (
<UplotChart
labels={lines.map((line) => shortenRunLabel(line.label))}
plots={lines.map((line) =>
(line.points ?? []).map(([step, value]) => ({ ts: step, value })),
)}
palette={palette}
xTime={false}
pending={isPending || found.isPending}
empty={points === 0 ? NO_CURVE : undefined}
{...presentation(widget.config as Record<string, unknown>)}
/>
)
}
/**
* The same chart, drawn from a data context.
*
+160 -2
View File
@@ -9,6 +9,7 @@ import {
PanelTitle,
SidePanel,
} from "@/components/Flow/SidePanel"
import { runOverviewQueryOptions } from "@/components/Runs/queries"
import { Button } from "@/components/ui/button"
import {
Dialog,
@@ -144,10 +145,158 @@ function MessagePicker({
)
}
/** Which runs a pinned chart draws, and which of their metrics. */
type RunsPick = {
metric?: string
flow?: string
group?: string
ids?: string[]
latest?: number
}
/** How a pinned chart says which runs it means. */
const RUN_PICKS = [
["latest", "Latest"],
["group", "Sweep"],
["ids", "Named"],
] as const
/**
* The runs a chart is pinned to.
*
* The flows offered are the ones that have actually run — a flow with no runs
* has no curve to draw, and the run tables already know which those are.
*/
function RunsSourceFields({
runs,
refreshS,
onChange,
onRefresh,
}: {
runs: RunsPick
refreshS: unknown
onChange: (next: RunsPick) => void
onRefresh: (seconds: number) => void
}) {
const { data: flows } = useQuery(runOverviewQueryOptions())
const mode = runs.ids?.length ? "ids" : runs.group ? "group" : "latest"
return (
<div className="grid gap-2">
<div className="grid gap-1.5">
<Label className="font-normal text-sm">Of flow</Label>
<Select
value={runs.flow ?? ""}
onValueChange={(flow) => onChange({ ...runs, flow })}
>
<SelectTrigger data-testid="runs-flow">
<SelectValue placeholder="Pick a flow" />
</SelectTrigger>
<SelectContent>
{(flows ?? []).map((row) => (
<SelectItem key={row.flow} value={row.flow}>
{row.flow}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<MessagePicker
kind="chart"
value={runs.metric ?? ""}
label="Draws"
testId="runs-metric"
placeholder="Pick a metric"
filter={(message) =>
(message.dtype === "float" || message.dtype === "int") &&
(!runs.flow || message.name.startsWith(`${runs.flow}.`))
}
onPick={(metric) => onChange({ ...runs, metric })}
/>
<Segmented
value={mode}
options={RUN_PICKS}
label="Which runs"
testId="runs-pick"
onChange={(picked) =>
onChange({
...runs,
group: undefined,
ids: undefined,
latest: picked === "latest" ? (runs.latest ?? 3) : undefined,
})
}
/>
{mode === "latest" && (
<div className="grid gap-1.5">
<Label className="font-normal text-sm">How many</Label>
<Input
type="number"
min={1}
max={MAX_SERIES}
value={runs.latest ?? 3}
aria-label="How many runs"
onChange={(event) =>
onChange({ ...runs, latest: Number(event.target.value) })
}
/>
</div>
)}
{mode === "group" && (
<div className="grid gap-1.5">
<Label className="font-normal text-sm">Sweep</Label>
<Input
value={runs.group ?? ""}
placeholder="A sweep's group id"
onChange={(event) =>
onChange({ ...runs, group: event.target.value })
}
/>
</div>
)}
{mode === "ids" && (
<div className="grid gap-1.5">
<Label className="font-normal text-sm">Runs</Label>
<Input
value={(runs.ids ?? []).join(",")}
placeholder="Run ids, comma separated"
onChange={(event) =>
onChange({
...runs,
ids: event.target.value
.split(",")
.map((one) => one.trim())
.filter(Boolean),
})
}
/>
</div>
)}
<div className="grid gap-1.5">
<Label className="font-normal text-sm">Refresh (seconds)</Label>
<Input
type="number"
min={15}
value={Number(refreshS) || 60}
aria-label="Refresh seconds"
onChange={(event) => onRefresh(Number(event.target.value))}
/>
</div>
</div>
)
}
/** Where a chart's lines come from: what the engine kept, or what it asks for. */
const CHART_SOURCES = [
["live", "Live"],
["query", "Query"],
["runs", "Runs"],
] as const
/**
@@ -278,6 +427,7 @@ export function WidgetPanel({
onChange({ config: { ...cfg, ...changes } })
const series = seriesOf(widget)
const runsPick = (cfg.runs ?? {}) as RunsPick
const setSeries = (next: Series[]) => set({ series: next })
// A bar's readings. An empty row stands in for none, so a fresh bar offers
// the picker rather than only a button.
@@ -305,6 +455,7 @@ export function WidgetPanel({
const options = (cfg.options ?? []) as { label?: string; value?: unknown }[]
const setOptions = (next: typeof options) => set({ options: next })
const querying = cfg.source === "query"
const pinnedRuns = cfg.source === "runs"
// What this chart would refresh at with nothing configured. The viewer can
// pick another window on the widget, which moves it.
const paced = refreshFor(
@@ -375,13 +526,20 @@ export function WidgetPanel({
) : widget.type === "chart" ? (
<div className="grid gap-3">
<Segmented
value={querying ? "query" : "live"}
value={pinnedRuns ? "runs" : querying ? "query" : "live"}
options={CHART_SOURCES}
label="Where the chart's data comes from"
testId="chart-source"
onChange={(source) => set({ source })}
/>
{querying ? (
{pinnedRuns ? (
<RunsSourceFields
runs={runsPick}
refreshS={cfg.refresh_s}
onChange={(runs) => set({ runs })}
onRefresh={(refresh_s) => set({ refresh_s })}
/>
) : querying ? (
<div className="grid gap-2">
<MessagePicker
kind="chart"
@@ -118,6 +118,20 @@ export function widgetIssue(widget: WidgetDef): string | null {
if (widget.type === "markdown" || widget.type === "clock") return null
const cfg = config(widget)
if (widget.type === "chart" && cfg.source === "runs") {
const runs = (cfg.runs ?? {}) as {
metric?: string
flow?: string
group?: string
ids?: string[]
}
if (!runs.metric) return "This chart names no run metric yet."
if (!runs.ids?.length && !runs.group && !runs.flow) {
return "Say which runs: a flow, a sweep, or run ids."
}
return null
}
if (widget.type === "chart" && cfg.source === "query") {
if (!text(cfg.request)) return "This chart does not ask for anything yet."
if (!text(cfg.message)) return "This chart has no answer to draw yet."
+2 -6
View File
@@ -13,7 +13,7 @@ import {
compareQueryOptions,
NO_CURVE,
runMetricsQueryOptions,
shortId,
shortenRunLabel,
} from "./queries"
/** The metrics one run recorded, in the order they are worth offering. */
@@ -46,11 +46,7 @@ export function RunMetricChart({
compareQueryOptions(ids, metric, refreshMs),
)
const lines = (data?.lines ?? []).slice(0, MAX_SERIES)
const labels = lines.map((line) =>
// The endpoint labels a line with the whole run id, which is too long to
// read in a legend beside four others.
line.label.replace(/^\S+/, (id) => shortId(id)),
)
const labels = lines.map((line) => shortenRunLabel(line.label))
const plots: HistoryPoint[][] = lines.map((line) =>
(line.points ?? []).map(([step, value]) => ({ ts: step, value })),
)
+17
View File
@@ -61,6 +61,14 @@ export const runsInfiniteQueryOptions = (filters: RunFilters) => ({
},
})
/** One page of runs, for a caller that pages nothing — a pinned chart. */
export const runsListQueryOptions = (
filters: RunFilters & { limit?: number },
) => ({
queryKey: [...runKeys.all, "pinned", filters] as const,
queryFn: () => RunsService.readRuns(filters),
})
export const runOverviewQueryOptions = () => ({
queryKey: runKeys.overview,
queryFn: () => RunsService.readOverview(),
@@ -138,6 +146,15 @@ export const NO_CURVE =
/** A run id, short enough for a table cell. The tail is the random half. */
export const shortId = (id: string) => id.slice(-8)
/**
* A series label from the comparison endpoint, cut down to fit a legend.
*
* It leads with the whole run id, which is 22 characters of mostly timestamp
* beside four others.
*/
export const shortenRunLabel = (label: string) =>
label.replace(/^\S+/, (id) => shortId(id))
/** A commit, at the length everyone reads one at. */
export const shortCommit = (commit: string) => commit.slice(0, 7)