An input row now starts from its message and ends as the argument the code
takes it as ("as prompt"); an output keeps its key on top and the message
below. The name the code uses is muted text with a pencil while it follows the
message's last segment, and a field only when it differs. Device Follows ports
get the same order (message, then the port driven). A list port's item type
moves to the second row so it no longer squeezes the name, two ports deriving
one name get a warning, and the rows rebuilt use 44px touch targets on phones.
The stored node spec is unchanged.
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`: Monaco types through a plain div (EditContext), 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>
|
||
)
|
||
}
|