Add a runs screen: a table, a run in full, and curves side by side

This commit is contained in:
2026-08-25 11:44:13 +02:00
parent 7e422c0047
commit d2951a325e
17 changed files with 1564 additions and 5 deletions
+164
View File
@@ -0,0 +1,164 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { OpenAPI, RunsService } from "@/client"
import { apiToken } from "@/lib/portal"
/**
* How deep one page of the run list is read.
*
* The endpoint caps a page at 500; asking for a hundred at a time keeps the
* first paint quick and leaves "Load more" something to do.
*/
export const PAGE = 100
/** How far the list will page before it stops offering to go deeper. */
export const LIST_CAP = 500
export type RunFilters = {
flow?: string
status?: string
group?: string
}
export const runKeys = {
all: ["runs"] as const,
overview: ["runs", "overview"] as const,
list: (filters: RunFilters) => ["runs", "list", filters] as const,
detail: (id: string) => ["runs", "detail", id] as const,
metrics: (id: string, name: string) =>
["runs", "detail", id, "metrics", name] as const,
compare: (ids: string[], metric: string) =>
["runs", "compare", ids.join(","), metric] as const,
}
/** The resting surface these screens are built from, as Health names it. */
export const CARD = "rounded-lg border border-border bg-card p-4 shadow-e1"
/** The statuses a run passes through, as the filter offers them. */
export const STATUSES = [
"queued",
"running",
"ok",
"error",
"cancelled",
"abandoned",
] as const
/** A run that has not settled is still worth re-reading. */
export const isLive = (status: string) =>
status === "queued" || status === "running"
export const runsInfiniteQueryOptions = (filters: RunFilters) => ({
queryKey: runKeys.list(filters),
queryFn: ({ pageParam }: { pageParam: number }) =>
RunsService.readRuns({ ...filters, limit: PAGE, offset: pageParam }),
initialPageParam: 0,
getNextPageParam: (last: unknown[], all: unknown[][]) => {
const read = all.reduce((total, page) => total + page.length, 0)
// A short page is the end of the history; the cap is the end of what this
// list will show of it.
return last.length < PAGE || read >= LIST_CAP ? undefined : read
},
})
export const runOverviewQueryOptions = () => ({
queryKey: runKeys.overview,
queryFn: () => RunsService.readOverview(),
refetchInterval: 30_000,
})
export const runQueryOptions = (id: string) => ({
queryKey: runKeys.detail(id),
queryFn: () => RunsService.readRun({ runId: id }),
// A finished run never changes again, so only a live one is polled. The
// socket's run_finished lands the last transition either way; this covers
// the metrics and node rows filling in while it runs.
refetchInterval: (query: { state: { data?: { status: string } } }) =>
query.state.data && isLive(query.state.data.status)
? 5_000
: (false as const),
})
/** One run's series, or every one of them when `name` is empty. */
export const runMetricsQueryOptions = (id: string, name = "") => ({
queryKey: runKeys.metrics(id, name),
queryFn: () => RunsService.readMetrics({ runId: id, name }),
})
export const compareQueryOptions = (
ids: string[],
metric: string,
refetchInterval?: number,
) => ({
queryKey: runKeys.compare(ids, metric),
queryFn: () => RunsService.compareMetric({ ids: ids.join(","), metric }),
enabled: ids.length > 0 && Boolean(metric),
...(refetchInterval ? { refetchInterval } : {}),
})
export function useCancelRun() {
const client = useQueryClient()
return useMutation({
mutationFn: (runId: string) => RunsService.cancelRun({ runId }),
onSuccess: () => client.invalidateQueries({ queryKey: runKeys.all }),
})
}
/**
* Save an artifact to disk.
*
* Not a plain link: `/artifacts/{digest}` takes a bearer token, which an
* anchor cannot carry. The bytes come through fetch and leave as an object
* URL — the same trip the browser would have made, with the header on it.
*/
export async function downloadArtifact(digest: string, name: string) {
const token = apiToken()
const answer = await fetch(`${OpenAPI.BASE}/api/v1/artifacts/${digest}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!answer.ok) throw new Error(`Could not read ${name}`)
const url = URL.createObjectURL(await answer.blob())
const link = document.createElement("a")
link.href = url
link.download = name
link.click()
URL.revokeObjectURL(url)
}
/** A run id, short enough for a table cell. The tail is the random half. */
export const shortId = (id: string) => id.slice(-8)
/** A commit, at the length everyone reads one at. */
export const shortCommit = (commit: string) => commit.slice(0, 7)
/**
* The parameter keys that differ across these runs.
*
* What makes a sweep readable: fifty runs of one flow share everything but the
* two knobs that were swept, and those two are the only columns worth drawing.
*/
export function varyingKeys(runs: { params: Record<string, unknown> }[]) {
if (runs.length < 2) return []
const keys = new Set<string>()
for (const run of runs)
for (const key of Object.keys(run.params)) keys.add(key)
return [...keys].filter((key) => {
const first = JSON.stringify(runs[0].params[key])
return runs.some((run) => JSON.stringify(run.params[key]) !== first)
})
}
/** A parameter value, as narrow as it can be written. */
export function paramText(value: unknown): string {
if (value === null || value === undefined) return "—"
if (typeof value === "number" || typeof value === "boolean")
return String(value)
if (typeof value === "string") return value
return JSON.stringify(value)
}
/** A run's parameters on one line, for a table cell. */
export const paramsSummary = (params: Record<string, unknown>) =>
Object.entries(params)
.map(([key, value]) => `${key}=${paramText(value)}`)
.join(" ")