Skip to content

Commit d6f2f9a

Browse files
committed
feat(dashboard): workflow flow-graph island
graph_builder + ReactFlow nodes (step, start, end, gateway, child-workflow) and the animated status edge, with dagre layout and runtime status overlay.
1 parent c433ebb commit d6f2f9a

11 files changed

Lines changed: 674 additions & 262 deletions

File tree

durable_dashboard/assets/src/lib/graph-layout.ts

Lines changed: 69 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,40 +11,83 @@
1111
*/
1212

1313
import dagre from "@dagrejs/dagre";
14-
import type { Edge, Node } from "@xyflow/react";
14+
import { type Edge, MarkerType, type Node } from "@xyflow/react";
1515
import type { GraphData, GraphEdge } from "./types";
1616

1717
// ---------------------------------------------------------------------------
1818
// Sizing — must match the React node components in `assets/src/react/nodes`.
19-
// Every node is a uniform 88×96 cell (64×64 icon box + label region below)
20-
// for the n8n-inspired rhythm. See DESIGN.md §11.
19+
// Horizontal status cards (Argo/Dagster/GitHub-style), uniform height so the
20+
// LR ranks read like an execution timeline. `child_workflow` nodes share the
21+
// step cell's exact footprint — they're the same card with a stacked sheet
22+
// behind it (the drill-in signature), which only peeks a few px beyond the
23+
// box and so doesn't change the dagre footprint. See DESIGN.md §11.
2124
// ---------------------------------------------------------------------------
22-
const CELL_WIDTH = 88;
23-
const CELL_HEIGHT = 96;
24-
25-
const NODESEP = 40;
26-
const RANKSEP = 80;
27-
const MARGIN = 32;
25+
const CELL_WIDTH = 200;
26+
const CELL_HEIGHT = 56;
27+
28+
// Start/End render as small terminal pills, not full cards.
29+
const TERMINAL_WIDTH = 52;
30+
const TERMINAL_HEIGHT = 28;
31+
32+
// Spacing chosen to keep neighbouring rows visually separate even when
33+
// goto branches force dagre to splay nodes onto extra rows. Previous
34+
// 40/80 was too tight (sub-row goto targets dipped *into* the main
35+
// lane); 60/110 was an improvement but still left branch-merge
36+
// patterns reading as "node sitting in a half-row dip." The current
37+
// 100/120 makes a goto sub-lane read as deliberate vertical
38+
// separation rather than misalignment.
39+
// Cards are wider + uniform-height now, so they need less vertical splay;
40+
// near-equal rank/node gaps read grid-like (Argo's ranksep≈nodesep insight).
41+
const NODESEP = 70;
42+
const RANKSEP = 110;
43+
const MARGIN = 40;
2844

2945
const START_NODE_ID = "__durable_start__";
3046
const END_NODE_ID = "__durable_end__";
3147

48+
const ARROW_MARKER = {
49+
type: MarkerType.ArrowClosed,
50+
width: 14,
51+
height: 14,
52+
color: "var(--muted-foreground)",
53+
};
54+
3255
interface Dim {
3356
width: number;
3457
height: number;
3558
}
3659

37-
// Every node renders as the same 88×96 cell (64 icon + 32 label). Keeps
38-
// the visual rhythm and makes dagre's collision math trivial.
39-
function dimsFor(_type: string): Dim {
60+
function dimsFor(type: string): Dim {
61+
if (type === "start" || type === "end") {
62+
return { width: TERMINAL_WIDTH, height: TERMINAL_HEIGHT };
63+
}
64+
// child_workflow shares the step cell footprint (see sizing note above).
4065
return { width: CELL_WIDTH, height: CELL_HEIGHT };
4166
}
4267

4368
// ---------------------------------------------------------------------------
4469
// Public entry point
4570
// ---------------------------------------------------------------------------
4671

47-
export function layoutGraph(graph: GraphData): { nodes: Node[]; edges: Edge[] } {
72+
type PositionMap = Map<string, { x: number; y: number }>;
73+
74+
/**
75+
* Lay out a graph for ReactFlow.
76+
*
77+
* `prevPositions` is the position map from a previous layout of the SAME
78+
* topology. When supplied and complete (covers every node we're about to
79+
* render, including the synthesized start/end markers), dagre is skipped
80+
* and the cached coordinates are reused verbatim. This is what stops the
81+
* canvas from re-laying-out and snapping nodes on every realtime status
82+
* tick during an active run — the bug that made the graph "jump around"
83+
* while a workflow was executing. The caller only passes `prevPositions`
84+
* when the topology signature (node ids + types + edge ids) is unchanged,
85+
* so reusing positions is always geometrically correct here.
86+
*/
87+
export function layoutGraph(
88+
graph: GraphData,
89+
prevPositions?: PositionMap,
90+
): { nodes: Node[]; edges: Edge[]; positions: PositionMap } {
4891
// Synthesize start/end markers wrapping the whole flow.
4992
const allNodes = [
5093
...graph.nodes.map((n) => ({ id: n.id, type: n.type, data: n.data })),
@@ -60,7 +103,8 @@ export function layoutGraph(graph: GraphData): { nodes: Node[]; edges: Edge[] }
60103
...leafIds.map((l) => boundaryEdge(l, END_NODE_ID)),
61104
];
62105

63-
const positions = layoutDagre(allNodes, allEdges);
106+
const canReuse = prevPositions !== undefined && allNodes.every((n) => prevPositions.has(n.id));
107+
const positions = canReuse ? (prevPositions as PositionMap) : layoutDagre(allNodes, allEdges);
64108

65109
const nodes: Node[] = allNodes.map((n) => {
66110
const pos = positions.get(n.id) || { x: 0, y: 0 };
@@ -81,9 +125,13 @@ export function layoutGraph(graph: GraphData): { nodes: Node[]; edges: Edge[] }
81125
label: e.label,
82126
className: e.className,
83127
type: "animated_flow",
128+
// A subtle arrowhead reinforces execution direction. Neutral fill (a CSS
129+
// var so it tracks the theme) keeps status legible on the stroke, not the
130+
// marker. Suppressed into the END terminal so arrows don't pile on it.
131+
markerEnd: e.target === END_NODE_ID ? undefined : ARROW_MARKER,
84132
}));
85133

86-
return { nodes, edges };
134+
return { nodes, edges, positions };
87135
}
88136

89137
// ---------------------------------------------------------------------------
@@ -135,6 +183,12 @@ function layoutDagre(
135183
ranksep: RANKSEP,
136184
marginx: MARGIN,
137185
marginy: MARGIN,
186+
// `tight-tree` produces visibly cleaner layouts for branch-and-merge
187+
// workflows than the default `network-simplex`: it keeps merging
188+
// siblings on a clearly-distinct sub-lane rather than dropping them
189+
// into a half-row dip below the main flow. n8n's editor uses a
190+
// similar layered approach.
191+
ranker: "tight-tree",
138192
});
139193

140194
for (const n of nodes) {

durable_dashboard/assets/src/lib/types.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,17 @@
77

88
export type StepStatus = "pending" | "running" | "completed" | "failed" | "waiting";
99

10+
// Types the server (`graph_builder.ex`) currently emits. `start`/`end` are
11+
// synthesized client-side in `graph-layout.ts`; `child_workflow` is the wider
12+
// n8n-style card for parallel/sub-workflow children. The `*_fork`/`*_join`
13+
// markers are no longer emitted (fan-out/in is pure edge geometry) but remain
14+
// in the union as a documented stale-BEAM fallback (mapped to HiddenNode).
1015
export type NodeType =
1116
| "step"
1217
| "decision"
18+
| "child_workflow"
19+
| "start"
20+
| "end"
1321
| "branch_fork"
1422
| "branch_join"
1523
| "parallel_fork"
@@ -27,6 +35,24 @@ export interface GraphNode {
2735
duration_ms?: number;
2836
clause?: string;
2937
parallel?: boolean;
38+
/** Set by `graph_builder.overlay_status/3` once a runtime step
39+
* execution has been matched to this graph node. */
40+
started_at?: string;
41+
completed_at?: string;
42+
step_execution_id?: string;
43+
workflow_execution_id?: string;
44+
is_current?: boolean;
45+
/** Truncated 1-line JSON previews of step input/output, suitable for
46+
* rendering in an n8n-style card. The full payload is fetched on
47+
* demand when the user opens the side drawer — keeping the graph
48+
* over-the-wire payload lean. */
49+
input_preview?: string;
50+
output_preview?: string;
51+
has_error?: boolean;
52+
/** Set when this step represents a parallel child running in its own
53+
* `WorkflowExecution`. The card surfaces an expand/drill-in
54+
* affordance and the LV translates the click into a navigate. */
55+
child_workflow_id?: string;
3056
};
3157
}
3258

durable_dashboard/assets/src/react/edges/animated_flow_edge.tsx

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
/**
2-
* Workflow edge — bezier path with optional label chip rendered through
3-
* ReactFlow's portal. Status-driven stroke styling is applied via the
4-
* `flow-edge-{completed,running,pending}` classes set on the edge wrapper
5-
* by `graph_builder.overlay_status/3` (DESIGN.md §11). This component
6-
* focuses on label rendering; we keep edge stroke handled by CSS so the
7-
* theme can shift it without a re-render.
2+
* Workflow edge — orthogonal **smoothstep** path with an optional label chip.
3+
* Smoothstep (vs the old free bezier) is the load-bearing fix for "odd
4+
* branching": edges leave the source's right face, run straight, then step to
5+
* each target's Y, so fan-out/fan-in form clean parallel trunks instead of
6+
* overlapping swoops (the Argo/Dagster/GitHub-Actions look). Status stroke is
7+
* applied via the `flow-edge-{completed,running,pending,conditional}` classes
8+
* set by `graph_builder.overlay_status/3` (DESIGN.md §11), so the theme/status
9+
* can shift the stroke without a re-render.
810
*/
911

10-
import { BaseEdge, EdgeLabelRenderer, type EdgeProps, getBezierPath } from "@xyflow/react";
12+
import { BaseEdge, EdgeLabelRenderer, type EdgeProps, getSmoothStepPath } from "@xyflow/react";
1113
import { memo } from "react";
1214

1315
function AnimatedFlowEdgeComponent({
@@ -21,13 +23,17 @@ function AnimatedFlowEdgeComponent({
2123
label,
2224
markerEnd,
2325
}: EdgeProps) {
24-
const [path, labelX, labelY] = getBezierPath({
26+
const [path, labelX, labelY] = getSmoothStepPath({
2527
sourceX,
2628
sourceY,
2729
targetX,
2830
targetY,
2931
sourcePosition,
3032
targetPosition,
33+
// Soft modern corners + a 20px straight run out of each handle so sibling
34+
// branches share a clean trunk before splaying to their lanes.
35+
borderRadius: 8,
36+
offset: 20,
3137
});
3238

3339
return (

durable_dashboard/assets/src/react/flow_graph.tsx

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,15 @@ import {
1818
type Node,
1919
ReactFlow,
2020
ReactFlowProvider,
21+
useReactFlow,
2122
} from "@xyflow/react";
22-
import { useEffect, useMemo, useState } from "react";
23+
import { useEffect, useMemo, useRef, useState } from "react";
2324
import { createRoot, type Root } from "react-dom/client";
2425
import { layoutGraph } from "../lib/graph-layout";
2526
import { resolveToken } from "../lib/tokens";
2627
import type { GraphData } from "../lib/types";
2728
import { AnimatedFlowEdge } from "./edges/animated_flow_edge";
29+
import { ChildWorkflowNode } from "./nodes/child_workflow_node";
2830
import { EndNode } from "./nodes/end_node";
2931
import { GatewayNode } from "./nodes/gateway_node";
3032
import { HiddenNode } from "./nodes/hidden_node";
@@ -39,11 +41,16 @@ import { StepNode } from "./nodes/step_node";
3941
// `HiddenNode` fallback so a stale BEAM that still emits them doesn't
4042
// surface as floating "Parallel" / "Join" text on the canvas (which is
4143
// what ReactFlow's default node renderer would do for unknown types).
44+
//
45+
// `child_workflow` is the n8n-style wider card emitted by
46+
// `graph_builder.attach_child_workflow_id/2` for parallel children that
47+
// have their own WorkflowExecution row — see DESIGN.md §11.
4248
const NODE_TYPES = {
4349
step: StepNode,
4450
decision: GatewayNode,
4551
start: StartNode,
4652
end: EndNode,
53+
child_workflow: ChildWorkflowNode,
4754
parallel_fork: HiddenNode,
4855
parallel_join: HiddenNode,
4956
branch_fork: HiddenNode,
@@ -108,8 +115,34 @@ interface FlowIslandRootProps {
108115
) => void;
109116
}
110117

118+
// Topology signature: node ids + types + edge ids. Status/data/className
119+
// changes do NOT alter it. The layout (dagre) only needs to re-run when this
120+
// changes; a realtime status tick keeps the same signature and reuses cached
121+
// positions, so the canvas no longer reflows on every update.
122+
function topoSignature(graph: GraphData): string {
123+
const nodes = graph.nodes
124+
.map((n) => `${n.id}:${n.type}`)
125+
.sort()
126+
.join("|");
127+
const edges = graph.edges
128+
.map((e) => e.id)
129+
.sort()
130+
.join("|");
131+
return `${nodes}__${edges}`;
132+
}
133+
111134
function FlowIslandRoot({ initialGraph, registerHandlers }: FlowIslandRootProps) {
112135
const [graph, setGraphState] = useState<GraphData>(initialGraph);
136+
const { fitView } = useReactFlow();
137+
138+
// Cache of the last layout's positions + the topology they belong to.
139+
const layoutRef = useRef<{
140+
sig: string;
141+
positions: Map<string, { x: number; y: number }>;
142+
}>({ sig: "", positions: new Map() });
143+
// Fit the viewport on first paint and whenever topology grows/shrinks,
144+
// never on a plain status tick (which would yank the viewport mid-read).
145+
const fitPendingRef = useRef(true);
113146

114147
useEffect(() => {
115148
registerHandlers(
@@ -126,31 +159,60 @@ function FlowIslandRoot({ initialGraph, registerHandlers }: FlowIslandRootProps)
126159
);
127160
}, [registerHandlers]);
128161

129-
const { nodes, edges } = useMemo(() => layoutGraph(graph), [graph]);
162+
const { nodes, edges } = useMemo(() => {
163+
const sig = topoSignature(graph);
164+
const reuse = sig === layoutRef.current.sig && layoutRef.current.positions.size > 0;
165+
const result = layoutGraph(graph, reuse ? layoutRef.current.positions : undefined);
166+
layoutRef.current = { sig, positions: result.positions };
167+
if (!reuse) fitPendingRef.current = true;
168+
return { nodes: result.nodes, edges: result.edges };
169+
}, [graph]);
170+
171+
// Re-fit only when a relayout happened (topology change / first paint).
172+
// requestAnimationFrame lets ReactFlow measure the new nodes first.
173+
useEffect(() => {
174+
if (!fitPendingRef.current || nodes.length === 0) return;
175+
fitPendingRef.current = false;
176+
const raf = requestAnimationFrame(() => fitView({ padding: 0.2, duration: 200 }));
177+
return () => cancelAnimationFrame(raf);
178+
}, [nodes, fitView]);
130179

131180
return (
132181
<ReactFlow
133182
nodes={nodes as Node[]}
134183
edges={edges as Edge[]}
135184
nodeTypes={NODE_TYPES}
136185
edgeTypes={EDGE_TYPES}
137-
fitView
138186
fitViewOptions={{ padding: 0.2 }}
139187
proOptions={{ hideAttribution: true }}
140188
defaultEdgeOptions={{ type: "animated_flow" }}
141189
minZoom={0.2}
142-
maxZoom={1.5}
190+
maxZoom={2}
191+
// Explicit viewport interaction props — defaults are already on
192+
// for these, but spelling them out makes the intent obvious and
193+
// guards against silent regressions. Dashboard users should be
194+
// able to zoom, pan, and rearrange nodes when inspecting a run.
195+
nodesDraggable
196+
nodesConnectable={false}
197+
panOnDrag
198+
zoomOnScroll
199+
zoomOnPinch
200+
panOnScroll={false}
201+
selectionOnDrag={false}
143202
>
144203
<Background gap={24} size={1} />
145204
<Controls position="bottom-right" showInteractive={false} />
146205
<MiniMap
147206
position="top-right"
148207
nodeStrokeWidth={1}
208+
nodeBorderRadius={4}
149209
pannable
150210
zoomable
151211
nodeColor={miniMapNodeColor}
152212
nodeStrokeColor={miniMapNodeStroke}
153-
maskColor={resolveToken("--background", "rgba(0,0,0,0.5)")}
213+
// Translucent mask (was a solid --background that read as an all-black
214+
// wash hiding the nodes). A CSS color-mix string stays theme-reactive.
215+
maskColor="color-mix(in oklch, var(--background) 55%, transparent)"
154216
/>
155217
</ReactFlow>
156218
);

0 commit comments

Comments
 (0)