The bubble opened with the loader's own frame — `exec(compile(...))` inside `_load_function` — before anything about the user's code, and then said all of it twice, because a load failure arrives both as the node's status and as a node_error issue carrying the same text. It is now `Type: message (line N)`, deduplicated, with the line taken from the node's own file. Full tracebacks still go to the server log. Long messages grow the bubble instead of stretching it, and scroll only past 240px. The tooltip arrow is gone: it read as a rotated square notch under the bubble, and no better anywhere else it appeared.
150 lines
4.5 KiB
TypeScript
150 lines
4.5 KiB
TypeScript
import { Handle, type NodeProps, Position } from "@xyflow/react"
|
|
import { Braces, Clock, Code2, Database, Globe, Radio } from "lucide-react"
|
|
import { memo } from "react"
|
|
|
|
import type { MessageSpec, NodeDef_Input } from "@/client"
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip"
|
|
import { cn } from "@/lib/utils"
|
|
import { portOf } from "./deriveEdges"
|
|
import { useNodeEmits, useNodeStatus } from "./liveStore"
|
|
|
|
const NODE_ICONS = {
|
|
python: Code2,
|
|
mqtt: Radio,
|
|
http: Globe,
|
|
influxdb: Database,
|
|
delay: Clock,
|
|
mlp: Braces,
|
|
} 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
|
|
issueText: string
|
|
[key: string]: unknown
|
|
}
|
|
|
|
/** Vertically distribute handles so several ports stay reachable. */
|
|
function handleOffset(index: number, total: number): string {
|
|
if (total <= 1) return "50%"
|
|
const span = 60
|
|
return `${50 - span / 2 + (span / (total - 1)) * index}%`
|
|
}
|
|
|
|
function PortHandles({
|
|
specs,
|
|
type,
|
|
position,
|
|
}: {
|
|
specs: MessageSpec[]
|
|
type: "source" | "target"
|
|
position: Position
|
|
}) {
|
|
return (
|
|
<>
|
|
{specs.map((spec, index) => {
|
|
const port = portOf(spec)
|
|
return (
|
|
<Handle
|
|
key={`${type}-${port}`}
|
|
id={port}
|
|
type={type}
|
|
position={position}
|
|
className={cn(
|
|
"!bg-card !border-muted-foreground/60",
|
|
!spec.name && "unbound",
|
|
)}
|
|
style={{ top: handleOffset(index, specs.length) }}
|
|
/>
|
|
)
|
|
})}
|
|
</>
|
|
)
|
|
}
|
|
|
|
function FlowNodeComponent({ data, selected }: NodeProps) {
|
|
const { definition, flow, typeLabel, issueText } = data as FlowNodeData
|
|
const live = useNodeStatus(`${flow}.${definition.id}`)
|
|
const emits = useNodeEmits(`${flow}.${definition.id}`)
|
|
const Icon = NODE_ICONS[definition.type as keyof typeof NODE_ICONS] ?? 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 style = STATUS_STYLES[status as keyof typeof STATUS_STYLES]
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"relative min-w-[168px] max-w-[220px] rounded-lg border border-border bg-card px-3 py-2.5 shadow-e1 transition-shadow",
|
|
selected && "border-primary shadow-e2",
|
|
)}
|
|
>
|
|
{/* Remounting on each emit is what restarts the animation. */}
|
|
{emits > 0 ? <span key={emits} className="node-pulse" /> : null}
|
|
|
|
<PortHandles
|
|
specs={definition.requires ?? []}
|
|
type="target"
|
|
position={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>
|
|
{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}
|
|
</div>
|
|
|
|
<PortHandles
|
|
specs={definition.provides ?? []}
|
|
type="source"
|
|
position={Position.Right}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export const FlowNode = memo(FlowNodeComponent)
|