Flow inputs and outputs are visible and editable in the UI

A flow's inputs are the messages it takes from outside — a dashboard control,
a run, the API — and its outputs are what a batch run reports. Both existed in
the document and in the engine, and neither had any UI: the values looked
hard-coded on the canvas and the Run button always used the declared defaults.

The canvas now draws each as a labelled endpoint, the way it already draws a
dashboard tile or another flow, skipping an input something else already
accounts for. The flow panel edits them — mode, name, type, starting value,
and for a live flow the value it currently holds with a way to put a new one
in. Pressing Run on a batch flow asks for its parameters first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUb8YpL2s3gmN9WTACTt4q
This commit is contained in:
2026-08-20 18:10:16 +02:00
co-authored by Claude Opus 5
parent 3508713e85
commit 2abeae7f9c
10 changed files with 644 additions and 22 deletions
+3 -1
View File
@@ -16,6 +16,8 @@ Deferring because out of scope is fine, but don't mention deferring than.
- FEAT/UI add animation to widgets; i.e. status of bars, gauges etc. should fade from one state to another. Multi-buttons (like "Mode" in the "Home" dashboard of the demo) should transition from one state to another; use inspiration for animations based on the google material guidelines - FEAT/UI add animation to widgets; i.e. status of bars, gauges etc. should fade from one state to another. Multi-buttons (like "Mode" in the "Home" dashboard of the demo) should transition from one state to another; use inspiration for animations based on the google material guidelines
- BUG when clicking "edit" in the "Home" dashboard of the demo on hub.fluksio.com, most of the panels disappear (only a handfull is left for actual edit) - BUG when clicking "edit" in the "Home" dashboard of the demo on hub.fluksio.com, most of the panels disappear (only a handfull is left for actual edit)
- CHORE/UI: the edge popover shows the same value twice — `MessageSparkline` falls through to a collapsed `ValuePreview` for a non-numeric value, and `EdgeInspector` then renders its own `ValuePreview defaultOpen` below it. Cosmetic; one of the two is redundant. - CHORE/UI: the edge popover shows the same value twice — `MessageSparkline` falls through to a collapsed `ValuePreview` for a non-numeric value, and `EdgeInspector` then renders its own `ValuePreview defaultOpen` below it. Cosmetic; one of the two is redundant.
- FEAT/UI: a settings-and-inputs overview page, so what every node of an installation is configured with can be read and searched in one place rather than one panel at a time.
- FEAT/UI: an input endpoint opens the flow panel, which is right for editing but not for reading one value. A panel of its own — the declaration, the current value, its history — is what clicking a label wants to give.
- FEAT/UI we should introduce a sync between the header of the python function and the node configuration; i.e. adding an input/output or static paramter would change the header of the python function and changing the python function header and vice versa - FEAT/UI we should introduce a sync between the header of the python function and the node configuration; i.e. adding an input/output or static paramter would change the header of the python function and changing the python function header and vice versa
- BUG/UI on flows like "House history" where the widget sets the range for the "draw the window" node to generate some data, the edges overlap the nodes. We should adjust the flow visualization to account for these cyclic behaviors - BUG/UI on flows like "House history" where the widget sets the range for the "draw the window" node to generate some data, the edges overlap the nodes. We should adjust the flow visualization to account for these cyclic behaviors
- BUG/UI when enlarging the code editor of a node, the code editor should enlarge to the left (node settings remain on the right) so that the code editor fills the center of the screen with the node properties available next to it - BUG/UI when enlarging the code editor of a node, the code editor should enlarge to the left (node settings remain on the right) so that the code editor fills the center of the screen with the node properties available next to it
@@ -210,7 +212,7 @@ Open on purpose. Each names what should bring it back.
- FEAT/RUNS: stage caching. `run_node.cache_key` is written on every run and the artifact store is content-addressed, so the pieces are in place; what is missing is computing the key from the node's source digest plus its input values and skipping a node whose key already has an `ok` row with its artifacts still present. The two research repos want this more than they want resume — neither persists checkpoints, and both re-run unchanged preprocessing every time. - FEAT/RUNS: stage caching. `run_node.cache_key` is written on every run and the artifact store is content-addressed, so the pieces are in place; what is missing is computing the key from the node's source digest plus its input values and skipping a node whose key already has an `ok` row with its artifacts still present. The two research repos want this more than they want resume — neither persists checkpoints, and both re-run unchanged preprocessing every time.
- FEAT/RUNS: per-label requirements overlays (`requirements-gpu.txt`) synced into a remote worker's venv, with drift surfaced against the engine's manifest. Today a worker's environment is whatever `--python` points at, which is fine for one hand-managed GPU box and not for several. `venv_digest` already arrives at attach and is shown on `/workers`, so the reporting half exists. - FEAT/RUNS: per-label requirements overlays (`requirements-gpu.txt`) synced into a remote worker's venv, with drift surfaced against the engine's manifest. Today a worker's environment is whatever `--python` points at, which is fine for one hand-managed GPU box and not for several. `venv_digest` already arrives at attach and is shown on `/workers`, so the reporting half exists.
- FEAT/UI: a dashboard shows a run's curve only while it is running. Emissions reach the socket live, but a run's values live in its own state namespace, so reloading the panel afterwards leaves the chart empty — the durable series is on the run (`/runs/{id}/metrics`) and nothing binds a widget to it. A chart variant that reads a run's series, or the existing querying chart pointed at `/runs/series/compare`, is what would close it. This is also what a demo needs to show a finished experiment rather than only a live one. - FEAT/UI: a dashboard shows a run's curve only while it is running. Emissions reach the socket live, but a run's values live in its own state namespace, so reloading the panel afterwards leaves the chart empty — the durable series is on the run (`/runs/{id}/metrics`) and nothing binds a widget to it. A chart variant that reads a run's series, or the existing querying chart pointed at `/runs/series/compare`, is what would close it. This is also what a demo needs to show a finished experiment rather than only a live one.
- FEAT/UI: nothing submits a run with parameters from the UI. Pressing Run on a batch flow submits one with the declared defaults, which is enough to try it; choosing parameters, or launching a sweep, is API-only. A form generated from the flow's `inputs` is the obvious shape. - FEAT/UI: launching a sweep is API-only. Pressing Run on a batch flow asks for its parameters, but the many-runs-at-once shape has no UI; a run detail screen is what it wants to land next to.
- FEAT/RUNS: a run detail screen. The API answers everything — params, per-node status with logs and tracebacks, artifacts, metrics, and `/runs/series/compare` in the chart widget's own `series` shape — but nothing in the dashboard reads it yet, so a run is inspected over HTTP. Comparing curves is a widget binding once someone builds the page around it. - FEAT/RUNS: a run detail screen. The API answers everything — params, per-node status with logs and tracebacks, artifacts, metrics, and `/runs/series/compare` in the chart widget's own `series` shape — but nothing in the dashboard reads it yet, so a run is inspected over HTTP. Comparing curves is a widget binding once someone builds the page around it.
- FEAT/RUNS: a thin client CLI (`fluksio run/runs/sweep/worker`) over the same API. The engine being resident is what makes runs cheap; a CLI is ergonomics on top, and `curl` covers it until someone is running sweeps daily. - FEAT/RUNS: a thin client CLI (`fluksio run/runs/sweep/worker`) over the same API. The engine being resident is what makes runs cheap; a CLI is ergonomics on top, and `curl` covers it until someone is running sweeps daily.
- FEAT/RUNS: the step on a run's series is the count of emissions on that message, so a node yielding every tenth training step records steps 0, 1, 2 rather than 0, 10, 20 — a faithful x-axis of its own emissions, not of the loop inside it. If a real step number ever matters, a `record`-typed streaming port carrying its own `step` is the shape to read it from; the column is already there. - FEAT/RUNS: the step on a run's series is the count of emissions on that message, so a node yielding every tenth training step records steps 0, 1, 2 rather than 0, 10, 20 — a faithful x-axis of its own emissions, not of the loop inside it. If a real step number ever matters, a `record`-typed streaming port carrying its own `step` is the shape to read it from; the column is already there.
@@ -1,5 +1,5 @@
import { Handle, type NodeProps, Position } from "@xyflow/react" import { Handle, type NodeProps, Position } from "@xyflow/react"
import { LayoutDashboard, Workflow } from "lucide-react" import { LayoutDashboard, LogIn, LogOut, Workflow } from "lucide-react"
import { memo } from "react" import { memo } from "react"
import { useIsMobile } from "@/hooks/useMobile" import { useIsMobile } from "@/hooks/useMobile"
@@ -9,6 +9,8 @@ import type { EndpointNodeData } from "./endpoints"
const KIND_ICONS = { const KIND_ICONS = {
dashboard: LayoutDashboard, dashboard: LayoutDashboard,
flow: Workflow, flow: Workflow,
input: LogIn,
output: LogOut,
} as const } as const
/** /**
@@ -0,0 +1,341 @@
import { useMutation } from "@tanstack/react-query"
import { ArrowUpFromLine, Plus, X } from "lucide-react"
import type { DType, FlowDef_Input, FlowInput_Input } from "@/client"
import { MessagesService } from "@/client"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { qualify } from "./deriveEdges"
import { useLiveValue } from "./liveStore"
import { DTYPES } from "./NodePanel"
import { PANEL_SECTION } from "./SidePanel"
import { ValuePreview } from "./ValuePreview"
/**
* A typed literal, read back from what was typed.
*
* Half-finished input is normal while editing — "-", "1.", a JSON object with
* one brace — so anything that does not parse yet is kept as text rather than
* replaced with a zero the person did not type.
*/
export function parseByDtype(dtype: DType | undefined, raw: string): unknown {
if (raw === "") return null
if (dtype === "int" || dtype === "float") {
const parsed = Number(raw)
return Number.isNaN(parsed) ? raw : parsed
}
if (dtype === "bool") return raw === "true"
if (dtype === "str") return raw
try {
return JSON.parse(raw)
} catch {
return raw
}
}
/** The same value as something a text field can hold. */
export function asText(value: unknown): string {
if (value === null || value === undefined) return ""
if (typeof value === "object") return JSON.stringify(value)
return String(value)
}
/** Putting a declared value into the running graph, credited to the input. */
function usePublishInput() {
return useMutation({
mutationFn: ({ message, value }: { message: string; value: unknown }) =>
MessagesService.publishMessage({
name: message,
requestBody: {
value,
source_kind: "flow",
// Matches the endpoint id the canvas builds, so that label pulses.
source_id: `input:${message}`,
source_label: message,
source_detail: "input",
},
}),
})
}
function InputRow({
flow,
declared,
live,
onChange,
onRemove,
}: {
flow: string
declared: FlowInput_Input
/** Whether the engine is running this flow, so a value can be put into it. */
live: boolean
onChange: (next: FlowInput_Input) => void
onRemove: () => void
}) {
const spec = declared.spec ?? {}
const name = spec.name ?? ""
const message = qualify(flow, name)
const current = useLiveValue(message)
const publish = usePublishInput()
return (
<div className="grid gap-1.5">
<div className="flex items-center gap-1.5">
<Input
defaultValue={name}
placeholder="name"
aria-label="Input name"
className="h-8 flex-1 text-sm"
onBlur={(event) =>
onChange({
...declared,
spec: { ...spec, name: event.target.value.trim() },
})
}
/>
<Select
value={spec.dtype ?? "float"}
onValueChange={(next) =>
onChange({ ...declared, spec: { ...spec, dtype: next as DType } })
}
>
<SelectTrigger className="!h-8 w-[86px] text-sm" aria-label="Type">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DTYPES.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
{spec.dtype === "bool" ? (
<Select
value={declared.initial === true ? "true" : "false"}
onValueChange={(next) =>
onChange({ ...declared, initial: next === "true" })
}
>
<SelectTrigger
className="!h-8 flex-1 text-sm"
aria-label="Starting value"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">true</SelectItem>
<SelectItem value="false">false</SelectItem>
</SelectContent>
</Select>
) : (
<Input
value={asText(declared.initial)}
placeholder="starts at"
aria-label="Starting value"
className="h-8 flex-1 text-sm"
onChange={(event) =>
onChange({
...declared,
initial: parseByDtype(spec.dtype, event.target.value),
})
}
/>
)}
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="Remove input"
onClick={onRemove}
>
<X />
</Button>
</div>
{live && name ? (
<div className="flex items-center gap-2 pl-1 text-xs text-muted-foreground">
<span className="shrink-0">now</span>
{current === undefined ? (
<span className="flex-1"></span>
) : (
<span className="min-w-0 flex-1">
<ValuePreview value={current.value} dtype={spec.dtype} />
</span>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
aria-label="Publish this value now"
disabled={publish.isPending || declared.initial === null}
onClick={() =>
publish.mutate({ message, value: declared.initial })
}
>
<ArrowUpFromLine />
</Button>
</TooltipTrigger>
<TooltipContent>Put this value into the flow now</TooltipContent>
</Tooltip>
</div>
) : null}
</div>
)
}
/**
* What the flow takes from outside, and what a run of it reports.
*
* An input is a message no node here computes — a control writes it, a run
* passes it in, an agent publishes it — so the flow declares it and says what
* it starts from. Without that the node reading it waits for something nothing
* provides, which is what the canvas reports.
*/
export function BoundarySections({
flow,
definition,
running,
onChange,
}: {
flow: string
definition: FlowDef_Input
/** Whether the engine is running this flow, so it holds values worth showing. */
running: boolean
onChange: (next: FlowDef_Input) => void
}) {
const inputs = definition.inputs ?? []
const batch = definition.mode === "batch"
const outputs = definition.outputs ?? []
// A run reads its parameters into a namespace of its own, so what the engine
// holds for a batch flow is not what any run of it saw.
const live = running && !batch
// Everything the flow computes, which is what a result can be made of.
const provided = [
...new Set(
(definition.nodes ?? []).flatMap((node) =>
(node.provides ?? []).map((spec) => spec.name ?? "").filter(Boolean),
),
),
].sort()
const setInputs = (next: FlowInput_Input[]) =>
onChange({ ...definition, inputs: next })
return (
<>
<div className="grid gap-3">
<span className={PANEL_SECTION}>Mode</span>
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">
{batch
? "Runs when a run asks it to, from its inputs to its outputs. Nothing here is subscribed or scheduled."
: "Runs continuously: subscriptions, schedules and webhooks are live."}
</p>
<Select
value={definition.mode ?? "live"}
onValueChange={(next) =>
onChange({ ...definition, mode: next as "live" | "batch" })
}
>
<SelectTrigger className="!h-8 w-[92px] text-sm" aria-label="Mode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="live">live</SelectItem>
<SelectItem value="batch">batch</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-3">
<div className="flex items-center justify-between">
<span className={PANEL_SECTION}>Inputs</span>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-muted-foreground"
data-testid="add-flow-input"
onClick={() =>
setInputs([...inputs, { spec: { name: "", dtype: "float" } }])
}
>
<Plus />
Add
</Button>
</div>
{inputs.length === 0 ? (
<p className="text-sm text-muted-foreground">
Messages that arrive from outside a dashboard control, a run, the
API and the value the flow starts from.
</p>
) : null}
{inputs.map((declared, index) => (
// Keyed by position: renaming an input must not remount its row.
<InputRow
key={`input-${index}`}
flow={flow}
declared={declared}
live={live}
onChange={(next) =>
setInputs(inputs.map((one, at) => (at === index ? next : one)))
}
onRemove={() =>
setInputs(inputs.filter((_one, at) => at !== index))
}
/>
))}
</div>
{batch ? (
<div className="grid gap-3">
<span className={PANEL_SECTION}>Result</span>
<p className="text-sm text-muted-foreground">
{provided.length === 0
? "Nothing is produced here yet, so a run has nothing to report."
: "What a run reports when it finishes. Everything else it computed goes with it."}
</p>
{provided.map((name) => (
<label
key={name}
htmlFor={`output-${name}`}
className="flex items-center gap-2 font-mono text-sm"
>
<Checkbox
id={`output-${name}`}
checked={outputs.includes(name)}
onCheckedChange={(checked) =>
onChange({
...definition,
outputs: checked
? [...outputs, name]
: outputs.filter((one) => one !== name),
})
}
/>
{name}
</label>
))}
</div>
) : null}
</>
)
}
+52 -13
View File
@@ -47,7 +47,13 @@ import { inCodeEditor, useShortcuts } from "@/lib/shortcuts"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { CanvasTitle } from "./CanvasTitle" import { CanvasTitle } from "./CanvasTitle"
import { CommandPalette } from "./CommandPalette" import { CommandPalette } from "./CommandPalette"
import { bindingsKey, deriveEdges, portOf, qualify } from "./deriveEdges" import {
bindingsKey,
boundaryKey,
deriveEdges,
portOf,
qualify,
} from "./deriveEdges"
import { EdgeInspector, type InspectedEdge } from "./EdgeInspector" import { EdgeInspector, type InspectedEdge } from "./EdgeInspector"
import { EndpointNode } from "./EndpointNode" import { EndpointNode } from "./EndpointNode"
import { deriveEndpoints, ENDPOINT_TYPE, isEndpointNode } from "./endpoints" import { deriveEndpoints, ENDPOINT_TYPE, isEndpointNode } from "./endpoints"
@@ -57,6 +63,7 @@ import { FlowPanel } from "./FlowPanel"
import { LiveEdge } from "./LiveEdge" import { LiveEdge } from "./LiveEdge"
import { type Direction, layoutGraph } from "./layout" import { type Direction, layoutGraph } from "./layout"
import { NodePanel } from "./NodePanel" import { NodePanel } from "./NodePanel"
import { RunDialog } from "./RunDialog"
import "./flow.css" import "./flow.css"
import { liveStore, useFlowPaused } from "./liveStore" import { liveStore, useFlowPaused } from "./liveStore"
import { import {
@@ -228,6 +235,8 @@ function FlowEditorInner({
const [rebind, setRebind] = useState<Rebind | null>(null) const [rebind, setRebind] = useState<Rebind | null>(null)
const [renamed, setRenamed] = useState<MessageRename | null>(null) const [renamed, setRenamed] = useState<MessageRename | null>(null)
const [flowPanelOpen, setFlowPanelOpen] = useState(false) const [flowPanelOpen, setFlowPanelOpen] = useState(false)
// A batch run is asked for its parameters before it is submitted.
const [runOpen, setRunOpen] = useState(false)
// Throwing an edit away is offered from the dock, so its confirmation lives // Throwing an edit away is offered from the dock, so its confirmation lives
// here rather than inside the settings panel. // here rather than inside the settings panel.
const [discardOpen, setDiscardOpen] = useState(false) const [discardOpen, setDiscardOpen] = useState(false)
@@ -368,8 +377,9 @@ function FlowEditorInner({
) )
// A cheap fingerprint of the wiring, and the only thing the layout depends // A cheap fingerprint of the wiring, and the only thing the layout depends
// on: what the graph looks like follows from what is wired to what. // on: what the graph looks like follows from what is wired to what. The
const key = bindingsKey(definitions) // flow's own boundary is drawn too, so a declared input counts as wiring.
const key = `${bindingsKey(definitions)}|${boundaryKey(flowDoc)}`
// Offer the names already in play: everything published is worth reading, // Offer the names already in play: everything published is worth reading,
// and an input nobody provides yet is worth publishing. // and an input nobody provides yet is worth publishing.
@@ -395,7 +405,11 @@ function FlowEditorInner({
const openEndpoint = useCallback( const openEndpoint = useCallback(
(id: string) => { (id: string) => {
const [kind, rest] = id.split(":", 2) const [kind, rest] = id.split(":", 2)
if (kind === "dashboard") { if (kind === "input" || kind === "output") {
// This flow's own boundary, declared in its settings.
setSelectedId(null)
setFlowPanelOpen(true)
} else if (kind === "dashboard") {
navigate({ navigate({
to: "/dashboards/$name", to: "/dashboards/$name",
params: { name: (rest ?? "").split(":")[0] }, params: { name: (rest ?? "").split(":")[0] },
@@ -448,7 +462,12 @@ function FlowEditorInner({
// canvasNodes, so an autosave, an undo or a delete cannot reach them. // canvasNodes, so an autosave, an undo or a delete cannot reach them.
// biome-ignore lint/correctness/useExhaustiveDependencies: the key covers the wiring, which is all these depend on. // biome-ignore lint/correctness/useExhaustiveDependencies: the key covers the wiring, which is all these depend on.
const external = useMemo(() => { const external = useMemo(() => {
const built = deriveEndpoints(detail.endpoints ?? [], definitions, flowName) const built = deriveEndpoints(
detail.endpoints ?? [],
definitions,
flowName,
flowDoc,
)
return { return {
...built, ...built,
nodes: built.nodes.map((node) => { nodes: built.nodes.map((node) => {
@@ -552,8 +571,8 @@ function FlowEditorInner({
}, [direction, definitions.length, external.nodes.length, fitView]) }, [direction, definitions.length, external.nodes.length, fitView])
const runMutation = useMutation({ const runMutation = useMutation({
mutationFn: () => mutationFn: (inputs: Record<string, unknown> = {}) =>
FlowsService.runFlow({ name: flowName, requestBody: { inputs: {} } }), FlowsService.runFlow({ name: flowName, requestBody: { inputs } }),
onSuccess: (state) => { onSuccess: (state) => {
liveStore.setValues( liveStore.setValues(
Object.fromEntries( Object.fromEntries(
@@ -568,6 +587,19 @@ function FlowEditorInner({
showErrorToast("The flow could not run. Check the node errors."), showErrorToast("The flow could not run. Check the node errors."),
}) })
/**
* Pressing Run.
*
* A batch flow is submitted as a run, and a run is identified by its
* parameters — so it asks for them rather than quietly using the defaults.
*/
const startRun = useCallback(async () => {
// Running executes what is stored, so the queued edit goes first.
await flush()
if (latest.current.mode === "batch") setRunOpen(true)
else runMutation.mutate({})
}, [flush, runMutation])
const enableMutation = useMutation({ const enableMutation = useMutation({
mutationFn: (next: boolean) => mutationFn: (next: boolean) =>
next next
@@ -1044,11 +1076,7 @@ function FlowEditorInner({
onClearNode: () => setLogsNode(null), onClearNode: () => setLogsNode(null),
}} }}
onAddNode={() => setPaletteOpen(true)} onAddNode={() => setPaletteOpen(true)}
onRun={async () => { onRun={startRun}
// Running executes what is stored, so the queued edit goes first.
await flush()
runMutation.mutate()
}}
onTogglePause={() => pauseMutation.mutate(!paused)} onTogglePause={() => pauseMutation.mutate(!paused)}
onStep={() => stepMutation.mutate()} onStep={() => stepMutation.mutate()}
stepping={stepMutation.isPending} stepping={stepMutation.isPending}
@@ -1125,7 +1153,18 @@ function FlowEditorInner({
flows={flows.data} flows={flows.data}
onAddNode={addNode} onAddNode={addNode}
onAddSharedNode={(libName) => addNode("python", libName)} onAddSharedNode={(libName) => addNode("python", libName)}
onRun={() => runMutation.mutate()} onRun={startRun}
/>
<RunDialog
open={runOpen}
definition={flowDoc}
pending={runMutation.isPending}
onOpenChange={setRunOpen}
onRun={(params) => {
setRunOpen(false)
runMutation.mutate(params)
}}
/> />
<Dialog <Dialog
@@ -11,6 +11,7 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { BoundarySections } from "./FlowBoundary"
import { PANEL_SECTION, PanelTitle, SidePanel } from "./SidePanel" import { PANEL_SECTION, PanelTitle, SidePanel } from "./SidePanel"
/** /**
@@ -89,6 +90,13 @@ export function FlowPanel({
</div> </div>
</div> </div>
<BoundarySections
flow={definition.name}
definition={definition}
running={enabled}
onChange={onChange}
/>
<div className="grid gap-3"> <div className="grid gap-3">
<span className={PANEL_SECTION}>Contents</span> <span className={PANEL_SECTION}>Contents</span>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
+1 -1
View File
@@ -50,7 +50,7 @@ import { PANEL_SECTION, PanelTitle, SidePanel } from "./SidePanel"
const NodeEditor = lazy(() => import("./NodeEditor")) const NodeEditor = lazy(() => import("./NodeEditor"))
const DTYPES: DType[] = [ export const DTYPES: DType[] = [
"float", "float",
"int", "int",
"str", "str",
+139
View File
@@ -0,0 +1,139 @@
import { useEffect, useState } from "react"
import type { FlowDef_Input } from "@/client"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { asText, parseByDtype } from "./FlowBoundary"
/**
* The parameters of one run, taken from the flow's inputs.
*
* A batch flow's inputs *are* its parameters — a run supplies values for them
* and keeps whatever it does not name — so this is a form over the same
* declarations the flow panel edits, prefilled with what they start from.
*/
export function RunDialog({
open,
definition,
pending,
onOpenChange,
onRun,
}: {
open: boolean
definition: FlowDef_Input
pending: boolean
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>>({})
// 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
// are read then rather than watched, so typing over one is not undone.
// 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]),
),
)
}, [open])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent data-testid="run-dialog">
<DialogHeader>
<DialogTitle>Run {definition.title || definition.name}</DialogTitle>
<DialogDescription>
{inputs.length === 0
? "This flow declares no parameters, so there is nothing to choose."
: "The run starts from these. Everything it computes is dropped afterwards except its result."}
</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 ?? "float"}
className="text-sm"
onChange={(event) =>
setValues({
...values,
[name]: parseByDtype(dtype, event.target.value),
})
}
/>
)}
</div>
)
})}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
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),
),
)
}
>
Run
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -85,6 +85,21 @@ export function deriveEdges(nodes: NodeDef_Input[], flow: string): Edge[] {
return edges return edges
} }
/**
* A cheap fingerprint of the flow's boundary — what it takes from outside and,
* for a batch flow, what it reports — since those are drawn like wiring too.
*/
export function boundaryKey(doc: {
mode?: string | null
inputs?: { spec?: MessageSpec | null }[] | null
outputs?: string[] | null
}): string {
const inputs = (doc.inputs ?? [])
.map((one) => `${one.spec?.name ?? ""}:${one.spec?.dtype ?? ""}`)
.join(",")
return `${doc.mode ?? "live"}|${inputs}|${(doc.outputs ?? []).join(",")}`
}
/** /**
* A cheap fingerprint of everything edges depend on, so dragging a node (which * A cheap fingerprint of everything edges depend on, so dragging a node (which
* replaces the array every frame) does not re-derive them. * replaces the array every frame) does not re-derive them.
+61 -5
View File
@@ -1,8 +1,8 @@
import type { Edge, Node as FlowCanvasNode } from "@xyflow/react" import type { Edge, Node as FlowCanvasNode } from "@xyflow/react"
import type { Endpoint, NodeDef_Input } from "@/client" import type { Endpoint, FlowDef_Input, NodeDef_Input } from "@/client"
import type { FlowEdgeData } from "./deriveEdges" import type { FlowEdgeData } from "./deriveEdges"
import { portOf, qualify } from "./deriveEdges" import { displayName, portOf, qualify } from "./deriveEdges"
/** /**
* Canvas elements for things wired into a flow that are not nodes in it. * Canvas elements for things wired into a flow that are not nodes in it.
@@ -20,6 +20,9 @@ import { portOf, qualify } from "./deriveEdges"
/** Marks a canvas node as one of these, for anything that has to skip them. */ /** Marks a canvas node as one of these, for anything that has to skip them. */
export const ENDPOINT_TYPE = "endpoint" export const ENDPOINT_TYPE = "endpoint"
/** The id prefixes an endpoint may carry, none of which is a node id. */
const PREFIXES = ["dashboard:", "flow:", "input:", "output:"]
export type EndpointNodeData = { export type EndpointNodeData = {
label: string label: string
kind: string kind: string
@@ -31,7 +34,57 @@ export type EndpointNodeData = {
} }
export function isEndpointNode(node: { id: string }): boolean { export function isEndpointNode(node: { id: string }): boolean {
return node.id.startsWith("dashboard:") || node.id.startsWith("flow:") return PREFIXES.some((prefix) => node.id.startsWith(prefix))
}
/**
* The flow's own boundary, as endpoints.
*
* An input is a message written from outside — a control, a run, the API — and
* an output is what a batch run reports. They come from the working document
* rather than from the server, so declaring one shows up on the canvas before
* it has been published.
*
* An input a dashboard or another flow already accounts for is left out: the
* label exists so a value has a visible origin, and it already has one.
*/
function boundaryOf(
doc: FlowDef_Input,
flow: string,
accounted: Set<string>,
): Endpoint[] {
const found: Endpoint[] = []
for (const declared of doc.inputs ?? []) {
const message = qualify(flow, declared.spec?.name ?? "")
if (!message || accounted.has(message)) continue
found.push({
kind: "input",
id: `input:${message}`,
label: displayName(flow, message),
detail: declared.spec?.dtype ?? "float",
provides: [message],
requires: [],
})
}
// Only a batch flow has a result; a live one never finishes to have one.
if (doc.mode === "batch") {
for (const name of doc.outputs ?? []) {
const message = qualify(flow, name)
if (!message) continue
found.push({
kind: "output",
id: `output:${message}`,
label: displayName(flow, message),
detail: "result",
provides: [],
requires: [message],
})
}
}
return found
} }
/** /**
@@ -46,8 +99,11 @@ export function deriveEndpoints(
endpoints: Endpoint[], endpoints: Endpoint[],
definitions: NodeDef_Input[], definitions: NodeDef_Input[],
flow: string, flow: string,
doc: FlowDef_Input,
): { nodes: FlowCanvasNode[]; edges: Edge[] } { ): { nodes: FlowCanvasNode[]; edges: Edge[] } {
if (endpoints.length === 0) return { nodes: [], edges: [] } const accounted = new Set(endpoints.flatMap((one) => one.provides ?? []))
const all = [...boundaryOf(doc, flow, accounted), ...endpoints]
if (all.length === 0) return { nodes: [], edges: [] }
// Which node consumes or produces each message, so a label can sit beside it. // Which node consumes or produces each message, so a label can sit beside it.
const consumers = new Map<string, { node: string; port: string }[]>() const consumers = new Map<string, { node: string; port: string }[]>()
@@ -77,7 +133,7 @@ export function deriveEndpoints(
const nodes: FlowCanvasNode[] = [] const nodes: FlowCanvasNode[] = []
const edges: Edge[] = [] const edges: Edge[] = []
for (const endpoint of endpoints) { for (const endpoint of all) {
const produces = endpoint.provides ?? [] const produces = endpoint.provides ?? []
const reads = endpoint.requires ?? [] const reads = endpoint.requires ?? []
+21 -1
View File
@@ -29,12 +29,19 @@ test.beforeAll(async ({ browser }) => {
{ {
id: "sink", id: "sink",
type: "python", type: "python",
requires: [{ name: "level", port: "level", dtype: "float" }], requires: [
{ name: "level", port: "level", dtype: "float" },
{ name: "ceiling", port: "ceiling", dtype: "float" },
],
provides: [], provides: [],
}, },
], ],
inputs: [ inputs: [
{ spec: { name: "level", port: "level", dtype: "float" }, initial: 0 }, { spec: { name: "level", port: "level", dtype: "float" }, initial: 0 },
{
spec: { name: "ceiling", port: "ceiling", dtype: "float" },
initial: 30,
},
], ],
version: 1, version: 1,
}, },
@@ -88,6 +95,19 @@ test("a dashboard control is drawn on the flow it feeds", async ({ page }) => {
await expect(page.getByText("Lever")).toBeVisible() await expect(page.getByText("Lever")).toBeVisible()
}) })
test("an input nothing else writes is drawn, and the wired one is not", async ({
page,
}) => {
await page.goto(`/flows/${flowName}`)
await page.waitForSelector(".react-flow__node")
// The flow declares it and no node computes it, so it needs a visible
// origin. `level` already has one — the control below — and a second label
// saying the same thing would only be in the way.
await expect(page.getByText("ceiling")).toBeVisible()
await expect(page.getByText("level")).toHaveCount(0)
})
test("the dashboard is never stored as a node of the flow", async ({ test("the dashboard is never stored as a node of the flow", async ({
page, page,
}) => { }) => {