Add a runs screen: a table, a run in full, and curves side by side

This commit is contained in:
2026-08-25 11:44:13 +02:00
parent 7e422c0047
commit d2951a325e
17 changed files with 1564 additions and 5 deletions
@@ -236,6 +236,7 @@ export function UplotChart({
yLabel,
palette,
smooth = false,
xTime = true,
onCursor,
onSelect,
}: {
@@ -262,6 +263,10 @@ export function UplotChart({
* Monotone rather than plain cubic on purpose: a spline that overshoots
* invents readings between two the sensor actually took. */
smooth?: boolean
/** The x axis reads as time. False when x is a count rather than a moment —
* a run's metric is indexed by step, and drawn as time it would date every
* point to 1970. */
xTime?: boolean
/** The x value under the pointer, and null once it leaves the plot. */
onCursor?: (ts: number | null) => void
/** The x value clicked, or null for a click that landed on no point. */
@@ -284,7 +289,7 @@ export function UplotChart({
// while a new reading only sets its data. The unit, the fixed range and the
// axis title are part of it — all are baked into the axes at build time —
// and so are the line shape and the palette, which the series close over.
const key = `${labels.join(" ")}|${unit ?? ""}|${yRange?.join(",") ?? ""}|${yLabel ?? ""}|${smooth}|${palette?.join("") ?? ""}`
const key = `${labels.join(" ")}|${unit ?? ""}|${yRange?.join(",") ?? ""}|${yLabel ?? ""}|${smooth}|${palette?.join("") ?? ""}|${xTime}`
// uPlot leaves its axes half-initialised while the scales have no range, and
// a resize in that window (a card still settling, say) draws them anyway and
// throws. Waiting for the first reading avoids the state altogether.
@@ -344,7 +349,7 @@ export function UplotChart({
],
},
scales: {
x: { time: true },
x: { time: xTime },
...(yRange ? { y: { range: yRange } } : {}),
},
axes: [
@@ -4,6 +4,7 @@ import { useEffect } from "react"
import { OpenAPI } from "@/client"
import { dashboardKeys, panelKeys } from "@/components/Dashboard/queries"
import { runKeys } from "@/components/Runs/queries"
import { connectionStore } from "@/lib/connectionStore"
import { apiToken } from "@/lib/portal"
import { type LogLine, liveStore, type ValueSource } from "./liveStore"
@@ -89,6 +90,14 @@ type FlowEvent =
paused?: string[]
}
| { type: "dashboard_changed"; dashboard?: string; ts?: number }
| {
type: "run_started" | "run_finished"
flow: string
run: string
status?: string
group?: string
ts?: number
}
function socketUrl(): string {
const base = String(OpenAPI.BASE || window.location.origin)
@@ -251,6 +260,13 @@ function connect() {
: panelKeys.all,
})
break
case "run_started":
case "run_finished":
// One invalidation covers the lot: the list, the flow counts, the run
// being watched, and any chart drawing a run's curve. A run is not a
// live value, so nothing here goes through the live store.
client?.invalidateQueries({ queryKey: runKeys.all })
break
}
}
@@ -0,0 +1,108 @@
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 { compareQueryOptions, runMetricsQueryOptions, shortId } from "./queries"
/**
* Why a finished run can have nothing to draw.
*
* A cache hit restores what a node returned, not the values it emitted along
* the way, so a run whose training node was reused has a result and no curve.
* Said here 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 replays no emissions, so a run that reused an earlier one draws nothing here — its outputs are still on the result."
/** 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, drawn on a step axis.
*
* 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.
*/
export function RunMetricChart({
ids,
metric,
refreshMs,
}: {
ids: string[]
metric: string
refreshMs?: number
}) {
const { data, isPending } = useQuery(
compareQueryOptions(ids, metric, refreshMs),
)
const lines = (data?.lines ?? []).slice(0, MAX_SERIES)
const labels = lines.map((line) =>
// The endpoint labels a line with the whole run id, which is too long to
// read in a legend beside four others.
line.label.replace(/^\S+/, (id) => shortId(id)),
)
const plots: HistoryPoint[][] = lines.map((line) =>
(line.points ?? []).map(([step, value]) => ({ ts: step, value })),
)
const drawn = plots.reduce((total, plot) => total + plot.length, 0)
return (
<div className="flex flex-col gap-2">
<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">
<SelectValue placeholder="Metric" />
</SelectTrigger>
<SelectContent>
{names.map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
@@ -0,0 +1,117 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router"
import { LayoutDashboard } from "lucide-react"
import { useState } from "react"
import { DashboardsService } from "@/client"
import {
dashboardKeys,
dashboardsQueryOptions,
} from "@/components/Dashboard/queries"
import { Button } from "@/components/ui/button"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import useCustomToast from "@/hooks/useCustomToast"
/** What the generator calls a flow's results dashboard. Mirrors the backend. */
const resultsName = (flow: string) => `${flow}_results`
/**
* Send these runs to a dashboard.
*
* The dashboard does the drawing; this only says which one and against what.
* A flow with no results dashboard yet is offered one built from its own
* declared ports, which is the shortest path from "I ran something" to "I can
* see it" — and an ordinary dashboard afterwards.
*/
export function OpenInDashboard({
flow,
ids,
}: {
flow?: string
ids: string[]
}) {
const navigate = useNavigate()
const client = useQueryClient()
const { showErrorToast } = useCustomToast()
const [open, setOpen] = useState(false)
const { data } = useQuery(dashboardsQueryOptions())
const show = (name: string) => {
setOpen(false)
navigate({
to: "/view/$name",
params: { name },
search: { runs: ids.join(",") },
})
}
const generate = useMutation({
mutationFn: (name: string) =>
DashboardsService.generateResultsDashboard({ flow: name }),
onSuccess: (made) => {
client.invalidateQueries({ queryKey: dashboardKeys.all })
show(made.name)
},
onError: (error: { status?: number }) => {
// Someone else made it between the listing and the click; it is the one
// that was wanted either way.
if (error.status === 409 && flow) return show(resultsName(flow))
showErrorToast("Could not build a results dashboard for this flow")
},
})
const dashboards = data?.data ?? []
const results = flow ? resultsName(flow) : ""
const hasResults = dashboards.some((one) => one.name === results)
if (ids.length === 0) return null
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-8">
<LayoutDashboard className="mr-1 size-3.5" />
Open in dashboard
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-64 p-1">
{flow && !hasResults && (
<button
type="button"
onClick={() => generate.mutate(flow)}
disabled={generate.isPending}
className="w-full rounded-md px-2 py-1.5 text-left text-sm hover:bg-accent disabled:opacity-50"
>
{generate.isPending
? "Building…"
: `Build a results dashboard for ${flow}`}
</button>
)}
{dashboards.map((one) => (
<button
key={one.name}
type="button"
onClick={() => show(one.name)}
className="w-full rounded-md px-2 py-1.5 text-left text-sm hover:bg-accent"
>
{one.title || one.name}
{one.name === results && (
<span className="ml-2 text-muted-foreground text-xs">
results
</span>
)}
</button>
))}
{dashboards.length === 0 && !flow && (
<p className="px-2 py-1.5 text-muted-foreground text-sm">
No dashboards yet.
</p>
)}
</PopoverContent>
</Popover>
)
}
+325
View File
@@ -0,0 +1,325 @@
import { useQuery } from "@tanstack/react-query"
import { Link } from "@tanstack/react-router"
import { ChevronDown, ChevronRight, Download } from "lucide-react"
import { useState } from "react"
import type { ArtifactRow, RunNodeRow } from "@/client"
import { ago } from "@/components/Health/queries"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import useCustomToast from "@/hooks/useCustomToast"
import { cn, dur, si } from "@/lib/utils"
import {
MetricPicker,
NO_CURVE,
RunMetricChart,
useMetricNames,
} from "./MetricChart"
import { OpenInDashboard } from "./OpenInDashboard"
import {
CARD,
downloadArtifact,
isLive,
paramText,
runQueryOptions,
shortCommit,
shortId,
useCancelRun,
} from "./queries"
import { NodeStatusBadge, RunStatusBadge, statusReason } from "./RunStatus"
const LABEL = "text-muted-foreground text-xs"
export function RunDetail({ id }: { id: string }) {
const { data: run, isPending } = useQuery(runQueryOptions(id))
const cancel = useCancelRun()
const names = useMetricNames(id)
const [metric, setMetric] = useState("")
if (isPending || !run) return <Skeleton className="h-96 w-full rounded-lg" />
const shown = metric && names.includes(metric) ? metric : (names[0] ?? "")
const nodes = run.nodes ?? []
const artifacts = run.artifacts ?? []
const reason = statusReason(run)
// A run whose nodes were all restored emits nothing, so an empty chart is
// the expected outcome rather than a fault. Said once, where it applies.
const cached = nodes.some((node) => node.status === "cached")
return (
<div className="flex flex-col gap-4">
<header className="flex flex-wrap items-center gap-3">
<Link
to="/runs"
search={{ flow: run.flow }}
className="text-muted-foreground text-sm hover:underline"
>
{run.flow}
</Link>
<h1 className="font-mono font-semibold text-xl">{shortId(run.id)}</h1>
<RunStatusBadge run={run} />
{run.group_id && (
<Link
to="/runs"
search={{ flow: run.flow, group: run.group_id }}
className="rounded-full border border-border px-2 py-0.5 text-muted-foreground text-xs hover:bg-accent"
>
in a sweep
</Link>
)}
<div className="ml-auto flex items-center gap-2">
<OpenInDashboard flow={run.flow} ids={[run.id]} />
{isLive(run.status) && (
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => cancel.mutate(run.id)}
disabled={cancel.isPending}
>
Cancel
</Button>
)}
</div>
</header>
{reason && <p className="text-muted-foreground text-sm">{reason}</p>}
<section className={cn(CARD, "grid gap-4 sm:grid-cols-2 lg:grid-cols-4")}>
<Fact label="Submitted">{ago(String(run.created_at ?? ""))}</Fact>
<Fact label="Took">{run.duration_ms ? dur(run.duration_ms) : "—"}</Fact>
<Fact label="By">{run.actor || "—"}</Fact>
<Fact label="Cause">{run.cause}</Fact>
<Fact label="Seed">{run.seed ?? "—"}</Fact>
<Fact label="Code">
{/* The user's own repository for a code-declared flow; the store's
commit is a generated shim and says less. */}
<span className="font-mono">
{shortCommit(run.origin_commit || run.commit || "") || "—"}
</span>
</Fact>
<Fact label="Labels">{run.labels.join(", ") || "—"}</Fact>
<Fact label="Parameters">
<span className="font-mono text-xs">
{shortId(run.params_digest || "")}
</span>
</Fact>
</section>
<section className={cn(CARD, "flex flex-col gap-2")}>
<h2 className="font-medium text-sm">Parameters</h2>
{Object.keys(run.params).length === 0 ? (
<p className="text-muted-foreground text-sm">
This run took its flow's own defaults.
</p>
) : (
<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>
))}
</dl>
)}
</section>
{names.length > 0 && (
<section className={cn(CARD, "flex flex-col gap-3")}>
<header className="flex items-center gap-3">
<h2 className="mr-auto font-medium text-sm">Metrics</h2>
<MetricPicker names={names} value={shown} onChange={setMetric} />
</header>
<RunMetricChart ids={[run.id]} metric={shown} />
</section>
)}
{names.length === 0 && cached && (
<p className={cn(CARD, "text-muted-foreground text-sm")}>{NO_CURVE}</p>
)}
{Object.keys(run.result ?? {}).length > 0 && (
<section className={cn(CARD, "flex flex-col gap-2")}>
<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>
))}
</dl>
</section>
)}
<NodesTable nodes={nodes} />
{artifacts.length > 0 && <Artifacts rows={artifacts} />}
</div>
)
}
function Fact({
label,
children,
}: {
label: string
children: React.ReactNode
}) {
return (
<div className="flex flex-col gap-0.5">
<span className={LABEL}>{label}</span>
<span className="truncate text-sm">{children}</span>
</div>
)
}
/** What each node did, with its logs and its traceback behind a disclosure. */
function NodesTable({ nodes }: { nodes: RunNodeRow[] }) {
const [open, setOpen] = useState<string | null>(null)
if (nodes.length === 0) return null
return (
<section className={cn(CARD, "overflow-x-auto p-0")}>
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-8" />
<TableHead>Node</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Took</TableHead>
<TableHead>Worker</TableHead>
<TableHead className="text-right">Attempt</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{nodes.map((node) => {
const detail = node.error || node.logs
const isOpen = open === node.node
return [
<TableRow key={node.node}>
<TableCell>
{detail && (
<button
type="button"
onClick={() => setOpen(isOpen ? null : node.node)}
aria-label={`Logs for ${node.node}`}
className="text-muted-foreground"
>
{isOpen ? (
<ChevronDown className="size-4" />
) : (
<ChevronRight className="size-4" />
)}
</button>
)}
</TableCell>
<TableCell className="font-mono text-sm">{node.node}</TableCell>
<TableCell>
<NodeStatusBadge status={node.status} />
</TableCell>
<TableCell className="text-right text-muted-foreground text-sm tabular-nums">
{node.duration_ms ? dur(node.duration_ms) : "—"}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{node.worker || "—"}
</TableCell>
<TableCell className="text-right text-muted-foreground text-sm tabular-nums">
{node.attempt}
</TableCell>
</TableRow>,
isOpen && detail ? (
<TableRow
key={`${node.node}-detail`}
className="hover:bg-transparent"
>
<TableCell colSpan={6} className="bg-muted/30">
{node.error && (
<pre className="mb-2 overflow-x-auto whitespace-pre-wrap text-destructive text-xs">
{node.error}
</pre>
)}
{node.logs && (
<pre className="max-h-64 overflow-auto whitespace-pre-wrap text-muted-foreground text-xs">
{node.logs}
</pre>
)}
</TableCell>
</TableRow>
) : null,
]
})}
</TableBody>
</Table>
</section>
)
}
function Artifacts({ rows }: { rows: ArtifactRow[] }) {
const { showErrorToast } = useCustomToast()
return (
<section className={cn(CARD, "overflow-x-auto p-0")}>
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Artifact</TableHead>
<TableHead>From</TableHead>
<TableHead>Type</TableHead>
<TableHead className="text-right">Size</TableHead>
<TableHead className="w-10" />
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.name}>
<TableCell className="font-mono text-sm">{row.name}</TableCell>
<TableCell className="text-muted-foreground text-sm">
{row.node}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{row.media_type || "—"}
</TableCell>
<TableCell className="text-right text-muted-foreground text-sm tabular-nums">
{si(row.size)}B
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
className="size-8"
aria-label={`Download ${row.name}`}
onClick={() =>
downloadArtifact(row.digest, row.name).catch(() =>
showErrorToast(`Could not download ${row.name}`),
)
}
>
<Download className="size-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</section>
)
}
@@ -0,0 +1,90 @@
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
/**
* How each status is drawn.
*
* Colour never carries the status on its own — the word is always written
* beside it (DESIGN-GUIDELINES.md → status is named in text).
*/
const LOOKS: Record<string, string> = {
ok: "border-transparent bg-status-success/15 text-status-success",
running: "border-transparent bg-primary/15 text-primary",
queued: "border-border bg-muted text-muted-foreground",
error: "border-transparent bg-destructive/15 text-destructive",
cancelled: "border-border bg-muted text-muted-foreground",
abandoned: "border-transparent bg-destructive/10 text-destructive",
}
/**
* Why a run is where it is, when the status alone does not say.
*
* A run that sits queued forever is the one genuinely puzzling state, and its
* reason is the answer: no worker carrying the labels it asked for is attached.
*/
export function statusReason(run: {
status: string
status_reason: string
labels: string[]
started_at?: unknown
}): string {
if (run.status_reason) return run.status_reason
if (run.status === "queued" && !run.started_at && run.labels.length)
return `Waiting for a worker labelled ${run.labels.join(", ")}`
return ""
}
export function RunStatusBadge({
run,
className,
}: {
run: {
status: string
status_reason: string
labels: string[]
started_at?: unknown
}
className?: string
}) {
const reason = statusReason(run)
const badge = (
<span
className={cn(
"inline-flex w-fit shrink-0 items-center rounded-full border px-2 py-0.5 text-xs font-medium",
LOOKS[run.status] ?? "border-border text-muted-foreground",
className,
)}
>
{run.status}
</span>
)
if (!reason) return badge
return (
<Tooltip>
<TooltipTrigger asChild>{badge}</TooltipTrigger>
<TooltipContent className="max-w-xs">{reason}</TooltipContent>
</Tooltip>
)
}
/** A node's outcome inside a run, where "cached" is its own thing. */
export function NodeStatusBadge({ status }: { status: string }) {
const look =
status === "cached"
? "border-border bg-muted text-muted-foreground"
: (LOOKS[status] ?? "border-border text-muted-foreground")
return (
<span
className={cn(
"inline-flex w-fit shrink-0 items-center rounded-full border px-2 py-0.5 text-xs font-medium",
look,
)}
>
{status}
</span>
)
}
+422
View File
@@ -0,0 +1,422 @@
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"
import { Link } from "@tanstack/react-router"
import { FlaskConical, X } from "lucide-react"
import type { fluksio__api__routes__runs__RunRow as RunRow } from "@/client"
import { MAX_SERIES } from "@/components/Common/UplotChart"
import { ago } from "@/components/Health/queries"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { cn, dur } from "@/lib/utils"
import { MetricPicker, RunMetricChart, useMetricNames } from "./MetricChart"
import { OpenInDashboard } from "./OpenInDashboard"
import {
CARD,
LIST_CAP,
paramsSummary,
paramText,
runOverviewQueryOptions,
runsInfiniteQueryOptions,
STATUSES,
shortCommit,
shortId,
varyingKeys,
} from "./queries"
import { RunStatusBadge } from "./RunStatus"
export type RunsSearch = {
flow?: string
status?: string
group?: string
/** The runs being compared, comma-joined — a comparison is a link. */
compare?: string
metric?: string
}
export function RunsScreen({
search,
update,
}: {
search: RunsSearch
update: (next: Partial<RunsSearch>) => void
}) {
const filters = {
...(search.flow ? { flow: search.flow } : {}),
...(search.status ? { status: search.status } : {}),
...(search.group ? { group: search.group } : {}),
}
const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery(runsInfiniteQueryOptions(filters))
const { data: overview } = useQuery(runOverviewQueryOptions())
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 })
}
return (
<div className="flex flex-col gap-6 lg:flex-row">
<FlowRail
rows={overview ?? []}
active={search.flow}
onPick={(flow) =>
update({ flow, group: undefined, compare: undefined })
}
/>
<section className="flex min-w-0 flex-1 flex-col gap-4">
<header className="flex flex-wrap items-center gap-3">
<h1 className="mr-auto font-semibold text-2xl">
{search.flow ?? "Runs"}
</h1>
<Select
value={search.status ?? "all"}
onValueChange={(value) =>
update({ status: value === "all" ? undefined : value })
}
>
<SelectTrigger className="h-8 w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Any status</SelectItem>
{STATUSES.map((status) => (
<SelectItem key={status} value={status}>
{status}
</SelectItem>
))}
</SelectContent>
</Select>
{search.group && (
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => update({ group: undefined })}
>
sweep {shortId(search.group)}
<X className="ml-1 size-3" />
</Button>
)}
</header>
{isPending ? (
<Skeleton className="h-64 w-full rounded-lg" />
) : (
<RunsTable
runs={runs}
search={search}
selected={selected}
onToggle={toggle}
onGroup={(group) => update({ group, compare: undefined })}
/>
)}
<div className="flex items-center gap-3">
{hasNextPage && (
<Button
variant="outline"
size="sm"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? "Loading…" : "Load more"}
</Button>
)}
<p className="text-muted-foreground text-xs">
{runs.length} run{runs.length === 1 ? "" : "s"}
{!hasNextPage && runs.length >= LIST_CAP
? ` — the newest ${LIST_CAP}, which is as deep as this list reads`
: ""}
</p>
</div>
{selected.length > 0 && (
<Compare
ids={selected}
search={search}
update={update}
flow={runs.find((run) => run.id === selected[0])?.flow}
/>
)}
</section>
</div>
)
}
/** The flows that have runs, which is what an experiment log is indexed by. */
function FlowRail({
rows,
active,
onPick,
}: {
rows: { flow: string; runs: number; running: number; queued: number }[]
active?: string
onPick: (flow: string | undefined) => void
}) {
const entry = (
key: string,
label: string,
count: number,
busy: number,
isActive: boolean,
flow: string | undefined,
) => (
<button
key={key}
type="button"
onClick={() => onPick(flow)}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm",
isActive
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50",
)}
>
<span className="min-w-0 flex-1 truncate">{label}</span>
{busy > 0 && (
<span className="rounded-full bg-primary/15 px-1.5 text-primary text-xs">
{busy}
</span>
)}
<span className="text-xs tabular-nums">{count}</span>
</button>
)
return (
<aside className="flex w-full shrink-0 flex-col gap-1 lg:w-56">
{entry(
"all",
"All runs",
rows.reduce((total, row) => total + row.runs, 0),
rows.reduce((total, row) => total + row.running + row.queued, 0),
!active,
undefined,
)}
{rows.map((row) =>
entry(
row.flow,
row.flow,
row.runs,
row.running + row.queued,
active === row.flow,
row.flow,
),
)}
{rows.length === 0 && (
<p className="px-2 py-4 text-muted-foreground text-sm">
<FlaskConical className="mb-1 size-4" />
<br />
Nothing has been run yet. Submit a batch flow from its editor, the CLI
or the API and it lands here.
</p>
)}
</aside>
)
}
function RunsTable({
runs,
search,
selected,
onToggle,
onGroup,
}: {
runs: RunRow[]
search: RunsSearch
selected: string[]
onToggle: (id: string) => 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
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>
)}
{runs.map((run) => (
<TableRow key={run.id}>
<TableCell>
<Checkbox
checked={selected.includes(run.id)}
onCheckedChange={() => onToggle(run.id)}
aria-label={`Compare ${run.id}`}
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Link
to="/runs/$id"
params={{ id: run.id }}
className="font-mono text-sm hover:underline"
>
{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. */}
{shortCommit(run.origin_commit ?? "") || "—"}
</TableCell>
<TableCell className="text-right text-muted-foreground text-sm tabular-nums">
{run.duration_ms ? dur(run.duration_ms) : "—"}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{ago(String(run.created_at ?? ""))}
</TableCell>
<TableCell className="max-w-40 truncate text-muted-foreground text-sm">
{run.actor || "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}
/** The picked runs, one metric at a time. */
function Compare({
ids,
search,
update,
flow,
}: {
ids: string[]
search: RunsSearch
update: (next: Partial<RunsSearch>) => void
flow?: string
}) {
const names = useMetricNames(ids[0])
const metric =
search.metric && names.includes(search.metric)
? search.metric
: (names[0] ?? "")
const tooMany = ids.length > MAX_SERIES
return (
<section className={cn(CARD, "flex flex-col gap-3")}>
<header className="flex flex-wrap items-center gap-3">
<h2 className="mr-auto font-medium text-sm">
Comparing {ids.length} run{ids.length === 1 ? "" : "s"}
</h2>
<MetricPicker
names={names}
value={metric}
onChange={(name) => update({ metric: name })}
/>
<OpenInDashboard flow={flow} ids={ids} />
<Button
variant="ghost"
size="sm"
className="h-8"
onClick={() => update({ compare: undefined })}
>
Clear
</Button>
</header>
{tooMany && (
<p className="text-muted-foreground text-xs">
A chart carries {MAX_SERIES} lines; the first {MAX_SERIES} of these
are drawn.
</p>
)}
{metric ? (
<RunMetricChart ids={ids.slice(0, MAX_SERIES)} metric={metric} />
) : (
<p className="text-muted-foreground text-sm">
These runs recorded no metric series to compare.
</p>
)}
</section>
)
}
+164
View File
@@ -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(" ")
@@ -1,6 +1,7 @@
import {
ArrowLeft,
Bell,
FlaskConical,
Home,
KeyRound,
LayoutDashboard,
@@ -29,6 +30,9 @@ const baseItems: Item[] = [
{ icon: Home, title: "Home", path: "/" },
{ icon: Workflow, title: "Flows", path: "/flows" },
{ icon: LayoutDashboard, title: "Dashboards", path: "/dashboards" },
// What a batch flow leaves behind. Beside the flows rather than under Home:
// an experiment log is browsed, not glanced at.
{ icon: FlaskConical, title: "Runs", path: "/runs" },
// Both are engine-wide operator settings rather than personal ones, so they
// sit here and not among the per-user tabs under Settings.
{ icon: KeyRound, title: "Secrets", path: "/secrets" },