The site promises simulated inputs and mocked sensor values, and nothing in the app was that. A run already is: the values are the caller's, the state is the run's own namespace, and nothing it computes reaches the live flow. What was missing was a screen to do it from, and the draft flag being honoured. `/runs/new` is a flow, a field per declared input, a seed and Run; `/runs` stays the log. A comma-separated list in a number field expands into the grid `fluksio sweep --param` builds and goes to the sweep route, so launching one no longer needs a terminal. Only numbers split: a comma in a string is content, and one in JSON is syntax. `RunCreate.draft` was validated at submit and dropped before the run executed, so "try the working copy" ran the published one. `Run.draft` is a column now, the driver reads the same copy the submit checked, and a retry carries it. `FlowSummary.mode` came with it so the rail can say which flows are batch before one is picked. Also here: a Retry button on a finished run, which the route has always had and the UI never did, and parameter cells truncated to their column with the full value on hover.
402 lines
13 KiB
TypeScript
402 lines
13 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 { 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, RunMetricChart, useMetricNames } from "./MetricChart"
|
|
import { OpenInDashboard } from "./OpenInDashboard"
|
|
import {
|
|
CARD,
|
|
downloadArtifact,
|
|
isLive,
|
|
NO_CURVE,
|
|
paramText,
|
|
runQueryOptions,
|
|
shortCommit,
|
|
shortId,
|
|
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 = 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 an
|
|
// empty chart here means that run is gone rather than that this one failed.
|
|
// Said once, where it applies.
|
|
const cached = nodes.some((node) => node.status === "cached")
|
|
|
|
// 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: !(name in run.params),
|
|
})),
|
|
...Object.keys(run.params)
|
|
.filter((name) => !declared.has(name))
|
|
.map((name) => ({ name, value: run.params[name], fromFlow: false })),
|
|
]
|
|
|
|
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>
|
|
)}
|
|
|
|
<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)}
|
|
disabled={retry.isPending}
|
|
data-testid="retry-run"
|
|
>
|
|
<RotateCcw className="size-3.5" />
|
|
Retry
|
|
</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>
|
|
|
|
{/* 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">
|
|
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>
|
|
)}
|
|
|
|
{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]) => (
|
|
<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-[10px]">
|
|
{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>
|
|
)
|
|
}
|