Say what caused a value, and draw what is not a node
Moving a dashboard slider lit up an edge between two nodes that had done nothing. The canvas pulsed on the message's timestamp alone, and a message has no idea who published it — so it credited whichever node happened to be drawn as a producer. That was never only about dashboards. Two nodes producing one message pulsed both their edges whichever fired, and a message produced in another flow changed with nothing on screen to account for it at all. Values now carry their cause: a node, a dashboard widget, another flow, an agent or an API caller. An edge pulses only for the producer that actually published, and the edge inspector says where a value came from when it did not come from a node. What is not a node in this flow is now drawn as one — a label rather than a card, because a dashboard with twenty tiles would otherwise bury the logic the canvas exists to show. That covers cross-flow wiring too, which is the link in/out affordance that has been missing. They are never part of the document. They join at render, after everything that reads or writes the canvas nodes, so an autosave, an undo or a delete cannot reach them — with a Playwright test that drags a node and asserts the stored flow still holds exactly what it did. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011LF61rxW1FG5YCD2J9YqjY
This commit is contained in:
@@ -307,6 +307,53 @@ export const DashboardsPublicSchema = {
|
||||
title: 'DashboardsPublic'
|
||||
} as const;
|
||||
|
||||
export const EndpointSchema = {
|
||||
properties: {
|
||||
kind: {
|
||||
type: 'string',
|
||||
title: 'Kind'
|
||||
},
|
||||
id: {
|
||||
type: 'string',
|
||||
title: 'Id'
|
||||
},
|
||||
label: {
|
||||
type: 'string',
|
||||
title: 'Label'
|
||||
},
|
||||
detail: {
|
||||
type: 'string',
|
||||
title: 'Detail',
|
||||
default: ''
|
||||
},
|
||||
provides: {
|
||||
items: {
|
||||
type: 'string'
|
||||
},
|
||||
type: 'array',
|
||||
title: 'Provides',
|
||||
default: []
|
||||
},
|
||||
requires: {
|
||||
items: {
|
||||
type: 'string'
|
||||
},
|
||||
type: 'array',
|
||||
title: 'Requires',
|
||||
default: []
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['kind', 'id', 'label'],
|
||||
title: 'Endpoint',
|
||||
description: `Something wired into this flow that is not a node in it.
|
||||
|
||||
A dashboard control setting one of its messages, a tile showing one, or a
|
||||
node in another flow on the far side of a dotted name. The canvas draws
|
||||
these so a value never appears to come from nowhere — or worse, appears to
|
||||
come from whichever node happens to be drawn as a producer.`
|
||||
} as const;
|
||||
|
||||
export const FlowDef_InputSchema = {
|
||||
properties: {
|
||||
name: {
|
||||
@@ -416,6 +463,14 @@ export const FlowDetailSchema = {
|
||||
type: 'boolean',
|
||||
title: 'Paused',
|
||||
default: false
|
||||
},
|
||||
endpoints: {
|
||||
items: {
|
||||
'$ref': '#/components/schemas/Endpoint'
|
||||
},
|
||||
type: 'array',
|
||||
title: 'Endpoints',
|
||||
default: []
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
@@ -1950,6 +2005,26 @@ export const app__api__routes__messages__PublishRequestSchema = {
|
||||
properties: {
|
||||
value: {
|
||||
title: 'Value'
|
||||
},
|
||||
source_kind: {
|
||||
type: 'string',
|
||||
title: 'Source Kind',
|
||||
default: 'api'
|
||||
},
|
||||
source_id: {
|
||||
type: 'string',
|
||||
title: 'Source Id',
|
||||
default: ''
|
||||
},
|
||||
source_label: {
|
||||
type: 'string',
|
||||
title: 'Source Label',
|
||||
default: ''
|
||||
},
|
||||
source_detail: {
|
||||
type: 'string',
|
||||
title: 'Source Detail',
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
|
||||
@@ -29,6 +29,10 @@ export type app__api__routes__messages__MessageValue = {
|
||||
|
||||
export type app__api__routes__messages__PublishRequest = {
|
||||
value: unknown;
|
||||
source_kind?: string;
|
||||
source_id?: string;
|
||||
source_label?: string;
|
||||
source_detail?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -115,6 +119,23 @@ export type DashboardSummary = {
|
||||
*/
|
||||
export type DType = 'float' | 'int' | 'str' | 'bool' | 'json';
|
||||
|
||||
/**
|
||||
* Something wired into this flow that is not a node in it.
|
||||
*
|
||||
* A dashboard control setting one of its messages, a tile showing one, or a
|
||||
* node in another flow on the far side of a dotted name. The canvas draws
|
||||
* these so a value never appears to come from nowhere — or worse, appears to
|
||||
* come from whichever node happens to be drawn as a producer.
|
||||
*/
|
||||
export type Endpoint = {
|
||||
kind: string;
|
||||
id: string;
|
||||
label: string;
|
||||
detail?: string;
|
||||
provides?: Array<(string)>;
|
||||
requires?: Array<(string)>;
|
||||
};
|
||||
|
||||
/**
|
||||
* One atomic flow.
|
||||
*/
|
||||
@@ -151,6 +172,7 @@ export type FlowDetail = {
|
||||
has_draft?: boolean;
|
||||
enabled?: boolean;
|
||||
paused?: boolean;
|
||||
endpoints?: Array<Endpoint>;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -173,6 +173,7 @@ export function DashboardEditor({
|
||||
|
||||
<SectionGrid
|
||||
section={section}
|
||||
dashboard={draft.name}
|
||||
renderWidget={(widget) => (
|
||||
<WidgetFrame
|
||||
title={widget.title}
|
||||
@@ -220,7 +221,7 @@ export function DashboardEditor({
|
||||
className="min-h-0 flex-1 text-left"
|
||||
onClick={() => setSelected(widget.id)}
|
||||
>
|
||||
<WidgetBody widget={widget} />
|
||||
<WidgetBody widget={widget} dashboard={draft.name} />
|
||||
</button>
|
||||
</WidgetFrame>
|
||||
)}
|
||||
|
||||
@@ -48,10 +48,13 @@ export function widgetStyle(widget: WidgetDef): React.CSSProperties {
|
||||
|
||||
export function SectionGrid({
|
||||
section,
|
||||
dashboard,
|
||||
renderWidget,
|
||||
className,
|
||||
}: {
|
||||
section: SectionDef_Output
|
||||
/** Which dashboard this is, so an input widget can name itself. */
|
||||
dashboard: string
|
||||
renderWidget?: (widget: WidgetDef) => React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
@@ -72,7 +75,7 @@ export function SectionGrid({
|
||||
renderWidget(widget)
|
||||
) : (
|
||||
<WidgetFrame title={widget.title}>
|
||||
<WidgetBody widget={widget} />
|
||||
<WidgetBody widget={widget} dashboard={dashboard} />
|
||||
</WidgetFrame>
|
||||
)}
|
||||
</div>
|
||||
@@ -122,6 +125,7 @@ export function DashboardView({
|
||||
<SectionGrid
|
||||
key={section.id}
|
||||
section={section}
|
||||
dashboard={dashboard.name}
|
||||
renderWidget={renderWidget}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -47,10 +47,39 @@ export function useSaveDashboard(name: string) {
|
||||
})
|
||||
}
|
||||
|
||||
/** What an input widget does: put a value into the graph. */
|
||||
/** What an input widget does: put a value into the graph.
|
||||
*
|
||||
* The widget names itself so the flow canvas can show the value arriving from
|
||||
* here, rather than crediting whichever node is drawn as a producer.
|
||||
*/
|
||||
export function usePublishMessage() {
|
||||
return useMutation({
|
||||
mutationFn: ({ name, value }: { name: string; value: unknown }) =>
|
||||
MessagesService.publishMessage({ name, requestBody: { value } }),
|
||||
mutationFn: ({
|
||||
name,
|
||||
value,
|
||||
dashboard,
|
||||
widget,
|
||||
label,
|
||||
kind,
|
||||
}: {
|
||||
name: string
|
||||
value: unknown
|
||||
dashboard?: string
|
||||
widget?: string
|
||||
label?: string
|
||||
kind?: string
|
||||
}) =>
|
||||
MessagesService.publishMessage({
|
||||
name,
|
||||
requestBody: {
|
||||
value,
|
||||
source_kind: "dashboard",
|
||||
// Matches the endpoint id the canvas builds for this widget.
|
||||
source_id:
|
||||
dashboard && widget ? `dashboard:${dashboard}:${widget}` : "",
|
||||
source_label: label ?? widget ?? "Dashboard",
|
||||
source_detail: kind ?? "",
|
||||
},
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ function Unbound() {
|
||||
// Display
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function StatWidget({ widget }: { widget: WidgetDef }) {
|
||||
function StatWidget({ widget }: WidgetProps) {
|
||||
const cfg = config(widget)
|
||||
const message = text(cfg.message)
|
||||
const live = useLiveValue(message || undefined)
|
||||
@@ -154,7 +154,7 @@ function StatWidget({ widget }: { widget: WidgetDef }) {
|
||||
* The number is always written out as well: a reading that only exists as an
|
||||
* angle is unreadable to anyone who cannot judge one.
|
||||
*/
|
||||
function GaugeWidget({ widget }: { widget: WidgetDef }) {
|
||||
function GaugeWidget({ widget }: WidgetProps) {
|
||||
const cfg = config(widget)
|
||||
const message = text(cfg.message)
|
||||
const live = useLiveValue(message || undefined)
|
||||
@@ -229,7 +229,7 @@ function GaugeWidget({ widget }: { widget: WidgetDef }) {
|
||||
*
|
||||
* Enough for the labels and notes a dashboard carries, and not worth a parser.
|
||||
*/
|
||||
function MarkdownWidget({ widget }: { widget: WidgetDef }) {
|
||||
function MarkdownWidget({ widget }: WidgetProps) {
|
||||
const content = text(config(widget).content)
|
||||
const lines = content.split("\n")
|
||||
return (
|
||||
@@ -263,7 +263,7 @@ function MarkdownWidget({ widget }: { widget: WidgetDef }) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Publishing, with the value shown as sent until the engine confirms it. */
|
||||
function usePublish(widget: WidgetDef) {
|
||||
function usePublish(widget: WidgetDef, dashboard: string) {
|
||||
const cfg = config(widget)
|
||||
const target = text(cfg.target)
|
||||
const publish = usePublishMessage()
|
||||
@@ -273,15 +273,22 @@ function usePublish(widget: WidgetDef) {
|
||||
live,
|
||||
send: (value: unknown) => {
|
||||
if (!target) return
|
||||
publish.mutate({ name: target, value })
|
||||
publish.mutate({
|
||||
name: target,
|
||||
value,
|
||||
dashboard,
|
||||
widget: widget.id,
|
||||
label: widget.title || widget.id,
|
||||
kind: widget.type,
|
||||
})
|
||||
},
|
||||
pending: publish.isPending,
|
||||
}
|
||||
}
|
||||
|
||||
function ButtonWidget({ widget }: { widget: WidgetDef }) {
|
||||
function ButtonWidget({ widget, dashboard }: WidgetProps) {
|
||||
const cfg = config(widget)
|
||||
const { target, send, pending } = usePublish(widget)
|
||||
const { target, send, pending } = usePublish(widget, dashboard)
|
||||
if (!target) return <Unbound />
|
||||
return (
|
||||
<Button
|
||||
@@ -295,8 +302,8 @@ function ButtonWidget({ widget }: { widget: WidgetDef }) {
|
||||
)
|
||||
}
|
||||
|
||||
function SwitchWidget({ widget }: { widget: WidgetDef }) {
|
||||
const { target, live, send } = usePublish(widget)
|
||||
function SwitchWidget({ widget, dashboard }: WidgetProps) {
|
||||
const { target, live, send } = usePublish(widget, dashboard)
|
||||
if (!target) return <Unbound />
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
@@ -310,9 +317,9 @@ function SwitchWidget({ widget }: { widget: WidgetDef }) {
|
||||
)
|
||||
}
|
||||
|
||||
function SliderWidget({ widget }: { widget: WidgetDef }) {
|
||||
function SliderWidget({ widget, dashboard }: WidgetProps) {
|
||||
const cfg = config(widget)
|
||||
const { target, live, send } = usePublish(widget)
|
||||
const { target, live, send } = usePublish(widget, dashboard)
|
||||
const min = num(cfg.min, 0)
|
||||
const max = num(cfg.max, 100)
|
||||
const step = num(cfg.step, 1)
|
||||
@@ -357,9 +364,9 @@ function SliderWidget({ widget }: { widget: WidgetDef }) {
|
||||
)
|
||||
}
|
||||
|
||||
function InputWidget({ widget }: { widget: WidgetDef }) {
|
||||
function InputWidget({ widget, dashboard }: WidgetProps) {
|
||||
const cfg = config(widget)
|
||||
const { target, live, send } = usePublish(widget)
|
||||
const { target, live, send } = usePublish(widget, dashboard)
|
||||
const [draft, setDraft] = useState<string | null>(null)
|
||||
if (!target) return <Unbound />
|
||||
|
||||
@@ -384,9 +391,9 @@ function InputWidget({ widget }: { widget: WidgetDef }) {
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownWidget({ widget }: { widget: WidgetDef }) {
|
||||
function DropdownWidget({ widget, dashboard }: WidgetProps) {
|
||||
const cfg = config(widget)
|
||||
const { target, live, send } = usePublish(widget)
|
||||
const { target, live, send } = usePublish(widget, dashboard)
|
||||
const options = (cfg.options ?? []) as { label?: string; value?: unknown }[]
|
||||
if (!target) return <Unbound />
|
||||
|
||||
@@ -420,8 +427,10 @@ function asOriginal(selected: string, options: { value?: unknown }[]): unknown {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type WidgetProps = { widget: WidgetDef; dashboard: string }
|
||||
|
||||
const RENDERERS: Partial<
|
||||
Record<WidgetKind, (props: { widget: WidgetDef }) => React.ReactNode>
|
||||
Record<WidgetKind, (props: WidgetProps) => React.ReactNode>
|
||||
> = {
|
||||
stat: StatWidget,
|
||||
gauge: GaugeWidget,
|
||||
@@ -433,7 +442,7 @@ const RENDERERS: Partial<
|
||||
dropdown: DropdownWidget,
|
||||
}
|
||||
|
||||
export function WidgetBody({ widget }: { widget: WidgetDef }) {
|
||||
export function WidgetBody({ widget, dashboard }: WidgetProps) {
|
||||
const Renderer = RENDERERS[widget.type]
|
||||
if (!Renderer) {
|
||||
return (
|
||||
@@ -442,5 +451,5 @@ export function WidgetBody({ widget }: { widget: WidgetDef }) {
|
||||
</p>
|
||||
)
|
||||
}
|
||||
return <Renderer widget={widget} />
|
||||
return <Renderer widget={widget} dashboard={dashboard} />
|
||||
}
|
||||
|
||||
@@ -173,6 +173,15 @@ export function EdgeInspector({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Several nodes can publish one message, and so can a dashboard or
|
||||
another flow — so the value alone does not say what caused it. */}
|
||||
{live?.source && live.source.kind !== "node" ? (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Last set from {live.source.label}
|
||||
{live.source.detail ? ` (${live.source.detail})` : ""}.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{live === undefined ? (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Nothing has come through yet. Run the flow to see a value here.
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Handle, type NodeProps, Position } from "@xyflow/react"
|
||||
import { LayoutDashboard, Workflow } from "lucide-react"
|
||||
import { memo } from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { EndpointNodeData } from "./endpoints"
|
||||
|
||||
const KIND_ICONS = {
|
||||
dashboard: LayoutDashboard,
|
||||
flow: Workflow,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* A thing wired into this flow that is not a node in it.
|
||||
*
|
||||
* Drawn as a label rather than a card on purpose: a dashboard with twenty
|
||||
* tiles would otherwise bury the logic the canvas exists to show, which is
|
||||
* the same reason dashboards are their own documents. It is here to account
|
||||
* for a value, not to compete with the nodes for attention.
|
||||
*/
|
||||
function EndpointNodeComponent({ data, selected }: NodeProps) {
|
||||
const { label, kind, detail, provides, requires } = data as EndpointNodeData
|
||||
const Icon = KIND_ICONS[kind as keyof typeof KIND_ICONS] ?? Workflow
|
||||
const messages = [...provides, ...requires]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex max-w-48 items-center gap-2 px-1 py-0.5 text-muted-foreground",
|
||||
selected && "text-foreground",
|
||||
)}
|
||||
title={messages.join("\n")}
|
||||
>
|
||||
{/* Both sides always exist so an edge can attach; only one is used. */}
|
||||
{requires.map((message) => (
|
||||
<Handle
|
||||
key={`in-${message}`}
|
||||
type="target"
|
||||
id={message}
|
||||
position={Position.Left}
|
||||
className="!border-border !bg-card"
|
||||
/>
|
||||
))}
|
||||
{provides.map((message) => (
|
||||
<Handle
|
||||
key={`out-${message}`}
|
||||
type="source"
|
||||
id={message}
|
||||
position={Position.Right}
|
||||
className="!border-border !bg-card"
|
||||
/>
|
||||
))}
|
||||
|
||||
<Icon className="size-4 shrink-0" />
|
||||
<span className="grid min-w-0">
|
||||
<span className="truncate text-sm leading-tight">{label}</span>
|
||||
<span className="truncate text-xs tracking-wide uppercase opacity-70">
|
||||
{detail}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const EndpointNode = memo(EndpointNodeComponent)
|
||||
export default EndpointNode
|
||||
@@ -42,6 +42,8 @@ import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { CommandPalette } from "./CommandPalette"
|
||||
import { bindingsKey, deriveEdges, portOf, qualify } from "./deriveEdges"
|
||||
import { EdgeInspector, type InspectedEdge } from "./EdgeInspector"
|
||||
import { EndpointNode } from "./EndpointNode"
|
||||
import { deriveEndpoints, ENDPOINT_TYPE, isEndpointNode } from "./endpoints"
|
||||
import { FIT_VIEW, FlowDock } from "./FlowDock"
|
||||
import { FlowNode, type FlowNodeData } from "./FlowNode"
|
||||
import { FlowPanel } from "./FlowPanel"
|
||||
@@ -61,7 +63,10 @@ import {
|
||||
} from "./queries"
|
||||
import { useFlowSocket } from "./useFlowSocket"
|
||||
|
||||
const nodeTypes = { flow: FlowNode }
|
||||
const nodeTypes = { flow: FlowNode, [ENDPOINT_TYPE]: EndpointNode }
|
||||
|
||||
/** Is this canvas node actually part of the flow document? */
|
||||
const isDocumentNode = (node: { id: string }) => !isEndpointNode(node)
|
||||
const edgeTypes = { live: LiveEdge }
|
||||
|
||||
type Rebind = {
|
||||
@@ -380,11 +385,50 @@ function FlowEditorInner({
|
||||
}
|
||||
}, [key])
|
||||
|
||||
/** Where clicking an endpoint takes you: the thing it stands for. */
|
||||
const openEndpoint = useCallback(
|
||||
(id: string) => {
|
||||
const [kind, rest] = id.split(":", 2)
|
||||
if (kind === "dashboard") {
|
||||
navigate({
|
||||
to: "/dashboards/$name",
|
||||
params: { name: (rest ?? "").split(":")[0] },
|
||||
})
|
||||
} else if (kind === "flow") {
|
||||
navigate({
|
||||
to: "/flows/$flowName",
|
||||
params: { flowName: (rest ?? "").split(".")[0] },
|
||||
})
|
||||
}
|
||||
},
|
||||
[navigate],
|
||||
)
|
||||
|
||||
// Dashboards and other flows wired into this one. They are drawn but never
|
||||
// stored: they join at render, after everything that reads or writes
|
||||
// canvasNodes, so an autosave, an undo or a delete cannot reach them.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: positions change on every drag frame; the key covers the wiring.
|
||||
const external = useMemo(
|
||||
() =>
|
||||
deriveEndpoints(
|
||||
detail.endpoints ?? [],
|
||||
definitions,
|
||||
flowName,
|
||||
new Map(canvasNodes.map((node) => [node.id, node.position])),
|
||||
),
|
||||
[detail.endpoints, key, flowName],
|
||||
)
|
||||
|
||||
// Edges follow from the name bindings, so they are derived, never stored.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: the key is the dependency; the array identity changes on every drag frame.
|
||||
const edges = useMemo(
|
||||
() => deriveEdges(definitions, flowName),
|
||||
[key, flowName],
|
||||
() => [...deriveEdges(definitions, flowName), ...external.edges],
|
||||
[key, flowName, external],
|
||||
)
|
||||
|
||||
const shownNodes = useMemo(
|
||||
() => [...renderedNodes, ...external.nodes],
|
||||
[renderedNodes, external],
|
||||
)
|
||||
|
||||
// Editing ports adds and removes handles. React Flow measures those once, so
|
||||
@@ -680,14 +724,25 @@ function FlowEditorInner({
|
||||
return (
|
||||
<>
|
||||
<ReactFlow
|
||||
nodes={renderedNodes}
|
||||
nodes={shownNodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onNodeDragStop={(_event, _node, dragged) =>
|
||||
commit(definitions, mergeDragged(canvasNodes, dragged))
|
||||
commit(
|
||||
definitions,
|
||||
mergeDragged(canvasNodes, dragged.filter(isDocumentNode)),
|
||||
)
|
||||
}
|
||||
onNodesDelete={(deleted) =>
|
||||
deleteNodes(deleted.filter(isDocumentNode).map((node) => node.id))
|
||||
}
|
||||
onNodesDelete={(deleted) => deleteNodes(deleted.map((node) => node.id))}
|
||||
onNodeClick={(_event, node) => {
|
||||
// An endpoint is somewhere else's: opening its panel here would
|
||||
// offer to edit a node this flow does not contain.
|
||||
if (!isDocumentNode(node)) {
|
||||
openEndpoint(node.id)
|
||||
return
|
||||
}
|
||||
setFlowPanelOpen(false)
|
||||
setSelectedId(node.id)
|
||||
}}
|
||||
|
||||
@@ -34,7 +34,7 @@ function LiveEdgeComponent({
|
||||
data,
|
||||
selected,
|
||||
}: EdgeProps) {
|
||||
const { message } = (data ?? {}) as FlowEdgeData
|
||||
const { message, producerId } = (data ?? {}) as FlowEdgeData
|
||||
const live = useLiveValue(message)
|
||||
const zoom = useStore((state) => state.transform[2])
|
||||
const [pulsing, setPulsing] = useState(false)
|
||||
@@ -49,14 +49,20 @@ function LiveEdgeComponent({
|
||||
targetPosition,
|
||||
})
|
||||
|
||||
// Restart the stroke animation whenever a newer message lands.
|
||||
// Restart the stroke animation whenever a newer message lands — but only
|
||||
// for the producer that actually published it. A message can have several
|
||||
// producers, and can also be set from a dashboard or another flow, so
|
||||
// pulsing on the value alone claims things happened that did not.
|
||||
useEffect(() => {
|
||||
if (!live?.ts || live.ts === lastTs.current) return
|
||||
lastTs.current = live.ts
|
||||
const from = live.source
|
||||
// No source at all is an older engine; pulse rather than go silent.
|
||||
if (from && from.id !== producerId) return
|
||||
setPulsing(true)
|
||||
const timer = setTimeout(() => setPulsing(false), duration.pulse * 1000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [live?.ts])
|
||||
}, [live?.ts, live?.source, producerId])
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -27,6 +27,8 @@ export function portOf(spec: MessageSpec): string {
|
||||
export type FlowEdgeData = {
|
||||
message: string
|
||||
flow: string
|
||||
/** Whose publication this edge represents, so only it pulses. */
|
||||
producerId: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
@@ -65,7 +67,11 @@ export function deriveEdges(nodes: NodeDef_Input[], flow: string): Edge[] {
|
||||
target: node.id,
|
||||
targetHandle: targetPort,
|
||||
type: "live",
|
||||
data: { message, flow } satisfies FlowEdgeData,
|
||||
data: {
|
||||
message,
|
||||
flow,
|
||||
producerId: `${flow}.${producer.node}`,
|
||||
} satisfies FlowEdgeData,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { Edge, Node as FlowCanvasNode } from "@xyflow/react"
|
||||
|
||||
import type { Endpoint, NodeDef_Input } from "@/client"
|
||||
import type { FlowEdgeData } from "./deriveEdges"
|
||||
import { portOf, qualify } from "./deriveEdges"
|
||||
|
||||
/**
|
||||
* Canvas elements for things wired into a flow that are not nodes in it.
|
||||
*
|
||||
* A dashboard control setting one of its messages, a tile showing one, or a
|
||||
* node in another flow across a dotted name. Without these the canvas shows a
|
||||
* value changing with nothing to account for it — and pulses whichever node
|
||||
* happens to be drawn as a producer, which did nothing.
|
||||
*
|
||||
* They are never part of the document: this returns display-only nodes that
|
||||
* are appended after everything which reads or writes `canvasNodes`, so an
|
||||
* autosave, an undo or a delete cannot reach them.
|
||||
*/
|
||||
|
||||
/** Marks a canvas node as one of these, for anything that has to skip them. */
|
||||
export const ENDPOINT_TYPE = "endpoint"
|
||||
|
||||
export type EndpointNodeData = {
|
||||
label: string
|
||||
kind: string
|
||||
detail: string
|
||||
/** Messages it publishes into this flow, and ones it reads out of it. */
|
||||
provides: string[]
|
||||
requires: string[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** Lanes either side of the graph, so a label never lands on a node. */
|
||||
const GAP_X = 120
|
||||
const STACK_Y = 64
|
||||
/** Roughly a node's width; only used to find the right-hand lane. */
|
||||
const NODE_W = 220
|
||||
|
||||
export function isEndpointNode(node: { id: string }): boolean {
|
||||
return node.id.startsWith("dashboard:") || node.id.startsWith("flow:")
|
||||
}
|
||||
|
||||
/**
|
||||
* Place the endpoints and wire them to the nodes they touch.
|
||||
*
|
||||
* Positions are computed rather than stored: an endpoint is not part of the
|
||||
* flow, so there is nowhere to keep a position that would not be a lie about
|
||||
* what the document contains. A producer sits left of what it feeds, a
|
||||
* consumer right of what feeds it.
|
||||
*/
|
||||
export function deriveEndpoints(
|
||||
endpoints: Endpoint[],
|
||||
definitions: NodeDef_Input[],
|
||||
flow: string,
|
||||
positions: Map<string, { x: number; y: number }>,
|
||||
): { nodes: FlowCanvasNode[]; edges: Edge[] } {
|
||||
if (endpoints.length === 0) return { nodes: [], edges: [] }
|
||||
|
||||
// Which node consumes or produces each message, so a label can sit beside it.
|
||||
const consumers = new Map<string, { node: string; port: string }[]>()
|
||||
const producers = new Map<string, { node: string; port: string }[]>()
|
||||
for (const node of definitions) {
|
||||
for (const spec of node.requires ?? []) {
|
||||
const message = qualify(flow, spec.name ?? "")
|
||||
if (!message) continue
|
||||
consumers.set(message, [
|
||||
...(consumers.get(message) ?? []),
|
||||
{ node: node.id, port: portOf(spec) },
|
||||
])
|
||||
}
|
||||
for (const spec of node.provides ?? []) {
|
||||
const message = qualify(flow, spec.name ?? "")
|
||||
if (!message) continue
|
||||
producers.set(message, [
|
||||
...(producers.get(message) ?? []),
|
||||
{ node: node.id, port: portOf(spec) },
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// A lane either side of the graph. Anchoring each label to the node it
|
||||
// feeds put them on top of the nodes, so they live outside the whole thing
|
||||
// instead: producers to the left of everything, consumers to the right.
|
||||
const placed = [...positions.values()]
|
||||
const bounds = {
|
||||
left: placed.length ? Math.min(...placed.map((p) => p.x)) : 0,
|
||||
right: placed.length ? Math.max(...placed.map((p) => p.x)) + NODE_W : 0,
|
||||
top: placed.length ? Math.min(...placed.map((p) => p.y)) : 0,
|
||||
}
|
||||
|
||||
const nodes: FlowCanvasNode[] = []
|
||||
const edges: Edge[] = []
|
||||
// How many labels already sit on each side, so they stack instead of overlap.
|
||||
const stacked = { left: 0, right: 0 }
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
const produces = endpoint.provides ?? []
|
||||
const reads = endpoint.requires ?? []
|
||||
// A producer belongs upstream of what it feeds; everything else downstream.
|
||||
const side = produces.length > 0 ? "left" : "right"
|
||||
|
||||
const index = stacked[side]
|
||||
stacked[side] += 1
|
||||
nodes.push({
|
||||
id: endpoint.id,
|
||||
type: ENDPOINT_TYPE,
|
||||
position: {
|
||||
x: side === "left" ? bounds.left - GAP_X : bounds.right + GAP_X,
|
||||
y: bounds.top + index * STACK_Y,
|
||||
},
|
||||
// Not part of the document, and not the author's to rearrange.
|
||||
draggable: false,
|
||||
selectable: true,
|
||||
deletable: false,
|
||||
data: {
|
||||
label: endpoint.label,
|
||||
kind: endpoint.kind,
|
||||
detail: endpoint.detail ?? "",
|
||||
provides: produces,
|
||||
requires: reads,
|
||||
} satisfies EndpointNodeData,
|
||||
})
|
||||
|
||||
// An endpoint that publishes feeds every node consuming that message.
|
||||
for (const message of produces) {
|
||||
for (const consumer of consumers.get(message) ?? []) {
|
||||
edges.push({
|
||||
id: `${endpoint.id}->${consumer.node}:${consumer.port}`,
|
||||
source: endpoint.id,
|
||||
sourceHandle: message,
|
||||
target: consumer.node,
|
||||
targetHandle: consumer.port,
|
||||
type: "live",
|
||||
data: {
|
||||
message,
|
||||
flow,
|
||||
producerId: endpoint.id,
|
||||
} satisfies FlowEdgeData,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// One that reads is fed by every node producing it.
|
||||
for (const message of reads) {
|
||||
for (const producer of producers.get(message) ?? []) {
|
||||
edges.push({
|
||||
id: `${producer.node}:${producer.port}->${endpoint.id}`,
|
||||
source: producer.node,
|
||||
sourceHandle: producer.port,
|
||||
target: endpoint.id,
|
||||
targetHandle: message,
|
||||
type: "live",
|
||||
data: {
|
||||
message,
|
||||
flow,
|
||||
producerId: `${flow}.${producer.node}`,
|
||||
} satisfies FlowEdgeData,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, edges }
|
||||
}
|
||||
@@ -8,7 +8,18 @@ import { useSyncExternalStore } from "react"
|
||||
* nothing else.
|
||||
*/
|
||||
|
||||
export type LiveValue = { value: unknown; ts: number | null }
|
||||
/** Who caused a value. The canvas needs it to pulse the right edge. */
|
||||
export type ValueSource = {
|
||||
kind: "node" | "dashboard" | "flow" | "agent" | "api"
|
||||
id: string
|
||||
label: string
|
||||
detail?: string
|
||||
}
|
||||
export type LiveValue = {
|
||||
value: unknown
|
||||
ts: number | null
|
||||
source?: ValueSource
|
||||
}
|
||||
export type LiveStatus = {
|
||||
status: "active" | "error" | "running" | "success"
|
||||
error?: string | null
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useQueryClient } from "@tanstack/react-query"
|
||||
import { useEffect, useRef } from "react"
|
||||
|
||||
import { OpenAPI } from "@/client"
|
||||
import { type LogLine, liveStore } from "./liveStore"
|
||||
import { type LogLine, liveStore, type ValueSource } from "./liveStore"
|
||||
import { flowKeys } from "./queries"
|
||||
|
||||
const RECONNECT_MIN = 1000
|
||||
@@ -19,7 +19,13 @@ type FlowEvent =
|
||||
paused?: string[]
|
||||
logs?: LogLine[]
|
||||
}
|
||||
| { type: "message_value"; name: string; value: unknown; ts: number }
|
||||
| {
|
||||
type: "message_value"
|
||||
name: string
|
||||
value: unknown
|
||||
ts: number
|
||||
source?: ValueSource
|
||||
}
|
||||
| { type: "node_executed"; node: string; outputs: number }
|
||||
| { type: "node_error"; node: string; error: string }
|
||||
| { type: "node_status"; node: string; status: string; error?: string | null }
|
||||
@@ -88,6 +94,7 @@ export function useFlowSocket(onAuthFailure?: () => void): void {
|
||||
liveStore.setValue(message.name, {
|
||||
value: message.value,
|
||||
ts: message.ts,
|
||||
source: message.source,
|
||||
})
|
||||
break
|
||||
case "node_executed":
|
||||
|
||||
Reference in New Issue
Block a user