Files
app/frontend/src/components/Flow/FlowPanel.tsx
T
Melvin StroblandClaude Fable 5 7344eac262 Start, stop and pause flows, and show what their nodes print
Flows can now be taken off the engine and put back. Stopped state lives in a
runtime.json beside the flow, not in the flow document: the canvas autosaves
that document, so a stopped flow would otherwise start itself again on the
next edit. A stopped flow gets no subscriptions, schedules or webhooks, its
nodes are skipped by the scheduler, and running it answers 409. Pausing holds
a flow's nodes while its values keep arriving, so the canvas still shows what
is coming in.

Node code is user code and print is how it says things, so stdout is teed
through a contextvar sink active only during a node execution — one event per
execution, capped, so a chatty node cannot outrun the stream. A node that
fails sends its traceback the same way, trimmed to the author's own frames.
The dock gains a logs panel and a pause control; the dashboard replaces its
placeholder with what is running, stopped or failing; the edge inspector can
send the last message again.

Single-stepping is deferred and noted: the scheduler keeps no progress between
calls, so a step button would re-run the same node rather than advance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 23:35:08 +02:00

235 lines
7.2 KiB
TypeScript

import { 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 { Switch } from "@/components/ui/switch"
import { PANEL_SECTION, SidePanel } from "./SidePanel"
const NAME_PATTERN = /^[a-z][a-z0-9_]*$/
/**
* The flow's own settings, in the same panel its nodes use.
*
* The name is also the namespace of every message in the flow, which is why
* renaming goes through the server rather than being another autosaved field.
*/
export function FlowPanel({
open,
definition,
nodeCount,
renaming,
onChange,
onRename,
onDelete,
hasDraft,
discarding,
onDiscardDraft,
enabled,
toggling,
onToggleEnabled,
onClose,
}: {
open: boolean
definition: FlowDef_Input
nodeCount: number
renaming: boolean
onChange: (next: FlowDef_Input) => void
onRename: (newName: string) => void
onDelete: () => void
hasDraft: boolean
discarding: boolean
onDiscardDraft: () => void
enabled: boolean
toggling: boolean
onToggleEnabled: (next: boolean) => void
onClose: () => void
}) {
const [name, setName] = useState(definition.name)
const [confirmOpen, setConfirmOpen] = useState(false)
const [discardOpen, setDiscardOpen] = useState(false)
const valid = NAME_PATTERN.test(name)
const changed = name !== definition.name
return (
<>
<SidePanel
open={open}
label="Flow settings"
testId="flow-panel"
bodyKey={definition.name}
onClose={onClose}
header={
<Input
value={definition.title ?? ""}
placeholder={definition.name}
aria-label="Flow title"
className="h-8 flex-1 text-sm font-medium"
onChange={(event) =>
onChange({ ...definition, title: event.target.value })
}
/>
}
footer={
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setConfirmOpen(true)}
data-testid="delete-flow"
>
Delete flow
</Button>
}
>
<div className="grid gap-5 p-4">
<div className="grid gap-2">
<span className={PANEL_SECTION}>Running</span>
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">
{enabled
? "The engine runs this flow: subscriptions, schedules and webhooks are live."
: "Stopped. Nothing of this flow is subscribed, scheduled or reachable."}
</p>
<Switch
checked={enabled}
disabled={toggling}
onCheckedChange={onToggleEnabled}
aria-label="Run this flow"
data-testid="flow-enabled"
/>
</div>
</div>
<div className="grid gap-2">
<span className={PANEL_SECTION}>Name</span>
<div className="flex items-center gap-1.5">
<Input
value={name}
aria-label="Flow name"
autoComplete="off"
className="h-8 flex-1 font-mono text-sm"
onChange={(event) => setName(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && valid && changed) {
onRename(name)
}
}}
/>
<Button
size="sm"
className="h-8"
disabled={!valid || !changed || renaming}
onClick={() => onRename(name)}
>
{renaming ? "Renaming…" : "Rename"}
</Button>
</div>
<p className="text-sm text-muted-foreground">
{valid || !name
? "Messages in this flow are named after it, so other flows reading them follow the rename."
: "Lowercase letters, digits and underscores, starting with a letter."}
</p>
</div>
<div className="grid gap-2">
<span className={PANEL_SECTION}>Contents</span>
<p className="text-sm text-muted-foreground">
{nodeCount === 0
? "No nodes yet."
: `${nodeCount} node${nodeCount === 1 ? "" : "s"}.`}
</p>
</div>
{hasDraft ? (
<div className="grid gap-2">
<span className={PANEL_SECTION}>Unpublished changes</span>
<p className="text-sm text-muted-foreground">
The engine is still running the last published version of this
flow.
</p>
<Button
variant="outline"
size="sm"
className="h-8 justify-self-start"
disabled={discarding}
onClick={() => setDiscardOpen(true)}
data-testid="discard-draft"
>
{discarding ? "Discarding…" : "Discard changes"}
</Button>
</div>
) : null}
</div>
</SidePanel>
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
Delete {definition.title || definition.name}?
</DialogTitle>
<DialogDescription>
This removes the flow and the code of its{" "}
{nodeCount === 1 ? "node" : `${nodeCount} nodes`}. Its history
stays in the flow store's git repository.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmOpen(false)}>
Keep it
</Button>
<Button
variant="destructive"
onClick={() => {
setConfirmOpen(false)
onDelete()
}}
data-testid="confirm-delete-flow"
>
Delete flow
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={discardOpen} onOpenChange={setDiscardOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Discard the unpublished changes?</DialogTitle>
<DialogDescription>
The canvas goes back to the version the engine is running. What
you edited since is dropped, though the flow store's git history
keeps it.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDiscardOpen(false)}>
Keep editing
</Button>
<Button
variant="destructive"
onClick={() => {
setDiscardOpen(false)
onDiscardDraft()
}}
data-testid="confirm-discard-draft"
>
Discard changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}