后端(35+ 新路由): - AES-256-GCM 字段加密(enc:v1:),密钥自动生成 data/encryption.key - 新模型:Host/K8sCluster/Credential/BuildNode/DeployTool/WebhookEndpoint/ JenkinsServer/SystemSetting;Pipeline 扩展触发器/变量/缓存列, PipelineRun 扩展 buildNumber/externalUrl - 资源 CRUD + 真实测试端点:主机/集群/构建节点 TCP 拨号、 工具真实执行版本检测(保守安装策略)、Webhook HMAC-SHA256 签名发送、 入站流水线触发 /api/hooks/pipeline/:id - Jenkins 完整集成(移植 opsx-jenkins 生产客户端):crumb/BasicAuth/ buildWithParameters/Location 队列/progressiveText 增量控制台, 服务器 CRUD+测试、Job 树拉取(folder 压平)、导入为流水线、 jenkins 类型流水线真实执行回传日志 - 流水线列表附带 lastRun、运行重试、summary 统计、系统设置单行表 - 运行结束触发出站 Webhook 通知;GitOps 同步状态真实推进 前端(14 页全部去 mock): - 流水线列表/发布记录/批量发布/环境/凭证/构建节点/工具/Webhook/ 系统设置/Jenkins 导入/GitOps(资源树+差异) 全部接入真实 API - 编辑器 4 Tab 持久化(基本信息/触发设置含真实 Webhook URL/变量缓存/发布历史) - TopBar 真实流水线切换 + 仅保存/保存并运行 - Sidebar 徽标真实统计;删除未使用组件与 mock 数据文件
274 lines
16 KiB
TypeScript
274 lines
16 KiB
TypeScript
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: p.installs || 0,
|
|
rating: p.rating || null,
|
|
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: p.rating || null,
|
|
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)' }}>
|
|
{plugin.rating != null && <span className="flex items-center gap-1"><Star className="w-3 h-3" style={{ color: '#9a6700' }} />{plugin.rating}</span>}
|
|
{plugin.installs > 0 && <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>
|
|
);
|
|
}
|