@gadmin2n/schematics 0.0.70 → 0.0.72

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.
Files changed (28) hide show
  1. package/dist/lib/application/files/gadmin2-game-angle-demo/config/prisma/system.prisma +21 -0
  2. package/dist/lib/application/files/gadmin2-game-angle-demo/server/gadmin-cli.json +9 -1
  3. package/dist/lib/application/files/gadmin2-game-angle-demo/server/package-lock.json +15579 -0
  4. package/dist/lib/application/files/gadmin2-game-angle-demo/server/package.json +5 -3
  5. package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/permissions.ts +16 -4
  6. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/app.module.ts +2 -0
  7. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/agenda/agenda.controller.ts +106 -0
  8. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/agenda/agenda.module.ts +10 -0
  9. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/agenda/agenda.service.ts +569 -0
  10. package/dist/lib/application/files/gadmin2-game-angle-demo/server/yarn.lock +1159 -481
  11. package/dist/lib/application/files/gadmin2-game-angle-demo/web/.env +1 -0
  12. package/dist/lib/application/files/gadmin2-game-angle-demo/web/package.json +1 -1
  13. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/agentPanel/inspectorActions.ts +130 -3
  14. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/canvas/NumCard/index.tsx +5 -5
  15. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/canvas/NumLineCard/index.tsx +2 -2
  16. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/config/routeRegistry.tsx +15 -0
  17. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/dev-shell/DevShell.tsx +55 -43
  18. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/dev-shell/SkillMenu.tsx +1 -1
  19. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/en/common.json +2 -4
  20. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/zh_CN/common.json +2 -4
  21. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/agenda/index.tsx +536 -0
  22. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/agenda/show.tsx +671 -0
  23. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/CanvasPage.tsx +9 -60
  24. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/CanvasToolbar.tsx +2 -2
  25. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/ComponentThumbnail.tsx +3 -1
  26. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/canvasContextMenuRegistry.tsx +11 -11
  27. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/vite-env.d.ts +12 -0
  28. package/package.json +1 -1
@@ -0,0 +1,671 @@
1
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import {
3
+ Badge,
4
+ Button,
5
+ Card,
6
+ Col,
7
+ DatePicker,
8
+ Descriptions,
9
+ Popconfirm,
10
+ Row,
11
+ Select,
12
+ Space,
13
+ Spin,
14
+ Switch,
15
+ Tag,
16
+ Tooltip,
17
+ Typography,
18
+ message,
19
+ } from 'antd';
20
+ import {
21
+ ArrowLeftOutlined,
22
+ DeleteOutlined,
23
+ PlayCircleOutlined,
24
+ } from '@ant-design/icons';
25
+ import { useNavigate, useParams } from 'react-router-dom';
26
+ import { customRequest } from 'helpers/http';
27
+ import { useECharts } from 'hooks/useECharts';
28
+ import dayjs from 'dayjs';
29
+
30
+ const { Title } = Typography;
31
+
32
+ // ─── Types ────────────────────────────────────────────────────────────────────
33
+
34
+ interface JobDetail {
35
+ name: string;
36
+ module: string | null;
37
+ state: string;
38
+ disabled: boolean;
39
+ nextRunAt: string | null;
40
+ lastRunAt: string | null;
41
+ lastFinishedAt: string | null;
42
+ failReason: string | null;
43
+ repeatInterval: string | null;
44
+ data: Record<string, any> | null;
45
+ runs: { queued: number; running: number; success: number; failed: number };
46
+ duration: {
47
+ totalRuns: number;
48
+ maxMs: number | null;
49
+ minMs: number | null;
50
+ avgMs: number | null;
51
+ firstRunAt: string | null;
52
+ lastRunAt: string | null;
53
+ };
54
+ }
55
+
56
+ interface LogItem {
57
+ id: string;
58
+ jobName: string;
59
+ status: 'success' | 'failed';
60
+ startAt: string;
61
+ endAt: string;
62
+ durationMs: number;
63
+ error: string | null;
64
+ logs: string | null;
65
+ data: Record<string, any> | null;
66
+ }
67
+
68
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
69
+
70
+ const STATE_COLOR: Record<string, string> = {
71
+ running: 'processing',
72
+ scheduled: 'default',
73
+ queued: 'warning',
74
+ completed: 'success',
75
+ failed: 'error',
76
+ repeating: 'purple',
77
+ paused: 'default',
78
+ };
79
+
80
+ function formatMs(ms: number | null): string {
81
+ if (ms === null) return '—';
82
+ if (ms < 1000) return `${ms}ms`;
83
+ const sec = ms / 1000;
84
+ if (sec < 60) return `${sec.toFixed(1)}s`;
85
+ const min = Math.floor(sec / 60);
86
+ const remain = Math.round(sec % 60);
87
+ return `${min}m ${remain}s`;
88
+ }
89
+
90
+ function formatTime(v: string | null): string {
91
+ return v ? new Date(v).toLocaleString() : '—';
92
+ }
93
+
94
+ /** 圆圈指示器 */
95
+ function RunDot({
96
+ count,
97
+ color,
98
+ label,
99
+ }: {
100
+ count: number;
101
+ color: string;
102
+ label: string;
103
+ }) {
104
+ const size = 28;
105
+ const hasCount = count > 0;
106
+ return (
107
+ <Tooltip title={`${label}: ${count}`}>
108
+ <span
109
+ style={{
110
+ display: 'inline-flex',
111
+ alignItems: 'center',
112
+ justifyContent: 'center',
113
+ verticalAlign: 'middle',
114
+ width: size,
115
+ height: size,
116
+ borderRadius: '50%',
117
+ border: `2.5px solid ${hasCount ? color : '#d9d9d9'}`,
118
+ fontSize: 11,
119
+ fontWeight: 600,
120
+ color: hasCount ? color : '#d9d9d9',
121
+ cursor: 'default',
122
+ lineHeight: 1,
123
+ }}
124
+ >
125
+ {hasCount ? count : ''}
126
+ </span>
127
+ </Tooltip>
128
+ );
129
+ }
130
+
131
+ // ─── Duration Bar Chart ───────────────────────────────────────────────────────
132
+
133
+ function DurationChart({
134
+ logs,
135
+ selectedIndex,
136
+ onSelect,
137
+ }: {
138
+ logs: LogItem[];
139
+ selectedIndex: number | null;
140
+ onSelect: (index: number) => void;
141
+ }) {
142
+ // 按时间正序(最早在左)
143
+ const sorted = useMemo(() => [...logs].reverse(), [logs]);
144
+
145
+ const option = useMemo(() => {
146
+ return {
147
+ tooltip: {
148
+ trigger: 'axis' as const,
149
+ formatter: (params: any) => {
150
+ const p = params[0];
151
+ return `${p.name}<br/>Duration: ${formatMs(p.value)}<br/>Status: ${sorted[p.dataIndex]?.status}`;
152
+ },
153
+ },
154
+ grid: { left: 50, right: 16, top: 16, bottom: 40 },
155
+ xAxis: {
156
+ type: 'category' as const,
157
+ data: sorted.map((l) =>
158
+ new Date(l.startAt).toLocaleString(undefined, {
159
+ month: 'short',
160
+ day: 'numeric',
161
+ hour: '2-digit',
162
+ minute: '2-digit',
163
+ }),
164
+ ),
165
+ axisLabel: { rotate: 30, fontSize: 10 },
166
+ },
167
+ yAxis: {
168
+ type: 'value' as const,
169
+ name: 'Duration',
170
+ axisLabel: {
171
+ formatter: (v: number) => formatMs(v),
172
+ },
173
+ },
174
+ series: [
175
+ {
176
+ type: 'bar',
177
+ data: sorted.map((l, i) => {
178
+ const isSuccess = l.status === 'success';
179
+ const baseColor = isSuccess ? '#52c41a' : '#ff4d4f';
180
+ const dimColor = isSuccess ? '#b7eb8f' : '#ffa39e';
181
+ const isSelected = selectedIndex !== null && i === selectedIndex;
182
+ const hasSel = selectedIndex !== null;
183
+ return {
184
+ value: l.durationMs,
185
+ itemStyle: {
186
+ color: hasSel ? (isSelected ? baseColor : dimColor) : baseColor,
187
+ borderColor: isSelected ? '#000' : 'transparent',
188
+ borderWidth: isSelected ? 2 : 0,
189
+ },
190
+ };
191
+ }),
192
+ barMaxWidth: 30,
193
+ cursor: 'pointer',
194
+ },
195
+ ],
196
+ };
197
+ }, [sorted, selectedIndex]);
198
+
199
+ const onEvents = useMemo(
200
+ () => ({
201
+ click: (params: any) => {
202
+ if (params.componentType === 'series') {
203
+ onSelect(params.dataIndex);
204
+ }
205
+ },
206
+ }),
207
+ [onSelect],
208
+ );
209
+
210
+ const { domRef } = useECharts({ option, onEvents });
211
+
212
+ return <div ref={domRef} style={{ width: '100%', height: 280 }} />;
213
+ }
214
+
215
+ // ─── AgendaJobShow ────────────────────────────────────────────────────────────
216
+
217
+ export default function AgendaJobShow() {
218
+ const { jobName: rawJobName } = useParams<{ jobName: string }>();
219
+ const jobName = decodeURIComponent(rawJobName || '');
220
+ const navigate = useNavigate();
221
+
222
+ const [detail, setDetail] = useState<JobDetail | null>(null);
223
+ const [loading, setLoading] = useState(true);
224
+
225
+ // Logs for chart
226
+ const [chartLogs, setChartLogs] = useState<LogItem[]>([]);
227
+ // Selected run index (in time-ascending order, i.e. reversed chartLogs)
228
+ const [selectedRunIndex, setSelectedRunIndex] = useState<number | null>(null);
229
+ const sortedLogs = useMemo(() => [...chartLogs].reverse(), [chartLogs]);
230
+ const selectedRun =
231
+ selectedRunIndex !== null ? sortedLogs[selectedRunIndex] : null;
232
+
233
+ // Chart filters
234
+ const [filterBefore, setFilterBefore] = useState<dayjs.Dayjs | null>(dayjs());
235
+ const [filterLimit, setFilterLimit] = useState(25);
236
+ const [filterStatus, setFilterStatus] = useState<string | undefined>();
237
+
238
+ // Fetch job detail
239
+ const fetchDetail = useCallback(async () => {
240
+ setLoading(true);
241
+ try {
242
+ const res = await customRequest<JobDetail>(
243
+ `agenda/job/${encodeURIComponent(jobName)}`,
244
+ 'GET',
245
+ );
246
+ setDetail(res);
247
+ } catch (e: any) {
248
+ message.error(e?.message ?? 'Failed to fetch job detail');
249
+ } finally {
250
+ setLoading(false);
251
+ }
252
+ }, [jobName]);
253
+
254
+ // Fetch chart logs with filters
255
+ const fetchChartLogs = useCallback(async () => {
256
+ try {
257
+ const params: Record<string, any> = {
258
+ jobName,
259
+ skip: 0,
260
+ limit: filterLimit,
261
+ };
262
+ if (filterBefore) params.before = filterBefore.toISOString();
263
+ if (filterStatus) params.status = filterStatus;
264
+ const res = await customRequest<{ logs: LogItem[]; total: number }>(
265
+ 'agenda/logs',
266
+ 'GET',
267
+ params,
268
+ );
269
+ setChartLogs(res.logs);
270
+ setSelectedRunIndex(null);
271
+ } catch {
272
+ // ignore
273
+ }
274
+ }, [jobName, filterLimit, filterBefore, filterStatus]);
275
+
276
+ useEffect(() => {
277
+ fetchDetail();
278
+ fetchChartLogs();
279
+ }, [fetchDetail, fetchChartLogs]);
280
+
281
+ // Actions
282
+ async function handleAction(
283
+ action: 'requeue' | 'delete' | 'disable' | 'enable',
284
+ ) {
285
+ if (!detail) return;
286
+ try {
287
+ const jobsRes = await customRequest<{ jobs: { id: string }[] }>(
288
+ 'agenda/jobs',
289
+ 'GET',
290
+ { job: jobName, limit: 100 },
291
+ );
292
+ const ids = jobsRes.jobs.map((j) => j.id);
293
+ if (!ids.length) {
294
+ message.warning('No matching jobs found');
295
+ return;
296
+ }
297
+ const res = await customRequest<{ affected: number }>(
298
+ `agenda/${action}`,
299
+ 'POST',
300
+ { jobIds: ids },
301
+ );
302
+ message.success(`操作成功,影响 ${res.affected} 条任务`);
303
+ if (action === 'delete') {
304
+ navigate(-1);
305
+ } else {
306
+ fetchDetail();
307
+ fetchChartLogs();
308
+ }
309
+ } catch (e: any) {
310
+ message.error(e?.message ?? '操作失败');
311
+ }
312
+ }
313
+
314
+ if (loading) {
315
+ return (
316
+ <div
317
+ style={{
318
+ display: 'flex',
319
+ justifyContent: 'center',
320
+ alignItems: 'center',
321
+ minHeight: 'calc(100vh - 64px)',
322
+ }}
323
+ >
324
+ <Spin size="large" />
325
+ </div>
326
+ );
327
+ }
328
+
329
+ if (!detail) {
330
+ return (
331
+ <div style={{ padding: 24 }}>
332
+ <Button icon={<ArrowLeftOutlined />} onClick={() => navigate(-1)}>
333
+ 返回
334
+ </Button>
335
+ <div style={{ marginTop: 24, textAlign: 'center', color: '#999' }}>
336
+ Job 不存在
337
+ </div>
338
+ </div>
339
+ );
340
+ }
341
+
342
+ const { runs, duration } = detail;
343
+ const totalRuns = runs.success + runs.failed;
344
+
345
+ return (
346
+ <div style={{ background: '#f0f2f5', minHeight: 'calc(100vh - 64px)' }}>
347
+ {/* Header */}
348
+ <Card style={{ marginBottom: 16 }}>
349
+ <div
350
+ style={{
351
+ display: 'flex',
352
+ justifyContent: 'space-between',
353
+ alignItems: 'center',
354
+ }}
355
+ >
356
+ <Space size="middle" align="center">
357
+ <Button
358
+ type="text"
359
+ icon={<ArrowLeftOutlined />}
360
+ onClick={() => navigate(-1)}
361
+ />
362
+ <div>
363
+ <Title level={4} style={{ margin: 0 }}>
364
+ {detail.name}
365
+ </Title>
366
+ <Space size={4} style={{ marginTop: 4 }}>
367
+ <Popconfirm
368
+ title={
369
+ detail.disabled ? '确定恢复此任务?' : '确定暂停此任务?'
370
+ }
371
+ okText="确认"
372
+ cancelText="取消"
373
+ onConfirm={() =>
374
+ handleAction(detail.disabled ? 'enable' : 'disable')
375
+ }
376
+ >
377
+ <span style={{ marginRight: 8 }}>
378
+ <Switch checked={!detail.disabled} />
379
+ </span>
380
+ </Popconfirm>
381
+ {detail.module && <Tag color="cyan">{detail.module}</Tag>}
382
+ <Badge
383
+ status={STATE_COLOR[detail.state] as any}
384
+ text={detail.state}
385
+ />
386
+ {detail.repeatInterval && (
387
+ <Tag color="purple">{detail.repeatInterval}</Tag>
388
+ )}
389
+ </Space>
390
+ </div>
391
+ </Space>
392
+ <Space>
393
+ <Popconfirm
394
+ title="确定立即执行此任务?"
395
+ okText="确认"
396
+ cancelText="取消"
397
+ onConfirm={() => handleAction('requeue')}
398
+ >
399
+ <Tooltip title="Trigger: 立即执行一次">
400
+ <Button icon={<PlayCircleOutlined />} />
401
+ </Tooltip>
402
+ </Popconfirm>
403
+ <Popconfirm
404
+ title="确定删除此任务?"
405
+ description="删除后无法恢复,请谨慎操作。"
406
+ okText="确认删除"
407
+ cancelText="取消"
408
+ okButtonProps={{ danger: true }}
409
+ onConfirm={() => handleAction('delete')}
410
+ >
411
+ <Tooltip title="Delete">
412
+ <Button danger icon={<DeleteOutlined />} />
413
+ </Tooltip>
414
+ </Popconfirm>
415
+ </Space>
416
+ </div>
417
+ </Card>
418
+
419
+ {/* Filter bar */}
420
+ <Card size="small" style={{ marginBottom: 16 }}>
421
+ <Space wrap size="middle" align="center">
422
+ <DatePicker
423
+ showTime
424
+ placeholder="截止时间"
425
+ value={filterBefore}
426
+ onChange={(v) => setFilterBefore(v)}
427
+ allowClear
428
+ style={{ width: 200 }}
429
+ />
430
+ <Select
431
+ value={filterLimit}
432
+ onChange={(v) => setFilterLimit(v)}
433
+ style={{ width: 80 }}
434
+ options={[10, 25, 50, 100].map((n) => ({
435
+ value: n,
436
+ label: String(n),
437
+ }))}
438
+ />
439
+ <Space size={6}>
440
+ {(
441
+ [
442
+ { key: undefined, label: 'All', color: undefined },
443
+ { key: 'queued', label: 'Queued', color: '#faad14' },
444
+ { key: 'success', label: 'Success', color: '#006d32' },
445
+ { key: 'running', label: 'Running', color: '#52c41a' },
446
+ { key: 'failed', label: 'Failed', color: '#ff4d4f' },
447
+ ] as const
448
+ ).map((item) => (
449
+ <Tag
450
+ key={item.label}
451
+ color={
452
+ filterStatus === item.key
453
+ ? (item.color ?? 'processing')
454
+ : undefined
455
+ }
456
+ style={{
457
+ cursor: 'pointer',
458
+ borderColor:
459
+ filterStatus === item.key
460
+ ? (item.color ?? '#1677ff')
461
+ : undefined,
462
+ }}
463
+ onClick={() => setFilterStatus(item.key)}
464
+ >
465
+ {item.label}
466
+ </Tag>
467
+ ))}
468
+ </Space>
469
+ <Button
470
+ type="link"
471
+ onClick={() => {
472
+ setFilterBefore(null);
473
+ setFilterLimit(25);
474
+ setFilterStatus(undefined);
475
+ }}
476
+ >
477
+ Clear Filters
478
+ </Button>
479
+ </Space>
480
+ </Card>
481
+
482
+ {/* Left: Chart | Right: Details */}
483
+ <Row gutter={16} style={{ marginBottom: 16 }}>
484
+ <Col span={10}>
485
+ <Card title="运行历史" size="small" style={{ height: '100%' }}>
486
+ {chartLogs.length > 0 ? (
487
+ <DurationChart
488
+ logs={chartLogs}
489
+ selectedIndex={selectedRunIndex}
490
+ onSelect={(i) =>
491
+ setSelectedRunIndex(selectedRunIndex === i ? null : i)
492
+ }
493
+ />
494
+ ) : (
495
+ <div
496
+ style={{
497
+ height: 280,
498
+ display: 'flex',
499
+ alignItems: 'center',
500
+ justifyContent: 'center',
501
+ color: '#999',
502
+ }}
503
+ >
504
+ 暂无运行记录
505
+ </div>
506
+ )}
507
+ </Card>
508
+ </Col>
509
+ <Col span={14}>
510
+ <Card size="small" style={{ height: '100%' }}>
511
+ {selectedRun ? (
512
+ <>
513
+ <div
514
+ style={{
515
+ display: 'flex',
516
+ justifyContent: 'space-between',
517
+ alignItems: 'center',
518
+ marginBottom: 12,
519
+ }}
520
+ >
521
+ <Title level={5} style={{ margin: 0, color: '#595959' }}>
522
+ Run Detail
523
+ </Title>
524
+ <Button
525
+ size="small"
526
+ type="link"
527
+ onClick={() => setSelectedRunIndex(null)}
528
+ >
529
+ 返回总览
530
+ </Button>
531
+ </div>
532
+ <Descriptions column={2} size="small">
533
+ <Descriptions.Item label="Status">
534
+ <Tag
535
+ color={
536
+ selectedRun.status === 'success' ? 'success' : 'error'
537
+ }
538
+ >
539
+ {selectedRun.status}
540
+ </Tag>
541
+ </Descriptions.Item>
542
+ <Descriptions.Item label="Duration">
543
+ {formatMs(selectedRun.durationMs)}
544
+ </Descriptions.Item>
545
+ <Descriptions.Item label="Start">
546
+ {formatTime(selectedRun.startAt)}
547
+ </Descriptions.Item>
548
+ <Descriptions.Item label="End">
549
+ {formatTime(selectedRun.endAt)}
550
+ </Descriptions.Item>
551
+ {selectedRun.error && (
552
+ <Descriptions.Item label="Error" span={2}>
553
+ <span style={{ color: '#ff4d4f' }}>
554
+ {selectedRun.error}
555
+ </span>
556
+ </Descriptions.Item>
557
+ )}
558
+ </Descriptions>
559
+
560
+ <Title level={5} style={{ marginTop: 16, color: '#595959' }}>
561
+ Handler Logs
562
+ </Title>
563
+ {selectedRun.logs ? (
564
+ <pre
565
+ style={{
566
+ margin: 0,
567
+ padding: 12,
568
+ background: '#fafafa',
569
+ border: '1px solid #f0f0f0',
570
+ borderRadius: 4,
571
+ fontSize: 12,
572
+ lineHeight: 1.6,
573
+ maxHeight: 300,
574
+ overflow: 'auto',
575
+ whiteSpace: 'pre-wrap',
576
+ wordBreak: 'break-all',
577
+ }}
578
+ >
579
+ {selectedRun.logs}
580
+ </pre>
581
+ ) : (
582
+ <div style={{ color: '#999' }}>暂无日志</div>
583
+ )}
584
+ </>
585
+ ) : (
586
+ <>
587
+ <Title level={5} style={{ marginTop: 0, color: '#595959' }}>
588
+ Runs Summary
589
+ </Title>
590
+ <Descriptions column={2} size="small">
591
+ <Descriptions.Item label="Total Runs">
592
+ {totalRuns}
593
+ </Descriptions.Item>
594
+ <Descriptions.Item label="First Run">
595
+ {formatTime(duration.firstRunAt)}
596
+ </Descriptions.Item>
597
+ <Descriptions.Item label="Success">
598
+ <span style={{ color: '#006d32' }}>{runs.success}</span>
599
+ </Descriptions.Item>
600
+ <Descriptions.Item label="Last Run">
601
+ {formatTime(duration.lastRunAt)}
602
+ </Descriptions.Item>
603
+ <Descriptions.Item label="Failed">
604
+ <span style={{ color: '#ff4d4f' }}>{runs.failed}</span>
605
+ </Descriptions.Item>
606
+ <Descriptions.Item label="Max Duration">
607
+ {formatMs(duration.maxMs)}
608
+ </Descriptions.Item>
609
+ <Descriptions.Item label="Avg Duration">
610
+ {formatMs(duration.avgMs)}
611
+ </Descriptions.Item>
612
+ <Descriptions.Item label="Min Duration">
613
+ {formatMs(duration.minMs)}
614
+ </Descriptions.Item>
615
+ </Descriptions>
616
+
617
+ <Title level={5} style={{ marginTop: 16, color: '#595959' }}>
618
+ Job Details
619
+ </Title>
620
+ <Descriptions column={2} size="small">
621
+ <Descriptions.Item label="Name">
622
+ {detail.name}
623
+ </Descriptions.Item>
624
+ <Descriptions.Item label="Module">
625
+ {detail.module ?? '—'}
626
+ </Descriptions.Item>
627
+ <Descriptions.Item label="State">
628
+ <Badge
629
+ status={STATE_COLOR[detail.state] as any}
630
+ text={detail.state}
631
+ />
632
+ </Descriptions.Item>
633
+ <Descriptions.Item label="Schedule">
634
+ {detail.repeatInterval ?? '—'}
635
+ </Descriptions.Item>
636
+ <Descriptions.Item label="Next Run">
637
+ {formatTime(detail.nextRunAt)}
638
+ </Descriptions.Item>
639
+ <Descriptions.Item label="Last Run">
640
+ {formatTime(detail.lastRunAt)}
641
+ </Descriptions.Item>
642
+ {detail.failReason && (
643
+ <Descriptions.Item label="Fail Reason" span={2}>
644
+ <span style={{ color: '#ff4d4f' }}>
645
+ {detail.failReason}
646
+ </span>
647
+ </Descriptions.Item>
648
+ )}
649
+ {detail.data && (
650
+ <Descriptions.Item label="Data" span={2}>
651
+ <pre
652
+ style={{
653
+ margin: 0,
654
+ fontSize: 12,
655
+ maxHeight: 120,
656
+ overflow: 'auto',
657
+ }}
658
+ >
659
+ {JSON.stringify(detail.data, null, 2)}
660
+ </pre>
661
+ </Descriptions.Item>
662
+ )}
663
+ </Descriptions>
664
+ </>
665
+ )}
666
+ </Card>
667
+ </Col>
668
+ </Row>
669
+ </div>
670
+ );
671
+ }