Files
aiops/frontend/src/components/deploy/PipelineManager.tsx
T
attacker 24f955832e feat: 发布权限 RBAC + PipelineEditor 修复 + 菜单优化
- 新增 DeployRBAC 页面: Jenkins-style Item Role 权限管理
  - 正则匹配流水线 code,实时命中预览
  - 内置用户分配面板,无需跳转系统管理
  - 支持创建/编辑/删除/启用禁用 Item Role
- 修复 PipelineEditor 成员管理 http 双解包 bug
- 修复 PipelineManager 前端 code 路由迁移(delete/trigger/stop/loadRuns)
- 新增批量删除流水线运行记录 API
- Sidebar: 应用发布下新增「发布权限」, 仓库认证→发布配置, 移除用户组
- 恢复系统管理菜单(不受发布权限改动影响)
- 后端 ItemRole CRUD handler + 正则匹配中间件已就绪
2026-06-21 05:26:01 +00:00

756 lines
47 KiB
TypeScript

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<any[]>([]);
const [loading, setLoading] = useState(true);
const [expanded, setExpanded] = useState<Record<number, boolean>>({});
const [runs, setRuns] = useState<Record<number, any[]>>({});
const [loadingRuns, setLoadingRuns] = useState<Record<number, boolean>>({});
const [envFilter, setEnvFilter] = useState<string>('all');
const [runningRunMap, setRunningRunMap] = useState<Record<number, any>>({});
// 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<string[]>(['checkout', 'build', 'image', 'deploy']);
const [newRepoId, setNewRepoId] = useState<number | string>('');
const [newRegistryId, setNewRegistryId] = useState<number | string>('');
const [newDeployMode, setNewDeployMode] = useState('branch');
const [newDeployTarget, setNewDeployTarget] = useState('ssh');
const [newEnvironmentId, setNewEnvironmentId] = useState<number | string>('');
const [saving, setSaving] = useState(false);
// Credentials for Git/Docker
const [gitCredentials, setGitCredentials] = useState<any[]>([]);
const [dockerCredentials, setDockerCredentials] = useState<any[]>([]);
const [newGitCredentialId, setNewGitCredentialId] = useState<number | string>('');
const [newRegistryCredentialId, setNewRegistryCredentialId] = useState<number | string>('');
// 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<any[]>([]);
const [registries, setRegistries] = useState<any[]>([]);
const [environments, setEnvironments] = useState<any[]>([]);
const [servers, setServers] = useState<any[]>([]);
const [clusters, setClusters] = useState<any[]>([]);
// Log dialog
const [logOpen, setLogOpen] = useState(false);
const [logRun, setLogRun] = useState<any>(null);
const [logs, setLogs] = useState<LogEntry[]>([]);
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 (
<div className="space-y-5">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-[var(--dg-text-primary)]">流水线</h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">CI/CD 构建 · 部署 · 发布一体化</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]" onClick={loadPipelines}>
<Clock className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />刷新
</Button>
<Button onClick={() => setNewOpen(true)}><Plus className="h-4 w-4 mr-2" />新建流水线</Button>
</div>
</div>
<div className="grid grid-cols-4 gap-4">
{[
{ 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) => (
<Card key={i} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4 flex items-center justify-between">
<div><p className="text-[var(--dg-text-secondary)] text-sm">{s.label}</p><p className="text-2xl font-bold text-[var(--dg-text-primary)]">{s.val}</p></div>
<div className="w-10 h-10 rounded-lg flex items-center justify-center" style={{ backgroundColor: s.color + '20' }}><s.icon className="h-5 w-5" style={{ color: s.color }} /></div>
</CardContent>
</Card>
))}
</div>
<div className="flex items-center gap-4">
<div className="relative max-w-md flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--dg-text-secondary)]" />
<Input placeholder="搜索流水线..." value={searchQuery} onChange={e => setSearchQuery(e.target.value)}
className="pl-10 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
<div className="flex items-center gap-1 bg-[var(--dg-bg-secondary)] rounded-lg p-1 border border-[var(--dg-border)]">
{ENV_TYPES.map(et => {
const active = envFilter === et.key;
const Icon = et.icon;
return (
<button key={et.key} onClick={() => setEnvFilter(et.key)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
active ? 'text-white shadow-sm' : 'text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]'}`}
style={active ? { backgroundColor: et.color } : {}}>
<Icon className="h-3.5 w-3.5" />{et.label}
</button>
);
})}
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-12"><Loader2 className="h-6 w-6 text-[var(--dg-text-secondary)] animate-spin" /></div>
) : filtered.length === 0 ? (
<div className="text-center py-16 text-[var(--dg-text-secondary)]">
<Workflow className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p className="text-sm">暂无流水线</p>
<Button variant="outline" className="mt-4" onClick={() => setNewOpen(true)}><Plus className="h-4 w-4 mr-1" />创建第一个流水线</Button>
</div>
) : (
<div className="space-y-3">
{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 (
<Card key={pipeline.id} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] overflow-hidden">
<CardContent className="p-0">
<div className="px-4 py-3">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold text-[var(--dg-text-primary)]">{pipeline.name}</h3>
{getStatusBadge(pipeline.last_status)}
<Badge variant="outline" className="text-xs text-[var(--dg-text-secondary)] border-[var(--dg-border)]">
{pipeline.run_count > 0 ? `运行 ${pipeline.run_count} 次` : '未执行'}
</Badge>
{pipeline.job_name && <Badge variant="secondary" className="text-xs font-mono opacity-60">{pipeline.job_name}</Badge>}
{pipeline.environment && <Badge style={{backgroundColor: pipeline.environment.color + '20', color: pipeline.environment.color}} className="text-xs">{pipeline.environment.display_name}</Badge>}
</div>
<p className="text-xs text-[var(--dg-text-secondary)] mt-1 line-clamp-1">{pipeline.description || '暂无描述'}</p>
<div className="flex items-center gap-3 mt-1 text-xs text-[var(--dg-text-secondary)] opacity-60">
<span className="flex items-center gap-1"><GitBranch className="h-3 w-3" />{pipeline.deploy_mode === 'tag' ? 'Tag发版' : pipeline.branch || 'main'}</span>
<span className="flex items-center gap-1">{(pipeline.deploy_target || 'ssh') === 'k8s' ? <><Network className="h-3 w-3" />K8s</> : <><Server className="h-3 w-3" />SSH</>}</span>
{repo && <span className="flex items-center gap-1"><Globe className="h-3 w-3" />{repo.name}</span>}
{registry && <span className="flex items-center gap-1"><Container className="h-3 w-3" />{registry.name}</span>}
{pipeline.last_run_at && <span><Clock className="h-3 w-3 inline mr-0.5" />{new Date(pipeline.last_run_at).toLocaleString('zh-CN')}</span>}
</div>
</div>
<div className="flex items-center gap-1 ml-4 shrink-0">
{pipeline.last_status === 'running' ? (
<Button variant="destructive" size="sm" className="h-8" onClick={() => handleStopRunning(pipeline.id, pipeline.code)}>
<StopCircle className="h-3.5 w-3.5 mr-1" />停止
</Button>
) : (
<Button variant="default" size="sm" className="h-8" onClick={() => handleTrigger(pipeline.id, pipeline.code)}>
<Play className="h-3.5 w-3.5 mr-1" />执行
</Button>
)}
<Button variant="outline" size="sm" className="h-8" onClick={() => navigate(`/pipelines/${pipeline.code || pipeline.id}`)}>编辑</Button>
<Button variant="outline" size="sm" className="h-8" onClick={() => navigate(`/pipelines/${pipeline.code || pipeline.id}/runs`)}>
<History className="h-3.5 w-3.5 mr-1" />记录</Button>
<Button variant="ghost" size="sm" className="h-8" onClick={() => toggleExpand(pipeline.id, pipeline.code)}>
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-500" onClick={() => handleDelete(pipeline)}><Trash2 className="h-4 w-4" /></Button>
</div>
</div>
{stages.length > 0 && (
<div className="flex items-center gap-1 mt-3 flex-wrap">
{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 (
<div key={i} className="flex items-center">
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-xs"
style={{ borderColor: color + '40', backgroundColor: color + '15', color: color }}>
<Icon className="h-3 w-3" />{s.name}
</div>
{i < stages.length - 1 && <ArrowRight className="h-3 w-3 text-[var(--dg-border)] mx-0.5" />}
</div>
);
})}
</div>
)}
</div>
{isExpanded && (
<div className="border-t border-[var(--dg-border)]">
{isLoadingRuns ? (
<div className="flex items-center justify-center py-6"><Loader2 className="h-5 w-5 animate-spin text-[var(--dg-text-secondary)]" /></div>
) : pipelineRuns.length === 0 ? (
<div className="py-6 text-center text-[var(--dg-text-secondary)] text-sm">暂无执行记录</div>
) : (
<table className="w-full text-sm">
<thead className="border-b border-[var(--dg-border)]"><tr className="bg-[var(--dg-bg-tertiary)]">
<th className="px-4 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">#</th>
<th className="px-4 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">状态</th>
<th className="px-4 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">触发人</th>
<th className="px-4 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">耗时</th>
<th className="px-4 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">时间</th>
<th className="px-4 py-2 text-right text-xs font-medium text-[var(--dg-text-secondary)]">操作</th>
</tr></thead>
<tbody>
{pipelineRuns.map((r: any) => (
<tr key={r.id} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
<td className="px-4 py-2 font-medium text-[var(--dg-text-primary)]">#{r.build_number || r.id}</td>
<td className="px-4 py-2">
{r.status === 'success' ? <Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30 text-[11px]"><CheckCircle className="h-3 w-3 mr-0.5" />成功</Badge> :
r.status === 'failed' ? <Badge className="bg-red-500/20 text-red-400 border-red-500/30 text-[11px]"><XCircle className="h-3 w-3 mr-0.5" />失败</Badge> :
r.status === 'running' ? <Badge className="bg-blue-500/20 text-blue-400 border-blue-500/30 text-[11px]"><Loader2 className="h-3 w-3 animate-spin mr-0.5" />执行中</Badge> :
r.status === 'cancelled' ? <Badge className="bg-yellow-500/20 text-yellow-400 border-yellow-500/30 text-[11px]"><StopCircle className="h-3 w-3 mr-0.5" />已停止</Badge> :
<Badge variant="secondary" className="text-[11px]">{r.status}</Badge>}
</td>
<td className="px-4 py-2 text-[var(--dg-text-secondary)] text-xs">{r.triggered_by || 'admin'}</td>
<td className="px-4 py-2 text-[var(--dg-text-secondary)] text-xs">{fmtDuration(r.duration)}</td>
<td className="px-4 py-2 text-[var(--dg-text-secondary)] text-xs">{r.created_at ? new Date(r.created_at).toLocaleString('zh-CN') : '-'}</td>
<td className="px-4 py-2 text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" className="h-7 text-xs text-blue-400 hover:text-blue-300" onClick={() => handleViewLog(pipeline.code, r)}><FileText className="h-3.5 w-3.5 mr-1" />日志</Button>
{(r.status === 'running' || r.status === 'pending') && (
<Button variant="ghost" size="sm" className="h-7 text-xs text-red-400 hover:text-red-300" onClick={() => handleStop(pipeline.id, pipeline.code, r.id)}><StopCircle className="h-3.5 w-3.5 mr-1" />停止</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
</CardContent>
</Card>
);
})}
</div>
)}
{/* ══════════ New Pipeline Dialog (Redesigned) ══════════ */}
<Dialog open={newOpen} onOpenChange={setNewOpen}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<DialogHeader><DialogTitle className="flex items-center gap-2"><Rocket className="h-5 w-5" />新建流水线</DialogTitle></DialogHeader>
<div className="space-y-4">
{/* 基本信息 */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>名称 <span className="text-red-400">*</span></Label>
<Input value={newName} onChange={e => setNewName(e.target.value)} placeholder="例如: demo-jar 后端部署" className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-9" />
</div>
<div className="space-y-1">
<Label>Job 名称 <span className="text-xs text-[var(--dg-text-secondary)]">(Webhook URL)</span></Label>
<Input value={newJobName} onChange={e => 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 && <p className="text-[10px] text-[var(--dg-text-secondary)] opacity-60">Webhook: POST .../webhook/job/<b>{newJobName}</b></p>}
</div>
</div>
<div className="space-y-1"><Label>描述</Label><Textarea value={newDesc} onChange={e => setNewDesc(e.target.value)} placeholder="流水线描述..." rows={2} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]" /></div>
{/* 部署模式 & 部署目标 */}
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<Label>部署模式</Label>
<div className="flex gap-1 bg-[var(--dg-bg-secondary)] rounded-lg p-1 border border-[var(--dg-border)]">
{[
{ key: 'branch', label: '分支', icon: GitBranch },
{ key: 'tag', label: 'Tag 发版', icon: Tag },
].map(m => {
const active = newDeployMode === m.key;
return (
<button key={m.key} onClick={() => setNewDeployMode(m.key)}
className={`flex-1 flex items-center justify-center gap-1 px-2 py-1.5 rounded-md text-xs font-medium transition-all ${
active ? 'bg-blue-500 text-white' : 'text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]'}`}>
<m.icon className="h-3.5 w-3.5" />{m.label}
</button>
);
})}
</div>
</div>
<div className="space-y-1">
<Label>部署目标</Label>
<div className="flex gap-1 bg-[var(--dg-bg-secondary)] rounded-lg p-1 border border-[var(--dg-border)]">
{[
{ key: 'ssh', label: 'SSH 主机', icon: Server },
{ key: 'k8s', label: 'K8s 集群', icon: Network },
].map(t => {
const active = newDeployTarget === t.key;
return (
<button key={t.key} onClick={() => setNewDeployTarget(t.key)}
className={`flex-1 flex items-center justify-center gap-1 px-2 py-1.5 rounded-md text-xs font-medium transition-all ${
active ? 'bg-green-500 text-white' : 'text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]'}`}>
<t.icon className="h-3.5 w-3.5" />{t.label}
</button>
);
})}
</div>
</div>
<div className="space-y-1">
<Label>环境分组</Label>
<Select value={String(newEnvironmentId)} onValueChange={v => setNewEnvironmentId(v === '__none' ? '' : v)}>
<SelectTrigger className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-9"><SelectValue placeholder="选择环境" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none">不指定</SelectItem>
{environments.map(env => (
<SelectItem key={env.id} value={String(env.id)}>
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full inline-block" style={{ backgroundColor: env.color }} />
{env.display_name}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Git 仓库 & 镜像仓库 (手动输入 + 凭证关联) */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>Git URL {selectedStages.includes('checkout') && <span className="text-red-400">*</span>}</Label>
<Input value={newSource} onChange={e => setNewSource(e.target.value)}
placeholder="https://gitee.com/user/repo.git" className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-9 font-mono text-xs" />
</div>
<div className="space-y-1">
<Label>分支 / Tag</Label>
<Input value={newBranch} onChange={e => setNewBranch(e.target.value)}
placeholder={newDeployMode === 'tag' ? 'v1.0.0' : 'main'} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-9" />
</div>
<div className="space-y-1">
<Label>Git 凭证 <span className="text-xs text-[var(--dg-text-secondary)] opacity-60">(API Token)</span></Label>
<Select value={String(newGitCredentialId)} onValueChange={v => setNewGitCredentialId(v === '__none' ? '' : v)}>
<SelectTrigger className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-9"><SelectValue placeholder="选择凭证" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none">不关联</SelectItem>
{gitCredentials.map(c => (
<SelectItem key={c.id} value={String(c.id)}>
<span className="flex items-center gap-1.5"><Globe className="h-3 w-3 opacity-60" />{c.name} {c.summary && <span className="text-[10px] opacity-50">({c.summary})</span>}</span>
</SelectItem>
))}
</SelectContent>
</Select>
{!newGitCredentialId && selectedStages.includes('checkout') && <p className="text-xs text-amber-400">建议关联凭证</p>}
</div>
<div className="space-y-1">
<Label>镜像仓库凭证 {selectedStages.includes('image') && <span className="text-red-400">*</span>}</Label>
<Select value={String(newRegistryCredentialId)} onValueChange={v => setNewRegistryCredentialId(v === '__none' ? '' : v)}>
<SelectTrigger className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-9"><SelectValue placeholder="选择 Docker 凭证" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none">不关联</SelectItem>
{dockerCredentials.map(c => (
<SelectItem key={c.id} value={String(c.id)}>
<span className="flex items-center gap-1.5"><Container className="h-3 w-3 opacity-60" />{c.name} {c.summary && <span className="text-[10px] opacity-50">({c.summary})</span>}</span>
</SelectItem>
))}
</SelectContent>
</Select>
{!newRegistryCredentialId && selectedStages.includes('image') && <p className="text-xs text-amber-400">需要关联 Docker 凭证</p>}
</div>
</div>
{/* 镜像命名提示 */}
{newRegistryCredentialId && newJobName && (
<div className="p-2.5 rounded-md bg-blue-500/5 border border-blue-500/20 text-xs text-[var(--dg-text-secondary)]">
<span className="text-blue-400">镜像命名:</span>
<code className="text-[var(--dg-text-primary)]">{'{registry}'}/{'{namespace}'}/{'{'}{'{JOB_BASE_NAME}'}{'}'}:{'{'}{'{tag}'}{'}'}</code>
<span className="ml-2 opacity-60">例:</span>
<code className="opacity-60">harbor.ast1001.com/test/{newJobName}:latest</code>
</div>
)}
<div className="space-y-1"><Label>Dockerfile 路径</Label><Input value={newDockerfile} onChange={e => setNewDockerfile(e.target.value)} placeholder="jar/Dockerfile" className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-9 font-mono text-xs" /></div>
{/* Fixed-order stage selector */}
<div className="space-y-2">
<Label>发布流程 <span className="text-xs text-[var(--dg-text-secondary)] opacity-70 ml-1">按顺序选择阶段</span></Label>
<div className="flex flex-wrap gap-2">
{STAGE_DEFS.map(def => {
const active = selectedStages.includes(def.key);
return (
<Badge key={def.key}
className={`cursor-pointer transition-all text-xs px-3 py-1.5 ${active ? 'border-current' : 'border-[var(--dg-border)] opacity-50 hover:opacity-80'}`}
style={active ? { backgroundColor: def.color + '20', color: def.color, borderColor: def.color } : {}}
onClick={() => setSelectedStages(active ? selectedStages.filter(t => t !== def.key) : [...selectedStages, def.key])}
title={def.desc}>
<def.icon className="h-3 w-3 mr-1" />{def.label} {def.needs.includes('repo') ? '🔗' : ''}{def.needs.includes('registry') ? '📦' : ''}
</Badge>
);
})}
</div>
{selectedStages.length > 0 && (
<div className="flex items-center gap-1 mt-2 flex-wrap">
{selectedStages.map((key, i) => {
const def = STAGE_DEFS.find(t => t.key === key)!;
return (
<div key={i} className="flex items-center">
<Badge className="text-[10px] px-2 py-0.5" style={{ backgroundColor: def.color + '20', color: def.color, borderColor: def.color + '40' }}>
<def.icon className="h-3 w-3 mr-0.5" />{def.label}
</Badge>
{i < selectedStages.length - 1 && <ArrowRight className="h-3 w-3 text-[var(--dg-border)] mx-0.5" />}
</div>
);
})}
</div>
)}
</div>
{/* ══════════ 触发配置 ══════════ */}
<div className="border-t border-[var(--dg-border)] pt-4">
<Label className="mb-2 block">触发方式</Label>
<div className="flex gap-1 bg-[var(--dg-bg-secondary)] rounded-lg p-1 border border-[var(--dg-border)]">
{[
{ key: 'manual', label: '手动', desc: '手动触发执行' },
{ key: 'git_push', label: '代码推送', desc: 'Git Push 自动触发' },
{ key: 'cron', label: '定时', desc: 'Cron 定时执行' },
{ key: 'webhook', label: 'Webhook', desc: '外部 Webhook 调用' },
].map(t => {
const active = newTriggerMode === t.key;
return (
<button key={t.key} onClick={() => setNewTriggerMode(t.key)}
className={`flex-1 flex flex-col items-center gap-0.5 px-2 py-2 rounded-md text-xs font-medium transition-all ${
active ? 'bg-blue-500 text-white' : 'text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]'}`}
title={t.desc}>
{t.label}
</button>
);
})}
</div>
{/* Git Push 配置 */}
{newTriggerMode === 'git_push' && (
<div className="mt-3 space-y-2 p-3 rounded-md bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]">
<div className="space-y-1">
<Label className="text-xs">监听分支 <span className="text-[var(--dg-text-secondary)] opacity-60">(逗号分隔,支持 * 通配)</span></Label>
<Input value={newWatchBranches} onChange={e => setNewWatchBranches(e.target.value)}
placeholder="release/*, main" className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] h-8 font-mono text-xs" />
</div>
<div className="space-y-1">
<Label className="text-xs">排除分支 <span className="text-[var(--dg-text-secondary)] opacity-60">(不触发构建)</span></Label>
<Input value={newExcludeBranch} onChange={e => setNewExcludeBranch(e.target.value)}
placeholder="wip/*, draft/*" className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] h-8 font-mono text-xs" />
</div>
</div>
)}
{/* Cron 配置 */}
{newTriggerMode === 'cron' && (
<div className="mt-3 space-y-2 p-3 rounded-md bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]">
<div className="space-y-1">
<Label className="text-xs">Cron 表达式 <span className="text-[var(--dg-text-secondary)] opacity-60">(标准 5 位)</span></Label>
<Input value={newCronExpr} onChange={e => setNewCronExpr(e.target.value)}
placeholder="0 2 * * *" className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] h-8 font-mono text-xs" />
</div>
<div className="flex flex-wrap gap-1.5">
{[
{ expr: '*/5 * * * *', label: '每5分钟' },
{ expr: '0 * * * *', label: '每小时' },
{ expr: '0 2 * * *', label: '每天2点' },
{ expr: '0 0 * * 0', label: '每周日' },
].map(c => (
<Badge key={c.expr} variant="outline" className="cursor-pointer text-[10px] hover:bg-blue-500/10"
onClick={() => setNewCronExpr(c.expr)}>{c.label}</Badge>
))}
</div>
</div>
)}
{/* Webhook 配置 */}
{newTriggerMode === 'webhook' && (
<div className="mt-3 space-y-2 p-3 rounded-md bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]">
<div className="space-y-1">
<Label className="text-xs">Webhook Token <span className="text-[var(--dg-text-secondary)] opacity-60">(签名验证,可选)</span></Label>
<Input value={newWebhookToken} onChange={e => setNewWebhookToken(e.target.value)}
placeholder="随机字符串" className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] h-8 font-mono text-xs" />
</div>
<p className="text-[10px] text-[var(--dg-text-secondary)] opacity-60">
Webhook URL: <code className="text-[var(--dg-blue)]">POST /api/v1/webhook/pipeline/{'{code}'}</code>
</p>
</div>
)}
</div>
{/* ══════════ 环境变量 ══════════ */}
<div className="border-t border-[var(--dg-border)] pt-4">
<div className="flex items-center justify-between mb-2">
<Label>环境变量 <span className="text-xs text-[var(--dg-text-secondary)] opacity-60">(${'{VAR_NAME}'} 引用)</span></Label>
<Button variant="ghost" size="sm" className="h-7 text-xs"
onClick={() => setNewEnvVars([...newEnvVars, { key: '', value: '', desc: '' }])}>
<Plus className="h-3 w-3 mr-1" />添加变量
</Button>
</div>
{newEnvVars.length === 0 ? (
<p className="text-xs text-[var(--dg-text-muted)] py-2">暂无变量,点击"添加变量"新增</p>
) : (
<div className="space-y-2">
{newEnvVars.map((v, i) => (
<div key={i} className="grid grid-cols-12 gap-2 items-start p-2 rounded-md bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]">
<Input value={v.key} onChange={e => {
const nv = [...newEnvVars]; nv[i] = { ...nv[i], key: e.target.value }; setNewEnvVars(nv);
}} placeholder="变量名" className="col-span-3 h-8 text-xs font-mono bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
<Input value={v.value} onChange={e => {
const nv = [...newEnvVars]; nv[i] = { ...nv[i], value: e.target.value }; setNewEnvVars(nv);
}} placeholder="变量值" className="col-span-4 h-8 text-xs font-mono bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
<Input value={v.desc} onChange={e => {
const nv = [...newEnvVars]; nv[i] = { ...nv[i], desc: e.target.value }; setNewEnvVars(nv);
}} placeholder="说明" className="col-span-4 h-8 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
<Button variant="ghost" size="icon" className="col-span-1 h-8 w-8 text-red-400"
onClick={() => setNewEnvVars(newEnvVars.filter((_, idx) => idx !== i))}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
)}
</div>
{/* ══════════ 缓存配置 ══════════ */}
<div className="border-t border-[var(--dg-border)] pt-4">
<Label className="mb-2 block">构建缓存 <span className="text-xs text-[var(--dg-text-secondary)] opacity-60">(加速后续构建)</span></Label>
<Input value={newCachePaths} onChange={e => setNewCachePaths(e.target.value)}
placeholder="node_modules, .m2, target" className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-8 text-xs font-mono" />
<p className="text-[10px] text-[var(--dg-text-secondary)] opacity-50 mt-1">多个路径用逗号分隔,相同的 Key 会命中已有缓存</p>
</div>
</div>
<div className="flex justify-end gap-2 mt-4 pt-4 border-t border-[var(--dg-border)]">
<Button variant="outline" onClick={() => setNewOpen(false)}>取消</Button>
<Button onClick={handleCreate} disabled={saving}>
{saving ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Rocket className="h-4 w-4 mr-1" />}
{saving ? '创建中...' : '创建流水线'}
</Button>
</div>
</DialogContent>
</Dialog>
{/* Log Dialog */}
<Dialog open={logOpen} onOpenChange={setLogOpen}>
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<DialogHeader><DialogTitle className="flex items-center gap-2"><FileText className="h-5 w-5" />执行日志 #{logRun?.build_number || logRun?.id}</DialogTitle></DialogHeader>
<div className="space-y-3">
<div className="flex flex-wrap gap-3 text-sm text-[var(--dg-text-secondary)]">
<span>触发方式: {logRun?.triggered_by || 'manual'}</span>
<span>分支: {logRun?.branch || '-'}</span>
<span>耗时: {fmtDuration(logRun?.duration)}</span>
</div>
<div className="bg-slate-950 rounded-md p-3 max-h-[50vh] overflow-y-auto font-mono text-xs">
{logs.length === 0 ? <div className="text-slate-500 py-4 text-center">暂无日志</div> :
logs.map((l: LogEntry, i: number) => (
<div key={i} className="flex gap-2 py-0.5">
<span className="text-slate-500 shrink-0">[{l.time}]</span>
<span className={l.level === 'error' ? 'text-red-400' : l.level === 'warn' ? 'text-yellow-400' : l.level === 'success' ? 'text-green-400' : 'text-slate-300'}>
{l.level === 'success' ? '✓' : l.level === 'error' ? '✗' : l.level === 'warn' ? '!' : '>'} {l.message}
</span>
</div>
))}
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}