- A node's height follows the ports on its busiest side. It is a function of the document, so `layoutGraph` reserves exactly what is drawn and nothing measured is fed back into the layout. - The three status controls now sit in slots that are there whether the control is or not. A node running many times a second mounted and unmounted the stop button on every execution, resizing the card each time. - A port bound to another flow's message is drawn as a label, naming the node at the far end and its type. Only the opposite direction was answered before. The scan behind both is now cached on the store's commit counter rather than reading every flow per request. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZeGnqVsf5VHQqvz4HdUhN
334 lines
12 KiB
TypeScript
334 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 { nodeHeight, PORT_SPAN } 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.
|
|
const STATUS_STYLES = {
|
|
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
|
|
}
|
|
|
|
/** Spread handles along the node's edge so several ports stay reachable. */
|
|
function handleOffset(index: number, total: number): string {
|
|
if (total <= 1) return "50%"
|
|
// The same fraction `nodeHeight` sizes the node for, so a node is always
|
|
// tall enough for the ports this spreads down it.
|
|
const span = PORT_SPAN * 100
|
|
return `${50 - span / 2 + (span / (total - 1)) * index}%`
|
|
}
|
|
|
|
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>
|
|
{/* Each control sits in a slot that is there whether the control
|
|
is or not. They come and go with what the node is doing — and a
|
|
node running many times a second comes and goes that often — so
|
|
in the flex row itself they would resize the card on every
|
|
execution, which reads as a flickering shape. */}
|
|
<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>
|
|
|
|
<span className="flex size-6 shrink-0 items-center justify-center">
|
|
{(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>
|
|
|
|
<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 || 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)
|