@gadmin2n/schematics 0.0.78 → 0.0.80
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/GRACEFUL-DEPLOYMENT.md +270 -0
- 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 +229 -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 +464 -55
- package/package.json +1 -1
|
@@ -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,229 @@
|
|
|
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?:
|
|
34
|
+
| 'PENDING'
|
|
35
|
+
| 'RUNNING'
|
|
36
|
+
| 'COMPLETED'
|
|
37
|
+
| 'FAILED'
|
|
38
|
+
| 'NOT_EXECUTED';
|
|
39
|
+
duration?: string; // e.g., "2.5s"
|
|
40
|
+
error?: string;
|
|
41
|
+
[key: string]: unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function formatDuration(
|
|
45
|
+
startedAt: string | null,
|
|
46
|
+
finishedAt: string | null,
|
|
47
|
+
): string {
|
|
48
|
+
if (!startedAt) return '';
|
|
49
|
+
const endTime = finishedAt ? new Date(finishedAt).getTime() : Date.now();
|
|
50
|
+
const ms = endTime - new Date(startedAt).getTime();
|
|
51
|
+
if (ms < 1000) return `${ms}ms`;
|
|
52
|
+
const s = ms / 1000;
|
|
53
|
+
if (s < 60) return `${s.toFixed(1)}s`;
|
|
54
|
+
const m = Math.floor(s / 60);
|
|
55
|
+
const rem = s % 60;
|
|
56
|
+
return rem > 0 ? `${m}m ${rem.toFixed(0)}s` : `${m}m`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getStatusIcon(status?: string) {
|
|
60
|
+
switch (status) {
|
|
61
|
+
case 'COMPLETED':
|
|
62
|
+
return <CheckCircleOutlined style={{ color: '#52c41a', fontSize: 16 }} />;
|
|
63
|
+
case 'RUNNING':
|
|
64
|
+
return <LoadingOutlined style={{ color: '#1677ff', fontSize: 16 }} />;
|
|
65
|
+
case 'FAILED':
|
|
66
|
+
return <CloseCircleOutlined style={{ color: '#ff4d4f', fontSize: 16 }} />;
|
|
67
|
+
case 'NOT_EXECUTED':
|
|
68
|
+
return <ClockCircleOutlined style={{ color: '#bfbfbf', fontSize: 16 }} />;
|
|
69
|
+
case 'PENDING':
|
|
70
|
+
default:
|
|
71
|
+
return <ClockCircleOutlined style={{ color: '#faad14', fontSize: 16 }} />;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const ExecutionStatusNode = memo(({ data }: NodeProps) => {
|
|
76
|
+
const nodeData = data as ExecutionStatusNodeData;
|
|
77
|
+
const colors = CATEGORY_COLORS[nodeData.category] || CATEGORY_COLORS.ACTION;
|
|
78
|
+
const isNotExecuted = nodeData.executionStatus === 'NOT_EXECUTED';
|
|
79
|
+
|
|
80
|
+
// If there's execution status, use status color as background; otherwise use category color
|
|
81
|
+
const bgColor = nodeData.executionStatus
|
|
82
|
+
? STATUS_COLORS[nodeData.executionStatus]
|
|
83
|
+
: colors.bg;
|
|
84
|
+
|
|
85
|
+
const borderColor = nodeData.isSelected
|
|
86
|
+
? '#1677ff'
|
|
87
|
+
: isNotExecuted
|
|
88
|
+
? '#d9d9d9'
|
|
89
|
+
: colors.border;
|
|
90
|
+
const borderStyle = nodeData.isSelected
|
|
91
|
+
? '2px solid'
|
|
92
|
+
: isNotExecuted
|
|
93
|
+
? '1.5px dashed'
|
|
94
|
+
: '1px solid';
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div
|
|
98
|
+
style={{
|
|
99
|
+
background: bgColor,
|
|
100
|
+
border: `${borderStyle} ${borderColor}`,
|
|
101
|
+
borderRadius: 8,
|
|
102
|
+
padding: '8px 12px',
|
|
103
|
+
fontSize: 13,
|
|
104
|
+
minWidth: 140,
|
|
105
|
+
textAlign: 'center',
|
|
106
|
+
opacity: isNotExecuted ? 0.6 : 1,
|
|
107
|
+
boxShadow: nodeData.isSelected
|
|
108
|
+
? '0 0 0 2px rgba(22,119,255,0.2)'
|
|
109
|
+
: !isNotExecuted && nodeData.executionStatus
|
|
110
|
+
? '0 2px 8px rgba(0,0,0,0.15)'
|
|
111
|
+
: undefined,
|
|
112
|
+
position: 'relative',
|
|
113
|
+
transition: 'all 0.2s ease',
|
|
114
|
+
}}
|
|
115
|
+
>
|
|
116
|
+
{!nodeData.readonly && (
|
|
117
|
+
<Handle
|
|
118
|
+
type="target"
|
|
119
|
+
position={Position.Top}
|
|
120
|
+
style={{
|
|
121
|
+
width: 14,
|
|
122
|
+
height: 14,
|
|
123
|
+
background: '#fff',
|
|
124
|
+
border: '2px solid #555',
|
|
125
|
+
cursor: 'crosshair',
|
|
126
|
+
}}
|
|
127
|
+
/>
|
|
128
|
+
)}
|
|
129
|
+
{nodeData.readonly && (
|
|
130
|
+
<Handle
|
|
131
|
+
type="target"
|
|
132
|
+
position={Position.Top}
|
|
133
|
+
style={{
|
|
134
|
+
width: 0,
|
|
135
|
+
height: 0,
|
|
136
|
+
border: 'none',
|
|
137
|
+
background: 'transparent',
|
|
138
|
+
visibility: 'hidden',
|
|
139
|
+
}}
|
|
140
|
+
/>
|
|
141
|
+
)}
|
|
142
|
+
|
|
143
|
+
{/* Status icon in top-right corner */}
|
|
144
|
+
{nodeData.executionStatus && (
|
|
145
|
+
<div
|
|
146
|
+
style={{
|
|
147
|
+
position: 'absolute',
|
|
148
|
+
top: -8,
|
|
149
|
+
right: -8,
|
|
150
|
+
display: 'flex',
|
|
151
|
+
alignItems: 'center',
|
|
152
|
+
justifyContent: 'center',
|
|
153
|
+
width: 24,
|
|
154
|
+
height: 24,
|
|
155
|
+
background: '#fff',
|
|
156
|
+
borderRadius: '50%',
|
|
157
|
+
border: '1px solid #ddd',
|
|
158
|
+
}}
|
|
159
|
+
>
|
|
160
|
+
{getStatusIcon(nodeData.executionStatus)}
|
|
161
|
+
</div>
|
|
162
|
+
)}
|
|
163
|
+
|
|
164
|
+
<div style={{ fontWeight: 500, marginBottom: nodeData.duration ? 4 : 0 }}>
|
|
165
|
+
{nodeData.label}
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
{/* Duration badge */}
|
|
169
|
+
{nodeData.duration && (
|
|
170
|
+
<div
|
|
171
|
+
style={{
|
|
172
|
+
fontSize: 11,
|
|
173
|
+
color: '#666',
|
|
174
|
+
fontFamily: 'monospace',
|
|
175
|
+
marginBottom: 4,
|
|
176
|
+
}}
|
|
177
|
+
>
|
|
178
|
+
{nodeData.duration}
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
|
|
182
|
+
{/* Execution status text */}
|
|
183
|
+
{nodeData.executionStatus &&
|
|
184
|
+
nodeData.executionStatus !== 'NOT_EXECUTED' && (
|
|
185
|
+
<div
|
|
186
|
+
style={{
|
|
187
|
+
fontSize: 11,
|
|
188
|
+
color: nodeData.executionStatus === 'FAILED' ? '#ff4d4f' : '#666',
|
|
189
|
+
fontWeight: 500,
|
|
190
|
+
}}
|
|
191
|
+
>
|
|
192
|
+
{nodeData.executionStatus === 'COMPLETED' && 'Completed'}
|
|
193
|
+
{nodeData.executionStatus === 'RUNNING' && 'Running...'}
|
|
194
|
+
{nodeData.executionStatus === 'FAILED' && 'Failed'}
|
|
195
|
+
{nodeData.executionStatus === 'PENDING' && 'Pending'}
|
|
196
|
+
</div>
|
|
197
|
+
)}
|
|
198
|
+
|
|
199
|
+
{!nodeData.readonly && (
|
|
200
|
+
<Handle
|
|
201
|
+
type="source"
|
|
202
|
+
position={Position.Bottom}
|
|
203
|
+
style={{
|
|
204
|
+
width: 14,
|
|
205
|
+
height: 14,
|
|
206
|
+
background: '#fff',
|
|
207
|
+
border: '2px solid #555',
|
|
208
|
+
cursor: 'crosshair',
|
|
209
|
+
}}
|
|
210
|
+
/>
|
|
211
|
+
)}
|
|
212
|
+
{nodeData.readonly && (
|
|
213
|
+
<Handle
|
|
214
|
+
type="source"
|
|
215
|
+
position={Position.Bottom}
|
|
216
|
+
style={{
|
|
217
|
+
width: 0,
|
|
218
|
+
height: 0,
|
|
219
|
+
border: 'none',
|
|
220
|
+
background: 'transparent',
|
|
221
|
+
visibility: 'hidden',
|
|
222
|
+
}}
|
|
223
|
+
/>
|
|
224
|
+
)}
|
|
225
|
+
</div>
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
ExecutionStatusNode.displayName = 'ExecutionStatusNode';
|