Files
app/frontend/src/components/Runs/queries.ts
T
stroblme 4350916bd8
Docs / docs (push) Successful in 35s
Playwright Tests / test-playwright (1, 2) (push) Failing after 3m14s
Playwright Tests / test-playwright (2, 2) (push) Failing after 1m44s
pre-commit / pre-commit (push) Failing after 3m54s
Test Backend / test-backend (push) Successful in 3m12s
Compose Smoke Test / test-compose (push) Successful in 32s
Playwright Tests / merge-reports (push) Failing after 1m28s
Scroll the runs table, pick a range, and choose what a comparison plots against
2026-08-25 16:32:34 +02:00

207 lines
7.2 KiB
TypeScript

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, x: string) =>
["runs", "compare", ids.join(","), metric, x] 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
},
})
/** 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(),
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 }),
})
/** What a comparison can be plotted against, beside another metric's name. */
export const STEP_AXIS = "step"
export const TIME_AXIS = "time"
export const compareQueryOptions = (
ids: string[],
metric: string,
x = STEP_AXIS,
refetchInterval?: number,
) => ({
queryKey: runKeys.compare(ids, metric, x),
queryFn: () => RunsService.compareMetric({ ids: ids.join(","), metric, x }),
enabled: ids.length > 0 && Boolean(metric),
...(refetchInterval ? { refetchInterval } : {}),
})
/**
* How many runs one selection carries.
*
* The selection is the address, which is what makes a comparison a link — and
* a URL is not a place to put five hundred 22-character ids. A chart draws
* five of them anyway; the rest of the room is for changing your mind.
*/
export const MAX_SELECTION = 50
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)
}
/**
* Why a finished run can have nothing to draw.
*
* A cache hit replays no emissions, so a restored node's curve is read back
* from the run that recorded it. Deleting that run — deleting its flow does —
* takes the curve with it, and then a reused run has a result and nothing to
* draw. Said out loud rather than left as an empty chart, which reads as a fault.
*/
export const NO_CURVE =
"No curve was recorded. A node restored from the cache is read back from the run that produced it, so this draws nothing once that run has been deleted — its outputs are still on the result."
/** 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)
/**
* 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(" ")