The first cut had node code call fluksio.log_metric, which was a second, undeclared way for data to leave a node: invisible to validation, absent from the canvas, and stored where the graph could not see it. That is precisely the MLflow discrepancy this framework exists to avoid, so it is gone. A node that produces values over time is a generator. Every yield is a dict keyed by output port, published the instant it happens — same port, same type check, same place on the canvas as any other value — and what it returns is its result. A port doing this declares stream: true, and a run keeps every number one takes, so experiment tracking is a consequence of the graph rather than an API beside it: a chart binds to a training curve the way it binds to a temperature. fluksio.emit writes the same ports imperatively, for where a yield cannot reach — inside a training framework's callback. In a live flow an emission also wakes what is downstream, as a subscriber publishing does; in a run it does not, because a run's graph is scheduled once and mid-node cascades would leave 'finished' with nothing to mean. The enqueued item carries no payload: the value is already in state, and one carrying it would re-apply an old emission after the node returned. Verified on the stack: 30 loss values arrived live on the flow socket during a run, attributed to the node that produced them, and the same node run on the remote worker streamed its curve back across the socket. Also caches remote compile results per worker, so attaching a GPU box does not put a network round trip in every rebuild. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AD8SfVhzXBG2nAfFcVh3iD
1160 lines
35 KiB
TypeScript
1160 lines
35 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
|
import { Activity, Maximize2, Minimize2, X } from "lucide-react"
|
|
import {
|
|
type ComponentProps,
|
|
lazy,
|
|
Suspense,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
} from "react"
|
|
|
|
import {
|
|
type DType,
|
|
FlowsService,
|
|
type MessageSpec,
|
|
type NodeDef_Input,
|
|
type NodeTypeInfo,
|
|
} from "@/client"
|
|
import { Button } from "@/components/ui/button"
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandItem,
|
|
CommandList,
|
|
} from "@/components/ui/command"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Label } from "@/components/ui/label"
|
|
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover"
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select"
|
|
import { Switch } from "@/components/ui/switch"
|
|
import useCustomToast from "@/hooks/useCustomToast"
|
|
import { inCodeEditor, useShortcuts } from "@/lib/shortcuts"
|
|
import { cn } from "@/lib/utils"
|
|
import { MessageSparkline } from "./MessageSparkline"
|
|
import {
|
|
flowKeys,
|
|
libraryQueryOptions,
|
|
nodeSourceQueryOptions,
|
|
secretsQueryOptions,
|
|
useParamSuggestions,
|
|
} from "./queries"
|
|
import { PANEL_SECTION, PanelTitle, SidePanel } from "./SidePanel"
|
|
|
|
const NodeEditor = lazy(() => import("./NodeEditor"))
|
|
|
|
const DTYPES: DType[] = [
|
|
"float",
|
|
"int",
|
|
"str",
|
|
"bool",
|
|
"json",
|
|
"series",
|
|
"record",
|
|
"list",
|
|
"artifact",
|
|
]
|
|
|
|
/** What a list may hold. One declared level: no list of lists. */
|
|
const ITEM_DTYPES: DType[] = ["record", "float", "int", "str", "bool", "json"]
|
|
|
|
/** Radix selects cannot hold an empty value, so "no secret" needs a name. */
|
|
const NO_SECRET = "__none__"
|
|
|
|
const SECTION = PANEL_SECTION
|
|
|
|
/** Settings the engine reads itself, so they are not the author's to name. */
|
|
const RESERVED_PARAMS = new Set(["synchronous"])
|
|
|
|
/**
|
|
* A text field that offers what is already in use elsewhere.
|
|
*
|
|
* Typing stays free — the list is a shortcut, never a constraint — but picking
|
|
* beats typing wherever two things only work together when they match.
|
|
*/
|
|
function SuggestInput({
|
|
value,
|
|
suggestions,
|
|
empty,
|
|
onChange,
|
|
onFocus,
|
|
onBlur,
|
|
...props
|
|
}: {
|
|
value: string
|
|
suggestions: string[]
|
|
/** What to say when nothing matches what is typed. */
|
|
empty: string
|
|
onChange: (next: string) => void
|
|
} & Omit<ComponentProps<typeof Input>, "value" | "onChange">) {
|
|
const [open, setOpen] = useState(false)
|
|
const matches = suggestions.filter(
|
|
(item) =>
|
|
item !== value && item.toLowerCase().includes(value.toLowerCase()),
|
|
)
|
|
|
|
return (
|
|
<Popover open={open && matches.length > 0} onOpenChange={setOpen}>
|
|
<PopoverAnchor asChild>
|
|
<Input
|
|
value={value}
|
|
autoComplete="off"
|
|
{...props}
|
|
onFocus={(event) => {
|
|
setOpen(true)
|
|
onFocus?.(event)
|
|
}}
|
|
onBlur={(event) => {
|
|
setOpen(false)
|
|
onBlur?.(event)
|
|
}}
|
|
onChange={(event) => {
|
|
onChange(event.target.value)
|
|
setOpen(true)
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Escape") setOpen(false)
|
|
}}
|
|
/>
|
|
</PopoverAnchor>
|
|
<PopoverContent
|
|
align="start"
|
|
className="w-[--radix-popover-trigger-width] p-0"
|
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
|
>
|
|
<Command shouldFilter={false}>
|
|
<CommandList>
|
|
<CommandEmpty>{empty}</CommandEmpty>
|
|
<CommandGroup>
|
|
{matches.map((item) => (
|
|
<CommandItem
|
|
key={item}
|
|
value={item}
|
|
className="font-mono text-sm"
|
|
// Blur fires before click, so commit on mousedown.
|
|
onMouseDown={(event) => {
|
|
event.preventDefault()
|
|
onChange(item)
|
|
setOpen(false)
|
|
}}
|
|
onSelect={() => {
|
|
onChange(item)
|
|
setOpen(false)
|
|
}}
|
|
>
|
|
{item}
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* A message name, typed freely or picked from the names already in play.
|
|
*
|
|
* The suggestions are the point: a message only connects when both ends spell
|
|
* it the same way, so choosing beats typing.
|
|
*/
|
|
function MessageNameInput({
|
|
value,
|
|
suggestions,
|
|
placeholder,
|
|
autoFocus,
|
|
onChange,
|
|
onRenamed,
|
|
}: {
|
|
value: string
|
|
suggestions: string[]
|
|
placeholder: string
|
|
autoFocus: boolean
|
|
onChange: (next: string) => void
|
|
/** The name as it was before this edit, once the field is done with. */
|
|
onRenamed?: (previous: string, next: string) => void
|
|
}) {
|
|
// Every keystroke commits, so a rename is only a rename once the user is
|
|
// finished with the field.
|
|
const before = useRef(value)
|
|
|
|
return (
|
|
<SuggestInput
|
|
value={value}
|
|
suggestions={suggestions}
|
|
empty="No matching message."
|
|
placeholder={placeholder}
|
|
aria-label="Message name"
|
|
// A port added by hand is meant to be named right away.
|
|
autoFocus={autoFocus}
|
|
className="h-8 flex-1 font-mono text-sm"
|
|
onChange={onChange}
|
|
onFocus={() => {
|
|
before.current = value
|
|
}}
|
|
onBlur={() => {
|
|
if (before.current !== value) onRenamed?.(before.current, value)
|
|
before.current = value
|
|
}}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function PortList({
|
|
title,
|
|
specs,
|
|
flow,
|
|
emptyHint,
|
|
suggestions,
|
|
onChange,
|
|
onRenamed,
|
|
streamable = false,
|
|
}: {
|
|
title: string
|
|
specs: MessageSpec[]
|
|
flow: string
|
|
emptyHint: string
|
|
suggestions: string[]
|
|
onChange: (next: MessageSpec[]) => void
|
|
onRenamed?: (previous: string, next: string) => void
|
|
/** Outputs only: a port a node publishes on repeatedly while it runs. */
|
|
streamable?: boolean
|
|
}) {
|
|
// The port just added, so its name field can take focus.
|
|
const [freshIndex, setFreshIndex] = useState<number | null>(null)
|
|
|
|
const update = (index: number, patch: Partial<MessageSpec>) => {
|
|
const next = specs.map((spec, i) =>
|
|
i === index ? { ...spec, ...patch } : spec,
|
|
)
|
|
onChange(next)
|
|
}
|
|
|
|
return (
|
|
<div className="grid gap-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className={SECTION}>{title}</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 text-xs text-muted-foreground"
|
|
onClick={() => {
|
|
setFreshIndex(specs.length)
|
|
onChange([...specs, { name: "", dtype: "float" }])
|
|
}}
|
|
>
|
|
Add
|
|
</Button>
|
|
</div>
|
|
|
|
{specs.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">{emptyHint}</p>
|
|
) : null}
|
|
|
|
{specs.map((spec, index) => (
|
|
// 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 gap-2">
|
|
<div className="flex items-center gap-1.5">
|
|
<MessageNameInput
|
|
value={spec.name ?? ""}
|
|
suggestions={suggestions}
|
|
placeholder={`name in ${flow}`}
|
|
autoFocus={index === freshIndex}
|
|
onChange={(name) => update(index, { name, port: "" })}
|
|
onRenamed={onRenamed}
|
|
/>
|
|
<Select
|
|
value={spec.dtype ?? "float"}
|
|
onValueChange={(value) =>
|
|
update(index, { dtype: value as DType })
|
|
}
|
|
>
|
|
<SelectTrigger
|
|
className="!h-8 w-[92px] text-sm"
|
|
aria-label="Type"
|
|
>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{DTYPES.map((dtype) => (
|
|
<SelectItem key={dtype} value={dtype}>
|
|
{dtype}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
{spec.dtype === "list" ? (
|
|
<Select
|
|
value={spec.item ?? "record"}
|
|
onValueChange={(value) =>
|
|
update(index, { item: value as DType })
|
|
}
|
|
>
|
|
<SelectTrigger
|
|
className="!h-8 w-[92px] text-sm"
|
|
aria-label="Item type"
|
|
title="What each item of the list is"
|
|
>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{ITEM_DTYPES.map((dtype) => (
|
|
<SelectItem key={dtype} value={dtype}>
|
|
{dtype}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
) : null}
|
|
<Input
|
|
type="number"
|
|
min={0}
|
|
step="any"
|
|
value={spec.interval ? String(spec.interval) : ""}
|
|
placeholder="∞"
|
|
aria-label="Deliver at most every n seconds"
|
|
title="Deliver at most every n seconds; empty is every time"
|
|
className="h-8 w-16 text-sm"
|
|
onChange={(event) =>
|
|
update(index, { interval: Number(event.target.value) || 0 })
|
|
}
|
|
/>
|
|
{streamable ? (
|
|
<Button
|
|
variant={spec.stream ? "secondary" : "ghost"}
|
|
size="icon-sm"
|
|
aria-label="Streaming output"
|
|
aria-pressed={spec.stream ?? false}
|
|
title={
|
|
"Published repeatedly while the node runs — a curve rather " +
|
|
"than a result. A run keeps every value it takes."
|
|
}
|
|
className={cn(
|
|
"shrink-0",
|
|
!spec.stream && "text-muted-foreground",
|
|
)}
|
|
onClick={() => update(index, { stream: !spec.stream })}
|
|
>
|
|
<Activity />
|
|
</Button>
|
|
) : null}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
className="text-muted-foreground"
|
|
aria-label="Remove port"
|
|
onClick={() => onChange(specs.filter((_, i) => i !== index))}
|
|
>
|
|
<X />
|
|
</Button>
|
|
</div>
|
|
{spec.name ? <MessageSparkline flow={flow} name={spec.name} /> : null}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** The value types a free-form setting can hold, and how to read one back. */
|
|
const FREE_TYPES = ["text", "number", "on/off", "json"] as const
|
|
type FreeType = (typeof FREE_TYPES)[number]
|
|
|
|
function freeTypeOf(value: unknown): FreeType {
|
|
if (typeof value === "boolean") return "on/off"
|
|
if (typeof value === "number") return "number"
|
|
if (value !== null && typeof value === "object") return "json"
|
|
return "text"
|
|
}
|
|
|
|
function castTo(type: FreeType, raw: string): unknown {
|
|
if (type === "number") return Number(raw) || 0
|
|
if (type === "on/off") return raw === "true"
|
|
if (type === "json") {
|
|
try {
|
|
return JSON.parse(raw)
|
|
} catch {
|
|
// Half-typed JSON is normal while editing; keep the text until it parses.
|
|
return raw
|
|
}
|
|
}
|
|
return raw
|
|
}
|
|
|
|
function asText(value: unknown): string {
|
|
if (value === null || value === undefined) return ""
|
|
if (typeof value === "object") return JSON.stringify(value)
|
|
return String(value)
|
|
}
|
|
|
|
/**
|
|
* Settings a node type does not declare.
|
|
*
|
|
* A function node's parameters are its author's to name — they arrive in
|
|
* `process` as whatever was put here — so there is no schema to render and the
|
|
* keys are typed in alongside the values.
|
|
*/
|
|
function FreeParamsForm({
|
|
params,
|
|
reserved,
|
|
onChange,
|
|
}: {
|
|
params: Record<string, unknown>
|
|
reserved: Set<string>
|
|
onChange: (next: Record<string, unknown>) => void
|
|
}) {
|
|
const [freshKey, setFreshKey] = useState<string | null>(null)
|
|
const entries = Object.entries(params).filter(([key]) => !reserved.has(key))
|
|
|
|
const rename = (from: string, to: string) => {
|
|
if (to === from) return
|
|
// Rebuilt rather than patched, so the settings keep the order they were
|
|
// typed in instead of jumping around as one is renamed.
|
|
const next: Record<string, unknown> = {}
|
|
for (const [key, value] of Object.entries(params)) {
|
|
next[key === from ? to : key] = value
|
|
}
|
|
onChange(next)
|
|
}
|
|
|
|
return (
|
|
<div className="grid gap-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className={SECTION}>Settings</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 text-xs text-muted-foreground"
|
|
data-testid="add-param"
|
|
onClick={() => {
|
|
let name = "setting"
|
|
for (let i = 2; name in params; i++) name = `setting${i}`
|
|
setFreshKey(name)
|
|
onChange({ ...params, [name]: "" })
|
|
}}
|
|
>
|
|
Add
|
|
</Button>
|
|
</div>
|
|
|
|
{entries.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
Values your code reads from <code>params</code>.
|
|
</p>
|
|
) : null}
|
|
|
|
{entries.map(([key, value], index) => {
|
|
const type = freeTypeOf(value)
|
|
return (
|
|
// Keyed by position, not by name: renaming a setting must not
|
|
// remount its row and take the half-typed value with it.
|
|
<div key={`param-${index}`} className="flex items-center gap-1.5">
|
|
<Input
|
|
defaultValue={key}
|
|
placeholder="name"
|
|
aria-label="Setting name"
|
|
autoFocus={key === freshKey}
|
|
className="h-8 flex-1 text-sm"
|
|
onBlur={(event) => rename(key, event.target.value.trim() || key)}
|
|
/>
|
|
<Select
|
|
value={type}
|
|
onValueChange={(next) =>
|
|
onChange({
|
|
...params,
|
|
[key]: castTo(next as FreeType, asText(value)),
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger
|
|
className="!h-8 w-[86px] text-sm"
|
|
aria-label="Type"
|
|
>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{FREE_TYPES.map((option) => (
|
|
<SelectItem key={option} value={option}>
|
|
{option}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
{type === "on/off" ? (
|
|
<Switch
|
|
checked={value === true}
|
|
aria-label="Value"
|
|
onCheckedChange={(checked) =>
|
|
onChange({ ...params, [key]: checked })
|
|
}
|
|
/>
|
|
) : (
|
|
<Input
|
|
value={asText(value)}
|
|
placeholder="value"
|
|
aria-label="Setting value"
|
|
className="h-8 flex-1 text-sm"
|
|
onChange={(event) =>
|
|
onChange({
|
|
...params,
|
|
[key]: castTo(type, event.target.value),
|
|
})
|
|
}
|
|
/>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
className="text-muted-foreground"
|
|
aria-label="Remove setting"
|
|
onClick={() => {
|
|
const next = { ...params }
|
|
delete next[key]
|
|
onChange(next)
|
|
}}
|
|
>
|
|
<X />
|
|
</Button>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* The cron expression that fires as often as `seconds` asks for, if there is
|
|
* one. Only divisors of an hour or a day line up — a step of seven minutes
|
|
* would jump from :56 back to :00, which is not every seven minutes.
|
|
*/
|
|
function cronFromInterval(seconds: unknown): string | null {
|
|
const value = Number(seconds)
|
|
if (!Number.isFinite(value) || value <= 0) return null
|
|
const minutes = value / 60
|
|
// Cron's finest grain is the minute.
|
|
if (!Number.isInteger(minutes)) return null
|
|
if (minutes === 1) return "* * * * *"
|
|
if (minutes < 60) return 60 % minutes === 0 ? `*/${minutes} * * * *` : null
|
|
const hours = minutes / 60
|
|
if (!Number.isInteger(hours)) return null
|
|
if (hours === 1) return "0 * * * *"
|
|
if (hours < 24) return 24 % hours === 0 ? `0 */${hours} * * *` : null
|
|
return hours === 24 ? "0 0 * * *" : null
|
|
}
|
|
|
|
/** What the five fields mean, and the schedule the interval beside them asks for. */
|
|
function CronHelp({
|
|
params,
|
|
onPick,
|
|
}: {
|
|
params: Record<string, unknown>
|
|
onPick: (expression: string) => void
|
|
}) {
|
|
const derived = cronFromInterval(params.interval)
|
|
|
|
return (
|
|
<>
|
|
<p className="text-xs text-muted-foreground">
|
|
Five fields —{" "}
|
|
<span className="font-mono">
|
|
minute hour day-of-month month weekday
|
|
</span>
|
|
. <span className="font-mono">*</span> is every,{" "}
|
|
<span className="font-mono">*/5</span> every fifth,{" "}
|
|
<span className="font-mono">1-5</span> a range.
|
|
</p>
|
|
{derived && params.cron !== derived ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 justify-self-start text-xs text-muted-foreground"
|
|
data-testid="use-derived-cron"
|
|
onClick={() => onPick(derived)}
|
|
>
|
|
Every {String(params.interval)}s is
|
|
<span className="font-mono">{derived}</span>
|
|
</Button>
|
|
) : null}
|
|
</>
|
|
)
|
|
}
|
|
|
|
/** A small form built from the node type's declared parameters. */
|
|
function ParamsForm({
|
|
type,
|
|
schema,
|
|
params,
|
|
onChange,
|
|
}: {
|
|
type: string | undefined
|
|
schema: Record<string, unknown> | undefined
|
|
params: Record<string, unknown>
|
|
onChange: (next: Record<string, unknown>) => void
|
|
}) {
|
|
const properties = (schema?.properties ?? {}) as Record<
|
|
string,
|
|
{
|
|
type?: string
|
|
title?: string
|
|
description?: string
|
|
default?: unknown
|
|
"x-secret"?: boolean
|
|
}
|
|
>
|
|
const entries = Object.entries(properties)
|
|
|
|
const { data: secretList } = useQuery(secretsQueryOptions())
|
|
const secrets = secretList?.data ?? []
|
|
// A type with nothing to fill in has nothing to suggest, and the other flows
|
|
// are not worth reading for it.
|
|
const suggestions = useParamSuggestions(entries.length ? type : undefined)
|
|
if (entries.length === 0) return null
|
|
|
|
const set = (key: string, value: unknown) =>
|
|
onChange({ ...params, [key]: value })
|
|
|
|
return (
|
|
<div className="grid gap-3">
|
|
<span className={SECTION}>Settings</span>
|
|
{entries.map(([key, property]) => {
|
|
const value = params[key] ?? property.default ?? ""
|
|
const label = property.title ?? key
|
|
|
|
if (property.type === "boolean") {
|
|
return (
|
|
<div key={key} className="flex items-center justify-between gap-2">
|
|
<Label htmlFor={`param-${key}`} className="text-sm font-normal">
|
|
{label}
|
|
</Label>
|
|
<Switch
|
|
id={`param-${key}`}
|
|
checked={Boolean(value)}
|
|
onCheckedChange={(checked) => set(key, checked)}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// A credential is stored once and referenced, so it never ends up in
|
|
// flow.json where the whole team can read it.
|
|
if (property["x-secret"]) {
|
|
const reference = (params[key] ?? null) as { $secret?: string } | null
|
|
return (
|
|
<div key={key} className="grid gap-1.5">
|
|
<Label className="text-sm font-normal">{label}</Label>
|
|
<Select
|
|
value={reference?.$secret ?? ""}
|
|
onValueChange={(name) =>
|
|
set(key, name === NO_SECRET ? null : { $secret: name })
|
|
}
|
|
>
|
|
<SelectTrigger className="!h-8 text-sm" aria-label={label}>
|
|
<SelectValue placeholder="Pick a stored secret" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={NO_SECRET}>None</SelectItem>
|
|
{secrets.map((secret) => (
|
|
<SelectItem key={secret} value={secret}>
|
|
{secret}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-xs text-muted-foreground">
|
|
{secrets.length
|
|
? property.description ||
|
|
"Stored in the secrets store, never in the flow file."
|
|
: "No secrets stored yet."}
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (property.type === "object" || property.type === "array") {
|
|
return null
|
|
}
|
|
|
|
const numeric =
|
|
property.type === "integer" || property.type === "number"
|
|
return (
|
|
<div key={key} className="grid gap-1.5">
|
|
<Label htmlFor={`param-${key}`} className="text-sm font-normal">
|
|
{label}
|
|
</Label>
|
|
<SuggestInput
|
|
id={`param-${key}`}
|
|
className="h-8 text-sm"
|
|
type={numeric ? "number" : "text"}
|
|
value={String(value)}
|
|
suggestions={suggestions[key] ?? []}
|
|
empty="Nothing like that in use yet."
|
|
onChange={(next) => set(key, numeric ? Number(next) : next)}
|
|
/>
|
|
{property.description ? (
|
|
<p className="text-xs text-muted-foreground">
|
|
{property.description}
|
|
</p>
|
|
) : null}
|
|
{key === "cron" ? (
|
|
<CronHelp
|
|
params={params}
|
|
onPick={(expression) => set(key, expression)}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Sharing a node moves its code to the library, where other flows can point at
|
|
* it. Each flow keeps its own ports and settings; only the code is common, so
|
|
* one fix reaches all of them.
|
|
*/
|
|
function SharingSection({
|
|
flow,
|
|
node,
|
|
onShared,
|
|
}: {
|
|
flow: string
|
|
node: NodeDef_Input
|
|
onShared: () => void
|
|
}) {
|
|
const queryClient = useQueryClient()
|
|
const { showErrorToast } = useCustomToast()
|
|
const { data: library } = useQuery(libraryQueryOptions())
|
|
const [name, setName] = useState("")
|
|
|
|
const shared = node.source_ref
|
|
const usages = library?.find((entry) => entry.name === shared)?.used_by ?? []
|
|
|
|
const done = () => {
|
|
queryClient.invalidateQueries({ queryKey: flowKeys.library })
|
|
queryClient.invalidateQueries({ queryKey: flowKeys.detail(flow) })
|
|
onShared()
|
|
}
|
|
|
|
const share = useMutation({
|
|
mutationFn: (libName: string) =>
|
|
FlowsService.shareNode({
|
|
name: flow,
|
|
nodeId: node.id,
|
|
requestBody: { lib_name: libName },
|
|
}),
|
|
onSuccess: done,
|
|
onError: () =>
|
|
showErrorToast("That name is taken, or is not a valid name."),
|
|
})
|
|
|
|
const unshare = useMutation({
|
|
mutationFn: () => FlowsService.unshareNode({ name: flow, nodeId: node.id }),
|
|
onSuccess: done,
|
|
onError: () => showErrorToast("The node could not be unshared."),
|
|
})
|
|
|
|
if (shared) {
|
|
return (
|
|
<div className="grid gap-3">
|
|
<span className={SECTION}>Shared</span>
|
|
<p className="text-sm text-muted-foreground">
|
|
Runs <span className="font-mono">{shared}</span> from the library
|
|
{usages.length > 1
|
|
? `, along with ${usages.length - 1} other node${
|
|
usages.length === 2 ? "" : "s"
|
|
}`
|
|
: ""}
|
|
. Editing the code here changes it everywhere.
|
|
</p>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8 justify-self-start"
|
|
disabled={unshare.isPending}
|
|
onClick={() => unshare.mutate()}
|
|
data-testid="unshare-node"
|
|
>
|
|
Keep a private copy
|
|
</Button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="grid gap-3">
|
|
<span className={SECTION}>Reuse</span>
|
|
<p className="text-sm text-muted-foreground">
|
|
Move this node's code to the library so other flows can run it too.
|
|
</p>
|
|
<div className="flex items-center gap-1.5">
|
|
<Input
|
|
value={name}
|
|
placeholder="read_temperature"
|
|
aria-label="Shared name"
|
|
autoComplete="off"
|
|
className="h-8 flex-1 font-mono text-sm"
|
|
onChange={(event) => setName(event.target.value)}
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
className="h-8"
|
|
disabled={!name || share.isPending}
|
|
onClick={() => share.mutate(name)}
|
|
data-testid="share-node"
|
|
>
|
|
Share
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** The value a fresh output returns, so the scaffold runs as written. */
|
|
const PLACEHOLDER: Record<DType, string> = {
|
|
float: "0.0",
|
|
int: "0",
|
|
bool: "False",
|
|
str: '""',
|
|
json: "{}",
|
|
series: '{"lines": []}',
|
|
record: "{}",
|
|
list: "[]",
|
|
// Bytes never travel as a message: the node stores them and returns what
|
|
// the next one opens.
|
|
artifact: 'fluksio.save_artifact(b"", "result.bin")',
|
|
}
|
|
|
|
const SCAFFOLD_DOC =
|
|
'"""A new node. Return a dict keyed by your output ports."""'
|
|
|
|
const quote = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
|
|
/**
|
|
* Exactly what `scaffoldFor` writes, for any set of ports — and the source a
|
|
* node starts life with, which is that with no ports at all.
|
|
*
|
|
* Nothing else matches, which is what makes it safe to overwrite: a returned
|
|
* value that is not one of the placeholders is somebody's code.
|
|
*/
|
|
const SCAFFOLD_SHAPE = (() => {
|
|
const value = Object.values(PLACEHOLDER).map(quote).join("|")
|
|
const entry = `"[^"]+": (?:${value})`
|
|
return new RegExp(
|
|
`^${quote(SCAFFOLD_DOC)}\\n\\n\\ndef process\\(\\w+(?:, \\w+)*\\):\\n return \\{(?:${entry}(?:, ${entry})*)?\\}\\n$`,
|
|
)
|
|
})()
|
|
|
|
/** The name the node function sees; the engine derives it the same way. */
|
|
const portName = (spec: MessageSpec) =>
|
|
spec.port || (spec.name ?? "").split(".").pop() || ""
|
|
|
|
/** A `process` that takes this node's inputs and returns its outputs. */
|
|
function scaffoldFor(node: NodeDef_Input): string {
|
|
const args = [
|
|
...new Set(
|
|
(node.requires ?? [])
|
|
.map(portName)
|
|
// Anything else cannot be a keyword argument, so it cannot be a port.
|
|
.filter((port) => /^[A-Za-z_]\w*$/.test(port) && port !== "params"),
|
|
),
|
|
]
|
|
const returns = (node.provides ?? [])
|
|
.filter((spec) => portName(spec))
|
|
.map((spec) => `"${portName(spec)}": ${PLACEHOLDER[spec.dtype ?? "float"]}`)
|
|
return `${SCAFFOLD_DOC}\n\n\ndef process(${[...args, "params"].join(
|
|
", ",
|
|
)}):\n return {${returns.join(", ")}}\n`
|
|
}
|
|
|
|
function PanelBody({
|
|
node,
|
|
flow,
|
|
nodeType,
|
|
suggestions,
|
|
expanded,
|
|
onChange,
|
|
onRenameMessage,
|
|
onSaveSource,
|
|
onShared,
|
|
onToggleExpand,
|
|
}: {
|
|
node: NodeDef_Input
|
|
flow: string
|
|
nodeType: NodeTypeInfo | undefined
|
|
suggestions: PortSuggestions
|
|
expanded: boolean
|
|
onChange: (next: NodeDef_Input) => void
|
|
onRenameMessage: (previous: string, next: string) => void
|
|
onSaveSource: (code: string) => void
|
|
onShared: () => void
|
|
onToggleExpand: () => void
|
|
}) {
|
|
const hasSource = nodeType?.has_source ?? node.type === "python"
|
|
const { data: source } = useQuery({
|
|
...nodeSourceQueryOptions(flow, node.id),
|
|
enabled: hasSource,
|
|
})
|
|
|
|
const [code, setCode] = useState<string | null>(null)
|
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
const pending = useRef<string | null>(null)
|
|
const save = useRef(onSaveSource)
|
|
save.current = onSaveSource
|
|
|
|
/** Send what is typed now rather than a second from now. */
|
|
const saveNow = () => {
|
|
if (timer.current) {
|
|
clearTimeout(timer.current)
|
|
timer.current = null
|
|
}
|
|
if (pending.current !== null) save.current(pending.current)
|
|
}
|
|
|
|
const editCode = (next: string) => {
|
|
setCode(next)
|
|
pending.current = next
|
|
if (timer.current) clearTimeout(timer.current)
|
|
timer.current = setTimeout(() => {
|
|
timer.current = null
|
|
save.current(next)
|
|
}, 1000)
|
|
}
|
|
|
|
/**
|
|
* Change the node, and keep an untouched scaffold in step with its ports.
|
|
*
|
|
* Only code that is still exactly what this panel generates is rewritten —
|
|
* one edit of your own and it is yours, ports or no ports. Shared code is
|
|
* never rewritten either: the other flows using it have their own ports.
|
|
*/
|
|
const editNode = (next: NodeDef_Input) => {
|
|
onChange(next)
|
|
const current = code ?? source?.code
|
|
if (!hasSource || next.source_ref || current === undefined) return
|
|
const wanted = scaffoldFor(next)
|
|
if (current !== wanted && SCAFFOLD_SHAPE.test(current)) editCode(wanted)
|
|
}
|
|
|
|
// ⌘S in the editor means "apply this code"; the editor's own state is here,
|
|
// so the binding is too. Anywhere else on the canvas it publishes the flow.
|
|
useShortcuts(
|
|
{
|
|
"mod+s": (event) => {
|
|
if (inCodeEditor(event.target)) saveNow()
|
|
},
|
|
},
|
|
["mod+s"],
|
|
)
|
|
|
|
// Closing the panel must not lose the last keystrokes.
|
|
useEffect(() => {
|
|
return () => {
|
|
if (timer.current) {
|
|
clearTimeout(timer.current)
|
|
if (pending.current !== null) save.current(pending.current)
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
return (
|
|
<>
|
|
<div className={cn("grid gap-6 p-4", expanded && "max-w-2xl")}>
|
|
<PortList
|
|
title="Consumes"
|
|
specs={node.requires ?? []}
|
|
flow={flow}
|
|
emptyHint="Nothing yet. Add a message this node reads."
|
|
suggestions={suggestions.consumes}
|
|
onChange={(requires) => editNode({ ...node, requires })}
|
|
/>
|
|
<PortList
|
|
title="Provides"
|
|
specs={node.provides ?? []}
|
|
flow={flow}
|
|
emptyHint="Nothing yet. Add a message this node publishes."
|
|
suggestions={suggestions.provides}
|
|
onChange={(provides) => editNode({ ...node, provides })}
|
|
streamable
|
|
// Only the publishing side names a message; an input is as often
|
|
// re-pointed at a different one as it is renamed.
|
|
onRenamed={onRenameMessage}
|
|
/>
|
|
<ParamsForm
|
|
type={node.type}
|
|
schema={nodeType?.params_schema}
|
|
params={node.params ?? {}}
|
|
onChange={(params) => onChange({ ...node, params })}
|
|
/>
|
|
{nodeType?.free_params ? (
|
|
<FreeParamsForm
|
|
params={node.params ?? {}}
|
|
reserved={RESERVED_PARAMS}
|
|
onChange={(params) => onChange({ ...node, params })}
|
|
/>
|
|
) : null}
|
|
{hasSource ? (
|
|
<div className="grid gap-1.5">
|
|
<Label htmlFor="node-timeout" className={SECTION}>
|
|
Timeout
|
|
</Label>
|
|
<Input
|
|
id="node-timeout"
|
|
type="number"
|
|
min={0}
|
|
step="any"
|
|
className="h-8 text-sm"
|
|
placeholder="30 (default)"
|
|
value={node.timeout ? String(node.timeout) : ""}
|
|
onChange={(event) =>
|
|
onChange({
|
|
...node,
|
|
timeout: Number(event.target.value) || null,
|
|
})
|
|
}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Seconds this code may run before it is stopped. Above 60 the
|
|
engine may deliver the same work again while it is still running.
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
{hasSource ? (
|
|
<SharingSection flow={flow} node={node} onShared={onShared} />
|
|
) : null}
|
|
</div>
|
|
|
|
{hasSource ? (
|
|
<div className="flex min-h-[280px] flex-1 flex-col gap-2 px-4 pb-4">
|
|
<div className="flex items-center justify-between">
|
|
<span className={SECTION}>
|
|
{node.source_ref ? `Shared code · ${node.source_ref}` : "Code"}
|
|
</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
className="hidden text-muted-foreground md:inline-flex"
|
|
onClick={onToggleExpand}
|
|
aria-label={
|
|
expanded ? "Collapse the editor" : "Expand the editor"
|
|
}
|
|
data-testid="toggle-editor-size"
|
|
>
|
|
{expanded ? <Minimize2 /> : <Maximize2 />}
|
|
</Button>
|
|
</div>
|
|
<div className="min-h-0 flex-1 overflow-hidden rounded-md border border-border">
|
|
<Suspense
|
|
fallback={
|
|
<div className="h-full w-full animate-pulse bg-muted" />
|
|
}
|
|
>
|
|
<NodeEditor
|
|
value={code ?? source?.code ?? ""}
|
|
onChange={editCode}
|
|
/>
|
|
</Suspense>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Node settings, floating over the canvas so the graph stays visible and live.
|
|
* On a phone there is no room for that, so it becomes a full-screen sheet.
|
|
*/
|
|
/** Message names worth offering on each side of a node. */
|
|
export type PortSuggestions = { consumes: string[]; provides: string[] }
|
|
|
|
export function NodePanel({
|
|
node,
|
|
flow,
|
|
nodeTypes,
|
|
suggestions,
|
|
expanded,
|
|
onChange,
|
|
onRenameMessage,
|
|
onSaveSource,
|
|
onShared,
|
|
onToggleExpand,
|
|
onClose,
|
|
onDelete,
|
|
}: {
|
|
node: NodeDef_Input | null
|
|
flow: string
|
|
nodeTypes: NodeTypeInfo[]
|
|
suggestions: PortSuggestions
|
|
expanded: boolean
|
|
onChange: (next: NodeDef_Input) => void
|
|
onRenameMessage: (previous: string, next: string) => void
|
|
onSaveSource: (code: string) => void
|
|
onShared: () => void
|
|
onToggleExpand: () => void
|
|
onClose: () => void
|
|
onDelete: () => void
|
|
}) {
|
|
const nodeType = nodeTypes.find((entry) => entry.type === node?.type)
|
|
|
|
return (
|
|
<SidePanel
|
|
open={Boolean(node)}
|
|
label="Node settings"
|
|
testId="node-panel"
|
|
bodyKey={node?.id ?? "none"}
|
|
expanded={expanded}
|
|
onClose={onClose}
|
|
header={
|
|
node ? (
|
|
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
|
{/* Where the node lives: the flow is the namespace of its
|
|
messages, so it reads like the folder holding them. */}
|
|
<span className="min-w-0 truncate font-mono text-sm text-muted-foreground">
|
|
/{flow}/
|
|
</span>
|
|
<PanelTitle
|
|
value={node.title || node.id}
|
|
label="Node name"
|
|
onConfirm={(title) => onChange({ ...node, title })}
|
|
/>
|
|
</div>
|
|
) : null
|
|
}
|
|
footer={
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-destructive hover:text-destructive"
|
|
onClick={onDelete}
|
|
>
|
|
Delete node
|
|
</Button>
|
|
}
|
|
>
|
|
{node ? (
|
|
<PanelBody
|
|
node={node}
|
|
flow={flow}
|
|
nodeType={nodeType}
|
|
suggestions={suggestions}
|
|
expanded={expanded}
|
|
onChange={onChange}
|
|
onRenameMessage={onRenameMessage}
|
|
onSaveSource={onSaveSource}
|
|
onShared={onShared}
|
|
onToggleExpand={onToggleExpand}
|
|
/>
|
|
) : null}
|
|
</SidePanel>
|
|
)
|
|
}
|