Files
app/frontend/src/components/Flow/NodePanel.tsx
T
stroblme 6802cd2203 Make changing engine-run code superuser-only
Writing node source, installing modules and any flow save that adds,
retypes or repoints a code node (Function, Command), edits a Command
node's settings or changes a node's resources.env now answer 403 for
everyone but a superuser. MCP tools and the assistant reach the same
routes, so they inherit the gate. Node types report runs_code.

The editor shows code read-only with a note, disables a Command node's
settings, leaves code nodes and reusable nodes out of the palette and
paste, and the Modules page offers no install.
2026-10-06 11:36:30 +02:00

1680 lines
52 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import {
Activity,
CornerDownRight,
Maximize2,
Minimize2,
X,
} from "lucide-react"
import {
type ComponentProps,
lazy,
Suspense,
useEffect,
useRef,
useState,
} from "react"
import {
type DType,
type FlowOrigin,
FlowsService,
type MessageSpec,
type NodeDef_Input,
type NodeTypeInfo,
} from "@/client"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
} from "@/components/ui/command"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import useAuth from "@/hooks/useAuth"
import useCustomToast from "@/hooks/useCustomToast"
import { inCodeEditor, useShortcuts } from "@/lib/shortcuts"
import { cn } from "@/lib/utils"
import { DtypeValue } from "./FlowBoundary"
import { JsonField } from "./JsonField"
import { MessageSparkline } from "./MessageSparkline"
import {
flavorsQueryOptions,
flowKeys,
libraryQueryOptions,
nodeSourceQueryOptions,
secretsQueryOptions,
useParamSuggestions,
} from "./queries"
import {
FieldLabel,
InfoTip,
PANEL_SECTION,
PanelTitle,
SidePanel,
} from "./SidePanel"
const NodeEditor = lazy(() => import("./NodeEditor"))
export const DTYPES: DType[] = [
"float",
"int",
"str",
"bool",
"json",
"series",
"record",
"list",
"artifact",
"image",
"audio",
"video",
]
/** What a list may hold. One declared level: no list of lists. */
const ITEM_DTYPES: DType[] = ["record", "float", "int", "str", "bool", "json"]
/** Radix selects cannot hold an empty value, so "no secret" needs a name. */
const NO_SECRET = "__none__"
const SECTION = PANEL_SECTION
/** Settings the engine reads itself, so they are not the author's to name. */
const RESERVED_PARAMS = new Set(["synchronous"])
/** A setting reaches the function by name, so the name has to be one. */
const IDENTIFIER = /^[A-Za-z_]\w*$/
/** Sentinels for the resources dropdown, which has two options that are not sizes. */
const NO_RESOURCES = "__shared__"
const CUSTOM_RESOURCES = "__custom__"
/** `2h` and `30m` and `90` — the grammar the engine parses, written back. */
function parseDuration(text: string): number | null {
const match = /^\s*(\d+)\s*([smhd])?\s*$/i.exec(text)
if (!match) return null
const unit = (match[2] ?? "s").toLowerCase()
const scale = { s: 1, m: 60, h: 3600, d: 86400 }[unit] ?? 1
return Number(match[1]) * scale
}
function formatDuration(seconds: number | null | undefined): string {
if (!seconds) return ""
if (seconds % 86400 === 0) return `${seconds / 86400}d`
if (seconds % 3600 === 0) return `${seconds / 3600}h`
if (seconds % 60 === 0) return `${seconds / 60}m`
return `${seconds}s`
}
/**
* A text field that offers what is already in use elsewhere.
*
* Typing stays free — the list is a shortcut, never a constraint — but picking
* beats typing wherever two things only work together when they match.
*/
function SuggestInput({
value,
suggestions,
empty,
onChange,
onFocus,
onBlur,
...props
}: {
value: string
suggestions: string[]
/** What to say when nothing matches what is typed. */
empty: string
onChange: (next: string) => void
} & Omit<ComponentProps<typeof Input>, "value" | "onChange">) {
const [open, setOpen] = useState(false)
const matches = suggestions.filter(
(item) =>
item !== value && item.toLowerCase().includes(value.toLowerCase()),
)
return (
<Popover open={open && matches.length > 0} onOpenChange={setOpen}>
<PopoverAnchor asChild>
<Input
value={value}
autoComplete="off"
{...props}
onFocus={(event) => {
setOpen(true)
onFocus?.(event)
}}
onBlur={(event) => {
setOpen(false)
onBlur?.(event)
}}
onChange={(event) => {
onChange(event.target.value)
setOpen(true)
}}
onKeyDown={(event) => {
if (event.key === "Escape") setOpen(false)
}}
/>
</PopoverAnchor>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] p-0"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<Command shouldFilter={false}>
<CommandList>
<CommandEmpty>{empty}</CommandEmpty>
<CommandGroup>
{matches.map((item) => (
<CommandItem
key={item}
value={item}
className="font-mono text-sm"
// Blur fires before click, so commit on mousedown.
onMouseDown={(event) => {
event.preventDefault()
onChange(item)
setOpen(false)
}}
onSelect={() => {
onChange(item)
setOpen(false)
}}
>
{item}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
/**
* A message name, typed freely or picked from the names already in play.
*
* The suggestions are the point: a message only connects when both ends spell
* it the same way, so choosing beats typing.
*/
function MessageNameInput({
value,
suggestions,
placeholder,
autoFocus,
onChange,
onRenamed,
}: {
value: string
suggestions: string[]
placeholder: string
autoFocus: boolean
onChange: (next: string) => void
/** The name as it was before this edit, once the field is done with. */
onRenamed?: (previous: string, next: string) => void
}) {
// Every keystroke commits, so a rename is only a rename once the user is
// finished with the field.
const before = useRef(value)
return (
<SuggestInput
value={value}
suggestions={suggestions}
empty="No matching message."
placeholder={placeholder}
aria-label="Message name"
// A port added by hand is meant to be named right away.
autoFocus={autoFocus}
className="h-8 flex-1 font-mono text-sm"
onChange={onChange}
onFocus={() => {
before.current = value
}}
onBlur={() => {
if (before.current !== value) onRenamed?.(before.current, value)
before.current = value
}}
/>
)
}
/** Why a port cannot be called `name`, or null when it can. */
function portProblem(name: string, others: string[]): string | null {
if (!IDENTIFIER.test(name))
return "A port name is letters, digits and _, and does not start with a digit."
if (others.includes(name)) return `Another port here is called ${name}.`
return null
}
function PortList({
title,
specs,
flow,
emptyHint,
suggestions,
onChange,
onRenamed,
onPortRenamed,
onRemoved,
streamable = false,
}: {
title: string
specs: MessageSpec[]
flow: string
emptyHint: string
suggestions: string[]
onChange: (next: MessageSpec[]) => void
onRenamed?: (previous: string, next: string) => void
/** A port taking another name, reported just before the list that has it. */
onPortRenamed?: (from: string, to: string) => void
/** The port about to be dropped, reported just before the shorter list. */
onRemoved?: (spec: MessageSpec) => void
/** Outputs only: a port a node publishes on repeatedly while it runs. */
streamable?: boolean
}) {
// The port just added, so its name field can take focus.
const [freshIndex, setFreshIndex] = useState<number | null>(null)
// A port name is kept once its field is left, so what is typed lives here
// until then. Only one field has focus at a time, so one draft is enough.
const [naming, setNaming] = useState<{ index: number; text: string } | null>(
null,
)
const update = (index: number, patch: Partial<MessageSpec>) => {
const next = specs.map((spec, i) =>
i === index ? { ...spec, ...patch } : spec,
)
onChange(next)
}
return (
<div className="grid min-w-0 gap-3">
<div className="flex items-center justify-between">
<span className={SECTION}>{title}</span>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-muted-foreground"
onClick={() => {
setFreshIndex(specs.length)
onChange([...specs, { name: "", dtype: "float" }])
}}
>
Add
</Button>
</div>
{specs.length === 0 ? (
<p className="text-sm text-muted-foreground">{emptyHint}</p>
) : null}
{specs.map((spec, index) => {
// A port follows its message until it is named by hand; the field
// then holds that name, and is empty while there is none.
const own = ownsName(spec) ? (spec.port ?? "") : ""
const typed = naming?.index === index ? naming.text.trim() : null
const problem = typed
? portProblem(
typed,
specs.filter((_, i) => i !== index).map(portName),
)
: null
return (
// The curve reads as its own thing rather than as part of the row
// above it, so it gets a little air.
<div key={`port-${index}`} className="grid min-w-0 gap-1.5">
<div className="flex min-w-0 items-center gap-1.5">
<Input
value={naming?.index === index ? naming.text : own}
placeholder={portName({ name: spec.name }) || "port"}
aria-label="Port name"
aria-invalid={Boolean(problem)}
title="The name the node sees this port by. Empty follows the message."
className="h-8 flex-1 font-mono text-sm"
onChange={(event) =>
setNaming({ index, text: event.target.value })
}
onBlur={() => {
if (typed !== null && !problem && typed !== own) {
const to = typed || portName({ name: spec.name })
if (to !== portName(spec))
onPortRenamed?.(portName(spec), to)
update(index, { port: typed })
}
setNaming(null)
}}
/>
<Select
value={spec.dtype ?? "float"}
onValueChange={(value) =>
update(index, { dtype: value as DType })
}
>
<SelectTrigger
className="!h-8 w-[104px] text-sm"
aria-label="Type"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DTYPES.map((dtype) => (
<SelectItem key={dtype} value={dtype}>
{dtype}
</SelectItem>
))}
</SelectContent>
</Select>
{spec.dtype === "list" ? (
<Select
value={spec.item ?? "record"}
onValueChange={(value) =>
update(index, { item: value as DType })
}
>
<SelectTrigger
className="!h-8 w-[92px] text-sm"
aria-label="Item type"
title="What each item of the list is"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ITEM_DTYPES.map((dtype) => (
<SelectItem key={dtype} value={dtype}>
{dtype}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="Remove port"
onClick={() => {
onRemoved?.(spec)
onChange(specs.filter((_, i) => i !== index))
}}
>
<X />
</Button>
</div>
<div className="flex min-w-0 items-center gap-1.5">
<CornerDownRight
aria-hidden
className="size-4 shrink-0 text-muted-foreground"
/>
<MessageNameInput
value={spec.name ?? ""}
suggestions={suggestions}
placeholder={`message in ${flow}`}
autoFocus={index === freshIndex}
onChange={(name) =>
update(index, {
name,
port: ownsName(spec) ? spec.port : "",
})
}
onRenamed={onRenamed}
/>
<Input
type="number"
min={0}
step="any"
value={spec.interval ? String(spec.interval) : ""}
placeholder="∞"
aria-label="Deliver at most every n seconds"
title="Deliver at most every n seconds; empty is every time"
className="h-8 w-14 text-sm"
onChange={(event) =>
update(index, { interval: Number(event.target.value) || 0 })
}
/>
{streamable ? (
<Button
variant={spec.stream ? "secondary" : "ghost"}
size="icon-sm"
aria-label="Streaming output"
aria-pressed={spec.stream ?? false}
title={
"Published repeatedly while the node runs: a curve rather than " +
"a result. A run keeps every value it takes."
}
className={cn(
"shrink-0",
!spec.stream && "text-muted-foreground",
)}
onClick={() => update(index, { stream: !spec.stream })}
>
<Activity />
</Button>
) : null}
</div>
{problem ? (
<p className="text-xs text-destructive">{problem}</p>
) : null}
{spec.name ? (
<MessageSparkline
flow={flow}
name={spec.name}
dtype={spec.dtype ?? undefined}
/>
) : null}
</div>
)
})}
</div>
)
}
/** The value types a free-form setting can hold, and how to read one back. */
const FREE_TYPES = ["text", "number", "on/off", "json"] as const
type FreeType = (typeof FREE_TYPES)[number]
function freeTypeOf(value: unknown): FreeType {
if (typeof value === "boolean") return "on/off"
if (typeof value === "number") return "number"
if (value !== null && typeof value === "object") return "json"
return "text"
}
function castTo(type: FreeType, raw: string): unknown {
if (type === "number") return Number(raw) || 0
if (type === "on/off") return raw === "true"
if (type === "json") {
try {
// An empty setting turned into JSON starts as an empty object.
return JSON.parse(raw || "{}")
} catch {
// Text that is not JSON stays text, and the type select says so.
return raw
}
}
return raw
}
export function asText(value: unknown): string {
if (value === null || value === undefined) return ""
if (typeof value === "object") return JSON.stringify(value)
return String(value)
}
/**
* Settings a node type does not declare.
*
* A function node's parameters are its author's to name — they arrive in
* `process` as whatever was put here — so there is no schema to render and the
* keys are typed in alongside the values.
*/
function FreeParamsForm({
params,
reserved,
onChange,
}: {
params: Record<string, unknown>
reserved: Set<string>
onChange: (next: Record<string, unknown>) => void
}) {
const [freshKey, setFreshKey] = useState<string | null>(null)
const entries = Object.entries(params).filter(([key]) => !reserved.has(key))
const rename = (from: string, to: string) => {
// A setting is an argument of `process`, so a name it cannot take is not
// a rename — it is a typo on its way to a node that will not load.
if (to === from || !IDENTIFIER.test(to)) return
// Rebuilt rather than patched, so the settings keep the order they were
// typed in instead of jumping around as one is renamed.
const next: Record<string, unknown> = {}
for (const [key, value] of Object.entries(params)) {
next[key === from ? to : key] = value
}
onChange(next)
}
return (
<div className="grid gap-3">
<div className="flex items-center justify-between">
<span className={SECTION}>Settings</span>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-muted-foreground"
data-testid="add-param"
onClick={() => {
let name = "setting"
for (let i = 2; name in params; i++) name = `setting${i}`
setFreshKey(name)
onChange({ ...params, [name]: "" })
}}
>
Add
</Button>
</div>
{entries.length === 0 ? (
<p className="text-sm text-muted-foreground">
Constants of this node, passed to <code>process</code> by name like
its inputs.
</p>
) : null}
{entries.map(([key, value], index) => {
const type = freeTypeOf(value)
return (
// Keyed by position, not by name: renaming a setting must not
// remount its row and take the half-typed value with it.
<div key={`param-${index}`} className="flex items-center gap-1.5">
<Input
defaultValue={key}
placeholder="name"
aria-label="Setting name"
autoFocus={key === freshKey}
className="h-8 flex-1 text-sm"
onBlur={(event) => rename(key, event.target.value.trim() || key)}
/>
<Select
value={type}
onValueChange={(next) =>
onChange({
...params,
[key]: castTo(next as FreeType, asText(value)),
})
}
>
<SelectTrigger
className="!h-8 w-[86px] text-sm"
aria-label="Type"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FREE_TYPES.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
{type === "on/off" ? (
<Switch
checked={value === true}
aria-label="Value"
onCheckedChange={(checked) =>
onChange({ ...params, [key]: checked })
}
/>
) : type === "json" ? (
<JsonField
value={value}
label={key}
className="flex-1"
onChange={(next) => onChange({ ...params, [key]: next })}
/>
) : (
<Input
value={asText(value)}
placeholder="value"
aria-label="Setting value"
className="h-8 flex-1 text-sm"
onChange={(event) =>
onChange({
...params,
[key]: castTo(type, event.target.value),
})
}
/>
)}
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="Remove setting"
onClick={() => {
const next = { ...params }
delete next[key]
onChange(next)
}}
>
<X />
</Button>
</div>
)
})}
</div>
)
}
/**
* The cron expression that fires as often as `seconds` asks for, if there is
* one. Only divisors of an hour or a day line up — a step of seven minutes
* would jump from :56 back to :00, which is not every seven minutes.
*/
function cronFromInterval(seconds: unknown): string | null {
const value = Number(seconds)
if (!Number.isFinite(value) || value <= 0) return null
const minutes = value / 60
// Cron's finest grain is the minute.
if (!Number.isInteger(minutes)) return null
if (minutes === 1) return "* * * * *"
if (minutes < 60) return 60 % minutes === 0 ? `*/${minutes} * * * *` : null
const hours = minutes / 60
if (!Number.isInteger(hours)) return null
if (hours === 1) return "0 * * * *"
if (hours < 24) return 24 % hours === 0 ? `0 */${hours} * * *` : null
return hours === 24 ? "0 0 * * *" : null
}
/** What the five fields mean, and the schedule the interval beside them asks for. */
function CronHelp({
params,
derivable,
onPick,
}: {
params: Record<string, unknown>
/**
* Whether `interval` beside it is a schedule at all. On a delay it is a rate
* limit, and a cron built from it would say something the node never does.
*/
derivable: boolean
onPick: (expression: string) => void
}) {
const derived = cronFromInterval(params.interval)
return (
<>
<InfoTip label="Cron">
Five fields:{" "}
<span className="font-mono">
minute hour day-of-month month weekday
</span>
. <span className="font-mono">*</span> is every,{" "}
<span className="font-mono">*/5</span> every fifth,{" "}
<span className="font-mono">1-5</span> a range.
</InfoTip>
{derivable && derived && params.cron !== derived ? (
<Button
variant="ghost"
size="sm"
className="h-7 justify-self-start text-xs text-muted-foreground"
data-testid="use-derived-cron"
onClick={() => onPick(derived)}
>
Every {String(params.interval)}s is
<span className="font-mono">{derived}</span>
</Button>
) : null}
</>
)
}
/**
* What an inject publishes, one field per port it publishes on.
*
* Each port is its own message with its own type, so the value follows the
* port rather than the node — and since what an inject emits is also what it
* remembers, the field is where that default lives. A port with nothing of its
* own reads through to `payload`, which is what a flow written before this had.
*/
function InjectPayloads({
provides,
params,
onChange,
}: {
provides: MessageSpec[]
params: Record<string, unknown>
onChange: (next: Record<string, unknown>) => void
}) {
const payloads = (params.payloads ?? {}) as Record<string, unknown>
const ports = provides.filter((spec) => portName(spec))
const set = (port: string, value: unknown) => {
const next = { ...payloads }
// An empty field means the current time, which is having no value at all.
if (value === null) delete next[port]
else next[port] = value
onChange({ ...params, payloads: next })
}
if (ports.length === 0) {
return (
<p className="text-sm text-muted-foreground">
Add a port for this to publish on.
</p>
)
}
return (
<>
{ports.map((spec) => {
const port = portName(spec)
return (
<div key={port} className="grid gap-1.5">
<Label
htmlFor={`payload-${port}`}
className="font-mono text-sm font-normal"
>
{port}
</Label>
<DtypeValue
id={`payload-${port}`}
dtype={spec.dtype}
value={
port in payloads ? payloads[port] : (params.payload ?? null)
}
label={`What to emit on ${port}`}
placeholder="now"
className="text-sm"
onChange={(value) => set(port, value)}
/>
</div>
)
})}
</>
)
}
/** A small form built from the node type's declared parameters. */
function ParamsForm({
type,
schema,
params,
provides,
onChange,
}: {
type: string | undefined
schema: Record<string, unknown> | undefined
params: Record<string, unknown>
/** The ports an inject's payload fields follow; unused by every other type. */
provides: MessageSpec[]
onChange: (next: Record<string, unknown>) => void
}) {
const properties = (schema?.properties ?? {}) as Record<
string,
{
type?: string
title?: string
description?: string
default?: unknown
"x-secret"?: boolean
}
>
const entries = Object.entries(properties)
const { data: secretList } = useQuery(secretsQueryOptions())
const secrets = secretList?.data ?? []
// A type with nothing to fill in has nothing to suggest, and the other flows
// are not worth reading for it.
const suggestions = useParamSuggestions(entries.length ? type : undefined)
if (entries.length === 0) return null
const set = (key: string, value: unknown) =>
onChange({ ...params, [key]: value })
return (
<div className="grid gap-3">
<span className={SECTION}>Settings</span>
{entries.map(([key, property]) => {
// In the schema's own place, so the payload keeps its position in the
// form even though it is now one field per port.
if (type === "inject" && key === "payload") {
return (
<InjectPayloads
key={key}
provides={provides}
params={params}
onChange={onChange}
/>
)
}
const value = params[key] ?? property.default ?? ""
const label = property.title ?? key
if (property.type === "boolean") {
return (
<div key={key} className="flex items-center justify-between gap-2">
<Label htmlFor={`param-${key}`} className="text-sm font-normal">
{label}
</Label>
<Switch
id={`param-${key}`}
checked={Boolean(value)}
onCheckedChange={(checked) => set(key, checked)}
/>
</div>
)
}
// A credential is stored once and referenced, so it never ends up in
// flow.json where the whole team can read it.
if (property["x-secret"]) {
const reference = (params[key] ?? null) as { $secret?: string } | null
return (
<div key={key} className="grid gap-1.5">
<FieldLabel
help={
property.description ||
"Stored in the secrets store, never in the flow file."
}
>
{label}
</FieldLabel>
<Select
value={reference?.$secret ?? ""}
onValueChange={(name) =>
set(key, name === NO_SECRET ? null : { $secret: name })
}
>
<SelectTrigger className="!h-8 text-sm" aria-label={label}>
<SelectValue placeholder="Pick a stored secret" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_SECRET}>None</SelectItem>
{secrets.map((secret) => (
<SelectItem key={secret} value={secret}>
{secret}
</SelectItem>
))}
</SelectContent>
</Select>
{secrets.length ? null : (
<p className="text-xs text-muted-foreground">
No secrets stored yet.
</p>
)}
</div>
)
}
if (
property.type === "object" ||
property.type === "array" ||
(value !== null && typeof value === "object")
) {
// An inject's per-port payloads are the fields drawn above.
if (type === "inject" && key === "payloads") return null
return (
<div key={key} className="grid gap-1.5">
<FieldLabel htmlFor={`param-${key}`} help={property.description}>
{label}
</FieldLabel>
<JsonField
id={`param-${key}`}
value={
params[key] ??
property.default ??
(property.type === "array" ? [] : {})
}
label={label}
onChange={(next) => set(key, next)}
/>
</div>
)
}
const numeric =
property.type === "integer" || property.type === "number"
return (
<div key={key} className="grid gap-1.5">
<FieldLabel htmlFor={`param-${key}`} help={property.description}>
{label}
</FieldLabel>
<SuggestInput
id={`param-${key}`}
className="h-8 text-sm"
type={numeric ? "number" : "text"}
value={String(value)}
suggestions={suggestions[key] ?? []}
empty="Nothing like that in use yet."
onChange={(next) => set(key, numeric ? Number(next) : next)}
/>
{key === "cron" ? (
<CronHelp
params={params}
derivable={type === "inject"}
onPick={(expression) => set(key, expression)}
/>
) : null}
</div>
)
})}
</div>
)
}
/**
* How much of a machine this node takes, and how long it is expected to take.
*
* A named size is the usual answer: what "gpu-small" means is a property of the
* machines this instance has, and those change. The numbers are still there
* for a node that genuinely wants its own.
*/
function ResourcesSection({
node,
onChange,
}: {
node: NodeDef_Input
onChange: (node: NodeDef_Input) => void
}) {
const { data: flavors } = useQuery(flavorsQueryOptions())
const resources = node.resources ?? null
const choice = !resources
? NO_RESOURCES
: (resources.flavor ?? CUSTOM_RESOURCES)
const edit = (next: NonNullable<NodeDef_Input["resources"]> | null) =>
onChange({ ...node, resources: next })
const pick = (value: string) => {
if (value === NO_RESOURCES) return edit(null)
// A flavor already says how much, so the numbers go with it — sending both
// is two answers, and the engine refuses it.
const kept = resources?.duration_s
? { duration_s: resources.duration_s }
: {}
if (value === CUSTOM_RESOURCES) return edit({ cpus: 1, gpus: 0, ...kept })
return edit({ flavor: value, ...kept })
}
return (
<div className="grid gap-1.5">
<div className="flex items-center gap-1">
<Label htmlFor="node-resources" className={SECTION}>
Resources
</Label>
<InfoTip label="Resources">
What is held for the whole execution, and what the node's own
libraries are told they may use. The duration is a planning figure,
not a limit. Held nothing, the node shares the pool with every other
node that asks for nothing.
</InfoTip>
</div>
<Select value={choice} onValueChange={pick}>
<SelectTrigger id="node-resources" className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_RESOURCES}>Shared pool</SelectItem>
{(flavors?.data ?? []).map((flavor) => (
<SelectItem key={flavor.name} value={flavor.name}>
{flavor.name} — {flavor.cpus} cpu ·{" "}
{Math.round((flavor.ram ?? 0) / 1024)} GB
{flavor.gpus ? ` · ${flavor.gpus} gpu` : ""}
</SelectItem>
))}
<SelectItem value={CUSTOM_RESOURCES}>Custom…</SelectItem>
</SelectContent>
</Select>
{choice === CUSTOM_RESOURCES ? (
<div className="grid grid-cols-3 gap-1.5">
{(
[
["cpus", "cores", 1],
["gpus", "gpus", 0],
["ram", "MB", 0],
] as const
).map(([field, label, floor]) => (
<div key={field} className="grid gap-1">
<Input
type="number"
min={floor}
className="h-8 text-sm"
aria-label={label}
placeholder={field === "ram" ? "unstated" : String(floor)}
value={
resources?.[field] != null ? String(resources[field]) : ""
}
onChange={(event) => {
const raw = event.target.value
const value =
raw === "" ? null : Math.max(floor, Number(raw) || 0)
edit({
...resources,
cpus: resources?.cpus ?? 1,
// `ram` may be left unstated; the other two always have one.
[field]: field === "ram" ? value : (value ?? floor),
})
}}
/>
<span className="text-xs text-muted-foreground">{label}</span>
</div>
))}
</div>
) : null}
{resources ? (
<Input
className="h-8 text-sm"
placeholder="expected duration, e.g. 30m or 2h"
defaultValue={formatDuration(resources.duration_s)}
key={`${choice}-${resources.duration_s ?? ""}`}
onBlur={(event) => {
const text = event.target.value.trim()
const seconds = text === "" ? null : parseDuration(text)
if (text !== "" && seconds === null) {
event.target.value = formatDuration(resources.duration_s)
return
}
if (seconds !== resources.duration_s) {
edit({ ...resources, duration_s: seconds })
}
}}
/>
) : null}
</div>
)
}
/**
* Sharing a node moves its code to the library, where other flows can point at
* it. Each flow keeps its own ports and settings; only the code is common, so
* one fix reaches all of them.
*/
function SharingSection({
flow,
node,
onShared,
}: {
flow: string
node: NodeDef_Input
onShared: () => void
}) {
const queryClient = useQueryClient()
const { showErrorToast } = useCustomToast()
const { data: library } = useQuery(libraryQueryOptions())
const [name, setName] = useState("")
const shared = node.source_ref
const usages = library?.find((entry) => entry.name === shared)?.used_by ?? []
const done = () => {
queryClient.invalidateQueries({ queryKey: flowKeys.library })
queryClient.invalidateQueries({ queryKey: flowKeys.detail(flow) })
onShared()
}
const share = useMutation({
mutationFn: (libName: string) =>
FlowsService.shareNode({
name: flow,
nodeId: node.id,
requestBody: { lib_name: libName },
}),
onSuccess: done,
onError: () =>
showErrorToast("That name is taken, or is not a valid name."),
})
const unshare = useMutation({
mutationFn: () => FlowsService.unshareNode({ name: flow, nodeId: node.id }),
onSuccess: done,
onError: () => showErrorToast("The node could not be unshared."),
})
if (shared) {
return (
<div className="grid gap-3">
<span className={SECTION}>Shared</span>
<p className="text-sm text-muted-foreground">
Runs <span className="font-mono">{shared}</span> from the library
{usages.length > 1
? `, along with ${usages.length - 1} other node${
usages.length === 2 ? "" : "s"
}`
: ""}
. Editing the code here changes it everywhere.
</p>
<Button
variant="outline"
size="sm"
className="h-8 justify-self-start"
disabled={unshare.isPending}
onClick={() => unshare.mutate()}
data-testid="unshare-node"
>
Keep a private copy
</Button>
</div>
)
}
return (
<div className="grid gap-3">
<div className="flex items-center gap-1">
<span className={SECTION}>Reuse</span>
<InfoTip label="Reuse">
Moves this node's code to the library so other flows can run it too.
Editing a shared node changes it everywhere at once.
</InfoTip>
</div>
<div className="flex items-center gap-1.5">
<Input
value={name}
placeholder="shared_name"
aria-label="Shared name"
autoComplete="off"
className="h-8 flex-1 font-mono text-sm"
onChange={(event) => setName(event.target.value)}
/>
<Button
size="sm"
className="h-8"
disabled={!name || share.isPending}
onClick={() => share.mutate(name)}
data-testid="share-node"
>
Share
</Button>
</div>
</div>
)
}
/** The value a fresh output returns, so the scaffold runs as written. */
const PLACEHOLDER: Record<DType, string> = {
float: "0.0",
int: "0",
bool: "False",
str: '""',
json: "{}",
series: '{"lines": []}',
record: "{}",
list: "[]",
// Bytes never travel as a message: the node stores them and returns what
// the next one opens. A media port is the same reference, saying what kind
// of bytes they are.
artifact: 'fluksio.save_artifact(b"", "result.bin")',
image: 'fluksio.save_artifact(b"", "frame.png", media_type="image/png")',
audio: 'fluksio.save_artifact(b"", "clip.wav", media_type="audio/wav")',
video: 'fluksio.save_artifact(b"", "clip.mp4", media_type="video/mp4")',
}
/** Why a code editor or an exec node's settings are read-only. */
const CODE_LOCKED = "Only a superuser can change code the engine runs."
const SCAFFOLD_DOC =
'"""A new node. Return a dict keyed by your output ports."""'
const quote = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
/**
* Exactly what `scaffoldFor` writes, for any set of ports — and the source a
* node starts life with, which is that with no ports at all.
*
* Nothing else matches, which is what makes it safe to overwrite: a returned
* value that is not one of the placeholders is somebody's code.
*/
const SCAFFOLD_SHAPE = (() => {
const value = Object.values(PLACEHOLDER).map(quote).join("|")
const entry = `"[^"]+": (?:${value})`
return new RegExp(
`^${quote(SCAFFOLD_DOC)}\\n\\n\\ndef process\\((?:\\w+(?:, \\w+)*)?\\):\\n return \\{(?:${entry}(?:, ${entry})*)?\\}\\n$`,
)
})()
/** The name the node function sees; the engine derives it the same way. */
const portName = (spec: MessageSpec) =>
spec.port || (spec.name ?? "").split(".").pop() || ""
/**
* Whether a port was named by hand rather than after its message. Nothing
* stores that: a port whose name is not the one its message gives it is one.
*/
const ownsName = (spec: MessageSpec) =>
Boolean(spec.port) && spec.port !== portName({ name: spec.name })
/**
* A `process` that takes this node's inputs and settings, and returns its
* outputs.
*
* Both arrive by name, so both are arguments — the ports first, in the order
* they are declared, then the settings.
*/
function scaffoldFor(node: NodeDef_Input): string {
const args = [
...new Set(
[
...(node.requires ?? []).map(portName),
...Object.keys(node.params ?? {}).filter(
(name) => !RESERVED_PARAMS.has(name),
),
]
// Anything else cannot be a keyword argument, so it cannot be one.
.filter((name) => IDENTIFIER.test(name)),
),
]
const returns = (node.provides ?? [])
.filter((spec) => portName(spec))
.map((spec) => `"${portName(spec)}": ${PLACEHOLDER[spec.dtype ?? "float"]}`)
return `${SCAFFOLD_DOC}\n\n\ndef process(${args.join(
", ",
)}):\n return {${returns.join(", ")}}\n`
}
function PanelBody({
node,
flow,
nodeType,
origin,
suggestions,
expanded,
onChange,
onRenameMessage,
onSaveSource,
onShared,
onToggleExpand,
}: {
node: NodeDef_Input
flow: string
nodeType: NodeTypeInfo | undefined
origin: FlowOrigin | null | undefined
suggestions: PortSuggestions
expanded: boolean
onChange: (next: NodeDef_Input) => void
onRenameMessage: (previous: string, next: string) => void
onSaveSource: (code: string) => void
onShared: () => void
onToggleExpand: () => void
}) {
const hasSource = nodeType?.has_source ?? node.type === "python"
const { data: source } = useQuery({
...nodeSourceQueryOptions(flow, node.id),
enabled: hasSource,
})
// Code the engine runs is a superuser's to change; the server refuses
// anyone else, so the panel shows it read-only rather than failing a save.
const { user } = useAuth()
const mayEditCode = Boolean(user?.is_superuser)
const settingsAreCode =
!mayEditCode && Boolean(nodeType?.runs_code) && !hasSource
const [code, setCode] = useState<string | null>(null)
// The port the X is dropping or its name field renaming, noted just before
// the list that says so arrives: both are one edit, and a second one would
// only overwrite the first.
const portMove = useRef<[string, string | null] | null>(null)
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
const pending = useRef<string | null>(null)
const save = useRef(onSaveSource)
save.current = onSaveSource
/** Send what is typed now rather than a second from now. */
const saveNow = () => {
if (timer.current) {
clearTimeout(timer.current)
timer.current = null
}
if (pending.current !== null) save.current(pending.current)
}
const editCode = (next: string) => {
setCode(next)
pending.current = next
if (timer.current) clearTimeout(timer.current)
timer.current = setTimeout(() => {
timer.current = null
save.current(next)
}, 1000)
}
/**
* Change the node, and keep an untouched scaffold in step with its ports.
*
* Only code that is still exactly what this panel generates is rewritten —
* one edit of your own and it is yours, ports or no ports. Shared code is
* never rewritten either: the other flows using it have their own ports.
*/
const editNode = (next: NodeDef_Input) => {
onChange(next)
const current = code ?? source?.code
if (
!hasSource ||
!mayEditCode ||
next.source_ref ||
origin ||
current === undefined
)
return
const wanted = scaffoldFor(next)
if (current !== wanted && SCAFFOLD_SHAPE.test(current)) editCode(wanted)
}
/**
* A per-port inject value belongs to its port, so it follows it: carried
* along by a finished rename, dropped with the port itself (`to` of null).
* A node holding none of them — every other type — comes back untouched,
* which is what makes the same reference a fair test for "nothing to do".
*/
const repoint = (next: NodeDef_Input, from: string, to: string | null) => {
const payloads = (next.params?.payloads ?? {}) as Record<string, unknown>
if (!from || from === to || !(from in payloads)) return next
const moved = { ...payloads }
if (to) moved[to] = moved[from]
delete moved[from]
return { ...next, params: { ...next.params, payloads: moved } }
}
const renamePort = (previous: string, next: string) => {
onRenameMessage(previous, next)
// A port named by hand keeps its name through a new message.
const spec = node.provides?.find((entry) => entry.name === next)
if (spec && ownsName(spec)) return
// Only a finished rename moves a value; per keystroke it would delete it
// on the first character typed.
const moved = repoint(
node,
portName({ name: previous }),
portName({ name: next }),
)
if (moved !== node) editNode(moved)
}
// ⌘S in the editor means "apply this code"; the editor's own state is here,
// so the binding is too. Anywhere else on the canvas it publishes the flow.
useShortcuts(
{
"mod+s": (event) => {
if (inCodeEditor(event.target)) saveNow()
},
},
["mod+s"],
)
// Closing the panel must not lose the last keystrokes.
useEffect(() => {
return () => {
if (timer.current) {
clearTimeout(timer.current)
if (pending.current !== null) save.current(pending.current)
}
}
}, [])
const paramsForm = (
<ParamsForm
type={node.type}
schema={nodeType?.params_schema}
params={node.params ?? {}}
provides={node.provides ?? []}
onChange={(params) => editNode({ ...node, params })}
/>
)
return (
<div
className={cn(
expanded
? "flex min-h-0 flex-1 flex-col md:flex-row-reverse"
: "contents",
)}
>
<div
className={cn(
"grid min-w-0 gap-6 p-4",
expanded &&
"md:w-[400px] md:shrink-0 md:overflow-y-auto md:border-l md:border-border",
)}
>
<PortList
title="Consumes"
specs={node.requires ?? []}
flow={flow}
emptyHint="Nothing yet. Add a message this node reads."
suggestions={suggestions.consumes}
onChange={(requires) => editNode({ ...node, requires })}
/>
<PortList
title="Provides"
specs={node.provides ?? []}
flow={flow}
emptyHint="Nothing yet. Add a message this node publishes."
suggestions={suggestions.provides}
onChange={(provides) => {
const [from, to] = portMove.current ?? ["", null]
portMove.current = null
editNode(repoint({ ...node, provides }, from, to))
}}
streamable
// Only the publishing side names a message; an input is as often
// re-pointed at a different one as it is renamed.
onRenamed={renamePort}
onPortRenamed={(from, to) => {
portMove.current = [from, to]
}}
onRemoved={(spec) => {
portMove.current = [portName(spec), null]
}}
/>
{settingsAreCode ? (
/* An exec node's command is its code, so the whole form is. */
<fieldset disabled className="grid min-w-0 gap-2">
{paramsForm}
<p
className="text-xs text-muted-foreground"
data-testid="node-settings-locked"
>
{CODE_LOCKED}
</p>
</fieldset>
) : (
paramsForm
)}
{nodeType?.free_params ? (
<FreeParamsForm
params={node.params ?? {}}
reserved={RESERVED_PARAMS}
onChange={(params) => editNode({ ...node, params })}
/>
) : null}
{hasSource ? (
<div className="grid gap-1.5">
<div className="flex items-center gap-1">
<Label htmlFor="node-timeout" className={SECTION}>
Timeout
</Label>
<InfoTip label="Timeout">
Seconds this code may stay silent before it is stopped. A yield
or an emit resets the clock. 0 disables the limit; empty uses
the engine default.
</InfoTip>
</div>
<Input
id="node-timeout"
type="number"
min={0}
step="any"
className="h-8 text-sm"
placeholder="no limit (default)"
value={node.timeout != null ? String(node.timeout) : ""}
onChange={(event) =>
onChange({
...node,
timeout:
event.target.value === ""
? null
: Math.max(0, Number(event.target.value) || 0),
})
}
/>
</div>
) : null}
{hasSource ? (
<ResourcesSection node={node} onChange={editNode} />
) : null}
{hasSource ? (
<SharingSection flow={flow} node={node} onShared={onShared} />
) : null}
</div>
{hasSource ? (
<div
className={cn(
"flex min-h-[280px] min-w-0 flex-1 flex-col gap-2 px-4 pb-4",
expanded && "md:pt-4",
)}
>
<div className="flex items-center justify-between">
<span className={SECTION}>
{node.source_ref ? `Shared code · ${node.source_ref}` : "Code"}
</span>
<Button
variant="ghost"
size="icon-sm"
className="hidden text-muted-foreground md:inline-flex"
onClick={onToggleExpand}
aria-label={
expanded ? "Collapse the editor" : "Expand the editor"
}
data-testid="toggle-editor-size"
>
{expanded ? <Minimize2 /> : <Maximize2 />}
</Button>
</div>
{origin ? (
/* The body below is an import of the real function, and the real
function is somewhere else. Editing it here would be undone by
the next sync, so it is read-only and says where to go. */
<p
className="text-xs text-muted-foreground"
data-testid="node-source-generated"
>
Generated by <span className="font-mono">fluksio sync</span> from{" "}
<span className="font-mono">{origin.repo || "a repository"}</span>
. Edit it there and sync again.
</p>
) : mayEditCode ? null : (
<p
className="text-xs text-muted-foreground"
data-testid="node-source-locked"
>
{CODE_LOCKED}
</p>
)}
<div className="min-h-0 flex-1 overflow-hidden rounded-md border border-border">
<Suspense
fallback={
<div className="h-full w-full animate-pulse bg-muted" />
}
>
<NodeEditor
value={code ?? source?.code ?? ""}
onChange={editCode}
readOnly={Boolean(origin) || !mayEditCode}
/>
</Suspense>
</div>
</div>
) : null}
</div>
)
}
/**
* Node settings, floating over the canvas so the graph stays visible and live.
* On a phone there is no room for that, so it becomes a full-screen sheet.
*/
/** Message names worth offering on each side of a node. */
export type PortSuggestions = { consumes: string[]; provides: string[] }
export function NodePanel({
node,
flow,
nodeTypes,
origin,
suggestions,
expanded,
onChange,
onRenameMessage,
onSaveSource,
onShared,
onToggleExpand,
onClose,
onDelete,
}: {
node: NodeDef_Input | null
flow: string
nodeTypes: NodeTypeInfo[]
/** Set when the flow was declared in code elsewhere; its bodies are generated. */
origin: FlowOrigin | null | undefined
suggestions: PortSuggestions
expanded: boolean
onChange: (next: NodeDef_Input) => void
onRenameMessage: (previous: string, next: string) => void
onSaveSource: (code: string) => void
onShared: () => void
onToggleExpand: () => void
onClose: () => void
onDelete: () => void
}) {
const nodeType = nodeTypes.find((entry) => entry.type === node?.type)
return (
<SidePanel
open={Boolean(node)}
label="Node settings"
testId="node-panel"
bodyKey={node?.id ?? "none"}
expanded={expanded}
onClose={onClose}
header={
node ? (
<div className="flex min-w-0 flex-1 items-center gap-1.5">
{/* Where the node lives: the flow is the namespace of its
messages, so it reads like the folder holding them. */}
<span className="min-w-0 truncate font-mono text-sm text-muted-foreground">
/{flow}/
</span>
<PanelTitle
value={node.title || node.id}
label="Node name"
onConfirm={(title) => onChange({ ...node, title })}
/>
</div>
) : null
}
footer={
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={onDelete}
>
Delete node
</Button>
}
>
{node ? (
<PanelBody
node={node}
flow={flow}
nodeType={nodeType}
origin={origin}
suggestions={suggestions}
expanded={expanded}
onChange={onChange}
onRenameMessage={onRenameMessage}
onSaveSource={onSaveSource}
onShared={onShared}
onToggleExpand={onToggleExpand}
/>
) : null}
</SidePanel>
)
}