`resources` was reachable from the SDK and the API and nowhere else, and `node_queued` was published to nothing at all — so a node waiting for a machine looked exactly like a node that had hung, which is the failure the event was added for. The node panel gets a Resources section: a size by name, the numbers for a node that wants its own, and how long it is expected to take. Picking a flavor drops the numbers, because saying both is two answers and the engine refuses it. A queued node draws a neutral dot rather than one of the three status colours — it is not running, it did not go well, and it did not go wrong; it is idle with a reason, which the tooltip gives. And a Workers screen, which is the first UI for any of this: every machine the engine can reach with what is free of each, what is attached, what a cluster has been asked for, and the sizes, editable. `fluksio status` grew a line of the same. The demo's training node already preferred a GPU worker, which was exactly the declaration that used to be dropped, so it now says how much of that machine it takes as well.
333 lines
12 KiB
TypeScript
333 lines
12 KiB
TypeScript
import { Handle, type NodeProps, Position } from "@xyflow/react"
|
|
import {
|
|
Bell,
|
|
Braces,
|
|
Bug,
|
|
Clock,
|
|
Code2,
|
|
Database,
|
|
FileText,
|
|
Filter,
|
|
Globe,
|
|
Merge,
|
|
Play,
|
|
Plug,
|
|
Radio,
|
|
Shuffle,
|
|
Split,
|
|
Square,
|
|
Terminal,
|
|
Timer,
|
|
} from "lucide-react"
|
|
import { memo, useEffect, useRef, useState } from "react"
|
|
|
|
import { FlowsService, type MessageSpec, type NodeDef_Input } from "@/client"
|
|
import { Button } from "@/components/ui/button"
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip"
|
|
import { useIsMobile } from "@/hooks/useMobile"
|
|
import { duration } from "@/lib/motion"
|
|
import { cn } from "@/lib/utils"
|
|
import { portOf } from "./deriveEdges"
|
|
import { handleOffset, nodeHeight } from "./layout"
|
|
import {
|
|
liveStore,
|
|
useNodeEmits,
|
|
useNodeFailure,
|
|
useNodeStatus,
|
|
} from "./liveStore"
|
|
|
|
const NODE_ICONS = {
|
|
python: Code2,
|
|
mqtt: Radio,
|
|
http: Globe,
|
|
influxdb: Database,
|
|
delay: Clock,
|
|
mlp: Braces,
|
|
inject: Play,
|
|
switch: Split,
|
|
change: Shuffle,
|
|
rbe: Filter,
|
|
join: Merge,
|
|
trigger: Timer,
|
|
exec: Terminal,
|
|
file: FileText,
|
|
ntfy: Bell,
|
|
} as const
|
|
|
|
// One dot says everything about a node's state. Idle nodes carry no dot at all,
|
|
// so the canvas stays quiet until something happens.
|
|
// Queued is neutral on purpose: the three status colours mean "running",
|
|
// "went well" and "went wrong", and a node waiting for a machine is none of
|
|
// those — it is idle with a reason, which the tooltip gives.
|
|
const STATUS_STYLES = {
|
|
queued: {
|
|
dot: "bg-muted-foreground",
|
|
label: "Queued — waiting for a machine",
|
|
},
|
|
running: { dot: "bg-primary animate-pulse", label: "Running" },
|
|
success: { dot: "bg-status-success", label: "Last run succeeded" },
|
|
error: { dot: "bg-destructive", label: "Something went wrong" },
|
|
} as const
|
|
|
|
export type FlowNodeData = {
|
|
definition: NodeDef_Input
|
|
flow: string
|
|
typeLabel: string
|
|
isPlugin?: boolean
|
|
issueText: string
|
|
/** Open the logs at this node's own lines. */
|
|
onShowLogs?: (nodeId: string) => void
|
|
[key: string]: unknown
|
|
}
|
|
|
|
function PortHandles({
|
|
specs,
|
|
type,
|
|
position,
|
|
}: {
|
|
specs: MessageSpec[]
|
|
type: "source" | "target"
|
|
position: Position
|
|
}) {
|
|
// The ports run across whichever edge they sit on.
|
|
const along = position === Position.Top || position === Position.Bottom
|
|
return (
|
|
<>
|
|
{specs.map((spec, index) => {
|
|
const port = portOf(spec)
|
|
const offset = handleOffset(index, specs.length)
|
|
return (
|
|
<Handle
|
|
key={`${type}-${port}`}
|
|
id={port}
|
|
type={type}
|
|
position={position}
|
|
className={cn(
|
|
"!bg-card !border-muted-foreground/60",
|
|
!spec.name && "unbound",
|
|
)}
|
|
style={along ? { left: offset } : { top: offset }}
|
|
/>
|
|
)
|
|
})}
|
|
</>
|
|
)
|
|
}
|
|
|
|
function FlowNodeComponent({ data, selected }: NodeProps) {
|
|
const { definition, flow, typeLabel, isPlugin, issueText, onShowLogs } =
|
|
data as FlowNodeData
|
|
const nodeId = `${flow}.${definition.id}`
|
|
const live = useNodeStatus(nodeId)
|
|
const emits = useNodeEmits(nodeId)
|
|
// What it last failed with, which outlives the run that failed.
|
|
const failure = useNodeFailure(nodeId)
|
|
// The graph runs top to bottom on a phone, so the ports have to face that
|
|
// way too — see DESIGN-GUIDELINES.md → Responsive.
|
|
const vertical = useIsMobile()
|
|
// Ports run down the sides only while the graph runs across, and that is the
|
|
// only direction in which their number decides the height: running downwards
|
|
// they spread along the node's width, which is fixed. Read from the document
|
|
// rather than measured, so `layoutGraph` can reserve exactly this much.
|
|
const ports = Math.max(
|
|
(definition.requires ?? []).length,
|
|
(definition.provides ?? []).length,
|
|
)
|
|
// Whether a pulse is playing right now; the ring is mounted only while it is.
|
|
const [firing, setFiring] = useState(false)
|
|
// The count outlives this component: the store is module-level, and a
|
|
// snapshot restores what the engine counted before the page even loaded. So
|
|
// the number we mount with is history, and only a change on top of it is
|
|
// something that just happened.
|
|
const seen = useRef(emits)
|
|
|
|
useEffect(() => {
|
|
if (emits === seen.current) return
|
|
seen.current = emits
|
|
setFiring(true)
|
|
const timer = setTimeout(() => setFiring(false), duration.pulse * 1000)
|
|
return () => clearTimeout(timer)
|
|
}, [emits])
|
|
|
|
const Icon =
|
|
NODE_ICONS[definition.type as keyof typeof NODE_ICONS] ??
|
|
// A connector's own type cannot be in the map above, and a device is
|
|
// not a piece of code.
|
|
(isPlugin ? Plug : Code2)
|
|
|
|
// Whatever is wrong — it failed to load, it failed to run, or the graph
|
|
// around it does not add up — is the same red dot with the same explanation.
|
|
// A load failure arrives twice, as node status and as a validation issue, so
|
|
// identical lines collapse into one.
|
|
const lines = [live?.status === "error" ? live.error : null, issueText]
|
|
.filter(Boolean)
|
|
.join("\n")
|
|
.split("\n")
|
|
const problem = [...new Set(lines)].join("\n")
|
|
const status = problem ? "error" : live?.status
|
|
const running = status === "running"
|
|
const style = STATUS_STYLES[status as keyof typeof STATUS_STYLES]
|
|
// Failed at some point, fine as of the last run. It gets no dot and no
|
|
// border — those tell the truth about the last run — only the traceback
|
|
// button, which says in words when it was.
|
|
const failedEarlier = !problem && Boolean(failure)
|
|
const failedAt = failure
|
|
? new Date(failure.ts * 1000).toLocaleTimeString()
|
|
: ""
|
|
|
|
return (
|
|
// The pulse ring measures itself from here rather than from the card, so
|
|
// a border can never land on top of it — see `.node-pulse` in flow.css.
|
|
<div className="relative rounded-lg">
|
|
{/* Remounting on each emit is what restarts the animation, so this is
|
|
gated on the pulse rather than on the count: a node mounting with a
|
|
count already in the store would otherwise play it once for free.
|
|
A run is not one shot and has no length known in advance, so while one
|
|
is on the ring laps under a key of its own until the node stops. */}
|
|
{running || firing ? (
|
|
<span
|
|
key={running ? "run" : emits}
|
|
className={cn("node-pulse", running && "node-pulse-run")}
|
|
/>
|
|
) : null}
|
|
<div
|
|
style={vertical ? undefined : { minHeight: nodeHeight(ports) }}
|
|
className={cn(
|
|
"flow-node-card relative flex min-w-[168px] max-w-[220px] flex-col justify-center rounded-lg border border-border bg-card px-3 py-2.5 shadow-e1 transition-shadow",
|
|
// Whatever is wrong right now is on the card as well as on the dot:
|
|
// a click through from the brain has to land on something visible.
|
|
// Selection wins the border when it is both — the dot is the status
|
|
// channel, and it is still red underneath.
|
|
problem && "border-destructive",
|
|
selected && "border-primary shadow-e2",
|
|
)}
|
|
>
|
|
<PortHandles
|
|
specs={definition.requires ?? []}
|
|
type="target"
|
|
position={vertical ? Position.Top : Position.Left}
|
|
/>
|
|
|
|
<div className="flex items-center gap-2.5">
|
|
<span className="flex size-7 shrink-0 items-center justify-center rounded-sm bg-primary/10 text-primary">
|
|
<Icon className="size-4" />
|
|
</span>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate text-sm font-medium">
|
|
{definition.title || definition.id}
|
|
</span>
|
|
<span className="block truncate text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground">
|
|
{typeLabel}
|
|
</span>
|
|
</span>
|
|
{/* This and the dot below sit in slots that are there whether the
|
|
control is or not: both come and go on their own — a node running
|
|
many times a second mounts and unmounts this on every execution —
|
|
and in the flex row itself that resizes the card each time, which
|
|
reads as a flickering shape. The traceback button below is left
|
|
in the row on purpose: it appears when a node newly fails and
|
|
goes when someone acknowledges it, which is a thing to notice
|
|
rather than a flicker, and a slot for it costs the title 34px. */}
|
|
<span className="flex size-6 shrink-0 items-center justify-center">
|
|
{running ? (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
className="nodrag nopan size-6 shrink-0 text-muted-foreground hover:text-destructive"
|
|
aria-label="Stop this node"
|
|
data-testid="node-cancel"
|
|
onClick={(event) => {
|
|
event.stopPropagation()
|
|
// Best effort by nature: it may well have finished between
|
|
// the render and the click, which is the outcome asked for.
|
|
FlowsService.cancelNode({
|
|
name: flow,
|
|
nodeId: definition.id,
|
|
}).catch(() => {})
|
|
}}
|
|
>
|
|
<Square />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Stop this node</TooltipContent>
|
|
</Tooltip>
|
|
) : null}
|
|
</span>
|
|
|
|
{(status === "error" || failedEarlier) && onShowLogs ? (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
// `nodrag` keeps xyflow from reading the press as a drag; the
|
|
// click itself is stopped so the node panel stays closed.
|
|
className={cn(
|
|
"nodrag nopan size-6 shrink-0 hover:text-destructive",
|
|
// Red while it is the only thing left saying so, and named
|
|
// in words beside it: colour never carries a status alone.
|
|
failedEarlier
|
|
? "text-destructive"
|
|
: "text-muted-foreground",
|
|
)}
|
|
aria-label={
|
|
failedEarlier
|
|
? `Failed at ${failedAt} — show the traceback`
|
|
: "Show what this node printed"
|
|
}
|
|
data-testid="node-traceback"
|
|
onClick={(event) => {
|
|
event.stopPropagation()
|
|
onShowLogs(definition.id)
|
|
// Reading it is what dismisses it: nothing else does, and a
|
|
// marker that never goes away stops meaning anything.
|
|
liveStore.acknowledgeFailure(nodeId)
|
|
}}
|
|
>
|
|
<Bug />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{failedEarlier
|
|
? `Failed at ${failedAt} — show the traceback`
|
|
: "Show the traceback"}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
) : null}
|
|
|
|
<span className="flex size-2 shrink-0 items-center justify-center">
|
|
{style ? (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span
|
|
role="img"
|
|
className={cn("size-2 shrink-0 rounded-full", style.dot)}
|
|
aria-label={style.label}
|
|
/>
|
|
</TooltipTrigger>
|
|
<TooltipContent className="max-h-60 max-w-xs overflow-y-auto whitespace-pre-line break-words">
|
|
{problem || live?.detail || style.label}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
) : null}
|
|
</span>
|
|
</div>
|
|
|
|
<PortHandles
|
|
specs={definition.provides ?? []}
|
|
type="source"
|
|
position={vertical ? Position.Bottom : Position.Right}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export const FlowNode = memo(FlowNodeComponent)
|