Add a runs screen: a table, a run in full, and curves side by side
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user