@llamaindex/workflow-debugger 0.1.9 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +205 -0
- package/eslint.config.js +26 -0
- package/{dist/index.html → index.html} +1 -2
- package/package.json +12 -17
- package/postcss.config.js +6 -0
- package/src/App.tsx +18 -0
- package/src/components/code-block.tsx +48 -0
- package/src/components/error-boundary.tsx +85 -0
- package/src/components/json-schema-editor.tsx +291 -0
- package/src/components/run-details-panel.tsx +290 -0
- package/src/components/run-list-panel.tsx +83 -0
- package/src/components/send-event-dialog.tsx +299 -0
- package/src/components/workflow-config-panel.tsx +247 -0
- package/src/components/workflow-debugger.tsx +342 -0
- package/src/components/workflow-visualization.tsx +720 -0
- package/src/index.css +86 -0
- package/src/main.tsx +24 -0
- package/tailwind.config.js +9 -0
- package/tests/json-schema-editor.test.tsx +62 -0
- package/tests/test-setup.ts +1 -0
- package/tsconfig.build.json +5 -0
- package/tsconfig.json +16 -0
- package/ui_sample.png +0 -0
- package/vite.config.ts +46 -0
- package/vitest.config.ts +16 -0
- package/dist/app.css +0 -10
- package/dist/app.js +0 -624
- package/dist/assets/KaTeX_AMS-Regular.ttf +0 -0
- package/dist/assets/KaTeX_AMS-Regular.woff +0 -0
- package/dist/assets/KaTeX_AMS-Regular.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold.woff +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular.woff +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Bold.ttf +0 -0
- package/dist/assets/KaTeX_Main-Bold.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold.woff2 +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic.woff +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Italic.ttf +0 -0
- package/dist/assets/KaTeX_Main-Italic.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular.ttf +0 -0
- package/dist/assets/KaTeX_Main-Regular.woff +0 -0
- package/dist/assets/KaTeX_Main-Regular.woff2 +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic.woff +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/dist/assets/KaTeX_Math-Italic.ttf +0 -0
- package/dist/assets/KaTeX_Math-Italic.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular.ttf +0 -0
- package/dist/assets/KaTeX_Script-Regular.woff +0 -0
- package/dist/assets/KaTeX_Script-Regular.woff2 +0 -0
- package/dist/assets/KaTeX_Size1-Regular.ttf +0 -0
- package/dist/assets/KaTeX_Size1-Regular.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular.ttf +0 -0
- package/dist/assets/KaTeX_Size2-Regular.woff +0 -0
- package/dist/assets/KaTeX_Size2-Regular.woff2 +0 -0
- package/dist/assets/KaTeX_Size3-Regular.ttf +0 -0
- package/dist/assets/KaTeX_Size3-Regular.woff +0 -0
- package/dist/assets/KaTeX_Size4-Regular.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular.woff +0 -0
- package/dist/assets/KaTeX_Size4-Regular.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular.ttf +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular.woff +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular.woff2 +0 -0
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
ReactFlow,
|
|
4
|
+
Node,
|
|
5
|
+
Edge,
|
|
6
|
+
addEdge,
|
|
7
|
+
useNodesState,
|
|
8
|
+
useEdgesState,
|
|
9
|
+
Connection,
|
|
10
|
+
Background,
|
|
11
|
+
BackgroundVariant,
|
|
12
|
+
NodeTypes,
|
|
13
|
+
Handle,
|
|
14
|
+
Position,
|
|
15
|
+
MarkerType,
|
|
16
|
+
} from "@xyflow/react";
|
|
17
|
+
import "@xyflow/react/dist/style.css";
|
|
18
|
+
import { useWorkflowsClient } from "@llamaindex/ui";
|
|
19
|
+
import { getWorkflowsByNameRepresentation } from "@llamaindex/workflows-client";
|
|
20
|
+
|
|
21
|
+
// Node type renderers are created inside the component so they can react to theme
|
|
22
|
+
|
|
23
|
+
interface WorkflowEventItem {
|
|
24
|
+
type: string;
|
|
25
|
+
data: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface WorkflowVisualizationProps {
|
|
29
|
+
workflowName: string | null;
|
|
30
|
+
className?: string;
|
|
31
|
+
events?: WorkflowEventItem[];
|
|
32
|
+
onNodeClick?: (nodeId: string) => void;
|
|
33
|
+
highlightedNodeId?: string | null;
|
|
34
|
+
selectedNodeId?: string | null;
|
|
35
|
+
isComplete?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface WorkflowGraphNode {
|
|
39
|
+
id: string;
|
|
40
|
+
label: string;
|
|
41
|
+
node_type: "step" | "event" | "external";
|
|
42
|
+
title?: string;
|
|
43
|
+
event_type?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface WorkflowGraphEdge {
|
|
47
|
+
source: string;
|
|
48
|
+
target: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface WorkflowGraphData {
|
|
52
|
+
nodes: WorkflowGraphNode[];
|
|
53
|
+
edges: WorkflowGraphEdge[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function WorkflowVisualization({
|
|
57
|
+
workflowName,
|
|
58
|
+
className = "w-full h-[600px]",
|
|
59
|
+
events,
|
|
60
|
+
onNodeClick,
|
|
61
|
+
highlightedNodeId,
|
|
62
|
+
selectedNodeId,
|
|
63
|
+
isComplete = false,
|
|
64
|
+
}: WorkflowVisualizationProps) {
|
|
65
|
+
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
|
66
|
+
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
|
67
|
+
const [loading, setLoading] = useState(false);
|
|
68
|
+
const [error, setError] = useState<string | null>(null);
|
|
69
|
+
const [lastProcessedEventIndex, setLastProcessedEventIndex] = useState(-1);
|
|
70
|
+
const [isDark, setIsDark] = useState<boolean>(() => {
|
|
71
|
+
if (typeof window === "undefined") return false;
|
|
72
|
+
try {
|
|
73
|
+
if (document.documentElement.classList.contains("dark")) return true;
|
|
74
|
+
return (
|
|
75
|
+
window.matchMedia &&
|
|
76
|
+
window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
77
|
+
);
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
const nodeTypes: NodeTypes = useMemo(() => {
|
|
83
|
+
return {
|
|
84
|
+
step: ({ data, id }) => {
|
|
85
|
+
const isHighlighted = highlightedNodeId === id;
|
|
86
|
+
const isSelected = selectedNodeId === id;
|
|
87
|
+
|
|
88
|
+
// Always include transition for smooth fade-out
|
|
89
|
+
let style: React.CSSProperties = {
|
|
90
|
+
transition: "all 1s ease-out",
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// If highlight color is set and not null, apply highlight styles
|
|
94
|
+
if (data.highlightColor && data.highlightColor !== null) {
|
|
95
|
+
style.backgroundColor = isDark ? undefined : "#CCFBEF";
|
|
96
|
+
style.borderColor = data.highlightColor;
|
|
97
|
+
style.boxShadow = `0 0 0 3px ${data.highlightColor}66`;
|
|
98
|
+
} else if (data.highlightColor === null) {
|
|
99
|
+
// Explicitly fade back to default styles
|
|
100
|
+
style.backgroundColor = isDark ? "#0b1220" : "#E6EEFF";
|
|
101
|
+
style.borderColor = isDark ? "#274690" : "#7EA6FF";
|
|
102
|
+
style.boxShadow = "0 0 0 0px transparent";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (isSelected) {
|
|
106
|
+
style = {
|
|
107
|
+
backgroundColor: isDark ? "#1e3a8a" : "#DBEAFE",
|
|
108
|
+
borderColor: "#3B82F6",
|
|
109
|
+
boxShadow: "0 0 0 4px #3B82F6",
|
|
110
|
+
transition: "all 1s ease-out",
|
|
111
|
+
};
|
|
112
|
+
} else if (isHighlighted) {
|
|
113
|
+
style = {
|
|
114
|
+
...style,
|
|
115
|
+
borderColor: "#10B981",
|
|
116
|
+
boxShadow: "0 0 0 3px #10B98166",
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return (
|
|
120
|
+
<div
|
|
121
|
+
className={
|
|
122
|
+
"px-4 py-2 shadow-md rounded-md min-w-[160px] cursor-pointer transition-all " +
|
|
123
|
+
// light
|
|
124
|
+
"bg-[#E6EEFF] border-2 border-[#7EA6FF] " +
|
|
125
|
+
// dark
|
|
126
|
+
"dark:bg-[#0b1220] dark:border-[#274690] " +
|
|
127
|
+
"hover:shadow-lg hover:scale-105"
|
|
128
|
+
}
|
|
129
|
+
style={style}
|
|
130
|
+
onClick={() => onNodeClick?.(id)}
|
|
131
|
+
>
|
|
132
|
+
<Handle type="target" position={Position.Left} />
|
|
133
|
+
<div className="flex items-center justify-between gap-2">
|
|
134
|
+
<div className="font-bold text-[#1E3A8A] text-sm dark:text-[#e5edff]">
|
|
135
|
+
{data.label}
|
|
136
|
+
</div>
|
|
137
|
+
{data.workerCount > 0 && (
|
|
138
|
+
<div className="flex items-center justify-center min-w-[20px] h-[20px] px-1.5 rounded-full bg-[#10B981] text-white text-[10px] font-bold">
|
|
139
|
+
{data.workerCount}
|
|
140
|
+
</div>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
{data.title && (
|
|
144
|
+
<div className="text-xs text-[#1D4ED8] mt-1 dark:text-[#9db4ff]">
|
|
145
|
+
{data.title}
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
{(data.lastInputEvent || data.lastOutputEvent) && (
|
|
149
|
+
<div className="text-[10px] text-gray-700 dark:text-gray-300 mt-1">
|
|
150
|
+
{data.lastInputEvent && (
|
|
151
|
+
<div>
|
|
152
|
+
<span className="font-semibold">in:</span>{" "}
|
|
153
|
+
{data.lastInputEvent}
|
|
154
|
+
</div>
|
|
155
|
+
)}
|
|
156
|
+
{data.lastOutputEvent && (
|
|
157
|
+
<div>
|
|
158
|
+
<span className="font-semibold">out:</span>{" "}
|
|
159
|
+
{data.lastOutputEvent}
|
|
160
|
+
</div>
|
|
161
|
+
)}
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
<Handle type="source" position={Position.Right} />
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
},
|
|
168
|
+
event: ({ data, id }) => {
|
|
169
|
+
const isHighlighted = highlightedNodeId === id;
|
|
170
|
+
const isSelected = selectedNodeId === id;
|
|
171
|
+
|
|
172
|
+
// Always include transition for smooth fade-out
|
|
173
|
+
let style: React.CSSProperties = {
|
|
174
|
+
transition: "all 1s ease-out",
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// If highlight color is set and not null, apply highlight styles
|
|
178
|
+
if (data.highlightColor && data.highlightColor !== null) {
|
|
179
|
+
style.backgroundColor = isDark ? undefined : "#FFE8B5";
|
|
180
|
+
style.borderColor = data.highlightColor;
|
|
181
|
+
style.boxShadow = `0 0 0 3px ${data.highlightColor}66`;
|
|
182
|
+
} else if (data.highlightColor === null) {
|
|
183
|
+
// Explicitly fade back to default styles
|
|
184
|
+
style.backgroundColor = isDark ? "#241a06" : "#FFF3BF";
|
|
185
|
+
style.borderColor = isDark ? "#F59E0B" : "#FFD166";
|
|
186
|
+
style.boxShadow = "0 0 0 0px transparent";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (isSelected) {
|
|
190
|
+
style = {
|
|
191
|
+
backgroundColor: isDark ? "#1e3a8a" : "#DBEAFE",
|
|
192
|
+
borderColor: "#3B82F6",
|
|
193
|
+
boxShadow: "0 0 0 4px #3B82F6",
|
|
194
|
+
transition: "all 1s ease-out",
|
|
195
|
+
};
|
|
196
|
+
} else if (isHighlighted) {
|
|
197
|
+
style = {
|
|
198
|
+
...style,
|
|
199
|
+
borderColor: "#10B981",
|
|
200
|
+
boxShadow: "0 0 0 3px #10B98166",
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
return (
|
|
204
|
+
<div
|
|
205
|
+
className={
|
|
206
|
+
"px-3 py-2 shadow-md rounded-full min-w-[140px] text-center cursor-pointer transition-all " +
|
|
207
|
+
"bg-[#FFF3BF] border-2 border-[#FFD166] " +
|
|
208
|
+
"dark:bg-[#241a06] dark:border-[#F59E0B] " +
|
|
209
|
+
"hover:shadow-lg hover:scale-105"
|
|
210
|
+
}
|
|
211
|
+
style={style}
|
|
212
|
+
onClick={() => onNodeClick?.(id)}
|
|
213
|
+
>
|
|
214
|
+
<Handle type="target" position={Position.Left} />
|
|
215
|
+
<div className="font-bold text-[#92400E] text-sm dark:text-[#fde68a]">
|
|
216
|
+
{data.label}
|
|
217
|
+
</div>
|
|
218
|
+
{data.event_type && (
|
|
219
|
+
<div className="text-xs text-[#B45309] mt-1 dark:text-[#fbbf24]">
|
|
220
|
+
{data.event_type}
|
|
221
|
+
</div>
|
|
222
|
+
)}
|
|
223
|
+
<Handle type="source" position={Position.Right} />
|
|
224
|
+
</div>
|
|
225
|
+
);
|
|
226
|
+
},
|
|
227
|
+
external: ({ data, id }) => {
|
|
228
|
+
const isHighlighted = highlightedNodeId === id;
|
|
229
|
+
const isSelected = selectedNodeId === id;
|
|
230
|
+
|
|
231
|
+
let style = data.highlightColor
|
|
232
|
+
? {
|
|
233
|
+
backgroundColor: isDark ? undefined : "#FFE5D4",
|
|
234
|
+
borderColor: data.highlightColor,
|
|
235
|
+
boxShadow: `0 0 0 3px ${data.highlightColor}66`,
|
|
236
|
+
}
|
|
237
|
+
: undefined;
|
|
238
|
+
|
|
239
|
+
if (isSelected) {
|
|
240
|
+
style = {
|
|
241
|
+
backgroundColor: isDark ? "#1e3a8a" : "#DBEAFE",
|
|
242
|
+
borderColor: "#3B82F6",
|
|
243
|
+
boxShadow: "0 0 0 4px #3B82F6",
|
|
244
|
+
};
|
|
245
|
+
} else if (isHighlighted) {
|
|
246
|
+
style = {
|
|
247
|
+
backgroundColor: style?.backgroundColor,
|
|
248
|
+
borderColor: "#10B981",
|
|
249
|
+
boxShadow: "0 0 0 3px #10B98166",
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
return (
|
|
253
|
+
<div
|
|
254
|
+
className={
|
|
255
|
+
"px-3 py-2 shadow-md rounded-lg min-w-[140px] text-center cursor-pointer transition-all " +
|
|
256
|
+
"bg-[#FFEDD5] border-2 border-[#FB923C] " +
|
|
257
|
+
"dark:bg-[#1f0f08] dark:border-[#ea7a3a] " +
|
|
258
|
+
"hover:shadow-lg hover:scale-105"
|
|
259
|
+
}
|
|
260
|
+
style={style}
|
|
261
|
+
onClick={() => onNodeClick?.(id)}
|
|
262
|
+
>
|
|
263
|
+
<Handle type="target" position={Position.Left} />
|
|
264
|
+
<div className="font-bold text-[#7C2D12] text-sm dark:text-[#fed7aa]">
|
|
265
|
+
{data.label}
|
|
266
|
+
</div>
|
|
267
|
+
{data.title && (
|
|
268
|
+
<div className="text-xs text-[#C2410C] mt-1 dark:text-[#fdba74]">
|
|
269
|
+
{data.title}
|
|
270
|
+
</div>
|
|
271
|
+
)}
|
|
272
|
+
<Handle type="source" position={Position.Right} />
|
|
273
|
+
</div>
|
|
274
|
+
);
|
|
275
|
+
},
|
|
276
|
+
} as NodeTypes;
|
|
277
|
+
}, [isDark, highlightedNodeId, selectedNodeId, onNodeClick]);
|
|
278
|
+
|
|
279
|
+
useEffect(() => {
|
|
280
|
+
if (typeof window === "undefined" || !window.matchMedia) return;
|
|
281
|
+
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
|
282
|
+
const handler = (e: MediaQueryListEvent) => setIsDark(e.matches);
|
|
283
|
+
if (mq.addEventListener) mq.addEventListener("change", handler);
|
|
284
|
+
else mq.addListener(handler);
|
|
285
|
+
return () => {
|
|
286
|
+
if (mq.removeEventListener) mq.removeEventListener("change", handler);
|
|
287
|
+
else mq.removeListener(handler);
|
|
288
|
+
};
|
|
289
|
+
}, []);
|
|
290
|
+
|
|
291
|
+
const workflowsClient = useWorkflowsClient();
|
|
292
|
+
|
|
293
|
+
const onConnect = useCallback(
|
|
294
|
+
(params: Connection) => setEdges((eds) => addEdge(params, eds)),
|
|
295
|
+
[setEdges],
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
// Simple left-to-right DAG layout (no external deps)
|
|
299
|
+
const layoutGraph = useCallback(
|
|
300
|
+
(graphData: WorkflowGraphData): { nodes: Node[]; edges: Edge[] } => {
|
|
301
|
+
const outEdges = new Map<string, string[]>();
|
|
302
|
+
const inDegree = new Map<string, number>();
|
|
303
|
+
|
|
304
|
+
graphData.nodes.forEach((n) => {
|
|
305
|
+
outEdges.set(n.id, []);
|
|
306
|
+
inDegree.set(n.id, 0);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
graphData.edges.forEach(({ source, target }) => {
|
|
310
|
+
outEdges.get(source)?.push(target);
|
|
311
|
+
inDegree.set(target, (inDegree.get(target) || 0) + 1);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// Kahn topological layering
|
|
315
|
+
const queue: string[] = [];
|
|
316
|
+
inDegree.forEach((deg, id) => {
|
|
317
|
+
if (deg === 0) queue.push(id);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
const layer = new Map<string, number>();
|
|
321
|
+
queue.forEach((id) => layer.set(id, 0));
|
|
322
|
+
|
|
323
|
+
while (queue.length) {
|
|
324
|
+
const id = queue.shift() as string;
|
|
325
|
+
const currentLayer = layer.get(id) ?? 0;
|
|
326
|
+
for (const nxt of outEdges.get(id) || []) {
|
|
327
|
+
const nextLayer = Math.max(currentLayer + 1, layer.get(nxt) ?? 0);
|
|
328
|
+
layer.set(nxt, nextLayer);
|
|
329
|
+
inDegree.set(nxt, (inDegree.get(nxt) || 0) - 1);
|
|
330
|
+
if ((inDegree.get(nxt) || 0) === 0) queue.push(nxt);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Group nodes by layer
|
|
335
|
+
const layerToNodes: Record<number, string[]> = {};
|
|
336
|
+
graphData.nodes.forEach((n) => {
|
|
337
|
+
const l = layer.get(n.id) ?? 0;
|
|
338
|
+
if (!layerToNodes[l]) layerToNodes[l] = [];
|
|
339
|
+
layerToNodes[l].push(n.id);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
const horizontalGap = 260;
|
|
343
|
+
const verticalGap = 110;
|
|
344
|
+
|
|
345
|
+
const positionedNodes: Node[] = graphData.nodes.map((node) => {
|
|
346
|
+
const l = layer.get(node.id) ?? 0;
|
|
347
|
+
const siblings = layerToNodes[l];
|
|
348
|
+
const index = siblings.indexOf(node.id);
|
|
349
|
+
const x = l * horizontalGap;
|
|
350
|
+
const y = index * verticalGap;
|
|
351
|
+
return {
|
|
352
|
+
id: node.id,
|
|
353
|
+
type: node.node_type,
|
|
354
|
+
position: { x, y },
|
|
355
|
+
data: {
|
|
356
|
+
label: node.label,
|
|
357
|
+
title: node.title,
|
|
358
|
+
event_type: node.event_type,
|
|
359
|
+
type: node.node_type,
|
|
360
|
+
},
|
|
361
|
+
} as Node;
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
const positionedEdges: Edge[] = graphData.edges.map((edge, index) => ({
|
|
365
|
+
id: `edge-${index}`,
|
|
366
|
+
source: edge.source,
|
|
367
|
+
target: edge.target,
|
|
368
|
+
type: "smoothstep",
|
|
369
|
+
animated: false,
|
|
370
|
+
style: {
|
|
371
|
+
stroke: "var(--wf-edge-stroke)",
|
|
372
|
+
strokeWidth: 2,
|
|
373
|
+
},
|
|
374
|
+
markerEnd: {
|
|
375
|
+
type: MarkerType.ArrowClosed,
|
|
376
|
+
color: "var(--wf-edge-stroke)",
|
|
377
|
+
},
|
|
378
|
+
}));
|
|
379
|
+
|
|
380
|
+
return { nodes: positionedNodes, edges: positionedEdges };
|
|
381
|
+
},
|
|
382
|
+
[],
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
const fetchWorkflowVisualization = useCallback(async () => {
|
|
386
|
+
if (!workflowName) {
|
|
387
|
+
setNodes([]);
|
|
388
|
+
setEdges([]);
|
|
389
|
+
setError(null);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
setLoading(true);
|
|
394
|
+
setError(null);
|
|
395
|
+
|
|
396
|
+
try {
|
|
397
|
+
const { data, error } = await getWorkflowsByNameRepresentation({
|
|
398
|
+
client: workflowsClient,
|
|
399
|
+
path: { name: workflowName },
|
|
400
|
+
});
|
|
401
|
+
if (error) {
|
|
402
|
+
throw new Error(String(error));
|
|
403
|
+
}
|
|
404
|
+
const graphData = (data as { graph?: WorkflowGraphData })?.graph as
|
|
405
|
+
| WorkflowGraphData
|
|
406
|
+
| undefined;
|
|
407
|
+
if (!graphData) {
|
|
408
|
+
throw new Error("No graph data received");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const laidOut = layoutGraph(graphData);
|
|
412
|
+
setNodes(laidOut.nodes);
|
|
413
|
+
setEdges(laidOut.edges);
|
|
414
|
+
} catch (err) {
|
|
415
|
+
console.error("Error fetching workflow visualization:", err);
|
|
416
|
+
setError(
|
|
417
|
+
err instanceof Error
|
|
418
|
+
? err.message
|
|
419
|
+
: "Failed to load workflow visualization",
|
|
420
|
+
);
|
|
421
|
+
setNodes([]);
|
|
422
|
+
setEdges([]);
|
|
423
|
+
} finally {
|
|
424
|
+
setLoading(false);
|
|
425
|
+
}
|
|
426
|
+
}, [workflowName, workflowsClient, layoutGraph, setNodes, setEdges]);
|
|
427
|
+
|
|
428
|
+
useEffect(() => {
|
|
429
|
+
fetchWorkflowVisualization();
|
|
430
|
+
}, [fetchWorkflowVisualization]);
|
|
431
|
+
|
|
432
|
+
// React to streaming events to highlight nodes and update last input/output
|
|
433
|
+
useEffect(() => {
|
|
434
|
+
if (!events || events.length === 0) {
|
|
435
|
+
setLastProcessedEventIndex(-1);
|
|
436
|
+
// Clear all highlights when events are cleared (new run)
|
|
437
|
+
setNodes((prev) =>
|
|
438
|
+
prev.map((n) => {
|
|
439
|
+
const newData: Record<string, unknown> = { ...n.data };
|
|
440
|
+
delete newData.highlightColor;
|
|
441
|
+
delete newData.activeWorkers;
|
|
442
|
+
delete newData.workerCount;
|
|
443
|
+
delete newData.fadeTimestamp;
|
|
444
|
+
return { ...n, data: newData } as Node;
|
|
445
|
+
}),
|
|
446
|
+
);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Process only new events since last time (DO THIS BEFORE isComplete check!)
|
|
451
|
+
const newEvents = events.slice(lastProcessedEventIndex + 1);
|
|
452
|
+
|
|
453
|
+
// If no new events but workflow is complete, still trigger fade-out
|
|
454
|
+
if (newEvents.length === 0) {
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
setLastProcessedEventIndex(events.length - 1);
|
|
459
|
+
|
|
460
|
+
const getSimpleName = (raw: unknown): string => {
|
|
461
|
+
if (!raw) return "";
|
|
462
|
+
try {
|
|
463
|
+
const str = typeof raw === "string" ? raw : String(raw);
|
|
464
|
+
const cleaned = str
|
|
465
|
+
.replace("<class", "")
|
|
466
|
+
.replace(">", "")
|
|
467
|
+
.replaceAll("'", "");
|
|
468
|
+
const parts = cleaned.split(".");
|
|
469
|
+
const lastPart = parts.at(-1) || "";
|
|
470
|
+
return lastPart.trim();
|
|
471
|
+
} catch {
|
|
472
|
+
return "";
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
const stateColor = (stepState?: string): string | null => {
|
|
477
|
+
switch (stepState) {
|
|
478
|
+
case "preparing":
|
|
479
|
+
return "#3B82F6"; // blue
|
|
480
|
+
case "in_progress":
|
|
481
|
+
return "#F59E0B"; // amber
|
|
482
|
+
case "running":
|
|
483
|
+
return "#10B981"; // emerald
|
|
484
|
+
case "not_running":
|
|
485
|
+
case "not_in_progress":
|
|
486
|
+
case "exited":
|
|
487
|
+
return null;
|
|
488
|
+
default:
|
|
489
|
+
return "#10B981"; // default active
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
// Process each new event
|
|
494
|
+
for (const event of newEvents) {
|
|
495
|
+
const type = event?.type ?? "";
|
|
496
|
+
const payload =
|
|
497
|
+
(event?.data as Record<string, unknown> | undefined) ?? {};
|
|
498
|
+
|
|
499
|
+
// Handle StepStateChanged events
|
|
500
|
+
// Note: event.type is the short name like "StepStateChanged", not the qualified name
|
|
501
|
+
if (
|
|
502
|
+
type === "StepStateChanged" ||
|
|
503
|
+
type === "workflows.events.StepStateChanged"
|
|
504
|
+
) {
|
|
505
|
+
const stepName = String((payload as { name?: unknown })?.name || "");
|
|
506
|
+
const stepState = String(
|
|
507
|
+
(payload as { step_state?: unknown })?.step_state || "",
|
|
508
|
+
);
|
|
509
|
+
const workerId = String(
|
|
510
|
+
(payload as { worker_id?: unknown })?.worker_id || "",
|
|
511
|
+
);
|
|
512
|
+
const color = stateColor(stepState);
|
|
513
|
+
|
|
514
|
+
setNodes((prev) =>
|
|
515
|
+
prev.map((n) => {
|
|
516
|
+
const newData: Record<string, unknown> = { ...n.data };
|
|
517
|
+
|
|
518
|
+
if (n.id === stepName) {
|
|
519
|
+
// Track active workers for this step
|
|
520
|
+
const activeWorkers = new Set<string>(
|
|
521
|
+
(newData.activeWorkers as string[]) || [],
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
// Update worker set based on state
|
|
525
|
+
if (
|
|
526
|
+
stepState === "preparing" ||
|
|
527
|
+
stepState === "in_progress" ||
|
|
528
|
+
stepState === "running"
|
|
529
|
+
) {
|
|
530
|
+
activeWorkers.add(workerId);
|
|
531
|
+
} else if (
|
|
532
|
+
stepState === "not_running" ||
|
|
533
|
+
stepState === "not_in_progress" ||
|
|
534
|
+
stepState === "exited"
|
|
535
|
+
) {
|
|
536
|
+
activeWorkers.delete(workerId);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
newData.activeWorkers = Array.from(activeWorkers);
|
|
540
|
+
newData.workerCount = activeWorkers.size;
|
|
541
|
+
|
|
542
|
+
// Determine highlight color based on active workers
|
|
543
|
+
if (activeWorkers.size > 0) {
|
|
544
|
+
// Use the color for the current state, or default to running color
|
|
545
|
+
newData.highlightColor = color || "#10B981";
|
|
546
|
+
newData.fadeTimestamp = Date.now();
|
|
547
|
+
} else if (
|
|
548
|
+
stepState === "exited" ||
|
|
549
|
+
stepState === "not_running"
|
|
550
|
+
) {
|
|
551
|
+
// When step exits/completes, keep the last highlight color and trigger fade-out
|
|
552
|
+
// Only fade if there was a highlight to begin with
|
|
553
|
+
if (newData.highlightColor) {
|
|
554
|
+
const fadeTimestamp = Date.now();
|
|
555
|
+
newData.fadeTimestamp = fadeTimestamp;
|
|
556
|
+
|
|
557
|
+
// Keep the highlight visible for 1s, then fade out
|
|
558
|
+
setTimeout(() => {
|
|
559
|
+
setNodes((prev) =>
|
|
560
|
+
prev.map((n) => {
|
|
561
|
+
if (
|
|
562
|
+
n.id === stepName &&
|
|
563
|
+
n.data.fadeTimestamp === fadeTimestamp
|
|
564
|
+
) {
|
|
565
|
+
const newData: Record<string, unknown> = {
|
|
566
|
+
...n.data,
|
|
567
|
+
};
|
|
568
|
+
newData.highlightColor = null;
|
|
569
|
+
|
|
570
|
+
return { ...n, data: newData } as Node;
|
|
571
|
+
}
|
|
572
|
+
return n;
|
|
573
|
+
}),
|
|
574
|
+
);
|
|
575
|
+
}, 1000);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
newData.lastInputEvent = getSimpleName(
|
|
580
|
+
(payload as { input_event_name?: unknown })?.input_event_name,
|
|
581
|
+
);
|
|
582
|
+
newData.lastOutputEvent = getSimpleName(
|
|
583
|
+
(payload as { output_event_name?: unknown })?.output_event_name,
|
|
584
|
+
);
|
|
585
|
+
newData.status = stepState;
|
|
586
|
+
return { ...n, data: newData } as Node;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Keep other nodes unchanged (don't clear their highlights)
|
|
590
|
+
return n;
|
|
591
|
+
}),
|
|
592
|
+
);
|
|
593
|
+
} else if (
|
|
594
|
+
type &&
|
|
595
|
+
!type.includes("workflows.events.EventsQueueChanged")
|
|
596
|
+
) {
|
|
597
|
+
// Handle user-defined events (like ProgressEvent)
|
|
598
|
+
const eventName = getSimpleName(
|
|
599
|
+
type.replace("__main__.", "").replace("workflows.events.", ""),
|
|
600
|
+
);
|
|
601
|
+
|
|
602
|
+
setNodes((prev) =>
|
|
603
|
+
prev.map((n) => {
|
|
604
|
+
if (n.id === eventName && n.type === "event") {
|
|
605
|
+
const newData: Record<string, unknown> = { ...n.data };
|
|
606
|
+
newData.highlightColor = "#10B981";
|
|
607
|
+
newData.fadeTimestamp = Date.now();
|
|
608
|
+
return { ...n, data: newData } as Node;
|
|
609
|
+
}
|
|
610
|
+
return n;
|
|
611
|
+
}),
|
|
612
|
+
);
|
|
613
|
+
|
|
614
|
+
// Clear the event highlight after 1 second
|
|
615
|
+
// Don't add to cleanup array - let it run independently
|
|
616
|
+
setTimeout(() => {
|
|
617
|
+
setNodes((prev) =>
|
|
618
|
+
prev.map((n) => {
|
|
619
|
+
if (n.id === eventName && n.type === "event") {
|
|
620
|
+
const newData: Record<string, unknown> = { ...n.data };
|
|
621
|
+
newData.highlightColor = null;
|
|
622
|
+
newData.fadeTimestamp = null;
|
|
623
|
+
return { ...n, data: newData } as Node;
|
|
624
|
+
}
|
|
625
|
+
return n;
|
|
626
|
+
}),
|
|
627
|
+
);
|
|
628
|
+
}, 1000);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}, [events, lastProcessedEventIndex, setNodes, isComplete]);
|
|
632
|
+
|
|
633
|
+
if (loading) {
|
|
634
|
+
return (
|
|
635
|
+
<div
|
|
636
|
+
className={`${className} flex items-center justify-center bg-card border border-border rounded-lg`}
|
|
637
|
+
>
|
|
638
|
+
<div className="text-center">
|
|
639
|
+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-2"></div>
|
|
640
|
+
<p className="text-muted-foreground">
|
|
641
|
+
Loading workflow visualization...
|
|
642
|
+
</p>
|
|
643
|
+
</div>
|
|
644
|
+
</div>
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
if (error) {
|
|
649
|
+
return (
|
|
650
|
+
<div
|
|
651
|
+
className={`${className} flex items-center justify-center bg-destructive/10 border border-destructive/30 rounded-lg`}
|
|
652
|
+
>
|
|
653
|
+
<div className="text-center p-4">
|
|
654
|
+
<p className="text-destructive font-medium">
|
|
655
|
+
Error loading visualization
|
|
656
|
+
</p>
|
|
657
|
+
<p className="text-sm mt-1 text-destructive">{error}</p>
|
|
658
|
+
<button
|
|
659
|
+
onClick={fetchWorkflowVisualization}
|
|
660
|
+
className="mt-3 px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm hover:opacity-90 transition-colors"
|
|
661
|
+
>
|
|
662
|
+
Retry
|
|
663
|
+
</button>
|
|
664
|
+
</div>
|
|
665
|
+
</div>
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (!workflowName) {
|
|
670
|
+
return (
|
|
671
|
+
<div
|
|
672
|
+
className={`${className} flex items-center justify-center bg-card border border-border rounded-lg`}
|
|
673
|
+
>
|
|
674
|
+
<p className="text-muted-foreground">
|
|
675
|
+
Select a workflow to view its visualization
|
|
676
|
+
</p>
|
|
677
|
+
</div>
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
if (nodes.length === 0) {
|
|
682
|
+
return (
|
|
683
|
+
<div
|
|
684
|
+
className={`${className} flex items-center justify-center bg-card border border-border rounded-lg`}
|
|
685
|
+
>
|
|
686
|
+
<p className="text-muted-foreground">No workflow data available</p>
|
|
687
|
+
</div>
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
return (
|
|
692
|
+
<div
|
|
693
|
+
className={`${className} border border-border rounded-lg overflow-hidden`}
|
|
694
|
+
>
|
|
695
|
+
<ReactFlow
|
|
696
|
+
nodes={nodes}
|
|
697
|
+
edges={edges}
|
|
698
|
+
onNodesChange={onNodesChange}
|
|
699
|
+
onEdgesChange={onEdgesChange}
|
|
700
|
+
onConnect={onConnect}
|
|
701
|
+
nodeTypes={nodeTypes}
|
|
702
|
+
defaultEdgeOptions={{
|
|
703
|
+
markerEnd: {
|
|
704
|
+
type: MarkerType.ArrowClosed,
|
|
705
|
+
color: "var(--wf-edge-stroke)",
|
|
706
|
+
},
|
|
707
|
+
}}
|
|
708
|
+
fitView
|
|
709
|
+
attributionPosition="bottom-left"
|
|
710
|
+
>
|
|
711
|
+
<Background
|
|
712
|
+
variant={BackgroundVariant.Dots}
|
|
713
|
+
gap={12}
|
|
714
|
+
size={1}
|
|
715
|
+
color={isDark ? "#374151" : "#e5e7eb"}
|
|
716
|
+
/>
|
|
717
|
+
</ReactFlow>
|
|
718
|
+
</div>
|
|
719
|
+
);
|
|
720
|
+
}
|