Files
app/frontend/src/components/Runs/RunDetail.tsx
T

425 lines
14 KiB
TypeScript

import { useQuery } from "@tanstack/react-query"
import { Link } from "@tanstack/react-router"
import { ChevronDown, ChevronRight, Download, RotateCcw } from "lucide-react"
import { useState } from "react"
import type { ArtifactRow, RunNodeRow } from "@/client"
import { ValuePreview } from "@/components/Flow/ValuePreview"
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 { ago, cn, dur, si } from "@/lib/utils"
import { MetricPicker, RunMetricChart, useMetricNames } from "./MetricChart"
import { OpenInDashboard } from "./OpenInDashboard"
import {
CARD,
curveLost,
downloadArtifact,
FLOW_GONE,
isLive,
paramText,
runQueryOptions,
shortCommit,
shortId,
tookDefault,
useCancelRun,
useFlowInputs,
useRetryRun,
} 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 retry = useRetryRun()
const names = useMetricNames(id)
const { declared: current, gone } = useFlowInputs(run?.flow)
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 restored node's curve comes from the run it was restored from, so it
// goes when that run is deleted. Said once, where it applies.
const lost = nodes.filter((node) => node.curve_lost)
// What the flow declared when this run was submitted. A run from before
// that was kept reads against the flow's current declarations.
const declared = run.declared
? new Map(Object.entries(run.declared))
: current
// Declared order first, so the panel reads the way the flow does; anything
// the run carries that the flow no longer declares still shows, since it is
// what the run was actually given.
const fed = [
...[...declared.keys()].map((name) => ({
name,
value: name in run.params ? run.params[name] : declared.get(name),
fromFlow: tookDefault(run, name),
})),
...Object.keys(run.params)
.filter((name) => !declared.has(name))
.map((name) => ({
name,
value: run.params[name],
fromFlow: tookDefault(run, name),
})),
]
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>
)}
{run.draft && (
<span className="rounded-full border border-border px-2 py-0.5 text-muted-foreground text-xs">
ran the working copy
</span>
)}
{gone && (
<span
className="rounded-full border border-border px-2 py-0.5 text-muted-foreground text-xs"
data-testid="flow-gone"
>
flow deleted
</span>
)}
<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>
) : (
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => retry.mutate(run.id)}
// There is no flow left to run it against; the route would 404.
disabled={retry.isPending || gone}
data-testid="retry-run"
>
<RotateCcw className="size-3.5" />
Retry
</Button>
)}
</div>
</header>
{reason && <p className="text-muted-foreground text-sm">{reason}</p>}
{gone && <p className="text-muted-foreground text-sm">{FLOW_GONE}</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>
{/* What the run actually ran with, which is not the same list as what it
was passed: a flow's inputs *are* its parameters, and one left alone
took the flow's own value. Both are drawn, and which is which is
marked — reading a number off a chart is worth nothing if the other
half of the setting is invisible. */}
<section className={cn(CARD, "flex flex-col gap-2")}>
<h2 className="font-medium text-sm">Inputs</h2>
{fed.length === 0 ? (
<p className="text-muted-foreground text-sm">
{gone && !run.declared
? "The run carried no parameters of its own, and its flow is gone, so what it took from the flow cannot be read back."
: "This flow declares no inputs, so there was nothing to choose."}
</p>
) : (
<dl className="grid gap-x-6 gap-y-1 sm:grid-cols-2 lg:grid-cols-3">
{fed.map(({ name, value, fromFlow }) => (
<Entry
key={name}
name={name}
value={value}
note={fromFlow ? "default" : undefined}
/>
))}
</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>
)}
{lost.length > 0 && (
<p className={cn(CARD, "text-muted-foreground text-sm")}>
{curveLost(lost)}
</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]) => (
<Entry key={key} name={key} value={value} />
))}
</dl>
</section>
)}
<NodesTable nodes={nodes} />
{artifacts.length > 0 && <Artifacts rows={artifacts} />}
</div>
)
}
/**
* 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.
*/
export function Entry({
name,
value,
note,
}: {
name: string
value: unknown
/** Where the value came from, when it was not this run. */
note?: string
}) {
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={cn(LABEL, "flex items-baseline gap-1.5")}>
{name}
{note && (
<span className="rounded-full border border-border px-1.5 text-xs">
{note}
</span>
)}
</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,
}: {
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>
)
}