import { Handle, type NodeProps, Position } from "@xyflow/react" import { Bell, Braces, Bug, Clock, Code2, Database, FileText, Filter, Globe, Merge, Play, Plug, Radio, Shuffle, Split, Terminal, Timer, } from "lucide-react" import { memo } from "react" import type { MessageSpec, NodeDef_Input } from "@/client" import { Button } from "@/components/ui/button" import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip" 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 } /** Vertically distribute handles 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 }) { return ( <> {specs.map((spec, index) => { const port = portOf(spec) 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}`) 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 (
{/* Remounting on each emit is what restarts the animation. */} {emits > 0 ? : null}
{definition.title || definition.id} {typeLabel} {status === "error" && onShowLogs ? ( Show the traceback ) : null} {style ? ( {problem || style.label} ) : null}
) } export const FlowNode = memo(FlowNodeComponent)