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
@@ -0,0 +1,33 @@
"""run.draft
A run submitted against the working copy validated the draft and then executed
what was published. The row now records which copy was asked for, so the
driver reads the same one the submit checked.
Revision ID: a8f6f79e40be
Revises: e7d3b1a9c624
Create Date: 2026-09-02
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "a8f6f79e40be"
down_revision = "e7d3b1a9c624"
branch_labels = None
depends_on = None
def upgrade():
# Not null with a default, so every run recorded before this reads as what
# it was: a run of the published flow.
op.add_column(
"run",
sa.Column("draft", sa.Boolean(), nullable=False, server_default=sa.text("0")),
)
def downgrade():
op.drop_column("run", "draft")
+1
View File
@@ -303,6 +303,7 @@ def read_flows(controller: FlowControllerDep) -> Any:
FlowSummary( FlowSummary(
name=definition.name, name=definition.name,
title=definition.title, title=definition.title,
mode=definition.mode,
node_count=len(definition.nodes), node_count=len(definition.nodes),
error_count=sum(1 for s in statuses if s.status == "error"), error_count=sum(1 for s in statuses if s.status == "error"),
has_draft=controller.store.has_draft(name), has_draft=controller.store.has_draft(name),
+2
View File
@@ -134,6 +134,8 @@ class RunRow(BaseModel):
commit: str = "" commit: str = ""
seed: int | None seed: int | None
group_id: str | None group_id: str | None
#: Whether it ran the working copy rather than what is published.
draft: bool = False
labels: list[str] labels: list[str]
created_at: Any created_at: Any
started_at: Any = None started_at: Any = None
+11 -2
View File
@@ -906,6 +906,7 @@ class RunService:
group_id=group_id, group_id=group_id,
cause=cause, cause=cause,
no_cache=no_cache, no_cache=no_cache,
draft=draft,
status="queued", status="queued",
labels=required_labels(flow), labels=required_labels(flow),
needs=required_resources(flow), needs=required_resources(flow),
@@ -973,6 +974,10 @@ class RunService:
inputs, the same group, so a sweep is completed rather than repeated. inputs, the same group, so a sweep is completed rather than repeated.
The stage cache is what makes it cheap — the nodes that finished are The stage cache is what makes it cheap — the nodes that finished are
restored rather than run again. restored rather than run again.
A run of the working copy retries as one, which means whatever the
draft is now rather than what it was — there is no older draft to go
back to, and the point of retrying a draft run is the code on disk.
""" """
with Session(db_engine) as session: with Session(db_engine) as session:
run = session.get(Run, run_id) run = session.get(Run, run_id)
@@ -989,6 +994,7 @@ class RunService:
cause="retry", cause="retry",
actor=actor, actor=actor,
no_cache=source.no_cache, no_cache=source.no_cache,
draft=source.draft,
parent_id=source.id, parent_id=source.id,
) )
@@ -1203,17 +1209,20 @@ class RunService:
sink = MetricSink(run_id) sink = MetricSink(run_id)
try: try:
flow = self.controller.store.read_flow(run.flow) flow = self.controller.store.read_flow(run.flow, draft=run.draft)
# Read again, now that it is this run's turn: a sweep queues every # Read again, now that it is this run's turn: a sweep queues every
# run at once, and the code on disk is free to move in the hours # run at once, and the code on disk is free to move in the hours
# before the last of them starts. What the record must name is the # before the last of them starts. What the record must name is the
# state that ran, not the state that was submitted. # state that ran, not the state that was submitted. A draft that
# was published meanwhile reads as the published copy, which is
# the same document under a different name.
digests = node_digests(flow) digests = node_digests(flow)
run.code_digest = self._restamp(run, run_digest(flow)) run.code_digest = self._restamp(run, run_digest(flow))
state = self._state_factory(f"{RUN_NAMESPACE}:{run_id}") state = self._state_factory(f"{RUN_NAMESPACE}:{run_id}")
pipeline = self.controller.build_run_pipeline( pipeline = self.controller.build_run_pipeline(
flow, flow,
state=state, state=state,
draft=run.draft,
observer=observe, observer=observe,
emission_observer=sink.handle, emission_observer=sink.handle,
run=RunContext( run=RunContext(
+2
View File
@@ -355,6 +355,8 @@ class MessageHistory(BaseModel):
class FlowSummary(BaseModel): class FlowSummary(BaseModel):
name: str name: str
title: str = "" title: str = ""
#: Whether running it means one finite execution or leaving it running.
mode: Literal["live", "batch"] = "live"
node_count: int = 0 node_count: int = 0
error_count: int = 0 error_count: int = 0
has_draft: bool = False has_draft: bool = False
+3
View File
@@ -374,6 +374,9 @@ class Run(SQLModel, table=True):
cause: str = Field(default="api", max_length=32) cause: str = Field(default="api", max_length=32)
#: Re-execute every node, whatever the stage cache holds for it. #: Re-execute every node, whatever the stage cache holds for it.
no_cache: bool = False no_cache: bool = False
#: Run the unpublished working copy rather than what is published. Read
#: again when the run starts, so a draft published in between is what runs.
draft: bool = False
#: queued, running, ok, error, cancelled or abandoned. #: queued, running, ok, error, cancelled or abandoned.
status: str = Field(default="queued", index=True, max_length=16) status: str = Field(default="queued", index=True, max_length=16)
#: Why it is where it is: what it waits for, or what went wrong. #: Why it is where it is: what it waits for, or what went wrong.
+56
View File
@@ -14,6 +14,7 @@ from sqlmodel import Session, col, select
from fluksio.core.config import settings from fluksio.core.config import settings
from fluksio.core.db import engine as db_engine from fluksio.core.db import engine as db_engine
from fluksio.flow.artifacts import ArtifactStore from fluksio.flow.artifacts import ArtifactStore
from fluksio.flow.controller import FlowController
from fluksio.flow.messages import DType, MessageSpec from fluksio.flow.messages import DType, MessageSpec
from fluksio.flow.pipeline import NodeOutcome from fluksio.flow.pipeline import NodeOutcome
from fluksio.flow.runs import ( from fluksio.flow.runs import (
@@ -27,6 +28,7 @@ from fluksio.flow.runs import (
seed_values, seed_values,
) )
from fluksio.flow.schemas import FlowDef, FlowInput, NodeDef from fluksio.flow.schemas import FlowDef, FlowInput, NodeDef
from fluksio.flow.store import FlowStore
from fluksio.models import Run, RunArtifact, RunMetric, RunNode from fluksio.models import Run, RunArtifact, RunMetric, RunNode
@@ -492,6 +494,56 @@ def test_the_seed_is_recorded_the_same_way_however_it_arrived():
session.commit() session.commit()
DRAFT_SOURCE = "def process():\n return {'answer': 'draft'}\n"
PUBLISHED_SOURCE = "def process():\n return {'answer': 'published'}\n"
def test_a_draft_run_executes_the_draft(tmp_path):
"""The flag was checked at submit and forgotten by the time it ran.
Which made trying an edit before publishing it impossible from anywhere:
the submit validated the working copy and the driver then executed what
was published, and the two only agree when there is no draft.
"""
flow = FlowDef(
name="study",
mode="batch",
outputs=["answer"],
nodes=[
NodeDef(
id="answer",
provides=[MessageSpec(name="answer", dtype=DType.STR)],
)
],
)
store = FlowStore(tmp_path / "flows")
store.write_flow(flow)
store.write_node_source("study", "answer", PUBLISHED_SOURCE)
store.write_node_source("study", "answer", DRAFT_SOURCE, draft=True)
service = RunService(controller=FlowController(store), queue=_Collect())
made = []
try:
for draft, expected in ((True, "draft"), (False, "published")):
run = service.submit("study", {}, draft=draft)
made.append(run.id)
assert run.draft is draft
service._drive(run.id)
with Session(db_engine) as session:
stored = session.get(Run, run.id)
assert stored.status == "ok", stored.status_reason
assert stored.result == {"answer": expected}
finally:
with Session(db_engine) as session:
for run in session.exec(select(Run).where(col(Run.id).in_(made))).all():
session.delete(run)
for node in session.exec(
select(RunNode).where(col(RunNode.run_id).in_(made))
).all():
session.delete(node)
session.commit()
def test_a_retry_is_a_new_run_that_names_the_one_it_repeats(): def test_a_retry_is_a_new_run_that_names_the_one_it_repeats():
"""The way back from a run an engine restart interrupted. """The way back from a run an engine restart interrupted.
@@ -516,6 +568,7 @@ def test_a_retry_is_a_new_run_that_names_the_one_it_repeats():
status="abandoned", status="abandoned",
params={"lr": 0.3}, params={"lr": 0.3},
group_id="sweep-9", group_id="sweep-9",
draft=True,
created_at=datetime.now(UTC), created_at=datetime.now(UTC),
) )
) )
@@ -537,6 +590,9 @@ def test_a_retry_is_a_new_run_that_names_the_one_it_repeats():
"sweep-9", "sweep-9",
"retry", "retry",
) )
# What it was a run of comes with it: retrying a run of the working
# copy that silently ran the published one would say nothing at all.
assert again.draft is True
assert queue.items[-1].run_id == again.id assert queue.items[-1].run_id == again.id
# A run that has not finished is cancelled, not retried. # A run that has not finished is cancelled, not retried.
+20 -5
View File
@@ -41,6 +41,13 @@ value would be dropped rather than delayed, so submitting is refused instead.
## Submitting ## Submitting
**Runs → New run** in the app is the same call with a form in front of it: pick
a flow, fill in its declared inputs, press Run. A comma-separated list in a
number field runs every combination of them as a sweep. **Use draft** runs the
working copy rather than what is published, which is how a change is tried
before it is deployed, and each run keeps its own state, so nothing it computes
reaches the live flow.
```bash ```bash
curl -X POST $FLUKSIO/runs/flows/train_polymer_gnn \ curl -X POST $FLUKSIO/runs/flows/train_polymer_gnn \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
@@ -54,6 +61,11 @@ result, per-node record and artifacts.
Wrong parameters are refused before anything executes: an undeclared name, or Wrong parameters are refused before anything executes: an undeclared name, or
a value of the wrong type, comes back as a 422 naming the problem. a value of the wrong type, comes back as a 422 naming the problem.
`"draft": true` on the body runs the unpublished working copy — the flow
document and the node sources the editor is showing. The copy is read again
when the run starts, so a draft published while it waited runs as the
published one.
### Sweeps ### Sweeps
An ensemble is the same parameters at different seeds; a grid search is the An ensemble is the same parameters at different seeds; a grid search is the
@@ -313,10 +325,11 @@ that for the runs of a sweep that did not finish.
## Looking at what ran ## Looking at what ran
The **Runs** screen is the experiment log: every run newest-first, filtered by **New run** is where one is started, and the **Runs** screen is the experiment
flow, by status, or down to one sweep. A sweep is worth filtering to, since the log: every run newest-first, filtered by flow, by status, or down to one
table then draws a column per parameter that actually varied, which is what sweep. A sweep is worth filtering to, since the table then draws a column per
makes fifty runs of one flow readable. parameter that actually varied, which is what makes fifty runs of one flow
readable.
Tick two or more and their curves go side by side. Shift-click to take a Tick two or more and their curves go side by side. Shift-click to take a
range, or the header box to take everything on screen. That comparison is the range, or the header box to take everything on screen. That comparison is the
@@ -329,7 +342,9 @@ still lie on top of each other; or against another metric of the same runs (an
epoch, or samples seen) joined on the step the two share. epoch, or samples seen) joined on the step the two share.
One run in full is params, the per-node record with its logs and traceback, One run in full is params, the per-node record with its logs and traceback,
the artifacts it made, its metrics and its result. the artifacts it made, its metrics and its result. **Retry** submits it again
as a run of its own, keeping the flow, the inputs, the seed and the sweep it
belonged to.
### Taking it into a dataframe ### Taking it into a dataframe
+2
View File
@@ -18,6 +18,8 @@ phone the sidebar collapses to a sheet.
| **Home** | the brain graph, health, and everything that recently happened | | **Home** | the brain graph, health, and everything that recently happened |
| **Flows** | the list of flows, and the canvas for each | | **Flows** | the list of flows, and the canvas for each |
| **Dashboards** | the widget canvases, and the panels that display them | | **Dashboards** | the widget canvases, and the panels that display them |
| **Runs** | starting a run with values you choose, and the log of what ran |
| **Workers** | the machines that nodes can be placed on |
| **Secrets** | credentials your nodes reference without holding | | **Secrets** | credentials your nodes reference without holding |
| **Modules** | the Python packages your node code may import | | **Modules** | the Python packages your node code may import |
| **Alerts** | where failures get sent | | **Alerts** | where failures get sent |
+16
View File
@@ -1263,6 +1263,12 @@ export const FlowSummarySchema = {
title: 'Title', title: 'Title',
default: '' default: ''
}, },
mode: {
type: 'string',
enum: ['live', 'batch'],
title: 'Mode',
default: 'live'
},
node_count: { node_count: {
type: 'integer', type: 'integer',
title: 'Node Count', title: 'Node Count',
@@ -2849,6 +2855,11 @@ export const RunDetailSchema = {
], ],
title: 'Group Id' title: 'Group Id'
}, },
draft: {
type: 'boolean',
title: 'Draft',
default: false
},
labels: { labels: {
items: { items: {
type: 'string' type: 'string'
@@ -4123,6 +4134,11 @@ export const fluksio__api__routes__runs__RunRowSchema = {
], ],
title: 'Group Id' title: 'Group Id'
}, },
draft: {
type: 'boolean',
title: 'Draft',
default: false
},
labels: { labels: {
items: { items: {
type: 'string' 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 * 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 * memory twice. Capped, because nothing else here was: any account, and any
* worker credential, could otherwise fill the data volume. * 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 The data for the request.
* @param data.name * @param data.name
* @param data.mediaType * @param data.mediaType
* @param data.volatile
* @returns ArtifactRef Successful Response * @returns ArtifactRef Successful Response
* @throws ApiError * @throws ApiError
*/ */
@@ -135,7 +140,8 @@ export class ArtifactsService {
url: '/api/v1/artifacts', url: '/api/v1/artifacts',
query: { query: {
name: data.name, name: data.name,
media_type: data.mediaType media_type: data.mediaType,
volatile: data.volatile
}, },
errors: { errors: {
422: 'Validation Error' 422: 'Validation Error'
+4
View File
@@ -400,6 +400,7 @@ export type FlowStatePublic = {
export type FlowSummary = { export type FlowSummary = {
name: string; name: string;
title?: string; title?: string;
mode?: 'live' | 'batch';
node_count?: number; node_count?: number;
error_count?: number; error_count?: number;
has_draft?: boolean; has_draft?: boolean;
@@ -470,6 +471,7 @@ export type fluksio__api__routes__runs__RunRow = {
commit?: string; commit?: string;
seed: (number | null); seed: (number | null);
group_id: (string | null); group_id: (string | null);
draft?: boolean;
labels: Array<(string)>; labels: Array<(string)>;
created_at: unknown; created_at: unknown;
started_at?: unknown; started_at?: unknown;
@@ -1018,6 +1020,7 @@ export type RunDetail = {
commit?: string; commit?: string;
seed: (number | null); seed: (number | null);
group_id: (string | null); group_id: (string | null);
draft?: boolean;
labels: Array<(string)>; labels: Array<(string)>;
created_at: unknown; created_at: unknown;
started_at?: unknown; started_at?: unknown;
@@ -1356,6 +1359,7 @@ export type AlertsTestChannelResponse = (Message);
export type ArtifactsPutArtifactData = { export type ArtifactsPutArtifactData = {
mediaType?: string; mediaType?: string;
name?: string; name?: string;
volatile?: boolean;
}; };
export type ArtifactsPutArtifactResponse = (ArtifactRef); export type ArtifactsPutArtifactResponse = (ArtifactRef);
+102 -63
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react" 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 { Button } from "@/components/ui/button"
import { import {
Dialog, Dialog,
@@ -22,6 +22,83 @@ import {
import { parseByDtype } from "./FlowBoundary" import { parseByDtype } from "./FlowBoundary"
import { asText } from "./NodePanel" 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. * The parameters of one run, taken from the flow's inputs.
* *
@@ -42,8 +119,8 @@ export function RunDialog({
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
onRun: (params: Record<string, unknown>) => void onRun: (params: Record<string, unknown>) => void
}) { }) {
const inputs = (definition.inputs ?? []).filter((one) => one.spec?.name) const inputs = declaredInputs(definition)
const [values, setValues] = useState<Record<string, unknown>>({}) const [values, setValues] = useState<Record<string, string>>({})
// Opening is what fills the form: an edit to the flow between two runs // 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 // 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. // biome-ignore lint/correctness/useExhaustiveDependencies: opening is the dependency.
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
setValues( setValues(initialText(inputs))
Object.fromEntries(
inputs.map((one) => [one.spec?.name ?? "", one.initial ?? null]),
),
)
}, [open]) }, [open])
return ( return (
@@ -70,52 +143,7 @@ export function RunDialog({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-3"> <ParamFields inputs={inputs} values={values} onChange={setValues} />
{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>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}> <Button variant="outline" onClick={() => onOpenChange(false)}>
@@ -125,15 +153,7 @@ export function RunDialog({
variant="brand" variant="brand"
disabled={pending} disabled={pending}
data-testid="submit-run" data-testid="submit-run"
onClick={() => onClick={() => onRun(parseParams(inputs, values))}
// 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),
),
)
}
> >
Run Run
</Button> </Button>
@@ -142,3 +162,22 @@ export function RunDialog({
</Dialog> </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 { useQuery } from "@tanstack/react-query"
import { Link } from "@tanstack/react-router" 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 { useState } from "react"
import type { ArtifactRow, RunNodeRow } from "@/client" import type { ArtifactRow, RunNodeRow } from "@/client"
@@ -31,6 +31,7 @@ import {
shortId, shortId,
useCancelRun, useCancelRun,
useFlowInputs, useFlowInputs,
useRetryRun,
} from "./queries" } from "./queries"
import { NodeStatusBadge, RunStatusBadge, statusReason } from "./RunStatus" import { NodeStatusBadge, RunStatusBadge, statusReason } from "./RunStatus"
@@ -39,6 +40,7 @@ const LABEL = "text-muted-foreground text-xs"
export function RunDetail({ id }: { id: string }) { export function RunDetail({ id }: { id: string }) {
const { data: run, isPending } = useQuery(runQueryOptions(id)) const { data: run, isPending } = useQuery(runQueryOptions(id))
const cancel = useCancelRun() const cancel = useCancelRun()
const retry = useRetryRun()
const names = useMetricNames(id) const names = useMetricNames(id)
const declared = useFlowInputs(run?.flow) const declared = useFlowInputs(run?.flow)
const [metric, setMetric] = useState("") const [metric, setMetric] = useState("")
@@ -89,10 +91,15 @@ export function RunDetail({ id }: { id: string }) {
in a sweep in a sweep
</Link> </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"> <div className="ml-auto flex items-center gap-2">
<OpenInDashboard flow={run.flow} ids={[run.id]} /> <OpenInDashboard flow={run.flow} ids={[run.id]} />
{isLive(run.status) && ( {isLive(run.status) ? (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -102,6 +109,18 @@ export function RunDetail({ id }: { id: string }) {
> >
Cancel Cancel
</Button> </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> </div>
</header> </header>
@@ -193,7 +212,7 @@ export function RunDetail({ id }: { id: string }) {
* record like any other, and serialising it onto one truncated line answers * record like any other, and serialising it onto one truncated line answers
* nothing. * nothing.
*/ */
function Entry({ export function Entry({
name, name,
value, value,
note, note,
+67 -17
View File
@@ -4,7 +4,8 @@ import {
useQueryClient, useQueryClient,
} from "@tanstack/react-query" } from "@tanstack/react-query"
import { Link } from "@tanstack/react-router" 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 { useRef, useState } from "react"
import type { fluksio__api__routes__runs__RunRow as RunRow } from "@/client" import type { fluksio__api__routes__runs__RunRow as RunRow } from "@/client"
@@ -153,6 +154,7 @@ export function RunsScreen({
<FlowRail <FlowRail
rows={overview ?? []} rows={overview ?? []}
active={search.flow} active={search.flow}
all="All runs"
onPick={(flow) => onPick={(flow) =>
update({ flow, group: undefined, compare: undefined }) update({ flow, group: undefined, compare: undefined })
} }
@@ -164,6 +166,17 @@ export function RunsScreen({
{search.flow ?? "Runs"} {search.flow ?? "Runs"}
</h1> </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 <Select
value={search.status ?? "all"} value={search.status ?? "all"}
onValueChange={(value) => 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. */ /** The flows that have runs, which is what an experiment log is indexed by. */
function FlowRail({ export function FlowRail({
rows, rows,
active, active,
all,
empty,
onPick, onPick,
}: { }: {
rows: { flow: string; runs: number; running: number; queued: number }[] rows: RailRow[]
active?: string 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 onPick: (flow: string | undefined) => void
}) { }) {
const entry = ( const entry = (
@@ -317,6 +344,7 @@ function FlowRail({
busy: number, busy: number,
isActive: boolean, isActive: boolean,
flow: string | undefined, flow: string | undefined,
note?: string,
) => ( ) => (
<button <button
key={key} key={key}
@@ -330,6 +358,11 @@ function FlowRail({
)} )}
> >
<span className="min-w-0 flex-1 truncate">{label}</span> <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 && ( {busy > 0 && (
<span className="rounded-full bg-primary/15 px-1.5 text-primary text-xs"> <span className="rounded-full bg-primary/15 px-1.5 text-primary text-xs">
{busy} {busy}
@@ -341,14 +374,15 @@ function FlowRail({
return ( return (
<aside className="flex w-full shrink-0 flex-col gap-1 lg:w-56"> <aside className="flex w-full shrink-0 flex-col gap-1 lg:w-56">
{entry( {all &&
"all", entry(
"All runs", "all",
rows.reduce((total, row) => total + row.runs, 0), all,
rows.reduce((total, row) => total + row.running + row.queued, 0), rows.reduce((total, row) => total + row.runs, 0),
!active, rows.reduce((total, row) => total + row.running + row.queued, 0),
undefined, !active,
)} undefined,
)}
{rows.map((row) => {rows.map((row) =>
entry( entry(
row.flow, row.flow,
@@ -357,15 +391,19 @@ function FlowRail({
row.running + row.queued, row.running + row.queued,
active === row.flow, active === row.flow,
row.flow, row.flow,
row.note,
), ),
)} )}
{rows.length === 0 && ( {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" /> <FlaskConical className="mb-1 size-4" />
<br /> {empty ?? (
Nothing has been run yet. Submit a batch flow from its editor, the CLI <p>
or the API and it lands here. Nothing has been run yet. New run is where to start one, and a
</p> run from the CLI or the API lands here too.
</p>
)}
</div>
)} )}
</aside> </aside>
) )
@@ -376,7 +414,14 @@ function ParamValue({ value }: { value: unknown }) {
if (value !== null && typeof value === "object") { if (value !== null && typeof value === "object") {
return <ValuePreview value={value} className="max-w-56" /> 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. */ /** How long ago a moment was, in `dur`'s units so a row's two times agree. */
@@ -498,6 +543,11 @@ function RunsTable({
sweep sweep
</button> </button>
)} )}
{run.draft && (
<span className="rounded-full border border-border px-1.5 text-muted-foreground text-xs">
draft
</span>
)}
</div> </div>
</TableCell> </TableCell>
{showFlow && ( {showFlow && (
+20
View File
@@ -1,4 +1,5 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router"
import { useMemo } from "react" import { useMemo } from "react"
import { OpenAPI, RunsService } from "@/client" 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. * 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 LayoutRunsIndexRouteImport } from './routes/_layout/runs/index'
import { Route as LayoutFlowsIndexRouteImport } from './routes/_layout/flows/index' import { Route as LayoutFlowsIndexRouteImport } from './routes/_layout/flows/index'
import { Route as LayoutDashboardsIndexRouteImport } from './routes/_layout/dashboards/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 LayoutRunsIdRouteImport } from './routes/_layout/runs/$id'
import { Route as CanvasFlowsFlowNameRouteImport } from './routes/_canvas/flows/$flowName' import { Route as CanvasFlowsFlowNameRouteImport } from './routes/_canvas/flows/$flowName'
import { Route as CanvasDashboardsNameRouteImport } from './routes/_canvas/dashboards/$name' import { Route as CanvasDashboardsNameRouteImport } from './routes/_canvas/dashboards/$name'
@@ -131,6 +132,11 @@ const LayoutDashboardsIndexRoute = LayoutDashboardsIndexRouteImport.update({
path: '/dashboards/', path: '/dashboards/',
getParentRoute: () => LayoutRoute, getParentRoute: () => LayoutRoute,
} as any) } as any)
const LayoutRunsNewRoute = LayoutRunsNewRouteImport.update({
id: '/runs/new',
path: '/runs/new',
getParentRoute: () => LayoutRoute,
} as any)
const LayoutRunsIdRoute = LayoutRunsIdRouteImport.update({ const LayoutRunsIdRoute = LayoutRunsIdRouteImport.update({
id: '/runs/$id', id: '/runs/$id',
path: '/runs/$id', path: '/runs/$id',
@@ -166,6 +172,7 @@ export interface FileRoutesByFullPath {
'/dashboards/$name': typeof CanvasDashboardsNameRoute '/dashboards/$name': typeof CanvasDashboardsNameRoute
'/flows/$flowName': typeof CanvasFlowsFlowNameRoute '/flows/$flowName': typeof CanvasFlowsFlowNameRoute
'/runs/$id': typeof LayoutRunsIdRoute '/runs/$id': typeof LayoutRunsIdRoute
'/runs/new': typeof LayoutRunsNewRoute
'/dashboards/': typeof LayoutDashboardsIndexRoute '/dashboards/': typeof LayoutDashboardsIndexRoute
'/flows/': typeof LayoutFlowsIndexRoute '/flows/': typeof LayoutFlowsIndexRoute
'/runs/': typeof LayoutRunsIndexRoute '/runs/': typeof LayoutRunsIndexRoute
@@ -189,6 +196,7 @@ export interface FileRoutesByTo {
'/dashboards/$name': typeof CanvasDashboardsNameRoute '/dashboards/$name': typeof CanvasDashboardsNameRoute
'/flows/$flowName': typeof CanvasFlowsFlowNameRoute '/flows/$flowName': typeof CanvasFlowsFlowNameRoute
'/runs/$id': typeof LayoutRunsIdRoute '/runs/$id': typeof LayoutRunsIdRoute
'/runs/new': typeof LayoutRunsNewRoute
'/dashboards': typeof LayoutDashboardsIndexRoute '/dashboards': typeof LayoutDashboardsIndexRoute
'/flows': typeof LayoutFlowsIndexRoute '/flows': typeof LayoutFlowsIndexRoute
'/runs': typeof LayoutRunsIndexRoute '/runs': typeof LayoutRunsIndexRoute
@@ -215,6 +223,7 @@ export interface FileRoutesById {
'/_canvas/dashboards/$name': typeof CanvasDashboardsNameRoute '/_canvas/dashboards/$name': typeof CanvasDashboardsNameRoute
'/_canvas/flows/$flowName': typeof CanvasFlowsFlowNameRoute '/_canvas/flows/$flowName': typeof CanvasFlowsFlowNameRoute
'/_layout/runs/$id': typeof LayoutRunsIdRoute '/_layout/runs/$id': typeof LayoutRunsIdRoute
'/_layout/runs/new': typeof LayoutRunsNewRoute
'/_layout/dashboards/': typeof LayoutDashboardsIndexRoute '/_layout/dashboards/': typeof LayoutDashboardsIndexRoute
'/_layout/flows/': typeof LayoutFlowsIndexRoute '/_layout/flows/': typeof LayoutFlowsIndexRoute
'/_layout/runs/': typeof LayoutRunsIndexRoute '/_layout/runs/': typeof LayoutRunsIndexRoute
@@ -240,6 +249,7 @@ export interface FileRouteTypes {
| '/dashboards/$name' | '/dashboards/$name'
| '/flows/$flowName' | '/flows/$flowName'
| '/runs/$id' | '/runs/$id'
| '/runs/new'
| '/dashboards/' | '/dashboards/'
| '/flows/' | '/flows/'
| '/runs/' | '/runs/'
@@ -263,6 +273,7 @@ export interface FileRouteTypes {
| '/dashboards/$name' | '/dashboards/$name'
| '/flows/$flowName' | '/flows/$flowName'
| '/runs/$id' | '/runs/$id'
| '/runs/new'
| '/dashboards' | '/dashboards'
| '/flows' | '/flows'
| '/runs' | '/runs'
@@ -288,6 +299,7 @@ export interface FileRouteTypes {
| '/_canvas/dashboards/$name' | '/_canvas/dashboards/$name'
| '/_canvas/flows/$flowName' | '/_canvas/flows/$flowName'
| '/_layout/runs/$id' | '/_layout/runs/$id'
| '/_layout/runs/new'
| '/_layout/dashboards/' | '/_layout/dashboards/'
| '/_layout/flows/' | '/_layout/flows/'
| '/_layout/runs/' | '/_layout/runs/'
@@ -448,6 +460,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LayoutDashboardsIndexRouteImport preLoaderRoute: typeof LayoutDashboardsIndexRouteImport
parentRoute: typeof LayoutRoute 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': { '/_layout/runs/$id': {
id: '/_layout/runs/$id' id: '/_layout/runs/$id'
path: '/runs/$id' path: '/runs/$id'
@@ -494,6 +513,7 @@ interface LayoutRouteChildren {
LayoutWorkersRoute: typeof LayoutWorkersRoute LayoutWorkersRoute: typeof LayoutWorkersRoute
LayoutIndexRoute: typeof LayoutIndexRoute LayoutIndexRoute: typeof LayoutIndexRoute
LayoutRunsIdRoute: typeof LayoutRunsIdRoute LayoutRunsIdRoute: typeof LayoutRunsIdRoute
LayoutRunsNewRoute: typeof LayoutRunsNewRoute
LayoutDashboardsIndexRoute: typeof LayoutDashboardsIndexRoute LayoutDashboardsIndexRoute: typeof LayoutDashboardsIndexRoute
LayoutFlowsIndexRoute: typeof LayoutFlowsIndexRoute LayoutFlowsIndexRoute: typeof LayoutFlowsIndexRoute
LayoutRunsIndexRoute: typeof LayoutRunsIndexRoute LayoutRunsIndexRoute: typeof LayoutRunsIndexRoute
@@ -508,6 +528,7 @@ const LayoutRouteChildren: LayoutRouteChildren = {
LayoutWorkersRoute: LayoutWorkersRoute, LayoutWorkersRoute: LayoutWorkersRoute,
LayoutIndexRoute: LayoutIndexRoute, LayoutIndexRoute: LayoutIndexRoute,
LayoutRunsIdRoute: LayoutRunsIdRoute, LayoutRunsIdRoute: LayoutRunsIdRoute,
LayoutRunsNewRoute: LayoutRunsNewRoute,
LayoutDashboardsIndexRoute: LayoutDashboardsIndexRoute, LayoutDashboardsIndexRoute: LayoutDashboardsIndexRoute,
LayoutFlowsIndexRoute: LayoutFlowsIndexRoute, LayoutFlowsIndexRoute: LayoutFlowsIndexRoute,
LayoutRunsIndexRoute: LayoutRunsIndexRoute, 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 }) 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)
})