feat: FlowStack 发布系统首个可运行版本 (gin + React)

- 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)
This commit is contained in:
Logan.Li committed 2026-10-27 02:44:10 +08:00
commit 04d1342b27
115 files changed
+23268

No files matched your search

+250
View File
@@ -0,0 +1,250 @@
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>
);
}
+168
View File
@@ -0,0 +1,168 @@
import { useState } from 'react';
import {
Cpu,
Plus,
Search,
CheckCircle2,
XCircle,
Loader2,
Edit2,
Trash2,
Tag,
Activity
} from 'lucide-react';
import { toast } from 'sonner';
interface BuildNode {
id: string;
name: string;
ip: string;
labels: string[];
maxConcurrency: number;
currentJobs: number;
status: 'online' | 'offline' | 'busy';
cpu: string;
memory: string;
disk: string;
lastHeartbeat: string;
}
const mockNodes: BuildNode[] = [
{ id: '1', name: 'build-host-01', ip: '192.168.10.101', labels: ['docker', 'go', 'java'], maxConcurrency: 4, currentJobs: 2, status: 'busy', cpu: '45%', memory: '6.2GB/16GB', disk: '120GB/500GB', lastHeartbeat: '10秒前' },
{ id: '2', name: 'build-host-02', ip: '192.168.10.102', labels: ['docker', 'node', 'python'], maxConcurrency: 3, currentJobs: 0, status: 'online', cpu: '12%', memory: '2.1GB/8GB', disk: '80GB/300GB', lastHeartbeat: '5秒前' },
{ id: '3', name: 'build-host-03', ip: '192.168.10.103', labels: ['k8s', 'helm'], maxConcurrency: 2, currentJobs: 0, status: 'offline', cpu: '-', memory: '-', disk: '-', lastHeartbeat: '2小时前' },
];
const statusConfig = {
online: { icon: CheckCircle2, color: '#1a7f37', bg: '#dafbe1', label: '在线' },
offline: { icon: XCircle, color: '#cf222e', bg: '#ffebe9', label: '离线' },
busy: { icon: Loader2, color: '#9a6700', bg: '#fff8c5', label: '忙碌' },
};
export default function BuildNodeManager() {
const [searchQuery, setSearchQuery] = useState('');
const filtered = mockNodes.filter(n =>
n.name.includes(searchQuery) || n.ip.includes(searchQuery)
);
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: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
{filtered.length}
</span>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 px-3 py-[6px] rounded-md border" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }}>
<Search className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
<input value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="搜索节点..." className="bg-transparent outline-none w-32 text-[13px]" style={{ color: 'var(--text-primary)' }} />
</div>
<button className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }} onClick={() => toast.info('添加节点', { description: '功能开发中...' })}>
<Plus className="w-3.5 h-3.5" />
添加节点
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
<div className="grid grid-cols-3 gap-3 mb-4">
{[
{ label: '在线节点', count: 2, color: '#1a7f37' },
{ label: '忙碌节点', count: 1, color: '#9a6700' },
{ label: '离线节点', count: 1, color: '#cf222e' },
].map((stat) => (
<div key={stat.label} className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>{stat.label}</div>
<div className="text-[22px] font-bold" style={{ color: 'var(--text-primary)' }}>{stat.count}</div>
</div>
))}
</div>
<div className="space-y-3">
{filtered.map((node) => {
const status = statusConfig[node.status];
const StatusIcon = status.icon;
const utilization = node.status === 'offline' ? 0 : (node.currentJobs / node.maxConcurrency) * 100;
return (
<div key={node.id} className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<Cpu className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
<div>
<div className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>{node.name}</div>
<div className="text-[11px] font-mono" style={{ color: 'var(--text-tertiary)' }}>{node.ip}</div>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-[11px] px-2 py-[3px] rounded-full font-medium flex items-center gap-1" style={{ background: status.bg, color: status.color }}>
<StatusIcon className={`w-3 h-3 ${node.status === 'busy' ? 'animate-spin' : ''}`} />
{status.label}
</span>
<button className="p-1 rounded hover:bg-black/5"><Edit2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} /></button>
<button className="p-1 rounded hover:bg-black/5"><Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} /></button>
</div>
</div>
{node.status !== 'offline' && (
<>
{/* Concurrency bar */}
<div className="mb-3">
<div className="flex items-center justify-between text-[11px] mb-1">
<span style={{ color: 'var(--text-tertiary)' }}>并发任务</span>
<span style={{ color: 'var(--text-secondary)' }}>{node.currentJobs} / {node.maxConcurrency}</span>
</div>
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--panel-bg-secondary)' }}>
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${utilization}%`,
background: utilization > 80 ? 'var(--accent-red)' : utilization > 50 ? 'var(--accent-amber)' : 'var(--accent-green)',
}}
/>
</div>
</div>
{/* Resource metrics */}
<div className="grid grid-cols-4 gap-3 text-[11px]">
<div className="flex items-center gap-1.5">
<Activity className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />
<span style={{ color: 'var(--text-secondary)' }}>CPU {node.cpu}</span>
</div>
<div className="flex items-center gap-1.5">
<span style={{ color: 'var(--text-tertiary)' }}>内存</span>
<span style={{ color: 'var(--text-secondary)' }}>{node.memory}</span>
</div>
<div className="flex items-center gap-1.5">
<span style={{ color: 'var(--text-tertiary)' }}>磁盘</span>
<span style={{ color: 'var(--text-secondary)' }}>{node.disk}</span>
</div>
<div className="flex items-center gap-1.5">
<span style={{ color: 'var(--text-tertiary)' }}>心跳</span>
<span style={{ color: 'var(--text-secondary)' }}>{node.lastHeartbeat}</span>
</div>
</div>
</>
)}
{/* Labels */}
<div className="flex items-center gap-1 mt-3 flex-wrap">
{node.labels.map((label) => (
<span key={label} className="text-[10px] px-1.5 py-[2px] rounded font-medium flex items-center gap-1" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
<Tag className="w-2.5 h-2.5" />
{label}
</span>
))}
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
+182
View File
@@ -0,0 +1,182 @@
import { useState } from 'react';
import {
Key,
Plus,
Search,
Eye,
EyeOff,
Copy,
Trash2,
Edit2,
Shield,
GitBranch,
Container
} from 'lucide-react';
import { toast } from 'sonner';
interface Credential {
id: string;
name: string;
type: 'ssh' | 'password' | 'docker' | 'git' | 'kubeconfig';
scope: string;
createdAt: string;
usageCount: number;
}
const mockCredentials: Credential[] = [
{ id: '1', name: 'production-ssh-key', type: 'ssh', scope: 'production-group', createdAt: '2024-06-01', usageCount: 12 },
{ id: '2', name: 'docker-registry-auth', type: 'docker', scope: '全局', createdAt: '2024-06-05', usageCount: 8 },
{ id: '3', name: 'gitee-token', type: 'git', scope: '全局', createdAt: '2024-06-10', usageCount: 5 },
{ id: '4', name: 'staging-ssh-key', type: 'ssh', scope: 'staging-group', createdAt: '2024-06-12', usageCount: 3 },
{ id: '5', name: 'ack-kubeconfig', type: 'kubeconfig', scope: 'ack-production', createdAt: '2024-06-15', usageCount: 6 },
{ id: '6', name: 'db-password', type: 'password', scope: 'production-group', createdAt: '2024-06-18', usageCount: 2 },
];
const typeConfig: Record<string, { icon: typeof Key; label: string; color: string; bg: string }> = {
ssh: { icon: Key, label: 'SSH密钥', color: '#0969da', bg: '#ddf4ff' },
password: { icon: Shield, label: '用户名密码', color: '#9a6700', bg: '#fff8c5' },
docker: { icon: Container, label: 'Docker认证', color: '#1a7f37', bg: '#dafbe1' },
git: { icon: GitBranch, label: 'Git凭证', color: '#8250df', bg: '#fbefff' },
kubeconfig: { icon: Key, label: 'Kubeconfig', color: '#bc4c00', bg: '#fff1e5' },
};
export default function CredentialManager() {
const [searchQuery, setSearchQuery] = useState('');
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
const filtered = mockCredentials.filter(c =>
c.name.includes(searchQuery) || c.type.includes(searchQuery)
);
const toggleSecret = (id: string) => {
setShowSecrets(prev => ({ ...prev, [id]: !prev[id] }));
};
const copyValue = (name: string) => {
toast.success('已复制', { description: `${name} 的值已复制到剪贴板` });
};
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: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}
>
{filtered.length}
</span>
</div>
<div className="flex items-center gap-2">
<div
className="flex items-center gap-2 px-3 py-[6px] rounded-md border"
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }}
>
<Search className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="搜索凭证..."
className="bg-transparent outline-none w-32 text-[13px]"
style={{ color: 'var(--text-primary)' }}
/>
</div>
<button
className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white"
style={{ background: 'var(--accent-blue)' }}
onClick={() => toast.info('新建凭证', { description: '功能开发中...' })}
>
<Plus className="w-3.5 h-3.5" />
新建凭证
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
{/* Type summary */}
<div className="grid grid-cols-5 gap-3 mb-4">
{Object.entries(typeConfig).map(([type, config]) => {
const count = mockCredentials.filter(c => c.type === type).length;
const Icon = config.icon;
return (
<div key={type} className="rounded-lg border p-3 flex items-center gap-3" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="w-8 h-8 rounded-md flex items-center justify-center" style={{ background: config.bg }}>
<Icon className="w-4 h-4" style={{ color: config.color }} />
</div>
<div>
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{config.label}</div>
<div className="text-[16px] font-bold" style={{ color: 'var(--text-primary)' }}>{count}</div>
</div>
</div>
);
})}
</div>
{/* Credential list */}
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<table className="w-full">
<thead>
<tr style={{ background: 'var(--panel-bg-secondary)' }}>
{['凭证名称', '类型', '使用范围', '使用次数', '创建时间', '值', '操作'].map((h) => (
<th key={h} className="text-left px-4 py-2 text-[11px] font-semibold uppercase tracking-wider" style={{ color: 'var(--text-tertiary)' }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{filtered.map((cred) => {
const config = typeConfig[cred.type];
const Icon = config.icon;
const isRevealed = showSecrets[cred.id];
return (
<tr key={cred.id} className="border-t hover:bg-black/[0.02] transition-colors" style={{ borderColor: 'var(--border-color-light)' }}>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded flex items-center justify-center" style={{ background: config.bg }}>
<Icon className="w-3 h-3" style={{ color: config.color }} />
</div>
<span className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>{cred.name}</span>
</div>
</td>
<td className="px-4 py-3">
<span className="text-[11px] px-2 py-[3px] rounded font-medium" style={{ background: config.bg, color: config.color }}>
{config.label}
</span>
</td>
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{cred.scope}</td>
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{cred.usageCount}</td>
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>{cred.createdAt}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<code className="text-[11px] px-2 py-[3px] rounded font-mono" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-secondary)' }}>
{isRevealed ? 'AKIAIOSFODNN7EXAMPLE' : '••••••••••••••••'}
</code>
<button onClick={() => toggleSecret(cred.id)} className="p-1 rounded hover:bg-black/5">
{isRevealed ? <EyeOff className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} /> : <Eye className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />}
</button>
<button onClick={() => copyValue(cred.name)} className="p-1 rounded hover:bg-black/5">
<Copy className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />
</button>
</div>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-0">
<button className="p-1 rounded hover:bg-black/5"><Edit2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} /></button>
<button className="p-1 rounded hover:bg-black/5"><Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} /></button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
);
}
+295
View File
@@ -0,0 +1,295 @@
import { useState } from 'react';
import {
Server,
Plus,
Search,
CheckCircle2,
XCircle,
Edit2,
Trash2,
Terminal,
CloudCog,
} from 'lucide-react';
import { toast } from 'sonner';
interface Host {
id: string;
name: string;
ip: string;
port: number;
username: string;
tags: string[];
status: 'online' | 'offline';
os: string;
}
interface K8sCluster {
id: string;
name: string;
context: string;
version: string;
nodes: number;
namespaces: string[];
status: 'connected' | 'disconnected';
}
const mockHosts: Host[] = [
{ id: '1', name: 'prod-web-01', ip: '192.168.1.101', port: 22, username: 'root', tags: ['production', 'web'], status: 'online', os: 'CentOS 8' },
{ id: '2', name: 'prod-web-02', ip: '192.168.1.102', port: 22, username: 'root', tags: ['production', 'web'], status: 'online', os: 'CentOS 8' },
{ id: '3', name: 'prod-api-01', ip: '192.168.1.111', port: 22, username: 'root', tags: ['production', 'api'], status: 'online', os: 'Ubuntu 22.04' },
{ id: '4', name: 'staging-web-01', ip: '192.168.2.101', port: 22, username: 'admin', tags: ['staging', 'web'], status: 'online', os: 'Ubuntu 22.04' },
{ id: '5', name: 'staging-api-01', ip: '192.168.2.111', port: 22, username: 'admin', tags: ['staging', 'api'], status: 'offline', os: 'CentOS 7' },
];
const mockClusters: K8sCluster[] = [
{ id: '1', name: 'ack-production', context: 'ack-prod', version: 'v1.28.3', nodes: 6, namespaces: ['aiops', 'gateway', 'monitoring'], status: 'connected' },
{ id: '2', name: 'ack-staging', context: 'ack-staging', version: 'v1.27.8', nodes: 3, namespaces: ['aiops', 'default'], status: 'connected' },
{ id: '3', name: 'tke-test', context: 'tke-test', version: 'v1.26.5', nodes: 2, namespaces: ['default'], status: 'disconnected' },
];
export default function EnvironmentManager() {
const [activeTab, setActiveTab] = useState<'hosts' | 'k8s'>('hosts');
const [searchQuery, setSearchQuery] = useState('');
const filteredHosts = mockHosts.filter(h =>
h.name.includes(searchQuery) || h.ip.includes(searchQuery)
);
const filteredClusters = mockClusters.filter(c =>
c.name.includes(searchQuery) || c.context.includes(searchQuery)
);
return (
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
{/* Header */}
<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>
<div className="flex items-center border rounded-md overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}>
<button
onClick={() => setActiveTab('hosts')}
className="px-3 py-[5px] text-[12px] font-medium transition-colors"
style={{
background: activeTab === 'hosts' ? 'var(--panel-bg-secondary)' : 'transparent',
color: activeTab === 'hosts' ? 'var(--accent-blue)' : 'var(--text-secondary)',
}}
>
主机组
</button>
<button
onClick={() => setActiveTab('k8s')}
className="px-3 py-[5px] text-[12px] font-medium transition-colors"
style={{
background: activeTab === 'k8s' ? 'var(--panel-bg-secondary)' : 'transparent',
color: activeTab === 'k8s' ? 'var(--accent-blue)' : 'var(--text-secondary)',
}}
>
K8s集群
</button>
</div>
</div>
<div className="flex items-center gap-2">
<div
className="flex items-center gap-2 px-3 py-[6px] rounded-md border"
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }}
>
<Search className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={activeTab === 'hosts' ? '搜索主机...' : '搜索集群...'}
className="bg-transparent outline-none w-32 text-[13px]"
style={{ color: 'var(--text-primary)' }}
/>
</div>
<button
className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white"
style={{ background: 'var(--accent-blue)' }}
onClick={() => toast.info(`添加${activeTab === 'hosts' ? '主机' : '集群'}`, { description: '功能开发中...' })}
>
<Plus className="w-3.5 h-3.5" />
添加{activeTab === 'hosts' ? '主机' : '集群'}
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
{activeTab === 'hosts' ? (
<div className="space-y-3">
{/* Host groups summary */}
<div className="grid grid-cols-4 gap-3 mb-4">
{[
{ label: '生产环境', count: 3, online: 3, color: '#1a7f37' },
{ label: '预发环境', count: 2, online: 1, color: '#9a6700' },
{ label: '测试环境', count: 0, online: 0, color: '#8250df' },
{ label: '总计', count: 5, online: 4, color: '#0969da' },
].map((stat) => (
<div
key={stat.label}
className="rounded-lg border p-4"
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
>
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>{stat.label}</div>
<div className="flex items-baseline gap-2">
<span className="text-[22px] font-bold" style={{ color: 'var(--text-primary)' }}>{stat.count}</span>
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
在线 {stat.online}
</span>
</div>
</div>
))}
</div>
{/* Host list */}
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<table className="w-full">
<thead>
<tr style={{ background: 'var(--panel-bg-secondary)' }}>
{['主机名', 'IP地址', '端口', '用户名', '标签', '操作系统', '状态', '操作'].map((h) => (
<th key={h} className="text-left px-4 py-2 text-[11px] font-semibold uppercase tracking-wider" style={{ color: 'var(--text-tertiary)' }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{filteredHosts.map((host) => (
<tr key={host.id} className="border-t hover:bg-black/[0.02] transition-colors" style={{ borderColor: 'var(--border-color-light)' }}>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Server className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} />
<span className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>{host.name}</span>
</div>
</td>
<td className="px-4 py-3 text-[12px] font-mono" style={{ color: 'var(--text-secondary)' }}>{host.ip}</td>
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{host.port}</td>
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{host.username}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1 flex-wrap">
{host.tags.map((tag) => (
<span
key={tag}
className="text-[10px] px-1.5 py-[2px] rounded font-medium"
style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}
>
{tag}
</span>
))}
</div>
</td>
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{host.os}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1">
{host.status === 'online' ? (
<CheckCircle2 className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} />
) : (
<XCircle className="w-3.5 h-3.5" style={{ color: 'var(--accent-red)' }} />
)}
<span className="text-[12px]" style={{ color: host.status === 'online' ? 'var(--accent-green)' : 'var(--accent-red)' }}>
{host.status === 'online' ? '在线' : '离线'}
</span>
</div>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-0">
<button className="p-1 rounded hover:bg-black/5" title="SSH连接" onClick={() => toast.info('SSH连接', { description: `${host.username}@${host.ip}:${host.port}` })}>
<Terminal className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button>
<button className="p-1 rounded hover:bg-black/5" title="编辑">
<Edit2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button>
<button className="p-1 rounded hover:bg-black/5" title="删除">
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : (
/* K8s Clusters */
<div className="space-y-3">
<div className="grid grid-cols-3 gap-3 mb-4">
{[
{ label: '已连接', count: 2, color: '#1a7f37' },
{ label: '未连接', count: 1, color: '#cf222e' },
{ label: '总节点数', count: 11, color: '#0969da' },
].map((stat) => (
<div key={stat.label} className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>{stat.label}</div>
<div className="text-[22px] font-bold" style={{ color: 'var(--text-primary)' }}>{stat.count}</div>
</div>
))}
</div>
<div className="space-y-3">
{filteredClusters.map((cluster) => (
<div
key={cluster.id}
className="rounded-lg border p-4"
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<CloudCog className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
<div>
<div className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>{cluster.name}</div>
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>Context: {cluster.context}</div>
</div>
</div>
<div className="flex items-center gap-2">
<span
className="text-[11px] px-2 py-[3px] rounded-full font-medium"
style={{
background: cluster.status === 'connected' ? '#dafbe1' : '#ffebe9',
color: cluster.status === 'connected' ? '#1a7f37' : '#cf222e',
}}
>
{cluster.status === 'connected' ? '已连接' : '未连接'}
</span>
<button className="p-1 rounded hover:bg-black/5">
<Edit2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button>
</div>
</div>
<div className="grid grid-cols-4 gap-4 text-[12px]">
<div>
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>版本</div>
<div style={{ color: 'var(--text-primary)' }}>{cluster.version}</div>
</div>
<div>
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>节点数</div>
<div style={{ color: 'var(--text-primary)' }}>{cluster.nodes}</div>
</div>
<div className="col-span-2">
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>命名空间</div>
<div className="flex items-center gap-1 flex-wrap">
{cluster.namespaces.map((ns) => (
<span
key={ns}
className="text-[10px] px-1.5 py-[2px] rounded font-medium"
style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}
>
{ns}
</span>
))}
</div>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}
+358
View File
@@ -0,0 +1,358 @@
import { useState } from 'react';
import {
CloudSync, GitBranch, RefreshCw, CheckCircle2, XCircle,
Clock, ChevronRight, TreePine, Diff, Layers,
Server, AlertTriangle, FolderGit, Package
} from 'lucide-react';
import { toast } from 'sonner';
import { useQuery, useMutation } from '@tanstack/react-query';
import { api } from '@/lib/api';
interface Application {
id: string;
name: string;
repo: string;
branch: string;
path: string;
cluster: string;
namespace: string;
status: 'synced' | 'out-of-sync' | 'syncing' | 'error' | 'unknown';
syncStatus: 'synced' | 'out-of-sync';
health: 'healthy' | 'degraded' | 'progressing' | 'suspended';
lastSync: string;
resources: number;
manifestType: 'helm' | 'kustomize' | 'plain';
autoSync: boolean;
}
interface ResourceNode {
id: string;
kind: string;
name: string;
namespace: string;
status: 'healthy' | 'degraded' | 'progressing' | 'suspended';
children?: ResourceNode[];
}
const mockApps: Application[] = [
{
id: 'app-1', name: 'aiops-backend', repo: 'https://gitee.com/attacker/aiops-gitops.git',
branch: 'main', path: 'apps/aiops-backend/overlays/production',
cluster: 'production-k8s', namespace: 'aiops', status: 'synced', syncStatus: 'synced',
health: 'healthy', lastSync: '2分钟前', resources: 12, manifestType: 'kustomize', autoSync: true,
},
{
id: 'app-2', name: 'aiops-web', repo: 'https://gitee.com/attacker/aiops-gitops.git',
branch: 'main', path: 'apps/aiops-web/overlays/production',
cluster: 'production-k8s', namespace: 'aiops', status: 'syncing', syncStatus: 'out-of-sync',
health: 'progressing', lastSync: '正在同步', resources: 8, manifestType: 'helm', autoSync: true,
},
{
id: 'app-3', name: 'gateway-api', repo: 'https://gitee.com/attacker/aiops-gitops.git',
branch: 'release/v2.1', path: 'apps/gateway/overlays/staging',
cluster: 'staging-k8s', namespace: 'gateway', status: 'out-of-sync', syncStatus: 'out-of-sync',
health: 'degraded', lastSync: '1小时前', resources: 6, manifestType: 'kustomize', autoSync: false,
},
{
id: 'app-4', name: 'data-processor', repo: 'https://gitee.com/attacker/aiops-gitops.git',
branch: 'main', path: 'apps/data/overlays/production',
cluster: 'production-k8s', namespace: 'data', status: 'synced', syncStatus: 'synced',
health: 'healthy', lastSync: '30分钟前', resources: 5, manifestType: 'plain', autoSync: true,
},
];
const mockResourceTree: ResourceNode[] = [
{
id: 'r1', kind: 'Deployment', name: 'aiops-backend', namespace: 'aiops', status: 'healthy',
children: [
{ id: 'r1-1', kind: 'ReplicaSet', name: 'aiops-backend-7d9f4', namespace: 'aiops', status: 'healthy',
children: [
{ id: 'r1-1-1', kind: 'Pod', name: 'aiops-backend-7d9f4-abc1', namespace: 'aiops', status: 'healthy' },
{ id: 'r1-1-2', kind: 'Pod', name: 'aiops-backend-7d9f4-abc2', namespace: 'aiops', status: 'healthy' },
{ id: 'r1-1-3', kind: 'Pod', name: 'aiops-backend-7d9f4-abc3', namespace: 'aiops', status: 'healthy' },
]
},
]
},
{
id: 'r2', kind: 'Service', name: 'aiops-backend-svc', namespace: 'aiops', status: 'healthy',
},
{
id: 'r3', kind: 'Ingress', name: 'aiops-backend-ingress', namespace: 'aiops', status: 'healthy',
},
{
id: 'r4', kind: 'ConfigMap', name: 'aiops-backend-config', namespace: 'aiops', status: 'healthy',
},
];
const statusConfig: Record<string, { color: string; bg: string; label: string; icon: typeof CheckCircle2 }> = {
synced: { color: '#1a7f37', bg: '#dafbe1', label: '已同步', icon: CheckCircle2 },
'out-of-sync': { color: '#9a6700', bg: '#fff8c5', label: '待同步', icon: Clock },
syncing: { color: '#0969da', bg: '#ddf4ff', label: '同步中', icon: RefreshCw },
error: { color: '#cf222e', bg: '#ffebe9', label: '错误', icon: XCircle },
unknown: { color: '#6e7781', bg: '#f6f8fa', label: '未知', icon: AlertTriangle },
};
const healthConfig: Record<string, { color: string; label: string; bg: string }> = {
healthy: { color: '#1a7f37', label: '健康', bg: '#dafbe1' },
degraded: { color: '#cf222e', label: '降级', bg: '#ffebe9' },
progressing: { color: '#9a6700', label: '进行中', bg: '#fff8c5' },
suspended: { color: '#6e7781', label: '暂停', bg: '#f6f8fa' },
};
const manifestConfig: Record<string, { label: string; icon: typeof Package }> = {
helm: { label: 'Helm', icon: Package },
kustomize: { label: 'Kustomize', icon: Layers },
plain: { label: 'Plain YAML', icon: FolderGit },
};
export default function GitOpsDeploy() {
const [selectedAppId, setSelectedAppId] = useState<string | null>(null);
const [activeView, setActiveView] = useState<'list' | 'tree' | 'diff'>('list');
const { data: dbApps = [] } = useQuery({ queryKey: ['gitops', 'apps'], queryFn: () => api<any[]>('/api/gitops/apps') });
const { data: argoApps = [] } = useQuery({ queryKey: ['gitops', 'argo'], queryFn: () => api<any[]>('/api/gitops/argo/apps') });
const syncMutation = useMutation({
mutationFn: (appName: string) => api<any>('/api/gitops/argo/sync', { method: 'POST', body: JSON.stringify({ appName }) }),
onSuccess: (data) => toast.success(data.message),
onError: (err: Error) => toast.error(err.message),
});
const apps: Application[] = dbApps.length > 0
? dbApps.map((db: any) => {
const argo = (argoApps as any[]).find((a: any) => a.metadata?.name === db.name);
return {
id: String(db.id),
name: db.name,
repo: db.repo,
branch: db.branch,
path: db.path,
cluster: db.cluster,
namespace: db.namespace,
status: argo?.status?.sync?.status === 'Synced' ? 'synced' : argo?.status?.sync?.status === 'OutOfSync' ? 'out-of-sync' : 'unknown',
syncStatus: argo?.status?.sync?.status === 'Synced' ? 'synced' : 'out-of-sync',
health: (argo?.status?.health?.status?.toLowerCase() || 'unknown') as any,
lastSync: '刚刚',
resources: 0,
manifestType: db.manifestType,
autoSync: db.syncPolicy === 'automated',
};
})
: mockApps;
const selectedApp = apps.find(a => a.id === selectedAppId);
const renderResourceNode = (node: ResourceNode, depth: number = 0) => {
const health = healthConfig[node.status];
const hasChildren = node.children && node.children.length > 0;
return (
<div key={node.id} className="select-none">
<div
className="flex items-center gap-2 py-1.5 px-3 hover:bg-black/[0.02] rounded-md cursor-pointer"
style={{ paddingLeft: `${12 + depth * 20}px` }}
>
{hasChildren ? <ChevronRight className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} /> : <div className="w-3.5" />}
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: health.color }} />
<span className="text-[11px] font-medium px-1.5 py-[1px] rounded" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>{node.kind}</span>
<span className="text-[12px] font-medium" style={{ color: 'var(--text-primary)' }}>{node.name}</span>
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{node.namespace}</span>
</div>
{hasChildren && node.children!.map(child => renderResourceNode(child, depth + 1))}
</div>
);
};
return (
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
{/* Header */}
<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)' }}>GitOps 部署</h1>
<span className="text-[11px] px-2 py-[2px] rounded-full font-medium" style={{ background: '#ddf4ff', color: 'var(--accent-blue)' }}>Argo CD</span>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center border rounded-md overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}>
{[
{ id: 'list', label: '应用列表', icon: Layers },
{ id: 'tree', label: '资源树', icon: TreePine },
{ id: 'diff', label: '差异对比', icon: Diff },
].map(v => (
<button key={v.id} onClick={() => setActiveView(v.id as any)}
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium transition-colors"
style={{ background: activeView === v.id ? 'var(--panel-bg-secondary)' : 'transparent', color: activeView === v.id ? 'var(--accent-blue)' : 'var(--text-secondary)' }}>
<v.icon className="w-3.5 h-3.5" />{v.label}
</button>
))}
</div>
<button onClick={() => toast.success('刷新应用状态')} className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
<RefreshCw className="w-3.5 h-3.5" />刷新
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-hidden flex">
{/* App list sidebar */}
<div className="w-[280px] border-r flex flex-col flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="px-3 py-2 text-[11px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>Applications ({apps.length})</div>
<div className="flex-1 overflow-y-auto custom-scrollbar">
{apps.map(app => {
const st = statusConfig[app.status];
const hl = healthConfig[app.health];
const mf = manifestConfig[app.manifestType];
const isSelected = selectedAppId === app.id;
return (
<div key={app.id} onClick={() => setSelectedAppId(app.id)}
className="mx-2 mb-1 p-2.5 rounded-md cursor-pointer border transition-all"
style={{
background: isSelected ? 'var(--accent-blue-light)' : 'transparent',
borderColor: isSelected ? 'var(--accent-blue)' : 'transparent',
}}>
<div className="flex items-center justify-between mb-1">
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>{app.name}</span>
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium flex items-center gap-1" style={{ background: st.bg, color: st.color }}>
<st.icon className="w-3 h-3" />{st.label}
</span>
</div>
<div className="flex items-center gap-1.5 mb-1">
<GitBranch className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />
<span className="text-[11px] font-mono truncate" style={{ color: 'var(--text-secondary)' }}>{app.repo.replace('https://', '').replace('.git', '')}</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-[10px] flex items-center gap-1" style={{ color: 'var(--text-tertiary)' }}><mf.icon className="w-3 h-3" />{mf.label}</span>
<span className="text-[10px]" style={{ color: hl.color }}>{hl.label}</span>
</div>
<span className="text-[10px]" style={{ color: 'var(--text-tertiary)' }}>{app.lastSync}</span>
</div>
</div>
);
})}
</div>
</div>
{/* Detail view */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
{!selectedApp ? (
<div className="h-full flex flex-col items-center justify-center text-center" style={{ color: 'var(--text-tertiary)' }}>
<CloudSync className="w-12 h-12 mb-3 opacity-30" />
<p className="text-[14px]">选择一个 Application 查看详情</p>
<p className="text-[12px] mt-1">Git 仓库作为部署声明的单一事实来源</p>
</div>
) : activeView === 'list' ? (
<div className="space-y-4">
{/* App header */}
<div className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<h2 className="text-[16px] font-semibold" style={{ color: 'var(--text-primary)' }}>{selectedApp.name}</h2>
<span className="text-[10px] px-2 py-[2px] rounded-full font-medium" style={{ background: statusConfig[selectedApp.status].bg, color: statusConfig[selectedApp.status].color }}>
{statusConfig[selectedApp.status].label}
</span>
<span className="text-[10px] px-2 py-[2px] rounded-full font-medium" style={{ background: healthConfig[selectedApp.health].bg || '#f6f8fa', color: healthConfig[selectedApp.health].color }}>
{healthConfig[selectedApp.health].label}
</span>
</div>
<div className="flex items-center gap-2">
<button onClick={() => syncMutation.mutate(selectedApp.name)}
disabled={syncMutation.isPending}
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md text-white disabled:opacity-50" style={{ background: 'var(--accent-blue)' }}>
<RefreshCw className={`w-3.5 h-3.5 ${syncMutation.isPending ? 'animate-spin' : ''}`} />立即同步
</button>
<button onClick={() => toast.info('回滚', { description: '选择要回滚的版本' })}
className="px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
回滚
</button>
</div>
</div>
<div className="grid grid-cols-4 gap-4 text-[12px]">
<div><div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>Git 仓库</div><div className="font-mono" style={{ color: 'var(--text-primary)' }}>{selectedApp.repo}</div></div>
<div><div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>分支 / 路径</div><div style={{ color: 'var(--text-primary)' }}>{selectedApp.branch} / {selectedApp.path}</div></div>
<div><div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>目标集群</div><div className="flex items-center gap-1" style={{ color: 'var(--text-primary)' }}><Server className="w-3 h-3" />{selectedApp.cluster}</div></div>
<div><div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>命名空间</div><div style={{ color: 'var(--text-primary)' }}>{selectedApp.namespace}</div></div>
</div>
</div>
{/* Sync config */}
<div className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<h3 className="text-[13px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>同步配置</h3>
<div className="grid grid-cols-3 gap-4">
<div className="flex items-center gap-2">
<label className="flex items-center gap-2 text-[12px] cursor-pointer">
<input type="checkbox" checked={selectedApp.autoSync} readOnly className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} />
<span style={{ color: 'var(--text-primary)' }}>自动同步</span>
</label>
</div>
<div><label className="block text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>同步策略</label>
<select className="w-full px-2 py-[5px] text-[12px] rounded-md border" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}>
<option>自动(Git 变更触发)</option><option>手动(仅点击同步)</option><option>定时同步</option>
</select></div>
<div><label className="block text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>Prune 资源</label>
<select className="w-full px-2 py-[5px] text-[12px] rounded-md border" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}>
<option>自动清理已删除资源</option><option>保留所有资源</option>
</select></div>
</div>
</div>
{/* Environment promotion */}
<div className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<h3 className="text-[13px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>环境晋升</h3>
<div className="flex items-center gap-3">
<div className="flex-1 rounded-md border p-3 text-center" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }}>
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>开发环境</div>
<div className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>dev</div>
<div className="text-[10px] mt-1" style={{ color: '#1a7f37' }}>已同步</div>
</div>
<ChevronRight className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} />
<div className="flex-1 rounded-md border p-3 text-center" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }}>
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>测试环境</div>
<div className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>staging</div>
<div className="text-[10px] mt-1" style={{ color: '#9a6700' }}>待同步</div>
</div>
<ChevronRight className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} />
<div className="flex-1 rounded-md border p-3 text-center" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }}>
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>生产环境</div>
<div className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>production</div>
<div className="text-[10px] mt-1" style={{ color: '#1a7f37' }}>已同步</div>
</div>
<button onClick={() => toast.success('晋升', { description: '将 staging 版本晋升到 production' })}
className="px-3 py-[7px] text-[12px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}>
晋升
</button>
</div>
</div>
</div>
) : activeView === 'tree' ? (
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="px-4 py-2 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color)' }}>
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>资源树</span>
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{mockResourceTree.length} 个顶级资源</span>
</div>
<div className="py-2">
{mockResourceTree.map(node => renderResourceNode(node))}
</div>
</div>
) : (
<div className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<h3 className="text-[13px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>Git vs 集群差异</h3>
<div className="space-y-2">
<div className="flex items-center gap-3 p-2 rounded-md" style={{ background: '#dafbe1' }}>
<CheckCircle2 className="w-4 h-4" style={{ color: '#1a7f37' }} />
<span className="text-[12px]" style={{ color: '#1a7f37' }}>Deployment/aiops-backend: Git 和集群一致</span>
</div>
<div className="flex items-center gap-3 p-2 rounded-md" style={{ background: '#fff8c5' }}>
<AlertTriangle className="w-4 h-4" style={{ color: '#9a6700' }} />
<span className="text-[12px]" style={{ color: '#9a6700' }}>Service/aiops-backend-svc: 集群中端口被手动修改 (80 {'->'} 8080)</span>
</div>
<div className="flex items-center gap-3 p-2 rounded-md" style={{ background: '#ffebe9' }}>
<XCircle className="w-4 h-4" style={{ color: '#cf222e' }} />
<span className="text-[12px]" style={{ color: '#cf222e' }}>ConfigMap/aiops-backend-config: Git 中新增,集群中不存在</span>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { useState } from 'react'
import '../App.css'
export default function Home() {
const [count, setCount] = useState(0)
return (
<>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
</>
)
}
+297
View File
@@ -0,0 +1,297 @@
import { useState } from 'react';
import {
Download,
Link,
Search,
CheckCircle2,
AlertCircle,
ArrowRight,
FileCode,
GitBranch,
RefreshCw
} from 'lucide-react';
import { toast } from 'sonner';
interface JenkinsJob {
id: string;
name: string;
path: string;
type: 'freestyle' | 'pipeline' | 'multibranch';
lastBuild: string;
status: 'success' | 'failed' | 'unstable';
selected: boolean;
}
const mockJobs: JenkinsJob[] = [
{ id: '1', name: 'backend-build', path: 'aiops/backend-build', type: 'pipeline', lastBuild: '2024-06-20 14:30', status: 'success', selected: false },
{ id: '2', name: 'frontend-deploy', path: 'aiops/frontend-deploy', type: 'freestyle', lastBuild: '2024-06-20 13:15', status: 'success', selected: false },
{ id: '3', name: 'gateway-service', path: 'platform/gateway-service', type: 'pipeline', lastBuild: '2024-06-19 18:45', status: 'failed', selected: false },
{ id: '4', name: 'data-sync-job', path: 'data/data-sync-job', type: 'freestyle', lastBuild: '2024-06-18 02:00', status: 'unstable', selected: false },
{ id: '5', name: 'mobile-app-build', path: 'mobile/mobile-app-build', type: 'multibranch', lastBuild: '2024-06-17 16:20', status: 'success', selected: false },
];
const typeConfig = {
freestyle: { label: '自由风格', color: '#6e7781', bg: '#f6f8fa' },
pipeline: { label: '流水线', color: '#0969da', bg: '#ddf4ff' },
multibranch: { label: '多分支', color: '#8250df', bg: '#fbefff' },
};
const statusConfig = {
success: { icon: CheckCircle2, color: '#1a7f37' },
failed: { icon: AlertCircle, color: '#cf222e' },
unstable: { icon: AlertCircle, color: '#9a6700' },
};
export default function JenkinsImport() {
const [step, setStep] = useState<'connect' | 'select' | 'preview' | 'done'>('connect');
const [jenkinsUrl, setJenkinsUrl] = useState('http://jenkins.company.com:8080');
const [jenkinsUser, setJenkinsUser] = useState('');
const [jenkinsToken, setJenkinsToken] = useState('');
const [jobs, setJobs] = useState<JenkinsJob[]>(mockJobs);
const [searchQuery, setSearchQuery] = useState('');
const [isConnecting, setIsConnecting] = useState(false);
const handleConnect = () => {
setIsConnecting(true);
setTimeout(() => {
setIsConnecting(false);
setStep('select');
toast.success('连接成功', { description: `已连接到 ${jenkinsUrl}` });
}, 1500);
};
const toggleJob = (id: string) => {
setJobs(prev => prev.map(j => j.id === id ? { ...j, selected: !j.selected } : j));
};
const selectedCount = jobs.filter(j => j.selected).length;
const filteredJobs = jobs.filter(j =>
j.name.includes(searchQuery) || j.path.includes(searchQuery)
);
const handleImport = () => {
toast.success('导入完成', { description: `成功导入 ${selectedCount} 个Job` });
setStep('done');
};
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)' }}>
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>Jenkins 导入</h1>
{step !== 'connect' && (
<div className="flex items-center gap-1 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>
<span style={{ color: step === 'select' || step === 'preview' || step === 'done' ? 'var(--accent-blue)' : undefined }}>1.连接</span>
<ArrowRight className="w-3 h-3" />
<span style={{ color: step === 'select' || step === 'preview' || step === 'done' ? 'var(--accent-blue)' : undefined }}>2.选择</span>
<ArrowRight className="w-3 h-3" />
<span style={{ color: step === 'preview' || step === 'done' ? 'var(--accent-blue)' : undefined }}>3.预览</span>
<ArrowRight className="w-3 h-3" />
<span style={{ color: step === 'done' ? 'var(--accent-blue)' : undefined }}>4.完成</span>
</div>
)}
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
<div className="max-w-2xl mx-auto">
{step === 'connect' && (
<div className="rounded-lg border p-6" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-lg flex items-center justify-center" style={{ background: 'var(--accent-blue-light)' }}>
<Link className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
</div>
<div>
<h2 className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>连接 Jenkins 服务器</h2>
<p className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>输入 Jenkins 地址和认证信息以获取 Job 列表</p>
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>Jenkins 地址</label>
<input
value={jenkinsUrl}
onChange={(e) => setJenkinsUrl(e.target.value)}
placeholder="http://jenkins.company.com:8080"
className="w-full px-3 py-[7px] rounded-md border text-[13px] outline-none focus:border-[var(--accent-blue)]"
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
/>
</div>
<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>
<input
value={jenkinsUser}
onChange={(e) => setJenkinsUser(e.target.value)}
placeholder="admin"
className="w-full px-3 py-[7px] rounded-md border text-[13px] outline-none focus:border-[var(--accent-blue)]"
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
/>
</div>
<div>
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>API Token</label>
<input
type="password"
value={jenkinsToken}
onChange={(e) => setJenkinsToken(e.target.value)}
placeholder="11xxxxxxxxxxxxxxxxxxxxxx"
className="w-full px-3 py-[7px] rounded-md border text-[13px] outline-none focus:border-[var(--accent-blue)]"
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
/>
</div>
</div>
<div className="flex items-center gap-2 pt-2">
<button
onClick={handleConnect}
disabled={isConnecting}
className="flex items-center gap-1.5 px-4 py-[7px] text-[13px] font-medium rounded-md text-white disabled:opacity-60"
style={{ background: 'var(--accent-blue)' }}
>
{isConnecting ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <Link className="w-3.5 h-3.5" />}
{isConnecting ? '连接中...' : '连接并获取Job'}
</button>
</div>
</div>
</div>
)}
{step === 'select' && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 px-3 py-[6px] rounded-md border" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }}>
<Search className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
<input value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="搜索Job..." className="bg-transparent outline-none w-40 text-[13px]" style={{ color: 'var(--text-primary)' }} />
</div>
</div>
<div className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>
已选择 <span className="font-semibold" style={{ color: 'var(--accent-blue)' }}>{selectedCount}</span> 个 Job
</div>
</div>
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<table className="w-full">
<thead>
<tr style={{ background: 'var(--panel-bg-secondary)' }}>
<th className="w-10 px-3 py-2"></th>
<th className="text-left px-3 py-2 text-[11px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>Job名称</th>
<th className="text-left px-3 py-2 text-[11px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>类型</th>
<th className="text-left px-3 py-2 text-[11px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>状态</th>
<th className="text-left px-3 py-2 text-[11px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>最后构建</th>
</tr>
</thead>
<tbody>
{filteredJobs.map((job) => {
const type = typeConfig[job.type];
const status = statusConfig[job.status];
const StatusIcon = status.icon;
return (
<tr key={job.id} className="border-t hover:bg-black/[0.02] transition-colors" style={{ borderColor: 'var(--border-color-light)' }}>
<td className="px-3 py-3">
<input
type="checkbox"
checked={job.selected}
onChange={() => toggleJob(job.id)}
className="w-4 h-4 rounded cursor-pointer"
style={{ accentColor: 'var(--accent-blue)' }}
/>
</td>
<td className="px-3 py-3">
<div className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>{job.name}</div>
<div className="text-[11px] font-mono" style={{ color: 'var(--text-tertiary)' }}>{job.path}</div>
</td>
<td className="px-3 py-3">
<span className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: type.bg, color: type.color }}>{type.label}</span>
</td>
<td className="px-3 py-3">
<div className="flex items-center gap-1">
<StatusIcon className="w-3.5 h-3.5" style={{ color: status.color }} />
<span className="text-[12px]" style={{ color: status.color }}>
{job.status === 'success' ? '成功' : job.status === 'failed' ? '失败' : '不稳定'}
</span>
</div>
</td>
<td className="px-3 py-3 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>{job.lastBuild}</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="flex justify-end">
<button
onClick={() => selectedCount > 0 ? setStep('preview') : toast.error('请至少选择一个Job')}
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-3.5 h-3.5" />
</button>
</div>
</div>
)}
{step === 'preview' && (
<div className="space-y-4">
<div className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<h3 className="text-[13px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>转换预览</h3>
<div className="space-y-3">
{jobs.filter(j => j.selected).map((job) => (
<div key={job.id} className="flex items-center gap-3 p-3 rounded-md" style={{ background: 'var(--panel-bg-secondary)' }}>
<FileCode className="w-4 h-4" style={{ color: 'var(--accent-blue)' }} />
<div className="flex-1">
<div className="text-[12px] font-medium" style={{ color: 'var(--text-primary)' }}>{job.name}</div>
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>Jenkins {typeConfig[job.type].label} → AIOps 流水线</div>
</div>
<ArrowRight className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />
<div className="flex items-center gap-1">
<GitBranch className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} />
<span className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>aiops-{job.name}</span>
</div>
</div>
))}
</div>
</div>
<div className="flex justify-end gap-2">
<button onClick={() => setStep('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={handleImport} className="flex items-center gap-1.5 px-4 py-[7px] text-[13px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}>
<Download className="w-3.5 h-3.5" />
确认导入
</button>
</div>
</div>
)}
{step === 'done' && (
<div className="text-center py-12">
<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)' }}>
成功导入 {selectedCount} 个 Jenkins Job 为 AIOps 流水线
</p>
<div className="flex justify-center gap-2">
<button
onClick={() => { setStep('connect'); setJobs(mockJobs.map(j => ({ ...j, selected: false }))); }}
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.info('跳转到流水线列表')}
className="px-4 py-[7px] text-[13px] font-medium rounded-md text-white"
style={{ background: 'var(--accent-blue)' }}
>
查看流水线
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
}
+403
View File
@@ -0,0 +1,403 @@
import { useState } from 'react';
import {
Search,
Plus,
Play,
Settings,
Trash2,
GitBranch,
Clock,
CheckCircle2,
XCircle,
Loader2,
Filter,
LayoutGrid,
List,
MoreHorizontal
} from 'lucide-react';
import { toast } from 'sonner';
interface Pipeline {
id: string;
name: string;
description: string;
lastStatus: 'success' | 'failed' | 'running' | 'pending';
lastRunAt: string;
triggerType: string;
duration: string;
branch: string;
deployType: 'host' | 'k8s';
}
const mockPipelines: Pipeline[] = [
{
id: '1',
name: 'aiops-backend',
description: 'Go后端服务 - Docker构建 + K8s部署',
lastStatus: 'success',
lastRunAt: '2024-06-20 14:30:15',
triggerType: 'Git Push',
duration: '3分22秒',
branch: 'master',
deployType: 'k8s',
},
{
id: '2',
name: 'aiops-web',
description: 'Vue前端应用 - npm构建 + 主机部署',
lastStatus: 'running',
lastRunAt: '2024-06-20 15:45:00',
triggerType: '手动',
duration: '1分12秒',
branch: 'master',
deployType: 'host',
},
{
id: '3',
name: 'gateway-api',
description: 'API网关服务 - Java构建 + K8s部署',
lastStatus: 'failed',
lastRunAt: '2024-06-20 13:20:00',
triggerType: '手动',
duration: '45秒',
branch: 'release/v2.1',
deployType: 'k8s',
},
{
id: '4',
name: 'admin-dashboard',
description: '管理后台 - React构建 + 主机部署',
lastStatus: 'success',
lastRunAt: '2024-06-20 12:10:00',
triggerType: 'Cron',
duration: '2分05秒',
branch: 'master',
deployType: 'host',
},
{
id: '5',
name: 'data-processor',
description: '数据处理服务 - Python构建 + K8s部署',
lastStatus: 'pending',
lastRunAt: '-',
triggerType: 'Webhook',
duration: '-',
branch: 'develop',
deployType: 'k8s',
},
];
const statusConfig = {
success: { icon: CheckCircle2, color: '#1a7f37', bg: '#dafbe1', label: '成功' },
failed: { icon: XCircle, color: '#cf222e', bg: '#ffebe9', label: '失败' },
running: { icon: Loader2, color: '#0969da', bg: '#ddf4ff', label: '执行中' },
pending: { icon: Clock, color: '#6e7781', bg: '#f6f8fa', label: '未执行' },
};
export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (id: string) => void }) {
const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [pipelines] = useState<Pipeline[]>(mockPipelines);
const filtered = pipelines.filter((p) => {
const matchSearch = p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
p.description.toLowerCase().includes(searchQuery.toLowerCase());
const matchStatus = statusFilter === 'all' || p.lastStatus === statusFilter;
return matchSearch && matchStatus;
});
const statusOptions = [
{ value: 'all', label: '全部状态' },
{ value: 'success', label: '成功' },
{ value: 'failed', label: '失败' },
{ value: 'running', label: '执行中' },
{ value: 'pending', label: '未执行' },
];
return (
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
{/* Header toolbar */}
<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: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}
>
{filtered.length}
</span>
</div>
<div className="flex items-center gap-2">
{/* Search */}
<div
className="flex items-center gap-2 px-3 py-[6px] rounded-md border text-[13px]"
style={{
background: 'var(--panel-bg-secondary)',
borderColor: 'var(--border-color-light)',
color: 'var(--text-secondary)',
}}
>
<Search className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="搜索流水线..."
className="bg-transparent outline-none w-40"
style={{ color: 'var(--text-primary)', fontSize: '13px' }}
/>
</div>
{/* Status filter */}
<div className="relative group">
<button
className="flex items-center gap-1.5 px-3 py-[6px] rounded-md border text-[13px] font-medium"
style={{
background: 'var(--panel-bg)',
borderColor: 'var(--border-color-light)',
color: 'var(--text-secondary)',
}}
>
<Filter className="w-3.5 h-3.5" />
{statusOptions.find(s => s.value === statusFilter)?.label}
</button>
<div className="absolute right-0 top-full mt-1 hidden group-hover:block z-50">
<div
className="rounded-md border shadow-lg py-1 min-w-[120px]"
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
>
{statusOptions.map((opt) => (
<button
key={opt.value}
onClick={() => setStatusFilter(opt.value)}
className="w-full text-left px-3 py-[6px] text-[13px] hover:bg-black/5 transition-colors"
style={{ color: statusFilter === opt.value ? 'var(--accent-blue)' : 'var(--text-secondary)' }}
>
{opt.label}
</button>
))}
</div>
</div>
</div>
{/* View toggle */}
<div className="flex items-center border rounded-md overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}>
<button
onClick={() => setViewMode('card')}
className="p-[6px] transition-colors"
style={{ background: viewMode === 'card' ? 'var(--panel-bg-secondary)' : 'var(--panel-bg)' }}
>
<LayoutGrid className="w-3.5 h-3.5" style={{ color: 'var(--text-secondary)' }} />
</button>
<button
onClick={() => setViewMode('list')}
className="p-[6px] transition-colors"
style={{ background: viewMode === 'list' ? 'var(--panel-bg-secondary)' : 'var(--panel-bg)' }}
>
<List className="w-3.5 h-3.5" style={{ color: 'var(--text-secondary)' }} />
</button>
</div>
{/* New pipeline button */}
<button
className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white"
style={{ background: 'var(--accent-blue)' }}
onClick={() => toast.info('新建流水线', { description: '功能开发中...' })}
>
<Plus className="w-3.5 h-3.5" />
新建流水线
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20">
<div className="w-12 h-12 rounded-full flex items-center justify-center mb-3" style={{ background: 'var(--panel-bg-secondary)' }}>
<Search className="w-5 h-5" style={{ color: 'var(--text-tertiary)' }} />
</div>
<p className="text-[13px] font-medium" style={{ color: 'var(--text-secondary)' }}>未找到流水线</p>
<p className="text-[12px] mt-1" style={{ color: 'var(--text-tertiary)' }}>尝试调整搜索条件</p>
</div>
) : viewMode === 'card' ? (
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
{filtered.map((pipeline) => {
const status = statusConfig[pipeline.lastStatus];
const StatusIcon = status.icon;
return (
<div
key={pipeline.id}
className="rounded-lg border transition-all duration-200 hover:shadow-md cursor-pointer group"
style={{
background: 'var(--panel-bg)',
borderColor: 'var(--border-color)',
}}
onClick={() => onSelectPipeline(pipeline.id)}
>
{/* Card header */}
<div className="px-4 py-3 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color-light)' }}>
<div className="flex items-center gap-2">
<div
className="w-2 h-2 rounded-full"
style={{ background: status.color }}
/>
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>
{pipeline.name}
</span>
</div>
<span
className="text-[10px] px-1.5 py-[2px] rounded font-medium uppercase tracking-wide"
style={{
background: pipeline.deployType === 'k8s' ? '#ddf4ff' : '#dafbe1',
color: pipeline.deployType === 'k8s' ? '#0969da' : '#1a7f37',
}}
>
{pipeline.deployType === 'k8s' ? 'K8s' : '主机'}
</span>
</div>
{/* Card body */}
<div className="px-4 py-3">
<p className="text-[12px] mb-3 line-clamp-2" style={{ color: 'var(--text-secondary)' }}>
{pipeline.description}
</p>
<div className="flex items-center gap-4 text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
<div className="flex items-center gap-1">
<GitBranch className="w-3 h-3" />
{pipeline.branch}
</div>
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{pipeline.duration}
</div>
</div>
</div>
{/* Card footer */}
<div
className="px-4 py-2 border-t flex items-center justify-between"
style={{ borderColor: 'var(--border-color-light)' }}
>
<div className="flex items-center gap-1.5">
<StatusIcon
className={`w-3.5 h-3.5 ${pipeline.lastStatus === 'running' ? 'animate-spin' : ''}`}
style={{ color: status.color }}
/>
<span className="text-[11px] font-medium" style={{ color: status.color }}>
{status.label}
</span>
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
{pipeline.lastRunAt}
</span>
</div>
<div className="flex items-center gap-0 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => { e.stopPropagation(); toast.success('开始执行', { description: pipeline.name }); }}
className="p-1 rounded hover:bg-black/5"
title="执行"
>
<Play className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} />
</button>
<button
className="p-1 rounded hover:bg-black/5"
title="配置"
onClick={(e) => e.stopPropagation()}
>
<Settings className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button>
<button
className="p-1 rounded hover:bg-black/5"
title="删除"
onClick={(e) => e.stopPropagation()}
>
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button>
</div>
</div>
</div>
);
})}
</div>
) : (
/* List view */
<div
className="rounded-lg border overflow-hidden"
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
>
<table className="w-full">
<thead>
<tr style={{ background: 'var(--panel-bg-secondary)' }}>
{['流水线', '分支', '部署方式', '最近执行', '耗时', '触发方式', '操作'].map((h) => (
<th key={h} className="text-left px-4 py-2 text-[11px] font-semibold uppercase tracking-wider" style={{ color: 'var(--text-tertiary)' }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{filtered.map((pipeline) => {
const status = statusConfig[pipeline.lastStatus];
const StatusIcon = status.icon;
return (
<tr
key={pipeline.id}
className="border-t cursor-pointer hover:bg-black/[0.02] transition-colors"
style={{ borderColor: 'var(--border-color-light)' }}
onClick={() => onSelectPipeline(pipeline.id)}
>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: status.color }} />
<div>
<div className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>{pipeline.name}</div>
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{pipeline.description}</div>
</div>
</div>
</td>
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>
<div className="flex items-center gap-1">
<GitBranch className="w-3 h-3" />
{pipeline.branch}
</div>
</td>
<td className="px-4 py-3">
<span
className="text-[10px] px-1.5 py-[2px] rounded font-medium uppercase"
style={{
background: pipeline.deployType === 'k8s' ? '#ddf4ff' : '#dafbe1',
color: pipeline.deployType === 'k8s' ? '#0969da' : '#1a7f37',
}}
>
{pipeline.deployType === 'k8s' ? 'K8s' : '主机'}
</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1.5">
<StatusIcon className={`w-3.5 h-3.5 ${pipeline.lastStatus === 'running' ? 'animate-spin' : ''}`} style={{ color: status.color }} />
<span className="text-[12px]" style={{ color: status.color }}>{status.label}</span>
</div>
</td>
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{pipeline.duration}</td>
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{pipeline.triggerType}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-0">
<button onClick={(e) => { e.stopPropagation(); toast.success('开始执行'); }} className="p-1 rounded hover:bg-black/5">
<Play className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} />
</button>
<button className="p-1 rounded hover:bg-black/5">
<MoreHorizontal className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
+273
View File
@@ -0,0 +1,273 @@
import { useState } from 'react';
import {
Puzzle, Search, Download, RefreshCw,
Star, Users, Trash2,
Zap, Palette, Code, Globe, Database, ShieldCheck
} from 'lucide-react';
import { toast } from 'sonner';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
type TabType = 'installed' | 'available' | 'updates' | 'advanced';
const categoryColors: Record<string, string> = {
'部署': '#0969da', '构建': '#1a7f37', '主题': '#8250df', '扫描': '#9a6700',
'安全': '#cf222e', '运维': '#6e7781', '通知': '#bf8700',
};
const iconMap: Record<string, typeof Puzzle> = {
'部署': Globe, '构建': Code, '主题': Palette, '扫描': ShieldCheck,
'安全': ShieldCheck, '运维': Database, '通知': Zap, 'general': Puzzle,
};
export default function PluginManager() {
const [activeTab, setActiveTab] = useState<TabType>('installed');
const [search, setSearch] = useState('');
const queryClient = useQueryClient();
const { data: installed = [] } = useQuery({ queryKey: ['plugins'], queryFn: () => api<any[]>('/api/plugins') });
const { data: available = [] } = useQuery({ queryKey: ['plugins', 'available'], queryFn: () => api<any[]>('/api/plugins/available') });
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['plugins'] });
const installMutation = useMutation({
mutationFn: (body: any) => api('/api/plugins/install', { method: 'POST', body: JSON.stringify(body) }),
onSuccess: () => { toast.success('插件安装成功'); invalidate(); },
onError: (err: Error) => toast.error(err.message),
});
const uninstallMutation = useMutation({
mutationFn: (id: number) => api(`/api/plugins/${id}`, { method: 'DELETE' }),
onSuccess: () => { toast.success('插件已卸载'); invalidate(); },
onError: (err: Error) => toast.error(err.message),
});
const toggleMutation = useMutation({
mutationFn: (input: { id: number; enabled: boolean }) =>
api(`/api/plugins/${input.id}/toggle`, { method: 'POST', body: JSON.stringify({ enabled: input.enabled }) }),
onSuccess: () => invalidate(),
onError: (err: Error) => toast.error(err.message),
});
const installedPlugins = installed.map((p: any) => ({
id: String(p.id),
name: p.name,
description: p.description || '',
version: p.version,
latestVersion: p.latestVersion || p.version,
author: p.author || 'Unknown',
installs: 0,
rating: 4.5,
category: p.category || 'general',
installed: true,
hasUpdate: p.latestVersion && p.latestVersion !== p.version,
enabled: p.enabled,
icon: iconMap[p.category || 'general'] || Puzzle,
}));
const availablePlugins = available.map((p: any) => ({
id: p.pluginId,
name: p.name,
description: p.description || '',
version: p.version,
latestVersion: p.version,
author: p.author || 'Unknown',
installs: p.installs || 0,
rating: 4.5,
category: p.category || 'general',
installed: false,
hasUpdate: false,
enabled: false,
icon: iconMap[p.category || 'general'] || Puzzle,
}));
const updatePlugins = installedPlugins.filter((p: any) => p.hasUpdate);
const allPlugins = [...installedPlugins, ...availablePlugins];
const filteredPlugins = () => {
const list = activeTab === 'installed' ? installedPlugins : activeTab === 'available' ? availablePlugins : activeTab === 'updates' ? updatePlugins : allPlugins;
return list.filter((p: any) => p.name.toLowerCase().includes(search.toLowerCase()) || p.description.toLowerCase().includes(search.toLowerCase()));
};
const handleInstall = (plugin: any) => {
installMutation.mutate({
pluginId: plugin.id,
name: plugin.name,
description: plugin.description,
version: plugin.version,
author: plugin.author,
category: plugin.category,
});
};
const handleUninstall = (id: string) => {
const numId = Number(id);
if (!isNaN(numId)) uninstallMutation.mutate(numId);
};
const handleToggle = (id: string) => {
const numId = Number(id);
const plugin = installedPlugins.find((p: any) => p.id === id);
if (!isNaN(numId) && plugin) {
toggleMutation.mutate({ id: numId, enabled: !plugin.enabled });
}
};
const tabs: { id: TabType; label: string; count: number }[] = [
{ id: 'installed', label: '已安装', count: installedPlugins.length },
{ id: 'available', label: '可安装', count: availablePlugins.length },
{ id: 'updates', label: '更新', count: updatePlugins.length },
{ id: 'advanced', label: '高级设置', count: 0 },
];
return (
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
{/* Header */}
<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: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
{installedPlugins.length} 已安装 / {availablePlugins.length} 可安装
</span>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center border rounded-md overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}>
<Search className="w-3.5 h-3.5 ml-2.5" style={{ color: 'var(--text-tertiary)' }} />
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="搜索插件..."
className="px-2 py-[5px] text-[12px] outline-none w-48" style={{ background: 'transparent', color: 'var(--text-primary)' }} />
</div>
<button onClick={() => toast.success('刷新插件列表')} className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
<RefreshCw className="w-3.5 h-3.5" />刷新
</button>
</div>
</div>
{/* Tabs */}
<div className="px-5 pt-3 border-b flex items-center gap-0 flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
{tabs.map(tab => (
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
className="px-4 py-[8px] text-[13px] font-medium border-b-2 transition-colors"
style={{
borderColor: activeTab === tab.id ? 'var(--accent-blue)' : 'transparent',
color: activeTab === tab.id ? 'var(--accent-blue)' : 'var(--text-secondary)',
}}>
{tab.label}
{tab.count > 0 && <span className="ml-1.5 text-[10px] px-1.5 py-[1px] rounded-full font-medium" style={{ background: activeTab === tab.id ? 'var(--accent-blue-light)' : 'var(--panel-bg-secondary)', color: activeTab === tab.id ? 'var(--accent-blue)' : 'var(--text-tertiary)' }}>{tab.count}</span>}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
{activeTab === 'advanced' ? (
<div className="max-w-2xl space-y-4">
<div className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<h3 className="text-[14px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>插件安装源</h3>
<div className="space-y-2">
{['https://plugins.opsx.dev/stable', 'https://plugins.opsx.dev/beta', 'https://plugins.opsx.dev/community'].map((url, idx) => (
<div key={idx} className="flex items-center justify-between p-2.5 rounded-md border" style={{ borderColor: 'var(--border-color-light)' }}>
<div className="flex items-center gap-2">
<Globe className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} />
<span className="text-[12px] font-mono" style={{ color: 'var(--text-primary)' }}>{url}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: idx === 0 ? '#dafbe1' : '#fff8c5', color: idx === 0 ? '#1a7f37' : '#9a6700' }}>{idx === 0 ? '默认' : '备用'}</span>
<button className="p-1 rounded hover:bg-black/[0.02]" style={{ color: 'var(--text-tertiary)' }}><Trash2 className="w-3.5 h-3.5" /></button>
</div>
</div>
))}
</div>
<button onClick={() => toast.info('添加插件源')} className="mt-2 flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
<Download className="w-3.5 h-3.5" />添加源
</button>
</div>
<div className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<h3 className="text-[14px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>代理设置</h3>
<div className="grid grid-cols-2 gap-3">
<div><label className="block text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>HTTP 代理</label>
<input placeholder="http://proxy:8080" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} /></div>
<div><label className="block text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>HTTPS 代理</label>
<input placeholder="https://proxy:8080" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} /></div>
</div>
</div>
<div className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<h3 className="text-[14px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>其他设置</h3>
<div className="space-y-2">
<label className="flex items-center gap-2 text-[12px] 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-[12px] 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-[12px] cursor-pointer">
<input type="checkbox" className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} />
<span style={{ color: 'var(--text-primary)' }}>允许安装未签名插件</span>
</label>
</div>
</div>
</div>
) : (
<div className="space-y-2">
{filteredPlugins().map(plugin => {
const Icon = plugin.icon;
return (
<div key={plugin.id} className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0" style={{ background: 'var(--panel-bg-secondary)' }}>
<Icon className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>{plugin.name}</span>
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: categoryColors[plugin.category] + '15', color: categoryColors[plugin.category] }}>{plugin.category}</span>
{plugin.installed && (
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: '#dafbe1', color: '#1a7f37' }}>已安装</span>
)}
</div>
<p className="text-[12px] mb-2" style={{ color: 'var(--text-secondary)' }}>{plugin.description}</p>
<div className="flex items-center gap-3 text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
<span className="flex items-center gap-1"><Star className="w-3 h-3" style={{ color: '#9a6700' }} />{plugin.rating}</span>
<span className="flex items-center gap-1"><Users className="w-3 h-3" />{plugin.installs.toLocaleString()} 次安装</span>
<span>作者: {plugin.author}</span>
<span className="font-mono">v{plugin.version}</span>
{plugin.hasUpdate && <span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: '#ddf4ff', color: 'var(--accent-blue)' }}>v{plugin.latestVersion} 可用</span>}
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{plugin.installed ? (
<>
<button onClick={() => handleToggle(plugin.id)}
className="px-3 py-[5px] text-[11px] font-medium rounded-md border transition-colors"
style={{ borderColor: 'var(--border-color-light)', color: plugin.enabled ? 'var(--accent-blue)' : 'var(--text-tertiary)' }}>
{plugin.enabled ? '已启用' : '已禁用'}
</button>
{plugin.hasUpdate && (
<button onClick={() => toast.info('更新插件', { description: `${plugin.name} 将更新到 v${plugin.latestVersion}` })}
className="flex items-center gap-1 px-3 py-[5px] text-[11px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}>
<RefreshCw className="w-3 h-3" />更新
</button>
)}
<button onClick={() => handleUninstall(plugin.id)}
className="p-1.5 rounded-md transition-colors hover:bg-black/[0.02]" style={{ color: 'var(--text-tertiary)' }}>
<Trash2 className="w-3.5 h-3.5" />
</button>
</>
) : (
<button onClick={() => handleInstall(plugin)}
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}>
<Download className="w-3.5 h-3.5" />安装
</button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
}
+335
View File
@@ -0,0 +1,335 @@
import { useState } from 'react';
import {
Filter, Search, GitBranch,
CheckCircle2, XCircle, Clock, ChevronRight, Terminal,
RotateCcw, ExternalLink, Calendar, User, Container, Layers,
Wifi, WifiOff
} from 'lucide-react';
import { toast } from 'sonner';
import { usePipelineLogs } from '@/hooks/usePipelineLogs';
interface PipelineRunStage {
name: string;
status: string;
duration: string;
podName?: string;
logs?: string[];
}
interface ReleaseRecord {
id: string;
pipelineName: string;
branch: string;
env: string;
deployType: 'host' | 'k8s' | 'gitops';
type: 'native' | 'jenkins' | 'tekton';
status: 'success' | 'failed' | 'running' | 'cancelled';
triggerBy: string;
startTime: string;
duration: string;
stages: PipelineRunStage[];
podStatus?: { name: string; status: string; restarts: number; age: string }[];
}
const mockRecords: ReleaseRecord[] = [
{
id: 'pr-202410261430',
pipelineName: 'aiops-backend',
branch: 'master',
env: 'production',
deployType: 'gitops',
type: 'tekton',
status: 'success',
triggerBy: '张三',
startTime: '2024-10-26 14:30:00',
duration: '3m 42s',
stages: [
{ name: 'git-clone', status: 'success', duration: '15s', podName: 'pr-abc-git-clone-xyz', logs: ['Cloning into \'/workspace/source\'...', 'HEAD is now at 7d9f4e2 feat: update api'] },
{ name: 'go-build', status: 'success', duration: '1m 30s', podName: 'pr-abc-go-build-xyz', logs: ['go build -o bin/app ./cmd/app', 'Build successful'] },
{ name: 'kaniko-build', status: 'success', duration: '1m 45s', podName: 'pr-abc-kaniko-xyz', logs: ['INFO[0000] Resolved base name...', 'Pushing image to registry...', 'Image pushed: registry/aiops:v202410261430'] },
{ name: 'update-manifest', status: 'success', duration: '8s', podName: 'pr-abc-update-xyz', logs: ['git clone gitops-repo', 'Updating image tag to v202410261430', 'git push origin main'] },
],
podStatus: [
{ name: 'aiops-backend-7d9f4-abc1', status: 'Running', restarts: 0, age: '5m' },
{ name: 'aiops-backend-7d9f4-abc2', status: 'Running', restarts: 0, age: '5m' },
{ name: 'aiops-backend-7d9f4-abc3', status: 'Running', restarts: 0, age: '5m' },
],
},
{
id: 'pr-202410261415',
pipelineName: 'aiops-web',
branch: 'master',
env: 'production',
deployType: 'host',
type: 'native',
status: 'success',
triggerBy: '李四',
startTime: '2024-10-26 14:15:00',
duration: '2m 18s',
stages: [
{ name: '拉取代码', status: 'success', duration: '12s' },
{ name: 'npm-build', status: 'success', duration: '1m 30s' },
{ name: 'scp-deploy', status: 'success', duration: '30s' },
],
},
{
id: 'pr-202410261400',
pipelineName: 'gateway-api',
branch: 'release/v2.1',
env: 'staging',
deployType: 'k8s',
type: 'jenkins',
status: 'failed',
triggerBy: '王五',
startTime: '2024-10-26 14:00:00',
duration: '5m 10s',
stages: [
{ name: 'git-clone', status: 'success', duration: '18s' },
{ name: 'maven-build', status: 'success', duration: '2m 45s' },
{ name: 'kubectl-deploy', status: 'failed', duration: '2m 7s', logs: ['kubectl apply -f k8s/', 'Error: deployment gateway-api timeout', 'Pod gateway-api-xxx CrashLoopBackOff'] },
],
podStatus: [
{ name: 'gateway-api-xxx', status: 'CrashLoopBackOff', restarts: 5, age: '10m' },
],
},
{
id: 'pr-202410261330',
pipelineName: 'admin-dashboard',
branch: 'master',
env: 'staging',
deployType: 'host',
type: 'native',
status: 'running',
triggerBy: '赵六',
startTime: '2024-10-26 13:30:00',
duration: '进行中',
stages: [
{ name: '拉取代码', status: 'success', duration: '10s' },
{ name: '构建', status: 'success', duration: '1m 20s' },
{ name: '部署到主机', status: 'running', duration: '30s' },
{ name: '后置操作', status: 'pending', duration: '-' },
],
},
];
const statusConfig: Record<string, { icon: typeof CheckCircle2; color: string; bg: string; label: string }> = {
success: { icon: CheckCircle2, color: '#1a7f37', bg: '#dafbe1', label: '成功' },
failed: { icon: XCircle, color: '#cf222e', bg: '#ffebe9', label: '失败' },
running: { icon: Clock, color: '#9a6700', bg: '#fff8c5', label: '执行中' },
cancelled: { icon: XCircle, color: '#6e7781', bg: '#f6f8fa', label: '已取消' },
};
const typeConfig: Record<string, { label: string; color: string; bg: string }> = {
native: { label: '原生', color: '#0969da', bg: '#ddf4ff' },
jenkins: { label: 'Jenkins', color: '#8250df', bg: '#fbefff' },
tekton: { label: 'Tekton', color: '#0e7c7b', bg: '#d5f5f6' },
};
function ExpandedRecord({ record, activeTab, setActiveTab }: {
record: ReleaseRecord;
activeTab: 'stages' | 'logs' | 'pods';
setActiveTab: (t: 'stages' | 'logs' | 'pods') => void;
}) {
const isRunning = record.status === 'running';
const { logs, connected } = usePipelineLogs(record.id, isRunning && activeTab === 'logs');
return (
<div className="border-t" style={{ borderColor: 'var(--border-color-light)' }}>
<div className="flex items-center gap-0 px-4 border-b" style={{ borderColor: 'var(--border-color-light)' }}>
{[
{ id: 'stages' as const, label: '阶段', icon: Layers },
{ id: 'logs' as const, label: '日志', icon: Terminal },
...(record.podStatus ? [{ id: 'pods' as const, label: 'Pod 状态', icon: Container }] : []),
].map(tab => (
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
className="flex items-center gap-1 px-3 py-[8px] text-[12px] font-medium border-b-2 transition-colors"
style={{ borderColor: activeTab === tab.id ? 'var(--accent-blue)' : 'transparent', color: activeTab === tab.id ? 'var(--accent-blue)' : 'var(--text-secondary)' }}>
<tab.icon className="w-3.5 h-3.5" />{tab.label}
{tab.id === 'logs' && isRunning && (
connected
? <Wifi className="w-2.5 h-2.5 ml-0.5" style={{ color: '#1a7f37' }} />
: <WifiOff className="w-2.5 h-2.5 ml-0.5" style={{ color: '#cf222e' }} />
)}
</button>
))}
</div>
<div className="p-4">
{activeTab === 'stages' && (
<div className="flex items-center gap-0">
{record.stages.map((stage, idx) => {
const st = statusConfig[stage.status] || statusConfig.success;
const isLast = idx === record.stages.length - 1;
return (
<div key={idx} className="flex items-center flex-1">
<div className="flex flex-col items-center flex-1">
<div className="w-6 h-6 rounded-full flex items-center justify-center mb-1" style={{ background: st.bg }}>
{stage.status === 'success' ? <CheckCircle2 className="w-3.5 h-3.5" style={{ color: st.color }} /> :
stage.status === 'failed' ? <XCircle className="w-3.5 h-3.5" style={{ color: st.color }} /> :
stage.status === 'running' ? <Clock className="w-3.5 h-3.5 animate-pulse" style={{ color: st.color }} /> :
<div className="w-2 h-2 rounded-full" style={{ background: 'var(--border-color)' }} />}
</div>
<div className="text-[10px] font-medium text-center" style={{ color: 'var(--text-secondary)' }}>{stage.name}</div>
<div className="text-[10px]" style={{ color: 'var(--text-tertiary)' }}>{stage.duration}</div>
{stage.podName && <div className="text-[9px] font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{stage.podName}</div>}
</div>
{!isLast && <div className="flex-1 h-[2px] mx-1" style={{ background: idx < record.stages.findIndex(s => s.status === 'failed' || s.status === 'running') ? '#dafbe1' : 'var(--border-color-light)' }} />}
</div>
);
})}
</div>
)}
{activeTab === 'logs' && (
<div className="rounded-md overflow-hidden" style={{ background: '#0d1117' }}>
<div className="px-3 py-1.5 border-b flex items-center gap-2" style={{ borderColor: '#21262d' }}>
<Terminal className="w-3.5 h-3.5" style={{ color: '#7d8590' }} />
<span className="text-[11px] font-medium" style={{ color: '#7d8590' }}>PipelineRun 日志</span>
{isRunning && (
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium ml-auto" style={{ background: connected ? '#1a7f3720' : '#cf222e20', color: connected ? '#1a7f37' : '#cf222e' }}>
{connected ? '实时推送中' : '连接中...'}
</span>
)}
</div>
<div className="p-3 font-mono text-[11px] leading-relaxed max-h-[300px] overflow-y-auto" style={{ color: '#e6edf3' }}>
{record.stages.map((stage, idx) => (
<div key={idx}>
<div className="py-1" style={{ color: '#58a6ff' }}>=== {stage.name} ===</div>
{stage.logs ? stage.logs.map((log, li) => (
<div key={li} className="py-[1px]" style={{ color: log.includes('Error') ? '#f85149' : '#e6edf3' }}>{log}</div>
)) : <div className="py-[1px]" style={{ color: '#7d8590' }}>No logs available</div>}
</div>
))}
{logs.length > 0 && (
<div className="mt-2 pt-2 border-t" style={{ borderColor: '#21262d' }}>
<div className="py-1" style={{ color: '#58a6ff' }}>=== 实时日志 ===</div>
{logs.map((log, idx) => (
<div key={idx} className="py-[1px]" style={{ color: log.level === 'error' ? '#f85149' : log.level === 'warn' ? '#d29922' : '#e6edf3' }}>
<span style={{ color: '#7d8590' }}>[{new Date(log.timestamp || '').toLocaleTimeString()}]</span> {log.message}
</div>
))}
</div>
)}
</div>
</div>
)}
{activeTab === 'pods' && record.podStatus && (
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-color)' }}>
<div className="grid grid-cols-[1fr_100px_80px_60px] gap-2 px-4 py-2 text-[11px] font-semibold" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
<div>Pod 名称</div><div>状态</div><div>重启次数</div><div>运行时间</div>
</div>
{record.podStatus.map((pod, idx) => (
<div key={idx} className="grid grid-cols-[1fr_100px_80px_60px] gap-2 px-4 py-2 border-t items-center text-[12px]" style={{ borderColor: 'var(--border-color-light)' }}>
<div className="font-mono" style={{ color: 'var(--text-primary)' }}>{pod.name}</div>
<div className="flex items-center gap-1">
<Container className="w-3 h-3" style={{ color: pod.status === 'Running' ? '#1a7f37' : '#cf222e' }} />
<span style={{ color: pod.status === 'Running' ? '#1a7f37' : '#cf222e' }}>{pod.status}</span>
</div>
<div style={{ color: pod.restarts > 0 ? '#cf222e' : 'var(--text-secondary)' }}>{pod.restarts}</div>
<div style={{ color: 'var(--text-tertiary)' }}>{pod.age}</div>
</div>
))}
</div>
)}
<div className="flex items-center gap-2 pt-3 mt-3 border-t" style={{ borderColor: 'var(--border-color-light)' }}>
<button onClick={() => toast.info('查看日志', { description: `${record.pipelineName} 完整日志` })}
className="flex items-center gap-1 px-3 py-[5px] text-[11px] font-medium rounded-md border transition-colors hover:bg-black/[0.02]" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
<Terminal className="w-3 h-3" />查看日志
</button>
{record.status === 'failed' && (
<button onClick={() => toast.success('重试发布', { description: `${record.pipelineName} 重新开始发布` })}
className="flex items-center gap-1 px-3 py-[5px] text-[11px] font-medium rounded-md border transition-colors hover:bg-black/[0.02]" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
<RotateCcw className="w-3 h-3" />重试
</button>
)}
<button onClick={() => toast.info('查看流水线', { description: `跳转至 ${record.pipelineName}` })}
className="flex items-center gap-1 px-3 py-[5px] text-[11px] font-medium rounded-md border transition-colors hover:bg-black/[0.02]" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
<ExternalLink className="w-3 h-3" />查看流水线
</button>
</div>
</div>
</div>
);
}
export default function ReleaseRecords() {
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [expandedId, setExpandedId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'stages' | 'logs' | 'pods'>('stages');
const filtered = mockRecords.filter(r => {
const matchSearch = r.pipelineName.includes(search) || r.id.includes(search);
const matchStatus = statusFilter === 'all' || r.status === statusFilter;
return matchSearch && matchStatus;
});
return (
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
{/* Header */}
<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: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>PipelineRun</span>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center border rounded-md overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}>
<Search className="w-3.5 h-3.5 ml-2.5" style={{ color: 'var(--text-tertiary)' }} />
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="搜索流水线或记录ID"
className="px-2 py-[5px] text-[12px] outline-none w-48" style={{ background: 'transparent', color: 'var(--text-primary)' }} />
</div>
<div className="relative">
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)}
className="px-3 py-[5px] text-[12px] border rounded-md outline-none appearance-none pr-7"
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
<option value="all">全部状态</option><option value="success">成功</option><option value="failed">失败</option><option value="running">执行中</option><option value="cancelled">已取消</option>
</select>
<Filter className="w-3 h-3 absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none" style={{ color: 'var(--text-tertiary)' }} />
</div>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
<div className="space-y-2">
{filtered.map((record) => {
const status = statusConfig[record.status];
const StatusIcon = status.icon;
const tp = typeConfig[record.type];
const isExpanded = expandedId === record.id;
return (
<div key={record.id} className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
{/* Summary row */}
<div onClick={() => setExpandedId(isExpanded ? null : record.id)}
className="px-4 py-3 flex items-center gap-4 cursor-pointer hover:bg-black/[0.01] transition-colors">
<StatusIcon className={`w-4 h-4 flex-shrink-0 ${record.status === 'running' ? 'animate-pulse' : ''}`} style={{ color: status.color }} />
<div className="w-[130px]">
<div className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>{record.pipelineName}</div>
<div className="text-[11px] font-mono" style={{ color: 'var(--text-tertiary)' }}>{record.id}</div>
</div>
<div className="flex items-center gap-1 text-[12px] w-[100px]" style={{ color: 'var(--text-secondary)' }}>
<GitBranch className="w-3 h-3" /> {record.branch}
</div>
<div className="w-[80px]"><span className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: tp.bg, color: tp.color }}>{tp.label}</span></div>
<div className="text-[12px] w-[70px]" style={{ color: 'var(--text-tertiary)' }}>{record.env}</div>
<div className="flex items-center gap-1 text-[11px] w-[80px]" style={{ color: 'var(--text-tertiary)' }}><User className="w-3 h-3" />{record.triggerBy}</div>
<div className="flex items-center gap-1 text-[11px] w-[130px]" style={{ color: 'var(--text-tertiary)' }}><Calendar className="w-3 h-3" />{record.startTime}</div>
<div className="text-[12px] font-medium w-[70px] text-right" style={{ color: record.status === 'running' ? 'var(--accent-blue)' : 'var(--text-secondary)' }}>{record.duration}</div>
<ChevronRight className={`w-4 h-4 flex-shrink-0 transition-transform ${isExpanded ? 'rotate-90' : ''}`} style={{ color: 'var(--text-tertiary)' }} />
</div>
{/* Expanded detail */}
{isExpanded && (
<ExpandedRecord
record={record}
activeTab={activeTab}
setActiveTab={setActiveTab}
/>
)}
</div>
);
})}
</div>
</div>
</div>
);
}
+241
View File
@@ -0,0 +1,241 @@
import { useState } from 'react';
import {
Palette, Monitor, Moon, Sun,
Save, RotateCcw, CheckCircle2, Globe, Bell
} from 'lucide-react';
import { toast } from 'sonner';
interface ThemeConfig {
mode: 'light' | 'dark' | 'system';
primaryColor: string;
fontSize: 'small' | 'medium' | 'large';
density: 'compact' | 'comfortable' | 'spacious';
sidebarStyle: 'collapsed' | 'icons' | 'full';
borderRadius: 'sharp' | 'soft' | 'round';
}
const colorOptions = [
{ name: '蓝色', value: '#0969da', class: 'bg-[#0969da]' },
{ name: '绿色', value: '#1a7f37', class: 'bg-[#1a7f37]' },
{ name: '紫色', value: '#8250df', class: 'bg-[#8250df]' },
{ name: '橙色', value: '#bc4c00', class: 'bg-[#bc4c00]' },
{ name: '红色', value: '#cf222e', class: 'bg-[#cf222e]' },
{ name: '青色', value: '#0e7c7b', class: 'bg-[#0e7c7b]' },
];
export default function SystemSettings() {
const [theme, setTheme] = useState<ThemeConfig>({
mode: 'light',
primaryColor: '#0969da',
fontSize: 'medium',
density: 'comfortable',
sidebarStyle: 'full',
borderRadius: 'soft',
});
const [notifications, setNotifications] = useState({
email: true,
browser: true,
pipelineSuccess: false,
pipelineFailed: true,
securityAlert: true,
});
const handleSave = () => {
toast.success('设置已保存', { description: '主题和偏好设置已更新' });
};
const handleReset = () => {
setTheme({
mode: 'light',
primaryColor: '#0969da',
fontSize: 'medium',
density: 'comfortable',
sidebarStyle: 'full',
borderRadius: 'soft',
});
toast.info('已恢复默认设置');
};
return (
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
{/* Header */}
<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)' }}>
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>系统设置</h1>
<div className="flex items-center gap-2">
<button onClick={handleReset} className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
<RotateCcw className="w-3.5 h-3.5" />恢复默认
</button>
<button onClick={handleSave} className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}>
<Save className="w-3.5 h-3.5" />保存设置
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
<div className="max-w-3xl mx-auto space-y-4">
{/* Appearance */}
<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 gap-2" style={{ borderColor: 'var(--border-color)', background: 'var(--panel-bg-secondary)' }}>
<Palette className="w-4 h-4" style={{ color: 'var(--accent-blue)' }} />
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>外观主题</span>
</div>
<div className="p-4 space-y-4">
{/* Mode */}
<div>
<label className="block text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>主题模式</label>
<div className="flex items-center gap-2">
{[
{ id: 'light', label: '浅色', icon: Sun },
{ id: 'dark', label: '深色', icon: Moon },
{ id: 'system', label: '跟随系统', icon: Monitor },
].map(m => (
<button key={m.id} onClick={() => setTheme(p => ({ ...p, mode: m.id as any }))}
className="flex items-center gap-2 px-4 py-[7px] text-[12px] font-medium rounded-md border transition-colors"
style={{
borderColor: theme.mode === m.id ? 'var(--accent-blue)' : 'var(--border-color-light)',
background: theme.mode === m.id ? 'var(--accent-blue-light)' : 'transparent',
color: theme.mode === m.id ? 'var(--accent-blue)' : 'var(--text-secondary)',
}}>
<m.icon className="w-3.5 h-3.5" />{m.label}
</button>
))}
</div>
</div>
{/* Primary color */}
<div>
<label className="block text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>主题色</label>
<div className="flex items-center gap-2">
{colorOptions.map(c => (
<button key={c.value} onClick={() => setTheme(p => ({ ...p, primaryColor: c.value }))}
className={`w-8 h-8 rounded-full ${c.class} flex items-center justify-center transition-transform hover:scale-110`}
title={c.name}>
{theme.primaryColor === c.value && <CheckCircle2 className="w-4 h-4 text-white" />}
</button>
))}
</div>
</div>
{/* Font size */}
<div>
<label className="block text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>字体大小</label>
<div className="flex items-center gap-2">
{[
{ id: 'small', label: '小', size: '12px' },
{ id: 'medium', label: '中', size: '14px' },
{ id: 'large', label: '大', size: '16px' },
].map(f => (
<button key={f.id} onClick={() => setTheme(p => ({ ...p, fontSize: f.id as any }))}
className="flex-1 py-[7px] text-[12px] font-medium rounded-md border transition-colors text-center"
style={{
borderColor: theme.fontSize === f.id ? 'var(--accent-blue)' : 'var(--border-color-light)',
background: theme.fontSize === f.id ? 'var(--accent-blue-light)' : 'transparent',
color: theme.fontSize === f.id ? 'var(--accent-blue)' : 'var(--text-secondary)',
fontSize: f.size,
}}>
{f.label}
</button>
))}
</div>
</div>
{/* Density */}
<div>
<label className="block text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>界面密度</label>
<div className="flex items-center gap-2">
{[
{ id: 'compact', label: '紧凑' },
{ id: 'comfortable', label: '舒适' },
{ id: 'spacious', label: '宽松' },
].map(d => (
<button key={d.id} onClick={() => setTheme(p => ({ ...p, density: d.id as any }))}
className="flex-1 py-[7px] text-[12px] font-medium rounded-md border transition-colors"
style={{
borderColor: theme.density === d.id ? 'var(--accent-blue)' : 'var(--border-color-light)',
background: theme.density === d.id ? 'var(--accent-blue-light)' : 'transparent',
color: theme.density === d.id ? 'var(--accent-blue)' : 'var(--text-secondary)',
}}>
{d.label}
</button>
))}
</div>
</div>
{/* Border radius */}
<div>
<label className="block text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>圆角风格</label>
<div className="flex items-center gap-2">
{[
{ id: 'sharp', label: '直角', radius: '0px' },
{ id: 'soft', label: '圆角', radius: '6px' },
{ id: 'round', label: '大圆角', radius: '12px' },
].map(r => (
<button key={r.id} onClick={() => setTheme(p => ({ ...p, borderRadius: r.id as any }))}
className="flex-1 py-[7px] text-[12px] font-medium border transition-colors"
style={{
borderColor: theme.borderRadius === r.id ? 'var(--accent-blue)' : 'var(--border-color-light)',
background: theme.borderRadius === r.id ? 'var(--accent-blue-light)' : 'transparent',
color: theme.borderRadius === r.id ? 'var(--accent-blue)' : 'var(--text-secondary)',
borderRadius: r.radius,
}}>
{r.label}
</button>
))}
</div>
</div>
</div>
</div>
{/* Notifications */}
<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 gap-2" style={{ borderColor: 'var(--border-color)', background: 'var(--panel-bg-secondary)' }}>
<Bell className="w-4 h-4" style={{ color: 'var(--accent-blue)' }} />
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>通知设置</span>
</div>
<div className="p-4 space-y-3">
{[
{ key: 'email', label: '邮件通知', desc: '通过邮件接收重要通知' },
{ key: 'browser', label: '浏览器通知', desc: '在浏览器中显示推送通知' },
{ key: 'pipelineSuccess', label: '流水线成功通知', desc: '流水线执行成功时发送通知' },
{ key: 'pipelineFailed', label: '流水线失败通知', desc: '流水线执行失败时发送通知(推荐开启)' },
{ key: 'securityAlert', label: '安全警报', desc: '凭证泄露、异常登录等安全事件' },
].map(item => (
<label key={item.key} className="flex items-start gap-3 cursor-pointer">
<input type="checkbox" checked={(notifications as any)[item.key]}
onChange={e => setNotifications(p => ({ ...p, [item.key]: e.target.checked }))}
className="mt-0.5 w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} />
<div>
<div className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>{item.label}</div>
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{item.desc}</div>
</div>
</label>
))}
</div>
</div>
{/* Language & Region */}
<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 gap-2" style={{ borderColor: 'var(--border-color)', background: 'var(--panel-bg-secondary)' }}>
<Globe className="w-4 h-4" style={{ color: 'var(--accent-blue)' }} />
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>语言与区域</span>
</div>
<div className="p-4 space-y-3">
<div className="grid grid-cols-2 gap-3">
<div><label className="block text-[12px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>界面语言</label>
<select className="w-full px-3 py-[6px] text-[13px] rounded-md border outline-none" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}>
<option>简体中文</option><option>English</option><option>日本語</option>
</select></div>
<div><label className="block text-[12px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>时区</label>
<select className="w-full px-3 py-[6px] text-[13px] rounded-md border outline-none" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}>
<option>Asia/Shanghai (UTC+8)</option><option>UTC</option><option>America/New_York</option>
</select></div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
+427
View File
@@ -0,0 +1,427 @@
import { useState } from 'react';
import {
Save,
Download,
CheckCircle2,
XCircle,
Loader2,
Settings,
ChevronDown,
Monitor,
Baby,
RefreshCw,
Terminal
} from 'lucide-react';
import { toast } from 'sonner';
interface DevOpsTool {
id: string;
name: string;
key: string;
description: string;
category: 'build' | 'deploy';
status: 'not-installed' | 'installed' | 'installing';
version: string;
installMode: 'preinstalled' | 'lazy' | 'disabled';
path: string;
}
const initialTools: DevOpsTool[] = [
{
id: 'jdk',
name: 'JDK (Java)',
key: 'jdk',
description: 'Java 开发工具包,流水线 Java/Maven 构建阶段使用',
category: 'build',
status: 'not-installed',
version: '',
installMode: 'lazy',
path: '/opt/tools/jdk',
},
{
id: 'maven',
name: 'Maven',
key: 'maven',
description: 'Java 项目构建工具,流水线 Java 构建阶段使用(依赖 JDK)',
category: 'build',
status: 'not-installed',
version: '',
installMode: 'lazy',
path: '/opt/tools/maven',
},
{
id: 'golang',
name: 'Go (Golang)',
key: 'golang',
description: 'Go 语言工具链,流水线 go build/test 阶段使用',
category: 'build',
status: 'not-installed',
version: '',
installMode: 'lazy',
path: '/opt/tools/go',
},
{
id: 'nvm',
name: 'NVM (Node 版本管理)',
key: 'nvm',
description: 'Node 版本管理器,流水线 Node.js 构建阶段使用(node 依赖此工具)',
category: 'build',
status: 'not-installed',
version: '',
installMode: 'lazy',
path: '/opt/tools/nvm',
},
{
id: 'nodejs',
name: 'Node.js + npm',
key: 'nodejs',
description: '前端构建工具,流水线 npm install/build 阶段使用(通过 NVM 安装)',
category: 'build',
status: 'not-installed',
version: '',
installMode: 'lazy',
path: '/opt/tools/node',
},
{
id: 'git',
name: 'Git',
key: 'git',
description: '版本控制工具,流水线代码拉取使用(首次使用自动安装)',
category: 'build',
status: 'installed',
version: '2.43.0',
installMode: 'preinstalled',
path: '/usr/bin/git',
},
{
id: 'docker',
name: 'Docker CLI',
key: 'docker-cli',
description: 'Docker 命令行工具,流水线镜像构建/推送使用(首次使用自动安装)',
category: 'deploy',
status: 'installed',
version: '24.0.7',
installMode: 'preinstalled',
path: '/usr/bin/docker',
},
{
id: 'kubectl',
name: 'kubectl',
key: 'kubectl',
description: 'Kubernetes 命令行工具,流水线 K8s 部署阶段使用',
category: 'deploy',
status: 'not-installed',
version: '',
installMode: 'lazy',
path: '/opt/tools/kubectl',
},
{
id: 'helm',
name: 'Helm',
key: 'helm',
description: 'Kubernetes 包管理器,流水线 Helm 部署阶段使用',
category: 'deploy',
status: 'not-installed',
version: '',
installMode: 'lazy',
path: '/opt/tools/helm',
},
];
const categoryConfig = {
build: { label: '构建', color: '#9a6700', bg: '#fff8c5' },
deploy: { label: '部署', color: '#0969da', bg: '#ddf4ff' },
};
const statusConfig = {
'not-installed': { icon: XCircle, label: '未安装', color: '#6e7781', bg: '#f6f8fa' },
'installed': { icon: CheckCircle2, label: '已安装', color: '#1a7f37', bg: '#dafbe1' },
'installing': { icon: Loader2, label: '安装中', color: '#9a6700', bg: '#fff8c5' },
};
const installModeConfig = {
preinstalled: '预装在镜像中',
lazy: '首次使用自动下载',
disabled: '禁用不安装',
};
export default function ToolConfig() {
const [tools, setTools] = useState<DevOpsTool[]>(initialTools);
const [mode, setMode] = useState<'local' | 'jenkins'>('local');
const [installingId, setInstallingId] = useState<string | null>(null);
const [dropdownOpen, setDropdownOpen] = useState<string | null>(null);
const handleInstall = (toolId: string) => {
setInstallingId(toolId);
setTools(prev => prev.map(t => t.id === toolId ? { ...t, status: 'installing' } : t));
setTimeout(() => {
setTools(prev => prev.map(t =>
t.id === toolId
? { ...t, status: 'installed', version: getDefaultVersion(toolId) }
: t
));
setInstallingId(null);
toast.success('安装完成', { description: `${getToolName(toolId)} 安装成功` });
}, 2000);
};
const handleTest = (toolId: string) => {
const tool = tools.find(t => t.id === toolId);
if (tool?.status === 'installed') {
toast.success('测试通过', { description: `${tool.name} v${tool.version} 运行正常` });
} else {
toast.error('测试失败', { description: `${tool?.name || toolId} 未安装` });
}
};
const handleModeChange = (toolId: string, newMode: 'preinstalled' | 'lazy' | 'disabled') => {
setTools(prev => prev.map(t => t.id === toolId ? { ...t, installMode: newMode } : t));
setDropdownOpen(null);
toast.info('策略已更新', { description: `${getToolName(toolId)} 安装策略改为: ${installModeConfig[newMode]}` });
};
const getDefaultVersion = (id: string) => {
const versions: Record<string, string> = {
jdk: '17.0.9', maven: '3.9.6', golang: '1.22.0', nvm: '0.39.7',
nodejs: '20.11.0', git: '2.43.0', docker: '24.0.7', kubectl: '1.28.3', helm: '3.14.0',
};
return versions[id] || '1.0.0';
};
const getToolName = (id: string) => tools.find(t => t.id === id)?.name || id;
const installedCount = tools.filter(t => t.status === 'installed').length;
const notInstalledCount = tools.filter(t => t.status === 'not-installed').length;
return (
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
{/* Header */}
<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: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
{installedCount}/{tools.length}
</span>
</div>
<div className="flex items-center gap-2">
{/* Mode toggle */}
<div className="flex items-center border rounded-md overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}>
<button
onClick={() => setMode('local')}
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium transition-colors"
style={{
background: mode === 'local' ? 'var(--panel-bg-secondary)' : 'transparent',
color: mode === 'local' ? 'var(--accent-blue)' : 'var(--text-secondary)',
}}
>
<Monitor className="w-3.5 h-3.5" />
本地模式
</button>
<button
onClick={() => setMode('jenkins')}
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium transition-colors"
style={{
background: mode === 'jenkins' ? 'var(--panel-bg-secondary)' : 'transparent',
color: mode === 'jenkins' ? 'var(--accent-blue)' : 'var(--text-secondary)',
}}
>
<Baby className="w-3.5 h-3.5" />
Jenkins 模式
</button>
</div>
<button
onClick={() => toast.success('刷新完成', { description: '工具状态已更新' })}
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border"
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
>
<RefreshCw className="w-3.5 h-3.5" />
刷新
</button>
<button
onClick={() => toast.success('配置已保存')}
className="flex items-center gap-1.5 px-3 py-[5px] text-[12px] font-medium rounded-md text-white"
style={{ background: 'var(--accent-blue)' }}
>
<Save className="w-3.5 h-3.5" />
保存
</button>
</div>
</div>
{/* Description bar */}
<div className="px-5 py-2 border-b text-[12px] flex items-center gap-2" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>
<Terminal className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
管理 DevOps 工具的安装策略:预装在镜像中、首次使用自动下载(懒加载)、或禁用不安装
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
{/* Stats */}
<div className="grid grid-cols-4 gap-3 mb-4">
{[
{ label: '已安装', count: installedCount, color: '#1a7f37' },
{ label: '未安装', count: notInstalledCount, color: '#6e7781' },
{ label: '构建工具', count: tools.filter(t => t.category === 'build').length, color: '#9a6700' },
{ label: '部署工具', count: tools.filter(t => t.category === 'deploy').length, color: '#0969da' },
].map((stat) => (
<div key={stat.label} className="rounded-lg border p-3" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>{stat.label}</div>
<div className="text-[20px] font-bold" style={{ color: stat.color }}>{stat.count}</div>
</div>
))}
</div>
{/* Tool list - screenshot style */}
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
{/* Table header */}
<div
className="grid grid-cols-[1fr_100px_120px_280px] gap-4 px-4 py-2 text-[11px] font-semibold uppercase tracking-wider"
style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}
>
<div>工具配置</div>
<div>分类</div>
<div>状态</div>
<div className="text-right">操作</div>
</div>
{tools.map((tool) => {
const cat = categoryConfig[tool.category];
const status = statusConfig[tool.status];
const StatusIcon = status.icon;
const isInstalling = installingId === tool.id;
return (
<div
key={tool.id}
className="grid grid-cols-[1fr_100px_120px_280px] gap-4 px-4 py-3 border-t items-center"
style={{ borderColor: 'var(--border-color-light)' }}
>
{/* Tool info */}
<div>
<div className="text-[14px] font-semibold mb-0.5" style={{ color: 'var(--text-primary)' }}>{tool.name}</div>
<div className="text-[12px] font-mono mb-1" style={{ color: 'var(--text-tertiary)' }}>{tool.key}</div>
<div className="text-[11px] leading-relaxed" style={{ color: 'var(--text-secondary)' }}>{tool.description}</div>
{tool.status === 'installed' && (
<div className="text-[10px] mt-1 font-mono" style={{ color: 'var(--text-tertiary)' }}>路径: {tool.path}</div>
)}
</div>
{/* Category */}
<div>
<span className="text-[10px] px-2 py-[3px] rounded-full font-medium" style={{ background: cat.bg, color: cat.color }}>
{cat.label}
</span>
</div>
{/* Status */}
<div>
<div className="flex items-center gap-1.5">
<StatusIcon className={`w-3.5 h-3.5 ${isInstalling ? 'animate-spin' : ''}`} style={{ color: status.color }} />
<span className="text-[12px] font-medium" style={{ color: status.color }}>{status.label}</span>
</div>
{tool.status === 'installed' && (
<div className="text-[11px] mt-0.5" style={{ color: 'var(--text-tertiary)' }}>v{tool.version}</div>
)}
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-2">
{/* Install / Installing button */}
{tool.status === 'not-installed' ? (
<button
onClick={() => handleInstall(tool.id)}
disabled={isInstalling}
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border transition-colors hover:bg-black/[0.02] disabled:opacity-50"
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
>
<Download className={`w-3.5 h-3.5 ${isInstalling ? 'animate-bounce' : ''}`} />
{isInstalling ? '安装中...' : '安装'}
</button>
) : tool.status === 'installing' ? (
<button
disabled
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border opacity-50"
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
安装中...
</button>
) : (
<button
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border"
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)', background: 'var(--panel-bg-secondary)' }}
>
<CheckCircle2 className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} />
已安装
</button>
)}
{/* Version / Install strategy dropdown */}
<div className="relative">
<button
onClick={() => setDropdownOpen(dropdownOpen === tool.id ? null : tool.id)}
className="flex items-center gap-1 px-2 py-[5px] text-[11px] rounded-md border"
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
>
{tool.status === 'installed' ? tool.version : installModeConfig[tool.installMode]}
<ChevronDown className="w-3 h-3" />
</button>
{dropdownOpen === tool.id && (
<>
<div className="fixed inset-0 z-40" onClick={() => setDropdownOpen(null)} />
<div
className="absolute right-0 top-full mt-1 py-1 rounded-md border shadow-lg z-50 min-w-[160px]"
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
>
{tool.status !== 'installed' && (
<>
<div className="px-3 py-1 text-[10px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>安装策略</div>
{(Object.keys(installModeConfig) as Array<keyof typeof installModeConfig>).map((modeKey) => (
<button
key={modeKey}
onClick={() => handleModeChange(tool.id, modeKey)}
className="w-full text-left px-3 py-[6px] text-[12px] hover:bg-black/5 transition-colors"
style={{ color: tool.installMode === modeKey ? 'var(--accent-blue)' : 'var(--text-secondary)' }}
>
{installModeConfig[modeKey]}
</button>
))}
<div className="border-t my-1" style={{ borderColor: 'var(--border-color-light)' }} />
</>
)}
<div className="px-3 py-1 text-[10px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>版本</div>
<button className="w-full text-left px-3 py-[6px] text-[12px] hover:bg-black/5 transition-colors" style={{ color: 'var(--text-secondary)' }}>
{getDefaultVersion(tool.id)} (默认)
</button>
</div>
</>
)}
</div>
{/* Test button */}
<button
onClick={() => handleTest(tool.id)}
className="px-3 py-[5px] text-[12px] font-medium rounded-md border transition-colors hover:bg-black/[0.02]"
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
>
测试
</button>
{/* Settings button */}
<button
onClick={() => toast.info('工具配置', { description: `${tool.name} 高级配置功能开发中...` })}
className="p-[5px] rounded-md border transition-colors hover:bg-black/[0.02]"
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-tertiary)' }}
>
<Settings className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
+225
View File
@@ -0,0 +1,225 @@
import { useState } from 'react';
import {
Webhook, Plus, Trash2, Edit2, ToggleLeft, ToggleRight,
CheckCircle2, AlertTriangle,
Send
} from 'lucide-react';
import { toast } from 'sonner';
interface WebhookItem {
id: string;
name: string;
url: string;
events: string[];
secret: string;
active: boolean;
lastTriggered: string;
lastStatus: 'success' | 'failed' | 'none';
}
const initialWebhooks: WebhookItem[] = [
{
id: 'wh-1',
name: '飞书群通知',
url: 'https://open.feishu.cn/open-apis/bot/v2/hook/xxxxx',
events: ['pipeline.success', 'pipeline.failed'],
secret: 'whsec_xxxxxxxxxxxx',
active: true,
lastTriggered: '2024-10-26 14:30:15',
lastStatus: 'success',
},
{
id: 'wh-2',
name: '钉钉群通知',
url: 'https://oapi.dingtalk.com/robot/send?access_token=xxxxx',
events: ['pipeline.success', 'pipeline.failed', 'pipeline.started'],
secret: 'SEC_xxxxxxxxxxxx',
active: true,
lastTriggered: '2024-10-26 14:15:10',
lastStatus: 'success',
},
{
id: 'wh-3',
name: '自定义回调',
url: 'https://api.example.com/webhook/cicd',
events: ['pipeline.failed'],
secret: '',
active: false,
lastTriggered: '从未触发',
lastStatus: 'none',
},
];
const eventOptions = [
{ value: 'pipeline.started', label: '流水线开始', desc: '当流水线开始执行时触发' },
{ value: 'pipeline.success', label: '流水线成功', desc: '当流水线执行成功时触发' },
{ value: 'pipeline.failed', label: '流水线失败', desc: '当流水线执行失败时触发' },
{ value: 'pipeline.cancelled', label: '流水线取消', desc: '当流水线被取消时触发' },
{ value: 'stage.started', label: '阶段开始', desc: '当某个阶段开始执行时触发' },
{ value: 'stage.completed', label: '阶段完成', desc: '当某个阶段执行完成时触发' },
{ value: 'deploy.success', label: '部署成功', desc: '当部署阶段成功时触发' },
{ value: 'deploy.failed', label: '部署失败', desc: '当部署阶段失败时触发' },
];
export default function WebhookConfig() {
const [webhooks, setWebhooks] = useState<WebhookItem[]>(initialWebhooks);
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState({ name: '', url: '', secret: '', events: [] as string[] });
const handleToggle = (id: string) => {
setWebhooks(prev => prev.map(w => w.id === id ? { ...w, active: !w.active } : w));
};
const handleDelete = (id: string) => {
setWebhooks(prev => prev.filter(w => w.id !== id));
toast.success('Webhook 已删除');
};
const handleSave = () => {
if (!form.name || !form.url) {
toast.error('请填写名称和 URL');
return;
}
if (editingId) {
setWebhooks(prev => prev.map(w => w.id === editingId ? { ...w, ...form } : w));
toast.success('Webhook 已更新');
} else {
setWebhooks(prev => [...prev, { ...form, id: `wh-${Date.now()}`, active: true, lastTriggered: '从未触发', lastStatus: 'none' }]);
toast.success('Webhook 已创建');
}
setShowForm(false);
setEditingId(null);
setForm({ name: '', url: '', secret: '', events: [] });
};
const startEdit = (w: WebhookItem) => {
setForm({ name: w.name, url: w.url, secret: w.secret, events: [...w.events] });
setEditingId(w.id);
setShowForm(true);
};
const toggleEvent = (event: string) => {
setForm(prev => ({
...prev,
events: prev.events.includes(event) ? prev.events.filter(e => e !== event) : [...prev.events, event],
}));
};
return (
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
{/* Header */}
<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)' }}>Webhook 配置</h1>
<span className="text-[11px] px-2 py-[2px] rounded-full font-medium" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
{webhooks.length}
</span>
</div>
<button
onClick={() => { setShowForm(true); setEditingId(null); setForm({ name: '', url: '', secret: '', events: [] }); }}
className="flex items-center gap-1.5 px-3 py-[5px] text-[12px] font-medium rounded-md text-white"
style={{ background: 'var(--accent-blue)' }}
>
<Plus className="w-3.5 h-3.5" />新建 Webhook
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
{showForm && (
<div className="rounded-lg border p-4 mb-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<h3 className="text-[14px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>{editingId ? '编辑 Webhook' : '新建 Webhook'}</h3>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div><label className="block text-[12px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>名称</label>
<input value={form.name} onChange={e => setForm(p => ({ ...p, name: e.target.value }))}
placeholder="如:飞书群通知" className="w-full px-3 py-[6px] rounded-md border text-[13px] outline-none"
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} /></div>
<div><label className="block text-[12px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>Webhook URL</label>
<input value={form.url} onChange={e => setForm(p => ({ ...p, url: e.target.value }))}
placeholder="https://..." className="w-full px-3 py-[6px] rounded-md border text-[13px] outline-none"
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} /></div>
</div>
<div><label className="block text-[12px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>签名密钥(可选)</label>
<input value={form.secret} onChange={e => setForm(p => ({ ...p, secret: e.target.value }))}
placeholder="用于验证请求来源" className="w-full px-3 py-[6px] rounded-md border text-[13px] outline-none"
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} /></div>
<div><label className="block text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>订阅事件</label>
<div className="grid grid-cols-2 gap-2">
{eventOptions.map(evt => (
<label key={evt.value} className="flex items-start gap-2 p-2 rounded-md border cursor-pointer hover:bg-black/[0.02]"
style={{ borderColor: 'var(--border-color-light)', background: form.events.includes(evt.value) ? 'var(--accent-blue-light)' : 'transparent' }}>
<input type="checkbox" checked={form.events.includes(evt.value)} onChange={() => toggleEvent(evt.value)} className="mt-0.5 w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} />
<div><div className="text-[12px] font-medium" style={{ color: 'var(--text-primary)' }}>{evt.label}</div><div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{evt.desc}</div></div>
</label>
))}
</div></div>
<div className="flex justify-end gap-2 pt-2">
<button onClick={() => setShowForm(false)} className="px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>取消</button>
<button onClick={handleSave} className="px-3 py-[5px] text-[12px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}>保存</button>
</div>
</div>
</div>
)}
<div className="space-y-2">
{webhooks.map((wh) => (
<div key={wh.id} className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>{wh.name}</span>
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: wh.active ? '#dafbe1' : '#f6f8fa', color: wh.active ? '#1a7f37' : '#6e7781' }}>
{wh.active ? '已启用' : '已禁用'}
</span>
</div>
<div className="flex items-center gap-1 text-[12px] font-mono mb-2" style={{ color: 'var(--text-tertiary)' }}>
<Webhook className="w-3 h-3" /> {wh.url}
</div>
<div className="flex items-center gap-1.5 flex-wrap">
{wh.events.map(evt => {
const opt = eventOptions.find(o => o.value === evt);
return (
<span key={evt} className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-secondary)' }}>
{opt?.label || evt}
</span>
);
})}
</div>
</div>
<div className="flex items-center gap-1">
<button onClick={() => toast.success('测试发送', { description: `${wh.name} 测试消息已发送` })}
className="p-1.5 rounded-md transition-colors hover:bg-black/[0.02]" style={{ color: 'var(--text-tertiary)' }} title="测试">
<Send className="w-3.5 h-3.5" />
</button>
<button onClick={() => startEdit(wh)}
className="p-1.5 rounded-md transition-colors hover:bg-black/[0.02]" style={{ color: 'var(--text-tertiary)' }} title="编辑">
<Edit2 className="w-3.5 h-3.5" />
</button>
<button onClick={() => handleToggle(wh.id)}
className="p-1.5 rounded-md transition-colors hover:bg-black/[0.02]" style={{ color: wh.active ? 'var(--accent-blue)' : 'var(--text-tertiary)' }} title={wh.active ? '禁用' : '启用'}>
{wh.active ? <ToggleRight className="w-4 h-4" /> : <ToggleLeft className="w-4 h-4" />}
</button>
<button onClick={() => handleDelete(wh.id)}
className="p-1.5 rounded-md transition-colors hover:bg-black/[0.02]" style={{ color: 'var(--text-tertiary)' }} title="删除">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
<div className="flex items-center gap-4 mt-2 pt-2 border-t text-[11px]" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-tertiary)' }}>
<span>最后触发: {wh.lastTriggered}</span>
{wh.lastStatus !== 'none' && (
<span className="flex items-center gap-1">
状态: {wh.lastStatus === 'success' ? <CheckCircle2 className="w-3 h-3" style={{ color: '#1a7f37' }} /> : <AlertTriangle className="w-3 h-3" style={{ color: '#cf222e' }} />}
{wh.lastStatus === 'success' ? '成功' : '失败'}
</span>
)}
</div>
</div>
))}
</div>
</div>
</div>
);
}