import { useState, useEffect, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { Card, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Textarea } from '@/components/ui/textarea'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Play, Plus, Search, GitBranch, Clock, CheckCircle, XCircle, Loader2, StopCircle, FileText, Trash2, ChevronDown, ChevronRight, Rocket, Box, Workflow, Code, Container, ShieldCheck, Tag, Globe, ArrowRight, RotateCcw, Layers, History, Server, Network, } from 'lucide-react'; import { pipelineApi, deployApi } from '@/services/api'; import http from '@/services/http'; import { useConfirm } from '@/components/ui/confirm-dialog'; import { toast } from 'sonner'; import { nameToCode, getStatusBadge } from './pipeline-helpers'; // ====== Fixed stage order (logical pipeline sequence) ====== const STAGE_DEFS = [ { key: 'checkout', label: '代码检出', icon: GitBranch, color: '#6366F1', order: 1, needs: ['repo'], desc: '从 Git 仓库拉取代码' }, { key: 'build', label: '构建', icon: Box, color: '#F59E0B', order: 2, needs: [], desc: '编译/打包项目' }, { key: 'test', label: '测试', icon: CheckCircle, color: '#10B981', order: 3, needs: [], desc: '运行单元/集成测试' }, { key: 'image', label: '镜像打包', icon: Container, color: '#06B6D4', order: 4, needs: ['registry'], desc: 'Docker 构建 & 推送' }, { key: 'deploy', label: '部署', icon: Rocket, color: '#EF4444', order: 5, needs: [], desc: '部署到目标环境' }, { key: 'verify', label: '验证', icon: ShieldCheck, color: '#3B82F6', order: 6, needs: [], desc: '部署后健康检查' }, ].sort((a, b) => a.order - b.order); interface LogEntry { time: string; level: string; message: string; stage: string; } function fmtDuration(s: number | undefined): string { if (!s) return '-'; if (s < 60) return `${s}s`; const m = Math.floor(s / 60); const sec = s % 60; return sec > 0 ? `${m}分${sec}秒` : `${m}分`; } function parseStages(raw: any): any[] { if (!raw) return []; try { const s = typeof raw === 'string' ? JSON.parse(raw) : raw; return Array.isArray(s) ? s : []; } catch { return []; } } export function PipelineManager() { const [searchQuery, setSearchQuery] = useState(''); const [pipelines, setPipelines] = useState([]); const [loading, setLoading] = useState(true); const [expanded, setExpanded] = useState>({}); const [runs, setRuns] = useState>({}); const [loadingRuns, setLoadingRuns] = useState>({}); const [envFilter, setEnvFilter] = useState('all'); const [runningRunMap, setRunningRunMap] = useState>({}); // New pipeline dialog (comprehensive) const [newOpen, setNewOpen] = useState(false); const [newName, setNewName] = useState(''); const [newJobName, setNewJobName] = useState(''); const [newDesc, setNewDesc] = useState(''); const [newSource, setNewSource] = useState(''); const [newBranch, setNewBranch] = useState('main'); const [newDockerfile, setNewDockerfile] = useState(''); const [selectedStages, setSelectedStages] = useState(['checkout', 'build', 'image', 'deploy']); const [newRepoId, setNewRepoId] = useState(''); const [newRegistryId, setNewRegistryId] = useState(''); const [newDeployMode, setNewDeployMode] = useState('branch'); const [newDeployTarget, setNewDeployTarget] = useState('ssh'); const [newEnvironmentId, setNewEnvironmentId] = useState(''); const [saving, setSaving] = useState(false); // Credentials for Git/Docker const [gitCredentials, setGitCredentials] = useState([]); const [dockerCredentials, setDockerCredentials] = useState([]); const [newGitCredentialId, setNewGitCredentialId] = useState(''); const [newRegistryCredentialId, setNewRegistryCredentialId] = useState(''); // Trigger config const [newTriggerMode, setNewTriggerMode] = useState('manual'); const [newWatchBranches, setNewWatchBranches] = useState(''); const [newExcludeBranch, setNewExcludeBranch] = useState(''); const [newCronExpr, setNewCronExpr] = useState(''); const [newWebhookToken, setNewWebhookToken] = useState(''); // Env vars: [{ key: '', value: '', desc: '' }] const [newEnvVars, setNewEnvVars] = useState<{ key: string; value: string; desc: string }[]>([]); // Cache paths const [newCachePaths, setNewCachePaths] = useState(''); // Reference data const [repos, setRepos] = useState([]); const [registries, setRegistries] = useState([]); const [environments, setEnvironments] = useState([]); const [servers, setServers] = useState([]); const [clusters, setClusters] = useState([]); // Log dialog const [logOpen, setLogOpen] = useState(false); const [logRun, setLogRun] = useState(null); const [logs, setLogs] = useState([]); const _confirm = useConfirm(); const navigate = useNavigate(); const ENV_TYPES = [ { key: 'all', label: '全部', icon: Layers, color: '#6B7280' }, { key: 'production', label: '生产', icon: ShieldCheck, color: '#EF4444' }, { key: 'testing', label: '测试', icon: CheckCircle, color: '#F59E0B' }, { key: 'staging', label: '预发布', icon: Globe, color: '#8B5CF6' }, { key: 'development', label: '开发', icon: Code, color: '#3B82F6' }, ]; const loadRefs = useCallback(async () => { try { const [rR, rReg, rEnv, rCred] = await Promise.all([ deployApi.getRepos().catch(() => ({ list: [] })), deployApi.getRegistries().catch(() => ({ list: [] })), deployApi.getEnvironments().catch(() => ({ list: [] })), http.get('/credentials', { page_size: 100 }).catch(() => ({ list: [] })), ] as const); setRepos(Array.isArray((rR as any)?.list) ? (rR as any).list : []); setRegistries(Array.isArray((rReg as any)?.list) ? (rReg as any).list : []); setEnvironments(Array.isArray((rEnv as any)?.list) ? (rEnv as any).list : []); const allCreds: any[] = (rCred as any)?.list || []; setGitCredentials(allCreds.filter((c: any) => c.secret_type === 'api')); setDockerCredentials(allCreds.filter((c: any) => c.secret_type === 'docker')); } catch { /* silent */ } }, []); const loadPipelines = useCallback(async () => { setLoading(true); try { const data = await pipelineApi.getPipelines(); setPipelines(Array.isArray(data?.list) ? data.list : []); } catch { toast.error('获取流水线列表失败'); } finally { setLoading(false); } }, []); useEffect(() => { loadRefs(); loadPipelines(); }, []); useEffect(() => { pipelines.forEach(p => { if (p.last_status === 'running' && !runningRunMap[p.id]) { pipelineApi.getRuns(p.id).then((res: any) => { const runsList = res?.list || []; const running = runsList.find((r: any) => r.status === 'running' || r.status === 'pending'); if (running) setRunningRunMap(prev => ({ ...prev, [p.id]: running })); }).catch(() => {}); } }); }, [pipelines]); const loadRuns = async (pipelineId: number, code: string) => { setLoadingRuns(p => ({ ...p, [pipelineId]: true })); try { const res = await pipelineApi.getRuns(code); setRuns(p => ({ ...p, [pipelineId]: (res as any)?.list || [] })); } catch { setRuns(p => ({ ...p, [pipelineId]: [] })); } finally { setLoadingRuns(p => ({ ...p, [pipelineId]: false })); } }; const toggleExpand = (pipelineId: number, code: string) => { if (!expanded[pipelineId]) { loadRuns(pipelineId, code); setExpanded(p => ({ ...p, [pipelineId]: true })); } else setExpanded(p => ({ ...p, [pipelineId]: false })); }; const handleCreate = async () => { if (!newName.trim()) { toast.error('请输入流水线名称'); return; } if (selectedStages.includes('checkout') && !newSource.trim()) { toast.error('代码检出阶段需要填写 Git URL'); return; } if (selectedStages.includes('image') && !newRegistryCredentialId) { toast.error('镜像打包阶段需要关联 Docker 凭证'); return; } setSaving(true); try { const stageNames = selectedStages.map(k => STAGE_DEFS.find(s => s.key === k)?.label || k); const stages = JSON.stringify(stageNames.map((name, i) => ({ id: `stage-${i + 1}`, name, type: selectedStages[i] || 'custom', tasks: [], }))); await pipelineApi.createPipeline({ name: newName, code: nameToCode(newName), job_name: newJobName || undefined, description: newDesc, source: newSource, branch: newBranch, dockerfile: newDockerfile, type: 'deploy', stages, deploy_mode: newDeployMode, deploy_target: newDeployTarget, environment_id: newEnvironmentId ? Number(newEnvironmentId) : null, repo_id: null, registry_id: null, // 凭证关联 git_credential_id: newGitCredentialId ? Number(newGitCredentialId) : null, registry_credential_id: newRegistryCredentialId ? Number(newRegistryCredentialId) : null, // 触发配置 trigger_mode: newTriggerMode, watch_branches: newWatchBranches || undefined, exclude_branch: newExcludeBranch || undefined, cron_expr: newCronExpr || undefined, webhook_token: newWebhookToken || undefined, // 环境变量 variables: newEnvVars.length > 0 ? JSON.stringify(newEnvVars) : undefined, // 缓存路径 cache_paths: newCachePaths || undefined, }); toast.success('流水线已创建'); setNewOpen(false); resetForm(); loadPipelines(); } catch { toast.error('创建失败'); } finally { setSaving(false); } }; const resetForm = () => { setNewName(''); setNewJobName(''); setNewDesc(''); setNewSource(''); setNewBranch('main'); setNewDockerfile(''); setSelectedStages(['checkout', 'build', 'image', 'deploy']); setNewRepoId(''); setNewRegistryId(''); setNewDeployMode('branch'); setNewDeployTarget('ssh'); setNewEnvironmentId(''); setNewGitCredentialId(''); setNewRegistryCredentialId(''); setNewTriggerMode('manual'); setNewWatchBranches(''); setNewExcludeBranch(''); setNewCronExpr(''); setNewWebhookToken(''); setNewEnvVars([]); setNewCachePaths(''); }; const handleTrigger = async (pipelineId: number, code: string) => { try { await pipelineApi.triggerPipeline(code); toast.success('已触发'); loadRuns(pipelineId, code); loadPipelines(); } catch { toast.error('触发失败'); } }; const handleStop = async (pipelineId: number, code: string, runId: number | string) => { const ok = await _confirm({ title: '停止运行', description: '确定要停止此运行吗?', variant: 'destructive' }); if (!ok) return; try { await pipelineApi.stopRun(code, runId); toast.success('已停止'); loadRuns(pipelineId, code); setRunningRunMap(prev => { const n = { ...prev }; delete n[pipelineId]; return n; }); } catch { toast.error('停止失败'); } }; const handleStopRunning = async (pipelineId: number, code: string) => { const runningRun = runningRunMap[pipelineId]; if (!runningRun) { try { const res: any = await pipelineApi.getRuns(code); const running = (res?.list || []).find((r: any) => r.status === 'running' || r.status === 'pending'); if (running) await handleStop(pipelineId, code, running.id); else toast.error('未找到运行中的任务'); } catch { toast.error('停止失败'); } return; } await handleStop(pipelineId, code, runningRun.id); }; const handleViewLog = async (code: string, run: any) => { setLogRun(run); setLogOpen(true); try { const data = await pipelineApi.getRunLog(code, run.id) as any; setLogs(data?.logs || []); } catch { setLogs([]); } }; const handleDelete = async (pipeline: any) => { const ok = await _confirm({ title: '删除流水线', description: `确定删除「${pipeline.name}」?`, variant: 'destructive' }); if (!ok) return; try { await pipelineApi.deletePipeline(pipeline.code); toast.success('已删除'); loadPipelines(); } catch { toast.error('删除失败'); } }; const filtered = pipelines.filter(p => { const matchSearch = (p.name || '').toLowerCase().includes(searchQuery.toLowerCase()) || (p.description || '').toLowerCase().includes(searchQuery.toLowerCase()); const matchEnv = envFilter === 'all' || (p.environment?.type || '') === envFilter; return matchSearch && matchEnv; }); const stats = { total: pipelines.length, running: pipelines.filter(p => p.last_status === 'running').length, success: pipelines.filter(p => p.last_status === 'success').length, failed: pipelines.filter(p => p.last_status === 'failed').length, }; return (

流水线

CI/CD 构建 · 部署 · 发布一体化

{[ { label: '流水线总数', val: stats.total, icon: Workflow, color: '#3B82F6' }, { label: '运行中', val: stats.running, icon: Loader2, color: '#3B82F6' }, { label: '成功', val: stats.success, icon: CheckCircle, color: '#10B981' }, { label: '失败', val: stats.failed, icon: XCircle, color: '#EF4444' }, ].map((s, i) => (

{s.label}

{s.val}

))}
setSearchQuery(e.target.value)} className="pl-10 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
{ENV_TYPES.map(et => { const active = envFilter === et.key; const Icon = et.icon; return ( ); })}
{loading ? (
) : filtered.length === 0 ? (

暂无流水线

) : (
{filtered.map(pipeline => { const stages = parseStages(pipeline.stages); const pipelineRuns = runs[pipeline.id] || []; const isExpanded = expanded[pipeline.id]; const isLoadingRuns = loadingRuns[pipeline.id]; const repo = pipeline.repo; const registry = pipeline.registry; return (

{pipeline.name}

{getStatusBadge(pipeline.last_status)} {pipeline.run_count > 0 ? `运行 ${pipeline.run_count} 次` : '未执行'} {pipeline.job_name && {pipeline.job_name}} {pipeline.environment && {pipeline.environment.display_name}}

{pipeline.description || '暂无描述'}

{pipeline.deploy_mode === 'tag' ? 'Tag发版' : pipeline.branch || 'main'} {(pipeline.deploy_target || 'ssh') === 'k8s' ? <>K8s : <>SSH} {repo && {repo.name}} {registry && {registry.name}} {pipeline.last_run_at && {new Date(pipeline.last_run_at).toLocaleString('zh-CN')}}
{pipeline.last_status === 'running' ? ( ) : ( )}
{stages.length > 0 && (
{stages.map((s: any, i: number) => { const def = STAGE_DEFS.find(d => d.label === s.name); const color = def?.color || '#6B7280'; const Icon = def?.icon || Tag; return (
{s.name}
{i < stages.length - 1 && }
); })}
)}
{isExpanded && (
{isLoadingRuns ? (
) : pipelineRuns.length === 0 ? (
暂无执行记录
) : ( {pipelineRuns.map((r: any) => ( ))}
# 状态 触发人 耗时 时间 操作
#{r.build_number || r.id} {r.status === 'success' ? 成功 : r.status === 'failed' ? 失败 : r.status === 'running' ? 执行中 : r.status === 'cancelled' ? 已停止 : {r.status}} {r.triggered_by || 'admin'} {fmtDuration(r.duration)} {r.created_at ? new Date(r.created_at).toLocaleString('zh-CN') : '-'}
{(r.status === 'running' || r.status === 'pending') && ( )}
)}
)}
); })}
)} {/* ══════════ New Pipeline Dialog (Redesigned) ══════════ */} 新建流水线
{/* 基本信息 */}
setNewName(e.target.value)} placeholder="例如: demo-jar 后端部署" className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-9" />
setNewJobName(e.target.value)} placeholder="cex-admin-server" className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-9 font-mono text-sm" /> {newJobName &&

Webhook: POST .../webhook/job/{newJobName}

}