diff --git a/PROJECT_EVALUATION.md b/PROJECT_EVALUATION.md index 1898a48..be1aee1 100644 --- a/PROJECT_EVALUATION.md +++ b/PROJECT_EVALUATION.md @@ -37,6 +37,7 @@ | 容量分析 | ✅ GetCapacityAnalysis | ✅ CapacityPage | ✅ | | kubectl 终端 | ✅ KubectlShell(WS) | ✅ KubectlTerminal | ✅ | | 拓扑图 | ✅ topology get/save | ✅ TopologyView | ✅ | +| **Node 详情页** | ✅ GetNodeDetail (M10) | ✅ NodeDetailPage | ✅ curl 验证 | ### ❌ 缺失 / 进行中 @@ -82,3 +83,4 @@ 2. **metrics-server 已安装** — `kubectl top nodes` 正常返回 3. **自研 K8s 客户端** — 不用 client-go,保持轻量 net/http 方案 4. **两个数据路径**:ListNodes 实时调 API(含 metrics),SyncCluster 写 DB(缺 metrics)→ 前端不同页面读不同路径 +5. **Node 详情页** (M10 ✅) — `GetNodeDetail` handler 返回完整节点信息:container_runtime/architecture/taints/conditions/资源分配率/Pod列表,前端 `NodeDetailPage.tsx` 可视化展示 diff --git a/ROADMAP.md b/ROADMAP.md index 9285c6e..9542cd8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -183,9 +183,38 @@ ## 后续规划(P2+) +### M10: Node 详情页(PID/容器运行时/分配率)[done] +**目标**: 节点名称可点击进入详情页,展示完整的系统信息、资源分配率、污点、条件、Pod 列表 + +**改动**: +- 后端: + - `kubernetesclient/client.go`: 新增 `NodeTaint` / `NodeCondition` 扩展字段 + `NodeInfo` 增加 `architecture`/`operatingSystem` + `GetNode(name)` 方法 + - `kubernetes.go`: 新增 `GetNodeDetail` handler — 获取单个节点详情 + 遍历 Pod 计算资源分配率(CPU/Memory/Pods requested vs allocatable) + - `routes.go`: 注册 `GET /kubernetes/:clusterCode/nodes/:name` (Kuboard code 路由) + `GET /kubernetes-clusters/:id/nodes/:name` (ID 路由) + - 新增 `parseCpuToMilli()` / `parseMemToMi()` 辅助函数(解析 Pod resource requests) +- 前端: + - 新增 `NodeDetailPage.tsx` (~330行) — 节点详情页:系统信息卡片 + 资源分配率卡片(进度条) + 条件卡片 + 污点卡片 + 标签卡片 + Pod 列表 + - `api.ts`: 新增 `getNodeDetail()` 方法 + - `App.tsx`: 新增路由 `:clusterCode/nodes/:nodeName` + - `ClusterOverviewPage.tsx`: 节点名称可点击跳转详情页 + +**验收标准**: +- [x] `GET /kubernetes/:clusterCode/nodes/:name` 返回完整详情(container_runtime/kubelet/os/kernel/architecture) +- [x] 资源分配率正确计算(cpu_alloc_pct/mem_alloc_pct/pod_alloc_pct) +- [x] metrics-server 数据注入(cpu_usage_m/mem_usage_mi) +- [x] 节点条件全量返回(5个 conditions 含 reason/message) +- [x] 污点列表返回 +- [x] 节点上的 Pod 列表返回(含 CPU/内存 requests) +- [x] 前端节点名称可点击跳转 +- [x] SPA 路由返回 200(非 301) +- [x] TypeScript 零编译错误 + Vite 构建成功 + +--- + +### 后续(P3+) + | 模块 | 描述 | |------|------| -| M10 | Node 详情页(PID/容器运行时/分配率) | | M11 | StatefulSet/DaemonSet 详情页(对标 Deployment) | | M12 | 事件聚合 + 告警联动 | | M13 | 多集群资源对比看板 | diff --git a/TDD_PLAN.md b/TDD_PLAN.md index 1c90dd3..83cb917 100644 --- a/TDD_PLAN.md +++ b/TDD_PLAN.md @@ -270,6 +270,52 @@ curl -s http://127.0.0.1/ | grep -oP 'index-[A-Za-z0-9]+\.js' --- +## M10: Node 详情页(PID/容器运行时/分配率) + +### 验证用例 + +```bash +# U1: 登录获取 Token +TOKEN=$(curl -s http://127.0.0.1:8080/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","password":"admin123"}' | jq -r '.data.token') + +# U2: Node detail API — Kuboard code route +curl -s "http://127.0.0.1:8080/api/v1/kubernetes/test-k8s/nodes/k8s-master-01" \ + -H "Authorization: Bearer *** | jq '.data.container_runtime' +# 期望: "containerd://2.1.4" + +# U3: 资源分配率字段 +curl -s "http://127.0.0.1:8080/api/v1/kubernetes/test-k8s/nodes/k8s-master-01" \ + -H "Authorization: Bearer *** | jq '.data | {cpu_alloc_pct, mem_alloc_pct, pod_alloc_pct, pod_count}' +# 期望: cpu_alloc_pct/mem_alloc_pct/pod_alloc_pct 均为数字 > 0 + +# U4: conditions + taints + pods 列表 +curl -s "http://127.0.0.1:8080/api/v1/kubernetes/test-k8s/nodes/k8s-master-01" \ + -H "Authorization: Bearer *** | jq '.data | {conditions: (.conditions|length), taints: (.taints|length), pods: (.pods|length)}' +# 期望: conditions>=4, pods>=1 + +# U5: SPA fallback — node detail 路由 200 +curl -s -o /dev/null -w "%{http_code}" \ + "http://127.0.0.1/kubernetes/test-k8s/nodes/k8s-master-01" +# 期望: 200 + +# U6: TypeScript + Vite 构建 +cd frontend && npx tsc --noEmit && npm run build 2>&1 | tail -3 +# 期望: 零错误 + built in Ns +``` + +### 通过标准 +- [x] U1: 登录成功 +- [x] U2: Node detail API 返回 container_runtime/kubelet/os/kernel/architecture +- [x] U3: 资源分配率正确(cpu_alloc_pct/mem_alloc_pct/pod_alloc_pct 数字) +- [x] U4: conditions/taints/pods 列表非空 +- [x] U5: SPA 路由 200(非 301) +- [x] U6: TypeScript 零错误 + Vite 构建成功 +- [x] 前端节点名称可点击跳转详情页 + +--- + ## M9: 服务/应用路由 Tab + 整体联调 ### 验证用例 diff --git a/backend/config.external.yaml b/backend/config.external.yaml new file mode 100644 index 0000000..3e5ceb5 --- /dev/null +++ b/backend/config.external.yaml @@ -0,0 +1,29 @@ +# AIOps Backend Configuration (External MySQL/Redis) +mode: release +port: "8080" +log_level: info + +# MySQL 数据库 (连接 75 的 MySQL) +database: + driver: mysql + host: 10.10.10.75 + port: 3306 + user: aiops + password: aiops_pass_2026 + dbname: aiops + charset: utf8mb4 + max_conn: 100 + max_idle: 10 + +# Redis 缓存 (连接 75 的 Redis) +redis: + driver: redis + host: 10.10.10.75 + port: 6379 + password: "" + db: 0 + +# JWT 配置 +jwt: + secret: "aiops-jwt-secret-key-2026" + expire_hour: 24 diff --git a/backend/internal/api/handlers/kubernetes.go b/backend/internal/api/handlers/kubernetes.go index ca9e52e..bcea573 100644 --- a/backend/internal/api/handlers/kubernetes.go +++ b/backend/internal/api/handlers/kubernetes.go @@ -241,22 +241,27 @@ func (h *KubernetesHandler) GetClusterDetail(c *gin.Context) { Code: 200, Message: "success", Data: map[string]interface{}{ - "id": cluster.ID, - "name": cluster.Name, - "code": cluster.Code, - "version": cluster.Version, - "status": cluster.Status, - "config_type": cluster.ConfigType, - "api_server": cluster.APIServer, - "api_endpoint": cluster.APIEndpoint, - "description": cluster.Description, - "has_token": cluster.Token != "", + "id": cluster.ID, + "name": cluster.Name, + "code": cluster.Code, + "version": cluster.Version, + "status": cluster.Status, + "config_type": cluster.ConfigType, + "api_server": cluster.APIServer, + "api_endpoint": cluster.APIEndpoint, + "description": cluster.Description, + "has_token": cluster.Token != "", "has_kube_config": cluster.KubeConfig != "", - "node_count": cluster.NodeCount, - "pod_count": cluster.PodCount, - "labels": cluster.Labels, - "created_at": cluster.CreatedAt, - "updated_at": cluster.UpdatedAt, + "node_count": cluster.NodeCount, + "pod_count": cluster.PodCount, + "service_count": cluster.ServiceCount, + "deployment_count": cluster.DeploymentCount, + "cpu_usage": cluster.CPUUsage, + "memory_usage": cluster.MemoryUsage, + "labels": cluster.Labels, + "last_sync_at": cluster.LastSyncAt, + "created_at": cluster.CreatedAt, + "updated_at": cluster.UpdatedAt, }, }) } @@ -376,6 +381,286 @@ func (h *KubernetesHandler) ListNodes(c *gin.Context) { c.JSON(http.StatusOK, models.Response{Code: 200, Message: "success", Data: result}) } +// GetNodeDetail 获取节点详情(PID/容器运行时/分配率)// M10 +// @Summary 获取节点详情 +// @Description 获取指定集群中单个节点的详细信息,包含系统信息、资源分配、污点、条件等 +// @Tags K8s集群 +// @Produce json +// @Security Bearer +// @Router /kubernetes/{clusterCode}/nodes/{name} [get] +func (h *KubernetesHandler) GetNodeDetail(c *gin.Context) { + clusterID, _ := strconv.Atoi(c.Param("id")) + if clusterID == 0 { + code := c.Param("clusterCode") + if code == "" { + code = c.Param("id") + } + clusterID = h.resolveClusterCode(code) + } + if clusterID == 0 { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "cluster not found"}) + return + } + + nodeName := c.Param("name") + if nodeName == "" { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "node name required"}) + return + } + + client, _, err := h.getK8sClient(clusterID) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) + return + } + + // 1. Get node detail + node, err := client.GetNode(nodeName) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } + + // 2. Get node metrics (best-effort) + var cpuUsageM, memUsageMi string + if m, err := client.GetNodeMetrics(); err == nil { + for _, item := range m.Items { + if item.Metadata.Name == nodeName { + cpuUsageM = formatCpuFromNano(item.Usage["cpu"]) + memUsageMi = formatMemFromKi(item.Usage["memory"]) + break + } + } + } + + // 3. Get pods on this node for allocation calculation + pods, _ := client.ListPods() + podCount := 0 + type podBrief struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Status string `json:"status"` + IP string `json:"ip"` + CPURequestM int `json:"cpu_request_m"` + MemRequestMi int64 `json:"mem_request_mi"` + RestartCount int `json:"restart_count"` + } + var podList []podBrief + totalCPURequestM := 0 + totalMemRequestMi := int64(0) + for _, p := range pods.Items { + if p.Spec.NodeName != nodeName { + continue + } + podCount++ + podCPUReq := 0 + podMemReq := int64(0) + for _, ctr := range p.Spec.Containers { + if req := ctr.Resources.Requests; req != nil { + podCPUReq += parseCpuToMilli(req["cpu"]) + podMemReq += parseMemToMi(req["memory"]) + } + } + totalCPURequestM += podCPUReq + totalMemRequestMi += podMemReq + + status := "Unknown" + if p.Status.Phase != "" { + status = p.Status.Phase + } + restartCount := 0 + for _, cs := range p.Status.ContainerStatuses { + restartCount += cs.RestartCount + } + podList = append(podList, podBrief{ + Namespace: p.Metadata.Namespace, + Name: p.Metadata.Name, + Status: status, + IP: p.Status.PodIP, + CPURequestM: podCPUReq, + MemRequestMi: podMemReq, + RestartCount: restartCount, + }) + } + + // 4. Determine role + role := "worker" + for k := range node.Metadata.Labels { + if k == "node-role.kubernetes.io/master" || k == "node-role.kubernetes.io/control-plane" { + role = "master" + break + } + } + + // 5. Determine status + nodeStatus := "NotReady" + for _, cond := range node.Status.Conditions { + if cond.Type == "Ready" && cond.Status == "True" { + nodeStatus = "Ready" + break + } + } + + // 6. Get IP + internalIP := "" + externalIP := "" + hostname := "" + for _, addr := range node.Status.Addresses { + switch addr.Type { + case "InternalIP": + internalIP = addr.Address + case "ExternalIP": + externalIP = addr.Address + case "Hostname": + hostname = addr.Address + } + } + + // 7. Build conditions list + type condInfo struct { + Type string `json:"type"` + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + Message string `json:"message,omitempty"` + } + conditions := []condInfo{} + for _, cond := range node.Status.Conditions { + conditions = append(conditions, condInfo{ + Type: cond.Type, + Status: cond.Status, + Reason: cond.Reason, + Message: cond.Message, + }) + } + + // 8. Build taints list + type taintInfo struct { + Key string `json:"key"` + Value string `json:"value"` + Effect string `json:"effect"` + } + taints := []taintInfo{} + for _, t := range node.Spec.Taints { + taints = append(taints, taintInfo{ + Key: t.Key, + Value: t.Value, + Effect: t.Effect, + }) + } + + // 9. Calculate allocation rate + capCPU := parseIntResource(node.Status.Capacity["cpu"]) + allocCPU := parseIntResource(node.Status.Allocatable["cpu"]) + capMem := parseMemResource(node.Status.Capacity["memory"]) + allocMem := parseMemResource(node.Status.Allocatable["memory"]) + capPods := parseIntResource(node.Status.Capacity["pods"]) + allocPods := parseIntResource(node.Status.Allocatable["pods"]) + + cpuReqCores := float64(totalCPURequestM) / 1000.0 + cpuAllocPct := 0.0 + if allocCPU > 0 { + cpuAllocPct = (cpuReqCores / float64(allocCPU)) * 100 + } + memAllocPct := 0.0 + if allocMem > 0 { + memAllocPct = (float64(totalMemRequestMi) / float64(allocMem)) * 100 + } + podAllocPct := 0.0 + if allocPods > 0 { + podAllocPct = (float64(podCount) / float64(allocPods)) * 100 + } + + // 10. Build labels list + type labelEntry struct { + Key string `json:"key"` + Value string `json:"value"` + } + labels := []labelEntry{} + for k, v := range node.Metadata.Labels { + labels = append(labels, labelEntry{Key: k, Value: v}) + } + + detail := map[string]interface{}{ + "name": node.Metadata.Name, + "role": role, + "status": nodeStatus, + "creation_time": node.Metadata.CreationTimestamp, + "internal_ip": internalIP, + "external_ip": externalIP, + "hostname": hostname, + "pod_cidr": node.Spec.PodCIDR, + "container_runtime": node.Status.NodeInfo.ContainerRuntimeVersion, + "kubelet_version": node.Status.NodeInfo.KubeletVersion, + "os_image": node.Status.NodeInfo.OSImage, + "kernel_version": node.Status.NodeInfo.KernelVersion, + "architecture": node.Status.NodeInfo.Architecture, + "operating_system": node.Status.NodeInfo.OperatingSystem, + // Capacity & Allocatable + "capacity": node.Status.Capacity, + "allocatable": node.Status.Allocatable, + // Metrics + "cpu_usage_m": cpuUsageM, + "mem_usage_mi": memUsageMi, + // Allocation + "pod_count": podCount, + "cpu_request_m": totalCPURequestM, + "mem_request_mi": totalMemRequestMi, + "cpu_alloc_pct": cpuAllocPct, + "mem_alloc_pct": memAllocPct, + "pod_alloc_pct": podAllocPct, + "cpu_capacity": capCPU, + "cpu_allocatable": allocCPU, + "mem_capacity_mb": capMem, + "mem_allocatable_mb": allocMem, + "pod_capacity": capPods, + "pod_allocatable": allocPods, + // Lists + "conditions": conditions, + "taints": taints, + "labels": labels, + "pods": podList, + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "success", Data: detail}) +} + +// parseCpuToMilli converts CPU resource string to millicores (e.g. "100m"→100, "2"→2000) +func parseCpuToMilli(val string) int { + if val == "" { + return 0 + } + if strings.HasSuffix(val, "m") { + var i int + fmt.Sscanf(strings.TrimSuffix(val, "m"), "%d", &i) + return i + } + var i int + fmt.Sscanf(val, "%d", &i) + return i * 1000 +} + +// parseMemToMi converts memory resource string to Mi (e.g. "256Mi"→256, "1Gi"→1024, "512Ki"→0) +func parseMemToMi(val string) int64 { + if val == "" { + return 0 + } + var num float64 + var unit string + fmt.Sscanf(val, "%f%s", &num, &unit) + switch unit { + case "Ki": + return int64(num / 1024) + case "Mi": + return int64(num) + case "Gi": + return int64(num * 1024) + case "Ti": + return int64(num * 1024 * 1024) + default: + return int64(num / 1024 / 1024) + } +} + // ListPods 获取Pod列表 // @Summary 获取Pod列表 // @Description 获取指定集群的Pod列表 @@ -936,7 +1221,6 @@ func (h *KubernetesHandler) GetPodLogs(c *gin.Context) { // 3 = error (server→client, JSON) // 4 = resize (client→server, JSON {"Height","Width"}) func (h *KubernetesHandler) ExecTerminal(c *gin.Context) { - clusterID, _ := strconv.Atoi(c.Param("id")) namespace := c.Param("namespace") podName := c.Param("podName") container := c.Query("container") @@ -945,7 +1229,7 @@ func (h *KubernetesHandler) ExecTerminal(c *gin.Context) { command = "bash" } - client, cluster, err := h.getK8sClient(clusterID) + client, cluster, err := h.getK8sClientByCode(c) if err != nil { c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) return @@ -1526,7 +1810,12 @@ func (h *KubernetesHandler) resolveClusterCode(code string) int { func (h *KubernetesHandler) getK8sClientByCode(c *gin.Context) (*k8sclient.Client, *models.KubernetesCluster, error) { clusterID, _ := strconv.Atoi(c.Param("id")) if clusterID == 0 { - clusterID = h.resolveClusterCode(c.Param("clusterCode")) + // 支持 :clusterCode 或 :id 参数 + code := c.Param("clusterCode") + if code == "" { + code = c.Param("id") + } + clusterID = h.resolveClusterCode(code) } if clusterID == 0 { return nil, nil, fmt.Errorf("cluster not found") diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 7227782..f7f18f7 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -284,6 +284,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c clusters.DELETE("/:id", k8sHandler.DeleteCluster) clusters.POST("/:id/sync", k8sHandler.SyncCluster) // 同步集群资源 // Logan.Li clusters.GET("/:id/nodes", k8sHandler.ListNodes) + clusters.GET("/:id/nodes/:name", k8sHandler.GetNodeDetail) // M10 节点详情 // Logan.Li clusters.GET("/:id/pods", k8sHandler.ListPods) clusters.GET("/:id/deployments", k8sHandler.ListDeployments) clusters.GET("/:id/services", k8sHandler.ListServices) @@ -336,6 +337,9 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c k8sHandler.ListDeploymentsNS(c) }) + // Node detail (cluster-scoped, not namespace-scoped) // M10 + kubernetesRouter.GET("/:clusterCode/nodes/:name", k8sHandler.GetNodeDetail) + // Deployments kubernetesRouter.GET("/:clusterCode/namespace/:namespace/deployments", k8sHandler.ListDeploymentsNS) kubernetesRouter.POST("/:clusterCode/namespace/:namespace/deployments/:name/scale", k8sHandler.ScaleDeploymentNS) diff --git a/backend/pkg/kubernetesclient/client.go b/backend/pkg/kubernetesclient/client.go index 33a438b..c8a2f6c 100644 --- a/backend/pkg/kubernetesclient/client.go +++ b/backend/pkg/kubernetesclient/client.go @@ -209,7 +209,15 @@ type NodeItem struct { } type NodeSpec struct { - PodCIDR string `json:"podCIDR"` + PodCIDR string `json:"podCIDR"` + Taints []NodeTaint `json:"taints,omitempty"` +} + +type NodeTaint struct { + Key string `json:"key"` + Value string `json:"value"` + Effect string `json:"effect"` + TimeAdded string `json:"timeAdded,omitempty"` } type NodeStatus struct { @@ -223,15 +231,19 @@ type NodeStatus struct { type ResourceList map[string]string type NodeCondition struct { - Type string `json:"type"` - Status string `json:"status"` + Type string `json:"type"` + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + Message string `json:"message,omitempty"` } type NodeInfo struct { - KubeletVersion string `json:"kubeletVersion"` - OSImage string `json:"osImage"` - KernelVersion string `json:"kernelVersion"` + KubeletVersion string `json:"kubeletVersion"` + OSImage string `json:"osImage"` + KernelVersion string `json:"kernelVersion"` ContainerRuntimeVersion string `json:"containerRuntimeVersion"` + Architecture string `json:"architecture"` + OperatingSystem string `json:"operatingSystem"` } type NodeAddress struct { @@ -421,6 +433,15 @@ func (c *Client) ListNodes() (*NodeList, error) { return &result, nil } +// GetNode fetches a single node by name +func (c *Client) GetNode(name string) (*NodeItem, error) { + var result NodeItem + if err := c.get("/api/v1/nodes/"+name, &result); err != nil { + return nil, err + } + return &result, nil +} + // ListPods fetches all pods across namespaces func (c *Client) ListPods() (*PodList, error) { var result PodList @@ -672,7 +693,7 @@ func (c *Client) ExecInPod(namespace, podName, container string, command []strin dialer := websocket.Dialer{ TLSClientConfig: c.TLSConfig, - Subprotocols: []string{"channel.k8s.io"}, + Subprotocols: []string{"v4.channel.k8s.io", "v3.channel.k8s.io", "channel.k8s.io"}, HandshakeTimeout: 10 * time.Second, } header := http.Header{} diff --git a/docker-compose.k8s.yml b/docker-compose.k8s.yml new file mode 100644 index 0000000..c1c4391 --- /dev/null +++ b/docker-compose.k8s.yml @@ -0,0 +1,47 @@ +services: + # ======================== Go 后端服务 (连接外部 MySQL/Redis) ======================== + backend: + build: + context: . + dockerfile: backend/Dockerfile + args: + VERSION: ${VERSION:-dev} + BUILD_TIME: ${BUILD_TIME:-unknown} + container_name: aiops-backend + hostname: aiops-backend + restart: unless-stopped + ports: + - "8080:8080" + volumes: + - ./backend/config.external.yaml:/app/config.yaml + - /var/run/docker.sock:/var/run/docker.sock + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + extra_hosts: + - "host.docker.internal:host-gateway" + networks: + - aiops-network + + # ======================== React 前端 + Nginx ======================== + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: aiops-frontend + hostname: aiops-frontend + restart: unless-stopped + ports: + - "80:80" + depends_on: + backend: + condition: service_healthy + networks: + - aiops-network + +networks: + aiops-network: + driver: bridge diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cc07ef1..18d90dd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ import { PlatformManager } from '@/components/cloud/PlatformManager'; import { CloudBilling } from '@/components/cloud/CloudBilling'; import { KubernetesClusters } from '@/components/kubernetes/KubernetesClusters'; import ClusterOverviewPage from '@/components/kubernetes/ClusterOverviewPage'; +import NodeDetailPage from '@/components/kubernetes/NodeDetailPage'; import NamespaceOverviewPage from '@/components/kubernetes/NamespaceOverviewPage'; import NamespaceResourcePage from '@/components/kubernetes/NamespaceResourcePage'; import DeploymentDetailPage from '@/components/kubernetes/DeploymentDetailPage'; @@ -88,6 +89,8 @@ function App() { } /> } /> } /> + {/* 节点详情 */} + } /> {/* 架构拓扑 */} } /> {/* 命名空间作用域 */} diff --git a/frontend/src/components/dashboard/DashboardRecent.tsx b/frontend/src/components/dashboard/DashboardRecent.tsx index fd7fe97..cedeee4 100644 --- a/frontend/src/components/dashboard/DashboardRecent.tsx +++ b/frontend/src/components/dashboard/DashboardRecent.tsx @@ -338,11 +338,11 @@ export function KubernetesClusterCard({ clusters }: { clusters: any[] }) {
-
{cluster.node_count ?? cluster.nodes ?? 0}
+
{cluster.node_count ?? 0}
节点
-
{cluster.pod_count ?? cluster.pods ?? 0}
+
{cluster.pod_count ?? 0}
Pod
diff --git a/frontend/src/components/kubernetes/ClusterOverviewPage.tsx b/frontend/src/components/kubernetes/ClusterOverviewPage.tsx index a49c77b..491dbdc 100644 --- a/frontend/src/components/kubernetes/ClusterOverviewPage.tsx +++ b/frontend/src/components/kubernetes/ClusterOverviewPage.tsx @@ -158,8 +158,9 @@ export default function ClusterOverviewPage() { const memPct = memMb > 0 ? (memUsageNorm / memMb) * 100 : 0; const color = (pct: number) => pct > 80 ? 'bg-red-500' : pct > 60 ? 'bg-yellow-500' : 'bg-emerald-500'; return ( - - {n.name} + navigate(`/kubernetes/${clusterCode}/nodes/${n.name}`)}> + {n.name} {n.role || 'worker'} {n.status || 'Unknown'} {n.ip || '-'} diff --git a/frontend/src/components/kubernetes/KubernetesClusterDetail.tsx b/frontend/src/components/kubernetes/KubernetesClusterDetail.tsx index c494f3c..e224e99 100644 --- a/frontend/src/components/kubernetes/KubernetesClusterDetail.tsx +++ b/frontend/src/components/kubernetes/KubernetesClusterDetail.tsx @@ -30,15 +30,14 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster: any; on if (!nodes || nodes.length === 0) return { cpuPct: 0, memPct: 0 }; let totalCpuPct = 0, totalMemPct = 0, validNodes = 0; for (const n of nodes) { - // ClusterOverviewPage 读的是 cpu_usage_m / mem_usage_mi(实时路径) - // ClusterDetail 的 nodes 从 getNodes API 获取,也有这些字段 + // 后端 ListNodes 返回 cpu_usage_m (如 "122m"), mem_usage_mi (如 "2105Mi"), cpu (核数), memory_mb (MB) const cpuCores = n.cpu || 0; const cpuUsageM = n.cpu_usage_m ? parseFloat(String(n.cpu_usage_m).replace('m', '')) : 0; - const cpuPct = cpuCores > 0 ? (cpuUsageM / 1000 / cpuCores) * 100 : (n.cpu_usage || 0); - const memMb = n.memory_mb || n.memory || 0; + const cpuPct = cpuCores > 0 ? (cpuUsageM / 1000 / cpuCores) * 100 : 0; + const memMb = n.memory_mb || 0; const memUsageMi = n.mem_usage_mi ? parseFloat(String(n.mem_usage_mi).replace('Mi', '').replace('Ki', '')) : 0; const memUsageNorm = n.mem_usage_mi?.includes?.('Ki') ? memUsageMi / 1024 : n.mem_usage_mi?.includes?.('Gi') ? memUsageMi * 1024 : memUsageMi; - const memPct = memMb > 0 ? (memUsageNorm / memMb) * 100 : (n.memory_usage || 0); + const memPct = memMb > 0 ? (memUsageNorm / memMb) * 100 : 0; if (cpuPct > 0 || memPct > 0) { totalCpuPct += cpuPct; totalMemPct += memPct; @@ -264,12 +263,12 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster: any; on - {Number(node.cpu_usage || 0).toFixed(1)}% / {node.cpu}核 + {node.cpu_usage_m || '0m'} / {node.cpu}核 - {Number(node.memory_usage || 0).toFixed(1)}% / {node.memory}GB + {node.mem_usage_mi || '0Mi'} / {node.memory_mb ? (node.memory_mb / 1024).toFixed(0) + 'GB' : '-'} - {node.pods ?? node.pod_count ?? 0} + {node.max_pods ?? 0} {node.os} )) @@ -322,8 +321,8 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster: any; on {pod.restart_count ?? 0} - {Number(pod.cpu_usage || 0).toFixed(1)}m - {Number(pod.memory_usage || 0).toFixed(0)}Mi + {pod.cpu_usage_m || '0m'} + {pod.mem_usage_mi || '0Mi'} {pod.node_name} )) diff --git a/frontend/src/components/kubernetes/NodeDetailPage.tsx b/frontend/src/components/kubernetes/NodeDetailPage.tsx new file mode 100644 index 0000000..ad80ba1 --- /dev/null +++ b/frontend/src/components/kubernetes/NodeDetailPage.tsx @@ -0,0 +1,383 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useParams, 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 { + RefreshCw, Loader2, Cpu, Server, Clock, HardDrive, Activity, + ChevronRight, Container, Network, Info, AlertTriangle, CheckCircle2, +} from 'lucide-react'; +import { kubernetesApi } from '@/services/api'; + +export default function NodeDetailPage() { + const { clusterCode, nodeName } = useParams<{ clusterCode: string; nodeName: string }>(); + const navigate = useNavigate(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + + const loadData = useCallback(async () => { + if (!clusterCode || !nodeName) return; + setLoading(true); + try { + const res: any = await kubernetesApi.getNodeDetail(clusterCode, nodeName); + setData(res); + } catch { setData(null); } finally { setLoading(false); } + }, [clusterCode, nodeName]); + + useEffect(() => { loadData(); }, [loadData]); + + const formatNum = (v: any, suffix = '') => { + if (v === undefined || v === null || v === '') return '-'; + const n = typeof v === 'number' ? v : parseFloat(v); + return isNaN(n) ? '-' : `${n.toLocaleString()}${suffix}`; + }; + + // Resource bar component + const ResourceBar = ({ label, used, total, unit, pct, color }: { label: string; used: string; total: string; unit: string; pct: number; color: string }) => { + const safePct = Math.min(Math.max(pct || 0, 0), 100); + const barColor = safePct > 80 ? 'bg-red-500' : safePct > 60 ? 'bg-amber-500' : 'bg-emerald-500'; + return ( +
+
+ {label} + + {used} / {total} {unit} + +
+
+
+
+
+ + {safePct.toFixed(0)}% + +
+
+ ); + }; + + const conditionColor = (type: string, status: string) => { + if (status === 'True') { + if (type === 'Ready') return 'bg-emerald-500/20 text-emerald-400'; + // Other True conditions (DiskPressure, MemoryPressure, PIDPressure, NetworkUnavailable) are BAD + return 'bg-red-500/20 text-red-400'; + } + return 'bg-slate-500/20 text-slate-400'; + }; + + const taintColor = (effect: string) => { + if (effect === 'NoSchedule') return 'bg-red-500/20 text-red-400 border-red-500/30'; + if (effect === 'NoExecute') return 'bg-red-600/20 text-red-300 border-red-600/30'; + if (effect === 'PreferNoSchedule') return 'bg-amber-500/20 text-amber-400 border-amber-500/30'; + return 'bg-slate-500/20 text-slate-400 border-slate-500/30'; + }; + + return ( +
+ {/* Breadcrumb */} +
+ 集群 + + {clusterCode} + + {nodeName} +
+ + {/* Header */} +
+
+
+ +
+
+
+

{nodeName}

+ + {data?.role || 'worker'} + + + {data?.status || 'Unknown'} + +
+

+ {data?.internal_ip && `${data.internal_ip}`} + {data?.hostname && ` · ${data.hostname}`} +

+
+
+ +
+ + {loading && !data ? ( + + + + + + ) : !data ? ( + + + + 无法加载节点详情 + + + ) : ( + <> + {/* Row 1: System Info + Basic Info */} +
+ {/* System Info */} + + +

+ 系统信息 +

+
+
+ 容器运行时 + {data.container_runtime || '-'} +
+
+ Kubelet + {data.kubelet_version || '-'} +
+
+ 操作系统 + {data.os_image || '-'} +
+
+ 内核版本 + {data.kernel_version || '-'} +
+
+ 架构 + {data.architecture || '-'} +
+
+ 创建时间 + {data.creation_time ? new Date(data.creation_time).toLocaleString('zh-CN') : '-'} +
+
+ Pod CIDR + {data.pod_cidr || '-'} +
+
+
+
+ + {/* Resource Allocation */} + + +

+ 资源分配率 +

+
+ {/* CPU allocation (requested vs allocatable) */} + + {/* CPU real usage (from metrics) */} + {data.cpu_usage_m && ( + + )} + {/* Memory allocation */} + + {/* Memory real usage */} + {data.mem_usage_mi && ( + + )} + {/* Pod allocation */} + +
+ {/* Capacity vs Allocatable raw */} +
+
+ CPU 容量 / 可分配 + {data.cpu_capacity} / {data.cpu_allocatable} 核 +
+
+ 内存容量 / 可分配 + {data.mem_capacity_mb} / {data.mem_allocatable_mb} Mi +
+
+ Pod 容量 / 可分配 + {data.pod_capacity} / {data.pod_allocatable} +
+
+
+
+
+ + {/* Row 2: Conditions + Taints */} +
+ {/* Conditions */} + + +

+ 节点条件 +

+ {(!data.conditions || data.conditions.length === 0) ? ( +

无条件数据

+ ) : ( +
+ {data.conditions.map((cond: any, i: number) => ( +
+ + {cond.status === 'True' ? : null} + {cond.type} + +
+ {cond.reason && {cond.reason}} + {cond.message && {cond.message}} +
+
+ ))} +
+ )} +
+
+ + {/* Taints */} + + +

+ 污点 (Taints) +

+ {(!data.taints || data.taints.length === 0) ? ( +

无污点

+ ) : ( +
+ {data.taints.map((t: any, i: number) => ( +
+ {t.effect} + {t.key} + {t.value && = {t.value}} +
+ ))} +
+ )} +
+
+
+ + {/* Row 3: Labels */} + {data.labels && data.labels.length > 0 && ( + + +

+ 标签 ({data.labels.length}) +

+
+ {data.labels.map((l: any, i: number) => ( +
+ {l.key} + : + {l.value} +
+ ))} +
+
+
+ )} + + {/* Row 4: Pods on this node */} + + +
+

+ + 节点上的 Pod + {data.pods?.length || 0} +

+
+ {(!data.pods || data.pods.length === 0) ? ( +

该节点上暂无 Pod

+ ) : ( + + + + 命名空间 + 名称 + 状态 + IP + CPU 请求 + 内存请求 + 重启次数 + + + + {data.pods.map((p: any, i: number) => ( + + {p.namespace} + {p.name} + + {p.status} + + {p.ip || '-'} + + {p.cpu_request_m > 0 ? `${p.cpu_request_m}m` : '-'} + + + {p.mem_request_mi > 0 ? `${p.mem_request_mi}Mi` : '-'} + + + {p.restart_count > 0 ? ( + {p.restart_count} + ) : ( + 0 + )} + + + ))} + +
+ )} +
+
+ + )} +
+ ); +} diff --git a/frontend/src/components/kubernetes/PodFileBrowser.tsx b/frontend/src/components/kubernetes/PodFileBrowser.tsx index 6a19d9b..419e691 100644 --- a/frontend/src/components/kubernetes/PodFileBrowser.tsx +++ b/frontend/src/components/kubernetes/PodFileBrowser.tsx @@ -45,7 +45,14 @@ export function PodFileBrowser({ setPreview(null); try { const res: any = await kubernetesApi.listPodFiles(clusterCode, namespace, podName, path, container || undefined); - const list: FileEntry[] = res?.entries || res?.data?.entries || (Array.isArray(res) ? res : []); + // 兼容后端 {files: [...]} 和 {entries: [...]} 两种返回格式 + const raw: any[] = res?.entries || res?.files || res?.data?.entries || res?.data?.files || (Array.isArray(res) ? res : []); + const list: FileEntry[] = raw.map((it: any) => ({ + name: it.name ?? it.Name ?? '', + isDir: Boolean(it.isDir ?? it.is_dir ?? it.IsDir ?? it.dir ?? it.directory), + size: typeof it.size === 'number' ? it.size : (it.Size ? Number(it.Size) : undefined), + modTime: it.modTime ?? it.mod_time ?? it.ModTime ?? it.mtime ?? it.modified, + })).filter(e => e.name && e.name !== '.'); setEntries(list); setCurrentPath(path); } catch (e: any) { diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 7b2d97a..2024279 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -175,6 +175,9 @@ export const kubernetesApi = { getNodes: async (clusterId: number | string) => { return http.get(`/kubernetes-clusters/${clusterId}/nodes`); }, + getNodeDetail: async (clusterCode: string, nodeName: string) => { + return http.get(`/kubernetes/${clusterCode}/nodes/${encodeURIComponent(nodeName)}`); + }, getPods: async (clusterId: number | string) => { return http.get(`/kubernetes-clusters/${clusterId}/pods`); }, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 3fd5620..179662c 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -17,21 +17,27 @@ export interface Server { diskUsage: number; } -// Kubernetes集群 +// Kubernetes集群 — 字段与后端 kubernetes_clusters 表 + JSON tag 一致(snake_case) export interface KubernetesCluster { - id: string; + id: number; name: string; - code?: string; // 唯一标识,用于 Kuboard 路由 // Logan.Li + code: string; // 唯一标识,必填 // Logan.Li version: string; - nodes: number; - pods: number; - services: number; - deployments: number; status: 'healthy' | 'warning' | 'error'; - cpuUsage: number; - memoryUsage: number; - createdAt: string; - namespace: string; + config_type: number; // 0=token, 1=kubeconfig + api_server: string; + api_endpoint: string; + node_count: number; + pod_count: number; + service_count: number; + deployment_count: number; + cpu_usage: number; + memory_usage: number; + description: string; + labels: string; // JSON string + last_sync_at: string | null; + created_at: string; + updated_at: string; } // K8s节点