ws:// and wss:// URLs dial out and hold a supervised connection; a path is served under /hooks/<flow>/ with an optional secret. Outputs only listen, inputs send. Frames are JSON keyed by port, with json_key and on_connect.
337 lines
12 KiB
TypeScript
337 lines
12 KiB
TypeScript
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,
|
|
trigger: 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 (
|
|
<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 || live?.detail || 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)
|