@gadmin2n/schematics 0.0.79 → 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/web/package.json +1 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/ExecutionStatusNode.tsx +21 -15
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/instance-detail.tsx +89 -20
- package/package.json +1 -1
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
# Graceful Deployment 方案
|
|
2
|
+
|
|
3
|
+
本文档描述项目在 K8s 环境下实现零停机滚动升级(Graceful Rolling Update)的完整方案。
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. 问题背景
|
|
8
|
+
|
|
9
|
+
在 K8s 多 Pod 部署(Ingress + Service)中,镜像升级/重启时如果没有 graceful shutdown 机制,会导致:
|
|
10
|
+
- 正在处理的请求被强制中断(502/504)
|
|
11
|
+
- 定时任务执行到一半被杀死
|
|
12
|
+
- 数据库连接、Temporal 连接未正确释放
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## 2. 整体时序
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
K8s 发起 Pod 删除(Deployment rolling update)
|
|
20
|
+
│
|
|
21
|
+
├─► [异步1] 从 Service Endpoints 摘除 Pod(kube-proxy 更新 iptables)
|
|
22
|
+
│ → 新流量不再路由到此 Pod
|
|
23
|
+
│
|
|
24
|
+
└─► [异步2] 执行 lifecycle.preStop hook
|
|
25
|
+
│
|
|
26
|
+
└─► sleep 5s(等待 iptables 规则同步到所有节点)
|
|
27
|
+
│
|
|
28
|
+
└─► 发送 SIGTERM 给容器主进程
|
|
29
|
+
│
|
|
30
|
+
├─► NestJS server.close()(停止接受新 TCP 连接)
|
|
31
|
+
├─► 处理完所有 in-flight HTTP 请求
|
|
32
|
+
├─► 触发 onApplicationShutdown() 生命周期钩子:
|
|
33
|
+
│ ├─► AgendaService: 停止接取新任务,等待当前任务完成
|
|
34
|
+
│ ├─► TemporalService: 断开 Temporal 连接
|
|
35
|
+
│ └─► OpenTelemetry: flush 追踪数据
|
|
36
|
+
└─► 进程退出,容器结束
|
|
37
|
+
|
|
38
|
+
═══════════════════════════════════════════════════════════════
|
|
39
|
+
terminationGracePeriodSeconds(300s)超时后 → 强制 SIGKILL
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**关键点:** `preStop sleep 5s` 和 `SIGTERM` 是串行的,但与 Endpoints 摘除是并行的。sleep 确保在进程开始关闭前,iptables 已完成更新,避免流量打到正在关闭的 Pod。
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## 3. 代码改动
|
|
47
|
+
|
|
48
|
+
### 3.1 `server/src/main.ts` — 启用 Shutdown Hooks
|
|
49
|
+
|
|
50
|
+
```typescript
|
|
51
|
+
// 在 app.listen() 之前
|
|
52
|
+
app.enableShutdownHooks();
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**作用:** 使 NestJS 在收到 SIGTERM 时触发所有 `onApplicationShutdown()` / `onModuleDestroy()` 生命周期钩子。没有此行,所有清理逻辑都不会被执行。
|
|
56
|
+
|
|
57
|
+
### 3.2 `Dockerfile` — STOPSIGNAL 对齐
|
|
58
|
+
|
|
59
|
+
```diff
|
|
60
|
+
- STOPSIGNAL SIGQUIT
|
|
61
|
+
+ STOPSIGNAL SIGTERM
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
**作用:** 与 K8s 默认发送的 SIGTERM 及 NestJS `enableShutdownHooks()` 监听的信号保持一致。
|
|
65
|
+
|
|
66
|
+
### 3.3 新增 Health Module
|
|
67
|
+
|
|
68
|
+
| 端点 | 用途 | 响应 |
|
|
69
|
+
|------|------|------|
|
|
70
|
+
| `GET /{DEPLOY_NAME}/api/health/live` | K8s livenessProbe | `{ "status": "ok" }` |
|
|
71
|
+
| `GET /{DEPLOY_NAME}/api/health/ready` | K8s readinessProbe | `{ "status": "ok" }` |
|
|
72
|
+
|
|
73
|
+
两个端点均通过 `@AllowUnauthorizedRequest()` 跳过认证,可被 K8s kubelet 直接访问。
|
|
74
|
+
|
|
75
|
+
### 3.4 AgendaService — 优雅关闭
|
|
76
|
+
|
|
77
|
+
`AgendaService.onApplicationShutdown()` 在收到 SIGTERM 后被触发:
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
async onApplicationShutdown() {
|
|
81
|
+
await this.agenda.stop(); // 停止接取新任务,等待当前 running jobs 完成
|
|
82
|
+
this.logger.log('Agenda stopped');
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**`agenda.stop()` 的行为:**
|
|
87
|
+
- 停止从 `agenda_jobs` 表 lock 新任务
|
|
88
|
+
- 等待所有正在执行的 job handler 的 Promise resolve
|
|
89
|
+
- 不会等待未来计划的任务(其他 Pod 会接管)
|
|
90
|
+
|
|
91
|
+
**注意:** 如果未来有执行时间可能超过 295s(300 - 5s preStop)的任务,需要在 job handler 中增加检查点机制提前退出,或加大 `terminationGracePeriodSeconds`。
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## 4. Spinnaker / K8s Deployment 配置
|
|
96
|
+
|
|
97
|
+
以下配置需在 Spinnaker 管理的 Deployment manifest 中修改:
|
|
98
|
+
|
|
99
|
+
```yaml
|
|
100
|
+
apiVersion: apps/v1
|
|
101
|
+
kind: Deployment
|
|
102
|
+
metadata:
|
|
103
|
+
name: gadmin-server
|
|
104
|
+
spec:
|
|
105
|
+
# ─── 滚动升级策略 ───────────────────────────────────────────
|
|
106
|
+
strategy:
|
|
107
|
+
type: RollingUpdate
|
|
108
|
+
rollingUpdate:
|
|
109
|
+
maxSurge: 1 # 先启动 1 个新 Pod
|
|
110
|
+
maxUnavailable: 0 # 零停机:旧 Pod 不提前终止
|
|
111
|
+
|
|
112
|
+
template:
|
|
113
|
+
spec:
|
|
114
|
+
# ─── 优雅关闭宽限期 ─────────────────────────────────────
|
|
115
|
+
terminationGracePeriodSeconds: 300
|
|
116
|
+
# 给 NestJS 足够时间完成:
|
|
117
|
+
# - in-flight HTTP 请求
|
|
118
|
+
# - 正在执行的 Agenda 定时任务(最长可能数分钟)
|
|
119
|
+
# - OpenTelemetry trace flush
|
|
120
|
+
# 超过 300s 未退出将被 SIGKILL 强杀
|
|
121
|
+
|
|
122
|
+
containers:
|
|
123
|
+
- name: server
|
|
124
|
+
image: <registry>/<image>:<tag>
|
|
125
|
+
ports:
|
|
126
|
+
- containerPort: 8000
|
|
127
|
+
|
|
128
|
+
# ─── preStop Hook ───────────────────────────────────
|
|
129
|
+
lifecycle:
|
|
130
|
+
preStop:
|
|
131
|
+
exec:
|
|
132
|
+
command: ["sh", "-c", "sleep 5"]
|
|
133
|
+
# 目的:等待 kube-proxy 完成 iptables 规则同步
|
|
134
|
+
# 确保不会有新流量在 SIGTERM 发出后仍然打到此 Pod
|
|
135
|
+
|
|
136
|
+
# ─── Readiness Probe ────────────────────────────────
|
|
137
|
+
# Pod 通过此探针后才会被加入 Service Endpoints 接收流量
|
|
138
|
+
readinessProbe:
|
|
139
|
+
httpGet:
|
|
140
|
+
path: /<DEPLOY_NAME>/api/health/ready
|
|
141
|
+
port: 8000
|
|
142
|
+
initialDelaySeconds: 5
|
|
143
|
+
periodSeconds: 5
|
|
144
|
+
failureThreshold: 3
|
|
145
|
+
successThreshold: 1
|
|
146
|
+
|
|
147
|
+
# ─── Liveness Probe ─────────────────────────────────
|
|
148
|
+
# 探针失败后 K8s 会重启容器(检测进程死锁/僵死)
|
|
149
|
+
livenessProbe:
|
|
150
|
+
httpGet:
|
|
151
|
+
path: /<DEPLOY_NAME>/api/health/live
|
|
152
|
+
port: 8000
|
|
153
|
+
initialDelaySeconds: 15
|
|
154
|
+
periodSeconds: 10
|
|
155
|
+
failureThreshold: 3
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
> **注意:** `<DEPLOY_NAME>` 替换为实际部署名称(如 `gadmin-test`),对应 `process.env.DEPLOY_NAME`。
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## 5. 配置说明
|
|
163
|
+
|
|
164
|
+
### 5.1 为什么 terminationGracePeriodSeconds = 300?
|
|
165
|
+
|
|
166
|
+
| 因素 | 说明 |
|
|
167
|
+
|------|------|
|
|
168
|
+
| preStop sleep | 5s |
|
|
169
|
+
| In-flight HTTP 请求处理 | 通常 < 10s |
|
|
170
|
+
| Agenda 定时任务完成 | 可能长达数分钟 |
|
|
171
|
+
| OpenTelemetry flush | < 5s |
|
|
172
|
+
| **总计余量** | 300s 覆盖大多数场景 |
|
|
173
|
+
|
|
174
|
+
`agenda.stop()` 会等待当前正在执行的 job 完成。如果 job 执行时间超过 295s(300 - 5s preStop),将被 SIGKILL 强杀。建议:
|
|
175
|
+
- 单个 job 执行时间控制在 4 分钟以内
|
|
176
|
+
- 超长任务使用 `isStopping` 检查点机制提前退出
|
|
177
|
+
|
|
178
|
+
### 5.2 为什么 preStop sleep 5 秒?
|
|
179
|
+
|
|
180
|
+
K8s 删除 Pod 时,Endpoints 摘除和 preStop/SIGTERM 是**并行**触发的:
|
|
181
|
+
- Endpoints 控制器通知各节点 kube-proxy 更新 iptables 规则需要时间(通常 1-3 秒)
|
|
182
|
+
- 如果不 sleep,SIGTERM 可能在 iptables 更新完成前就让进程开始关闭
|
|
183
|
+
- 5 秒是业界推荐值,足以覆盖绝大部分集群的传播延迟
|
|
184
|
+
|
|
185
|
+
### 5.3 maxUnavailable: 0 的意义
|
|
186
|
+
|
|
187
|
+
确保滚动升级过程中**始终有足够 Pod 处理流量**:
|
|
188
|
+
1. 先启动新 Pod(maxSurge: 1)
|
|
189
|
+
2. 新 Pod readinessProbe 通过后加入 Service
|
|
190
|
+
3. 旧 Pod 开始 graceful shutdown
|
|
191
|
+
4. 旧 Pod 完全退出后再终止下一个
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## 6. 验证方法
|
|
196
|
+
|
|
197
|
+
### 6.1 本地验证 Shutdown Hooks
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
cd server && yarn start:prod &
|
|
201
|
+
SERVER_PID=$!
|
|
202
|
+
|
|
203
|
+
# 等服务启动
|
|
204
|
+
sleep 3
|
|
205
|
+
|
|
206
|
+
# 发送 SIGTERM
|
|
207
|
+
kill -TERM $SERVER_PID
|
|
208
|
+
|
|
209
|
+
# 观察日志应输出:
|
|
210
|
+
# "Agenda stopped"
|
|
211
|
+
# 进程正常退出(exit code 0)
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### 6.2 验证 Health 端点
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
# 启动后请求
|
|
218
|
+
curl http://localhost:8000/<DEPLOY_NAME>/api/health/live
|
|
219
|
+
# 期望:{"status":"ok"}
|
|
220
|
+
|
|
221
|
+
curl http://localhost:8000/<DEPLOY_NAME>/api/health/ready
|
|
222
|
+
# 期望:{"status":"ok"}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### 6.3 K8s 环境验证
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
# 观察滚动升级过程
|
|
229
|
+
kubectl rollout status deployment/gadmin-server -w
|
|
230
|
+
|
|
231
|
+
# 查看 Pod 事件(确认 preStop 执行)
|
|
232
|
+
kubectl describe pod <pod-name> | grep -A5 "Events"
|
|
233
|
+
|
|
234
|
+
# 升级期间持续请求验证零中断
|
|
235
|
+
while true; do curl -s -o /dev/null -w "%{http_code}\n" http://<ingress>/api/health/ready; sleep 0.5; done
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
## 7. 信号传递链路
|
|
241
|
+
|
|
242
|
+
```
|
|
243
|
+
K8s kubelet
|
|
244
|
+
│
|
|
245
|
+
└─► Docker/containerd: 发送 STOPSIGNAL (SIGTERM)
|
|
246
|
+
│
|
|
247
|
+
└─► start-prod.sh(使用 exec,PID 1 是 node 进程)
|
|
248
|
+
│
|
|
249
|
+
└─► Node.js process
|
|
250
|
+
│
|
|
251
|
+
├─► NestJS enableShutdownHooks() 捕获 SIGTERM
|
|
252
|
+
│ └─► 调用 app.close()
|
|
253
|
+
│ └─► 触发所有生命周期钩子
|
|
254
|
+
│
|
|
255
|
+
└─► OpenTelemetry process.on('SIGTERM') handler
|
|
256
|
+
└─► flush traces
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
`start-prod.sh` 中的 `exec node dist/src/main` 确保 Node.js 进程是容器的 PID 1,信号不会被 shell 拦截。
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
## 8. 故障场景与应对
|
|
264
|
+
|
|
265
|
+
| 场景 | 现象 | 应对 |
|
|
266
|
+
|------|------|------|
|
|
267
|
+
| Job 超时被 SIGKILL | 任务中断,锁超时后释放 | 其他 Pod 自动重试(Agenda 分布式锁) |
|
|
268
|
+
| readinessProbe 失败 | Pod 从 Service 摘除 | 不接收新流量,等 liveness 判定是否重启 |
|
|
269
|
+
| preStop 未配置 | 升级时短暂 502 | 添加 preStop sleep 5 |
|
|
270
|
+
| enableShutdownHooks 未启用 | 资源泄漏(连接未关闭) | 已通过本次改造修复 |
|
|
@@ -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.50",
|
|
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.43",
|
|
92
|
+
"@gadmin2n/prisma-react-generator": "^0.0.59",
|
|
93
93
|
"@nestjs/testing": "^10.4.15",
|
|
94
94
|
"@types/cookie-parser": "^1.4.3",
|
|
95
95
|
"@types/express": "^4.17.21",
|
|
@@ -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.69",
|
|
15
15
|
"@monaco-editor/react": "^4.7.0",
|
|
16
16
|
"@refinedev/antd": "^5.47.0",
|
|
17
17
|
"@refinedev/cli": "^2.16.51",
|
|
@@ -30,7 +30,12 @@ export interface ExecutionStatusNodeData {
|
|
|
30
30
|
category: string;
|
|
31
31
|
isSelected: boolean;
|
|
32
32
|
readonly?: boolean;
|
|
33
|
-
executionStatus?:
|
|
33
|
+
executionStatus?:
|
|
34
|
+
| 'PENDING'
|
|
35
|
+
| 'RUNNING'
|
|
36
|
+
| 'COMPLETED'
|
|
37
|
+
| 'FAILED'
|
|
38
|
+
| 'NOT_EXECUTED';
|
|
34
39
|
duration?: string; // e.g., "2.5s"
|
|
35
40
|
error?: string;
|
|
36
41
|
[key: string]: unknown;
|
|
@@ -175,20 +180,21 @@ export const ExecutionStatusNode = memo(({ data }: NodeProps) => {
|
|
|
175
180
|
)}
|
|
176
181
|
|
|
177
182
|
{/* Execution status text */}
|
|
178
|
-
{nodeData.executionStatus &&
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
+
)}
|
|
192
198
|
|
|
193
199
|
{!nodeData.readonly && (
|
|
194
200
|
<Handle
|
|
@@ -207,9 +207,10 @@ export default function WorkflowInstanceDetailPage() {
|
|
|
207
207
|
const selectedExecution = selectedNodeId
|
|
208
208
|
? data.nodeExecutions.find((ne) => ne.nodeId === selectedNodeId)
|
|
209
209
|
: null;
|
|
210
|
-
const selectedDslNode =
|
|
211
|
-
|
|
212
|
-
|
|
210
|
+
const selectedDslNode =
|
|
211
|
+
selectedNodeId && data.dsl
|
|
212
|
+
? data.dsl.nodes.find((n) => n.id === selectedNodeId)
|
|
213
|
+
: null;
|
|
213
214
|
|
|
214
215
|
return (
|
|
215
216
|
<div
|
|
@@ -347,7 +348,9 @@ export default function WorkflowInstanceDetailPage() {
|
|
|
347
348
|
title: (
|
|
348
349
|
<Space
|
|
349
350
|
style={{ cursor: 'pointer' }}
|
|
350
|
-
onClick={() =>
|
|
351
|
+
onClick={() =>
|
|
352
|
+
setSelectedNodeId(isSelected ? null : ne.nodeId)
|
|
353
|
+
}
|
|
351
354
|
>
|
|
352
355
|
<span style={{ fontWeight: isSelected ? 600 : 400 }}>
|
|
353
356
|
{ne.nodeLabel || ne.nodeId}
|
|
@@ -358,7 +361,10 @@ export default function WorkflowInstanceDetailPage() {
|
|
|
358
361
|
<Button
|
|
359
362
|
size="small"
|
|
360
363
|
type="primary"
|
|
361
|
-
onClick={(e) => {
|
|
364
|
+
onClick={(e) => {
|
|
365
|
+
e.stopPropagation();
|
|
366
|
+
handleApprove(ne.nodeId, true);
|
|
367
|
+
}}
|
|
362
368
|
loading={actionLoading}
|
|
363
369
|
>
|
|
364
370
|
Approve
|
|
@@ -366,7 +372,10 @@ export default function WorkflowInstanceDetailPage() {
|
|
|
366
372
|
<Button
|
|
367
373
|
size="small"
|
|
368
374
|
danger
|
|
369
|
-
onClick={(e) => {
|
|
375
|
+
onClick={(e) => {
|
|
376
|
+
e.stopPropagation();
|
|
377
|
+
handleApprove(ne.nodeId, false);
|
|
378
|
+
}}
|
|
370
379
|
loading={actionLoading}
|
|
371
380
|
>
|
|
372
381
|
Reject
|
|
@@ -377,8 +386,14 @@ export default function WorkflowInstanceDetailPage() {
|
|
|
377
386
|
),
|
|
378
387
|
description: (
|
|
379
388
|
<div
|
|
380
|
-
style={{
|
|
381
|
-
|
|
389
|
+
style={{
|
|
390
|
+
fontSize: 12,
|
|
391
|
+
color: '#666',
|
|
392
|
+
cursor: 'pointer',
|
|
393
|
+
}}
|
|
394
|
+
onClick={() =>
|
|
395
|
+
setSelectedNodeId(isSelected ? null : ne.nodeId)
|
|
396
|
+
}
|
|
382
397
|
>
|
|
383
398
|
<span>
|
|
384
399
|
Duration:{' '}
|
|
@@ -396,7 +411,9 @@ export default function WorkflowInstanceDetailPage() {
|
|
|
396
411
|
),
|
|
397
412
|
status: getStepStatus(ne.status),
|
|
398
413
|
icon:
|
|
399
|
-
ne.status === 'RUNNING' ?
|
|
414
|
+
ne.status === 'RUNNING' ? (
|
|
415
|
+
<LoadingOutlined />
|
|
416
|
+
) : undefined,
|
|
400
417
|
};
|
|
401
418
|
})}
|
|
402
419
|
/>
|
|
@@ -432,7 +449,11 @@ export default function WorkflowInstanceDetailPage() {
|
|
|
432
449
|
</Tag>
|
|
433
450
|
</div>
|
|
434
451
|
|
|
435
|
-
<Descriptions
|
|
452
|
+
<Descriptions
|
|
453
|
+
column={1}
|
|
454
|
+
size="small"
|
|
455
|
+
style={{ marginBottom: 16 }}
|
|
456
|
+
>
|
|
436
457
|
<Descriptions.Item label="Node Type">
|
|
437
458
|
{selectedExecution.nodeType}
|
|
438
459
|
</Descriptions.Item>
|
|
@@ -447,14 +468,28 @@ export default function WorkflowInstanceDetailPage() {
|
|
|
447
468
|
: '—'}
|
|
448
469
|
</Descriptions.Item>
|
|
449
470
|
<Descriptions.Item label="Duration">
|
|
450
|
-
{formatDuration(
|
|
471
|
+
{formatDuration(
|
|
472
|
+
selectedExecution.startedAt,
|
|
473
|
+
selectedExecution.finishedAt,
|
|
474
|
+
)}
|
|
451
475
|
</Descriptions.Item>
|
|
452
476
|
</Descriptions>
|
|
453
477
|
|
|
454
478
|
{selectedExecution.input && (
|
|
455
479
|
<div style={{ marginBottom: 12 }}>
|
|
456
|
-
<Text strong style={{ fontSize: 12 }}>
|
|
457
|
-
|
|
480
|
+
<Text strong style={{ fontSize: 12 }}>
|
|
481
|
+
Input:
|
|
482
|
+
</Text>
|
|
483
|
+
<pre
|
|
484
|
+
style={{
|
|
485
|
+
background: '#fafafa',
|
|
486
|
+
padding: 8,
|
|
487
|
+
borderRadius: 4,
|
|
488
|
+
fontSize: 11,
|
|
489
|
+
maxHeight: 150,
|
|
490
|
+
overflow: 'auto',
|
|
491
|
+
}}
|
|
492
|
+
>
|
|
458
493
|
{JSON.stringify(selectedExecution.input, null, 2)}
|
|
459
494
|
</pre>
|
|
460
495
|
</div>
|
|
@@ -462,8 +497,19 @@ export default function WorkflowInstanceDetailPage() {
|
|
|
462
497
|
|
|
463
498
|
{selectedExecution.output && (
|
|
464
499
|
<div style={{ marginBottom: 12 }}>
|
|
465
|
-
<Text strong style={{ fontSize: 12 }}>
|
|
466
|
-
|
|
500
|
+
<Text strong style={{ fontSize: 12 }}>
|
|
501
|
+
Output:
|
|
502
|
+
</Text>
|
|
503
|
+
<pre
|
|
504
|
+
style={{
|
|
505
|
+
background: '#fafafa',
|
|
506
|
+
padding: 8,
|
|
507
|
+
borderRadius: 4,
|
|
508
|
+
fontSize: 11,
|
|
509
|
+
maxHeight: 150,
|
|
510
|
+
overflow: 'auto',
|
|
511
|
+
}}
|
|
512
|
+
>
|
|
467
513
|
{JSON.stringify(selectedExecution.output, null, 2)}
|
|
468
514
|
</pre>
|
|
469
515
|
</div>
|
|
@@ -471,8 +517,20 @@ export default function WorkflowInstanceDetailPage() {
|
|
|
471
517
|
|
|
472
518
|
{selectedExecution.error && (
|
|
473
519
|
<div style={{ marginBottom: 12 }}>
|
|
474
|
-
<Text strong style={{ fontSize: 12, color: '#ff4d4f' }}>
|
|
475
|
-
|
|
520
|
+
<Text strong style={{ fontSize: 12, color: '#ff4d4f' }}>
|
|
521
|
+
Error:
|
|
522
|
+
</Text>
|
|
523
|
+
<pre
|
|
524
|
+
style={{
|
|
525
|
+
background: '#fff1f0',
|
|
526
|
+
padding: 8,
|
|
527
|
+
borderRadius: 4,
|
|
528
|
+
fontSize: 11,
|
|
529
|
+
color: '#ff4d4f',
|
|
530
|
+
maxHeight: 150,
|
|
531
|
+
overflow: 'auto',
|
|
532
|
+
}}
|
|
533
|
+
>
|
|
476
534
|
{typeof selectedExecution.error === 'object'
|
|
477
535
|
? JSON.stringify(selectedExecution.error, null, 2)
|
|
478
536
|
: String(selectedExecution.error)}
|
|
@@ -596,14 +654,21 @@ export default function WorkflowInstanceDetailPage() {
|
|
|
596
654
|
overflow: 'auto',
|
|
597
655
|
}}
|
|
598
656
|
>
|
|
599
|
-
{JSON.stringify(
|
|
657
|
+
{JSON.stringify(
|
|
658
|
+
selectedExecution.output,
|
|
659
|
+
null,
|
|
660
|
+
2,
|
|
661
|
+
)}
|
|
600
662
|
</pre>
|
|
601
663
|
</div>
|
|
602
664
|
)}
|
|
603
665
|
|
|
604
666
|
{selectedExecution.error && (
|
|
605
667
|
<div style={{ marginBottom: 12 }}>
|
|
606
|
-
<Text
|
|
668
|
+
<Text
|
|
669
|
+
strong
|
|
670
|
+
style={{ fontSize: 12, color: '#ff4d4f' }}
|
|
671
|
+
>
|
|
607
672
|
Error:
|
|
608
673
|
</Text>
|
|
609
674
|
<pre
|
|
@@ -618,7 +683,11 @@ export default function WorkflowInstanceDetailPage() {
|
|
|
618
683
|
}}
|
|
619
684
|
>
|
|
620
685
|
{typeof selectedExecution.error === 'object'
|
|
621
|
-
? JSON.stringify(
|
|
686
|
+
? JSON.stringify(
|
|
687
|
+
selectedExecution.error,
|
|
688
|
+
null,
|
|
689
|
+
2,
|
|
690
|
+
)
|
|
622
691
|
: String(selectedExecution.error)}
|
|
623
692
|
</pre>
|
|
624
693
|
</div>
|