feat(k8s): M13 多集群资源对比看板 — GetMultiClusterComparison handler + ClusterComparePage (柱状图/雷达图/饼图/详细对比表)

This commit is contained in:
attacker committed 2026-06-24 05:03:47 +08:00
1 parent 7192f58b72
commit 3d7932c087
9 files changed
+591 -2

No files matched your search

@@ -0,0 +1,361 @@
import { useState, useEffect, useCallback } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
RadarChart, Radar, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Legend,
PieChart, Pie, Cell,
} from 'recharts';
import {
RefreshCw, Cloud, Cpu, Container, Boxes, Activity, Server, Shield,
HardDrive, Layers, Network, ChevronRight, TrendingUp,
} from 'lucide-react';
import { kubernetesApi } from '@/services/api';
const CHART_COLORS = ['#3B82F6', '#207060', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#14B8A6', '#F97316'];
export default function ClusterComparePage() {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [data, setData] = useState<any>(null);
const [error, setError] = useState<string>('');
const loadData = useCallback(async () => {
setLoading(true);
setError('');
try {
const res: any = await kubernetesApi.getMultiClusterComparison();
setData(res || null);
} catch (e: any) {
setError(e?.message || '加载失败');
setData(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadData(); }, [loadData]);
const clusters: any[] = data?.clusters || [];
const totals: any = data?.totals || {};
// 图表数据准备
const resourceBarData = clusters.map((c: any) => ({
name: c.code || c.name,
节点: c.node_count || 0,
Pod: c.pod_count || 0,
Deployment: c.deployment_count || 0,
Service: c.service_count || 0,
}));
const usageBarData = clusters.map((c: any) => ({
name: c.code || c.name,
'CPU %': Number((c.cpu_usage || 0).toFixed(1)),
'内存 %': Number((c.memory_usage || 0).toFixed(1)),
}));
const radarData = clusters.length > 0 ? [
{ metric: '节点数', ...Object.fromEntries(clusters.map((c: any) => [c.code || c.name, c.node_count || 0])) },
{ metric: 'Pod数', ...Object.fromEntries(clusters.map((c: any) => [c.code || c.name, Math.min(c.pod_count || 0, 100)])) },
{ metric: '部署数', ...Object.fromEntries(clusters.map((c: any) => [c.code || c.name, c.deployment_count || 0])) },
{ metric: 'CPU使用', ...Object.fromEntries(clusters.map((c: any) => [c.code || c.name, Number((c.cpu_usage || 0).toFixed(1))])) },
{ metric: '内存使用', ...Object.fromEntries(clusters.map((c: any) => [c.code || c.name, Number((c.memory_usage || 0).toFixed(1))])) },
{ metric: 'CPU核数', ...Object.fromEntries(clusters.map((c: any) => [c.code || c.name, c.total_cpu_cores || 0])) },
] : [];
const nodeRolePie = clusters.flatMap((c: any) => [
{ name: `${c.code}-Master`, value: c.master_count || 0, fill: CHART_COLORS[1] },
{ name: `${c.code}-Worker`, value: c.worker_count || 0, fill: CHART_COLORS[0] },
]).filter(d => d.value > 0);
const statusColorMap: Record<string, string> = {
healthy: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30',
warning: 'bg-amber-500/15 text-amber-400 border-amber-500/30',
error: 'bg-red-500/15 text-red-400 border-red-500/30',
};
// 格式化内存 (MB → GB)
const fmtMem = (mb: number | undefined) => {
if (!mb || mb <= 0) return '-';
return `${(mb / 1024).toFixed(1)} GB`;
};
// 格式化同步时间
const fmtTime = (t: string | null | undefined) => {
if (!t) return '从未同步';
try {
const d = new Date(t);
return d.toLocaleString('zh-CN', { hour12: false });
} catch { return t; }
};
return (
<div className="min-h-screen bg-slate-950 text-slate-100">
{/* Header */}
<div className="border-b border-slate-800/60 bg-slate-900/40 px-6 py-4 flex items-center justify-between sticky top-0 z-20 backdrop-blur">
<div className="flex items-center gap-3">
<Layers className="h-5 w-5 text-blue-400" />
<div>
<h1 className="text-lg font-semibold">多集群资源对比看板</h1>
<p className="text-xs text-slate-500">跨集群资源用量、节点分布、健康状态对比</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={loadData} disabled={loading}
className="border-slate-700 hover:bg-slate-800">
<RefreshCw className={`h-3.5 w-3.5 mr-1.5 ${loading ? 'animate-spin' : ''}`} />
刷新
</Button>
<Link to="/kubernetes">
<Button variant="outline" size="sm" className="border-slate-700 hover:bg-slate-800">
<Cloud className="h-3.5 w-3.5 mr-1.5" />集群列表
</Button>
</Link>
</div>
</div>
<div className="p-6 space-y-6 max-w-[1600px] mx-auto">
{error && (
<Card className="border-red-500/30 bg-red-500/5">
<CardContent className="py-3 text-sm text-red-400">⚠ {error}</CardContent>
</Card>
)}
{/* 概览卡片 */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
{[
{ label: '集群总数', value: totals.cluster_count || 0, icon: Cloud, color: 'text-blue-400', bg: 'bg-blue-500/10' },
{ label: '节点总数', value: totals.node_count || 0, icon: Cpu, color: 'text-emerald-400', bg: 'bg-emerald-500/10' },
{ label: 'Pod 总数', value: totals.pod_count || 0, icon: Container, color: 'text-purple-400', bg: 'bg-purple-500/10' },
{ label: 'Deployment 总数', value: totals.deployment_count || 0, icon: Boxes, color: 'text-amber-400', bg: 'bg-amber-500/10' },
{ label: 'Service 总数', value: totals.service_count || 0, icon: Network, color: 'text-cyan-400', bg: 'bg-cyan-500/10' },
].map((s, i) => (
<Card key={i} className="bg-slate-900/60 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-slate-500 mb-1">{s.label}</p>
<p className="text-2xl font-bold">{s.value}</p>
</div>
<div className={`p-2.5 rounded-lg ${s.bg}`}>
<s.icon className={`h-5 w-5 ${s.color}`} />
</div>
</div>
</CardContent>
</Card>
))}
</div>
{clusters.length === 0 && !loading && (
<Card className="bg-slate-900/60 border-slate-800">
<CardContent className="py-12 text-center">
<Cloud className="h-12 w-12 text-slate-700 mx-auto mb-3" />
<p className="text-slate-500">暂无集群数据,请先添加 K8s 集群并同步</p>
<Link to="/kubernetes" className="inline-block mt-3">
<Button variant="outline" size="sm" className="border-slate-700">
去添加集群 <ChevronRight className="h-3.5 w-3.5 ml-1" />
</Button>
</Link>
</CardContent>
</Card>
)}
{clusters.length > 0 && (
<>
{/* 资源对比柱状图 + 使用率柱状图 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className="bg-slate-900/60 border-slate-800">
<CardContent className="p-5">
<div className="flex items-center gap-2 mb-4">
<Layers className="h-4 w-4 text-blue-400" />
<h3 className="text-sm font-semibold">资源数量对比</h3>
</div>
<ResponsiveContainer width="100%" height={280}>
<BarChart data={resourceBarData} margin={{ top: 5, right: 10, left: -15, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
<XAxis dataKey="name" tick={{ fill: '#94a3b8', fontSize: 11 }} />
<YAxis tick={{ fill: '#94a3b8', fontSize: 11 }} />
<Tooltip contentStyle={{ background: '#0f172a', border: '1px solid #334155', borderRadius: 8, fontSize: 12 }} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Bar dataKey="节点" fill={CHART_COLORS[1]} radius={[3, 3, 0, 0]} />
<Bar dataKey="Pod" fill={CHART_COLORS[0]} radius={[3, 3, 0, 0]} />
<Bar dataKey="Deployment" fill={CHART_COLORS[2]} radius={[3, 3, 0, 0]} />
<Bar dataKey="Service" fill={CHART_COLORS[5]} radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card className="bg-slate-900/60 border-slate-800">
<CardContent className="p-5">
<div className="flex items-center gap-2 mb-4">
<Activity className="h-4 w-4 text-emerald-400" />
<h3 className="text-sm font-semibold">CPU / 内存使用率对比</h3>
</div>
<ResponsiveContainer width="100%" height={280}>
<BarChart data={usageBarData} margin={{ top: 5, right: 10, left: -15, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
<XAxis dataKey="name" tick={{ fill: '#94a3b8', fontSize: 11 }} />
<YAxis tick={{ fill: '#94a3b8', fontSize: 11 }} unit="%" />
<Tooltip contentStyle={{ background: '#0f172a', border: '1px solid #334155', borderRadius: 8, fontSize: 12 }} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Bar dataKey="CPU %" fill={CHART_COLORS[0]} radius={[3, 3, 0, 0]} />
<Bar dataKey="内存 %" fill={CHART_COLORS[1]} radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
{/* 雷达图 + 节点角色饼图 */}
{clusters.length >= 2 && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className="bg-slate-900/60 border-slate-800">
<CardContent className="p-5">
<div className="flex items-center gap-2 mb-4">
<TrendingUp className="h-4 w-4 text-purple-400" />
<h3 className="text-sm font-semibold">资源雷达对比</h3>
</div>
<ResponsiveContainer width="100%" height={320}>
<RadarChart data={radarData}>
<PolarGrid stroke="#1e293b" />
<PolarAngleAxis dataKey="metric" tick={{ fill: '#94a3b8', fontSize: 11 }} />
<PolarRadiusAxis tick={{ fill: '#64748b', fontSize: 10 }} />
{clusters.map((c: any, i: number) => (
<Radar key={i} name={c.code || c.name}
dataKey={c.code || c.name}
stroke={CHART_COLORS[i % CHART_COLORS.length]}
fill={CHART_COLORS[i % CHART_COLORS.length]}
fillOpacity={0.15}
strokeWidth={2}
/>
))}
<Legend wrapperStyle={{ fontSize: 11 }} />
<Tooltip contentStyle={{ background: '#0f172a', border: '1px solid #334155', borderRadius: 8, fontSize: 12 }} />
</RadarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card className="bg-slate-900/60 border-slate-800">
<CardContent className="p-5">
<div className="flex items-center gap-2 mb-4">
<Server className="h-4 w-4 text-amber-400" />
<h3 className="text-sm font-semibold">节点角色分布</h3>
</div>
{nodeRolePie.length > 0 ? (
<ResponsiveContainer width="100%" height={320}>
<PieChart>
<Pie data={nodeRolePie} cx="50%" cy="45%" innerRadius={50} outerRadius={90}
paddingAngle={3} dataKey="value"
label={(e: any) => `${e.name}: ${e.value}`}
labelLine={{ stroke: '#475569' }}
>
{nodeRolePie.map((_, i) => (
<Cell key={i} fill={nodeRolePie[i].fill} />
))}
</Pie>
<Tooltip contentStyle={{ background: '#0f172a', border: '1px solid #334155', borderRadius: 8, fontSize: 12 }} />
</PieChart>
</ResponsiveContainer>
) : (
<div className="h-[320px] flex items-center justify-center text-slate-600 text-sm">无节点角色数据</div>
)}
</CardContent>
</Card>
</div>
)}
{/* 详细对比表格 */}
<Card className="bg-slate-900/60 border-slate-800">
<CardContent className="p-5">
<div className="flex items-center gap-2 mb-4">
<HardDrive className="h-4 w-4 text-cyan-400" />
<h3 className="text-sm font-semibold">详细对比</h3>
<span className="text-xs text-slate-500 ml-2">点击行可进入集群概览</span>
</div>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">集群</TableHead>
<TableHead className="text-slate-400">状态</TableHead>
<TableHead className="text-slate-400 text-right">节点</TableHead>
<TableHead className="text-slate-400 text-right">Pod</TableHead>
<TableHead className="text-slate-400 text-right">Deployment</TableHead>
<TableHead className="text-slate-400 text-right">Service</TableHead>
<TableHead className="text-slate-400 text-right">CPU 核数</TableHead>
<TableHead className="text-slate-400 text-right">内存</TableHead>
<TableHead className="text-slate-400 text-right">CPU 使用率</TableHead>
<TableHead className="text-slate-400 text-right">内存使用率</TableHead>
<TableHead className="text-slate-400">角色</TableHead>
<TableHead className="text-slate-400">同步时间</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{clusters.map((c: any) => (
<TableRow key={c.id}
className="border-slate-800/50 hover:bg-slate-800/30 cursor-pointer"
onClick={() => navigate(`/kubernetes/${c.code}`)}
>
<TableCell>
<div className="font-medium text-slate-200">{c.name}</div>
<div className="text-xs text-slate-500">{c.code} · {c.api_server || '-'}</div>
</TableCell>
<TableCell>
<Badge variant="outline" className={`text-xs ${statusColorMap[c.status] || statusColorMap.error}`}>
{c.status === 'healthy' ? '✓ 健康' : c.status === 'warning' ? '⚠ 警告' : '✗ 异常'}
</Badge>
</TableCell>
<TableCell className="text-right font-mono text-slate-300">{c.node_count}</TableCell>
<TableCell className="text-right font-mono text-slate-300">{c.pod_count}</TableCell>
<TableCell className="text-right font-mono text-slate-300">{c.deployment_count}</TableCell>
<TableCell className="text-right font-mono text-slate-300">{c.service_count}</TableCell>
<TableCell className="text-right font-mono text-slate-300">{c.total_cpu_cores || '-'}</TableCell>
<TableCell className="text-right font-mono text-slate-300">{fmtMem(c.total_memory_mb)}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1.5">
<span className="font-mono text-sm text-blue-400">{(c.cpu_usage || 0).toFixed(1)}%</span>
<div className="w-12 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div className="h-full bg-blue-500 rounded-full" style={{ width: `${Math.min(c.cpu_usage || 0, 100)}%` }} />
</div>
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1.5">
<span className="font-mono text-sm text-emerald-400">{(c.memory_usage || 0).toFixed(1)}%</span>
<div className="w-12 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div className="h-full bg-emerald-500 rounded-full" style={{ width: `${Math.min(c.memory_usage || 0, 100)}%` }} />
</div>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-1 text-xs">
{(c.master_count > 0) && (
<span className="text-emerald-400">M:{c.master_count}</span>
)}
{(c.worker_count > 0) && (
<span className="text-blue-400">W:{c.worker_count}</span>
)}
{!c.master_count && !c.worker_count && <span className="text-slate-600">-</span>}
</div>
</TableCell>
<TableCell className="text-xs text-slate-500">{fmtTime(c.last_sync_at)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</>
)}
</div>
</div>
);
}
@@ -9,6 +9,7 @@ import {
Menu, Network, CloudCog, Globe, MapPin, Key,
Brain, Activity, BarChart3, GitBranch,
Bot, AlertTriangle, Container, Boxes, Clock, Rocket,
Layers,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
@@ -38,6 +39,7 @@ const menuItems: MenuItem[] = [
id: 'kubernetes', name: 'Kubernetes', icon: Cloud,
children: [
{ id: 'k8s-clusters', name: '集群列表', icon: Cloud, path: '/kubernetes' },
{ id: 'k8s-compare', name: '多集群对比', icon: Layers, path: '/kubernetes/compare' },
{ id: 'k8s-nodes', name: '节点管理', icon: Cpu, path: '/nodes' },
{
id: 'k8s-workload', name: '工作负载', icon: Boxes,