The canvas placed nodes with dagre, which orders nodes but knows nothing about ports: an edge leaves a particular handle and lands on a particular one, so two producers feeding a consumer in the other order crossed just before they landed however well the nodes were arranged. The graph was not a multigraph either, so two messages between one pair of nodes collapsed into one before crossings were counted, and the endpoint labels were laid out as 220-wide cards with every handle stacked on a single pixel. ELK's layered algorithm replaces it. Every declared port is handed to it as a fixed point on the node's rim, at the fraction `portFraction` puts the handle at — which the node components now render from the same function — so what the crossing count is minimised over is what ends up on the screen. Measured on this installation's flows, with the labels included: `home` goes from 51 crossings to 6 across and 70 to 29 down, `demo_training` 27 to 5 and 59 to 32, and every graph is the same size or smaller. The engine answers asynchronously and is a chunk of its own, so positions became state: the canvas draws nothing until the first layout lands, and an edit keeps the arrangement it had rather than flashing through the corner. The entry chunk is untouched and the flow route's own chunk came down 167 to 121 kB, since dagre used to be in it. Two things the crossings made worse come with it. A hovered edge resolves to full strength so one line can be followed through a busy rank, and a feedback edge — one whose target the layout put behind its source — swings out into a lane beside the graph instead of being drawn through everything between its ends, which is what "House history" was reported for.
326 lines
12 KiB
TypeScript
326 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 { handleOffset, nodeHeight } 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
|
|
}
|
|
|
|
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>
|
|
{/* 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. */}
|
|
<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>
|
|
|
|
{(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 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)
|