refactor(quality): Q11 前端巨型组件拆分 — PipelineRunDetail 678L→273L (-60%), 提取 types + StageProgressBar + RunsTable + LogConsole 4 个独立文件
This commit is contained in:
1 parent
2aed2bc218
commit
76aa8817c0
8 files changed
+632
-465
No files matched your search
@@ -129,7 +129,7 @@
|
|||||||
| ~~`api.ts` 1324 行/352 方法~~ → 已拆分为 12 个域文件 (api/kubernetes.ts, deploy.ts, monitor.ts 等) | ✅ 已解决 | 前端 | ~~P0~~ done |
|
| ~~`api.ts` 1324 行/352 方法~~ → 已拆分为 12 个域文件 (api/kubernetes.ts, deploy.ts, monitor.ts 等) | ✅ 已解决 | 前端 | ~~P0~~ done |
|
||||||
| 前端 **1026 处** `any` 类型 | 类型安全缺失,重构风险高 | 前端全局 | ~~P0~~ **done** (any 1076→40, unused 25→6, console 93→1, tsc --noEmit=0) |
|
| 前端 **1026 处** `any` 类型 | 类型安全缺失,重构风险高 | 前端全局 | ~~P0~~ **done** (any 1076→40, unused 25→6, console 93→1, tsc --noEmit=0) |
|
||||||
| ~~K8s 双路由体系(集群域+Kuboard 域)~~ → code 路由统一为主,ID 路由标记 deprecated 向后兼容 | ✅ 已解决 | 后端 | ~~P1~~ done |
|
| ~~K8s 双路由体系(集群域+Kuboard 域)~~ → code 路由统一为主,ID 路由标记 deprecated 向后兼容 | ✅ 已解决 | 后端 | ~~P1~~ done |
|
||||||
| 25 个 ≥500 行巨型组件 | 拆分困难 | 前端 | P1 → **Q6+Q7+Q8+Q9+Q10 done: 最大组件 ResourceVisualEditor 882L→282L (-68%) + AgentCLI 814L→521L (-36%) + SystemSettingsTab 754L→173L (-77%) + NamespaceResourcePage 719L→300L (-58%) + ApprovalWorkflowPage 705L→167L (-76%),累计提取 20 个独立组件文件** |
|
| 25 个 ≥500 行巨型组件 | 拆分困难 | 前端 | P1 → **Q6+Q7+Q8+Q9+Q10+Q11 done: 最大组件 ResourceVisualEditor 882L→282L (-68%) + AgentCLI 814L→521L (-36%) + SystemSettingsTab 754L→173L (-77%) + NamespaceResourcePage 719L→300L (-58%) + ApprovalWorkflowPage 705L→167L (-76%) + PipelineRunDetail 678L→273L (-60%),累计提取 24 个独立组件文件** |
|
||||||
|
|
||||||
### 3.2 🟡 中优先级
|
### 3.2 🟡 中优先级
|
||||||
|
|
||||||
|
|||||||
+34
@@ -482,6 +482,7 @@ services/api.ts (1324L)
|
|||||||
| Q8 | 前端巨型组件拆分(SystemSettingsTab 754L→173L,提取通用/安全/LDAP 3 个子 Tab) | P0 | **done** |
|
| Q8 | 前端巨型组件拆分(SystemSettingsTab 754L→173L,提取通用/安全/LDAP 3 个子 Tab) | P0 | **done** |
|
||||||
| Q9 | 前端巨型组件拆分(NamespaceResourcePage 719L→300L,提取 RESOURCE_CONFIG + PodDetailDialog) | P0 | **done** |
|
| Q9 | 前端巨型组件拆分(NamespaceResourcePage 719L→300L,提取 RESOURCE_CONFIG + PodDetailDialog) | P0 | **done** |
|
||||||
| Q10 | 前端巨型组件拆分(ApprovalWorkflowPage 705L→167L,提取 types + UserSelectInline + WorkflowEditorModal + WorkflowCard) | P0 | **done** |
|
| Q10 | 前端巨型组件拆分(ApprovalWorkflowPage 705L→167L,提取 types + UserSelectInline + WorkflowEditorModal + WorkflowCard) | P0 | **done** |
|
||||||
|
| Q11 | 前端巨型组件拆分(PipelineRunDetail 678L→273L,提取 types + StageProgressBar + RunsTable + LogConsole) | P0 | **done** |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -775,3 +776,36 @@ pending → in_progress → done / blocked
|
|||||||
- [x] 17/17 集成回归测试通过(零回归)
|
- [x] 17/17 集成回归测试通过(零回归)
|
||||||
- [x] Docker 部署 healthy
|
- [x] Docker 部署 healthy
|
||||||
- [x] 命名空间资源列表页 13 种资源类型功能不变(全部可正常渲染/操作)
|
- [x] 命名空间资源列表页 13 种资源类型功能不变(全部可正常渲染/操作)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q11: 前端巨型组件拆分 — PipelineRunDetail [done]
|
||||||
|
|
||||||
|
**问题**: `PipelineRunDetail.tsx`(678 行)是 Q10 拆分后项目最大的业务组件之一(排除 UI 库 sidebar/data-table),包含流水线运行详情页面的全部逻辑:阶段定义/状态常量/工具函数、交互式阶段进度条(6 阶段点击过滤)、执行记录表(多选/停止/浏览/状态徽章)、WebSocket 实时日志 Console(阶段摘要 + 彩色日志 + 实时输出动画)。单文件过大导致维护困难、代码导航低效。
|
||||||
|
|
||||||
|
**已完成 (2026-07-07)**:
|
||||||
|
|
||||||
|
将类型/常量/工具函数、阶段进度条、执行记录表、日志 Console 提取为 `pipeline-run-detail/` 子目录下的独立文件:
|
||||||
|
|
||||||
|
| 新文件 | 行数 | 职责 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `types.ts` | 119 | STAGE_DEFS/STATUS_BADGES/STATUS_ICONS 常量 + PipelineRun/LogEntry/StageStatus 接口 + formatDuration/formatTime/parseStageStatuses/getRunStageStats/getStageLogSummary 工具函数 + JsonData re-export |
|
||||||
|
| `StageProgressBar.tsx` | 89 | 交互式阶段进度条(6 阶段按钮 + 状态颜色 + 点击切换日志过滤 + ChevronRight 连接器) |
|
||||||
|
| `RunsTable.tsx` | 131 | 执行记录表(loading/空状态/checkbox 多选 + 状态徽章 + 触发方式 + 用户/时长/操作列) |
|
||||||
|
| `LogConsole.tsx` | 183 | 阶段日志 Console(CardHeader 元信息 + 阶段摘要 + 彩色日志渲染 + 阶段分隔符 + 实时输出动画) |
|
||||||
|
|
||||||
|
主文件 `PipelineRunDetail.tsx` 从 **678 行降至 273 行**(-60%),仅保留:状态管理(pipeline/runs/selectedRun/logs/loading/selectedStage/pageSize/selectedIds/deleting/browserOpen/browserRun)、refs(logEndRef/wsRef)、数据加载(fetchPipeline/fetchRuns/fetchLogs)、WebSocket 生命周期(connectWS + onmessage init/done/log 分发)、自动刷新/自动滚动 effects、CRUD 操作(triggerRun/stopRun/batchDelete/toggleSelect/toggleAll)、派生数据(filteredLogs/stageSummary/successCount/runningCount)、PageShell 布局 + 3 个子组件集成。
|
||||||
|
|
||||||
|
**提取原则**: types 文件为纯数据/类型(零 React 依赖,仅 lucide 图标类型引用);StageProgressBar 为受控组件(接收 selectedRun/selectedStage/onSelectStage props,内部用 getRunStageStats 计算);RunsTable 为受控列表组件(接收 runs + 10 个回调 props);LogConsole 为纯展示组件(接收 selectedRun/pipeline/pipelineCode/filteredLogs/stageSummary/logEndRef props,内部用 getRunStageStats 计算阶段状态)。零逻辑变更。
|
||||||
|
|
||||||
|
**验收标准**:
|
||||||
|
- [x] PipelineRunDetail.tsx 从 678 行降至 **273 行**(-60%)
|
||||||
|
- [x] 4 个提取的文件全部 < 185 行(最大 LogConsole 183 行)
|
||||||
|
- [x] `tsc -b` 零错误
|
||||||
|
- [x] `npm run build` 零错误(✓ built in 17.39s)
|
||||||
|
- [x] ESLint errors 保持 **0**(清理 1 个 unused var + 2 个冗余 disable 指令)
|
||||||
|
- [x] ESLint warnings 保持 **59**(无新增)
|
||||||
|
- [x] `go build` 零错误
|
||||||
|
- [x] 17/17 集成回归测试通过(零回归)
|
||||||
|
- [x] Docker 部署 healthy
|
||||||
|
- [x] 流水线运行详情页功能不变(阶段进度条/执行记录表/日志 Console/文件浏览器全部正常)
|
||||||
+16
@@ -1247,3 +1247,19 @@ python3 /srv/aiops/tests/integration_test.py --host 127.0.0.1 --port 8080 | tail
|
|||||||
- [x] U5: ESLint errors 保持 **0**(approval-workflow/ 目录零 warnings)
|
- [x] U5: ESLint errors 保持 **0**(approval-workflow/ 目录零 warnings)
|
||||||
- [x] U6: `go build` 零错误
|
- [x] U6: `go build` 零错误
|
||||||
- [x] U7: 17/17 集成回归测试通过(零回归)
|
- [x] U7: 17/17 集成回归测试通过(零回归)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P0-Q11: 前端巨型组件拆分 — PipelineRunDetail
|
||||||
|
|
||||||
|
### 通过标准
|
||||||
|
- [x] PipelineRunDetail.tsx 从 678 行降至 **273 行**(-60%)
|
||||||
|
- [x] 4 个提取的文件全部 < 185 行(types.ts 119L / StageProgressBar.tsx 89L / RunsTable.tsx 131L / LogConsole.tsx 183L)
|
||||||
|
- [x] `tsc -b` 零错误
|
||||||
|
- [x] `npm run build` 零错误(✓ built in 17.39s)
|
||||||
|
- [x] ESLint errors 保持 **0**(清理 1 个 unused var `stageStats` + 2 个冗余 `eslint-disable` 指令)
|
||||||
|
- [x] ESLint warnings 保持 **59**(无新增)
|
||||||
|
- [x] `go build` 零错误
|
||||||
|
- [x] 17/17 集成回归测试通过(零回归)
|
||||||
|
- [x] Docker 部署 healthy
|
||||||
|
- [x] 流水线运行详情页功能不变(阶段进度条点击过滤 / 执行记录表多选+停止 / 实时日志 Console / 文件浏览器)
|
||||||
@@ -1,157 +1,35 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import React from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Play, Square, RefreshCw, Clock, User, AlertCircle, CheckCircle2,
|
Play, RefreshCw, Trash2, History,
|
||||||
Loader2, XCircle, History, FolderOpen, Trash2, ChevronRight as ChevronRightSmall,
|
|
||||||
GitBranch, Box, Container, Rocket, ShieldCheck,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { pipelineApi } from '@/api';
|
import { pipelineApi } from '@/api';
|
||||||
import { FileBrowserDialog } from './FileBrowserDialog';
|
import { FileBrowserDialog } from './FileBrowserDialog';
|
||||||
import { PageShell } from '@/components/layout/PageShell';
|
import { PageShell } from '@/components/layout/PageShell';
|
||||||
import { StatStrip } from '@/components/ui/stat-strip';
|
import { StatStrip } from '@/components/ui/stat-strip';
|
||||||
import type { JsonData } from '@/types/common';
|
import type { JsonData } from '@/types/common';
|
||||||
|
import {
|
||||||
// ═══════════ 阶段定义 (与 PipelineManager 共用) ═══════════
|
type PipelineRun, type LogEntry,
|
||||||
const STAGE_DEFS = [
|
getStageLogSummary,
|
||||||
{ key: 'checkout', label: '代码检出', icon: GitBranch, color: '#6366F1', order: 1 },
|
} from './pipeline-run-detail/types';
|
||||||
{ key: 'build', label: '构建', icon: Box, color: '#F59E0B', order: 2 },
|
import { StageProgressBar } from './pipeline-run-detail/StageProgressBar';
|
||||||
{ key: 'test', label: '测试', icon: CheckCircle2,color: '#10B981', order: 3 },
|
import { RunsTable } from './pipeline-run-detail/RunsTable';
|
||||||
{ key: 'image', label: '镜像打包', icon: Container, color: '#06B6D4', order: 4 },
|
import { LogConsole } from './pipeline-run-detail/LogConsole';
|
||||||
{ key: 'deploy', label: '部署', icon: Rocket, color: '#EF4444', order: 5 },
|
|
||||||
{ key: 'verify', label: '验证', icon: ShieldCheck, color: '#3B82F6', order: 6 },
|
|
||||||
].sort((a, b) => a.order - b.order);
|
|
||||||
|
|
||||||
interface PipelineRun {
|
|
||||||
id: number;
|
|
||||||
pipeline_id: number;
|
|
||||||
build_number: number;
|
|
||||||
status: string;
|
|
||||||
branch: string;
|
|
||||||
commit_id: string;
|
|
||||||
commit_msg: string;
|
|
||||||
author: string;
|
|
||||||
triggered_by: string;
|
|
||||||
image_tag: string;
|
|
||||||
duration: number;
|
|
||||||
stages: string;
|
|
||||||
started_at: string;
|
|
||||||
finished_at: string;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LogEntry {
|
|
||||||
time: string;
|
|
||||||
level: string;
|
|
||||||
stage: string;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StageStatus {
|
|
||||||
name: string;
|
|
||||||
status: string; // success, failed, running, pending
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_BADGES: Record<string, string> = {
|
|
||||||
success: 'bg-emerald-500/20 dg-text-green border-emerald-500/30',
|
|
||||||
failed: 'bg-red-500/20 dg-text-red border-red-500/30',
|
|
||||||
running: 'bg-blue-500/20 dg-text-blue border-blue-500/30',
|
|
||||||
pending: 'bg-[var(--dg-bg-active)]/20 dg-text-secondary border-[var(--dg-border)]',
|
|
||||||
cancelled: 'bg-amber-500/20 dg-text-yellow border-amber-500/30',
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_ICONS: JsonData = {
|
|
||||||
success: CheckCircle2,
|
|
||||||
failed: XCircle,
|
|
||||||
running: Loader2,
|
|
||||||
pending: Clock,
|
|
||||||
cancelled: AlertCircle,
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatDuration(seconds: number): string {
|
|
||||||
if (!seconds) return '-';
|
|
||||||
if (seconds < 60) return `${seconds}s`;
|
|
||||||
const m = Math.floor(seconds / 60);
|
|
||||||
const s = seconds % 60;
|
|
||||||
return `${m}m${s}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(ts: string): string {
|
|
||||||
if (!ts) return '-';
|
|
||||||
const d = new Date(ts);
|
|
||||||
return d.toLocaleString('zh-CN', {
|
|
||||||
month: '2-digit', day: '2-digit',
|
|
||||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseStageStatuses(raw: string): StageStatus[] {
|
|
||||||
if (!raw) return [];
|
|
||||||
try {
|
|
||||||
const s = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
||||||
return Array.isArray(s) ? s : [];
|
|
||||||
} catch { return []; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 从 run.stages (JSON) 推导每个阶段的运行状态 */
|
|
||||||
function getRunStageStats(run: PipelineRun | null): Map<string, string> {
|
|
||||||
const map = new Map<string, string>();
|
|
||||||
if (!run) return map;
|
|
||||||
// 如果 run.stages 有数据,直接用
|
|
||||||
const statuses = parseStageStatuses(run.stages);
|
|
||||||
statuses.forEach(s => {
|
|
||||||
if (s.name || (s as JsonData).type) {
|
|
||||||
const key = (s as JsonData).type || (STAGE_DEFS.find(d => d.label === s.name)?.key);
|
|
||||||
if (key) map.set(key, s.status);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// 如果 stages 为空但 run 已结束,从整体状态推导
|
|
||||||
if (map.size === 0 && run.status === 'success') {
|
|
||||||
STAGE_DEFS.forEach(d => map.set(d.key, 'success'));
|
|
||||||
} else if (map.size === 0 && run.status === 'failed') {
|
|
||||||
STAGE_DEFS.forEach((d, i) => map.set(d.key, i === 0 ? 'success' : 'pending'));
|
|
||||||
} else if (map.size === 0 && run.status === 'running') {
|
|
||||||
map.set('checkout', 'running');
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 从日志中提取每个阶段的首条日志内容(阶段摘要) */
|
|
||||||
function getStageLogSummary(logs: LogEntry[], stageKey: string): string {
|
|
||||||
const first = logs.find(l => l.stage === stageKey);
|
|
||||||
if (first) return first.message;
|
|
||||||
const def = STAGE_DEFS.find(d => d.key === stageKey);
|
|
||||||
if (def) return `${def.label}...`;
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PipelineRunDetail() {
|
export default function PipelineRunDetail() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { code: pipelineCode } = useParams<{ code: string }>();
|
const { code: pipelineCode } = useParams<{ code: string }>();
|
||||||
|
|
||||||
// 阶段名称国际化映射
|
|
||||||
const stageLabel = (key: string) => {
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
checkout: t('deploy.stage.checkout'),
|
|
||||||
build: t('deploy.stage.build'),
|
|
||||||
test: t('deploy.stage.test'),
|
|
||||||
image: t('deploy.stage.image'),
|
|
||||||
deploy: t('deploy.stage.deploy'),
|
|
||||||
verify: t('common.verify'),
|
|
||||||
};
|
|
||||||
return map[key] || key;
|
|
||||||
};
|
|
||||||
const [pipeline, setPipeline] = useState<JsonData>(null);
|
const [pipeline, setPipeline] = useState<JsonData>(null);
|
||||||
const [runs, setRuns] = useState<PipelineRun[]>([]);
|
const [runs, setRuns] = useState<PipelineRun[]>([]);
|
||||||
const [selectedRun, setSelectedRun] = useState<PipelineRun | null>(null);
|
const [selectedRun, setSelectedRun] = useState<PipelineRun | null>(null);
|
||||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [selectedStage, setSelectedStage] = useState<string | null>(null); // null = 全部
|
const [selectedStage, setSelectedStage] = useState<string | null>(null);
|
||||||
const [pageSize, setPageSize] = useState(10);
|
const [pageSize, setPageSize] = useState(10);
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
@@ -194,27 +72,12 @@ export default function PipelineRunDetail() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hasRunning = runs.some(r => r.status === 'running');
|
const hasRunning = runs.some(r => r.status === 'running');
|
||||||
if (!hasRunning) return;
|
if (!hasRunning) return;
|
||||||
const t = setInterval(fetchRuns, 5000);
|
const timer = setInterval(fetchRuns, 5000);
|
||||||
return () => clearInterval(t);
|
return () => clearInterval(timer);
|
||||||
}, [runs, fetchRuns]);
|
}, [runs, fetchRuns]);
|
||||||
|
|
||||||
// 加载日志
|
// WebSocket 连接(运行中实时日志)
|
||||||
const fetchLogs = useCallback(async (run: PipelineRun) => {
|
const connectWS = useCallback((runId: number) => {
|
||||||
try {
|
|
||||||
const data: JsonData = await pipelineApi.getRunLogStream(run.id);
|
|
||||||
if (data?.logs) {
|
|
||||||
const parsed = typeof data.logs === 'string' ? JSON.parse(data.logs) : data.logs;
|
|
||||||
setLogs(Array.isArray(parsed) ? parsed : []);
|
|
||||||
} else {
|
|
||||||
setLogs([]);
|
|
||||||
}
|
|
||||||
if (run.status === 'running') connectWS(run.id);
|
|
||||||
} catch { setLogs([]); }
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// WebSocket
|
|
||||||
const connectWS = (runId: number) => {
|
|
||||||
if (wsRef.current) wsRef.current.close();
|
if (wsRef.current) wsRef.current.close();
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
@@ -236,7 +99,21 @@ export default function PipelineRunDetail() {
|
|||||||
ws.onclose = () => { wsRef.current = null; };
|
ws.onclose = () => { wsRef.current = null; };
|
||||||
ws.onerror = () => { wsRef.current = null; };
|
ws.onerror = () => { wsRef.current = null; };
|
||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
};
|
}, [fetchRuns]);
|
||||||
|
|
||||||
|
// 加载日志
|
||||||
|
const fetchLogs = useCallback(async (run: PipelineRun) => {
|
||||||
|
try {
|
||||||
|
const data: JsonData = await pipelineApi.getRunLogStream(run.id);
|
||||||
|
if (data?.logs) {
|
||||||
|
const parsed = typeof data.logs === 'string' ? JSON.parse(data.logs) : data.logs;
|
||||||
|
setLogs(Array.isArray(parsed) ? parsed : []);
|
||||||
|
} else {
|
||||||
|
setLogs([]);
|
||||||
|
}
|
||||||
|
if (run.status === 'running') connectWS(run.id);
|
||||||
|
} catch { setLogs([]); }
|
||||||
|
}, [connectWS]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedRun) {
|
if (selectedRun) {
|
||||||
@@ -246,12 +123,12 @@ export default function PipelineRunDetail() {
|
|||||||
return () => { if (wsRef.current) wsRef.current.close(); };
|
return () => { if (wsRef.current) wsRef.current.close(); };
|
||||||
}, [selectedRun, fetchLogs]);
|
}, [selectedRun, fetchLogs]);
|
||||||
|
|
||||||
// 自动滚动
|
// 自动滚动到底部
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
}, [logs]);
|
}, [logs]);
|
||||||
|
|
||||||
// 触发
|
// 触发新运行
|
||||||
const triggerRun = async () => {
|
const triggerRun = async () => {
|
||||||
try {
|
try {
|
||||||
await pipelineApi.triggerByCode(pipelineCode);
|
await pipelineApi.triggerByCode(pipelineCode);
|
||||||
@@ -260,7 +137,7 @@ export default function PipelineRunDetail() {
|
|||||||
} catch { toast.error(t('deploy.toast.triggerFailed')); }
|
} catch { toast.error(t('deploy.toast.triggerFailed')); }
|
||||||
};
|
};
|
||||||
|
|
||||||
// 停止
|
// 停止运行
|
||||||
const stopRun = async (runId: number) => {
|
const stopRun = async (runId: number) => {
|
||||||
try {
|
try {
|
||||||
await pipelineApi.stopRunByCode(pipelineCode, runId);
|
await pipelineApi.stopRunByCode(pipelineCode, runId);
|
||||||
@@ -294,23 +171,9 @@ export default function PipelineRunDetail() {
|
|||||||
else setSelectedIds(new Set(runs.map(r => r.id)));
|
else setSelectedIds(new Set(runs.map(r => r.id)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const triggerLabel = (run: PipelineRun) => {
|
// ═══════════ 派生数据 ═══════════
|
||||||
if (run.triggered_by === 'system') return 'Cron';
|
const filteredLogs = selectedStage ? logs.filter(l => l.stage === selectedStage) : logs;
|
||||||
if (run.triggered_by === 'git') return 'Git Push';
|
|
||||||
return run.triggered_by || 'Manual';
|
|
||||||
};
|
|
||||||
|
|
||||||
// ═══════════ 阶段状态映射 ═══════════
|
|
||||||
const stageStats = getRunStageStats(selectedRun);
|
|
||||||
|
|
||||||
// ═══════════ 过滤日志(按阶段) ═══════════
|
|
||||||
const filteredLogs = selectedStage
|
|
||||||
? logs.filter(l => l.stage === selectedStage)
|
|
||||||
: logs;
|
|
||||||
|
|
||||||
// 获取当前选中阶段的摘要日志
|
|
||||||
const stageSummary = selectedStage ? getStageLogSummary(logs, selectedStage) : '';
|
const stageSummary = selectedStage ? getStageLogSummary(logs, selectedStage) : '';
|
||||||
|
|
||||||
const successCount = runs.filter(r => r.status === 'success').length;
|
const successCount = runs.filter(r => r.status === 'success').length;
|
||||||
const runningCount = runs.filter(r => r.status === 'running').length;
|
const runningCount = runs.filter(r => r.status === 'running').length;
|
||||||
|
|
||||||
@@ -364,305 +227,37 @@ export default function PipelineRunDetail() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* ═══════════ 阶段进度条 (交互式) ═══════════ */}
|
{/* ═══════════ 阶段进度条 (交互式) ═══════════ */}
|
||||||
<div className="flex items-center gap-0 px-4 py-3 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] overflow-x-auto">
|
<StageProgressBar
|
||||||
{STAGE_DEFS.map((def, i) => {
|
selectedRun={selectedRun}
|
||||||
const status = stageStats.get(def.key) || '';
|
selectedStage={selectedStage}
|
||||||
const isSuccess = status === 'success' || status === 'completed';
|
onSelectStage={setSelectedStage}
|
||||||
const isRunning = status === 'running' || status === 'in_progress';
|
/>
|
||||||
const isFailed = status === 'failed' || status === 'error';
|
|
||||||
const isActive = selectedStage === def.key;
|
|
||||||
const _isSelected = selectedStage === def.key;
|
|
||||||
const hasData = stageStats.size > 0;
|
|
||||||
|
|
||||||
// 颜色: 失败红 / 运行中蓝 / 成功绿 / 未运行灰
|
|
||||||
const color = isFailed ? '#EF4444'
|
|
||||||
: isRunning ? '#3B82F6'
|
|
||||||
: isSuccess ? '#207060' // teal 绿 (OCR 设计稿色)
|
|
||||||
: '#6B7280';
|
|
||||||
const bgColor = isFailed ? '#EF444420'
|
|
||||||
: isRunning ? '#3B82F620'
|
|
||||||
: isSuccess ? '#20706020'
|
|
||||||
: '#6B728010';
|
|
||||||
const borderColor = isActive ? color : (color + '50');
|
|
||||||
|
|
||||||
const Icon = def.icon;
|
|
||||||
const icon = isFailed ? <XCircle className="h-3.5 w-3.5" />
|
|
||||||
: isRunning ? <Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
: isSuccess ? <CheckCircle2 className="h-3.5 w-3.5" />
|
|
||||||
: hasData ? <span className="text-[10px] opacity-40">○</span>
|
|
||||||
: <Icon className="h-3.5 w-3.5 opacity-40" />;
|
|
||||||
|
|
||||||
const isLast = i === STAGE_DEFS.length - 1;
|
|
||||||
const borderRadius = i === 0 ? '8px 0 0 8px' : isLast ? '0 8px 8px 0' : '0';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={def.key} className="flex items-center">
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedStage(selectedStage === def.key ? null : def.key)}
|
|
||||||
title={`${stageLabel(def.key)}${status ? ` (${status})` : ''} — ${t('deploy.runDetail.clickToViewLog')}`}
|
|
||||||
className={`flex items-center gap-1.5 px-3 py-2 text-[12px] font-semibold border shadow-sm transition-all cursor-pointer
|
|
||||||
${isActive ? 'ring-1 ring-offset-0 scale-[1.02]' : 'hover:brightness-110'}`}
|
|
||||||
style={{
|
|
||||||
backgroundColor: bgColor,
|
|
||||||
borderColor,
|
|
||||||
color,
|
|
||||||
borderRadius,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
<span>{stageLabel(def.key)}</span>
|
|
||||||
</button>
|
|
||||||
{!isLast && (
|
|
||||||
<div className="flex items-center justify-center w-5 h-5 -mx-px z-10">
|
|
||||||
<ChevronRightSmall className="h-3.5 w-3.5" style={{ color: color + '99' }} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{selectedRun && (
|
|
||||||
<span className="ml-auto text-[10px] text-[var(--dg-text-secondary)] opacity-40 shrink-0 pl-3 whitespace-nowrap">
|
|
||||||
{selectedStage ? stageLabel(selectedStage) : t('deploy.pipeline.allStages')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ═══════════ 主内容区: 执行记录表 + 日志 ═══════════ */}
|
{/* ═══════════ 主内容区: 执行记录表 + 日志 ═══════════ */}
|
||||||
<div className="flex-1 flex flex-col min-h-0">
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
{/* ─── 执行记录表 ─── */}
|
<RunsTable
|
||||||
<div className="rounded-md border border-[var(--dg-border)] overflow-hidden mb-4">
|
runs={runs}
|
||||||
{loading && runs.length === 0 ? (
|
loading={loading}
|
||||||
<div className="text-center py-12">
|
selectedRun={selectedRun}
|
||||||
<Loader2 className="w-6 h-6 animate-spin mx-auto mb-2 text-[var(--dg-text-muted)]" />
|
selectedIds={selectedIds}
|
||||||
{t('common.loading')}
|
onSelectRun={setSelectedRun}
|
||||||
</div>
|
onToggleSelect={toggleSelect}
|
||||||
) : runs.length === 0 ? (
|
onToggleAll={toggleAll}
|
||||||
<div className="text-center py-12 border border-dashed border-[var(--dg-border)] rounded-lg m-4">
|
onStopRun={stopRun}
|
||||||
<History className="w-10 h-10 mx-auto mb-3 text-[var(--dg-text-muted)]" />
|
onBrowse={(run) => { setBrowserRun({ id: run.id, buildNumber: run.build_number }); setBrowserOpen(true); }}
|
||||||
<p className="text-[var(--dg-text-secondary)]">{t('deploy.run.noRecordsHint')}</p>
|
/>
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
|
||||||
<tr>
|
|
||||||
<th className="px-2 py-2.5 w-10">
|
|
||||||
<input type="checkbox" checked={selectedIds.size === runs.length && runs.length > 0}
|
|
||||||
onChange={toggleAll} className="rounded" />
|
|
||||||
</th>
|
|
||||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] w-16">{t('deploy.pipeline.buildNumber')}</th>
|
|
||||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] w-20">{t('deploy.table.status')}</th>
|
|
||||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.table.trigger')}</th>
|
|
||||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.table.user')}</th>
|
|
||||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.table.duration')}</th>
|
|
||||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] hidden md:table-cell">{t('common.createdAt')}</th>
|
|
||||||
<th className="px-3 py-2.5 text-right font-medium text-[var(--dg-text-secondary)] w-32">{t('common.actions')}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{runs.map(run => {
|
|
||||||
const StatusIcon = STATUS_ICONS[run.status] || Clock;
|
|
||||||
return (
|
|
||||||
<tr key={run.id}
|
|
||||||
className={`border-b border-[var(--dg-border)] cursor-pointer transition-colors
|
|
||||||
${selectedRun?.id === run.id ? 'bg-[var(--dg-blue)]/10' : 'hover:bg-[var(--dg-bg-tertiary)]/50'}`}
|
|
||||||
onClick={() => { setSelectedRun(run); }}>
|
|
||||||
<td className="px-2 py-2.5" onClick={e => e.stopPropagation()}>
|
|
||||||
<input type="checkbox" checked={selectedIds.has(run.id)}
|
|
||||||
onChange={() => toggleSelect(run.id)} className="rounded" />
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2.5 font-mono font-semibold text-[var(--dg-text-primary)]">
|
|
||||||
#{run.build_number}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2.5">
|
|
||||||
<Badge className={`text-xs ${STATUS_BADGES[run.status] || ''} flex items-center gap-1 w-fit`}>
|
|
||||||
<StatusIcon className={`w-3 h-3 ${run.status === 'running' ? 'animate-spin' : ''}`} />
|
|
||||||
{run.status === 'success' ? t('deploy.status.success')
|
|
||||||
: run.status === 'failed' ? t('deploy.status.failed')
|
|
||||||
: run.status === 'running' ? t('deploy.runDetail.executing')
|
|
||||||
: run.status === 'cancelled' ? t('deploy.status.cancelled')
|
|
||||||
: t('deploy.status.pending')}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2.5">
|
|
||||||
<Badge variant="outline" className="text-xs">{triggerLabel(run)}</Badge>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2.5 text-[var(--dg-text-secondary)] flex items-center gap-1">
|
|
||||||
<User className="w-3 h-3" />
|
|
||||||
{run.triggered_by || 'admin'}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2.5 text-[var(--dg-text-secondary)] font-mono text-xs">
|
|
||||||
{run.status === 'running' ? (
|
|
||||||
<span className="flex items-center gap-1 dg-text-blue">
|
|
||||||
<Loader2 className="w-3 h-3 animate-spin" />{t('deploy.pipeline.executing')}
|
|
||||||
</span>
|
|
||||||
) : formatDuration(run.duration)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2.5 text-[var(--dg-text-muted)] text-xs hidden md:table-cell">
|
|
||||||
{formatTime(run.created_at)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2.5 text-right" onClick={e => e.stopPropagation()}>
|
|
||||||
<div className="flex justify-end gap-1">
|
|
||||||
<Button variant="ghost" size="sm" className="h-7 text-xs"
|
|
||||||
onClick={() => setSelectedRun(run)}>
|
|
||||||
{t('deploy.label.log')}
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="sm" className="h-7 text-xs"
|
|
||||||
onClick={() => { setBrowserRun({ id: run.id, buildNumber: run.build_number }); setBrowserOpen(true); }}>
|
|
||||||
<FolderOpen className="w-3.5 h-3.5 mr-1" />{t('deploy.label.browse')}
|
|
||||||
</Button>
|
|
||||||
{run.status === 'running' && (
|
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500"
|
|
||||||
onClick={() => stopRun(run.id)}>
|
|
||||||
<Square className="w-3.5 h-3.5" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ─── 阶段日志 Console ─── */}
|
{/* ═══════════ 阶段日志 Console ═══════════ */}
|
||||||
{selectedRun && (
|
{selectedRun && (
|
||||||
<Card className="flex-1 flex flex-col min-h-0 border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]">
|
<LogConsole
|
||||||
<CardHeader className="py-3 px-4 flex flex-row items-center justify-between border-b border-[var(--dg-border)]">
|
selectedRun={selectedRun}
|
||||||
<div className="flex items-center gap-3">
|
pipeline={pipeline}
|
||||||
<CardTitle className="text-sm font-mono">
|
pipelineCode={pipelineCode}
|
||||||
{pipeline?.name || `#${pipelineCode}`} #{selectedRun.build_number}
|
selectedStage={selectedStage}
|
||||||
</CardTitle>
|
filteredLogs={filteredLogs}
|
||||||
{selectedRun.status === 'running' && (
|
stageSummary={stageSummary}
|
||||||
<Badge className="bg-blue-500/20 dg-text-blue border-blue-500/30 text-xs">
|
logEndRef={logEndRef}
|
||||||
<Loader2 className="w-3 h-3 animate-spin mr-1" />{t('deploy.runDetail.realtime')}
|
/>
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{selectedStage && (
|
|
||||||
<Badge className="text-xs border-[var(--dg-border)]" variant="outline">
|
|
||||||
{stageLabel(selectedStage)}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-xs text-[var(--dg-text-muted)]">
|
|
||||||
<span>{t('deploy.runDetail.trigger')}: {triggerLabel(selectedRun)} ({selectedRun.triggered_by || 'admin'})</span>
|
|
||||||
<span className="opacity-40">|</span>
|
|
||||||
<span>{t('deploy.run.branch')}: {selectedRun.branch || '-'}</span>
|
|
||||||
{selectedRun.commit_id && (
|
|
||||||
<>
|
|
||||||
<span className="opacity-40">|</span>
|
|
||||||
<code className="bg-amber-400/10 dg-text-yellow px-1 py-0.5 rounded text-[10px]">
|
|
||||||
{selectedRun.commit_id.slice(0, 8)}
|
|
||||||
</code>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{selectedRun.image_tag && (
|
|
||||||
<>
|
|
||||||
<span className="opacity-40">|</span>
|
|
||||||
<code className="bg-cyan-400/10 dg-text-cyan px-1 py-0.5 rounded text-[10px]">
|
|
||||||
📦 {selectedRun.image_tag}
|
|
||||||
</code>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex-1 p-0 overflow-hidden min-h-0">
|
|
||||||
{/* ─── 阶段摘要 (OCR 设计稿特性: [代码检出] 命令 + 结果) ─── */}
|
|
||||||
{selectedStage && stageSummary && (
|
|
||||||
<div className="px-4 py-2 border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/30">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-[11px] font-semibold text-[var(--dg-text-secondary)]">
|
|
||||||
[{stageLabel(selectedStage)}]
|
|
||||||
</span>
|
|
||||||
{(() => {
|
|
||||||
const st = stageStats.get(selectedStage);
|
|
||||||
if (st === 'success' || st === 'completed') return <CheckCircle2 className="h-3.5 w-3.5 dg-text-green" />;
|
|
||||||
if (st === 'failed' || st === 'error') return <XCircle className="h-3.5 w-3.5 dg-text-red" />;
|
|
||||||
if (st === 'running' || st === 'in_progress') return <Loader2 className="h-3.5 w-3.5 dg-text-blue animate-spin" />;
|
|
||||||
return null;
|
|
||||||
})()}
|
|
||||||
<span className="text-xs text-[var(--dg-text-muted)] truncate font-mono">
|
|
||||||
{stageSummary}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="h-full overflow-y-auto bg-[#0d1117] font-mono text-xs leading-5 p-4" style={{ minHeight: 280 }}>
|
|
||||||
{filteredLogs.length === 0 && selectedRun.status !== 'running' ? (
|
|
||||||
<div className="text-center py-12 dg-text-muted">
|
|
||||||
{selectedStage ? t('deploy.pipeline.noLogStage') : t('deploy.pipeline.logOutput')}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{(() => {
|
|
||||||
let lastStage = '';
|
|
||||||
const stageStyles: Record<string, string> = {
|
|
||||||
checkout: 'border-indigo-500 bg-indigo-500/10',
|
|
||||||
build: 'border-amber-500 bg-amber-500/10',
|
|
||||||
test: 'border-emerald-500 bg-emerald-500/10',
|
|
||||||
image: 'border-cyan-500 bg-cyan-500/10',
|
|
||||||
deploy: 'border-red-500 bg-red-500/10',
|
|
||||||
verify: 'border-blue-500 bg-blue-500/10',
|
|
||||||
};
|
|
||||||
const stageIcons: Record<string, string> = {
|
|
||||||
checkout: '⬇', build: '⚙', test: '✓',
|
|
||||||
image: '📦', deploy: '🚀', verify: '🛡',
|
|
||||||
};
|
|
||||||
return filteredLogs.map((entry, i) => {
|
|
||||||
const showStage = !selectedStage && entry.stage && entry.stage !== lastStage;
|
|
||||||
if (showStage) lastStage = entry.stage || '';
|
|
||||||
const isError = entry.level === 'error';
|
|
||||||
const isWarn = entry.level === 'warn';
|
|
||||||
const isSuccess = entry.level === 'success';
|
|
||||||
const accent = isError ? 'border-l-2 border-red-500 bg-red-500/10'
|
|
||||||
: isWarn ? 'border-l-2 border-amber-500 bg-amber-500/10'
|
|
||||||
: isSuccess ? 'border-l-2 border-emerald-500 bg-emerald-500/5' : '';
|
|
||||||
const msgColor = isError ? 'text-red-200'
|
|
||||||
: isWarn ? 'text-amber-200'
|
|
||||||
: isSuccess ? 'text-emerald-200' : 'text-[var(--dg-text-secondary)]';
|
|
||||||
const levelBadge = isError
|
|
||||||
? <span className="shrink-0 px-1.5 py-px rounded text-[10px] font-bold bg-red-600/40 text-red-200">ERR</span>
|
|
||||||
: isWarn
|
|
||||||
? <span className="shrink-0 px-1.5 py-px rounded text-[10px] font-bold bg-amber-600/40 text-amber-200">WARN</span>
|
|
||||||
: isSuccess
|
|
||||||
? <span className="shrink-0 px-1.5 py-px rounded text-[10px] font-bold bg-emerald-600/30 text-emerald-200"> OK </span>
|
|
||||||
: <span className="shrink-0 px-1.5 py-px rounded text-[10px] bg-[var(--dg-bg-active)]/30 dg-text-secondary">INFO</span>;
|
|
||||||
return (
|
|
||||||
<React.Fragment key={i}>
|
|
||||||
{showStage && (
|
|
||||||
<div className={`flex items-center gap-2 my-2 py-1 px-2 border-l-4 rounded-r ${stageStyles[entry.stage || ''] || 'border-[var(--dg-border)] bg-[var(--dg-bg-active)]/10'}`}>
|
|
||||||
<span className="text-xs">{stageIcons[entry.stage || ''] || '▸'}</span>
|
|
||||||
<span className="text-[11px] font-semibold tracking-wider text-[var(--dg-text-primary)]">{entry.stage}</span>
|
|
||||||
<span className="flex-1 border-t border-[var(--dg-border)]/50"></span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className={`flex items-start gap-2 px-2 py-0.5 -mx-1 rounded-sm ${accent} ${!isError && !isWarn && !isSuccess ? 'hover:bg-white/[0.03]' : ''}`}>
|
|
||||||
<span className="dg-text-muted shrink-0 w-14 text-right text-[10px] pt-px">{entry.time}</span>
|
|
||||||
{levelBadge}
|
|
||||||
<span className={`break-all ${msgColor}`}>{entry.message}</span>
|
|
||||||
</div>
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
})()}
|
|
||||||
{selectedRun.status === 'running' && (
|
|
||||||
<div className="flex items-center gap-2 mt-2 py-1.5 px-3 bg-blue-500/10 border-l-4 border-blue-500 rounded-r">
|
|
||||||
<Loader2 className="w-3 h-3 animate-spin dg-text-blue shrink-0" />
|
|
||||||
<span className="text-[11px] text-blue-300 font-medium">{t('deploy.pipeline.realtimeOutput')}</span>
|
|
||||||
<span className="flex gap-1 ml-1">
|
|
||||||
<span className="w-1 h-1 rounded-full bg-blue-400 animate-bounce" style={{ animationDelay: '0ms' }}></span>
|
|
||||||
<span className="w-1 h-1 rounded-full bg-blue-400 animate-bounce" style={{ animationDelay: '150ms' }}></span>
|
|
||||||
<span className="w-1 h-1 rounded-full bg-blue-400 animate-bounce" style={{ animationDelay: '300ms' }}></span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div ref={logEndRef} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
Loader2, CheckCircle2, XCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import type { PipelineRun, LogEntry, JsonData } from './types';
|
||||||
|
import { getRunStageStats } from './types';
|
||||||
|
|
||||||
|
interface LogConsoleProps {
|
||||||
|
selectedRun: PipelineRun;
|
||||||
|
pipeline: JsonData;
|
||||||
|
pipelineCode: string | undefined;
|
||||||
|
selectedStage: string | null;
|
||||||
|
filteredLogs: LogEntry[];
|
||||||
|
stageSummary: string;
|
||||||
|
logEndRef: React.RefObject<HTMLDivElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogConsole({
|
||||||
|
selectedRun, pipeline, pipelineCode, selectedStage, filteredLogs, stageSummary, logEndRef,
|
||||||
|
}: LogConsoleProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const stageLabel = (key: string) => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
checkout: t('deploy.stage.checkout'),
|
||||||
|
build: t('deploy.stage.build'),
|
||||||
|
test: t('deploy.stage.test'),
|
||||||
|
image: t('deploy.stage.image'),
|
||||||
|
deploy: t('deploy.stage.deploy'),
|
||||||
|
verify: t('common.verify'),
|
||||||
|
};
|
||||||
|
return map[key] || key;
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerLabel = (run: PipelineRun) => {
|
||||||
|
if (run.triggered_by === 'system') return 'Cron';
|
||||||
|
if (run.triggered_by === 'git') return 'Git Push';
|
||||||
|
return run.triggered_by || 'Manual';
|
||||||
|
};
|
||||||
|
|
||||||
|
const stageStats = getRunStageStats(selectedRun);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="flex-1 flex flex-col min-h-0 border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]">
|
||||||
|
<CardHeader className="py-3 px-4 flex flex-row items-center justify-between border-b border-[var(--dg-border)]">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CardTitle className="text-sm font-mono">
|
||||||
|
{pipeline?.name || `#${pipelineCode}`} #{selectedRun.build_number}
|
||||||
|
</CardTitle>
|
||||||
|
{selectedRun.status === 'running' && (
|
||||||
|
<Badge className="bg-blue-500/20 dg-text-blue border-blue-500/30 text-xs">
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin mr-1" />{t('deploy.runDetail.realtime')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{selectedStage && (
|
||||||
|
<Badge className="text-xs border-[var(--dg-border)]" variant="outline">
|
||||||
|
{stageLabel(selectedStage)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-[var(--dg-text-muted)]">
|
||||||
|
<span>{t('deploy.runDetail.trigger')}: {triggerLabel(selectedRun)} ({selectedRun.triggered_by || 'admin'})</span>
|
||||||
|
<span className="opacity-40">|</span>
|
||||||
|
<span>{t('deploy.run.branch')}: {selectedRun.branch || '-'}</span>
|
||||||
|
{selectedRun.commit_id && (
|
||||||
|
<>
|
||||||
|
<span className="opacity-40">|</span>
|
||||||
|
<code className="bg-amber-400/10 dg-text-yellow px-1 py-0.5 rounded text-[10px]">
|
||||||
|
{selectedRun.commit_id.slice(0, 8)}
|
||||||
|
</code>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{selectedRun.image_tag && (
|
||||||
|
<>
|
||||||
|
<span className="opacity-40">|</span>
|
||||||
|
<code className="bg-cyan-400/10 dg-text-cyan px-1 py-0.5 rounded text-[10px]">
|
||||||
|
📦 {selectedRun.image_tag}
|
||||||
|
</code>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 p-0 overflow-hidden min-h-0">
|
||||||
|
{/* ─── 阶段摘要 ─── */}
|
||||||
|
{selectedStage && stageSummary && (
|
||||||
|
<div className="px-4 py-2 border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/30">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[11px] font-semibold text-[var(--dg-text-secondary)]">
|
||||||
|
[{stageLabel(selectedStage)}]
|
||||||
|
</span>
|
||||||
|
{(() => {
|
||||||
|
const st = stageStats.get(selectedStage);
|
||||||
|
if (st === 'success' || st === 'completed') return <CheckCircle2 className="h-3.5 w-3.5 dg-text-green" />;
|
||||||
|
if (st === 'failed' || st === 'error') return <XCircle className="h-3.5 w-3.5 dg-text-red" />;
|
||||||
|
if (st === 'running' || st === 'in_progress') return <Loader2 className="h-3.5 w-3.5 dg-text-blue animate-spin" />;
|
||||||
|
return null;
|
||||||
|
})()}
|
||||||
|
<span className="text-xs text-[var(--dg-text-muted)] truncate font-mono">
|
||||||
|
{stageSummary}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="h-full overflow-y-auto bg-[#0d1117] font-mono text-xs leading-5 p-4" style={{ minHeight: 280 }}>
|
||||||
|
{filteredLogs.length === 0 && selectedRun.status !== 'running' ? (
|
||||||
|
<div className="text-center py-12 dg-text-muted">
|
||||||
|
{selectedStage ? t('deploy.pipeline.noLogStage') : t('deploy.pipeline.logOutput')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{(() => {
|
||||||
|
let lastStage = '';
|
||||||
|
const stageStyles: Record<string, string> = {
|
||||||
|
checkout: 'border-indigo-500 bg-indigo-500/10',
|
||||||
|
build: 'border-amber-500 bg-amber-500/10',
|
||||||
|
test: 'border-emerald-500 bg-emerald-500/10',
|
||||||
|
image: 'border-cyan-500 bg-cyan-500/10',
|
||||||
|
deploy: 'border-red-500 bg-red-500/10',
|
||||||
|
verify: 'border-blue-500 bg-blue-500/10',
|
||||||
|
};
|
||||||
|
const stageIcons: Record<string, string> = {
|
||||||
|
checkout: '⬇', build: '⚙', test: '✓',
|
||||||
|
image: '📦', deploy: '🚀', verify: '🛡',
|
||||||
|
};
|
||||||
|
return filteredLogs.map((entry, i) => {
|
||||||
|
const showStage = !selectedStage && entry.stage && entry.stage !== lastStage;
|
||||||
|
if (showStage) lastStage = entry.stage || '';
|
||||||
|
const isError = entry.level === 'error';
|
||||||
|
const isWarn = entry.level === 'warn';
|
||||||
|
const isSuccess = entry.level === 'success';
|
||||||
|
const accent = isError ? 'border-l-2 border-red-500 bg-red-500/10'
|
||||||
|
: isWarn ? 'border-l-2 border-amber-500 bg-amber-500/10'
|
||||||
|
: isSuccess ? 'border-l-2 border-emerald-500 bg-emerald-500/5' : '';
|
||||||
|
const msgColor = isError ? 'text-red-200'
|
||||||
|
: isWarn ? 'text-amber-200'
|
||||||
|
: isSuccess ? 'text-emerald-200' : 'text-[var(--dg-text-secondary)]';
|
||||||
|
const levelBadge = isError
|
||||||
|
? <span className="shrink-0 px-1.5 py-px rounded text-[10px] font-bold bg-red-600/40 text-red-200">ERR</span>
|
||||||
|
: isWarn
|
||||||
|
? <span className="shrink-0 px-1.5 py-px rounded text-[10px] font-bold bg-amber-600/40 text-amber-200">WARN</span>
|
||||||
|
: isSuccess
|
||||||
|
? <span className="shrink-0 px-1.5 py-px rounded text-[10px] font-bold bg-emerald-600/30 text-emerald-200"> OK </span>
|
||||||
|
: <span className="shrink-0 px-1.5 py-px rounded text-[10px] bg-[var(--dg-bg-active)]/30 dg-text-secondary">INFO</span>;
|
||||||
|
return (
|
||||||
|
<React.Fragment key={i}>
|
||||||
|
{showStage && (
|
||||||
|
<div className={`flex items-center gap-2 my-2 py-1 px-2 border-l-4 rounded-r ${stageStyles[entry.stage || ''] || 'border-[var(--dg-border)] bg-[var(--dg-bg-active)]/10'}`}>
|
||||||
|
<span className="text-xs">{stageIcons[entry.stage || ''] || '▸'}</span>
|
||||||
|
<span className="text-[11px] font-semibold tracking-wider text-[var(--dg-text-primary)]">{entry.stage}</span>
|
||||||
|
<span className="flex-1 border-t border-[var(--dg-border)]/50"></span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={`flex items-start gap-2 px-2 py-0.5 -mx-1 rounded-sm ${accent} ${!isError && !isWarn && !isSuccess ? 'hover:bg-white/[0.03]' : ''}`}>
|
||||||
|
<span className="dg-text-muted shrink-0 w-14 text-right text-[10px] pt-px">{entry.time}</span>
|
||||||
|
{levelBadge}
|
||||||
|
<span className={`break-all ${msgColor}`}>{entry.message}</span>
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})()}
|
||||||
|
{selectedRun.status === 'running' && (
|
||||||
|
<div className="flex items-center gap-2 mt-2 py-1.5 px-3 bg-blue-500/10 border-l-4 border-blue-500 rounded-r">
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin dg-text-blue shrink-0" />
|
||||||
|
<span className="text-[11px] text-blue-300 font-medium">{t('deploy.pipeline.realtimeOutput')}</span>
|
||||||
|
<span className="flex gap-1 ml-1">
|
||||||
|
<span className="w-1 h-1 rounded-full bg-blue-400 animate-bounce" style={{ animationDelay: '0ms' }}></span>
|
||||||
|
<span className="w-1 h-1 rounded-full bg-blue-400 animate-bounce" style={{ animationDelay: '150ms' }}></span>
|
||||||
|
<span className="w-1 h-1 rounded-full bg-blue-400 animate-bounce" style={{ animationDelay: '300ms' }}></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={logEndRef} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
Loader2, Clock, History, User, Square, FolderOpen,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import type { PipelineRun } from './types';
|
||||||
|
import { STATUS_BADGES, STATUS_ICONS, formatDuration, formatTime } from './types';
|
||||||
|
|
||||||
|
interface RunsTableProps {
|
||||||
|
runs: PipelineRun[];
|
||||||
|
loading: boolean;
|
||||||
|
selectedRun: PipelineRun | null;
|
||||||
|
selectedIds: Set<number>;
|
||||||
|
onSelectRun: (run: PipelineRun) => void;
|
||||||
|
onToggleSelect: (id: number) => void;
|
||||||
|
onToggleAll: () => void;
|
||||||
|
onStopRun: (runId: number) => void;
|
||||||
|
onBrowse: (run: PipelineRun) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RunsTable({
|
||||||
|
runs, loading, selectedRun, selectedIds,
|
||||||
|
onSelectRun, onToggleSelect, onToggleAll, onStopRun, onBrowse,
|
||||||
|
}: RunsTableProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const triggerLabel = (run: PipelineRun) => {
|
||||||
|
if (run.triggered_by === 'system') return 'Cron';
|
||||||
|
if (run.triggered_by === 'git') return 'Git Push';
|
||||||
|
return run.triggered_by || 'Manual';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-[var(--dg-border)] overflow-hidden mb-4">
|
||||||
|
{loading && runs.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin mx-auto mb-2 text-[var(--dg-text-muted)]" />
|
||||||
|
{t('common.loading')}
|
||||||
|
</div>
|
||||||
|
) : runs.length === 0 ? (
|
||||||
|
<div className="text-center py-12 border border-dashed border-[var(--dg-border)] rounded-lg m-4">
|
||||||
|
<History className="w-10 h-10 mx-auto mb-3 text-[var(--dg-text-muted)]" />
|
||||||
|
<p className="text-[var(--dg-text-secondary)]">{t('deploy.run.noRecordsHint')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
||||||
|
<tr>
|
||||||
|
<th className="px-2 py-2.5 w-10">
|
||||||
|
<input type="checkbox" checked={selectedIds.size === runs.length && runs.length > 0}
|
||||||
|
onChange={onToggleAll} className="rounded" />
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] w-16">{t('deploy.pipeline.buildNumber')}</th>
|
||||||
|
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] w-20">{t('deploy.table.status')}</th>
|
||||||
|
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.table.trigger')}</th>
|
||||||
|
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.table.user')}</th>
|
||||||
|
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.table.duration')}</th>
|
||||||
|
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] hidden md:table-cell">{t('common.createdAt')}</th>
|
||||||
|
<th className="px-3 py-2.5 text-right font-medium text-[var(--dg-text-secondary)] w-32">{t('common.actions')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{runs.map(run => {
|
||||||
|
const StatusIcon = STATUS_ICONS[run.status] || Clock;
|
||||||
|
return (
|
||||||
|
<tr key={run.id}
|
||||||
|
className={`border-b border-[var(--dg-border)] cursor-pointer transition-colors
|
||||||
|
${selectedRun?.id === run.id ? 'bg-[var(--dg-blue)]/10' : 'hover:bg-[var(--dg-bg-tertiary)]/50'}`}
|
||||||
|
onClick={() => onSelectRun(run)}>
|
||||||
|
<td className="px-2 py-2.5" onClick={e => e.stopPropagation()}>
|
||||||
|
<input type="checkbox" checked={selectedIds.has(run.id)}
|
||||||
|
onChange={() => onToggleSelect(run.id)} className="rounded" />
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 font-mono font-semibold text-[var(--dg-text-primary)]">
|
||||||
|
#{run.build_number}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<Badge className={`text-xs ${STATUS_BADGES[run.status] || ''} flex items-center gap-1 w-fit`}>
|
||||||
|
<StatusIcon className={`w-3 h-3 ${run.status === 'running' ? 'animate-spin' : ''}`} />
|
||||||
|
{run.status === 'success' ? t('deploy.status.success')
|
||||||
|
: run.status === 'failed' ? t('deploy.status.failed')
|
||||||
|
: run.status === 'running' ? t('deploy.runDetail.executing')
|
||||||
|
: run.status === 'cancelled' ? t('deploy.status.cancelled')
|
||||||
|
: t('deploy.status.pending')}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<Badge variant="outline" className="text-xs">{triggerLabel(run)}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-[var(--dg-text-secondary)] flex items-center gap-1">
|
||||||
|
<User className="w-3 h-3" />
|
||||||
|
{run.triggered_by || 'admin'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-[var(--dg-text-secondary)] font-mono text-xs">
|
||||||
|
{run.status === 'running' ? (
|
||||||
|
<span className="flex items-center gap-1 dg-text-blue">
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />{t('deploy.pipeline.executing')}
|
||||||
|
</span>
|
||||||
|
) : formatDuration(run.duration)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-[var(--dg-text-muted)] text-xs hidden md:table-cell">
|
||||||
|
{formatTime(run.created_at)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-right" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 text-xs"
|
||||||
|
onClick={() => onSelectRun(run)}>
|
||||||
|
{t('deploy.label.log')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 text-xs"
|
||||||
|
onClick={() => onBrowse(run)}>
|
||||||
|
<FolderOpen className="w-3.5 h-3.5 mr-1" />{t('deploy.label.browse')}
|
||||||
|
</Button>
|
||||||
|
{run.status === 'running' && (
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500"
|
||||||
|
onClick={() => onStopRun(run.id)}>
|
||||||
|
<Square className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import {
|
||||||
|
CheckCircle2, Loader2, XCircle, ChevronRight as ChevronRightSmall,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { PipelineRun } from './types';
|
||||||
|
import { STAGE_DEFS, getRunStageStats } from './types';
|
||||||
|
|
||||||
|
interface StageProgressBarProps {
|
||||||
|
selectedRun: PipelineRun | null;
|
||||||
|
selectedStage: string | null;
|
||||||
|
onSelectStage: (key: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 阶段进度条 — 交互式,点击切换阶段日志过滤 */
|
||||||
|
export function StageProgressBar({ selectedRun, selectedStage, onSelectStage }: StageProgressBarProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const stageLabel = (key: string) => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
checkout: t('deploy.stage.checkout'),
|
||||||
|
build: t('deploy.stage.build'),
|
||||||
|
test: t('deploy.stage.test'),
|
||||||
|
image: t('deploy.stage.image'),
|
||||||
|
deploy: t('deploy.stage.deploy'),
|
||||||
|
verify: t('common.verify'),
|
||||||
|
};
|
||||||
|
return map[key] || key;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stageStats = getRunStageStats(selectedRun);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-0 px-4 py-3 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] overflow-x-auto">
|
||||||
|
{STAGE_DEFS.map((def, i) => {
|
||||||
|
const status = stageStats.get(def.key) || '';
|
||||||
|
const isSuccess = status === 'success' || status === 'completed';
|
||||||
|
const isRunning = status === 'running' || status === 'in_progress';
|
||||||
|
const isFailed = status === 'failed' || status === 'error';
|
||||||
|
const isActive = selectedStage === def.key;
|
||||||
|
const hasData = stageStats.size > 0;
|
||||||
|
|
||||||
|
const color = isFailed ? '#EF4444'
|
||||||
|
: isRunning ? '#3B82F6'
|
||||||
|
: isSuccess ? '#207060'
|
||||||
|
: '#6B7280';
|
||||||
|
const bgColor = isFailed ? '#EF444420'
|
||||||
|
: isRunning ? '#3B82F620'
|
||||||
|
: isSuccess ? '#20706020'
|
||||||
|
: '#6B728010';
|
||||||
|
const borderColor = isActive ? color : (color + '50');
|
||||||
|
|
||||||
|
const Icon = def.icon;
|
||||||
|
const icon = isFailed ? <XCircle className="h-3.5 w-3.5" />
|
||||||
|
: isRunning ? <Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
: isSuccess ? <CheckCircle2 className="h-3.5 w-3.5" />
|
||||||
|
: hasData ? <span className="text-[10px] opacity-40">○</span>
|
||||||
|
: <Icon className="h-3.5 w-3.5 opacity-40" />;
|
||||||
|
|
||||||
|
const isLast = i === STAGE_DEFS.length - 1;
|
||||||
|
const borderRadius = i === 0 ? '8px 0 0 8px' : isLast ? '0 8px 8px 0' : '0';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={def.key} className="flex items-center">
|
||||||
|
<button
|
||||||
|
onClick={() => onSelectStage(selectedStage === def.key ? null : def.key)}
|
||||||
|
title={`${stageLabel(def.key)}${status ? ` (${status})` : ''} — ${t('deploy.runDetail.clickToViewLog')}`}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-2 text-[12px] font-semibold border shadow-sm transition-all cursor-pointer
|
||||||
|
${isActive ? 'ring-1 ring-offset-0 scale-[1.02]' : 'hover:brightness-110'}`}
|
||||||
|
style={{ backgroundColor: bgColor, borderColor, color, borderRadius }}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<span>{stageLabel(def.key)}</span>
|
||||||
|
</button>
|
||||||
|
{!isLast && (
|
||||||
|
<div className="flex items-center justify-center w-5 h-5 -mx-px z-10">
|
||||||
|
<ChevronRightSmall className="h-3.5 w-3.5" style={{ color: color + '99' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{selectedRun && (
|
||||||
|
<span className="ml-auto text-[10px] text-[var(--dg-text-secondary)] opacity-40 shrink-0 pl-3 whitespace-nowrap">
|
||||||
|
{selectedStage ? stageLabel(selectedStage) : t('deploy.pipeline.allStages')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import {
|
||||||
|
CheckCircle2, Loader2, XCircle, Clock, AlertCircle,
|
||||||
|
GitBranch, Box, Container, Rocket, ShieldCheck,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { JsonData } from '@/types/common';
|
||||||
|
|
||||||
|
// Re-export for convenience (consumers import from this module)
|
||||||
|
export type { JsonData };
|
||||||
|
|
||||||
|
// ═══════════ 阶段定义 (与 PipelineManager 共用) ═══════════
|
||||||
|
export const STAGE_DEFS = [
|
||||||
|
{ key: 'checkout', label: '代码检出', icon: GitBranch, color: '#6366F1', order: 1 },
|
||||||
|
{ key: 'build', label: '构建', icon: Box, color: '#F59E0B', order: 2 },
|
||||||
|
{ key: 'test', label: '测试', icon: CheckCircle2, color: '#10B981', order: 3 },
|
||||||
|
{ key: 'image', label: '镜像打包', icon: Container, color: '#06B6D4', order: 4 },
|
||||||
|
{ key: 'deploy', label: '部署', icon: Rocket, color: '#EF4444', order: 5 },
|
||||||
|
{ key: 'verify', label: '验证', icon: ShieldCheck, color: '#3B82F6', order: 6 },
|
||||||
|
].sort((a, b) => a.order - b.order);
|
||||||
|
|
||||||
|
export interface PipelineRun {
|
||||||
|
id: number;
|
||||||
|
pipeline_id: number;
|
||||||
|
build_number: number;
|
||||||
|
status: string;
|
||||||
|
branch: string;
|
||||||
|
commit_id: string;
|
||||||
|
commit_msg: string;
|
||||||
|
author: string;
|
||||||
|
triggered_by: string;
|
||||||
|
image_tag: string;
|
||||||
|
duration: number;
|
||||||
|
stages: string;
|
||||||
|
started_at: string;
|
||||||
|
finished_at: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
time: string;
|
||||||
|
level: string;
|
||||||
|
stage: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StageStatus {
|
||||||
|
name: string;
|
||||||
|
status: string; // success, failed, running, pending
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STATUS_BADGES: Record<string, string> = {
|
||||||
|
success: 'bg-emerald-500/20 dg-text-green border-emerald-500/30',
|
||||||
|
failed: 'bg-red-500/20 dg-text-red border-red-500/30',
|
||||||
|
running: 'bg-blue-500/20 dg-text-blue border-blue-500/30',
|
||||||
|
pending: 'bg-[var(--dg-bg-active)]/20 dg-text-secondary border-[var(--dg-border)]',
|
||||||
|
cancelled: 'bg-amber-500/20 dg-text-yellow border-amber-500/30',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STATUS_ICONS: JsonData = {
|
||||||
|
success: CheckCircle2,
|
||||||
|
failed: XCircle,
|
||||||
|
running: Loader2,
|
||||||
|
pending: Clock,
|
||||||
|
cancelled: AlertCircle,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatDuration(seconds: number): string {
|
||||||
|
if (!seconds) return '-';
|
||||||
|
if (seconds < 60) return `${seconds}s`;
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
return `${m}m${s}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(ts: string): string {
|
||||||
|
if (!ts) return '-';
|
||||||
|
const d = new Date(ts);
|
||||||
|
return d.toLocaleString('zh-CN', {
|
||||||
|
month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseStageStatuses(raw: string): StageStatus[] {
|
||||||
|
if (!raw) return [];
|
||||||
|
try {
|
||||||
|
const s = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||||
|
return Array.isArray(s) ? s : [];
|
||||||
|
} catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 run.stages (JSON) 推导每个阶段的运行状态 */
|
||||||
|
export function getRunStageStats(run: PipelineRun | null): Map<string, string> {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
if (!run) return map;
|
||||||
|
const statuses = parseStageStatuses(run.stages);
|
||||||
|
statuses.forEach(s => {
|
||||||
|
if (s.name || (s as JsonData).type) {
|
||||||
|
const key = (s as JsonData).type || (STAGE_DEFS.find(d => d.label === s.name)?.key);
|
||||||
|
if (key) map.set(key, s.status);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (map.size === 0 && run.status === 'success') {
|
||||||
|
STAGE_DEFS.forEach(d => map.set(d.key, 'success'));
|
||||||
|
} else if (map.size === 0 && run.status === 'failed') {
|
||||||
|
STAGE_DEFS.forEach((d, i) => map.set(d.key, i === 0 ? 'success' : 'pending'));
|
||||||
|
} else if (map.size === 0 && run.status === 'running') {
|
||||||
|
map.set('checkout', 'running');
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从日志中提取每个阶段的首条日志内容(阶段摘要) */
|
||||||
|
export function getStageLogSummary(logs: LogEntry[], stageKey: string): string {
|
||||||
|
const first = logs.find(l => l.stage === stageKey);
|
||||||
|
if (first) return first.message;
|
||||||
|
const def = STAGE_DEFS.find(d => d.key === stageKey);
|
||||||
|
if (def) return `${def.label}...`;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
Reference in new issue
Block a user