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 ( ) })} ) } 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.
{/* 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 ? ( ) : null}
{definition.title || definition.id} {typeLabel} {/* 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. */} {running ? ( Stop this node ) : null} {(status === "error" || failedEarlier) && onShowLogs ? ( {failedEarlier ? `Failed at ${failedAt} — show the traceback` : "Show the traceback"} ) : null} {style ? ( {problem || style.label} ) : null}
) } export const FlowNode = memo(FlowNodeComponent)