Add a runs screen: a table, a run in full, and curves side by side
This commit is contained in:
@@ -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(" ")
|
||||
Reference in New Issue
Block a user