Files
app/frontend/src/components/Dashboard/RunsContext.tsx
T

131 lines
4.1 KiB
TypeScript

import { useQueries } from "@tanstack/react-query"
import type { ReactNode } from "react"
import type { RunDetail } from "@/client"
import type { LiveValue } from "@/components/Flow/liveStore"
import {
compareQueryOptions,
runQueryOptions,
shortId,
} from "@/components/Runs/queries"
import { type Dashboard, widgetsOf } from "./DashboardView"
import {
type ContextLine,
type DataContext,
DataContextProvider,
} from "./dataContext"
/**
* The message names this dashboard would want a curve for.
*
* Only charts: a stat shows one number and reads the run's result instead, so
* asking the server for its whole series would be a query nobody draws.
*/
function chartedNames(dashboard: Dashboard | undefined): string[] {
if (!dashboard) return []
const names = new Set<string>()
for (const widget of widgetsOf(dashboard)) {
if (widget.type !== "chart") continue
const config = (widget.config ?? {}) as {
series?: { message?: string }[]
runs?: { metric?: string }
}
for (const entry of config.series ?? [])
if (entry.message) names.add(entry.message)
if (config.runs?.metric) names.add(config.runs.metric)
}
return [...names]
}
/** A run's own label in a legend, beside four others. */
const runLabel = (run: RunDetail) =>
run.seed === null || run.seed === undefined
? shortId(run.id)
: `${shortId(run.id)} (seed ${run.seed})`
/**
* A dashboard drawn against runs rather than against the engine.
*
* The values a run finished with are its `result`, which the API reports with
* the flow prefix stripped — a run of `study` reports `accuracy`, while the
* dashboard binds `study.accuracy` — so they are qualified again here. The
* curves come from the comparison endpoint, which already answers in the
* shape a chart draws.
*
* Where several runs are shown, a chart draws all of them and anything with
* room for one number shows the first, which is the one the bar names.
*/
export function RunsContextProvider({
runIds,
dashboard,
children,
}: {
runIds: string[]
dashboard: Dashboard | undefined
children: ReactNode
}) {
const runs = useQueries({
queries: runIds.map((id) => runQueryOptions(id)),
})
const names = chartedNames(dashboard)
const series = useQueries({
queries: names.map((name) => compareQueryOptions(runIds, name)),
})
const loaded = runs
.map((query) => query.data)
.filter((run): run is RunDetail => Boolean(run))
const primary = loaded[0]
const finals = new Map<string, LiveValue>()
if (primary) {
const at = primary.finished_at ? Date.parse(String(primary.finished_at)) : 0
for (const [key, value] of Object.entries(primary.result ?? {})) {
finals.set(key.includes(".") ? key : `${primary.flow}.${key}`, {
value,
ts: at ? at / 1000 : null,
})
}
}
const curves = new Map<string, ContextLine[]>()
names.forEach((name, index) => {
const answer = series[index]?.data
if (!answer) return
curves.set(
name,
(answer.lines ?? []).map((line, position) => ({
// The endpoint labels by run id; the run rows carry the nicer name.
label: loaded[position] ? runLabel(loaded[position]) : line.label,
points: (line.points ?? []).map(([step, value]) => ({
ts: step,
value,
})),
})),
)
})
const flow = primary?.flow ?? ""
const context: DataContext = {
kind: "runs",
label:
runIds.length === 1
? `run ${shortId(runIds[0])}${flow ? ` of ${flow}` : ""}`
: `${runIds.length} runs${flow ? ` of ${flow}` : ""}`,
readOnly: true,
// A run's metrics are indexed by step, not by the clock.
xTime: false,
pending:
runs.some((query) => query.isPending) ||
series.some((query) => query.isPending),
value: (name) => finals.get(name),
lines: (name) => curves.get(name),
}
return <DataContextProvider value={context}>{children}</DataContextProvider>
}
/** The runs a dashboard was opened against, from `?runs=a,b,c`. */
export const runIdsOf = (runs: string | undefined) =>
runs ? runs.split(",").filter(Boolean) : []