Scroll the runs table, pick a range, and choose what a comparison plots against
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
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
This commit is contained in:
@@ -2622,6 +2622,11 @@ export const SeriesAnswerSchema = {
|
||||
type: 'string',
|
||||
title: 'Metric'
|
||||
},
|
||||
x: {
|
||||
type: 'string',
|
||||
title: 'X',
|
||||
default: 'step'
|
||||
},
|
||||
lines: {
|
||||
items: {
|
||||
'$ref': '#/components/schemas/MetricSeries'
|
||||
|
||||
@@ -1899,9 +1899,13 @@ export class RunsService {
|
||||
* This is the comparison view: it answers in the same shape a flow answers a
|
||||
* chart's query with, so putting three training curves beside each other is
|
||||
* a widget binding rather than a screen of its own.
|
||||
*
|
||||
* ``x`` names what to plot against — nothing or "step", "time", or another
|
||||
* metric of the same runs.
|
||||
* @param data The data for the request.
|
||||
* @param data.ids
|
||||
* @param data.metric
|
||||
* @param data.x
|
||||
* @returns SeriesAnswer Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
@@ -1911,7 +1915,8 @@ export class RunsService {
|
||||
url: '/api/v1/runs/series/compare',
|
||||
query: {
|
||||
ids: data.ids,
|
||||
metric: data.metric
|
||||
metric: data.metric,
|
||||
x: data.x
|
||||
},
|
||||
errors: {
|
||||
422: 'Validation Error'
|
||||
|
||||
@@ -929,6 +929,7 @@ export type SecretValue = {
|
||||
|
||||
export type SeriesAnswer = {
|
||||
metric: string;
|
||||
x?: string;
|
||||
lines?: Array<MetricSeries>;
|
||||
};
|
||||
|
||||
@@ -1611,6 +1612,7 @@ export type RunsReadMetricsResponse = (Array<MetricPoint>);
|
||||
export type RunsCompareMetricData = {
|
||||
ids: string;
|
||||
metric: string;
|
||||
x?: string;
|
||||
};
|
||||
|
||||
export type RunsCompareMetricResponse = (SeriesAnswer);
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
compareQueryOptions,
|
||||
NO_CURVE,
|
||||
runsListQueryOptions,
|
||||
STEP_AXIS,
|
||||
shortenRunLabel,
|
||||
} from "@/components/Runs/queries"
|
||||
import { type DataContext, useDataContext } from "./dataContext"
|
||||
@@ -155,7 +156,7 @@ function RunsChart({ widget }: WidgetProps) {
|
||||
: (found.data ?? []).slice(0, MAX_SERIES).map((run) => run.id)
|
||||
|
||||
const { data, isPending } = useQuery(
|
||||
compareQueryOptions(ids, metric, refreshMs),
|
||||
compareQueryOptions(ids, metric, STEP_AXIS, refreshMs),
|
||||
)
|
||||
const palette = usePalette()
|
||||
const lines = (data?.lines ?? []).slice(0, MAX_SERIES)
|
||||
|
||||
@@ -9,11 +9,14 @@ import {
|
||||
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. */
|
||||
@@ -28,32 +31,41 @@ export function useMetricNames(runId: string | undefined) {
|
||||
}
|
||||
|
||||
/**
|
||||
* One metric across one or more runs, drawn on a step axis.
|
||||
* 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, refreshMs),
|
||||
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(([step, value]) => ({ ts: step, value })),
|
||||
(line.points ?? []).map(([at, value]) => ({ ts: at, value })),
|
||||
)
|
||||
const drawn = plots.reduce((total, plot) => total + plot.length, 0)
|
||||
|
||||
return (
|
||||
<div className="flex h-64 flex-col gap-2">
|
||||
<div className={cn("flex min-h-0 flex-col gap-2", className ?? "h-64")}>
|
||||
<UplotChart
|
||||
labels={labels}
|
||||
plots={plots}
|
||||
@@ -84,7 +96,7 @@ export function MetricPicker({
|
||||
if (names.length === 0) return null
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger className="h-8 w-56">
|
||||
<SelectTrigger className="h-8 w-56" aria-label="Metric">
|
||||
<SelectValue placeholder="Metric" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -97,3 +109,44 @@ export function MetricPicker({
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ChevronDown, ChevronRight, Download } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
import type { ArtifactRow, RunNodeRow } from "@/client"
|
||||
import { ValuePreview } from "@/components/Flow/ValuePreview"
|
||||
import { ago } from "@/components/Health/queries"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
@@ -121,15 +122,7 @@ export function RunDetail({ id }: { id: string }) {
|
||||
) : (
|
||||
<dl className="grid gap-x-6 gap-y-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Object.entries(run.params).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex justify-between gap-4 border-border border-b py-1"
|
||||
>
|
||||
<dt className={LABEL}>{key}</dt>
|
||||
<dd className="truncate font-mono text-sm">
|
||||
{paramText(value)}
|
||||
</dd>
|
||||
</div>
|
||||
<Entry key={key} name={key} value={value} />
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
@@ -154,15 +147,7 @@ export function RunDetail({ id }: { id: string }) {
|
||||
<h2 className="font-medium text-sm">Result</h2>
|
||||
<dl className="grid gap-x-6 gap-y-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Object.entries(run.result ?? {}).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex justify-between gap-4 border-border border-b py-1"
|
||||
>
|
||||
<dt className={LABEL}>{key}</dt>
|
||||
<dd className="truncate font-mono text-sm">
|
||||
{paramText(value)}
|
||||
</dd>
|
||||
</div>
|
||||
<Entry key={key} name={key} value={value} />
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
@@ -175,6 +160,39 @@ export function RunDetail({ id }: { id: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One named value: read directly when it is a scalar, unfolded when it is not.
|
||||
*
|
||||
* The same disclosure a node's ports use on the canvas — a run's report is a
|
||||
* record like any other, and serialising it onto one truncated line answers
|
||||
* nothing.
|
||||
*/
|
||||
function Entry({ name, value }: { name: string; value: unknown }) {
|
||||
const structured = value !== null && typeof value === "object"
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"gap-4 border-border border-b py-1",
|
||||
structured ? "flex flex-col" : "flex justify-between",
|
||||
)}
|
||||
>
|
||||
<dt className={LABEL}>{name}</dt>
|
||||
<dd
|
||||
className={cn(
|
||||
"min-w-0",
|
||||
structured ? "" : "truncate font-mono text-sm",
|
||||
)}
|
||||
>
|
||||
{structured ? (
|
||||
<ValuePreview value={value} defaultOpen />
|
||||
) : (
|
||||
paramText(value)
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Fact({
|
||||
label,
|
||||
children,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { FlaskConical, X } from "lucide-react"
|
||||
import { useRef } from "react"
|
||||
|
||||
import type { fluksio__api__routes__runs__RunRow as RunRow } from "@/client"
|
||||
import { MAX_SERIES } from "@/components/Common/UplotChart"
|
||||
import { ValuePreview } from "@/components/Flow/ValuePreview"
|
||||
import { ago } from "@/components/Health/queries"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
@@ -24,16 +26,22 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { cn, dur } from "@/lib/utils"
|
||||
import { MetricPicker, RunMetricChart, useMetricNames } from "./MetricChart"
|
||||
import {
|
||||
AxisPicker,
|
||||
MetricPicker,
|
||||
RunMetricChart,
|
||||
useMetricNames,
|
||||
} from "./MetricChart"
|
||||
import { OpenInDashboard } from "./OpenInDashboard"
|
||||
import {
|
||||
CARD,
|
||||
LIST_CAP,
|
||||
paramsSummary,
|
||||
MAX_SELECTION,
|
||||
paramText,
|
||||
runOverviewQueryOptions,
|
||||
runsInfiniteQueryOptions,
|
||||
STATUSES,
|
||||
STEP_AXIS,
|
||||
shortCommit,
|
||||
shortId,
|
||||
varyingKeys,
|
||||
@@ -47,6 +55,8 @@ export type RunsSearch = {
|
||||
/** The runs being compared, comma-joined — a comparison is a link. */
|
||||
compare?: string
|
||||
metric?: string
|
||||
/** What the comparison is plotted against: a step, a time, or a metric. */
|
||||
x?: string
|
||||
}
|
||||
|
||||
export function RunsScreen({
|
||||
@@ -68,11 +78,38 @@ export function RunsScreen({
|
||||
const runs: RunRow[] = data?.pages.flat() ?? []
|
||||
const selected = search.compare ? search.compare.split(",") : []
|
||||
|
||||
const toggle = (id: string) => {
|
||||
const next = selected.includes(id)
|
||||
? selected.filter((one) => one !== id)
|
||||
: [...selected, id]
|
||||
update({ compare: next.length ? next.join(",") : undefined })
|
||||
const put = (next: string[]) =>
|
||||
update({
|
||||
compare: next.length ? next.slice(0, MAX_SELECTION).join(",") : undefined,
|
||||
})
|
||||
|
||||
// Where a shift-click measures from: the row last picked on its own. Held in
|
||||
// a ref rather than the address — it is how the last click was made, not
|
||||
// part of what the link says.
|
||||
const anchor = useRef<number | null>(null)
|
||||
|
||||
const select = (index: number, shift: boolean) => {
|
||||
const id = runs[index]?.id
|
||||
if (!id) return
|
||||
if (shift && anchor.current !== null && anchor.current !== index) {
|
||||
const [from, to] = [anchor.current, index].sort((a, b) => a - b)
|
||||
const span = runs.slice(from, to + 1).map((run) => run.id)
|
||||
put([...selected, ...span.filter((one) => !selected.includes(one))])
|
||||
return
|
||||
}
|
||||
anchor.current = index
|
||||
put(
|
||||
selected.includes(id)
|
||||
? selected.filter((one) => one !== id)
|
||||
: [...selected, id],
|
||||
)
|
||||
}
|
||||
|
||||
const selectAll = () => {
|
||||
const shown = runs.map((run) => run.id)
|
||||
const already = shown.every((id) => selected.includes(id))
|
||||
anchor.current = null
|
||||
put(already ? [] : shown)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -130,7 +167,9 @@ export function RunsScreen({
|
||||
runs={runs}
|
||||
search={search}
|
||||
selected={selected}
|
||||
onToggle={toggle}
|
||||
compact={selected.length > 0}
|
||||
onSelect={select}
|
||||
onSelectAll={selectAll}
|
||||
onGroup={(group) => update({ group, compare: undefined })}
|
||||
/>
|
||||
)}
|
||||
@@ -148,6 +187,10 @@ export function RunsScreen({
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{runs.length} run{runs.length === 1 ? "" : "s"}
|
||||
{selected.length > 0 ? `, ${selected.length} picked` : ""}
|
||||
{selected.length >= MAX_SELECTION
|
||||
? ` — ${MAX_SELECTION} is as many as one comparison carries`
|
||||
: ""}
|
||||
{!hasNextPage && runs.length >= LIST_CAP
|
||||
? ` — the newest ${LIST_CAP}, which is as deep as this list reads`
|
||||
: ""}
|
||||
@@ -238,130 +281,187 @@ function FlowRail({
|
||||
)
|
||||
}
|
||||
|
||||
/** One parameter: read directly when it is a scalar, unfolded when it is not. */
|
||||
function ParamValue({ value }: { value: unknown }) {
|
||||
if (value !== null && typeof value === "object") {
|
||||
return <ValuePreview value={value} className="max-w-56" />
|
||||
}
|
||||
return <span className="font-mono text-xs">{paramText(value)}</span>
|
||||
}
|
||||
|
||||
function RunsTable({
|
||||
runs,
|
||||
search,
|
||||
selected,
|
||||
onToggle,
|
||||
compact,
|
||||
onSelect,
|
||||
onSelectAll,
|
||||
onGroup,
|
||||
}: {
|
||||
runs: RunRow[]
|
||||
search: RunsSearch
|
||||
selected: string[]
|
||||
onToggle: (id: string) => void
|
||||
/** A comparison is open below, so the table gives up some of its height. */
|
||||
compact: boolean
|
||||
onSelect: (index: number, shift: boolean) => void
|
||||
onSelectAll: () => void
|
||||
onGroup: (group: string) => void
|
||||
}) {
|
||||
// Under a sweep filter the shared parameters say nothing; the two or three
|
||||
// that were swept are the whole point, so they get columns of their own.
|
||||
const varying = search.group ? varyingKeys(runs).slice(0, 4) : []
|
||||
const showFlow = !search.flow
|
||||
const columns = 6 + (showFlow ? 1 : 0) + Math.max(1, varying.length)
|
||||
|
||||
// Whether a click carried shift, read where the event still has it: Radix
|
||||
// hands `onCheckedChange` the new state and nothing else.
|
||||
const shift = useRef(false)
|
||||
const remember = (event: { shiftKey: boolean }) => {
|
||||
shift.current = event.shiftKey
|
||||
}
|
||||
|
||||
const picked = runs.filter((run) => selected.includes(run.id)).length
|
||||
const all = runs.length > 0 && picked === runs.length
|
||||
|
||||
return (
|
||||
<div className={cn(CARD, "overflow-x-auto p-0")}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-8" />
|
||||
<TableHead>Run</TableHead>
|
||||
{showFlow && <TableHead>Flow</TableHead>}
|
||||
<TableHead>Status</TableHead>
|
||||
{varying.length > 0 ? (
|
||||
varying.map((key) => <TableHead key={key}>{key}</TableHead>)
|
||||
) : (
|
||||
<TableHead>Parameters</TableHead>
|
||||
)}
|
||||
<TableHead>Seed</TableHead>
|
||||
<TableHead>Code</TableHead>
|
||||
<TableHead className="text-right">Took</TableHead>
|
||||
<TableHead>Started</TableHead>
|
||||
<TableHead>By</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{runs.length === 0 && (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell
|
||||
colSpan={10}
|
||||
className="h-24 text-center text-muted-foreground"
|
||||
>
|
||||
No runs match this filter.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<Table
|
||||
containerClassName={cn(
|
||||
"overflow-y-auto",
|
||||
// Bounded so the page does not scroll away from the comparison drawn
|
||||
// under it: the table is the thing with a scrollbar, not the screen.
|
||||
// While one is open the room left over is what the chart does not
|
||||
// need, which is a height rather than a share of the window — a short
|
||||
// laptop screen has the same chart on it as a tall one.
|
||||
compact ? "max-h-[calc(100svh-33rem)] min-h-40" : "max-h-[72svh]",
|
||||
)}
|
||||
>
|
||||
<TableHeader className="sticky top-0 z-10 bg-muted">
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-8">
|
||||
<Checkbox
|
||||
checked={all ? true : picked > 0 ? "indeterminate" : false}
|
||||
onCheckedChange={onSelectAll}
|
||||
aria-label={
|
||||
all ? "Clear the selection" : "Select every run shown"
|
||||
}
|
||||
data-testid="run-select-all"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>Run</TableHead>
|
||||
{showFlow && <TableHead>Flow</TableHead>}
|
||||
<TableHead>Status</TableHead>
|
||||
{varying.length > 0 ? (
|
||||
varying.map((key) => <TableHead key={key}>{key}</TableHead>)
|
||||
) : (
|
||||
<TableHead>Parameters</TableHead>
|
||||
)}
|
||||
{runs.map((run) => (
|
||||
<TableRow key={run.id} data-testid="run-row">
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selected.includes(run.id)}
|
||||
onCheckedChange={() => onToggle(run.id)}
|
||||
aria-label={`Compare ${run.id}`}
|
||||
data-testid="run-select"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
to="/runs/$id"
|
||||
params={{ id: run.id }}
|
||||
className="font-mono text-sm hover:underline"
|
||||
data-testid="run-link"
|
||||
<TableHead>Seed</TableHead>
|
||||
<TableHead>Code</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{runs.length === 0 && (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell
|
||||
colSpan={columns}
|
||||
className="h-24 text-center text-muted-foreground"
|
||||
>
|
||||
No runs match this filter.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{runs.map((run, index) => (
|
||||
<TableRow key={run.id} data-testid="run-row">
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selected.includes(run.id)}
|
||||
onPointerDown={remember}
|
||||
onKeyDown={remember}
|
||||
onCheckedChange={() => onSelect(index, shift.current)}
|
||||
aria-label={`Compare ${run.id}`}
|
||||
data-testid="run-select"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
to="/runs/$id"
|
||||
params={{ id: run.id }}
|
||||
className="font-mono text-sm hover:underline"
|
||||
data-testid="run-link"
|
||||
>
|
||||
{shortId(run.id)}
|
||||
</Link>
|
||||
{run.group_id && !search.group && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onGroup(run.group_id as string)}
|
||||
className="rounded-full border border-border px-1.5 text-muted-foreground text-xs hover:bg-accent"
|
||||
>
|
||||
{shortId(run.id)}
|
||||
</Link>
|
||||
{run.group_id && !search.group && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onGroup(run.group_id as string)}
|
||||
className="rounded-full border border-border px-1.5 text-muted-foreground text-xs hover:bg-accent"
|
||||
>
|
||||
sweep
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
{showFlow && (
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{run.flow}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
<RunStatusBadge run={run} />
|
||||
</TableCell>
|
||||
{varying.length > 0 ? (
|
||||
varying.map((key) => (
|
||||
<TableCell key={key} className="font-mono text-sm">
|
||||
{paramText(run.params[key])}
|
||||
</TableCell>
|
||||
))
|
||||
) : (
|
||||
<TableCell className="max-w-64 truncate font-mono text-muted-foreground text-xs">
|
||||
{paramsSummary(run.params) || "—"}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="text-muted-foreground text-sm tabular-nums">
|
||||
{run.seed ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-muted-foreground text-xs">
|
||||
{/* The user's own repository when there is one: for a flow
|
||||
declared in code, the store's commit names a generated
|
||||
shim rather than anything anyone wrote. A canvas flow has
|
||||
only the store's, which is then the whole answer. */}
|
||||
{shortCommit(run.origin_commit || run.commit || "") || "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground text-sm tabular-nums">
|
||||
{run.duration_ms ? dur(run.duration_ms) : "—"}
|
||||
</TableCell>
|
||||
sweep
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
{showFlow && (
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{ago(String(run.created_at ?? ""))}
|
||||
{run.flow}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-40 truncate text-muted-foreground text-sm">
|
||||
{run.actor || "—"}
|
||||
)}
|
||||
<TableCell>
|
||||
<RunStatusBadge run={run} />
|
||||
</TableCell>
|
||||
{varying.length > 0 ? (
|
||||
varying.map((key) => (
|
||||
<TableCell key={key}>
|
||||
<ParamValue value={run.params[key]} />
|
||||
</TableCell>
|
||||
))
|
||||
) : (
|
||||
<TableCell>
|
||||
{Object.keys(run.params).length === 0 ? (
|
||||
<span className="text-muted-foreground text-xs">—</span>
|
||||
) : (
|
||||
<div className="flex max-w-96 flex-wrap items-baseline gap-x-3 gap-y-0.5">
|
||||
{Object.entries(run.params).map(([key, value]) => (
|
||||
<span
|
||||
key={key}
|
||||
className="flex min-w-0 items-baseline gap-1 whitespace-nowrap"
|
||||
>
|
||||
<span className="font-mono text-muted-foreground text-xs">
|
||||
{key}
|
||||
</span>
|
||||
<ParamValue value={value} />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
<TableCell className="text-muted-foreground text-sm tabular-nums">
|
||||
{run.seed ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-muted-foreground text-xs">
|
||||
{/* The user's own repository when there is one: for a flow
|
||||
declared in code, the store's commit names a generated
|
||||
shim rather than anything anyone wrote. A canvas flow has
|
||||
only the store's, which is then the whole answer. */}
|
||||
{shortCommit(run.origin_commit || run.commit || "") || "—"}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-muted-foreground text-sm">
|
||||
{/* When it started, and how long it then took. Two readings of
|
||||
one thing, so one column rather than two. */}
|
||||
{ago(String(run.created_at ?? ""))}
|
||||
<span className="px-1.5 text-border">|</span>
|
||||
<span className="tabular-nums">
|
||||
{run.duration_ms ? dur(run.duration_ms) : "—"}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -382,6 +482,13 @@ function Compare({
|
||||
search.metric && names.includes(search.metric)
|
||||
? search.metric
|
||||
: (names[0] ?? "")
|
||||
// An axis naming a metric these runs stopped recording falls back to the
|
||||
// step, which every run has.
|
||||
const x =
|
||||
search.x &&
|
||||
(search.x === STEP_AXIS || search.x === "time" || names.includes(search.x))
|
||||
? search.x
|
||||
: STEP_AXIS
|
||||
const tooMany = ids.length > MAX_SERIES
|
||||
|
||||
return (
|
||||
@@ -395,6 +502,16 @@ function Compare({
|
||||
value={metric}
|
||||
onChange={(name) => update({ metric: name })}
|
||||
/>
|
||||
{names.length > 0 && (
|
||||
<AxisPicker
|
||||
names={names}
|
||||
metric={metric}
|
||||
value={x}
|
||||
onChange={(next) =>
|
||||
update({ x: next === STEP_AXIS ? undefined : next })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<OpenInDashboard flow={flow} ids={ids} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -414,7 +531,12 @@ function Compare({
|
||||
)}
|
||||
|
||||
{metric ? (
|
||||
<RunMetricChart ids={ids.slice(0, MAX_SERIES)} metric={metric} />
|
||||
<RunMetricChart
|
||||
ids={ids.slice(0, MAX_SERIES)}
|
||||
metric={metric}
|
||||
x={x}
|
||||
className="h-56"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
These runs recorded no metric series to compare.
|
||||
|
||||
@@ -27,8 +27,8 @@ export const runKeys = {
|
||||
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,
|
||||
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. */
|
||||
@@ -93,17 +93,31 @@ export const runMetricsQueryOptions = (id: string, 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),
|
||||
queryFn: () => RunsService.compareMetric({ ids: ids.join(","), metric }),
|
||||
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({
|
||||
|
||||
@@ -2,11 +2,23 @@ import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
function Table({
|
||||
className,
|
||||
containerClassName,
|
||||
...props
|
||||
}: React.ComponentProps<"table"> & {
|
||||
/** For the scroll container rather than the table — a bounded height here is
|
||||
* what lets a long table scroll inside the page instead of with it, and it
|
||||
* is the box a sticky header sticks to. */
|
||||
containerClassName?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto rounded-lg border"
|
||||
className={cn(
|
||||
"relative w-full overflow-x-auto rounded-lg border",
|
||||
containerClassName,
|
||||
)}
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
|
||||
@@ -17,6 +17,7 @@ export const Route = createFileRoute("/_layout/runs/")({
|
||||
group: text(search.group),
|
||||
compare: text(search.compare),
|
||||
metric: text(search.metric),
|
||||
x: text(search.x),
|
||||
}),
|
||||
head: () => ({ meta: [{ title: "Runs - Fluksio" }] }),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user