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:
@@ -1,5 +1,5 @@
|
||||
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 { useIsMobile } from "@/hooks/useMobile"
|
||||
@@ -9,6 +9,8 @@ import type { EndpointNodeData } from "./endpoints"
|
||||
const KIND_ICONS = {
|
||||
dashboard: LayoutDashboard,
|
||||
flow: Workflow,
|
||||
input: LogIn,
|
||||
output: LogOut,
|
||||
} 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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -47,7 +47,13 @@ import { inCodeEditor, useShortcuts } from "@/lib/shortcuts"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CanvasTitle } from "./CanvasTitle"
|
||||
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 { EndpointNode } from "./EndpointNode"
|
||||
import { deriveEndpoints, ENDPOINT_TYPE, isEndpointNode } from "./endpoints"
|
||||
@@ -57,6 +63,7 @@ import { FlowPanel } from "./FlowPanel"
|
||||
import { LiveEdge } from "./LiveEdge"
|
||||
import { type Direction, layoutGraph } from "./layout"
|
||||
import { NodePanel } from "./NodePanel"
|
||||
import { RunDialog } from "./RunDialog"
|
||||
import "./flow.css"
|
||||
import { liveStore, useFlowPaused } from "./liveStore"
|
||||
import {
|
||||
@@ -228,6 +235,8 @@ function FlowEditorInner({
|
||||
const [rebind, setRebind] = useState<Rebind | null>(null)
|
||||
const [renamed, setRenamed] = useState<MessageRename | null>(null)
|
||||
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
|
||||
// here rather than inside the settings panel.
|
||||
const [discardOpen, setDiscardOpen] = useState(false)
|
||||
@@ -368,8 +377,9 @@ function FlowEditorInner({
|
||||
)
|
||||
|
||||
// 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.
|
||||
const key = bindingsKey(definitions)
|
||||
// on: what the graph looks like follows from what is wired to what. The
|
||||
// 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,
|
||||
// and an input nobody provides yet is worth publishing.
|
||||
@@ -395,7 +405,11 @@ function FlowEditorInner({
|
||||
const openEndpoint = useCallback(
|
||||
(id: string) => {
|
||||
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({
|
||||
to: "/dashboards/$name",
|
||||
params: { name: (rest ?? "").split(":")[0] },
|
||||
@@ -448,7 +462,12 @@ function FlowEditorInner({
|
||||
// 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.
|
||||
const external = useMemo(() => {
|
||||
const built = deriveEndpoints(detail.endpoints ?? [], definitions, flowName)
|
||||
const built = deriveEndpoints(
|
||||
detail.endpoints ?? [],
|
||||
definitions,
|
||||
flowName,
|
||||
flowDoc,
|
||||
)
|
||||
return {
|
||||
...built,
|
||||
nodes: built.nodes.map((node) => {
|
||||
@@ -552,8 +571,8 @@ function FlowEditorInner({
|
||||
}, [direction, definitions.length, external.nodes.length, fitView])
|
||||
|
||||
const runMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
FlowsService.runFlow({ name: flowName, requestBody: { inputs: {} } }),
|
||||
mutationFn: (inputs: Record<string, unknown> = {}) =>
|
||||
FlowsService.runFlow({ name: flowName, requestBody: { inputs } }),
|
||||
onSuccess: (state) => {
|
||||
liveStore.setValues(
|
||||
Object.fromEntries(
|
||||
@@ -568,6 +587,19 @@ function FlowEditorInner({
|
||||
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({
|
||||
mutationFn: (next: boolean) =>
|
||||
next
|
||||
@@ -1044,11 +1076,7 @@ function FlowEditorInner({
|
||||
onClearNode: () => setLogsNode(null),
|
||||
}}
|
||||
onAddNode={() => setPaletteOpen(true)}
|
||||
onRun={async () => {
|
||||
// Running executes what is stored, so the queued edit goes first.
|
||||
await flush()
|
||||
runMutation.mutate()
|
||||
}}
|
||||
onRun={startRun}
|
||||
onTogglePause={() => pauseMutation.mutate(!paused)}
|
||||
onStep={() => stepMutation.mutate()}
|
||||
stepping={stepMutation.isPending}
|
||||
@@ -1125,7 +1153,18 @@ function FlowEditorInner({
|
||||
flows={flows.data}
|
||||
onAddNode={addNode}
|
||||
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
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { BoundarySections } from "./FlowBoundary"
|
||||
import { PANEL_SECTION, PanelTitle, SidePanel } from "./SidePanel"
|
||||
|
||||
/**
|
||||
@@ -89,6 +90,13 @@ export function FlowPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BoundarySections
|
||||
flow={definition.name}
|
||||
definition={definition}
|
||||
running={enabled}
|
||||
onChange={onChange}
|
||||
/>
|
||||
|
||||
<div className="grid gap-3">
|
||||
<span className={PANEL_SECTION}>Contents</span>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
||||
@@ -50,7 +50,7 @@ import { PANEL_SECTION, PanelTitle, SidePanel } from "./SidePanel"
|
||||
|
||||
const NodeEditor = lazy(() => import("./NodeEditor"))
|
||||
|
||||
const DTYPES: DType[] = [
|
||||
export const DTYPES: DType[] = [
|
||||
"float",
|
||||
"int",
|
||||
"str",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* replaces the array every frame) does not re-derive them.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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 { portOf, qualify } from "./deriveEdges"
|
||||
import { displayName, portOf, qualify } from "./deriveEdges"
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
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 = {
|
||||
label: string
|
||||
kind: string
|
||||
@@ -31,7 +34,57 @@ export type EndpointNodeData = {
|
||||
}
|
||||
|
||||
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[],
|
||||
definitions: NodeDef_Input[],
|
||||
flow: string,
|
||||
doc: FlowDef_Input,
|
||||
): { 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.
|
||||
const consumers = new Map<string, { node: string; port: string }[]>()
|
||||
@@ -77,7 +133,7 @@ export function deriveEndpoints(
|
||||
const nodes: FlowCanvasNode[] = []
|
||||
const edges: Edge[] = []
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
for (const endpoint of all) {
|
||||
const produces = endpoint.provides ?? []
|
||||
const reads = endpoint.requires ?? []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user