A series, record or list message declares its shape instead of riding DType.JSON, so a widget binds a shape rather than some JSON and a wrong binding is refused before anything runs. A list declares its item type, which is what keeps list[float] expressible for a pipeline. On top of that: an agenda over a list, a notification over a record, and a dashboard alert channel that publishes engine faults as one — so a panel can show what went wrong without a flow wiring it by hand. Also: only None means a node published nothing, a falsy value of the wrong shape is now the named error it always should have been; and the gauge's readout says its size is viewBox geometry rather than type scale.
1131 lines
34 KiB
TypeScript
1131 lines
34 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
|
import { Maximize2, Minimize2, X } from "lucide-react"
|
|
import {
|
|
type ComponentProps,
|
|
lazy,
|
|
Suspense,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
} from "react"
|
|
|
|
import {
|
|
type DType,
|
|
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 useCustomToast from "@/hooks/useCustomToast"
|
|
import { inCodeEditor, useShortcuts } from "@/lib/shortcuts"
|
|
import { cn } from "@/lib/utils"
|
|
import { MessageSparkline } from "./MessageSparkline"
|
|
import {
|
|
flowKeys,
|
|
libraryQueryOptions,
|
|
nodeSourceQueryOptions,
|
|
secretsQueryOptions,
|
|
useParamSuggestions,
|
|
} from "./queries"
|
|
import { PANEL_SECTION, PanelTitle, SidePanel } from "./SidePanel"
|
|
|
|
const NodeEditor = lazy(() => import("./NodeEditor"))
|
|
|
|
const DTYPES: DType[] = [
|
|
"float",
|
|
"int",
|
|
"str",
|
|
"bool",
|
|
"json",
|
|
"series",
|
|
"record",
|
|
"list",
|
|
]
|
|
|
|
/** 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 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-[--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
|
|
}}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function PortList({
|
|
title,
|
|
specs,
|
|
flow,
|
|
emptyHint,
|
|
suggestions,
|
|
onChange,
|
|
onRenamed,
|
|
}: {
|
|
title: string
|
|
specs: MessageSpec[]
|
|
flow: string
|
|
emptyHint: string
|
|
suggestions: string[]
|
|
onChange: (next: MessageSpec[]) => void
|
|
onRenamed?: (previous: string, next: string) => void
|
|
}) {
|
|
// The port just added, so its name field can take focus.
|
|
const [freshIndex, setFreshIndex] = useState<number | 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 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) => (
|
|
<div key={`port-${index}`} className="grid gap-1">
|
|
<div className="flex items-center gap-1.5">
|
|
<MessageNameInput
|
|
value={spec.name ?? ""}
|
|
suggestions={suggestions}
|
|
placeholder={`name in ${flow}`}
|
|
autoFocus={index === freshIndex}
|
|
onChange={(name) => update(index, { name, port: "" })}
|
|
onRenamed={onRenamed}
|
|
/>
|
|
<Select
|
|
value={spec.dtype ?? "float"}
|
|
onValueChange={(value) =>
|
|
update(index, { dtype: value as DType })
|
|
}
|
|
>
|
|
<SelectTrigger
|
|
className="!h-8 w-[92px] 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}
|
|
<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-16 text-sm"
|
|
onChange={(event) =>
|
|
update(index, { interval: Number(event.target.value) || 0 })
|
|
}
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
className="text-muted-foreground"
|
|
aria-label="Remove port"
|
|
onClick={() => onChange(specs.filter((_, i) => i !== index))}
|
|
>
|
|
<X />
|
|
</Button>
|
|
</div>
|
|
{spec.name ? <MessageSparkline flow={flow} name={spec.name} /> : 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 {
|
|
return JSON.parse(raw)
|
|
} catch {
|
|
// Half-typed JSON is normal while editing; keep the text until it parses.
|
|
return raw
|
|
}
|
|
}
|
|
return raw
|
|
}
|
|
|
|
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) => {
|
|
if (to === from) 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">
|
|
Values your code reads from <code>params</code>.
|
|
</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 })
|
|
}
|
|
/>
|
|
) : (
|
|
<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,
|
|
onPick,
|
|
}: {
|
|
params: Record<string, unknown>
|
|
onPick: (expression: string) => void
|
|
}) {
|
|
const derived = cronFromInterval(params.interval)
|
|
|
|
return (
|
|
<>
|
|
<p className="text-xs text-muted-foreground">
|
|
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.
|
|
</p>
|
|
{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}
|
|
</>
|
|
)
|
|
}
|
|
|
|
/** A small form built from the node type's declared parameters. */
|
|
function ParamsForm({
|
|
type,
|
|
schema,
|
|
params,
|
|
onChange,
|
|
}: {
|
|
type: string | undefined
|
|
schema: Record<string, unknown> | undefined
|
|
params: Record<string, unknown>
|
|
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]) => {
|
|
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">
|
|
<Label className="text-sm font-normal">{label}</Label>
|
|
<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>
|
|
<p className="text-xs text-muted-foreground">
|
|
{secrets.length
|
|
? property.description ||
|
|
"Stored in the secrets store, never in the flow file."
|
|
: "No secrets stored yet."}
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (property.type === "object" || property.type === "array") {
|
|
return null
|
|
}
|
|
|
|
const numeric =
|
|
property.type === "integer" || property.type === "number"
|
|
return (
|
|
<div key={key} className="grid gap-1.5">
|
|
<Label htmlFor={`param-${key}`} className="text-sm font-normal">
|
|
{label}
|
|
</Label>
|
|
<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)}
|
|
/>
|
|
{property.description ? (
|
|
<p className="text-xs text-muted-foreground">
|
|
{property.description}
|
|
</p>
|
|
) : null}
|
|
{key === "cron" ? (
|
|
<CronHelp
|
|
params={params}
|
|
onPick={(expression) => set(key, expression)}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
)
|
|
})}
|
|
</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">
|
|
<span className={SECTION}>Reuse</span>
|
|
<p className="text-sm text-muted-foreground">
|
|
Move this node's code to the library so other flows can run it too.
|
|
</p>
|
|
<div className="flex items-center gap-1.5">
|
|
<Input
|
|
value={name}
|
|
placeholder="read_temperature"
|
|
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: "[]",
|
|
}
|
|
|
|
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() || ""
|
|
|
|
/** A `process` that takes this node's inputs and returns its outputs. */
|
|
function scaffoldFor(node: NodeDef_Input): string {
|
|
const args = [
|
|
...new Set(
|
|
(node.requires ?? [])
|
|
.map(portName)
|
|
// Anything else cannot be a keyword argument, so it cannot be a port.
|
|
.filter((port) => /^[A-Za-z_]\w*$/.test(port) && port !== "params"),
|
|
),
|
|
]
|
|
const returns = (node.provides ?? [])
|
|
.filter((spec) => portName(spec))
|
|
.map((spec) => `"${portName(spec)}": ${PLACEHOLDER[spec.dtype ?? "float"]}`)
|
|
return `${SCAFFOLD_DOC}\n\n\ndef process(${[...args, "params"].join(
|
|
", ",
|
|
)}):\n return {${returns.join(", ")}}\n`
|
|
}
|
|
|
|
function PanelBody({
|
|
node,
|
|
flow,
|
|
nodeType,
|
|
suggestions,
|
|
expanded,
|
|
onChange,
|
|
onRenameMessage,
|
|
onSaveSource,
|
|
onShared,
|
|
onToggleExpand,
|
|
}: {
|
|
node: NodeDef_Input
|
|
flow: string
|
|
nodeType: NodeTypeInfo | 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,
|
|
})
|
|
|
|
const [code, setCode] = useState<string | 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 || next.source_ref || current === undefined) return
|
|
const wanted = scaffoldFor(next)
|
|
if (current !== wanted && SCAFFOLD_SHAPE.test(current)) editCode(wanted)
|
|
}
|
|
|
|
// ⌘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)
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
return (
|
|
<>
|
|
<div className={cn("grid gap-6 p-4", expanded && "max-w-2xl")}>
|
|
<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) => editNode({ ...node, provides })}
|
|
// Only the publishing side names a message; an input is as often
|
|
// re-pointed at a different one as it is renamed.
|
|
onRenamed={onRenameMessage}
|
|
/>
|
|
<ParamsForm
|
|
type={node.type}
|
|
schema={nodeType?.params_schema}
|
|
params={node.params ?? {}}
|
|
onChange={(params) => onChange({ ...node, params })}
|
|
/>
|
|
{nodeType?.free_params ? (
|
|
<FreeParamsForm
|
|
params={node.params ?? {}}
|
|
reserved={RESERVED_PARAMS}
|
|
onChange={(params) => onChange({ ...node, params })}
|
|
/>
|
|
) : null}
|
|
{hasSource ? (
|
|
<div className="grid gap-1.5">
|
|
<Label htmlFor="node-timeout" className={SECTION}>
|
|
Timeout
|
|
</Label>
|
|
<Input
|
|
id="node-timeout"
|
|
type="number"
|
|
min={0}
|
|
step="any"
|
|
className="h-8 text-sm"
|
|
placeholder="30 (default)"
|
|
value={node.timeout ? String(node.timeout) : ""}
|
|
onChange={(event) =>
|
|
onChange({
|
|
...node,
|
|
timeout: Number(event.target.value) || null,
|
|
})
|
|
}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Seconds this code may run before it is stopped. Above 60 the
|
|
engine may deliver the same work again while it is still running.
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
{hasSource ? (
|
|
<SharingSection flow={flow} node={node} onShared={onShared} />
|
|
) : null}
|
|
</div>
|
|
|
|
{hasSource ? (
|
|
<div className="flex min-h-[280px] flex-1 flex-col gap-2 px-4 pb-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>
|
|
<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}
|
|
/>
|
|
</Suspense>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
suggestions,
|
|
expanded,
|
|
onChange,
|
|
onRenameMessage,
|
|
onSaveSource,
|
|
onShared,
|
|
onToggleExpand,
|
|
onClose,
|
|
onDelete,
|
|
}: {
|
|
node: NodeDef_Input | null
|
|
flow: string
|
|
nodeTypes: NodeTypeInfo[]
|
|
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}
|
|
suggestions={suggestions}
|
|
expanded={expanded}
|
|
onChange={onChange}
|
|
onRenameMessage={onRenameMessage}
|
|
onSaveSource={onSaveSource}
|
|
onShared={onShared}
|
|
onToggleExpand={onToggleExpand}
|
|
/>
|
|
) : null}
|
|
</SidePanel>
|
|
)
|
|
}
|