Files
app/frontend/src/components/Flow/FlowNode.tsx
T
stroblmeandClaude Opus 5 148d50f2bd Grow a node with its ports, stop it flickering, draw what it reaches out to
- 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
2026-08-23 06:35:28 +02:00

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)