- Go gin 后端:流水线/节点/连线/运行/日志/GitOps/插件 REST API, SQLite(GORM) 自动建表 + seed,模拟执行引擎 + SSE 实时日志推送, 前端产物 go:embed 嵌入单二进制,一个端口同时服务 API 与页面 - React 19 前端:画布式流水线编辑器(React Flow + Tekton 任务库)、 GitOps 部署、批量发布、发布记录等 14 个模块页面, tRPC 全部替换为 REST(TanStack Query) - 首次启动生成随机密码管理员账号(日志 + data/initial-admin-password.txt) - run.sh 一键构建启动(默认端口 9527,支持 start/stop/restart/build/dev)
251 lines
19 KiB
TypeScript
251 lines
19 KiB
TypeScript
import { useState } from 'react';
|
|
import {
|
|
ChevronRight, CheckCircle2, Circle, GitBranch, Server,
|
|
Play, Loader2, Terminal, ArrowRight, CloudCog, Package, AlertCircle
|
|
} from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
|
|
interface PipelineItem {
|
|
id: string; name: string; branch: string; type: 'native' | 'jenkins';
|
|
deployType: 'host' | 'k8s'; env: string; lastStatus: string;
|
|
}
|
|
|
|
const mockPipelines: PipelineItem[] = [
|
|
{ id: '1', name: 'aiops-backend', branch: 'master', type: 'native', deployType: 'k8s', env: 'production', lastStatus: 'success' },
|
|
{ id: '2', name: 'aiops-web', branch: 'master', type: 'native', deployType: 'host', env: 'production', lastStatus: 'success' },
|
|
{ id: '3', name: 'gateway-api', branch: 'release/v2.1', type: 'jenkins', deployType: 'k8s', env: 'staging', lastStatus: 'pending' },
|
|
{ id: '4', name: 'admin-dashboard', branch: 'master', type: 'native', deployType: 'host', env: 'staging', lastStatus: 'success' },
|
|
{ id: '5', name: 'data-processor', branch: 'develop', type: 'jenkins', deployType: 'k8s', env: 'test', lastStatus: 'failed' },
|
|
{ id: '6', name: 'notification-service', branch: 'master', type: 'native', deployType: 'k8s', env: 'production', lastStatus: 'success' },
|
|
];
|
|
|
|
type StepType = 'select' | 'configure' | 'confirm' | 'executing' | 'done';
|
|
const steps: { id: StepType; label: string }[] = [
|
|
{ id: 'select', label: '选择流水线' },
|
|
{ id: 'configure', label: '配置参数' },
|
|
{ id: 'confirm', label: '确认发布' },
|
|
{ id: 'executing', label: '执行中' },
|
|
{ id: 'done', label: '完成' },
|
|
];
|
|
|
|
export default function BatchRelease() {
|
|
const [currentStep, setCurrentStep] = useState<StepType>('select');
|
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
const [execLogs, setExecLogs] = useState<string[]>([]);
|
|
const [completedCount, setCompletedCount] = useState(0);
|
|
|
|
const toggleSelect = (id: string) => {
|
|
setSelectedIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]);
|
|
};
|
|
|
|
const selectedPipelines = mockPipelines.filter(p => selectedIds.includes(p.id));
|
|
const stepIndex = steps.findIndex(s => s.id === currentStep);
|
|
|
|
const startExecution = () => {
|
|
setCurrentStep('executing');
|
|
setExecLogs([`[${new Date().toLocaleTimeString()}] 批量发布开始,共 ${selectedIds.length} 个流水线`]);
|
|
|
|
let count = 0;
|
|
selectedPipelines.forEach((p, idx) => {
|
|
setTimeout(() => {
|
|
setExecLogs(prev => [
|
|
...prev,
|
|
`[${new Date().toLocaleTimeString()}] [${idx + 1}/${selectedPipelines.length}] 开始发布: ${p.name} (${p.branch})`,
|
|
`[${new Date().toLocaleTimeString()}] -> 拉取代码...`,
|
|
`[${new Date().toLocaleTimeString()}] -> 构建中...`,
|
|
`[${new Date().toLocaleTimeString()}] -> ${p.deployType === 'k8s' ? '更新 K8s Deployment...' : 'SCP 上传 + SSH 部署...'}`,
|
|
`[${new Date().toLocaleTimeString()}] ✓ ${p.name} 发布成功`,
|
|
]);
|
|
count++;
|
|
setCompletedCount(count);
|
|
if (count === selectedPipelines.length) {
|
|
setTimeout(() => {
|
|
setExecLogs(prev => [...prev, `[${new Date().toLocaleTimeString()}] 批量发布全部完成`]);
|
|
setCurrentStep('done');
|
|
}, 500);
|
|
}
|
|
}, idx * 2500);
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
|
|
<div className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
|
<div className="flex items-center gap-3">
|
|
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>批量发布</h1>
|
|
<span className="text-[11px] px-2 py-[2px] rounded-full font-medium" style={{ background: '#ddf4ff', color: 'var(--accent-blue)' }}>向导</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="px-5 py-3 border-b flex items-center justify-center gap-0 flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
|
{steps.map((step, idx) => {
|
|
const isActive = idx === stepIndex;
|
|
const isDone = idx < stepIndex;
|
|
return (
|
|
<div key={step.id} className="flex items-center">
|
|
<div className="flex items-center gap-2 px-3">
|
|
{isDone ? <CheckCircle2 className="w-4 h-4" style={{ color: 'var(--accent-green)' }} /> :
|
|
isActive ? <div className="w-4 h-4 rounded-full border-2 flex items-center justify-center" style={{ borderColor: 'var(--accent-blue)' }}><div className="w-2 h-2 rounded-full" style={{ background: 'var(--accent-blue)' }} /></div> :
|
|
<Circle className="w-4 h-4" style={{ color: 'var(--border-color)' }} />}
|
|
<span className="text-[12px] font-medium" style={{ color: isActive ? 'var(--accent-blue)' : isDone ? 'var(--text-secondary)' : 'var(--text-tertiary)' }}>{step.label}</span>
|
|
</div>
|
|
{idx < steps.length - 1 && <ChevronRight className="w-4 h-4 mx-1" style={{ color: idx < stepIndex ? 'var(--accent-green)' : 'var(--border-color)' }} />}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
|
|
{currentStep === 'select' && (
|
|
<div className="max-w-4xl mx-auto">
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<p className="text-[13px]" style={{ color: 'var(--text-secondary)' }}>选择要批量发布的流水线(支持原生流水线和 Jenkins Job 混合发布)</p>
|
|
<span className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>已选择 <span className="font-semibold" style={{ color: 'var(--accent-blue)' }}>{selectedIds.length}</span> 个</span>
|
|
</div>
|
|
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
|
<div className="grid grid-cols-[40px_1fr_120px_100px_100px_80px] gap-2 px-4 py-2 text-[11px] font-semibold uppercase" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
|
|
<div></div><div>流水线</div><div>分支</div><div>类型</div><div>部署方式</div><div>环境</div>
|
|
</div>
|
|
{mockPipelines.map((p) => {
|
|
const isSelected = selectedIds.includes(p.id);
|
|
return (
|
|
<div key={p.id} onClick={() => toggleSelect(p.id)}
|
|
className="grid grid-cols-[40px_1fr_120px_100px_100px_80px] gap-2 px-4 py-2.5 border-t items-center cursor-pointer hover:bg-black/[0.02] transition-colors"
|
|
style={{ borderColor: 'var(--border-color-light)' }}>
|
|
<div><div className="w-4 h-4 rounded border flex items-center justify-center" style={{ borderColor: isSelected ? 'var(--accent-blue)' : 'var(--border-color)', background: isSelected ? 'var(--accent-blue)' : 'transparent' }}>
|
|
{isSelected && <CheckCircle2 className="w-3 h-3 text-white" />}
|
|
</div></div>
|
|
<div className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>{p.name}</div>
|
|
<div className="flex items-center gap-1 text-[12px]" style={{ color: 'var(--text-secondary)' }}><GitBranch className="w-3 h-3" /> {p.branch}</div>
|
|
<div><span className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: p.type === 'native' ? '#ddf4ff' : '#fbefff', color: p.type === 'native' ? '#0969da' : '#8250df' }}>{p.type === 'native' ? '原生' : 'Jenkins'}</span></div>
|
|
<div className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>{p.deployType === 'k8s' ? <span className="flex items-center gap-1"><CloudCog className="w-3 h-3" /> K8s</span> : <span className="flex items-center gap-1"><Server className="w-3 h-3" /> 主机</span>}</div>
|
|
<div className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>{p.env}</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="flex justify-end mt-4">
|
|
<button onClick={() => selectedIds.length > 0 ? setCurrentStep('configure') : toast.error('请至少选择一个流水线')}
|
|
className="flex items-center gap-1.5 px-4 py-[7px] text-[13px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}>
|
|
下一步 <ArrowRight className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{currentStep === 'configure' && (
|
|
<div className="max-w-3xl mx-auto space-y-4">
|
|
<p className="text-[13px] mb-3" style={{ color: 'var(--text-secondary)' }}>配置发布参数,所有选中的流水线将使用相同的发布策略</p>
|
|
<div className="rounded-lg border p-4 space-y-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div><label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>发布策略</label>
|
|
<select className="w-full px-3 py-[7px] rounded-md border text-[13px]" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}>
|
|
<option>串行发布(逐个执行,失败即停)</option><option>并行发布(同时执行,互不影响)</option><option>分组发布(按环境分组串行)</option>
|
|
</select></div>
|
|
<div><label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>失败处理</label>
|
|
<select className="w-full px-3 py-[7px] rounded-md border text-[13px]" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}>
|
|
<option>立即中止后续发布</option><option>继续发布其他流水线</option><option>标记失败并跳过</option>
|
|
</select></div>
|
|
</div>
|
|
<div><label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>K8s 就绪检查</label>
|
|
<div className="flex items-center gap-4">
|
|
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" defaultChecked className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>检查 Deployment 副本就绪</span></label>
|
|
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" defaultChecked className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>检查 Service 可用性</span></label>
|
|
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>等待 Pod 健康检查通过</span></label>
|
|
</div></div>
|
|
<div><label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>发布后通知</label>
|
|
<div className="flex items-center gap-4">
|
|
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" defaultChecked className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>飞书通知</span></label>
|
|
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>钉钉通知</span></label>
|
|
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>Webhook</span></label>
|
|
</div></div>
|
|
</div>
|
|
<div className="rounded-lg border p-3" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
|
<div className="text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>已选择的流水线</div>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
{selectedPipelines.map((p) => (
|
|
<span key={p.id} className="text-[11px] px-2 py-[3px] rounded font-medium flex items-center gap-1" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-secondary)' }}>
|
|
<Package className="w-3 h-3" />{p.name}</span>
|
|
))}
|
|
</div></div>
|
|
<div className="flex justify-between">
|
|
<button onClick={() => setCurrentStep('select')} className="px-4 py-[7px] text-[13px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>上一步</button>
|
|
<button onClick={() => setCurrentStep('confirm')} className="flex items-center gap-1.5 px-4 py-[7px] text-[13px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}>下一步 <ArrowRight className="w-4 h-4" /></button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{currentStep === 'confirm' && (
|
|
<div className="max-w-2xl mx-auto text-center py-8">
|
|
<div className="w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4" style={{ background: 'var(--accent-blue-light)' }}>
|
|
<AlertCircle className="w-8 h-8" style={{ color: 'var(--accent-blue)' }} />
|
|
</div>
|
|
<h2 className="text-[16px] font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>确认批量发布</h2>
|
|
<p className="text-[13px] mb-6" style={{ color: 'var(--text-secondary)' }}>即将发布 <span className="font-semibold" style={{ color: 'var(--accent-blue)' }}>{selectedIds.length}</span> 个流水线,串行执行,失败即停</p>
|
|
<div className="rounded-lg border p-4 mb-6 text-left" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
|
{selectedPipelines.map((p, idx) => (
|
|
<div key={p.id} className="flex items-center gap-3 py-2" style={{ borderTop: idx > 0 ? '1px solid var(--border-color-light)' : 'none' }}>
|
|
<span className="text-[11px] w-5 text-center font-medium" style={{ color: 'var(--text-tertiary)' }}>{idx + 1}</span>
|
|
<GitBranch className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
|
<span className="text-[13px] font-medium flex-1" style={{ color: 'var(--text-primary)' }}>{p.name}</span>
|
|
<span className="text-[11px] px-1.5 py-[1px] rounded" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>{p.branch}</span>
|
|
<span className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: p.type === 'native' ? '#ddf4ff' : '#fbefff', color: p.type === 'native' ? '#0969da' : '#8250df' }}>{p.type === 'native' ? '原生' : 'Jenkins'}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="flex justify-center gap-3">
|
|
<button onClick={() => setCurrentStep('configure')} className="px-4 py-[7px] text-[13px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>返回修改</button>
|
|
<button onClick={startExecution} className="flex items-center gap-1.5 px-5 py-[7px] text-[13px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}>
|
|
<Play className="w-4 h-4" />开始发布
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{currentStep === 'executing' && (
|
|
<div className="max-w-3xl mx-auto">
|
|
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
|
<div className="px-4 py-2.5 border-b flex items-center justify-between" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color)' }}>
|
|
<div className="flex items-center gap-2">
|
|
<Loader2 className="w-4 h-4 animate-spin" style={{ color: 'var(--accent-blue)' }} />
|
|
<span className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>批量发布执行中</span>
|
|
</div>
|
|
<span className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>{completedCount} / {selectedIds.length} 完成</span>
|
|
</div>
|
|
<div className="p-0">
|
|
<div className="px-4 py-2 border-b" style={{ background: '#0d1117', borderColor: '#21262d' }}>
|
|
<div className="flex items-center gap-2">
|
|
<Terminal className="w-3.5 h-3.5" style={{ color: '#7d8590' }} />
|
|
<span className="text-[11px] font-medium" style={{ color: '#7d8590' }}>执行日志</span>
|
|
</div>
|
|
</div>
|
|
<div className="p-3 font-mono text-[12px] leading-relaxed max-h-[400px] overflow-y-auto" style={{ background: '#0d1117', color: '#e6edf3' }}>
|
|
{execLogs.map((log, idx) => (
|
|
<div key={idx} className="py-[1px]">{log.includes('✓') ? <span style={{ color: '#3fb950' }}>{log}</span> : log.includes('->') ? <span style={{ color: '#58a6ff' }}>{log}</span> : log}</div>
|
|
))}
|
|
<div className="py-[1px] animate-pulse"><span style={{ color: '#7d8590' }}>_</span></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{currentStep === 'done' && (
|
|
<div className="max-w-2xl mx-auto text-center py-8">
|
|
<div className="w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4" style={{ background: '#dafbe1' }}>
|
|
<CheckCircle2 className="w-8 h-8" style={{ color: '#1a7f37' }} />
|
|
</div>
|
|
<h2 className="text-[16px] font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>批量发布完成</h2>
|
|
<p className="text-[13px] mb-6" style={{ color: 'var(--text-secondary)' }}>共成功发布 <span className="font-semibold" style={{ color: 'var(--accent-green)' }}>{selectedIds.length}</span> 个流水线</p>
|
|
<div className="flex justify-center gap-3">
|
|
<button onClick={() => { setCurrentStep('select'); setSelectedIds([]); setExecLogs([]); setCompletedCount(0); }}
|
|
className="px-4 py-[7px] text-[13px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>继续发布</button>
|
|
<button onClick={() => toast.success('已跳转至发布记录')} className="px-4 py-[7px] text-[13px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}>查看记录</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|