`ConnectorNode.store` keeps short strings per connector package in the
engine's database (`connector_state`, migration f8c3e5a91d27), so the nodes of
one package share them and `fluksio serve` needs nothing new: get, set and
delete, at most 64 kB a value and 256 keys a package. A superuser lists the
keys (never the values) and clears them through `/connectors/{node_type}/state`,
or under Stored state in a connector node's panel. Documented in the connector
contract.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GAeg4Jo6xjZzjwy9QDWMQ
1767 lines
55 KiB
TypeScript
1767 lines
55 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 {
|
|
ConnectorsService,
|
|
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)
|
|
|
|
// Secret names are a superuser's to read; anyone else has none to offer.
|
|
const { user } = useAuth()
|
|
const { data: secretList } = useQuery({
|
|
...secretsQueryOptions(),
|
|
enabled: Boolean(user?.is_superuser),
|
|
})
|
|
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$`,
|
|
)
|
|
})()
|
|
|
|
/**
|
|
* What a connector keeps across restarts, such as a certificate it pinned on
|
|
* first contact. Clearing it is how a replaced device gets trusted again.
|
|
*/
|
|
function StoredStateSection({ nodeType }: { nodeType: string }) {
|
|
const queryClient = useQueryClient()
|
|
const { showSuccessToast, showErrorToast } = useCustomToast()
|
|
const [confirming, setConfirming] = useState(false)
|
|
const queryKey = ["connector-state", nodeType]
|
|
const { data } = useQuery({
|
|
queryKey,
|
|
queryFn: () => ConnectorsService.readConnectorState({ nodeType }),
|
|
})
|
|
const clear = useMutation({
|
|
mutationFn: () => ConnectorsService.clearConnectorState({ nodeType }),
|
|
onSuccess: () => {
|
|
showSuccessToast("Stored state cleared")
|
|
setConfirming(false)
|
|
queryClient.invalidateQueries({ queryKey })
|
|
},
|
|
onError: () => showErrorToast("The stored state could not be cleared."),
|
|
})
|
|
|
|
if (!data) return null
|
|
return (
|
|
<div className="grid gap-2" data-testid="connector-state">
|
|
<div className="flex items-center gap-1">
|
|
<span className={SECTION}>Stored state</span>
|
|
<InfoTip label="Stored state">
|
|
What {data.connector} keeps across restarts, such as a certificate
|
|
pinned on first contact. Every node of this connector shares it, and
|
|
clearing it makes them start over.
|
|
</InfoTip>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<span className="text-sm text-muted-foreground">
|
|
{data.count === 0
|
|
? "Nothing stored"
|
|
: `${data.count} key${data.count === 1 ? "" : "s"}`}
|
|
</span>
|
|
{confirming ? (
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8"
|
|
onClick={() => setConfirming(false)}
|
|
>
|
|
Keep
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
className="h-8"
|
|
disabled={clear.isPending}
|
|
onClick={() => clear.mutate()}
|
|
data-testid="confirm-clear-state"
|
|
>
|
|
Clear all
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8"
|
|
disabled={data.count === 0}
|
|
onClick={() => setConfirming(true)}
|
|
data-testid="clear-state"
|
|
>
|
|
Clear
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** 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}
|
|
{nodeType?.plugin && user?.is_superuser ? (
|
|
<StoredStateSection nodeType={nodeType.type} />
|
|
) : 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>
|
|
)
|
|
}
|