Node source (Python) and the JSON dialog share one CodeMirror editor with find and replace, autocomplete, folding, indent guides, comment toggle and a JSON lint, themed from the CSS tokens so light and dark follow the .dark class without a second palette. Programmatic value changes no longer fire onChange, blur ignores focus moving into the find bar, readOnly keeps selection and copy. The editor chunk is 470 kB (157 kB gzipped) against 2.7 MB (704 kB) for Monaco, which also pulled in a 1.2 MB JSON mode, two workers and a font. At 4x CPU on slow 4G a node panel is ready in 5.7 s instead of 10.8 s, the JSON dialog in 1.5 s instead of 6.7 s. Typing without a key delay keeps every character. monaco-editor, @monaco-editor/react and the dompurify override are removed. Specs: typed code reaches the node source, a JSON setting saves only text that parses. Docs name Tab and Ctrl-M. Claude-Session: https://claude.ai/code/session_01SSi5AvZ7pFmySK8zy3NPMh
2471 lines
80 KiB
TypeScript
2471 lines
80 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||
import { Link } from "@tanstack/react-router"
|
||
import {
|
||
Activity,
|
||
CornerDownRight,
|
||
Maximize2,
|
||
Minimize2,
|
||
Pencil,
|
||
Trash2,
|
||
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 {
|
||
DEVICE_TYPE,
|
||
portOf,
|
||
type ReportedPort,
|
||
reportedPorts,
|
||
wiresAs,
|
||
} from "@/components/Devices/ports"
|
||
import { devicesQueryOptions } from "@/components/Devices/queries"
|
||
import { isLive, shortId } from "@/components/Runs/queries"
|
||
import { RunStatusBadge } from "@/components/Runs/RunStatus"
|
||
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 { handleError } from "@/utils"
|
||
import { qualify } from "./deriveEdges"
|
||
import { DtypeValue } from "./FlowBoundary"
|
||
import {
|
||
IDENTIFIER,
|
||
portName,
|
||
processArgs,
|
||
RESERVED_PARAMS,
|
||
withHeader,
|
||
} from "./header"
|
||
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
|
||
|
||
/** 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,
|
||
className,
|
||
onChange,
|
||
onRenamed,
|
||
}: {
|
||
value: string
|
||
suggestions: string[]
|
||
placeholder: string
|
||
autoFocus: boolean
|
||
/** Replaces the flex share, for a row that wraps. */
|
||
className?: string
|
||
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"
|
||
title={value || undefined}
|
||
// A port added by hand is meant to be named right away.
|
||
autoFocus={autoFocus}
|
||
className={cn(
|
||
"h-11 min-w-0 font-mono text-sm md:h-8",
|
||
className ?? "flex-1",
|
||
)}
|
||
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
|
||
}
|
||
|
||
/**
|
||
* The ports of one side of a node, a row each.
|
||
*
|
||
* Data reads top to bottom. An input starts from a message and ends as an
|
||
* argument of the node's code, so the message is on top and the argument
|
||
* below it. An output starts as a key of what the code returns and ends as a
|
||
* message, so the key is on top. A name the code uses only gets a field of its
|
||
* own when it differs from the message's last segment, which is what it
|
||
* follows until it is named by hand; until then it is muted text.
|
||
*/
|
||
function PortList({
|
||
title,
|
||
side,
|
||
specs,
|
||
flow,
|
||
emptyHint,
|
||
suggestions,
|
||
onChange,
|
||
onRenamed,
|
||
onPortRenamed,
|
||
onRemoved,
|
||
}: {
|
||
title: string
|
||
/** An input reads a message into the code, an output writes one out of it. */
|
||
side: "requires" | "provides"
|
||
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
|
||
}) {
|
||
const reads = side === "requires"
|
||
// 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,
|
||
)
|
||
// The port whose name was opened for naming by hand with the pencil.
|
||
const [opened, setOpened] = 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 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
|
||
const code = portName(spec)
|
||
// Two ports that derive one name would only differ by what they carry.
|
||
const clash =
|
||
!problem &&
|
||
code !== "" &&
|
||
specs.some((other, i) => i !== index && portName(other) === code)
|
||
const hasField = own !== "" || opened === index || typed !== null
|
||
|
||
const codeName = hasField ? (
|
||
<Input
|
||
value={naming?.index === index ? naming.text : own}
|
||
placeholder={portName({ name: spec.name }) || "port"}
|
||
aria-label={reads ? "Argument name" : "Port name"}
|
||
aria-invalid={Boolean(problem)}
|
||
autoFocus={opened === index}
|
||
title="The name the node sees this port by. Empty follows the message."
|
||
className="h-11 min-w-0 flex-1 font-mono text-sm md:h-8"
|
||
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)
|
||
setOpened(null)
|
||
}}
|
||
/>
|
||
) : (
|
||
<span
|
||
className="flex min-w-0 flex-1 items-center gap-1"
|
||
data-testid="port-follows"
|
||
>
|
||
<span
|
||
className="min-w-0 truncate px-3 font-mono text-sm text-muted-foreground"
|
||
title={`The node sees this port as ${code || "its message's name"}, following the message.`}
|
||
>
|
||
{code || "message name"}
|
||
</span>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-touch"
|
||
className="shrink-0 text-muted-foreground"
|
||
aria-label={`Name the ${reads ? "argument" : "port"} differently`}
|
||
title="Name it differently from the message"
|
||
onClick={() => setOpened(index)}
|
||
>
|
||
<Pencil />
|
||
</Button>
|
||
</span>
|
||
)
|
||
|
||
const messageName = (
|
||
<MessageNameInput
|
||
value={spec.name ?? ""}
|
||
suggestions={suggestions}
|
||
placeholder={`message in ${flow}`}
|
||
autoFocus={index === freshIndex}
|
||
className={reads ? undefined : "flex-[1_1_10rem]"}
|
||
onChange={(name) =>
|
||
update(index, {
|
||
name,
|
||
port: ownsName(spec) ? spec.port : "",
|
||
})
|
||
}
|
||
onRenamed={onRenamed}
|
||
/>
|
||
)
|
||
|
||
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">
|
||
{reads ? messageName : codeName}
|
||
<Select
|
||
value={spec.dtype ?? "float"}
|
||
onValueChange={(value) =>
|
||
update(index, { dtype: value as DType })
|
||
}
|
||
>
|
||
<SelectTrigger
|
||
className="!h-11 w-[104px] text-sm md:!h-8"
|
||
aria-label="Type"
|
||
>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{DTYPES.map((dtype) => (
|
||
<SelectItem key={dtype} value={dtype}>
|
||
{dtype}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-touch"
|
||
className="shrink-0 text-muted-foreground"
|
||
aria-label="Remove port"
|
||
onClick={() => {
|
||
onRemoved?.(spec)
|
||
setOpened(null)
|
||
onChange(specs.filter((_, i) => i !== index))
|
||
}}
|
||
>
|
||
<X />
|
||
</Button>
|
||
</div>
|
||
{/* Wraps for a list port, whose item type would otherwise take
|
||
the room the name needs. */}
|
||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||
<CornerDownRight
|
||
aria-hidden
|
||
className="size-4 shrink-0 text-muted-foreground"
|
||
/>
|
||
{reads ? (
|
||
<span className="flex min-w-0 flex-[1_1_10rem] items-center gap-1.5">
|
||
<span className="shrink-0 text-xs text-muted-foreground">
|
||
as
|
||
</span>
|
||
{codeName}
|
||
</span>
|
||
) : (
|
||
messageName
|
||
)}
|
||
{/* Together, so a narrow row moves all of them and not one. */}
|
||
<span className="ml-auto flex shrink-0 items-center gap-1.5">
|
||
{spec.dtype === "list" ? (
|
||
<Select
|
||
value={spec.item ?? "record"}
|
||
onValueChange={(value) =>
|
||
update(index, { item: value as DType })
|
||
}
|
||
>
|
||
<SelectTrigger
|
||
className="!h-11 w-[92px] text-sm md:!h-8"
|
||
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-11 w-14 text-sm md:h-8"
|
||
onChange={(event) =>
|
||
update(index, { interval: Number(event.target.value) || 0 })
|
||
}
|
||
/>
|
||
{/* An input's own flags (it reads without waking the node) go
|
||
here, as an output's streaming toggle does. */}
|
||
{side === "provides" ? (
|
||
<Button
|
||
variant={spec.stream ? "secondary" : "ghost"}
|
||
size="icon-touch"
|
||
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}
|
||
</span>
|
||
</div>
|
||
{problem ? (
|
||
<p className="text-xs text-destructive">{problem}</p>
|
||
) : clash ? (
|
||
<p
|
||
className="text-xs text-status-warning"
|
||
data-testid="port-clash"
|
||
>
|
||
Another {reads ? "input" : "output"} here is also called {code}.
|
||
Name one of them differently.
|
||
</p>
|
||
) : null}
|
||
{spec.name ? (
|
||
<MessageSparkline
|
||
flow={flow}
|
||
name={spec.name}
|
||
dtype={spec.dtype ?? undefined}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const objectOnly = (value: unknown) =>
|
||
value && typeof value === "object" && !Array.isArray(value)
|
||
? null
|
||
: "Config is a JSON object"
|
||
|
||
/**
|
||
* A device node: the device it stands for, and the message each of its ports
|
||
* carries. A port the device publishes is an output here, one it follows an
|
||
* input that wakes nothing. With a report the ports are picked from what the
|
||
* device has, without one they are typed in.
|
||
*/
|
||
function DeviceSettings({
|
||
node,
|
||
flow,
|
||
suggestions,
|
||
readers,
|
||
onChange,
|
||
}: {
|
||
node: NodeDef_Input
|
||
flow: string
|
||
suggestions: PortSuggestions
|
||
/** Qualified messages another node of this flow reads. */
|
||
readers: ReadonlySet<string>
|
||
onChange: (next: NodeDef_Input) => void
|
||
}) {
|
||
const { data } = useQuery(devicesQueryOptions())
|
||
const devices = data?.data ?? []
|
||
const id = String(node.params?.device ?? "")
|
||
const device = devices.find((one) => one.id === id)
|
||
const reported = reportedPorts(device)
|
||
const config = (node.params?.config ?? {}) as Record<string, unknown>
|
||
const titleOf = (one?: { id: string; title?: string }) =>
|
||
one ? one.title || one.id : ""
|
||
|
||
/** The ports of one side, with the config following a renamed port. */
|
||
const ports =
|
||
(side: "provides" | "requires") =>
|
||
(specs: MessageSpec[], renamed?: [string, string | null]) => {
|
||
const next = { ...config }
|
||
if (renamed) {
|
||
const [from, to] = renamed
|
||
if (to && from in next) next[to] = next[from]
|
||
delete next[from]
|
||
}
|
||
onChange({
|
||
...node,
|
||
params: { ...node.params, config: next },
|
||
[side]: specs,
|
||
})
|
||
}
|
||
/** One port's config. An empty one is no config, and is not kept. */
|
||
const setConfig = (port: string, value: Record<string, unknown>) => {
|
||
const next = { ...config, [port]: value }
|
||
if (Object.keys(value).length === 0) delete next[port]
|
||
onChange({ ...node, params: { ...node.params, config: next } })
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<div className="grid gap-1.5">
|
||
<FieldLabel htmlFor="device-node-device">Device</FieldLabel>
|
||
<Select
|
||
value={id || undefined}
|
||
onValueChange={(next) => {
|
||
const picked = devices.find((one) => one.id === next)
|
||
// A title that only ever named the device follows it.
|
||
const title =
|
||
!node.title || node.title === (titleOf(device) || id)
|
||
? titleOf(picked)
|
||
: node.title
|
||
onChange({
|
||
...node,
|
||
title,
|
||
params: { ...node.params, device: next },
|
||
})
|
||
}}
|
||
>
|
||
<SelectTrigger
|
||
id="device-node-device"
|
||
className="!h-8 w-full text-sm"
|
||
data-testid="device-node-device"
|
||
>
|
||
<SelectValue placeholder="Pick a device" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{devices.map((one) => (
|
||
<SelectItem key={one.id} value={one.id}>
|
||
{titleOf(one)}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<DevicePortList
|
||
title="Publishes"
|
||
help="Ports the device sends from, such as a button. Each publishes the message beside it."
|
||
direction="in"
|
||
specs={node.provides ?? []}
|
||
flow={flow}
|
||
config={config}
|
||
reported={reported}
|
||
readers={readers}
|
||
suggestions={suggestions.provides}
|
||
onChange={ports("provides")}
|
||
onConfig={setConfig}
|
||
/>
|
||
<DevicePortList
|
||
title="Follows"
|
||
help="Ports the device drives, such as its backlight. Each follows the message beside it."
|
||
direction="out"
|
||
specs={node.requires ?? []}
|
||
flow={flow}
|
||
config={config}
|
||
reported={reported}
|
||
suggestions={suggestions.consumes}
|
||
onChange={ports("requires")}
|
||
onConfig={setConfig}
|
||
/>
|
||
</>
|
||
)
|
||
}
|
||
|
||
function DevicePortList({
|
||
title,
|
||
help,
|
||
direction,
|
||
specs,
|
||
flow,
|
||
config,
|
||
reported,
|
||
readers,
|
||
suggestions,
|
||
onChange,
|
||
onConfig,
|
||
}: {
|
||
title: string
|
||
help: string
|
||
direction: "in" | "out"
|
||
specs: MessageSpec[]
|
||
flow: string
|
||
/** Per port, what the device is handed as it is. */
|
||
config: Record<string, unknown>
|
||
/** What the device says it has, or null when it has not said. */
|
||
reported: ReportedPort[] | null
|
||
/** Qualified messages another node of this flow reads. */
|
||
readers?: ReadonlySet<string>
|
||
suggestions: string[]
|
||
/** `renamed` is a port taking another name, or dropped (`to` of null). */
|
||
onChange: (next: MessageSpec[], renamed?: [string, string | null]) => void
|
||
onConfig: (port: string, next: Record<string, unknown>) => void
|
||
}) {
|
||
// An `out` port is one the device is driven through: it follows a message.
|
||
const follows = direction === "out"
|
||
const offered = reported?.filter((port) => wiresAs(port, direction))
|
||
// An out port is read when the device asks for it, never a reason to run.
|
||
const fresh = (port: ReportedPort | null): MessageSpec => ({
|
||
name: port ? port.port.replace(/\./g, "_") : "",
|
||
port: port?.port ?? "",
|
||
dtype: port?.dtype ?? "float",
|
||
...(direction === "out" ? { trigger: false } : {}),
|
||
})
|
||
const update = (index: number, patch: Partial<MessageSpec>) =>
|
||
onChange(
|
||
specs.map((spec, at) => (at === index ? { ...spec, ...patch } : spec)),
|
||
)
|
||
|
||
return (
|
||
<div
|
||
className="grid min-w-0 gap-3"
|
||
data-testid={`device-ports-${direction}`}
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<span className="flex items-center gap-1">
|
||
<span className={SECTION}>{title}</span>
|
||
<InfoTip label={title}>{help}</InfoTip>
|
||
</span>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-7 text-xs text-muted-foreground"
|
||
onClick={() => onChange([...specs, fresh(null)])}
|
||
>
|
||
Add
|
||
</Button>
|
||
</div>
|
||
{specs.length === 0 ? (
|
||
<p className="text-sm text-muted-foreground">No ports yet.</p>
|
||
) : null}
|
||
{specs.map((spec, index) => {
|
||
const port = spec.port ?? ""
|
||
const name = port || "new port"
|
||
const said = reported?.find((one) => one.port === port)
|
||
// The device says which settings it reads, so they get a form.
|
||
const schema =
|
||
said?.config &&
|
||
Object.keys((said.config.properties as object) ?? {}).length
|
||
? said.config
|
||
: undefined
|
||
// The device's own port: picked from what it reports, or typed.
|
||
const portField = offered ? (
|
||
<Select
|
||
value={port || undefined}
|
||
onValueChange={(next) => {
|
||
// The device says what it carries, so the type follows.
|
||
const picked = offered.find((one) => one.port === next)
|
||
onChange(
|
||
specs.map((one, at) =>
|
||
at === index
|
||
? {
|
||
...one,
|
||
port: next,
|
||
dtype: picked?.dtype ?? one.dtype,
|
||
}
|
||
: one,
|
||
),
|
||
port ? [port, next] : undefined,
|
||
)
|
||
}}
|
||
>
|
||
<SelectTrigger
|
||
className="!h-11 min-w-0 flex-1 font-mono text-sm md:!h-8"
|
||
aria-label={`Port of ${name}`}
|
||
>
|
||
<SelectValue placeholder="Pick a port" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{/* One the report has dropped stays pickable as it is. */}
|
||
{port && !offered.some((one) => one.port === port) ? (
|
||
<SelectItem value={port}>{port}</SelectItem>
|
||
) : null}
|
||
{offered.map((one) => (
|
||
<SelectItem key={one.port} value={one.port}>
|
||
{one.port}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
) : (
|
||
<Input
|
||
// Remounted with whatever was saved, so a refused name snaps back.
|
||
key={port}
|
||
defaultValue={port}
|
||
placeholder="Port, e.g. gpio.4"
|
||
aria-label={`Port of ${name}`}
|
||
autoComplete="off"
|
||
className="h-11 min-w-0 flex-1 font-mono text-sm md:h-8"
|
||
onBlur={(event) => {
|
||
const next = portOf(event.currentTarget.value)
|
||
event.currentTarget.value = next || port
|
||
if (next && next !== port)
|
||
onChange(
|
||
specs.map((one, at) =>
|
||
at === index ? { ...one, port: next } : one,
|
||
),
|
||
port ? [port, next] : undefined,
|
||
)
|
||
}}
|
||
/>
|
||
)
|
||
const messageField = (
|
||
<MessageNameInput
|
||
value={spec.name ?? ""}
|
||
suggestions={suggestions}
|
||
placeholder={`message in ${flow}`}
|
||
autoFocus={false}
|
||
onChange={(next) => update(index, { name: next })}
|
||
/>
|
||
)
|
||
// A port the device publishes is read top to bottom as its port, then
|
||
// the message. One it follows is a message, then the port it drives.
|
||
return (
|
||
<div key={`port-${index}`} className="grid min-w-0 gap-1.5">
|
||
<div className="flex min-w-0 items-center gap-1.5">
|
||
{follows ? messageField : portField}
|
||
<Select
|
||
value={spec.dtype ?? "float"}
|
||
onValueChange={(value) =>
|
||
update(index, { dtype: value as DType })
|
||
}
|
||
>
|
||
<SelectTrigger
|
||
className="!h-11 w-[104px] text-sm md:!h-8"
|
||
aria-label={`Type of ${name}`}
|
||
>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{DTYPES.map((dtype) => (
|
||
<SelectItem key={dtype} value={dtype}>
|
||
{dtype}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-touch"
|
||
className="shrink-0 text-muted-foreground"
|
||
aria-label={`Remove ${name}`}
|
||
onClick={() =>
|
||
onChange(
|
||
specs.filter((_, at) => at !== index),
|
||
port ? [port, null] : undefined,
|
||
)
|
||
}
|
||
>
|
||
<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"
|
||
/>
|
||
{follows ? (
|
||
<span className="flex min-w-0 flex-1 items-center gap-1.5">
|
||
<span className="shrink-0 text-xs text-muted-foreground">
|
||
to
|
||
</span>
|
||
{portField}
|
||
</span>
|
||
) : (
|
||
messageField
|
||
)}
|
||
{schema ? null : (
|
||
<JsonField
|
||
value={config[port] ?? {}}
|
||
label={`Config of ${name}`}
|
||
check={objectOnly}
|
||
className="h-11 w-24 shrink-0 md:h-8"
|
||
onChange={(next) => {
|
||
if (port) onConfig(port, next as Record<string, unknown>)
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
{reported && port && !said ? (
|
||
<p className="pl-5.5 text-xs text-muted-foreground">
|
||
The device does not report this port.
|
||
</p>
|
||
) : null}
|
||
{said?.on_demand &&
|
||
spec.name &&
|
||
readers?.has(qualify(flow, spec.name)) ? (
|
||
<p
|
||
className="pl-5.5 text-xs text-status-warning"
|
||
data-testid="device-port-always-on"
|
||
>
|
||
A node in this flow reads every frame, so {port} sends all the
|
||
time, also while nobody watches. Read snapshot or person
|
||
instead.
|
||
</p>
|
||
) : null}
|
||
{schema ? (
|
||
<div className="pl-5.5">
|
||
<ParamsForm
|
||
type={undefined}
|
||
title={`${port} settings`}
|
||
schema={schema}
|
||
params={(config[port] ?? {}) as Record<string, unknown>}
|
||
provides={[]}
|
||
onChange={(next) => onConfig(port, next)}
|
||
/>
|
||
</div>
|
||
) : 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
|
||
// Its own key, so a name the code's header changed shows up.
|
||
key={key}
|
||
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,
|
||
title = "Settings",
|
||
schema,
|
||
params,
|
||
provides,
|
||
onChange,
|
||
}: {
|
||
type: string | undefined
|
||
title?: string
|
||
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
|
||
enum?: 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}>{title}</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>
|
||
)
|
||
}
|
||
|
||
// A closed set of choices, such as a pin's pull.
|
||
if (property.enum?.length) {
|
||
return (
|
||
<div key={key} className="grid gap-1.5">
|
||
<FieldLabel htmlFor={`param-${key}`} help={property.description}>
|
||
{label}
|
||
</FieldLabel>
|
||
<Select
|
||
value={String(value)}
|
||
onValueChange={(next) =>
|
||
set(
|
||
key,
|
||
property.enum?.find((choice) => String(choice) === next),
|
||
)
|
||
}
|
||
>
|
||
<SelectTrigger id={`param-${key}`} className="!h-8 text-sm">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{property.enum.map((choice) => (
|
||
<SelectItem key={String(choice)} value={String(choice)}>
|
||
{String(choice)}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</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>
|
||
)
|
||
}
|
||
|
||
/** A fraction as a percentage, or a dash when there is none. */
|
||
const percent = (value: unknown) =>
|
||
typeof value === "number"
|
||
? value.toLocaleString(undefined, {
|
||
style: "percent",
|
||
maximumFractionDigits: 1,
|
||
})
|
||
: "–"
|
||
|
||
/**
|
||
* Train a new model for a wren node and offer it as the node's `model`. Using
|
||
* it is an edit like any other, so it waits in the draft until publishing.
|
||
*/
|
||
function TrainingSection({
|
||
flow,
|
||
node,
|
||
onUse,
|
||
}: {
|
||
flow: string
|
||
node: NodeDef_Input
|
||
onUse: (model: string) => void
|
||
}) {
|
||
const queryClient = useQueryClient()
|
||
// Why it would not start, such as wren's training flow not being registered.
|
||
const [problem, setProblem] = useState<string | null>(null)
|
||
const queryKey = ["wren-training", flow, node.id]
|
||
const { data: training } = useQuery({
|
||
queryKey,
|
||
queryFn: () => FlowsService.readTraining({ name: flow, nodeId: node.id }),
|
||
// Training takes minutes, so only a run still going is read again.
|
||
refetchInterval: (query) =>
|
||
query.state.data && isLive(query.state.data.status) ? 5_000 : false,
|
||
})
|
||
const train = useMutation({
|
||
mutationFn: () => FlowsService.trainNode({ name: flow, nodeId: node.id }),
|
||
onMutate: () => setProblem(null),
|
||
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
|
||
onError: handleError.bind(setProblem),
|
||
})
|
||
const running = training ? isLive(training.status) : false
|
||
const model = training?.model
|
||
const inUse = Boolean(model) && node.params?.model === model
|
||
|
||
return (
|
||
<div className="grid gap-2" data-testid="wren-training">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="flex items-center gap-1">
|
||
<span className={SECTION}>Training</span>
|
||
<InfoTip label="Training">
|
||
Trains a new model on the targets wired here and on the examples and
|
||
tests, as a run of wren's training flow. The node keeps its model
|
||
until you use the new one and publish.
|
||
</InfoTip>
|
||
</div>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-8"
|
||
disabled={train.isPending || running}
|
||
onClick={() => train.mutate()}
|
||
data-testid="wren-train"
|
||
>
|
||
Train
|
||
</Button>
|
||
</div>
|
||
{problem ? <p className="text-xs text-destructive">{problem}</p> : null}
|
||
{training ? (
|
||
<div className="flex items-center gap-2">
|
||
<RunStatusBadge
|
||
run={{ status: training.status, status_reason: "", labels: [] }}
|
||
/>
|
||
<Link
|
||
to="/runs/$id"
|
||
params={{ id: training.id }}
|
||
className="font-mono text-sm hover:underline"
|
||
>
|
||
{shortId(training.id)}
|
||
</Link>
|
||
</div>
|
||
) : null}
|
||
{training?.error ? (
|
||
<p className="text-xs text-destructive">{training.error}</p>
|
||
) : null}
|
||
{training && model ? (
|
||
<>
|
||
<dl className="grid gap-1 text-sm">
|
||
{[
|
||
["Exact match", percent(training.result?.exact_match)],
|
||
["Coverage", percent(training.result?.coverage)],
|
||
["Threshold", training.threshold?.toFixed(2) ?? "–"],
|
||
["Test exact match", percent(training.test_exact_match)],
|
||
].map(([label, value]) => (
|
||
<div key={label} className="flex justify-between gap-3">
|
||
<dt className="text-muted-foreground">{label}</dt>
|
||
<dd className="font-mono">{value}</dd>
|
||
</div>
|
||
))}
|
||
</dl>
|
||
<Button
|
||
size="sm"
|
||
className="h-8 justify-self-start"
|
||
disabled={inUse}
|
||
onClick={() => onUse(model)}
|
||
data-testid="wren-use-model"
|
||
>
|
||
{inUse ? "In use" : "Use this model"}
|
||
</Button>
|
||
</>
|
||
) : null}
|
||
</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. Deleting a key is how one replaced device gets trusted again;
|
||
* clearing them all starts every device over.
|
||
*/
|
||
function StoredStateSection({ nodeType }: { nodeType: string }) {
|
||
const queryClient = useQueryClient()
|
||
const { showSuccessToast, showErrorToast } = useCustomToast()
|
||
const [confirming, setConfirming] = useState(false)
|
||
// The key whose delete was pressed once; a second press deletes it.
|
||
const [armed, setArmed] = useState<string | null>(null)
|
||
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."),
|
||
})
|
||
const forget = useMutation({
|
||
mutationFn: (key: string) =>
|
||
ConnectorsService.deleteConnectorState({ nodeType, key }),
|
||
onSuccess: (_, key) => {
|
||
showSuccessToast(`Deleted '${key}'`)
|
||
queryClient.invalidateQueries({ queryKey })
|
||
},
|
||
onError: () => showErrorToast("The key could not be deleted."),
|
||
})
|
||
|
||
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>
|
||
{data.data.length > 0 ? (
|
||
<ul className="grid gap-1">
|
||
{data.data.map(({ key }) => {
|
||
// The icon says nothing of its own, so the label carries the
|
||
// confirm step.
|
||
const label =
|
||
armed === key ? `Confirm: delete ${key}` : `Delete ${key}`
|
||
return (
|
||
<li key={key} className="flex min-w-0 items-center gap-2">
|
||
<span className="min-w-0 flex-1 truncate font-mono text-sm">
|
||
{key}
|
||
</span>
|
||
<Button
|
||
variant={armed === key ? "destructive" : "ghost"}
|
||
size="icon-touch"
|
||
className={cn(
|
||
"shrink-0",
|
||
armed !== key && "text-muted-foreground",
|
||
)}
|
||
aria-label={label}
|
||
title={label}
|
||
disabled={forget.isPending}
|
||
onBlur={() => setArmed(null)}
|
||
onClick={() => {
|
||
if (armed !== key) {
|
||
setArmed(key)
|
||
return
|
||
}
|
||
setArmed(null)
|
||
forget.mutate(key)
|
||
}}
|
||
>
|
||
<Trash2 />
|
||
</Button>
|
||
</li>
|
||
)
|
||
})}
|
||
</ul>
|
||
) : null}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 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 (`processArgs`), and
|
||
* returns its outputs.
|
||
*/
|
||
function scaffoldFor(node: NodeDef_Input): string {
|
||
const args = processArgs(node)
|
||
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,
|
||
readers,
|
||
expanded,
|
||
onChange,
|
||
onRenameMessage,
|
||
onSaveSource,
|
||
onShared,
|
||
onToggleExpand,
|
||
}: {
|
||
node: NodeDef_Input
|
||
flow: string
|
||
nodeType: NodeTypeInfo | undefined
|
||
origin: FlowOrigin | null | undefined
|
||
suggestions: PortSuggestions
|
||
readers: ReadonlySet<string>
|
||
expanded: boolean
|
||
onChange: (next: NodeDef_Input) => void
|
||
onRenameMessage: (previous: string, next: string) => void
|
||
/** `drop` is false while the code is still being typed; see `settle`. */
|
||
onSaveSource: (code: string, drop: boolean) => 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)
|
||
// Code typed since the editor was last left.
|
||
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, false)
|
||
}
|
||
|
||
/**
|
||
* The editor was left, so the code is saved once more, now dropping the
|
||
* ports and settings its header no longer names. Saves while typing only
|
||
* add and rename, so a parameter deleted mid-edit keeps its port and
|
||
* message until then, and typing it back changes nothing.
|
||
*/
|
||
const settle = () => {
|
||
if (timer.current) {
|
||
clearTimeout(timer.current)
|
||
timer.current = null
|
||
}
|
||
if (pending.current !== null) save.current(pending.current, true)
|
||
pending.current = null
|
||
}
|
||
|
||
const editCode = (next: string) => {
|
||
setCode(next)
|
||
pending.current = next
|
||
if (timer.current) clearTimeout(timer.current)
|
||
timer.current = setTimeout(() => {
|
||
timer.current = null
|
||
save.current(next, false)
|
||
}, 1000)
|
||
}
|
||
|
||
/**
|
||
* Change the node, and keep its code in step with its ports and settings.
|
||
*
|
||
* Code that is still exactly what this panel generates is generated again,
|
||
* outputs included. Code of your own only has the header of `process`
|
||
* rewritten, and the body is left as it is. Shared code is never rewritten:
|
||
* 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 = SCAFFOLD_SHAPE.test(current)
|
||
? scaffoldFor(next)
|
||
: withHeader(current, processArgs(next))
|
||
if (current !== wanted) 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 leaves the editor too, and must not lose the last
|
||
// keystrokes.
|
||
useEffect(() => {
|
||
return () => {
|
||
if (timer.current) clearTimeout(timer.current)
|
||
if (pending.current !== null) save.current(pending.current, true)
|
||
pending.current = null
|
||
}
|
||
}, [])
|
||
|
||
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",
|
||
)}
|
||
>
|
||
{node.type === DEVICE_TYPE ? (
|
||
<DeviceSettings
|
||
node={node}
|
||
flow={flow}
|
||
suggestions={suggestions}
|
||
readers={readers}
|
||
onChange={editNode}
|
||
/>
|
||
) : (
|
||
<>
|
||
<PortList
|
||
title="Consumes"
|
||
side="requires"
|
||
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"
|
||
side="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))
|
||
}}
|
||
// 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]
|
||
}}
|
||
/>
|
||
</>
|
||
)}
|
||
{node.type === DEVICE_TYPE ? 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}
|
||
{node.type === "wren" ? (
|
||
<TrainingSection
|
||
flow={flow}
|
||
node={node}
|
||
onUse={(model) =>
|
||
editNode({ ...node, params: { ...node.params, model } })
|
||
}
|
||
/>
|
||
) : 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>
|
||
)}
|
||
{/* `nokey`: the editor types through a plain div, which the canvas
|
||
takes for itself, so Space would pan instead. */}
|
||
<div className="nokey 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}
|
||
onBlur={settle}
|
||
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,
|
||
readers,
|
||
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
|
||
/** Qualified messages the flow's nodes read, a device node's aside. */
|
||
readers: ReadonlySet<string>
|
||
expanded: boolean
|
||
onChange: (next: NodeDef_Input) => void
|
||
onRenameMessage: (previous: string, next: string) => void
|
||
onSaveSource: (code: string, drop: boolean) => 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}
|
||
readers={readers}
|
||
expanded={expanded}
|
||
onChange={onChange}
|
||
onRenameMessage={onRenameMessage}
|
||
onSaveSource={onSaveSource}
|
||
onShared={onShared}
|
||
onToggleExpand={onToggleExpand}
|
||
/>
|
||
) : null}
|
||
</SidePanel>
|
||
)
|
||
}
|