diff --git a/PROJECT_EVALUATION.md b/PROJECT_EVALUATION.md index 948704d..9f43cfc 100644 --- a/PROJECT_EVALUATION.md +++ b/PROJECT_EVALUATION.md @@ -39,6 +39,7 @@ | 拓扑图 | ✅ topology get/save | ✅ TopologyView | ✅ | | **Node 详情页** | ✅ GetNodeDetail (M10) | ✅ NodeDetailPage | ✅ curl 验证 | | **StatefulSet/DaemonSet 详情页** | ✅ GetStatefulSetDetail/GetDaemonSetDetail + YAML路由修复 (M11) | ✅ WorkloadViewPage 重写 (M11) | ✅ curl 验证 | +| **多集群资源对比看板** | ✅ GetMultiClusterComparison (M13) | ✅ ClusterComparePage (M13) | ✅ urllib 验证 | ### ❌ 缺失 / 进行中 diff --git a/ROADMAP.md b/ROADMAP.md index 9ec8483..3c01fa1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -282,7 +282,40 @@ | 模块 | 描述 | |------|------| -| M13 | 多集群资源对比看板 | +| M13 | 多集群资源对比看板 [done] | + +### M13: 多集群资源对比看板 [done] +**目标**: 跨集群资源用量、节点分布、健康状态对比 — 全局视角的多集群运维看板 + +**后端改动**: +- `handlers/kubernetes.go`: 新增 `GetMultiClusterComparison` handler: + - 聚合所有集群的 DB 缓存数据(node_count/pod_count/deployment_count/service_count/cpu_usage/memory_usage) + - 从 `kubernetes_nodes` 表聚合节点级数据:CPU 核数总和、内存总量、master/worker 分布、健康/非健康节点数 + - 集群级别 cpu_usage 为 0 时,用节点平均值回填 + - 返回 `{clusters: [...], totals: {cluster_count, node_count, pod_count, ...}}` +- `routes.go`: 注册 `GET /kubernetes/compare`(全局路由,不属于特定集群) + +**前端改动**: +- 新增 `ClusterComparePage.tsx` (~360行) — 多集群对比看板: + - 概览卡片:集群总数 / 节点总数 / Pod 总数 / Deployment 总数 / Service 总数 + - 资源数量对比柱状图(节点/Pod/Deployment/Service — 4 维度并排) + - CPU/内存使用率对比柱状图(百分比) + - 资源雷达图(多集群 6 维度对比 — 仅 ≥2 集群时显示) + - 节点角色分布饼图(Master/Worker) + - 详细对比表格(可点击行跳转集群概览,含 CPU/内存使用率进度条) +- `api.ts`: 新增 `getMultiClusterComparison()` 方法 +- `App.tsx`: 新增路由 `/kubernetes/compare` +- `Sidebar.tsx`: Kubernetes 菜单新增「多集群对比」入口(Layers 图标) + +**验收标准**: +- [x] `GET /api/v1/kubernetes/compare` 返回 clusters 数组 + totals 聚合 +- [x] totals 包含 cluster_count/node_count/pod_count/deployment_count/service_count +- [x] 每个集群包含 19 个字段(id/name/code/status/node_count/.../total_cpu_cores/total_memory_mb/master_count/worker_count) +- [x] 集群级别 cpu_usage 为 0 时用节点平均值回填 +- [x] 前端 4 类图表正确渲染(柱状图×2 + 雷达图 + 饼图) +- [x] 详细对比表格可点击跳转集群概览 +- [x] TypeScript 零编译错误 + Vite 构建成功 +- [x] SPA 路由 `/kubernetes/compare` 返回 200(非 301/404) --- diff --git a/TDD_PLAN.md b/TDD_PLAN.md index 391c4e2..8b46279 100644 --- a/TDD_PLAN.md +++ b/TDD_PLAN.md @@ -433,3 +433,48 @@ cd frontend && npx tsc --noEmit && npm run build 2>&1 | tail -3 - [x] 前端三维度柱状图正确渲染(recharts) - [x] 图表点击联动过滤事件列表 - [x] 最新告警面板展示 Warning 事件 + +--- + +## M13: 多集群资源对比看板 + +### 验证用例 + +```bash +# U1: 登录获取 Token +TOKEN=$(python3 -c " +import urllib.request, json +login_data = json.dumps({'username': 'admin', 'password': 'admin123'}).encode() +req = urllib.request.Request('http://127.0.0.1:8080/api/v1/auth/login', data=login_data, method='POST') +req.add_header('Content-Type', 'application/json') +resp = urllib.request.urlopen(req) +print(json.loads(resp.read())['data']['token']) +") + +# U2: 多集群对比 API +curl -s "http://127.0.0.1:8080/api/v1/kubernetes/compare" \ + -H "Authorization: Bearer $TOKEN" | jq '.data | {totals, cluster_count: (.clusters|length)}' +# 期望: totals 含 cluster_count/node_count/pod_count/deployment_count/service_count + +# U3: 验证每个集群包含完整字段 +curl -s "http://127.0.0.1:8080/api/v1/kubernetes/compare" \ + -H "Authorization: Bearer $TOKEN" | jq '.data.clusters[0] | keys' +# 期望: 19 个字段 (id/name/code/status/node_count/pod_count/.../total_cpu_cores/total_memory_mb/master_count/worker_count) + +# U4: SPA 路由 +curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8080/kubernetes/compare +# 期望: 200 + +# U5: TypeScript + Vite 构建 +cd frontend && npx tsc --noEmit && npm run build 2>&1 | tail -3 +# 期望: 零错误 + built in Ns +``` + +### 通过标准 +- [x] U2: Compare API 返回 clusters 数组 + totals 聚合数据 +- [x] U3: 每个集群包含 19 个字段(含 node 级别聚合数据) +- [x] U4: SPA 路由返回 200 +- [x] U5: TypeScript 零编译错误 + Vite 构建成功 +- [x] 前端 4 类图表正确渲染(柱状图×2 + 雷达图 + 饼图) +- [x] 详细对比表格可点击跳转集群概览 +- [x] totals 聚合正确(cluster_count/node_count/pod_count/deployment_count/service_count) diff --git a/backend/internal/api/handlers/kubernetes.go b/backend/internal/api/handlers/kubernetes.go index 833b822..4867c2f 100644 --- a/backend/internal/api/handlers/kubernetes.go +++ b/backend/internal/api/handlers/kubernetes.go @@ -3608,3 +3608,139 @@ func (h *KubernetesHandler) uploadPodFileWithNS(c *gin.Context, ns string) { }, }) } + +// ============================================================================ +// M13: 多集群资源对比看板 (Multi-Cluster Comparison Dashboard) // Logan.Li +// ============================================================================ + +// GetMultiClusterComparison 聚合所有集群的资源数据进行对比 +func (h *KubernetesHandler) GetMultiClusterComparison(c *gin.Context) { + // 1. 获取所有集群 + var clusters []models.KubernetesCluster + h.db.Find(&clusters) + + type ClusterComparison struct { + ID uint `json:"id"` + Name string `json:"name"` + Code string `json:"code"` + Status string `json:"status"` + Version string `json:"version"` + NodeCount int `json:"node_count"` + PodCount int `json:"pod_count"` + DeploymentCount int `json:"deployment_count"` + ServiceCount int `json:"service_count"` + CPUUsage float64 `json:"cpu_usage"` + MemoryUsage float64 `json:"memory_usage"` + APIServer string `json:"api_server"` + LastSyncAt *time.Time `json:"last_sync_at"` + + // Node-level aggregated data from kubernetes_nodes table + TotalCPUCores int `json:"total_cpu_cores"` + TotalMemoryMB int64 `json:"total_memory_mb"` + MasterCount int `json:"master_count"` + WorkerCount int `json:"worker_count"` + HealthyNodes int `json:"healthy_nodes"` + UnhealthyNodes int `json:"unhealthy_nodes"` + } + + // 2. 获取所有节点的聚合数据(按 cluster_id 分组) + type NodeAgg struct { + ClusterID uint + NodeCount int + MasterCount int + WorkerCount int + HealthyNodes int + UnhealthyNodes int + TotalCPU int + TotalMemory int64 + AvgCPUUsage float64 + AvgMemUsage float64 + } + nodeAggMap := map[uint]*NodeAgg{} + { + var nodes []models.KubernetesNode + h.db.Find(&nodes) + for _, n := range nodes { + agg, ok := nodeAggMap[n.ClusterID] + if !ok { + agg = &NodeAgg{ClusterID: n.ClusterID} + nodeAggMap[n.ClusterID] = agg + } + agg.NodeCount++ + agg.TotalCPU += n.CPU + agg.TotalMemory += n.Memory + if n.CPUUsage > 0 || n.MemoryUsage > 0 { + if agg.AvgCPUUsage == 0 { agg.AvgCPUUsage = n.CPUUsage } else { agg.AvgCPUUsage = (agg.AvgCPUUsage*float64(agg.NodeCount-1) + n.CPUUsage) / float64(agg.NodeCount) } + if agg.AvgMemUsage == 0 { agg.AvgMemUsage = n.MemoryUsage } else { agg.AvgMemUsage = (agg.AvgMemUsage*float64(agg.NodeCount-1) + n.MemoryUsage) / float64(agg.NodeCount) } + } + if n.Role == "master" { + agg.MasterCount++ + } else { + agg.WorkerCount++ + } + if n.Status == "Ready" { + agg.HealthyNodes++ + } else { + agg.UnhealthyNodes++ + } + } + } + + // 3. 构建对比数据 + comparisons := []ClusterComparison{} + totalNodes, totalPods, totalDeployments, totalServices := 0, 0, 0, 0 + for _, cl := range clusters { + cc := ClusterComparison{ + ID: cl.ID, + Name: cl.Name, + Code: cl.Code, + Status: cl.Status, + Version: cl.Version, + NodeCount: cl.NodeCount, + PodCount: cl.PodCount, + DeploymentCount: cl.DeploymentCount, + ServiceCount: cl.ServiceCount, + CPUUsage: cl.CPUUsage, + MemoryUsage: cl.MemoryUsage, + APIServer: cl.APIServer, + LastSyncAt: cl.LastSyncAt, + } + // 优先用 node 级别的实时聚合数据 + if agg, ok := nodeAggMap[cl.ID]; ok && agg.NodeCount > 0 { + cc.TotalCPUCores = agg.TotalCPU + cc.TotalMemoryMB = agg.TotalMemory + cc.MasterCount = agg.MasterCount + cc.WorkerCount = agg.WorkerCount + cc.HealthyNodes = agg.HealthyNodes + cc.UnhealthyNodes = agg.UnhealthyNodes + // 如果集群级别的 cpu_usage 为 0,使用节点平均值 + if cc.CPUUsage == 0 && agg.AvgCPUUsage > 0 { + cc.CPUUsage = agg.AvgCPUUsage + } + if cc.MemoryUsage == 0 && agg.AvgMemUsage > 0 { + cc.MemoryUsage = agg.AvgMemUsage + } + } + totalNodes += cc.NodeCount + totalPods += cc.PodCount + totalDeployments += cc.DeploymentCount + totalServices += cc.ServiceCount + comparisons = append(comparisons, cc) + } + + // 4. 返回结果 + c.JSON(200, models.Response{ + Code: 200, + Message: "success", + Data: gin.H{ + "clusters": comparisons, + "totals": gin.H{ + "cluster_count": len(clusters), + "node_count": totalNodes, + "pod_count": totalPods, + "deployment_count": totalDeployments, + "service_count": totalServices, + }, + }, + }) +} diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 5cee589..84d6785 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -432,6 +432,9 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c kubernetesRouter.GET("/:clusterCode/namespace/:namespace/:resource/:name/yaml", k8sHandler.GetResourceYAMLNS) kubernetesRouter.PUT("/:clusterCode/namespace/:namespace/:resource/:name/yaml", k8sHandler.UpdateResourceYAMLNS) kubernetesRouter.DELETE("/:clusterCode/namespace/:namespace/:resource/:name/yaml", k8sHandler.DeleteResourceYAMLNS) + + // 🆕 M13: 多集群资源对比看板 + kubernetesRouter.GET("/compare", k8sHandler.GetMultiClusterComparison) } // 告警路由 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f76697f..d693c84 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -25,6 +25,7 @@ import { KubernetesWorkloads } from '@/components/kubernetes/KubernetesWorkloads import PodMetricsPage from '@/components/kubernetes/PodMetricsPage'; import EventsPage from '@/components/kubernetes/EventsPage'; import CapacityPage from '@/components/kubernetes/CapacityPage'; +import ClusterComparePage from '@/components/kubernetes/ClusterComparePage'; import { AlertManager } from '@/components/monitor/AlertManager'; import { ApprovalFlowManager } from '@/components/approval/ApprovalFlow'; import { ScriptManager } from '@/components/automation/ScriptManager'; @@ -80,9 +81,11 @@ function App() { {/* ================================================================ */} {/* Kubernetes — Kuboard-style routes */} {/* ================================================================ */} - {/* 集群列表 */} + {/* Kubernetes 集群列表 */} } /> + {/* 🆕 M13: 多集群资源对比看板(全局,不属于特定集群) */} + } /> {/* 集群概览 (节点 + 命名空间列表) */} } /> diff --git a/frontend/src/components/kubernetes/ClusterComparePage.tsx b/frontend/src/components/kubernetes/ClusterComparePage.tsx new file mode 100644 index 0000000..afb5442 --- /dev/null +++ b/frontend/src/components/kubernetes/ClusterComparePage.tsx @@ -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(null); + const [error, setError] = useState(''); + + 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 = { + 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 ( +
+ {/* Header */} +
+
+ +
+

多集群资源对比看板

+

跨集群资源用量、节点分布、健康状态对比

+
+
+
+ + + + +
+
+ +
+ {error && ( + + ⚠ {error} + + )} + + {/* 概览卡片 */} +
+ {[ + { 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) => ( + + +
+
+

{s.label}

+

{s.value}

+
+
+ +
+
+
+
+ ))} +
+ + {clusters.length === 0 && !loading && ( + + + +

暂无集群数据,请先添加 K8s 集群并同步

+ + + +
+
+ )} + + {clusters.length > 0 && ( + <> + {/* 资源对比柱状图 + 使用率柱状图 */} +
+ + +
+ +

资源数量对比

+
+ + + + + + + + + + + + + +
+
+ + + +
+ +

CPU / 内存使用率对比

+
+ + + + + + + + + + + +
+
+
+ + {/* 雷达图 + 节点角色饼图 */} + {clusters.length >= 2 && ( +
+ + +
+ +

资源雷达对比

+
+ + + + + + {clusters.map((c: any, i: number) => ( + + ))} + + + + +
+
+ + + +
+ +

节点角色分布

+
+ {nodeRolePie.length > 0 ? ( + + + `${e.name}: ${e.value}`} + labelLine={{ stroke: '#475569' }} + > + {nodeRolePie.map((_, i) => ( + + ))} + + + + + ) : ( +
无节点角色数据
+ )} +
+
+
+ )} + + {/* 详细对比表格 */} + + +
+ +

详细对比

+ 点击行可进入集群概览 +
+
+ + + + 集群 + 状态 + 节点 + Pod + Deployment + Service + CPU 核数 + 内存 + CPU 使用率 + 内存使用率 + 角色 + 同步时间 + + + + {clusters.map((c: any) => ( + navigate(`/kubernetes/${c.code}`)} + > + +
{c.name}
+
{c.code} · {c.api_server || '-'}
+
+ + + {c.status === 'healthy' ? '✓ 健康' : c.status === 'warning' ? '⚠ 警告' : '✗ 异常'} + + + {c.node_count} + {c.pod_count} + {c.deployment_count} + {c.service_count} + {c.total_cpu_cores || '-'} + {fmtMem(c.total_memory_mb)} + +
+ {(c.cpu_usage || 0).toFixed(1)}% +
+
+
+
+ + +
+ {(c.memory_usage || 0).toFixed(1)}% +
+
+
+
+ + +
+ {(c.master_count > 0) && ( + M:{c.master_count} + )} + {(c.worker_count > 0) && ( + W:{c.worker_count} + )} + {!c.master_count && !c.worker_count && -} +
+
+ {fmtTime(c.last_sync_at)} + + ))} + +
+
+
+
+ + )} +
+
+ ); +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index cf556e4..dacf51a 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -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, diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index ed6f7a8..783b2eb 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -497,6 +497,11 @@ export const kubernetesApi = { return http.get(`/kubernetes/${clusterCode}/capacity`, params); }, + // === 🆕 M13: 多集群资源对比看板 === + getMultiClusterComparison: async () => { + return http.get('/kubernetes/compare'); + }, + // === 🆕 YAML Apply === applyYAML: async (clusterCode: string, yaml: string) => { return http.post(`/kubernetes/${clusterCode}/yaml/apply`, { yaml });