refactor(frontend): P1-P2 架构统一 — utils/hooks/api 模块化

P1:
- utils/storage.ts、theme.ts、validate.ts + index 统一出口
- request/store/useTheme 接入 storage;清零 confirm/alert
- PipelineManager、TerminalAuditTab 拆分子模块(<700行红线)

P2:
- api/monitor、api/deploy 子目录化
- hooks/api 扩展:servers/pipelines/alerts/audit/sessionCommands
- PipelineManager、AuditOperationsTab 接入 TanStack Query
This commit is contained in:
Logan.Li committed 2026-06-26 00:18:14 +08:00
1 parent d19a03d427
commit f45e203e21
196 files changed
+3228 -2018

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,230 @@
import { useNavigate } from 'react-router-dom';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Play,
GitBranch,
Clock,
CheckCircle,
XCircle,
Loader2,
StopCircle,
Trash2,
ChevronDown,
ChevronRight,
Container,
Globe,
History,
Server,
Network,
ArrowRight,
Package,
ChevronRight as ChevronRightSmall,
} from 'lucide-react';
import { getStatusBadge } from '../pipeline-helpers';
import { STAGE_DEFS, fmtDuration, parseStages } from './constants';
import { PipelineRunPanel } from './PipelineRunPanel';
interface PipelineCardProps {
pipeline: Record<string, unknown>;
isExpanded: boolean;
isLoadingRuns: boolean;
pipelineRuns: Record<string, unknown>[];
onToggleExpand: () => void;
onTrigger: () => void;
onStopRunning: () => void;
onDelete: () => void;
onStopRun: (runId: number | string) => void;
}
// 单条流水线卡片(含展开/折叠) // Logan.Li
export function PipelineCard({
pipeline,
isExpanded,
isLoadingRuns,
pipelineRuns,
onToggleExpand,
onTrigger,
onStopRunning,
onDelete,
onStopRun,
}: PipelineCardProps) {
const navigate = useNavigate();
const stages = parseStages(pipeline.stages);
const repo = pipeline.repo as Record<string, unknown> | undefined;
const registry = pipeline.registry as Record<string, unknown> | undefined;
const environment = pipeline.environment as Record<string, unknown> | undefined;
const code = (pipeline.code || pipeline.id) as string;
return (
<Card 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)]">{String(pipeline.name)}</h3>
{(pipeline.run_count as number) > 0 && (
<Badge variant="outline" className="text-[11px] text-[var(--dg-text-secondary)] border-[var(--dg-border)]">
#{String(pipeline.run_count)}
</Badge>
)}
{getStatusBadge(pipeline.last_status)}
{environment && (
<Badge
style={{ backgroundColor: `${environment.color}20`, color: environment.color as string }}
className="text-xs"
>
{String(environment.display_name)}
</Badge>
)}
{pipeline.job_name && (
<Badge variant="secondary" className="text-xs font-mono opacity-60">{String(pipeline.job_name)}</Badge>
)}
</div>
<p className="text-xs text-[var(--dg-text-secondary)] mt-1 line-clamp-1">
{String(pipeline.description || '暂无描述')}
</p>
{/* 最近运行摘要行:branch + commit → image:tag // Logan.Li */}
{(pipeline.last_run_branch || pipeline.last_commit_id || pipeline.last_image_tag) && (
<div className="flex items-center gap-1 mt-1.5 text-xs font-mono">
{pipeline.last_run_branch && (
<span className="flex items-center gap-0.5 text-indigo-400">
<GitBranch className="h-3 w-3" />{String(pipeline.last_run_branch)}
</span>
)}
{pipeline.last_run_branch && pipeline.last_commit_id && (
<span className="text-[var(--dg-text-secondary)] opacity-50 text-[10px] mx-0.5">+</span>
)}
{pipeline.last_commit_id && (
<code className="text-amber-400 bg-amber-400/10 px-1 py-0.5 rounded text-[11px]">
{String(pipeline.last_commit_id).slice(0, 8)}
</code>
)}
{(pipeline.last_run_branch || pipeline.last_commit_id) && pipeline.last_image_tag && (
<ArrowRight className="h-3 w-3 text-[var(--dg-text-secondary)] opacity-40 mx-0.5" />
)}
{pipeline.last_image_tag && (
<span className="flex items-center gap-0.5 text-cyan-400">
<Package className="h-3 w-3" />
<code className="bg-cyan-400/10 px-1 py-0.5 rounded text-[11px]">{String(pipeline.last_image_tag)}</code>
</span>
)}
{(pipeline.last_duration as number) > 0 && (
<span className="flex items-center gap-0.5 ml-auto text-[var(--dg-text-secondary)] opacity-50">
<Clock className="h-3 w-3" />{fmtDuration(pipeline.last_duration as number)}
</span>
)}
</div>
)}
<div className="flex items-center gap-3 mt-1.5 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发版' : String(pipeline.branch || 'main')}
</span>
<span className="flex items-center gap-1">
{(pipeline.deploy_target || 'ssh') === 'k8s' ? (
<><Network className="h-3 w-3" />Kubernetes</>
) : (
<><Server className="h-3 w-3" />SSH 主机</>
)}
</span>
{repo && <span className="flex items-center gap-1"><Globe className="h-3 w-3" />{String(repo.name)}</span>}
{registry && <span className="flex items-center gap-1"><Container className="h-3 w-3" />{String(registry.name)}</span>}
{pipeline.last_run_at && (
<span><Clock className="h-3 w-3 inline mr-0.5" />{new Date(pipeline.last_run_at as string).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={onStopRunning}>
<StopCircle className="h-3.5 w-3.5 mr-1" />停止
</Button>
) : (
<Button variant="default" size="sm" className="h-8" onClick={onTrigger}>
<Play className="h-3.5 w-3.5 mr-1" />执行
</Button>
)}
<Button variant="outline" size="sm" className="h-8" onClick={() => navigate(`/pipelines/${code}`)}>编辑</Button>
<Button variant="outline" size="sm" className="h-8" onClick={() => navigate(`/pipelines/${code}/runs`)}>
<History className="h-3.5 w-3.5 mr-1" />记录
</Button>
<Button variant="ghost" size="sm" className="h-8" onClick={onToggleExpand}>
{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={onDelete}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
{stages.length > 0 && (
<div className="flex items-center mt-2.5">
{stages.map((s, i) => {
const def = STAGE_DEFS.find((d) => d.label === s.name);
// 从 last_run_stages 获取最新运行状态 // Logan.Li
let runStatus = '';
if (pipeline.last_run_stages) {
try {
const rs = typeof pipeline.last_run_stages === 'string'
? JSON.parse(pipeline.last_run_stages as string)
: pipeline.last_run_stages;
const match = Array.isArray(rs)
? rs.find((r: Record<string, unknown>) => r.name === s.name || r.type === def?.key)
: null;
runStatus = String(match?.status || '');
} catch { /* ignore */ }
}
const isSuccess = runStatus === 'success' || runStatus === 'completed';
const isRunning = runStatus === 'running' || runStatus === 'in_progress';
const isFailed = runStatus === 'failed' || runStatus === 'error';
const color = isFailed ? '#EF4444' : isRunning ? '#3B82F6' : isSuccess ? '#10B981' : '#6B7280';
const bgColor = isFailed ? '#EF444428' : isRunning ? '#3B82F628' : isSuccess ? '#10B98128' : '#6B728018';
const icon = isFailed ? (
<XCircle className="h-3 w-3" />
) : isRunning ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : isSuccess ? (
<CheckCircle className="h-3 w-3" />
) : (
<span className="text-[10px] opacity-40">○</span>
);
return (
<div key={i} className="flex items-center">
<div
className="flex items-center gap-1.5 px-2.5 py-1.5 text-[11px] font-semibold border shadow-sm"
style={{
backgroundColor: bgColor,
borderColor: `${color}70`,
color,
borderRadius: i === 0 ? '8px 0 0 8px' : i === stages.length - 1 ? '0 8px 8px 0' : '0',
}}
>
{icon}{String(s.name)}
</div>
{i < stages.length - 1 && (
<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}CC` }} />
</div>
)}
</div>
);
})}
</div>
)}
</div>
{isExpanded && (
<PipelineRunPanel
pipeline={pipeline}
pipelineRuns={pipelineRuns}
isLoadingRuns={isLoadingRuns}
onStopRun={onStopRun}
/>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,503 @@
import { useState, useEffect, useCallback } from 'react';
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 {
Rocket,
Plus,
GitBranch,
Tag,
Server,
Network,
Globe,
Container,
Loader2,
Trash2,
ChevronRight as ChevronRightSmall,
} from 'lucide-react';
import { credentialApi, deployApi, pipelineApi } from '@/api';
import { toast } from 'sonner';
import { nameToCode } from '../pipeline-helpers';
import { STAGE_DEFS } from './constants';
interface PipelineCreateDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreated: () => void;
}
// 新建流水线对话框(含表单状态) // Logan.Li
export function PipelineCreateDialog({ open, onOpenChange, onCreated }: PipelineCreateDialogProps) {
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 [newDeployMode, setNewDeployMode] = useState('branch');
const [newDeployTarget, setNewDeployTarget] = useState('ssh');
const [newEnvironmentId, setNewEnvironmentId] = useState<number | string>('');
const [saving, setSaving] = useState(false);
const [gitCredentials, setGitCredentials] = useState<Record<string, unknown>[]>([]);
const [dockerCredentials, setDockerCredentials] = useState<Record<string, unknown>[]>([]);
const [newGitCredentialId, setNewGitCredentialId] = useState<number | string>('');
const [newRegistryCredentialId, setNewRegistryCredentialId] = useState<number | string>('');
const [newTriggerMode, setNewTriggerMode] = useState('manual');
const [newWatchBranches, setNewWatchBranches] = useState('');
const [newExcludeBranch, setNewExcludeBranch] = useState('');
const [newCronExpr, setNewCronExpr] = useState('');
const [newWebhookToken, setNewWebhookToken] = useState('');
const [newEnvVars, setNewEnvVars] = useState<{ key: string; value: string; desc: string }[]>([]);
const [newCachePaths, setNewCachePaths] = useState('');
const [newMaxExecutors, setNewMaxExecutors] = useState(2);
const [environments, setEnvironments] = useState<Record<string, unknown>[]>([]);
const loadRefs = useCallback(async () => {
try {
const [rEnv, rCred] = await Promise.all([
deployApi.getEnvironments().catch(() => ({ list: [] })),
credentialApi.list({ page_size: 100 }).catch(() => ({ list: [] })),
] as const);
setEnvironments(Array.isArray((rEnv as Record<string, unknown>)?.list) ? (rEnv as Record<string, unknown>).list as Record<string, unknown>[] : []);
const allCreds: Record<string, unknown>[] = (rCred as Record<string, unknown>)?.list as Record<string, unknown>[] || [];
setGitCredentials(allCreds.filter((c) => c.secret_type === 'api'));
setDockerCredentials(allCreds.filter((c) => c.secret_type === 'docker'));
} catch { /* silent */ }
}, []);
useEffect(() => {
if (open) loadRefs();
}, [open, loadRefs]);
const resetForm = () => {
setNewName('');
setNewJobName('');
setNewDesc('');
setNewSource('');
setNewBranch('main');
setNewDockerfile('');
setSelectedStages(['checkout', 'build', 'image', 'deploy']);
setNewDeployMode('branch');
setNewDeployTarget('ssh');
setNewEnvironmentId('');
setNewGitCredentialId('');
setNewRegistryCredentialId('');
setNewTriggerMode('manual');
setNewWatchBranches('');
setNewExcludeBranch('');
setNewCronExpr('');
setNewWebhookToken('');
setNewEnvVars([]);
setNewCachePaths('');
setNewMaxExecutors(2);
};
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,
max_executors: newMaxExecutors,
});
toast.success('流水线已创建');
onOpenChange(false);
resetForm();
onCreated();
} catch {
toast.error('创建失败');
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<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: 'Kubernetes 集群', 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 as number} 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 as string }} />
{String(env.display_name)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<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 as number} value={String(c.id)}>
<span className="flex items-center gap-1.5">
<Globe className="h-3 w-3 opacity-60" />{String(c.name)} {c.summary && <span className="text-[10px] opacity-50">({String(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 as number} value={String(c.id)}>
<span className="flex items-center gap-1.5">
<Container className="h-3 w-3 opacity-60" />{String(c.name)} {c.summary && <span className="text-[10px] opacity-50">({String(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>
<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 mt-2">
{selectedStages.map((key, i) => {
const def = STAGE_DEFS.find((t) => t.key === key)!;
return (
<div key={i} className="flex items-center">
<div
className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium border"
style={{
backgroundColor: `${def.color}20`,
borderColor: `${def.color}40`,
color: def.color,
borderRadius: i === 0 ? '6px 0 0 6px' : i === selectedStages.length - 1 ? '0 6px 6px 0' : '0',
}}
>
<def.icon className="h-3 w-3" />{def.label}
</div>
{i < selectedStages.length - 1 && (
<div className="flex items-center justify-center w-4 h-5 -mx-px z-10">
<ChevronRightSmall className="h-3 w-3" style={{ color: def.color }} />
</div>
)}
</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>
{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>
)}
{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>
)}
{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">暂无变量,点击&quot;添加变量&quot;新增</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 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">(Number of executors)</span></Label>
<div className="flex items-center gap-2">
<Select value={String(newMaxExecutors)} onValueChange={(v) => setNewMaxExecutors(Number(v))}>
<SelectTrigger className="w-32 h-8 text-xs bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 — 串行</SelectItem>
<SelectItem value="2">2</SelectItem>
<SelectItem value="3">3</SelectItem>
<SelectItem value="4">4</SelectItem>
<SelectItem value="5">5</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-[10px] text-[var(--dg-text-secondary)] opacity-50 mt-1">设为 1 则按顺序串行执行,默认 2 可并发构建</p>
</div>
</div>
<div className="flex justify-end gap-2 mt-4 pt-4 border-t border-[var(--dg-border)]">
<Button variant="outline" onClick={() => onOpenChange(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>
);
}
@@ -0,0 +1,237 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import {
Clock,
CheckCircle,
XCircle,
Loader2,
StopCircle,
FileText,
} from 'lucide-react';
import { pipelineApi } from '@/api';
import { toast } from 'sonner';
import { fmtDuration, type LogEntry } from './constants';
interface PipelineRunPanelProps {
pipeline: Record<string, unknown>;
pipelineRuns: Record<string, unknown>[];
isLoadingRuns: boolean;
onStopRun: (runId: number | string) => void;
}
// 流水线队列面板(Jenkins 风格) // Logan.Li
function PipelineQueuePanel({ pipeline }: { pipeline: Record<string, unknown> }) {
const [queue, setQueue] = useState<Record<string, unknown> | null>(null);
const [loading, setLoading] = useState(false);
const code = (pipeline.code || pipeline.id) as string;
const loadQueue = async () => {
if (!code) return;
setLoading(true);
try {
const res: Record<string, unknown> = await pipelineApi.getQueue(code);
setQueue((res?.data || res) as Record<string, unknown>);
} catch {
setQueue(null);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadQueue();
const t = setInterval(loadQueue, 10000);
return () => clearInterval(t);
}, [code]);
const handleCancel = async (runId: number) => {
try {
await pipelineApi.removeFromQueue(code, runId);
toast.success('已取消');
loadQueue();
} catch {
toast.error('取消失败');
}
};
const running = (queue?.running as number) || 0;
const queued = (queue?.queued as number) || 0;
const queueIds = (queue?.queue_ids as number[]) || [];
const max = (pipeline.max_executors as number) || 2;
const pendingRuns = (queue?.pending_runs as Record<string, unknown>[]) || [];
if (loading && !queue) return null;
if (running === 0 && queued === 0) return null;
return (
<div className="px-4 py-2 space-y-2">
<div className="flex items-center gap-2 text-xs">
<span className="text-[var(--dg-text-secondary)]">构建执行状态</span>
<span className="font-bold text-[var(--dg-text-primary)]">{running}/{max}</span>
{queued > 0 && (
<span className="text-[var(--dg-text-secondary)] ml-2">
构建队列 <span className="font-bold text-amber-400">({queued})</span>
</span>
)}
</div>
{queued > 0 && pendingRuns.length > 0 && (
<div className="space-y-1">
{pendingRuns.map((run) => (
<div key={run.id as number} className="flex items-center justify-between bg-[var(--dg-bg-tertiary)] rounded px-3 py-1.5">
<div className="flex items-center gap-2 text-xs">
<Clock className="h-3 w-3 text-amber-400" />
<span className="text-[var(--dg-text-secondary)] font-mono">#{String(run.build_number ?? run.id ?? '')}</span>
<span className="text-[var(--dg-text-secondary)]">{String(run.branch || '-')}</span>
<span className="text-[var(--dg-text-secondary)] opacity-50">{String(run.triggered_by || '-')}</span>
</div>
<Button variant="ghost" size="sm" className="h-6 text-xs text-red-400 hover:text-red-300" onClick={() => handleCancel(run.id as number)}>
<XCircle className="h-3 w-3 mr-1" />取消
</Button>
</div>
))}
</div>
)}
{queued > 0 && pendingRuns.length === 0 && queueIds.length > 0 && (
<div className="space-y-1">
{queueIds.map((id) => (
<div key={id} className="flex items-center justify-between bg-[var(--dg-bg-tertiary)] rounded px-3 py-1.5">
<div className="flex items-center gap-2 text-xs">
<Clock className="h-3 w-3 text-amber-400" />
<span className="text-[var(--dg-text-muted)] font-mono">Run #{id}</span>
</div>
<Button variant="ghost" size="sm" className="h-6 text-xs text-red-400 hover:text-red-300" onClick={() => handleCancel(id)}>
<XCircle className="h-3 w-3 mr-1" />取消
</Button>
</div>
))}
</div>
)}
</div>
);
}
// 展开后的运行列表面板 // Logan.Li
export function PipelineRunPanel({ pipeline, pipelineRuns, isLoadingRuns, onStopRun }: PipelineRunPanelProps) {
const [logOpen, setLogOpen] = useState(false);
const [logRun, setLogRun] = useState<Record<string, unknown> | null>(null);
const [logs, setLogs] = useState<LogEntry[]>([]);
const code = (pipeline.code || pipeline.id) as string;
const handleViewLog = async (run: Record<string, unknown>) => {
setLogRun(run);
setLogOpen(true);
try {
const data = await pipelineApi.getRunLog(code, run.id as number | string) as Record<string, unknown>;
setLogs((data?.logs as LogEntry[]) || []);
} catch {
setLogs([]);
}
};
return (
<div className="border-t border-[var(--dg-border)]">
<PipelineQueuePanel pipeline={pipeline} />
{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) => (
<tr key={r.id as number} 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)]">#{String(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]">{String(r.status)}</Badge>
)}
</td>
<td className="px-4 py-2 text-[var(--dg-text-secondary)] text-xs">{String(r.triggered_by || 'admin')}</td>
<td className="px-4 py-2 text-[var(--dg-text-secondary)] text-xs">{fmtDuration(r.duration as number | undefined)}</td>
<td className="px-4 py-2 text-[var(--dg-text-secondary)] text-xs">
{r.created_at ? new Date(r.created_at as string).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(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={() => onStopRun(r.id as number | string)}>
<StopCircle className="h-3.5 w-3.5 mr-1" />停止
</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
<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" />执行日志 #{String(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>触发方式: {String(logRun?.triggered_by || 'manual')}</span>
<span>分支: {String(logRun?.branch || '-')}</span>
<span>耗时: {fmtDuration(logRun?.duration as number | undefined)}</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, i) => (
<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>
);
}
@@ -0,0 +1,46 @@
import {
GitBranch,
Box,
CheckCircle,
Container,
Rocket,
ShieldCheck,
} from 'lucide-react';
// 流水线阶段定义(固定顺序) // Logan.Li
export 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);
// 运行日志条目 // Logan.Li
export interface LogEntry {
time: string;
level: string;
message: string;
stage: string;
}
// 格式化耗时 // Logan.Li
export 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}分`;
}
// 解析 stages 字段 // Logan.Li
export function parseStages(raw: unknown): Record<string, unknown>[] {
if (!raw) return [];
try {
const s = typeof raw === 'string' ? JSON.parse(raw) : raw;
return Array.isArray(s) ? s : [];
} catch {
return [];
}
}