@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.
@@ -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.47",
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.40",
92
- "@gadmin2n/prisma-react-generator": "^0.0.56",
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
+ }
@@ -0,0 +1,7 @@
1
+ import { Module } from '@nestjs/common';
2
+ import { HealthController } from './health.controller';
3
+
4
+ @Module({
5
+ controllers: [HealthController],
6
+ })
7
+ export class HealthModule {}
@@ -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
- return instance;
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.66",
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';
@@ -278,6 +278,7 @@ function FlowCanvas({
278
278
  elementsSelectable={true}
279
279
  connectionMode={ConnectionMode.Loose}
280
280
  deleteKeyCode={['Backspace', 'Delete']}
281
+ panOnScroll={true}
281
282
  zoomOnScroll={false}
282
283
  zoomOnPinch={false}
283
284
  zoomOnDoubleClick={false}
@@ -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 title="Node Execution Timeline">
282
- {data.nodeExecutions.length === 0 ? (
283
- <Text type="secondary">No node executions recorded yet.</Text>
284
- ) : (
285
- <Steps
286
- direction="vertical"
287
- size="small"
288
- current={-1}
289
- items={data.nodeExecutions.map((ne) => {
290
- const isApprovalPending =
291
- ne.nodeType === 'approval' && ne.status === 'RUNNING';
292
- return {
293
- title: (
294
- <Space>
295
- <span>{ne.nodeLabel || ne.nodeId}</span>
296
- <Tag style={{ fontSize: 11 }}>{ne.nodeType}</Tag>
297
- {isApprovalPending && (
298
- <Space size={4}>
299
- <Button
300
- size="small"
301
- type="primary"
302
- onClick={() => handleApprove(ne.nodeId, true)}
303
- loading={actionLoading}
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
- Approve
306
- </Button>
307
- <Button
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
- danger
310
- onClick={() => handleApprove(ne.nodeId, false)}
311
- loading={actionLoading}
537
+ style={{ marginBottom: 16 }}
312
538
  >
313
- Reject
314
- </Button>
315
- </Space>
316
- )}
317
- </Space>
318
- ),
319
- description: (
320
- <div style={{ fontSize: 12, color: '#666' }}>
321
- <span>
322
- Duration: {formatDuration(ne.startedAt, ne.finishedAt)}
323
- </span>
324
- {ne.error && (
325
- <div style={{ color: '#ff4d4f', marginTop: 4 }}>
326
- Error:{' '}
327
- {typeof ne.error === 'object'
328
- ? JSON.stringify(ne.error)
329
- : String(ne.error)}
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
- status: getStepStatus(ne.status),
335
- icon: ne.status === 'RUNNING' ? <LoadingOutlined /> : undefined,
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>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gadmin2n/schematics",
3
- "version": "0.0.77",
3
+ "version": "0.0.79",
4
4
  "description": "Gadmin - modern, fast, powerful node.js web framework (@schematics)",
5
5
  "main": "dist/index.js",
6
6
  "files": [