New run: start a run from the app, on the working copy

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.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013TTfoK82awm8wvxXhHz3XF
This commit is contained in:
2026-09-02 15:10:24 +02:00
co-authored by Claude Opus 5
parent d471614e6a
commit 5b122341d5
20 changed files with 926 additions and 91 deletions
+16
View File
@@ -1263,6 +1263,12 @@ export const FlowSummarySchema = {
title: 'Title',
default: ''
},
mode: {
type: 'string',
enum: ['live', 'batch'],
title: 'Mode',
default: 'live'
},
node_count: {
type: 'integer',
title: 'Node Count',
@@ -2849,6 +2855,11 @@ export const RunDetailSchema = {
],
title: 'Group Id'
},
draft: {
type: 'boolean',
title: 'Draft',
default: false
},
labels: {
items: {
type: 'string'
@@ -4123,6 +4134,11 @@ export const fluksio__api__routes__runs__RunRowSchema = {
],
title: 'Group Id'
},
draft: {
type: 'boolean',
title: 'Draft',
default: false
},
labels: {
items: {
type: 'string'
+7 -1
View File
@@ -123,9 +123,14 @@ export class ArtifactsService {
* legitimate a body here as a checkpoint, and neither should have to fit in
* memory twice. Capped, because nothing else here was: any account, and any
* worker credential, could otherwise fill the data volume.
*
* ``volatile`` puts it in the ring instead of the store — a frame a screen is
* watching now, which the oldest of falls out of memory rather than being
* kept. A worker on another host publishing a camera sends this.
* @param data The data for the request.
* @param data.name
* @param data.mediaType
* @param data.volatile
* @returns ArtifactRef Successful Response
* @throws ApiError
*/
@@ -135,7 +140,8 @@ export class ArtifactsService {
url: '/api/v1/artifacts',
query: {
name: data.name,
media_type: data.mediaType
media_type: data.mediaType,
volatile: data.volatile
},
errors: {
422: 'Validation Error'
+4
View File
@@ -400,6 +400,7 @@ export type FlowStatePublic = {
export type FlowSummary = {
name: string;
title?: string;
mode?: 'live' | 'batch';
node_count?: number;
error_count?: number;
has_draft?: boolean;
@@ -470,6 +471,7 @@ export type fluksio__api__routes__runs__RunRow = {
commit?: string;
seed: (number | null);
group_id: (string | null);
draft?: boolean;
labels: Array<(string)>;
created_at: unknown;
started_at?: unknown;
@@ -1018,6 +1020,7 @@ export type RunDetail = {
commit?: string;
seed: (number | null);
group_id: (string | null);
draft?: boolean;
labels: Array<(string)>;
created_at: unknown;
started_at?: unknown;
@@ -1356,6 +1359,7 @@ export type AlertsTestChannelResponse = (Message);
export type ArtifactsPutArtifactData = {
mediaType?: string;
name?: string;
volatile?: boolean;
};
export type ArtifactsPutArtifactResponse = (ArtifactRef);
+102 -63
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"
import type { FlowDef_Input } from "@/client"
import type { DType, FlowDef_Input, FlowInput_Input } from "@/client"
import { Button } from "@/components/ui/button"
import {
Dialog,
@@ -22,6 +22,83 @@ import {
import { parseByDtype } from "./FlowBoundary"
import { asText } from "./NodePanel"
/**
* A field per declared input, the way its type is entered.
*
* Held as text rather than as values: half-typed input is normal ("-", "1.",
* a JSON object with one brace), and a list of them is a sweep — both of
* which a parsed value would have thrown away. The caller parses on submit.
*/
export function ParamFields({
inputs,
values,
placeholderFor,
onChange,
}: {
inputs: FlowInput_Input[]
values: Record<string, string>
/** What an empty field says it takes, when the default is not enough. */
placeholderFor?: (dtype: DType | undefined) => string
onChange: (values: Record<string, string>) => void
}) {
return (
<div className="grid gap-3">
{inputs.map((declared) => {
const name = declared.spec?.name ?? ""
const dtype = declared.spec?.dtype
return (
<div key={name} className="grid gap-1.5">
<Label htmlFor={`param-${name}`} className="font-mono text-xs">
{name}
</Label>
{dtype === "bool" ? (
<Select
value={values[name] === "true" ? "true" : "false"}
onValueChange={(next) => onChange({ ...values, [name]: next })}
>
<SelectTrigger id={`param-${name}`} className="text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">true</SelectItem>
<SelectItem value="false">false</SelectItem>
</SelectContent>
</Select>
) : (
<Input
id={`param-${name}`}
value={values[name] ?? ""}
placeholder={
placeholderFor?.(dtype) ??
(dtype === "artifact"
? "@run:<id>.<output> or sha256:…"
: `${dtype ?? "float"} or @run:<id>.<output>`)
}
className="text-sm"
onChange={(event) =>
onChange({ ...values, [name]: event.target.value })
}
/>
)}
</div>
)
})}
</div>
)
}
/** What the fields start from: the declared value of each, as text. */
export function initialText(inputs: FlowInput_Input[]): Record<string, string> {
return Object.fromEntries(
inputs.map((one) => [one.spec?.name ?? "", asText(one.initial)]),
)
}
/** The declared inputs worth drawing a field for. */
export function declaredInputs(definition: FlowDef_Input): FlowInput_Input[] {
return (definition.inputs ?? []).filter((one) => one.spec?.name)
}
/**
* The parameters of one run, taken from the flow's inputs.
*
@@ -42,8 +119,8 @@ export function RunDialog({
onOpenChange: (open: boolean) => void
onRun: (params: Record<string, unknown>) => void
}) {
const inputs = (definition.inputs ?? []).filter((one) => one.spec?.name)
const [values, setValues] = useState<Record<string, unknown>>({})
const inputs = declaredInputs(definition)
const [values, setValues] = useState<Record<string, string>>({})
// Opening is what fills the form: an edit to the flow between two runs
// should show up, and the last run's values should not linger. The inputs
@@ -51,11 +128,7 @@ export function RunDialog({
// biome-ignore lint/correctness/useExhaustiveDependencies: opening is the dependency.
useEffect(() => {
if (!open) return
setValues(
Object.fromEntries(
inputs.map((one) => [one.spec?.name ?? "", one.initial ?? null]),
),
)
setValues(initialText(inputs))
}, [open])
return (
@@ -70,52 +143,7 @@ export function RunDialog({
</DialogDescription>
</DialogHeader>
<div className="grid gap-3">
{inputs.map((declared) => {
const name = declared.spec?.name ?? ""
const dtype = declared.spec?.dtype
return (
<div key={name} className="grid gap-1.5">
<Label htmlFor={`param-${name}`} className="font-mono text-xs">
{name}
</Label>
{dtype === "bool" ? (
<Select
value={values[name] === true ? "true" : "false"}
onValueChange={(next) =>
setValues({ ...values, [name]: next === "true" })
}
>
<SelectTrigger id={`param-${name}`} className="text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">true</SelectItem>
<SelectItem value="false">false</SelectItem>
</SelectContent>
</Select>
) : (
<Input
id={`param-${name}`}
value={asText(values[name])}
placeholder={
dtype === "artifact"
? "@run:<id>.<output> or sha256:…"
: `${dtype ?? "float"} or @run:<id>.<output>`
}
className="text-sm"
onChange={(event) =>
setValues({
...values,
[name]: parseByDtype(dtype, event.target.value),
})
}
/>
)}
</div>
)
})}
</div>
<ParamFields inputs={inputs} values={values} onChange={setValues} />
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
@@ -125,15 +153,7 @@ export function RunDialog({
variant="brand"
disabled={pending}
data-testid="submit-run"
onClick={() =>
// A parameter nobody filled in keeps its declared value, which is
// what leaving it out means.
onRun(
Object.fromEntries(
Object.entries(values).filter(([, value]) => value !== null),
),
)
}
onClick={() => onRun(parseParams(inputs, values))}
>
Run
</Button>
@@ -142,3 +162,22 @@ export function RunDialog({
</Dialog>
)
}
/**
* The fields as the engine takes them.
*
* A field nobody filled in is left out, which is what keeps its declared
* value — an empty parameter is not the same as a zero.
*/
export function parseParams(
inputs: FlowInput_Input[],
values: Record<string, string>,
): Record<string, unknown> {
const params: Record<string, unknown> = {}
for (const declared of inputs) {
const name = declared.spec?.name ?? ""
const parsed = parseByDtype(declared.spec?.dtype, values[name] ?? "")
if (parsed !== null) params[name] = parsed
}
return params
}
+486
View File
@@ -0,0 +1,486 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { Link, useNavigate } from "@tanstack/react-router"
import { FlaskConical, SquarePen } from "lucide-react"
import { useEffect, useState } from "react"
import {
type DType,
type FlowInput_Input,
type fluksio__api__routes__runs__RunRow as RunRow,
RunsService,
} from "@/client"
import { flowQueryOptions, flowsQueryOptions } from "@/components/Flow/queries"
import {
declaredInputs,
initialText,
ParamFields,
parseParams,
} from "@/components/Flow/RunDialog"
import { FieldLabel } from "@/components/Flow/SidePanel"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import useCustomToast from "@/hooks/useCustomToast"
import { cn, dur } from "@/lib/utils"
import { handleError } from "@/utils"
import {
CARD,
isLive,
paramsSummary,
runKeys,
runOverviewQueryOptions,
runQueryOptions,
runsListQueryOptions,
shortId,
} from "./queries"
import { Entry } from "./RunDetail"
import { RunStatusBadge, statusReason } from "./RunStatus"
import { FlowRail } from "./RunsScreen"
/** How many recent runs of the picked flow the page keeps in front of you. */
const RECENT = 8
/**
* Starting a run: a flow, values for its inputs, and what it did.
*
* A run is how this instance is tried without touching it — the values are
* yours rather than a sensor's, the state is the run's own, and nothing it
* computes reaches the live flow. The working copy is what it runs by
* default, so an edit is answered before it is published.
*/
export function NewRun({
flow,
onPick,
}: {
flow?: string
onPick: (flow: string | undefined) => void
}) {
const { data: flows } = useQuery(flowsQueryOptions())
const { data: overview } = useQuery(runOverviewQueryOptions())
const counts = new Map((overview ?? []).map((row) => [row.flow, row]))
const rows = (flows?.data ?? []).map((one) => {
const seen = counts.get(one.name)
const notes = [
one.mode === "batch" ? "batch" : "",
one.has_draft ? "draft" : "",
].filter(Boolean)
return {
flow: one.name,
runs: seen?.runs ?? 0,
running: seen?.running ?? 0,
queued: seen?.queued ?? 0,
note: notes.join(" · ") || undefined,
}
})
return (
<div className="flex flex-col gap-6 lg:flex-row">
<FlowRail
rows={rows}
active={flow}
empty={
<p>No flows yet. Draw one first, and it can be run from here.</p>
}
onPick={onPick}
/>
<section className="flex min-w-0 flex-1 flex-col gap-4">
<header className="flex flex-wrap items-center gap-3">
<div className="mr-auto">
<h1 className="font-semibold text-2xl">New run</h1>
<p className="text-muted-foreground text-sm">
Runs a flow with the values you give it. Each run keeps its own
state, so nothing it computes reaches the live flow.
</p>
</div>
{flow && (
<Button variant="outline" size="sm" className="h-8" asChild>
<Link to="/flows/$flowName" params={{ flowName: flow }}>
<SquarePen className="size-3.5" />
Open in editor
</Link>
</Button>
)}
</header>
{flow ? (
<RunForm key={flow} flow={flow} />
) : (
<div
className={cn(
CARD,
"flex flex-col items-center gap-2 py-10 text-muted-foreground text-sm",
)}
>
<FlaskConical className="size-5" />
Pick a flow to run.
</div>
)}
</section>
</div>
)
}
function RunForm({ flow }: { flow: string }) {
const client = useQueryClient()
const navigate = useNavigate()
const { showErrorToast } = useCustomToast()
const { data: detail, isPending } = useQuery(flowQueryOptions(flow))
const [values, setValues] = useState<Record<string, string>>({})
const [seed, setSeed] = useState("")
const [draft, setDraft] = useState(true)
const [noCache, setNoCache] = useState(false)
// The run this page is watching: the one just started, or the newest of the
// flow when the page is arrived at with runs already behind it.
const [latest, setLatest] = useState<string | undefined>()
const definition = detail?.definition
const inputs: FlowInput_Input[] = definition ? declaredInputs(definition) : []
const hasDraft = Boolean(detail?.has_draft)
// Filling the form is what arriving at a flow does, and switching flows
// remounts this, so the declared values are read once per flow.
// biome-ignore lint/correctness/useExhaustiveDependencies: the definition arriving is the dependency.
useEffect(() => {
if (definition) setValues(initialText(inputs))
}, [definition])
const { data: recent } = useQuery({
...runsListQueryOptions({ flow, limit: RECENT }),
refetchInterval: (query: { state: { data?: RunRow[] } }) =>
(query.state.data ?? []).some((run) => isLive(run.status))
? 3_000
: (false as const),
})
const grid = definition ? expand(inputs, values) : []
const seeded = seed === "" ? null : Number(seed)
// Which copy and which cache, shared by both shapes of submit. The seed is
// not: the sweep route takes one per entry, so that a grid can vary it.
const flags = { draft: draft && hasDraft, no_cache: noCache }
const withSeed =
seeded === null || Number.isNaN(seeded) ? {} : { seed: seeded }
const submit = useMutation({
mutationFn: async () => {
if (grid.length > 1) {
return RunsService.createSweep({
name: flow,
requestBody: {
...flags,
runs: grid.map((params) => ({
params,
...withSeed,
idempotency_key: submissionKey(),
})),
},
})
}
return RunsService.createRun({
name: flow,
requestBody: {
...flags,
...withSeed,
params: grid[0] ?? {},
idempotency_key: submissionKey(),
},
})
},
onSuccess: (made) => {
client.invalidateQueries({ queryKey: runKeys.all })
if (Array.isArray(made)) {
// A sweep is a table rather than a result: it goes where fifty runs
// are readable, filtered to the group it just made.
navigate({
to: "/runs",
search: { flow, group: made[0]?.group_id ?? undefined },
})
return
}
setLatest(made.id)
},
// The engine's own words: a refusal names the port or the node it is
// about, and a rewrite here would lose that.
onError: handleError.bind(showErrorToast),
})
if (isPending || !definition) {
return <Skeleton className="h-64 w-full rounded-lg" />
}
const watching = latest ?? recent?.[0]?.id
return (
<>
<section className={cn(CARD, "flex flex-col gap-4")}>
{inputs.length === 0 ? (
<p className="text-muted-foreground text-sm">
This flow declares no inputs, so there is nothing to choose. Add one
in the flow panel to run it with a value.
</p>
) : (
<ParamFields
inputs={inputs}
values={values}
placeholderFor={placeholderFor}
onChange={setValues}
/>
)}
<div className="flex flex-wrap items-end gap-4">
<div className="grid gap-1.5">
<FieldLabel
htmlFor="run-seed"
help="Fills an input named seed, and is recorded either way."
>
Seed
</FieldLabel>
<Input
id="run-seed"
value={seed}
inputMode="numeric"
placeholder="none"
className="h-8 w-28 text-sm"
onChange={(event) => setSeed(event.target.value)}
/>
</div>
{hasDraft && (
<div className="flex items-center gap-2 pb-1.5 text-sm">
<FieldLabel help="Runs the working copy instead of what is published.">
Use draft
</FieldLabel>
<Switch
checked={draft}
aria-label="Use draft"
data-testid="use-draft"
onCheckedChange={setDraft}
/>
</div>
)}
<div className="flex items-center gap-2 pb-1.5 text-sm">
<FieldLabel help="Executes every node, whatever an earlier run already worked out.">
Skip cache
</FieldLabel>
<Switch
checked={noCache}
aria-label="Skip cache"
onCheckedChange={setNoCache}
/>
</div>
<Button
variant="brand"
className="ml-auto"
disabled={submit.isPending}
data-testid="submit-new-run"
onClick={() => submit.mutate()}
>
{grid.length > 1 ? `Run ${grid.length}` : "Run"}
</Button>
</div>
<p className="text-muted-foreground text-xs">
A comma-separated list in a number field runs every combination of
them as a sweep.
</p>
</section>
{watching && <Result id={watching} />}
{(recent?.length ?? 0) > 0 && (
<section className={cn(CARD, "flex flex-col gap-3")}>
<h2 className="font-medium text-sm">Recent</h2>
<Table>
<TableHeader>
<TableRow>
<TableHead>Run</TableHead>
<TableHead>Status</TableHead>
<TableHead>Parameters</TableHead>
<TableHead>Took</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{(recent ?? []).map((run) => {
const summary = paramsSummary(run.params)
return (
<TableRow key={run.id} data-testid="recent-run">
<TableCell>
<Link
to="/runs/$id"
params={{ id: run.id }}
className="font-mono text-sm hover:underline"
>
{shortId(run.id)}
</Link>
</TableCell>
<TableCell>
<RunStatusBadge run={run} />
</TableCell>
<TableCell>
<span
className="block max-w-64 truncate font-mono text-xs"
title={summary}
>
{summary || "—"}
</span>
</TableCell>
<TableCell className="text-muted-foreground text-xs">
{run.duration_ms ? dur(run.duration_ms) : "—"}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
className="h-7"
onClick={() => {
setValues({
...initialText(inputs),
...Object.fromEntries(
Object.entries(run.params).map(([key, value]) => [
key,
asParamText(value),
]),
),
})
setSeed(run.seed === null ? "" : String(run.seed))
}}
>
Reuse
</Button>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</section>
)}
</>
)
}
/** What the run being watched did, without leaving the form to read it. */
function Result({ id }: { id: string }) {
const { data: run } = useQuery(runQueryOptions(id))
if (!run) return null
const reason = statusReason(run)
const failed = (run.nodes ?? []).find((node) => node.error)
const result = Object.entries(run.result ?? {})
return (
<section
className={cn(CARD, "flex flex-col gap-3")}
data-testid="run-result"
>
<header className="flex flex-wrap items-center gap-3">
<h2 className="mr-auto font-medium text-sm">
<span className="font-mono">{shortId(run.id)}</span>
</h2>
<RunStatusBadge run={run} />
<Button variant="outline" size="sm" className="h-8" asChild>
<Link to="/runs/$id" params={{ id: run.id }}>
Open run
</Link>
</Button>
</header>
{reason && <p className="text-muted-foreground text-sm">{reason}</p>}
{failed?.error && (
<pre className="overflow-x-auto rounded-md bg-muted p-2 text-xs">
{failed.node}: {failed.error}
</pre>
)}
{result.length > 0 ? (
<dl className="grid gap-x-6 gap-y-1 sm:grid-cols-2 lg:grid-cols-3">
{result.map(([name, value]) => (
<Entry key={name} name={name} value={value} />
))}
</dl>
) : (
!isLive(run.status) && (
<p className="text-muted-foreground text-sm">
This run declares no result. Its numbers are on the run itself.
</p>
)
)}
</section>
)
}
/**
* A key for one submission, so a retry after a timeout cannot double-submit.
*
* `crypto.randomUUID` is secure-context only, and this app is reached over
* plain HTTP on a LAN, where it is not defined at all — so the fallback is
* not theoretical.
*/
function submissionKey(): string {
if (typeof crypto?.randomUUID === "function") return crypto.randomUUID()
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
}
/** A stored parameter back in the field it was typed into. */
function asParamText(value: unknown): string {
if (value === null || value === undefined) return ""
if (typeof value === "object") return JSON.stringify(value)
return String(value)
}
function placeholderFor(dtype: DType | undefined): string {
if (dtype === "artifact") return "@run:<id>.<output> or sha256:…"
if (dtype === "int" || dtype === "float") {
return `${dtype}, or a list for a sweep`
}
return `${dtype ?? "float"} or @run:<id>.<output>`
}
/**
* The runs these fields ask for.
*
* A list in a number field is the sweep grammar, and the product of the lists
* is the grid — the same one `fluksio sweep --param` builds. Only numbers are
* split: a comma in a string is content, and one in JSON is syntax.
*
* ponytail: no per-field "this is a list" toggle. Add one if a numeric input
* ever legitimately takes a comma.
*/
export function expand(
inputs: FlowInput_Input[],
values: Record<string, string>,
): Record<string, unknown>[] {
let grid: Record<string, unknown>[] = [{}]
for (const declared of inputs) {
const name = declared.spec?.name ?? ""
const dtype = declared.spec?.dtype
const raw = values[name] ?? ""
const listed =
(dtype === "int" || dtype === "float") && raw.includes(",")
? raw.split(",").map((one) => one.trim())
: [raw]
grid = grid.flatMap((row) =>
listed.map((one) => {
const parsed = parseParams([declared], { [name]: one })
return { ...row, ...parsed }
}),
)
}
return grid
}
+22 -3
View File
@@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query"
import { Link } from "@tanstack/react-router"
import { ChevronDown, ChevronRight, Download } from "lucide-react"
import { ChevronDown, ChevronRight, Download, RotateCcw } from "lucide-react"
import { useState } from "react"
import type { ArtifactRow, RunNodeRow } from "@/client"
@@ -31,6 +31,7 @@ import {
shortId,
useCancelRun,
useFlowInputs,
useRetryRun,
} from "./queries"
import { NodeStatusBadge, RunStatusBadge, statusReason } from "./RunStatus"
@@ -39,6 +40,7 @@ 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("")
@@ -89,10 +91,15 @@ export function RunDetail({ id }: { id: string }) {
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) && (
{isLive(run.status) ? (
<Button
variant="outline"
size="sm"
@@ -102,6 +109,18 @@ export function RunDetail({ id }: { id: string }) {
>
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>
@@ -193,7 +212,7 @@ export function RunDetail({ id }: { id: string }) {
* record like any other, and serialising it onto one truncated line answers
* nothing.
*/
function Entry({
export function Entry({
name,
value,
note,
+67 -17
View File
@@ -4,7 +4,8 @@ import {
useQueryClient,
} from "@tanstack/react-query"
import { Link } from "@tanstack/react-router"
import { Download, FlaskConical, Trash2, X } from "lucide-react"
import { Download, FlaskConical, Plus, Trash2, X } from "lucide-react"
import type { ReactNode } from "react"
import { useRef, useState } from "react"
import type { fluksio__api__routes__runs__RunRow as RunRow } from "@/client"
@@ -153,6 +154,7 @@ export function RunsScreen({
<FlowRail
rows={overview ?? []}
active={search.flow}
all="All runs"
onPick={(flow) =>
update({ flow, group: undefined, compare: undefined })
}
@@ -164,6 +166,17 @@ export function RunsScreen({
{search.flow ?? "Runs"}
</h1>
<Button variant="brand" size="sm" className="h-8" asChild>
<Link
to="/runs/new"
search={{ flow: search.flow }}
data-testid="new-run"
>
<Plus className="size-3.5" />
New run
</Link>
</Button>
<Select
value={search.status ?? "all"}
onValueChange={(value) =>
@@ -300,14 +313,28 @@ export function RunsScreen({
)
}
export type RailRow = {
flow: string
runs: number
running: number
queued: number
/** What this flow is, when that is worth saying: batch, draft, both. */
note?: string
}
/** The flows that have runs, which is what an experiment log is indexed by. */
function FlowRail({
export function FlowRail({
rows,
active,
all,
empty,
onPick,
}: {
rows: { flow: string; runs: number; running: number; queued: number }[]
rows: RailRow[]
active?: string
/** The "everything" entry, which a screen that runs one flow has no use for. */
all?: string
empty?: ReactNode
onPick: (flow: string | undefined) => void
}) {
const entry = (
@@ -317,6 +344,7 @@ function FlowRail({
busy: number,
isActive: boolean,
flow: string | undefined,
note?: string,
) => (
<button
key={key}
@@ -330,6 +358,11 @@ function FlowRail({
)}
>
<span className="min-w-0 flex-1 truncate">{label}</span>
{note && (
<span className="rounded-full border border-border px-1.5 text-[10px]">
{note}
</span>
)}
{busy > 0 && (
<span className="rounded-full bg-primary/15 px-1.5 text-primary text-xs">
{busy}
@@ -341,14 +374,15 @@ function FlowRail({
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,
)}
{all &&
entry(
"all",
all,
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,
@@ -357,15 +391,19 @@ function FlowRail({
row.running + row.queued,
active === row.flow,
row.flow,
row.note,
),
)}
{rows.length === 0 && (
<p className="px-2 py-4 text-muted-foreground text-sm">
<div 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>
{empty ?? (
<p>
Nothing has been run yet. New run is where to start one, and a
run from the CLI or the API lands here too.
</p>
)}
</div>
)}
</aside>
)
@@ -376,7 +414,14 @@ function ParamValue({ value }: { value: unknown }) {
if (value !== null && typeof value === "object") {
return <ValuePreview value={value} className="max-w-56" />
}
return <span className="font-mono text-xs">{paramText(value)}</span>
// Cut to the column rather than widening it: a long value is worth reading
// in full on the run itself, and a table nobody can scan is worth less.
const text = paramText(value)
return (
<span className="block max-w-56 truncate font-mono text-xs" title={text}>
{text}
</span>
)
}
/** How long ago a moment was, in `dur`'s units so a row's two times agree. */
@@ -498,6 +543,11 @@ function RunsTable({
sweep
</button>
)}
{run.draft && (
<span className="rounded-full border border-border px-1.5 text-muted-foreground text-xs">
draft
</span>
)}
</div>
</TableCell>
{showFlow && (
+20
View File
@@ -1,4 +1,5 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router"
import { useMemo } from "react"
import { OpenAPI, RunsService } from "@/client"
@@ -128,6 +129,25 @@ export function useCancelRun() {
})
}
/**
* Run the same thing again, as a run of its own.
*
* The engine keeps the flow, the inputs, the seed and the group, so a sweep
* missing one config is completed rather than reissued — and lands on the new
* run, since that is what there is to watch.
*/
export function useRetryRun() {
const client = useQueryClient()
const navigate = useNavigate()
return useMutation({
mutationFn: (runId: string) => RunsService.retryRun({ runId }),
onSuccess: (run: { id: string }) => {
client.invalidateQueries({ queryKey: runKeys.all })
navigate({ to: "/runs/$id", params: { id: run.id } })
},
})
}
/**
* Save an artifact to disk.
*
+21
View File
@@ -29,6 +29,7 @@ import { Route as LayoutAdminRouteImport } from './routes/_layout/admin'
import { Route as LayoutRunsIndexRouteImport } from './routes/_layout/runs/index'
import { Route as LayoutFlowsIndexRouteImport } from './routes/_layout/flows/index'
import { Route as LayoutDashboardsIndexRouteImport } from './routes/_layout/dashboards/index'
import { Route as LayoutRunsNewRouteImport } from './routes/_layout/runs/new'
import { Route as LayoutRunsIdRouteImport } from './routes/_layout/runs/$id'
import { Route as CanvasFlowsFlowNameRouteImport } from './routes/_canvas/flows/$flowName'
import { Route as CanvasDashboardsNameRouteImport } from './routes/_canvas/dashboards/$name'
@@ -131,6 +132,11 @@ const LayoutDashboardsIndexRoute = LayoutDashboardsIndexRouteImport.update({
path: '/dashboards/',
getParentRoute: () => LayoutRoute,
} as any)
const LayoutRunsNewRoute = LayoutRunsNewRouteImport.update({
id: '/runs/new',
path: '/runs/new',
getParentRoute: () => LayoutRoute,
} as any)
const LayoutRunsIdRoute = LayoutRunsIdRouteImport.update({
id: '/runs/$id',
path: '/runs/$id',
@@ -166,6 +172,7 @@ export interface FileRoutesByFullPath {
'/dashboards/$name': typeof CanvasDashboardsNameRoute
'/flows/$flowName': typeof CanvasFlowsFlowNameRoute
'/runs/$id': typeof LayoutRunsIdRoute
'/runs/new': typeof LayoutRunsNewRoute
'/dashboards/': typeof LayoutDashboardsIndexRoute
'/flows/': typeof LayoutFlowsIndexRoute
'/runs/': typeof LayoutRunsIndexRoute
@@ -189,6 +196,7 @@ export interface FileRoutesByTo {
'/dashboards/$name': typeof CanvasDashboardsNameRoute
'/flows/$flowName': typeof CanvasFlowsFlowNameRoute
'/runs/$id': typeof LayoutRunsIdRoute
'/runs/new': typeof LayoutRunsNewRoute
'/dashboards': typeof LayoutDashboardsIndexRoute
'/flows': typeof LayoutFlowsIndexRoute
'/runs': typeof LayoutRunsIndexRoute
@@ -215,6 +223,7 @@ export interface FileRoutesById {
'/_canvas/dashboards/$name': typeof CanvasDashboardsNameRoute
'/_canvas/flows/$flowName': typeof CanvasFlowsFlowNameRoute
'/_layout/runs/$id': typeof LayoutRunsIdRoute
'/_layout/runs/new': typeof LayoutRunsNewRoute
'/_layout/dashboards/': typeof LayoutDashboardsIndexRoute
'/_layout/flows/': typeof LayoutFlowsIndexRoute
'/_layout/runs/': typeof LayoutRunsIndexRoute
@@ -240,6 +249,7 @@ export interface FileRouteTypes {
| '/dashboards/$name'
| '/flows/$flowName'
| '/runs/$id'
| '/runs/new'
| '/dashboards/'
| '/flows/'
| '/runs/'
@@ -263,6 +273,7 @@ export interface FileRouteTypes {
| '/dashboards/$name'
| '/flows/$flowName'
| '/runs/$id'
| '/runs/new'
| '/dashboards'
| '/flows'
| '/runs'
@@ -288,6 +299,7 @@ export interface FileRouteTypes {
| '/_canvas/dashboards/$name'
| '/_canvas/flows/$flowName'
| '/_layout/runs/$id'
| '/_layout/runs/new'
| '/_layout/dashboards/'
| '/_layout/flows/'
| '/_layout/runs/'
@@ -448,6 +460,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LayoutDashboardsIndexRouteImport
parentRoute: typeof LayoutRoute
}
'/_layout/runs/new': {
id: '/_layout/runs/new'
path: '/runs/new'
fullPath: '/runs/new'
preLoaderRoute: typeof LayoutRunsNewRouteImport
parentRoute: typeof LayoutRoute
}
'/_layout/runs/$id': {
id: '/_layout/runs/$id'
path: '/runs/$id'
@@ -494,6 +513,7 @@ interface LayoutRouteChildren {
LayoutWorkersRoute: typeof LayoutWorkersRoute
LayoutIndexRoute: typeof LayoutIndexRoute
LayoutRunsIdRoute: typeof LayoutRunsIdRoute
LayoutRunsNewRoute: typeof LayoutRunsNewRoute
LayoutDashboardsIndexRoute: typeof LayoutDashboardsIndexRoute
LayoutFlowsIndexRoute: typeof LayoutFlowsIndexRoute
LayoutRunsIndexRoute: typeof LayoutRunsIndexRoute
@@ -508,6 +528,7 @@ const LayoutRouteChildren: LayoutRouteChildren = {
LayoutWorkersRoute: LayoutWorkersRoute,
LayoutIndexRoute: LayoutIndexRoute,
LayoutRunsIdRoute: LayoutRunsIdRoute,
LayoutRunsNewRoute: LayoutRunsNewRoute,
LayoutDashboardsIndexRoute: LayoutDashboardsIndexRoute,
LayoutFlowsIndexRoute: LayoutFlowsIndexRoute,
LayoutRunsIndexRoute: LayoutRunsIndexRoute,
+26
View File
@@ -0,0 +1,26 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router"
import { NewRun } from "@/components/Runs/NewRun"
export const Route = createFileRoute("/_layout/runs/new")({
component: Page,
// Which flow is being run is the address, so a link opens the form on it —
// the same way the history screen's filter is a link.
validateSearch: (search: Record<string, unknown>): { flow?: string } => ({
flow:
typeof search.flow === "string" && search.flow ? search.flow : undefined,
}),
head: () => ({ meta: [{ title: "New run - Fluksio" }] }),
})
function Page() {
const { flow } = Route.useSearch()
const navigate = useNavigate()
return (
<NewRun
flow={flow}
onPick={(next) => navigate({ to: "/runs/new", search: { flow: next } })}
/>
)
}
+25
View File
@@ -149,3 +149,28 @@ test("picked runs can be deleted", async ({ page }) => {
await expect(page.getByTestId("run-row")).toHaveCount(0, { timeout: 30_000 })
})
test("a run is started from the form", async ({ page }) => {
await page.goto(`/runs/new?flow=${flowName}`)
await page.getByLabel("epochs", { exact: true }).fill("3")
await page.getByTestId("submit-new-run").click()
// The result card is the run just started, and it fills in as it goes.
const result = page.getByTestId("run-result")
await expect(result).toBeVisible()
await expect(result.getByText("score")).toBeVisible({ timeout: 30_000 })
await expect(page.getByTestId("recent-run").first()).toBeVisible()
})
test("a list of values is a sweep", async ({ page }) => {
await page.goto(`/runs/new?flow=${flowName}`)
await page.getByLabel("epochs", { exact: true }).fill("2,3")
const run = page.getByTestId("submit-new-run")
await expect(run).toHaveText("Run 2")
await run.click()
// Submitting a sweep lands on the history, filtered to that group.
await page.waitForURL(/\/runs\?.*group=/)
await expect(page.getByTestId("run-row")).toHaveCount(2)
})