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 { useNodeEmits, 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%" const span = 60 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 ( ) })} ) } function FlowNodeComponent({ data, selected }: NodeProps) { const { definition, flow, typeLabel, isPlugin, issueText, onShowLogs } = data as FlowNodeData const live = useNodeStatus(`${flow}.${definition.id}`) const emits = useNodeEmits(`${flow}.${definition.id}`) // 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() // 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 style = STATUS_STYLES[status as keyof typeof STATUS_STYLES] 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.
{/* 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. */} {firing ? : null}
{definition.title || definition.id} {typeLabel} {status === "running" ? ( Stop this node ) : null} {status === "error" && onShowLogs ? ( Show the traceback ) : null} {style ? ( {problem || style.label} ) : null}
) } export const FlowNode = memo(FlowNodeComponent)