- 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)
359 lines
22 KiB
TypeScript
359 lines
22 KiB
TypeScript
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>
|
|
);
|
|
}
|