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