refactor(quality): Q11 前端巨型组件拆分 — PipelineRunDetail 678L→273L (-60%), 提取 types + StageProgressBar + RunsTable + LogConsole 4 个独立文件

This commit is contained in:
attacker committed 2026-07-07 07:42:03 +08:00
1 parent 2aed2bc218
commit 76aa8817c0
8 files changed
+632 -465

No files matched your search

@@ -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<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 '';
}
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<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 [runs, setRuns] = useState<PipelineRun[]>([]);
const [selectedRun, setSelectedRun] = useState<PipelineRun | null>(null);
const [logs, setLogs] = useState<LogEntry[]>([]);
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 [selectedIds, setSelectedIds] = useState<Set<number>>(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() {
)}
>
{/* ═══════════ 阶段进度条 (交互式) ═══════════ */}
<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 _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>
<StageProgressBar
selectedRun={selectedRun}
selectedStage={selectedStage}
onSelectStage={setSelectedStage}
/>
{/* ═══════════ 主内容区: 执行记录表 + 日志 ═══════════ */}
<div className="flex-1 flex flex-col min-h-0">
{/* ─── 执行记录表 ─── */}
<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={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>
<RunsTable
runs={runs}
loading={loading}
selectedRun={selectedRun}
selectedIds={selectedIds}
onSelectRun={setSelectedRun}
onToggleSelect={toggleSelect}
onToggleAll={toggleAll}
onStopRun={stopRun}
onBrowse={(run) => { setBrowserRun({ id: run.id, buildNumber: run.build_number }); setBrowserOpen(true); }}
/>
{/* ─── 阶段日志 Console ─── */}
{/* ═══════════ 阶段日志 Console ═══════════ */}
{selectedRun && (
<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">
{/* ─── 阶段摘要 (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>
<LogConsole
selectedRun={selectedRun}
pipeline={pipeline}
pipelineCode={pipelineCode}
selectedStage={selectedStage}
filteredLogs={filteredLogs}
stageSummary={stageSummary}
logEndRef={logEndRef}
/>
)}
</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 '';
}