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.
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,14 +374,15 @@ function FlowRail({
|
||||
|
||||
return (
|
||||
<aside className="flex w-full shrink-0 flex-col gap-1 lg:w-56">
|
||||
{entry(
|
||||
"all",
|
||||
"All runs",
|
||||
rows.reduce((total, row) => total + row.runs, 0),
|
||||
rows.reduce((total, row) => total + row.running + row.queued, 0),
|
||||
!active,
|
||||
undefined,
|
||||
)}
|
||||
{all &&
|
||||
entry(
|
||||
"all",
|
||||
all,
|
||||
rows.reduce((total, row) => total + row.runs, 0),
|
||||
rows.reduce((total, row) => total + row.running + row.queued, 0),
|
||||
!active,
|
||||
undefined,
|
||||
)}
|
||||
{rows.map((row) =>
|
||||
entry(
|
||||
row.flow,
|
||||
@@ -357,15 +391,19 @@ function FlowRail({
|
||||
row.running + row.queued,
|
||||
active === row.flow,
|
||||
row.flow,
|
||||
row.note,
|
||||
),
|
||||
)}
|
||||
{rows.length === 0 && (
|
||||
<p className="px-2 py-4 text-muted-foreground text-sm">
|
||||
<div className="px-2 py-4 text-muted-foreground text-sm">
|
||||
<FlaskConical className="mb-1 size-4" />
|
||||
<br />
|
||||
Nothing has been run yet. Submit a batch flow from its editor, the CLI
|
||||
or the API and it lands here.
|
||||
</p>
|
||||
{empty ?? (
|
||||
<p>
|
||||
Nothing has been run yet. New run is where to start one, and a
|
||||
run from the CLI or the API lands here too.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
@@ -376,7 +414,14 @@ function ParamValue({ value }: { value: unknown }) {
|
||||
if (value !== null && typeof value === "object") {
|
||||
return <ValuePreview value={value} className="max-w-56" />
|
||||
}
|
||||
return <span className="font-mono text-xs">{paramText(value)}</span>
|
||||
// Cut to the column rather than widening it: a long value is worth reading
|
||||
// in full on the run itself, and a table nobody can scan is worth less.
|
||||
const text = paramText(value)
|
||||
return (
|
||||
<span className="block max-w-56 truncate font-mono text-xs" title={text}>
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** How long ago a moment was, in `dur`'s units so a row's two times agree. */
|
||||
@@ -498,6 +543,11 @@ function RunsTable({
|
||||
sweep
|
||||
</button>
|
||||
)}
|
||||
{run.draft && (
|
||||
<span className="rounded-full border border-border px-1.5 text-muted-foreground text-xs">
|
||||
draft
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
{showFlow && (
|
||||
|
||||
@@ -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