feat(k8s): M13 多集群资源对比看板 — GetMultiClusterComparison handler + ClusterComparePage (柱状图/雷达图/饼图/详细对比表)
This commit is contained in:
1 parent
7192f58b72
commit
3d7932c087
9 files changed
+591
-2
No files matched your search
@@ -39,6 +39,7 @@
|
|||||||
| 拓扑图 | ✅ topology get/save | ✅ TopologyView | ✅ |
|
| 拓扑图 | ✅ topology get/save | ✅ TopologyView | ✅ |
|
||||||
| **Node 详情页** | ✅ GetNodeDetail (M10) | ✅ NodeDetailPage | ✅ curl 验证 |
|
| **Node 详情页** | ✅ GetNodeDetail (M10) | ✅ NodeDetailPage | ✅ curl 验证 |
|
||||||
| **StatefulSet/DaemonSet 详情页** | ✅ GetStatefulSetDetail/GetDaemonSetDetail + YAML路由修复 (M11) | ✅ WorkloadViewPage 重写 (M11) | ✅ curl 验证 |
|
| **StatefulSet/DaemonSet 详情页** | ✅ GetStatefulSetDetail/GetDaemonSetDetail + YAML路由修复 (M11) | ✅ WorkloadViewPage 重写 (M11) | ✅ curl 验证 |
|
||||||
|
| **多集群资源对比看板** | ✅ GetMultiClusterComparison (M13) | ✅ ClusterComparePage (M13) | ✅ urllib 验证 |
|
||||||
|
|
||||||
### ❌ 缺失 / 进行中
|
### ❌ 缺失 / 进行中
|
||||||
|
|
||||||
|
|||||||
+34
-1
@@ -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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+45
@@ -433,3 +433,48 @@ cd frontend && npx tsc --noEmit && npm run build 2>&1 | tail -3
|
|||||||
- [x] 前端三维度柱状图正确渲染(recharts)
|
- [x] 前端三维度柱状图正确渲染(recharts)
|
||||||
- [x] 图表点击联动过滤事件列表
|
- [x] 图表点击联动过滤事件列表
|
||||||
- [x] 最新告警面板展示 Warning 事件
|
- [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)
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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.GET("/:clusterCode/namespace/:namespace/:resource/:name/yaml", k8sHandler.GetResourceYAMLNS)
|
||||||
kubernetesRouter.PUT("/:clusterCode/namespace/:namespace/:resource/:name/yaml", k8sHandler.UpdateResourceYAMLNS)
|
kubernetesRouter.PUT("/:clusterCode/namespace/:namespace/:resource/:name/yaml", k8sHandler.UpdateResourceYAMLNS)
|
||||||
kubernetesRouter.DELETE("/:clusterCode/namespace/:namespace/:resource/:name/yaml", k8sHandler.DeleteResourceYAMLNS)
|
kubernetesRouter.DELETE("/:clusterCode/namespace/:namespace/:resource/:name/yaml", k8sHandler.DeleteResourceYAMLNS)
|
||||||
|
|
||||||
|
// 🆕 M13: 多集群资源对比看板
|
||||||
|
kubernetesRouter.GET("/compare", k8sHandler.GetMultiClusterComparison)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 告警路由
|
// 告警路由
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { KubernetesWorkloads } from '@/components/kubernetes/KubernetesWorkloads
|
|||||||
import PodMetricsPage from '@/components/kubernetes/PodMetricsPage';
|
import PodMetricsPage from '@/components/kubernetes/PodMetricsPage';
|
||||||
import EventsPage from '@/components/kubernetes/EventsPage';
|
import EventsPage from '@/components/kubernetes/EventsPage';
|
||||||
import CapacityPage from '@/components/kubernetes/CapacityPage';
|
import CapacityPage from '@/components/kubernetes/CapacityPage';
|
||||||
|
import ClusterComparePage from '@/components/kubernetes/ClusterComparePage';
|
||||||
import { AlertManager } from '@/components/monitor/AlertManager';
|
import { AlertManager } from '@/components/monitor/AlertManager';
|
||||||
import { ApprovalFlowManager } from '@/components/approval/ApprovalFlow';
|
import { ApprovalFlowManager } from '@/components/approval/ApprovalFlow';
|
||||||
import { ScriptManager } from '@/components/automation/ScriptManager';
|
import { ScriptManager } from '@/components/automation/ScriptManager';
|
||||||
@@ -80,9 +81,11 @@ function App() {
|
|||||||
{/* ================================================================ */}
|
{/* ================================================================ */}
|
||||||
{/* Kubernetes — Kuboard-style routes */}
|
{/* Kubernetes — Kuboard-style routes */}
|
||||||
{/* ================================================================ */}
|
{/* ================================================================ */}
|
||||||
{/* 集群列表 */}
|
{/* Kubernetes 集群列表 */}
|
||||||
<Route path="kubernetes">
|
<Route path="kubernetes">
|
||||||
<Route index element={<KubernetesClusters />} />
|
<Route index element={<KubernetesClusters />} />
|
||||||
|
{/* 🆕 M13: 多集群资源对比看板(全局,不属于特定集群) */}
|
||||||
|
<Route path="compare" element={<ClusterComparePage />} />
|
||||||
{/* 集群概览 (节点 + 命名空间列表) */}
|
{/* 集群概览 (节点 + 命名空间列表) */}
|
||||||
<Route path=":clusterCode">
|
<Route path=":clusterCode">
|
||||||
<Route index element={<ClusterOverviewPage />} />
|
<Route index element={<ClusterOverviewPage />} />
|
||||||
|
|||||||
@@ -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,
|
Menu, Network, CloudCog, Globe, MapPin, Key,
|
||||||
Brain, Activity, BarChart3, GitBranch,
|
Brain, Activity, BarChart3, GitBranch,
|
||||||
Bot, AlertTriangle, Container, Boxes, Clock, Rocket,
|
Bot, AlertTriangle, Container, Boxes, Clock, Rocket,
|
||||||
|
Layers,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
@@ -38,6 +39,7 @@ const menuItems: MenuItem[] = [
|
|||||||
id: 'kubernetes', name: 'Kubernetes', icon: Cloud,
|
id: 'kubernetes', name: 'Kubernetes', icon: Cloud,
|
||||||
children: [
|
children: [
|
||||||
{ id: 'k8s-clusters', name: '集群列表', icon: Cloud, path: '/kubernetes' },
|
{ 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-nodes', name: '节点管理', icon: Cpu, path: '/nodes' },
|
||||||
{
|
{
|
||||||
id: 'k8s-workload', name: '工作负载', icon: Boxes,
|
id: 'k8s-workload', name: '工作负载', icon: Boxes,
|
||||||
|
|||||||
@@ -497,6 +497,11 @@ export const kubernetesApi = {
|
|||||||
return http.get(`/kubernetes/${clusterCode}/capacity`, params);
|
return http.get(`/kubernetes/${clusterCode}/capacity`, params);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// === 🆕 M13: 多集群资源对比看板 ===
|
||||||
|
getMultiClusterComparison: async () => {
|
||||||
|
return http.get('/kubernetes/compare');
|
||||||
|
},
|
||||||
|
|
||||||
// === 🆕 YAML Apply ===
|
// === 🆕 YAML Apply ===
|
||||||
applyYAML: async (clusterCode: string, yaml: string) => {
|
applyYAML: async (clusterCode: string, yaml: string) => {
|
||||||
return http.post(`/kubernetes/${clusterCode}/yaml/apply`, { yaml });
|
return http.post(`/kubernetes/${clusterCode}/yaml/apply`, { yaml });
|
||||||
|
|||||||
Reference in new issue
Block a user