From 76aa8817c022a0786a902f4244aee5572ee5f0c2 Mon Sep 17 00:00:00 2001 From: attacker Date: Tue, 7 Jul 2026 07:42:03 +0800 Subject: [PATCH] =?UTF-8?q?refactor(quality):=20Q11=20=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E5=B7=A8=E5=9E=8B=E7=BB=84=E4=BB=B6=E6=8B=86=E5=88=86=20?= =?UTF-8?q?=E2=80=94=20PipelineRunDetail=20678L=E2=86=92273L=20(-60%),=20?= =?UTF-8?q?=E6=8F=90=E5=8F=96=20types=20+=20StageProgressBar=20+=20RunsTab?= =?UTF-8?q?le=20+=20LogConsole=204=20=E4=B8=AA=E7=8B=AC=E7=AB=8B=E6=96=87?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROJECT_EVALUATION.md | 2 +- ROADMAP.md | 34 ++ TDD_PLAN.md | 16 + .../components/deploy/PipelineRunDetail.tsx | 523 ++---------------- .../deploy/pipeline-run-detail/LogConsole.tsx | 183 ++++++ .../deploy/pipeline-run-detail/RunsTable.tsx | 131 +++++ .../pipeline-run-detail/StageProgressBar.tsx | 89 +++ .../deploy/pipeline-run-detail/types.ts | 119 ++++ 8 files changed, 632 insertions(+), 465 deletions(-) create mode 100644 frontend/src/components/deploy/pipeline-run-detail/LogConsole.tsx create mode 100644 frontend/src/components/deploy/pipeline-run-detail/RunsTable.tsx create mode 100644 frontend/src/components/deploy/pipeline-run-detail/StageProgressBar.tsx create mode 100644 frontend/src/components/deploy/pipeline-run-detail/types.ts diff --git a/PROJECT_EVALUATION.md b/PROJECT_EVALUATION.md index b4d96b5..71e1e1f 100644 --- a/PROJECT_EVALUATION.md +++ b/PROJECT_EVALUATION.md @@ -129,7 +129,7 @@ | ~~`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) | | ~~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 🟡 中优先级 diff --git a/ROADMAP.md b/ROADMAP.md index 17af03f..486ee68 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -482,6 +482,7 @@ services/api.ts (1324L) | Q8 | 前端巨型组件拆分(SystemSettingsTab 754L→173L,提取通用/安全/LDAP 3 个子 Tab) | P0 | **done** | | Q9 | 前端巨型组件拆分(NamespaceResourcePage 719L→300L,提取 RESOURCE_CONFIG + PodDetailDialog) | 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] Docker 部署 healthy - [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/文件浏览器全部正常) diff --git a/TDD_PLAN.md b/TDD_PLAN.md index 21d5b6e..f7c014e 100644 --- a/TDD_PLAN.md +++ b/TDD_PLAN.md @@ -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] U6: `go build` 零错误 - [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 / 文件浏览器) diff --git a/frontend/src/components/deploy/PipelineRunDetail.tsx b/frontend/src/components/deploy/PipelineRunDetail.tsx index deb57e4..001dc98 100644 --- a/frontend/src/components/deploy/PipelineRunDetail.tsx +++ b/frontend/src/components/deploy/PipelineRunDetail.tsx @@ -1,157 +1,35 @@ import { useState, useEffect, useRef, useCallback } from 'react'; -import React from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; import { - Play, Square, RefreshCw, Clock, User, AlertCircle, CheckCircle2, - Loader2, XCircle, History, FolderOpen, Trash2, ChevronRight as ChevronRightSmall, - GitBranch, Box, Container, Rocket, ShieldCheck, + Play, RefreshCw, Trash2, History, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { toast } from 'sonner'; import { pipelineApi } from '@/api'; import { FileBrowserDialog } from './FileBrowserDialog'; import { PageShell } from '@/components/layout/PageShell'; import { StatStrip } from '@/components/ui/stat-strip'; import type { JsonData } from '@/types/common'; - -// ═══════════ 阶段定义 (与 PipelineManager 共用) ═══════════ -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); - -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 = { - 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 { - const map = new Map(); - 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 ''; -} +import { + type PipelineRun, type LogEntry, + getStageLogSummary, +} from './pipeline-run-detail/types'; +import { StageProgressBar } from './pipeline-run-detail/StageProgressBar'; +import { RunsTable } from './pipeline-run-detail/RunsTable'; +import { LogConsole } from './pipeline-run-detail/LogConsole'; export default function PipelineRunDetail() { const { t } = useTranslation(); const { code: pipelineCode } = useParams<{ code: string }>(); - // 阶段名称国际化映射 - const stageLabel = (key: string) => { - const map: Record = { - 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(null); const [runs, setRuns] = useState([]); const [selectedRun, setSelectedRun] = useState(null); const [logs, setLogs] = useState([]); const [loading, setLoading] = useState(false); - const [selectedStage, setSelectedStage] = useState(null); // null = 全部 + const [selectedStage, setSelectedStage] = useState(null); const [pageSize, setPageSize] = useState(10); const [selectedIds, setSelectedIds] = useState>(new Set()); const [deleting, setDeleting] = useState(false); @@ -194,27 +72,12 @@ export default function PipelineRunDetail() { useEffect(() => { const hasRunning = runs.some(r => r.status === 'running'); if (!hasRunning) return; - const t = setInterval(fetchRuns, 5000); - return () => clearInterval(t); + const timer = setInterval(fetchRuns, 5000); + return () => clearInterval(timer); }, [runs, 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([]); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // WebSocket - const connectWS = (runId: number) => { + // WebSocket 连接(运行中实时日志) + const connectWS = useCallback((runId: number) => { if (wsRef.current) wsRef.current.close(); const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const token = localStorage.getItem('token'); @@ -236,7 +99,21 @@ export default function PipelineRunDetail() { ws.onclose = () => { wsRef.current = null; }; ws.onerror = () => { wsRef.current = null; }; 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(() => { if (selectedRun) { @@ -246,12 +123,12 @@ export default function PipelineRunDetail() { return () => { if (wsRef.current) wsRef.current.close(); }; }, [selectedRun, fetchLogs]); - // 自动滚动 + // 自动滚动到底部 useEffect(() => { logEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [logs]); - // 触发 + // 触发新运行 const triggerRun = async () => { try { await pipelineApi.triggerByCode(pipelineCode); @@ -260,7 +137,7 @@ export default function PipelineRunDetail() { } catch { toast.error(t('deploy.toast.triggerFailed')); } }; - // 停止 + // 停止运行 const stopRun = async (runId: number) => { try { await pipelineApi.stopRunByCode(pipelineCode, runId); @@ -294,23 +171,9 @@ export default function PipelineRunDetail() { else setSelectedIds(new Set(runs.map(r => r.id))); }; - 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); - - // ═══════════ 过滤日志(按阶段) ═══════════ - const filteredLogs = selectedStage - ? logs.filter(l => l.stage === selectedStage) - : logs; - - // 获取当前选中阶段的摘要日志 + // ═══════════ 派生数据 ═══════════ + const filteredLogs = selectedStage ? logs.filter(l => l.stage === selectedStage) : logs; const stageSummary = selectedStage ? getStageLogSummary(logs, selectedStage) : ''; - const successCount = runs.filter(r => r.status === 'success').length; const runningCount = runs.filter(r => r.status === 'running').length; @@ -364,305 +227,37 @@ export default function PipelineRunDetail() { )} > {/* ═══════════ 阶段进度条 (交互式) ═══════════ */} -
- {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 _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 ? - : isRunning ? - : isSuccess ? - : hasData ? ○ - : ; - - const isLast = i === STAGE_DEFS.length - 1; - const borderRadius = i === 0 ? '8px 0 0 8px' : isLast ? '0 8px 8px 0' : '0'; - - return ( -
- - {!isLast && ( -
- -
- )} -
- ); - })} - {selectedRun && ( - - {selectedStage ? stageLabel(selectedStage) : t('deploy.pipeline.allStages')} - - )} -
+ {/* ═══════════ 主内容区: 执行记录表 + 日志 ═══════════ */}
- {/* ─── 执行记录表 ─── */} -
- {loading && runs.length === 0 ? ( -
- - {t('common.loading')} -
- ) : runs.length === 0 ? ( -
- -

{t('deploy.run.noRecordsHint')}

-
- ) : ( - - - - - - - - - - - - - - - {runs.map(run => { - const StatusIcon = STATUS_ICONS[run.status] || Clock; - return ( - { setSelectedRun(run); }}> - - - - - - - - - - ); - })} - -
- 0} - onChange={toggleAll} className="rounded" /> - {t('deploy.pipeline.buildNumber')}{t('deploy.table.status')}{t('deploy.table.trigger')}{t('deploy.table.user')}{t('deploy.table.duration')}{t('common.createdAt')}{t('common.actions')}
e.stopPropagation()}> - toggleSelect(run.id)} className="rounded" /> - - #{run.build_number} - - - - {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')} - - - {triggerLabel(run)} - - - {run.triggered_by || 'admin'} - - {run.status === 'running' ? ( - - {t('deploy.pipeline.executing')} - - ) : formatDuration(run.duration)} - - {formatTime(run.created_at)} - e.stopPropagation()}> -
- - - {run.status === 'running' && ( - - )} -
-
- )} -
+ { setBrowserRun({ id: run.id, buildNumber: run.build_number }); setBrowserOpen(true); }} + /> - {/* ─── 阶段日志 Console ─── */} + {/* ═══════════ 阶段日志 Console ═══════════ */} {selectedRun && ( - - -
- - {pipeline?.name || `#${pipelineCode}`} #{selectedRun.build_number} - - {selectedRun.status === 'running' && ( - - {t('deploy.runDetail.realtime')} - - )} - {selectedStage && ( - - {stageLabel(selectedStage)} - - )} -
-
- {t('deploy.runDetail.trigger')}: {triggerLabel(selectedRun)} ({selectedRun.triggered_by || 'admin'}) - | - {t('deploy.run.branch')}: {selectedRun.branch || '-'} - {selectedRun.commit_id && ( - <> - | - - {selectedRun.commit_id.slice(0, 8)} - - - )} - {selectedRun.image_tag && ( - <> - | - - 📦 {selectedRun.image_tag} - - - )} -
-
- - {/* ─── 阶段摘要 (OCR 设计稿特性: [代码检出] 命令 + 结果) ─── */} - {selectedStage && stageSummary && ( -
-
- - [{stageLabel(selectedStage)}] - - {(() => { - const st = stageStats.get(selectedStage); - if (st === 'success' || st === 'completed') return ; - if (st === 'failed' || st === 'error') return ; - if (st === 'running' || st === 'in_progress') return ; - return null; - })()} - - {stageSummary} - -
-
- )} -
- {filteredLogs.length === 0 && selectedRun.status !== 'running' ? ( -
- {selectedStage ? t('deploy.pipeline.noLogStage') : t('deploy.pipeline.logOutput')} -
- ) : ( - <> - {(() => { - let lastStage = ''; - const stageStyles: Record = { - 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 = { - 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 - ? ERR - : isWarn - ? WARN - : isSuccess - ? OK - : INFO; - return ( - - {showStage && ( -
- {stageIcons[entry.stage || ''] || '▸'} - {entry.stage} - -
- )} -
- {entry.time} - {levelBadge} - {entry.message} -
-
- ); - }); - })()} - {selectedRun.status === 'running' && ( -
- - {t('deploy.pipeline.realtimeOutput')} - - - - - -
- )} -
- - )} -
- - + )}
diff --git a/frontend/src/components/deploy/pipeline-run-detail/LogConsole.tsx b/frontend/src/components/deploy/pipeline-run-detail/LogConsole.tsx new file mode 100644 index 0000000..6371767 --- /dev/null +++ b/frontend/src/components/deploy/pipeline-run-detail/LogConsole.tsx @@ -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; +} + +export function LogConsole({ + selectedRun, pipeline, pipelineCode, selectedStage, filteredLogs, stageSummary, logEndRef, +}: LogConsoleProps) { + const { t } = useTranslation(); + + const stageLabel = (key: string) => { + const map: Record = { + 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 ( + + +
+ + {pipeline?.name || `#${pipelineCode}`} #{selectedRun.build_number} + + {selectedRun.status === 'running' && ( + + {t('deploy.runDetail.realtime')} + + )} + {selectedStage && ( + + {stageLabel(selectedStage)} + + )} +
+
+ {t('deploy.runDetail.trigger')}: {triggerLabel(selectedRun)} ({selectedRun.triggered_by || 'admin'}) + | + {t('deploy.run.branch')}: {selectedRun.branch || '-'} + {selectedRun.commit_id && ( + <> + | + + {selectedRun.commit_id.slice(0, 8)} + + + )} + {selectedRun.image_tag && ( + <> + | + + 📦 {selectedRun.image_tag} + + + )} +
+
+ + {/* ─── 阶段摘要 ─── */} + {selectedStage && stageSummary && ( +
+
+ + [{stageLabel(selectedStage)}] + + {(() => { + const st = stageStats.get(selectedStage); + if (st === 'success' || st === 'completed') return ; + if (st === 'failed' || st === 'error') return ; + if (st === 'running' || st === 'in_progress') return ; + return null; + })()} + + {stageSummary} + +
+
+ )} +
+ {filteredLogs.length === 0 && selectedRun.status !== 'running' ? ( +
+ {selectedStage ? t('deploy.pipeline.noLogStage') : t('deploy.pipeline.logOutput')} +
+ ) : ( + <> + {(() => { + let lastStage = ''; + const stageStyles: Record = { + 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 = { + 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 + ? ERR + : isWarn + ? WARN + : isSuccess + ? OK + : INFO; + return ( + + {showStage && ( +
+ {stageIcons[entry.stage || ''] || '▸'} + {entry.stage} + +
+ )} +
+ {entry.time} + {levelBadge} + {entry.message} +
+
+ ); + }); + })()} + {selectedRun.status === 'running' && ( +
+ + {t('deploy.pipeline.realtimeOutput')} + + + + + +
+ )} +
+ + )} +
+ + + ); +} diff --git a/frontend/src/components/deploy/pipeline-run-detail/RunsTable.tsx b/frontend/src/components/deploy/pipeline-run-detail/RunsTable.tsx new file mode 100644 index 0000000..90315a9 --- /dev/null +++ b/frontend/src/components/deploy/pipeline-run-detail/RunsTable.tsx @@ -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; + 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 ( +
+ {loading && runs.length === 0 ? ( +
+ + {t('common.loading')} +
+ ) : runs.length === 0 ? ( +
+ +

{t('deploy.run.noRecordsHint')}

+
+ ) : ( + + + + + + + + + + + + + + + {runs.map(run => { + const StatusIcon = STATUS_ICONS[run.status] || Clock; + return ( + onSelectRun(run)}> + + + + + + + + + + ); + })} + +
+ 0} + onChange={onToggleAll} className="rounded" /> + {t('deploy.pipeline.buildNumber')}{t('deploy.table.status')}{t('deploy.table.trigger')}{t('deploy.table.user')}{t('deploy.table.duration')}{t('common.createdAt')}{t('common.actions')}
e.stopPropagation()}> + onToggleSelect(run.id)} className="rounded" /> + + #{run.build_number} + + + + {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')} + + + {triggerLabel(run)} + + + {run.triggered_by || 'admin'} + + {run.status === 'running' ? ( + + {t('deploy.pipeline.executing')} + + ) : formatDuration(run.duration)} + + {formatTime(run.created_at)} + e.stopPropagation()}> +
+ + + {run.status === 'running' && ( + + )} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/deploy/pipeline-run-detail/StageProgressBar.tsx b/frontend/src/components/deploy/pipeline-run-detail/StageProgressBar.tsx new file mode 100644 index 0000000..7096b7b --- /dev/null +++ b/frontend/src/components/deploy/pipeline-run-detail/StageProgressBar.tsx @@ -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 = { + 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 ( +
+ {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 ? + : isRunning ? + : isSuccess ? + : hasData ? ○ + : ; + + const isLast = i === STAGE_DEFS.length - 1; + const borderRadius = i === 0 ? '8px 0 0 8px' : isLast ? '0 8px 8px 0' : '0'; + + return ( +
+ + {!isLast && ( +
+ +
+ )} +
+ ); + })} + {selectedRun && ( + + {selectedStage ? stageLabel(selectedStage) : t('deploy.pipeline.allStages')} + + )} +
+ ); +} diff --git a/frontend/src/components/deploy/pipeline-run-detail/types.ts b/frontend/src/components/deploy/pipeline-run-detail/types.ts new file mode 100644 index 0000000..6a10b29 --- /dev/null +++ b/frontend/src/components/deploy/pipeline-run-detail/types.ts @@ -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 = { + 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 { + const map = new Map(); + 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 ''; +}