import { Handle, type NodeProps, Position } from "@xyflow/react"
import {
Bell,
Bird,
Braces,
Bug,
Cable,
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,
websocket: Cable,
influxdb: Database,
delay: Clock,
mlp: Braces,
inject: Play,
switch: Split,
change: Shuffle,
rbe: Filter,
join: Merge,
debounce: Timer,
exec: Terminal,
file: FileText,
ntfy: Bell,
wren: Bird,
} 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 (
)
})}
>
)
}
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}
{/* 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. */}
{running ? (
Stop this node
) : null}
{(status === "error" || failedEarlier) && onShowLogs ? (
{failedEarlier
? `Failed at ${failedAt}. Show the traceback`
: "Show the traceback"}
) : null}
{style ? (
{problem || live?.detail || style.label}
) : null}