Files
app/frontend/src/components/Runs/MetricChart.tsx
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

153 lines
4.0 KiB
TypeScript

import { useQuery } from "@tanstack/react-query"
import type { HistoryPoint } from "@/client"
import { MAX_SERIES, UplotChart } from "@/components/Common/UplotChart"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { cn } from "@/lib/utils"
import {
compareQueryOptions,
NO_CURVE,
runMetricsQueryOptions,
STEP_AXIS,
shortenRunLabel,
TIME_AXIS,
} from "./queries"
/** The metrics one run recorded, in the order they are worth offering. */
export function useMetricNames(runId: string | undefined) {
const { data } = useQuery({
...runMetricsQueryOptions(runId ?? "", ""),
enabled: Boolean(runId),
})
const names = new Set<string>()
for (const point of data ?? []) if (point.name) names.add(point.name)
return [...names]
}
/**
* One metric across one or more runs.
*
* The endpoint answers in the chart widget's own series shape, so comparing
* three curves and showing one are the same call with a different id list.
*
* `x` is what the readings are plotted against: the step they were recorded
* at, the seconds since the run's own first one, or another metric of the same
* runs. Never a clock — even "time" is an elapsed count, which is what makes
* two runs started hours apart comparable.
*/
export function RunMetricChart({
ids,
metric,
x = STEP_AXIS,
refreshMs,
className,
}: {
ids: string[]
metric: string
x?: string
refreshMs?: number
className?: string
}) {
const { data, isPending } = useQuery(
compareQueryOptions(ids, metric, x, refreshMs),
)
const lines = (data?.lines ?? []).slice(0, MAX_SERIES)
const labels = lines.map((line) => shortenRunLabel(line.label))
const plots: HistoryPoint[][] = lines.map((line) =>
(line.points ?? []).map(([at, value]) => ({ ts: at, value })),
)
const drawn = plots.reduce((total, plot) => total + plot.length, 0)
return (
<div className={cn("flex min-h-0 flex-col gap-2", className ?? "h-64")}>
<UplotChart
labels={labels}
plots={plots}
xTime={false}
yLabel={metric}
pending={isPending}
empty={NO_CURVE}
/>
{drawn > 0 && (data?.lines?.length ?? 0) > MAX_SERIES && (
<p className="text-muted-foreground text-xs">
Showing {MAX_SERIES} of {data?.lines?.length} runs.
</p>
)}
</div>
)
}
/** The metric picker both the detail and the comparison sit under. */
export function MetricPicker({
names,
value,
onChange,
}: {
names: string[]
value: string
onChange: (name: string) => void
}) {
if (names.length === 0) return null
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger className="h-8 w-56" aria-label="Metric">
<SelectValue placeholder="Metric" />
</SelectTrigger>
<SelectContent>
{names.map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
/**
* What the comparison is plotted against.
*
* Doubles as the axis label — it sits directly above the chart, and naming the
* axis twice is one caption too many.
*/
export function AxisPicker({
names,
metric,
value,
onChange,
}: {
names: string[]
/** Excluded from the choices: a metric against itself is a straight line. */
metric: string
value: string
onChange: (x: string) => void
}) {
const options = [
{ value: STEP_AXIS, label: "vs step" },
{ value: TIME_AXIS, label: "vs time (s)" },
...names
.filter((name) => name !== metric)
.map((name) => ({ value: name, label: `vs ${name}` })),
]
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger className="h-8 w-44" aria-label="Plotted against">
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)
}