@gadmin2n/schematics 0.0.77 → 0.0.79
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/dist/lib/application/files/gadmin2-game-angle-demo/server/package.json +3 -3
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/app.module.ts +2 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/main.ts +6 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/health/health.controller.ts +17 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/health/health.module.ts +7 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.ts +22 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/package.json +1 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/EnhancedFlowRenderer.tsx +388 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/ExecutionStatusNode.tsx +223 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/FlowRenderer.tsx +1 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/instance-detail.tsx +395 -55
- package/package.json +1 -1
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@agendajs/postgres-backend": "^3.0.5",
|
|
38
38
|
"@azure/identity": "^4.13.0",
|
|
39
|
-
"@gadmin2n/nest-common": "^0.0.
|
|
39
|
+
"@gadmin2n/nest-common": "^0.0.49",
|
|
40
40
|
"@nestjs/cache-manager": "^3.0.1",
|
|
41
41
|
"@nestjs/common": "^10.4.15",
|
|
42
42
|
"@nestjs/config": "^3.2.0",
|
|
@@ -88,8 +88,8 @@
|
|
|
88
88
|
},
|
|
89
89
|
"devDependencies": {
|
|
90
90
|
"@faker-js/faker": "^10.4.0",
|
|
91
|
-
"@gadmin2n/prisma-nest-generator": "^0.0.
|
|
92
|
-
"@gadmin2n/prisma-react-generator": "^0.0.
|
|
91
|
+
"@gadmin2n/prisma-nest-generator": "^0.0.42",
|
|
92
|
+
"@gadmin2n/prisma-react-generator": "^0.0.58",
|
|
93
93
|
"@nestjs/testing": "^10.4.15",
|
|
94
94
|
"@types/cookie-parser": "^1.4.3",
|
|
95
95
|
"@types/express": "^4.17.21",
|
|
@@ -19,6 +19,7 @@ import { ServeStaticModule } from '@nestjs/serve-static';
|
|
|
19
19
|
import { join } from 'path';
|
|
20
20
|
import { LogFormat } from './lib/logger';
|
|
21
21
|
import { AgendaModule } from './modules/agenda/agenda.module';
|
|
22
|
+
import { HealthModule } from './modules/health/health.module';
|
|
22
23
|
import { RoleModule } from './modules/role/role.module';
|
|
23
24
|
import { RoleService } from './modules/role/role.service';
|
|
24
25
|
import { RolesRefresherService } from './modules/role/roles-refresher.service';
|
|
@@ -85,6 +86,7 @@ import { RolesRefresherService } from './modules/role/roles-refresher.service';
|
|
|
85
86
|
|
|
86
87
|
...modules,
|
|
87
88
|
AgendaModule,
|
|
89
|
+
HealthModule,
|
|
88
90
|
],
|
|
89
91
|
|
|
90
92
|
controllers: [AppController],
|
|
@@ -122,6 +122,12 @@ async function bootstrap() {
|
|
|
122
122
|
});
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
// 启用 NestJS 生命周期钩子,确保 SIGTERM 时触发 onApplicationShutdown / onModuleDestroy
|
|
126
|
+
app.enableShutdownHooks();
|
|
127
|
+
|
|
128
|
+
// 启用 NestJS 生命周期钩子,确保 SIGTERM 时触发 onApplicationShutdown / onModuleDestroy
|
|
129
|
+
app.enableShutdownHooks();
|
|
130
|
+
|
|
125
131
|
await app.listen(configService.get('nest').port);
|
|
126
132
|
|
|
127
133
|
console.log(
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Controller, Get } from '@nestjs/common';
|
|
2
|
+
import { AllowUnauthorizedRequest } from '../../lib/auth.guard';
|
|
3
|
+
|
|
4
|
+
@Controller('health')
|
|
5
|
+
export class HealthController {
|
|
6
|
+
@Get('live')
|
|
7
|
+
@AllowUnauthorizedRequest()
|
|
8
|
+
liveness() {
|
|
9
|
+
return { status: 'ok' };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
@Get('ready')
|
|
13
|
+
@AllowUnauthorizedRequest()
|
|
14
|
+
readiness() {
|
|
15
|
+
return { status: 'ok' };
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -422,7 +422,7 @@ export class WorkflowService {
|
|
|
422
422
|
const instance = await this.prisma.workflowInstance.findUnique({
|
|
423
423
|
where: { id: instanceId },
|
|
424
424
|
include: {
|
|
425
|
-
workflow: { select: { name: true } },
|
|
425
|
+
workflow: { select: { name: true, dsl: true } },
|
|
426
426
|
nodeExecutions: {
|
|
427
427
|
orderBy: { createdAt: 'asc' },
|
|
428
428
|
},
|
|
@@ -430,7 +430,27 @@ export class WorkflowService {
|
|
|
430
430
|
});
|
|
431
431
|
|
|
432
432
|
if (!instance) throw new NotFoundException('Instance not found');
|
|
433
|
-
|
|
433
|
+
|
|
434
|
+
// Fetch the workflow DSL from the version
|
|
435
|
+
let dsl: any = null;
|
|
436
|
+
if (instance.versionId) {
|
|
437
|
+
const version = await this.prisma.workflowVersion.findUnique({
|
|
438
|
+
where: { id: instance.versionId },
|
|
439
|
+
select: { dsl: true },
|
|
440
|
+
});
|
|
441
|
+
dsl = version?.dsl || null;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Fallback: use DSL from the workflow itself
|
|
445
|
+
if (!dsl && instance.workflow.dsl) {
|
|
446
|
+
dsl = instance.workflow.dsl;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return {
|
|
450
|
+
...instance,
|
|
451
|
+
workflow: { name: instance.workflow.name },
|
|
452
|
+
dsl,
|
|
453
|
+
};
|
|
434
454
|
}
|
|
435
455
|
|
|
436
456
|
async cancelInstance(instanceId: bigint, temporalService: any) {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"@dnd-kit/sortable": "^7.0.2",
|
|
12
12
|
"@dnd-kit/utilities": "^3.2.2",
|
|
13
13
|
"@gadmin2n/charts": "^0.0.7",
|
|
14
|
-
"@gadmin2n/react-common": "^0.0.
|
|
14
|
+
"@gadmin2n/react-common": "^0.0.68",
|
|
15
15
|
"@monaco-editor/react": "^4.7.0",
|
|
16
16
|
"@refinedev/antd": "^5.47.0",
|
|
17
17
|
"@refinedev/cli": "^2.16.51",
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import React, { useCallback, useLayoutEffect, useMemo, useRef } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
ReactFlow,
|
|
4
|
+
ReactFlowProvider,
|
|
5
|
+
Background,
|
|
6
|
+
Controls,
|
|
7
|
+
MiniMap,
|
|
8
|
+
ConnectionMode,
|
|
9
|
+
useReactFlow,
|
|
10
|
+
type Node,
|
|
11
|
+
type Edge,
|
|
12
|
+
type Connection,
|
|
13
|
+
type NodeChange,
|
|
14
|
+
type EdgeChange,
|
|
15
|
+
applyNodeChanges,
|
|
16
|
+
} from '@xyflow/react';
|
|
17
|
+
import '@xyflow/react/dist/style.css';
|
|
18
|
+
import { CustomNode } from './CustomNode';
|
|
19
|
+
import { ExecutionStatusNode } from './ExecutionStatusNode';
|
|
20
|
+
import type { WorkflowDSL, WorkflowNodeType } from '../types';
|
|
21
|
+
|
|
22
|
+
interface NodeExecution {
|
|
23
|
+
id: string;
|
|
24
|
+
nodeId: string;
|
|
25
|
+
nodeType: string;
|
|
26
|
+
nodeLabel: string | null;
|
|
27
|
+
status: string;
|
|
28
|
+
input: any;
|
|
29
|
+
output: any;
|
|
30
|
+
error: any;
|
|
31
|
+
startedAt: string | null;
|
|
32
|
+
finishedAt: string | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const nodeTypes = { custom: CustomNode, executionStatus: ExecutionStatusNode };
|
|
36
|
+
|
|
37
|
+
interface EnhancedFlowRendererProps {
|
|
38
|
+
dsl: WorkflowDSL | null;
|
|
39
|
+
nodeTypeMap?: Record<string, { category: string; icon: string }>;
|
|
40
|
+
selectedNodeId?: string | null;
|
|
41
|
+
readonly?: boolean;
|
|
42
|
+
onNodeClick?: (nodeId: string) => void;
|
|
43
|
+
onDslChange?: (dsl: WorkflowDSL) => void;
|
|
44
|
+
// New props for execution status
|
|
45
|
+
nodeExecutions?: NodeExecution[];
|
|
46
|
+
showExecutionStatus?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function formatDuration(
|
|
50
|
+
startedAt: string | null,
|
|
51
|
+
finishedAt: string | null,
|
|
52
|
+
): string {
|
|
53
|
+
if (!startedAt) return '';
|
|
54
|
+
const endTime = finishedAt ? new Date(finishedAt).getTime() : Date.now();
|
|
55
|
+
const ms = endTime - new Date(startedAt).getTime();
|
|
56
|
+
if (ms < 1000) return `${ms}ms`;
|
|
57
|
+
const s = ms / 1000;
|
|
58
|
+
if (s < 60) return `${s.toFixed(1)}s`;
|
|
59
|
+
const m = Math.floor(s / 60);
|
|
60
|
+
const rem = s % 60;
|
|
61
|
+
return rem > 0 ? `${m}m ${rem.toFixed(0)}s` : `${m}m`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function EnhancedFlowCanvas({
|
|
65
|
+
dsl,
|
|
66
|
+
nodeTypeMap,
|
|
67
|
+
selectedNodeId,
|
|
68
|
+
readonly,
|
|
69
|
+
onNodeClick,
|
|
70
|
+
onDslChange,
|
|
71
|
+
nodeExecutions,
|
|
72
|
+
showExecutionStatus,
|
|
73
|
+
}: EnhancedFlowRendererProps) {
|
|
74
|
+
const reactFlowInstance = useReactFlow();
|
|
75
|
+
const dslRef = useRef(dsl);
|
|
76
|
+
dslRef.current = dsl;
|
|
77
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
78
|
+
const hasSetViewport = useRef(false);
|
|
79
|
+
|
|
80
|
+
// Build execution map: nodeId -> execution record
|
|
81
|
+
const executionMap = useMemo(() => {
|
|
82
|
+
if (!nodeExecutions) return {};
|
|
83
|
+
const map: Record<string, NodeExecution> = {};
|
|
84
|
+
nodeExecutions.forEach((exec) => {
|
|
85
|
+
map[exec.nodeId] = exec;
|
|
86
|
+
});
|
|
87
|
+
return map;
|
|
88
|
+
}, [nodeExecutions]);
|
|
89
|
+
|
|
90
|
+
const { nodes, edges } = useMemo(() => {
|
|
91
|
+
if (!dsl) return { nodes: [] as Node[], edges: [] as Edge[] };
|
|
92
|
+
|
|
93
|
+
const rfNodes: Node[] = dsl.nodes.map((n) => {
|
|
94
|
+
const category = nodeTypeMap?.[n.type]?.category || 'ACTION';
|
|
95
|
+
// Transpose x/y for vertical (top-to-bottom) layout
|
|
96
|
+
// Scale y down to compress vertical spacing (original x-step=250 → 120)
|
|
97
|
+
// Add y offset so top node isn't clipped
|
|
98
|
+
const position = { x: n.position.y * 1.5, y: n.position.x * 0.5 + 20 };
|
|
99
|
+
|
|
100
|
+
// Get execution status for this node
|
|
101
|
+
const execution = executionMap[n.id];
|
|
102
|
+
const nodeType = showExecutionStatus ? 'executionStatus' : 'custom';
|
|
103
|
+
|
|
104
|
+
const nodeData: any = {
|
|
105
|
+
label: n.label,
|
|
106
|
+
category,
|
|
107
|
+
isSelected: n.id === selectedNodeId,
|
|
108
|
+
readonly: !!readonly,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// Add execution status data — show NOT_EXECUTED for nodes without records
|
|
112
|
+
if (showExecutionStatus) {
|
|
113
|
+
if (execution) {
|
|
114
|
+
nodeData.executionStatus = execution.status;
|
|
115
|
+
nodeData.duration = formatDuration(
|
|
116
|
+
execution.startedAt,
|
|
117
|
+
execution.finishedAt,
|
|
118
|
+
);
|
|
119
|
+
nodeData.error = execution.error;
|
|
120
|
+
} else {
|
|
121
|
+
nodeData.executionStatus = 'NOT_EXECUTED';
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
id: n.id,
|
|
127
|
+
type: nodeType,
|
|
128
|
+
position,
|
|
129
|
+
measured: { width: 140, height: 40 },
|
|
130
|
+
selected: n.id === selectedNodeId,
|
|
131
|
+
data: nodeData,
|
|
132
|
+
};
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const rfEdges: Edge[] = dsl.edges.map((e) => ({
|
|
136
|
+
id: e.id,
|
|
137
|
+
source: e.source,
|
|
138
|
+
target: e.target,
|
|
139
|
+
sourceHandle: e.sourceHandle,
|
|
140
|
+
label: e.label,
|
|
141
|
+
type: 'smoothstep',
|
|
142
|
+
animated: showExecutionStatus ? false : true, // Disable animation when showing execution
|
|
143
|
+
}));
|
|
144
|
+
|
|
145
|
+
return { nodes: rfNodes, edges: rfEdges };
|
|
146
|
+
}, [dsl, nodeTypeMap, selectedNodeId, executionMap, showExecutionStatus]);
|
|
147
|
+
|
|
148
|
+
// Set viewport once before first paint: horizontal center + vertical top padding
|
|
149
|
+
useLayoutEffect(() => {
|
|
150
|
+
if (!hasSetViewport.current && containerRef.current && nodes.length > 0) {
|
|
151
|
+
const containerWidth = containerRef.current.clientWidth;
|
|
152
|
+
let minX = Infinity,
|
|
153
|
+
maxX = -Infinity;
|
|
154
|
+
nodes.forEach((n) => {
|
|
155
|
+
const w = n.measured?.width || 140;
|
|
156
|
+
minX = Math.min(minX, n.position.x);
|
|
157
|
+
maxX = Math.max(maxX, n.position.x + w);
|
|
158
|
+
});
|
|
159
|
+
const contentWidth = maxX - minX;
|
|
160
|
+
const x = (containerWidth - contentWidth) / 2 - minX;
|
|
161
|
+
reactFlowInstance.setViewport({ x, y: 0, zoom: 1 });
|
|
162
|
+
hasSetViewport.current = true;
|
|
163
|
+
}
|
|
164
|
+
}, [nodes, reactFlowInstance]);
|
|
165
|
+
|
|
166
|
+
const onNodesChange = useCallback(
|
|
167
|
+
(changes: NodeChange[]) => {
|
|
168
|
+
const currentDsl = dslRef.current;
|
|
169
|
+
if (!currentDsl || !onDslChange) return;
|
|
170
|
+
|
|
171
|
+
// Apply changes to get new node positions
|
|
172
|
+
const updatedRfNodes = applyNodeChanges(changes, nodes);
|
|
173
|
+
|
|
174
|
+
// Check if any position actually changed
|
|
175
|
+
const positionChanged = changes.some(
|
|
176
|
+
(c) => c.type === 'position' && c.position,
|
|
177
|
+
);
|
|
178
|
+
const removeChanges = changes.filter((c) => c.type === 'remove');
|
|
179
|
+
|
|
180
|
+
if (removeChanges.length > 0) {
|
|
181
|
+
const removedIds = new Set(
|
|
182
|
+
removeChanges.map((c) => (c as any).id as string),
|
|
183
|
+
);
|
|
184
|
+
const newNodes = currentDsl.nodes.filter((n) => !removedIds.has(n.id));
|
|
185
|
+
const newEdges = currentDsl.edges.filter(
|
|
186
|
+
(e) => !removedIds.has(e.source) && !removedIds.has(e.target),
|
|
187
|
+
);
|
|
188
|
+
onDslChange({ nodes: newNodes, edges: newEdges });
|
|
189
|
+
} else if (positionChanged) {
|
|
190
|
+
const newNodes = currentDsl.nodes.map((n) => {
|
|
191
|
+
const rfNode = updatedRfNodes.find((rn) => rn.id === n.id);
|
|
192
|
+
if (rfNode && rfNode.position) {
|
|
193
|
+
// Reverse transpose: display (x,y) → stored (y,x) with scale reversal
|
|
194
|
+
return {
|
|
195
|
+
...n,
|
|
196
|
+
position: {
|
|
197
|
+
x: (rfNode.position.y - 20) / 0.5,
|
|
198
|
+
y: rfNode.position.x / 1.5,
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
return n;
|
|
203
|
+
});
|
|
204
|
+
onDslChange({ ...currentDsl, nodes: newNodes });
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
[nodes, onDslChange],
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const onEdgesChange = useCallback(
|
|
211
|
+
(changes: EdgeChange[]) => {
|
|
212
|
+
const currentDsl = dslRef.current;
|
|
213
|
+
if (!currentDsl || !onDslChange) return;
|
|
214
|
+
|
|
215
|
+
const removeChanges = changes.filter((c) => c.type === 'remove');
|
|
216
|
+
if (removeChanges.length > 0) {
|
|
217
|
+
const removedIds = new Set(
|
|
218
|
+
removeChanges.map((c) => (c as any).id as string),
|
|
219
|
+
);
|
|
220
|
+
const newEdges = currentDsl.edges.filter((e) => !removedIds.has(e.id));
|
|
221
|
+
onDslChange({ ...currentDsl, edges: newEdges });
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
[onDslChange],
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const onConnect = useCallback(
|
|
228
|
+
(connection: Connection) => {
|
|
229
|
+
const currentDsl = dslRef.current;
|
|
230
|
+
if (!currentDsl || !onDslChange) return;
|
|
231
|
+
|
|
232
|
+
const newEdge = {
|
|
233
|
+
id: `e-${connection.source}-${connection.target}-${Date.now()}`,
|
|
234
|
+
source: connection.source!,
|
|
235
|
+
target: connection.target!,
|
|
236
|
+
sourceHandle: connection.sourceHandle || undefined,
|
|
237
|
+
label: undefined,
|
|
238
|
+
};
|
|
239
|
+
onDslChange({
|
|
240
|
+
...currentDsl,
|
|
241
|
+
edges: [...currentDsl.edges, newEdge],
|
|
242
|
+
});
|
|
243
|
+
},
|
|
244
|
+
[onDslChange],
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
const onDragOver = useCallback((event: React.DragEvent) => {
|
|
248
|
+
event.preventDefault();
|
|
249
|
+
event.dataTransfer.dropEffect = 'move';
|
|
250
|
+
}, []);
|
|
251
|
+
|
|
252
|
+
const onDrop = useCallback(
|
|
253
|
+
(event: React.DragEvent) => {
|
|
254
|
+
event.preventDefault();
|
|
255
|
+
const currentDsl = dslRef.current;
|
|
256
|
+
if (!onDslChange) return;
|
|
257
|
+
|
|
258
|
+
const data = event.dataTransfer.getData('application/workflow-node-type');
|
|
259
|
+
if (!data) return;
|
|
260
|
+
|
|
261
|
+
let nodeType: WorkflowNodeType;
|
|
262
|
+
try {
|
|
263
|
+
nodeType = JSON.parse(data);
|
|
264
|
+
} catch {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const flowPosition = reactFlowInstance.screenToFlowPosition({
|
|
269
|
+
x: event.clientX,
|
|
270
|
+
y: event.clientY,
|
|
271
|
+
});
|
|
272
|
+
// Reverse transpose: display (x,y) → stored (y,x) with scale reversal
|
|
273
|
+
const position = {
|
|
274
|
+
x: (flowPosition.y - 20) / 0.5,
|
|
275
|
+
y: flowPosition.x / 1.5,
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
// Initialize config with default values from configSchema
|
|
279
|
+
const initialConfig: Record<string, any> = {};
|
|
280
|
+
const schema = nodeType.configSchema as any;
|
|
281
|
+
if (schema?.properties) {
|
|
282
|
+
for (const [key, prop] of Object.entries<any>(schema.properties)) {
|
|
283
|
+
if (prop.type === 'string') initialConfig[key] = '';
|
|
284
|
+
else if (prop.type === 'number') initialConfig[key] = undefined;
|
|
285
|
+
else if (prop.type === 'boolean') initialConfig[key] = false;
|
|
286
|
+
else if (prop.type === 'array') initialConfig[key] = [];
|
|
287
|
+
else if (prop.type === 'object') initialConfig[key] = {};
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const newNode = {
|
|
292
|
+
id: crypto.randomUUID(),
|
|
293
|
+
type: nodeType.type,
|
|
294
|
+
label: nodeType.label,
|
|
295
|
+
position,
|
|
296
|
+
config: initialConfig,
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const newDsl: WorkflowDSL = currentDsl
|
|
300
|
+
? { ...currentDsl, nodes: [...currentDsl.nodes, newNode] }
|
|
301
|
+
: { nodes: [newNode], edges: [] };
|
|
302
|
+
|
|
303
|
+
onDslChange(newDsl);
|
|
304
|
+
},
|
|
305
|
+
[reactFlowInstance, onDslChange],
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
if (!dsl || dsl.nodes.length === 0) {
|
|
309
|
+
return (
|
|
310
|
+
<div
|
|
311
|
+
style={{ width: '100%', height: '100%' }}
|
|
312
|
+
onDragOver={onDragOver}
|
|
313
|
+
onDrop={onDrop}
|
|
314
|
+
>
|
|
315
|
+
<ReactFlow
|
|
316
|
+
nodes={[]}
|
|
317
|
+
edges={[]}
|
|
318
|
+
nodeTypes={nodeTypes}
|
|
319
|
+
fitView
|
|
320
|
+
onDragOver={onDragOver}
|
|
321
|
+
onDrop={onDrop}
|
|
322
|
+
>
|
|
323
|
+
<Background />
|
|
324
|
+
<Controls showInteractive={false} />
|
|
325
|
+
</ReactFlow>
|
|
326
|
+
</div>
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return (
|
|
331
|
+
<div ref={containerRef} style={{ width: '100%', height: '100%' }}>
|
|
332
|
+
<ReactFlow
|
|
333
|
+
nodes={nodes}
|
|
334
|
+
edges={edges}
|
|
335
|
+
nodeTypes={nodeTypes}
|
|
336
|
+
onNodesChange={onNodesChange}
|
|
337
|
+
onEdgesChange={onEdgesChange}
|
|
338
|
+
onConnect={onConnect}
|
|
339
|
+
onDragOver={onDragOver}
|
|
340
|
+
onDrop={onDrop}
|
|
341
|
+
nodesDraggable={!showExecutionStatus}
|
|
342
|
+
nodesConnectable={!showExecutionStatus}
|
|
343
|
+
elementsSelectable={true}
|
|
344
|
+
connectionMode={ConnectionMode.Loose}
|
|
345
|
+
deleteKeyCode={['Backspace', 'Delete']}
|
|
346
|
+
panOnScroll={true}
|
|
347
|
+
zoomOnScroll={false}
|
|
348
|
+
zoomOnPinch={false}
|
|
349
|
+
zoomOnDoubleClick={false}
|
|
350
|
+
onNodeClick={(_event, node) => {
|
|
351
|
+
onNodeClick?.(node.id);
|
|
352
|
+
}}
|
|
353
|
+
onPaneClick={() => {
|
|
354
|
+
onNodeClick?.('');
|
|
355
|
+
}}
|
|
356
|
+
>
|
|
357
|
+
<Background />
|
|
358
|
+
<Controls showInteractive={false} />
|
|
359
|
+
<MiniMap
|
|
360
|
+
nodeColor={(node) => {
|
|
361
|
+
const category = (node.data as any)?.category || 'ACTION';
|
|
362
|
+
const colors: Record<string, string> = {
|
|
363
|
+
TRIGGER: '#91caff',
|
|
364
|
+
ACTION: '#b7eb8f',
|
|
365
|
+
CONDITION: '#ffd591',
|
|
366
|
+
LOOP: '#d3adf7',
|
|
367
|
+
APPROVAL: '#ffa39e',
|
|
368
|
+
SUB_WORKFLOW: '#adc6ff',
|
|
369
|
+
};
|
|
370
|
+
return colors[category] || '#b7eb8f';
|
|
371
|
+
}}
|
|
372
|
+
nodeStrokeWidth={3}
|
|
373
|
+
maskColor="rgba(240, 240, 240, 0.6)"
|
|
374
|
+
zoomable
|
|
375
|
+
pannable
|
|
376
|
+
/>
|
|
377
|
+
</ReactFlow>
|
|
378
|
+
</div>
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function EnhancedFlowRenderer(props: EnhancedFlowRendererProps) {
|
|
383
|
+
return (
|
|
384
|
+
<ReactFlowProvider>
|
|
385
|
+
<EnhancedFlowCanvas {...props} />
|
|
386
|
+
</ReactFlowProvider>
|
|
387
|
+
);
|
|
388
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import React, { memo } from 'react';
|
|
2
|
+
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
|
3
|
+
import {
|
|
4
|
+
CheckCircleOutlined,
|
|
5
|
+
ClockCircleOutlined,
|
|
6
|
+
CloseCircleOutlined,
|
|
7
|
+
LoadingOutlined,
|
|
8
|
+
} from '@ant-design/icons';
|
|
9
|
+
|
|
10
|
+
const CATEGORY_COLORS: Record<string, { bg: string; border: string }> = {
|
|
11
|
+
TRIGGER: { bg: '#e6f7ff', border: '#91caff' },
|
|
12
|
+
ACTION: { bg: '#f6ffed', border: '#b7eb8f' },
|
|
13
|
+
CONDITION: { bg: '#fff7e6', border: '#ffd591' },
|
|
14
|
+
LOOP: { bg: '#f9f0ff', border: '#d3adf7' },
|
|
15
|
+
APPROVAL: { bg: '#fff1f0', border: '#ffa39e' },
|
|
16
|
+
SUB_WORKFLOW: { bg: '#f0f5ff', border: '#adc6ff' },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Status-specific background colors (overlay on top of category colors)
|
|
20
|
+
const STATUS_COLORS: Record<string, string> = {
|
|
21
|
+
COMPLETED: '#f6ffed', // light green
|
|
22
|
+
RUNNING: '#e6f7ff', // light blue
|
|
23
|
+
FAILED: '#fff1f0', // light red
|
|
24
|
+
PENDING: '#fffbe6', // light yellow
|
|
25
|
+
NOT_EXECUTED: '#fafafa', // gray
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export interface ExecutionStatusNodeData {
|
|
29
|
+
label: string;
|
|
30
|
+
category: string;
|
|
31
|
+
isSelected: boolean;
|
|
32
|
+
readonly?: boolean;
|
|
33
|
+
executionStatus?: 'PENDING' | 'RUNNING' | 'COMPLETED' | 'FAILED' | 'NOT_EXECUTED';
|
|
34
|
+
duration?: string; // e.g., "2.5s"
|
|
35
|
+
error?: string;
|
|
36
|
+
[key: string]: unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function formatDuration(
|
|
40
|
+
startedAt: string | null,
|
|
41
|
+
finishedAt: string | null,
|
|
42
|
+
): string {
|
|
43
|
+
if (!startedAt) return '';
|
|
44
|
+
const endTime = finishedAt ? new Date(finishedAt).getTime() : Date.now();
|
|
45
|
+
const ms = endTime - new Date(startedAt).getTime();
|
|
46
|
+
if (ms < 1000) return `${ms}ms`;
|
|
47
|
+
const s = ms / 1000;
|
|
48
|
+
if (s < 60) return `${s.toFixed(1)}s`;
|
|
49
|
+
const m = Math.floor(s / 60);
|
|
50
|
+
const rem = s % 60;
|
|
51
|
+
return rem > 0 ? `${m}m ${rem.toFixed(0)}s` : `${m}m`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getStatusIcon(status?: string) {
|
|
55
|
+
switch (status) {
|
|
56
|
+
case 'COMPLETED':
|
|
57
|
+
return <CheckCircleOutlined style={{ color: '#52c41a', fontSize: 16 }} />;
|
|
58
|
+
case 'RUNNING':
|
|
59
|
+
return <LoadingOutlined style={{ color: '#1677ff', fontSize: 16 }} />;
|
|
60
|
+
case 'FAILED':
|
|
61
|
+
return <CloseCircleOutlined style={{ color: '#ff4d4f', fontSize: 16 }} />;
|
|
62
|
+
case 'NOT_EXECUTED':
|
|
63
|
+
return <ClockCircleOutlined style={{ color: '#bfbfbf', fontSize: 16 }} />;
|
|
64
|
+
case 'PENDING':
|
|
65
|
+
default:
|
|
66
|
+
return <ClockCircleOutlined style={{ color: '#faad14', fontSize: 16 }} />;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const ExecutionStatusNode = memo(({ data }: NodeProps) => {
|
|
71
|
+
const nodeData = data as ExecutionStatusNodeData;
|
|
72
|
+
const colors = CATEGORY_COLORS[nodeData.category] || CATEGORY_COLORS.ACTION;
|
|
73
|
+
const isNotExecuted = nodeData.executionStatus === 'NOT_EXECUTED';
|
|
74
|
+
|
|
75
|
+
// If there's execution status, use status color as background; otherwise use category color
|
|
76
|
+
const bgColor = nodeData.executionStatus
|
|
77
|
+
? STATUS_COLORS[nodeData.executionStatus]
|
|
78
|
+
: colors.bg;
|
|
79
|
+
|
|
80
|
+
const borderColor = nodeData.isSelected
|
|
81
|
+
? '#1677ff'
|
|
82
|
+
: isNotExecuted
|
|
83
|
+
? '#d9d9d9'
|
|
84
|
+
: colors.border;
|
|
85
|
+
const borderStyle = nodeData.isSelected
|
|
86
|
+
? '2px solid'
|
|
87
|
+
: isNotExecuted
|
|
88
|
+
? '1.5px dashed'
|
|
89
|
+
: '1px solid';
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<div
|
|
93
|
+
style={{
|
|
94
|
+
background: bgColor,
|
|
95
|
+
border: `${borderStyle} ${borderColor}`,
|
|
96
|
+
borderRadius: 8,
|
|
97
|
+
padding: '8px 12px',
|
|
98
|
+
fontSize: 13,
|
|
99
|
+
minWidth: 140,
|
|
100
|
+
textAlign: 'center',
|
|
101
|
+
opacity: isNotExecuted ? 0.6 : 1,
|
|
102
|
+
boxShadow: nodeData.isSelected
|
|
103
|
+
? '0 0 0 2px rgba(22,119,255,0.2)'
|
|
104
|
+
: !isNotExecuted && nodeData.executionStatus
|
|
105
|
+
? '0 2px 8px rgba(0,0,0,0.15)'
|
|
106
|
+
: undefined,
|
|
107
|
+
position: 'relative',
|
|
108
|
+
transition: 'all 0.2s ease',
|
|
109
|
+
}}
|
|
110
|
+
>
|
|
111
|
+
{!nodeData.readonly && (
|
|
112
|
+
<Handle
|
|
113
|
+
type="target"
|
|
114
|
+
position={Position.Top}
|
|
115
|
+
style={{
|
|
116
|
+
width: 14,
|
|
117
|
+
height: 14,
|
|
118
|
+
background: '#fff',
|
|
119
|
+
border: '2px solid #555',
|
|
120
|
+
cursor: 'crosshair',
|
|
121
|
+
}}
|
|
122
|
+
/>
|
|
123
|
+
)}
|
|
124
|
+
{nodeData.readonly && (
|
|
125
|
+
<Handle
|
|
126
|
+
type="target"
|
|
127
|
+
position={Position.Top}
|
|
128
|
+
style={{
|
|
129
|
+
width: 0,
|
|
130
|
+
height: 0,
|
|
131
|
+
border: 'none',
|
|
132
|
+
background: 'transparent',
|
|
133
|
+
visibility: 'hidden',
|
|
134
|
+
}}
|
|
135
|
+
/>
|
|
136
|
+
)}
|
|
137
|
+
|
|
138
|
+
{/* Status icon in top-right corner */}
|
|
139
|
+
{nodeData.executionStatus && (
|
|
140
|
+
<div
|
|
141
|
+
style={{
|
|
142
|
+
position: 'absolute',
|
|
143
|
+
top: -8,
|
|
144
|
+
right: -8,
|
|
145
|
+
display: 'flex',
|
|
146
|
+
alignItems: 'center',
|
|
147
|
+
justifyContent: 'center',
|
|
148
|
+
width: 24,
|
|
149
|
+
height: 24,
|
|
150
|
+
background: '#fff',
|
|
151
|
+
borderRadius: '50%',
|
|
152
|
+
border: '1px solid #ddd',
|
|
153
|
+
}}
|
|
154
|
+
>
|
|
155
|
+
{getStatusIcon(nodeData.executionStatus)}
|
|
156
|
+
</div>
|
|
157
|
+
)}
|
|
158
|
+
|
|
159
|
+
<div style={{ fontWeight: 500, marginBottom: nodeData.duration ? 4 : 0 }}>
|
|
160
|
+
{nodeData.label}
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
{/* Duration badge */}
|
|
164
|
+
{nodeData.duration && (
|
|
165
|
+
<div
|
|
166
|
+
style={{
|
|
167
|
+
fontSize: 11,
|
|
168
|
+
color: '#666',
|
|
169
|
+
fontFamily: 'monospace',
|
|
170
|
+
marginBottom: 4,
|
|
171
|
+
}}
|
|
172
|
+
>
|
|
173
|
+
{nodeData.duration}
|
|
174
|
+
</div>
|
|
175
|
+
)}
|
|
176
|
+
|
|
177
|
+
{/* Execution status text */}
|
|
178
|
+
{nodeData.executionStatus && nodeData.executionStatus !== 'NOT_EXECUTED' && (
|
|
179
|
+
<div
|
|
180
|
+
style={{
|
|
181
|
+
fontSize: 11,
|
|
182
|
+
color: nodeData.executionStatus === 'FAILED' ? '#ff4d4f' : '#666',
|
|
183
|
+
fontWeight: 500,
|
|
184
|
+
}}
|
|
185
|
+
>
|
|
186
|
+
{nodeData.executionStatus === 'COMPLETED' && 'Completed'}
|
|
187
|
+
{nodeData.executionStatus === 'RUNNING' && 'Running...'}
|
|
188
|
+
{nodeData.executionStatus === 'FAILED' && 'Failed'}
|
|
189
|
+
{nodeData.executionStatus === 'PENDING' && 'Pending'}
|
|
190
|
+
</div>
|
|
191
|
+
)}
|
|
192
|
+
|
|
193
|
+
{!nodeData.readonly && (
|
|
194
|
+
<Handle
|
|
195
|
+
type="source"
|
|
196
|
+
position={Position.Bottom}
|
|
197
|
+
style={{
|
|
198
|
+
width: 14,
|
|
199
|
+
height: 14,
|
|
200
|
+
background: '#fff',
|
|
201
|
+
border: '2px solid #555',
|
|
202
|
+
cursor: 'crosshair',
|
|
203
|
+
}}
|
|
204
|
+
/>
|
|
205
|
+
)}
|
|
206
|
+
{nodeData.readonly && (
|
|
207
|
+
<Handle
|
|
208
|
+
type="source"
|
|
209
|
+
position={Position.Bottom}
|
|
210
|
+
style={{
|
|
211
|
+
width: 0,
|
|
212
|
+
height: 0,
|
|
213
|
+
border: 'none',
|
|
214
|
+
background: 'transparent',
|
|
215
|
+
visibility: 'hidden',
|
|
216
|
+
}}
|
|
217
|
+
/>
|
|
218
|
+
)}
|
|
219
|
+
</div>
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
ExecutionStatusNode.displayName = 'ExecutionStatusNode';
|
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
Spin,
|
|
10
10
|
Steps,
|
|
11
11
|
Tag,
|
|
12
|
-
Tooltip,
|
|
13
12
|
Typography,
|
|
14
13
|
} from 'antd';
|
|
15
14
|
import {
|
|
@@ -23,6 +22,8 @@ import {
|
|
|
23
22
|
} from '@ant-design/icons';
|
|
24
23
|
import { useNavigate, useParams } from 'react-router-dom';
|
|
25
24
|
import { customRequest } from 'helpers/http';
|
|
25
|
+
import { EnhancedFlowRenderer } from './components/EnhancedFlowRenderer';
|
|
26
|
+
import type { WorkflowDSL } from './types';
|
|
26
27
|
|
|
27
28
|
const { Title, Text } = Typography;
|
|
28
29
|
|
|
@@ -67,6 +68,7 @@ interface InstanceDetail {
|
|
|
67
68
|
createdAt: string;
|
|
68
69
|
workflow: { name: string };
|
|
69
70
|
nodeExecutions: NodeExecution[];
|
|
71
|
+
dsl?: WorkflowDSL;
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
function formatDuration(start: string | null, end: string | null): string {
|
|
@@ -102,6 +104,8 @@ export default function WorkflowInstanceDetailPage() {
|
|
|
102
104
|
const [data, setData] = useState<InstanceDetail | null>(null);
|
|
103
105
|
const [loading, setLoading] = useState(true);
|
|
104
106
|
const [actionLoading, setActionLoading] = useState(false);
|
|
107
|
+
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
|
108
|
+
const [viewMode, setViewMode] = useState<'timeline' | 'canvas'>('canvas');
|
|
105
109
|
|
|
106
110
|
const fetchData = useCallback(async () => {
|
|
107
111
|
if (!instanceId) return;
|
|
@@ -130,6 +134,13 @@ export default function WorkflowInstanceDetailPage() {
|
|
|
130
134
|
}
|
|
131
135
|
}, [data?.status, fetchData]);
|
|
132
136
|
|
|
137
|
+
// Fallback to timeline if no DSL available
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
if (data && !data.dsl && viewMode === 'canvas') {
|
|
140
|
+
setViewMode('timeline');
|
|
141
|
+
}
|
|
142
|
+
}, [data, viewMode]);
|
|
143
|
+
|
|
133
144
|
const handleCancel = async () => {
|
|
134
145
|
setActionLoading(true);
|
|
135
146
|
try {
|
|
@@ -193,6 +204,12 @@ export default function WorkflowInstanceDetailPage() {
|
|
|
193
204
|
if (!data) return null;
|
|
194
205
|
|
|
195
206
|
const statusCfg = STATUS_TAG[data.status] || STATUS_TAG.PENDING;
|
|
207
|
+
const selectedExecution = selectedNodeId
|
|
208
|
+
? data.nodeExecutions.find((ne) => ne.nodeId === selectedNodeId)
|
|
209
|
+
: null;
|
|
210
|
+
const selectedDslNode = selectedNodeId && data.dsl
|
|
211
|
+
? data.dsl.nodes.find((n) => n.id === selectedNodeId)
|
|
212
|
+
: null;
|
|
196
213
|
|
|
197
214
|
return (
|
|
198
215
|
<div
|
|
@@ -277,65 +294,388 @@ export default function WorkflowInstanceDetailPage() {
|
|
|
277
294
|
</Descriptions>
|
|
278
295
|
</Card>
|
|
279
296
|
|
|
280
|
-
{/* Node Execution Timeline */}
|
|
281
|
-
<Card
|
|
282
|
-
{
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
297
|
+
{/* Node Execution Timeline / Canvas View */}
|
|
298
|
+
<Card
|
|
299
|
+
title={
|
|
300
|
+
viewMode === 'timeline'
|
|
301
|
+
? 'Node Execution Timeline'
|
|
302
|
+
: 'Flow Execution Visualization'
|
|
303
|
+
}
|
|
304
|
+
extra={
|
|
305
|
+
data.dsl ? (
|
|
306
|
+
<Space>
|
|
307
|
+
<Button.Group>
|
|
308
|
+
<Button
|
|
309
|
+
type={viewMode === 'canvas' ? 'primary' : 'default'}
|
|
310
|
+
onClick={() => {
|
|
311
|
+
setViewMode('canvas');
|
|
312
|
+
setSelectedNodeId(null);
|
|
313
|
+
}}
|
|
314
|
+
>
|
|
315
|
+
Canvas
|
|
316
|
+
</Button>
|
|
317
|
+
<Button
|
|
318
|
+
type={viewMode === 'timeline' ? 'primary' : 'default'}
|
|
319
|
+
onClick={() => {
|
|
320
|
+
setViewMode('timeline');
|
|
321
|
+
setSelectedNodeId(null);
|
|
322
|
+
}}
|
|
323
|
+
>
|
|
324
|
+
Timeline
|
|
325
|
+
</Button>
|
|
326
|
+
</Button.Group>
|
|
327
|
+
</Space>
|
|
328
|
+
) : null
|
|
329
|
+
}
|
|
330
|
+
>
|
|
331
|
+
{viewMode === 'timeline' ? (
|
|
332
|
+
// Timeline View
|
|
333
|
+
<div style={{ display: 'flex', gap: 16 }}>
|
|
334
|
+
<div style={{ flex: 1 }}>
|
|
335
|
+
{data.nodeExecutions.length === 0 ? (
|
|
336
|
+
<Text type="secondary">No node executions recorded yet.</Text>
|
|
337
|
+
) : (
|
|
338
|
+
<Steps
|
|
339
|
+
direction="vertical"
|
|
340
|
+
size="small"
|
|
341
|
+
current={-1}
|
|
342
|
+
items={data.nodeExecutions.map((ne) => {
|
|
343
|
+
const isApprovalPending =
|
|
344
|
+
ne.nodeType === 'approval' && ne.status === 'RUNNING';
|
|
345
|
+
const isSelected = selectedNodeId === ne.nodeId;
|
|
346
|
+
return {
|
|
347
|
+
title: (
|
|
348
|
+
<Space
|
|
349
|
+
style={{ cursor: 'pointer' }}
|
|
350
|
+
onClick={() => setSelectedNodeId(isSelected ? null : ne.nodeId)}
|
|
351
|
+
>
|
|
352
|
+
<span style={{ fontWeight: isSelected ? 600 : 400 }}>
|
|
353
|
+
{ne.nodeLabel || ne.nodeId}
|
|
354
|
+
</span>
|
|
355
|
+
<Tag style={{ fontSize: 11 }}>{ne.nodeType}</Tag>
|
|
356
|
+
{isApprovalPending && (
|
|
357
|
+
<Space size={4}>
|
|
358
|
+
<Button
|
|
359
|
+
size="small"
|
|
360
|
+
type="primary"
|
|
361
|
+
onClick={(e) => { e.stopPropagation(); handleApprove(ne.nodeId, true); }}
|
|
362
|
+
loading={actionLoading}
|
|
363
|
+
>
|
|
364
|
+
Approve
|
|
365
|
+
</Button>
|
|
366
|
+
<Button
|
|
367
|
+
size="small"
|
|
368
|
+
danger
|
|
369
|
+
onClick={(e) => { e.stopPropagation(); handleApprove(ne.nodeId, false); }}
|
|
370
|
+
loading={actionLoading}
|
|
371
|
+
>
|
|
372
|
+
Reject
|
|
373
|
+
</Button>
|
|
374
|
+
</Space>
|
|
375
|
+
)}
|
|
376
|
+
</Space>
|
|
377
|
+
),
|
|
378
|
+
description: (
|
|
379
|
+
<div
|
|
380
|
+
style={{ fontSize: 12, color: '#666', cursor: 'pointer' }}
|
|
381
|
+
onClick={() => setSelectedNodeId(isSelected ? null : ne.nodeId)}
|
|
304
382
|
>
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
383
|
+
<span>
|
|
384
|
+
Duration:{' '}
|
|
385
|
+
{formatDuration(ne.startedAt, ne.finishedAt)}
|
|
386
|
+
</span>
|
|
387
|
+
{ne.error && (
|
|
388
|
+
<div style={{ color: '#ff4d4f', marginTop: 4 }}>
|
|
389
|
+
Error:{' '}
|
|
390
|
+
{typeof ne.error === 'object'
|
|
391
|
+
? JSON.stringify(ne.error)
|
|
392
|
+
: String(ne.error)}
|
|
393
|
+
</div>
|
|
394
|
+
)}
|
|
395
|
+
</div>
|
|
396
|
+
),
|
|
397
|
+
status: getStepStatus(ne.status),
|
|
398
|
+
icon:
|
|
399
|
+
ne.status === 'RUNNING' ? <LoadingOutlined /> : undefined,
|
|
400
|
+
};
|
|
401
|
+
})}
|
|
402
|
+
/>
|
|
403
|
+
)}
|
|
404
|
+
</div>
|
|
405
|
+
|
|
406
|
+
{/* Node Detail Panel (shared with canvas view) */}
|
|
407
|
+
{selectedExecution && (
|
|
408
|
+
<div
|
|
409
|
+
style={{
|
|
410
|
+
width: 320,
|
|
411
|
+
borderLeft: '1px solid #f0f0f0',
|
|
412
|
+
paddingLeft: 16,
|
|
413
|
+
overflowY: 'auto',
|
|
414
|
+
}}
|
|
415
|
+
>
|
|
416
|
+
<div style={{ marginBottom: 16 }}>
|
|
417
|
+
<Title level={5}>
|
|
418
|
+
{selectedExecution.nodeLabel || selectedExecution.nodeId}
|
|
419
|
+
</Title>
|
|
420
|
+
<Tag
|
|
421
|
+
color={
|
|
422
|
+
selectedExecution.status === 'COMPLETED'
|
|
423
|
+
? 'green'
|
|
424
|
+
: selectedExecution.status === 'RUNNING'
|
|
425
|
+
? 'blue'
|
|
426
|
+
: selectedExecution.status === 'FAILED'
|
|
427
|
+
? 'red'
|
|
428
|
+
: 'gold'
|
|
429
|
+
}
|
|
430
|
+
>
|
|
431
|
+
{selectedExecution.status}
|
|
432
|
+
</Tag>
|
|
433
|
+
</div>
|
|
434
|
+
|
|
435
|
+
<Descriptions column={1} size="small" style={{ marginBottom: 16 }}>
|
|
436
|
+
<Descriptions.Item label="Node Type">
|
|
437
|
+
{selectedExecution.nodeType}
|
|
438
|
+
</Descriptions.Item>
|
|
439
|
+
<Descriptions.Item label="Started">
|
|
440
|
+
{selectedExecution.startedAt
|
|
441
|
+
? new Date(selectedExecution.startedAt).toLocaleString()
|
|
442
|
+
: '—'}
|
|
443
|
+
</Descriptions.Item>
|
|
444
|
+
<Descriptions.Item label="Finished">
|
|
445
|
+
{selectedExecution.finishedAt
|
|
446
|
+
? new Date(selectedExecution.finishedAt).toLocaleString()
|
|
447
|
+
: '—'}
|
|
448
|
+
</Descriptions.Item>
|
|
449
|
+
<Descriptions.Item label="Duration">
|
|
450
|
+
{formatDuration(selectedExecution.startedAt, selectedExecution.finishedAt)}
|
|
451
|
+
</Descriptions.Item>
|
|
452
|
+
</Descriptions>
|
|
453
|
+
|
|
454
|
+
{selectedExecution.input && (
|
|
455
|
+
<div style={{ marginBottom: 12 }}>
|
|
456
|
+
<Text strong style={{ fontSize: 12 }}>Input:</Text>
|
|
457
|
+
<pre style={{ background: '#fafafa', padding: 8, borderRadius: 4, fontSize: 11, maxHeight: 150, overflow: 'auto' }}>
|
|
458
|
+
{JSON.stringify(selectedExecution.input, null, 2)}
|
|
459
|
+
</pre>
|
|
460
|
+
</div>
|
|
461
|
+
)}
|
|
462
|
+
|
|
463
|
+
{selectedExecution.output && (
|
|
464
|
+
<div style={{ marginBottom: 12 }}>
|
|
465
|
+
<Text strong style={{ fontSize: 12 }}>Output:</Text>
|
|
466
|
+
<pre style={{ background: '#fafafa', padding: 8, borderRadius: 4, fontSize: 11, maxHeight: 150, overflow: 'auto' }}>
|
|
467
|
+
{JSON.stringify(selectedExecution.output, null, 2)}
|
|
468
|
+
</pre>
|
|
469
|
+
</div>
|
|
470
|
+
)}
|
|
471
|
+
|
|
472
|
+
{selectedExecution.error && (
|
|
473
|
+
<div style={{ marginBottom: 12 }}>
|
|
474
|
+
<Text strong style={{ fontSize: 12, color: '#ff4d4f' }}>Error:</Text>
|
|
475
|
+
<pre style={{ background: '#fff1f0', padding: 8, borderRadius: 4, fontSize: 11, color: '#ff4d4f', maxHeight: 150, overflow: 'auto' }}>
|
|
476
|
+
{typeof selectedExecution.error === 'object'
|
|
477
|
+
? JSON.stringify(selectedExecution.error, null, 2)
|
|
478
|
+
: String(selectedExecution.error)}
|
|
479
|
+
</pre>
|
|
480
|
+
</div>
|
|
481
|
+
)}
|
|
482
|
+
</div>
|
|
483
|
+
)}
|
|
484
|
+
</div>
|
|
485
|
+
) : (
|
|
486
|
+
// Canvas View
|
|
487
|
+
<>
|
|
488
|
+
{data.dsl ? (
|
|
489
|
+
<div style={{ display: 'flex', gap: 16 }}>
|
|
490
|
+
{/* Canvas */}
|
|
491
|
+
<div style={{ flex: 1, height: 500, minHeight: 500 }}>
|
|
492
|
+
<EnhancedFlowRenderer
|
|
493
|
+
dsl={data.dsl}
|
|
494
|
+
nodeExecutions={data.nodeExecutions}
|
|
495
|
+
showExecutionStatus={true}
|
|
496
|
+
selectedNodeId={selectedNodeId}
|
|
497
|
+
readonly={true}
|
|
498
|
+
onNodeClick={(nodeId) => setSelectedNodeId(nodeId)}
|
|
499
|
+
/>
|
|
500
|
+
</div>
|
|
501
|
+
|
|
502
|
+
{/* Node Detail Panel */}
|
|
503
|
+
{selectedNodeId && (
|
|
504
|
+
<div
|
|
505
|
+
style={{
|
|
506
|
+
width: 320,
|
|
507
|
+
borderLeft: '1px solid #f0f0f0',
|
|
508
|
+
paddingLeft: 16,
|
|
509
|
+
overflowY: 'auto',
|
|
510
|
+
}}
|
|
511
|
+
>
|
|
512
|
+
{selectedExecution ? (
|
|
513
|
+
<>
|
|
514
|
+
<div style={{ marginBottom: 16 }}>
|
|
515
|
+
<Title level={5}>
|
|
516
|
+
{selectedExecution.nodeLabel ||
|
|
517
|
+
selectedExecution.nodeId}
|
|
518
|
+
</Title>
|
|
519
|
+
<Tag
|
|
520
|
+
color={
|
|
521
|
+
selectedExecution.status === 'COMPLETED'
|
|
522
|
+
? 'green'
|
|
523
|
+
: selectedExecution.status === 'RUNNING'
|
|
524
|
+
? 'blue'
|
|
525
|
+
: selectedExecution.status === 'FAILED'
|
|
526
|
+
? 'red'
|
|
527
|
+
: 'gold'
|
|
528
|
+
}
|
|
529
|
+
>
|
|
530
|
+
{selectedExecution.status}
|
|
531
|
+
</Tag>
|
|
532
|
+
</div>
|
|
533
|
+
|
|
534
|
+
<Descriptions
|
|
535
|
+
column={1}
|
|
308
536
|
size="small"
|
|
309
|
-
|
|
310
|
-
onClick={() => handleApprove(ne.nodeId, false)}
|
|
311
|
-
loading={actionLoading}
|
|
537
|
+
style={{ marginBottom: 16 }}
|
|
312
538
|
>
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
539
|
+
<Descriptions.Item label="Node Type">
|
|
540
|
+
{selectedExecution.nodeType}
|
|
541
|
+
</Descriptions.Item>
|
|
542
|
+
<Descriptions.Item label="Started">
|
|
543
|
+
{selectedExecution.startedAt
|
|
544
|
+
? new Date(
|
|
545
|
+
selectedExecution.startedAt,
|
|
546
|
+
).toLocaleString()
|
|
547
|
+
: '—'}
|
|
548
|
+
</Descriptions.Item>
|
|
549
|
+
<Descriptions.Item label="Finished">
|
|
550
|
+
{selectedExecution.finishedAt
|
|
551
|
+
? new Date(
|
|
552
|
+
selectedExecution.finishedAt,
|
|
553
|
+
).toLocaleString()
|
|
554
|
+
: '—'}
|
|
555
|
+
</Descriptions.Item>
|
|
556
|
+
<Descriptions.Item label="Duration">
|
|
557
|
+
{formatDuration(
|
|
558
|
+
selectedExecution.startedAt,
|
|
559
|
+
selectedExecution.finishedAt,
|
|
560
|
+
)}
|
|
561
|
+
</Descriptions.Item>
|
|
562
|
+
</Descriptions>
|
|
563
|
+
|
|
564
|
+
{selectedExecution.input && (
|
|
565
|
+
<div style={{ marginBottom: 12 }}>
|
|
566
|
+
<Text strong style={{ fontSize: 12 }}>
|
|
567
|
+
Input:
|
|
568
|
+
</Text>
|
|
569
|
+
<pre
|
|
570
|
+
style={{
|
|
571
|
+
background: '#fafafa',
|
|
572
|
+
padding: 8,
|
|
573
|
+
borderRadius: 4,
|
|
574
|
+
fontSize: 11,
|
|
575
|
+
maxHeight: 150,
|
|
576
|
+
overflow: 'auto',
|
|
577
|
+
}}
|
|
578
|
+
>
|
|
579
|
+
{JSON.stringify(selectedExecution.input, null, 2)}
|
|
580
|
+
</pre>
|
|
581
|
+
</div>
|
|
582
|
+
)}
|
|
583
|
+
|
|
584
|
+
{selectedExecution.output && (
|
|
585
|
+
<div style={{ marginBottom: 12 }}>
|
|
586
|
+
<Text strong style={{ fontSize: 12 }}>
|
|
587
|
+
Output:
|
|
588
|
+
</Text>
|
|
589
|
+
<pre
|
|
590
|
+
style={{
|
|
591
|
+
background: '#fafafa',
|
|
592
|
+
padding: 8,
|
|
593
|
+
borderRadius: 4,
|
|
594
|
+
fontSize: 11,
|
|
595
|
+
maxHeight: 150,
|
|
596
|
+
overflow: 'auto',
|
|
597
|
+
}}
|
|
598
|
+
>
|
|
599
|
+
{JSON.stringify(selectedExecution.output, null, 2)}
|
|
600
|
+
</pre>
|
|
601
|
+
</div>
|
|
602
|
+
)}
|
|
603
|
+
|
|
604
|
+
{selectedExecution.error && (
|
|
605
|
+
<div style={{ marginBottom: 12 }}>
|
|
606
|
+
<Text strong style={{ fontSize: 12, color: '#ff4d4f' }}>
|
|
607
|
+
Error:
|
|
608
|
+
</Text>
|
|
609
|
+
<pre
|
|
610
|
+
style={{
|
|
611
|
+
background: '#fff1f0',
|
|
612
|
+
padding: 8,
|
|
613
|
+
borderRadius: 4,
|
|
614
|
+
fontSize: 11,
|
|
615
|
+
color: '#ff4d4f',
|
|
616
|
+
maxHeight: 150,
|
|
617
|
+
overflow: 'auto',
|
|
618
|
+
}}
|
|
619
|
+
>
|
|
620
|
+
{typeof selectedExecution.error === 'object'
|
|
621
|
+
? JSON.stringify(selectedExecution.error, null, 2)
|
|
622
|
+
: String(selectedExecution.error)}
|
|
623
|
+
</pre>
|
|
624
|
+
</div>
|
|
625
|
+
)}
|
|
626
|
+
|
|
627
|
+
{/* Approval actions */}
|
|
628
|
+
{selectedExecution.nodeType === 'approval' &&
|
|
629
|
+
selectedExecution.status === 'RUNNING' && (
|
|
630
|
+
<Space style={{ width: '100%' }}>
|
|
631
|
+
<Button
|
|
632
|
+
type="primary"
|
|
633
|
+
block
|
|
634
|
+
onClick={() =>
|
|
635
|
+
handleApprove(selectedExecution.nodeId, true)
|
|
636
|
+
}
|
|
637
|
+
loading={actionLoading}
|
|
638
|
+
>
|
|
639
|
+
Approve
|
|
640
|
+
</Button>
|
|
641
|
+
<Button
|
|
642
|
+
danger
|
|
643
|
+
block
|
|
644
|
+
onClick={() =>
|
|
645
|
+
handleApprove(selectedExecution.nodeId, false)
|
|
646
|
+
}
|
|
647
|
+
loading={actionLoading}
|
|
648
|
+
>
|
|
649
|
+
Reject
|
|
650
|
+
</Button>
|
|
651
|
+
</Space>
|
|
652
|
+
)}
|
|
653
|
+
</>
|
|
654
|
+
) : selectedDslNode ? (
|
|
655
|
+
<div>
|
|
656
|
+
<div style={{ marginBottom: 16 }}>
|
|
657
|
+
<Title level={5}>{selectedDslNode.label}</Title>
|
|
658
|
+
<Tag color="default">Not Executed</Tag>
|
|
659
|
+
</div>
|
|
660
|
+
<Descriptions column={1} size="small">
|
|
661
|
+
<Descriptions.Item label="Node Type">
|
|
662
|
+
{selectedDslNode.type}
|
|
663
|
+
</Descriptions.Item>
|
|
664
|
+
<Descriptions.Item label="Status">
|
|
665
|
+
<Text type="secondary">Waiting for execution</Text>
|
|
666
|
+
</Descriptions.Item>
|
|
667
|
+
</Descriptions>
|
|
330
668
|
</div>
|
|
331
|
-
)}
|
|
669
|
+
) : null}
|
|
332
670
|
</div>
|
|
333
|
-
)
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
671
|
+
)}
|
|
672
|
+
</div>
|
|
673
|
+
) : (
|
|
674
|
+
<Text type="secondary">
|
|
675
|
+
Workflow DSL not available for visualization.
|
|
676
|
+
</Text>
|
|
677
|
+
)}
|
|
678
|
+
</>
|
|
339
679
|
)}
|
|
340
680
|
</Card>
|
|
341
681
|
</div>
|