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:
@@ -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")
|
||||
@@ -303,6 +303,7 @@ def read_flows(controller: FlowControllerDep) -> Any:
|
||||
FlowSummary(
|
||||
name=definition.name,
|
||||
title=definition.title,
|
||||
mode=definition.mode,
|
||||
node_count=len(definition.nodes),
|
||||
error_count=sum(1 for s in statuses if s.status == "error"),
|
||||
has_draft=controller.store.has_draft(name),
|
||||
|
||||
@@ -134,6 +134,8 @@ class RunRow(BaseModel):
|
||||
commit: str = ""
|
||||
seed: int | None
|
||||
group_id: str | None
|
||||
#: Whether it ran the working copy rather than what is published.
|
||||
draft: bool = False
|
||||
labels: list[str]
|
||||
created_at: Any
|
||||
started_at: Any = None
|
||||
|
||||
@@ -906,6 +906,7 @@ class RunService:
|
||||
group_id=group_id,
|
||||
cause=cause,
|
||||
no_cache=no_cache,
|
||||
draft=draft,
|
||||
status="queued",
|
||||
labels=required_labels(flow),
|
||||
needs=required_resources(flow),
|
||||
@@ -973,6 +974,10 @@ class RunService:
|
||||
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
|
||||
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:
|
||||
run = session.get(Run, run_id)
|
||||
@@ -989,6 +994,7 @@ class RunService:
|
||||
cause="retry",
|
||||
actor=actor,
|
||||
no_cache=source.no_cache,
|
||||
draft=source.draft,
|
||||
parent_id=source.id,
|
||||
)
|
||||
|
||||
@@ -1203,17 +1209,20 @@ class RunService:
|
||||
|
||||
sink = MetricSink(run_id)
|
||||
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
|
||||
# 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
|
||||
# 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)
|
||||
run.code_digest = self._restamp(run, run_digest(flow))
|
||||
state = self._state_factory(f"{RUN_NAMESPACE}:{run_id}")
|
||||
pipeline = self.controller.build_run_pipeline(
|
||||
flow,
|
||||
state=state,
|
||||
draft=run.draft,
|
||||
observer=observe,
|
||||
emission_observer=sink.handle,
|
||||
run=RunContext(
|
||||
|
||||
@@ -355,6 +355,8 @@ class MessageHistory(BaseModel):
|
||||
class FlowSummary(BaseModel):
|
||||
name: str
|
||||
title: str = ""
|
||||
#: Whether running it means one finite execution or leaving it running.
|
||||
mode: Literal["live", "batch"] = "live"
|
||||
node_count: int = 0
|
||||
error_count: int = 0
|
||||
has_draft: bool = False
|
||||
|
||||
@@ -374,6 +374,9 @@ class Run(SQLModel, table=True):
|
||||
cause: str = Field(default="api", max_length=32)
|
||||
#: Re-execute every node, whatever the stage cache holds for it.
|
||||
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.
|
||||
status: str = Field(default="queued", index=True, max_length=16)
|
||||
#: Why it is where it is: what it waits for, or what went wrong.
|
||||
|
||||
@@ -14,6 +14,7 @@ from sqlmodel import Session, col, select
|
||||
from fluksio.core.config import settings
|
||||
from fluksio.core.db import engine as db_engine
|
||||
from fluksio.flow.artifacts import ArtifactStore
|
||||
from fluksio.flow.controller import FlowController
|
||||
from fluksio.flow.messages import DType, MessageSpec
|
||||
from fluksio.flow.pipeline import NodeOutcome
|
||||
from fluksio.flow.runs import (
|
||||
@@ -27,6 +28,7 @@ from fluksio.flow.runs import (
|
||||
seed_values,
|
||||
)
|
||||
from fluksio.flow.schemas import FlowDef, FlowInput, NodeDef
|
||||
from fluksio.flow.store import FlowStore
|
||||
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()
|
||||
|
||||
|
||||
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():
|
||||
"""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",
|
||||
params={"lr": 0.3},
|
||||
group_id="sweep-9",
|
||||
draft=True,
|
||||
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",
|
||||
"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
|
||||
|
||||
# A run that has not finished is cancelled, not retried.
|
||||
|
||||
+20
-5
@@ -41,6 +41,13 @@ value would be dropped rather than delayed, so submitting is refused instead.
|
||||
|
||||
## 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
|
||||
curl -X POST $FLUKSIO/runs/flows/train_polymer_gnn \
|
||||
-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
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
The **Runs** screen is the experiment log: every run newest-first, filtered by
|
||||
flow, by status, or down to one sweep. A sweep is worth filtering to, since the
|
||||
table then draws a column per parameter that actually varied, which is what
|
||||
makes fifty runs of one flow readable.
|
||||
**New run** is where one is started, and the **Runs** screen is the experiment
|
||||
log: every run newest-first, filtered by flow, by status, or down to one
|
||||
sweep. A sweep is worth filtering to, since the table then draws a column per
|
||||
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
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ phone the sidebar collapses to a sheet.
|
||||
| **Home** | the brain graph, health, and everything that recently happened |
|
||||
| **Flows** | the list of flows, and the canvas for each |
|
||||
| **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 |
|
||||
| **Modules** | the Python packages your node code may import |
|
||||
| **Alerts** | where failures get sent |
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,9 +374,10 @@ function FlowRail({
|
||||
|
||||
return (
|
||||
<aside className="flex w-full shrink-0 flex-col gap-1 lg:w-56">
|
||||
{entry(
|
||||
{all &&
|
||||
entry(
|
||||
"all",
|
||||
"All runs",
|
||||
all,
|
||||
rows.reduce((total, row) => total + row.runs, 0),
|
||||
rows.reduce((total, row) => total + row.running + row.queued, 0),
|
||||
!active,
|
||||
@@ -357,16 +391,20 @@ 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.
|
||||
{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 && (
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user