425 lines
14 KiB
TypeScript
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>
|
|
)
|
|
}
|