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>
235 lines
7.2 KiB
TypeScript
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>
|
|
</>
|
|
)
|
|
}
|