feat(k8s): M10 Node 详情页 — PID/容器运行时/分配率

- 后端: GetNodeDetail handler — 获取单个节点完整信息
  * 系统信息: container_runtime/architecture/kubelet/os/kernel
  * 资源分配率: 遍历 Pod 计算 CPU/Memory/Pods requested vs allocatable
  * metrics-server 数据注入 (cpu_usage_m/mem_usage_mi)
  * 全量 conditions/taints/labels + 节点上的 Pod 列表
- 后端: K8s 客户端扩展
  * NodeTaint/NodeCondition 扩展字段 (reason/message)
  * NodeInfo 增加 architecture/operatingSystem
  * GetNode(name) 单节点查询方法
  * parseCpuToMilli()/parseMemToMi() 资源解析辅助函数
- 后端: 注册路由
  * GET /kubernetes/:clusterCode/nodes/:name (Kuboard code)
  * GET /kubernetes-clusters/:id/nodes/:name (ID)
- 前端: NodeDetailPage.tsx (~330行)
  * 系统信息卡片 + 资源分配率卡片(进度条)
  * 条件卡片 + 污点卡片 + 标签卡片 + Pod 列表
- 前端: 节点名称可点击跳转 (ClusterOverviewPage)
- 验证: API/SPA/TS/Vite 全部通过
This commit is contained in:
attacker committed 2026-06-23 17:57:09 +08:00
1 parent 28dfc519e1
commit 87b7755094
16 files changed
+921 -52

No files matched your search

+2
View File
@@ -37,6 +37,7 @@
| 容量分析 | ✅ GetCapacityAnalysis | ✅ CapacityPage | ✅ | | 容量分析 | ✅ GetCapacityAnalysis | ✅ CapacityPage | ✅ |
| kubectl 终端 | ✅ KubectlShell(WS) | ✅ KubectlTerminal | ✅ | | kubectl 终端 | ✅ KubectlShell(WS) | ✅ KubectlTerminal | ✅ |
| 拓扑图 | ✅ topology get/save | ✅ TopologyView | ✅ | | 拓扑图 | ✅ topology get/save | ✅ TopologyView | ✅ |
| **Node 详情页** | ✅ GetNodeDetail (M10) | ✅ NodeDetailPage | ✅ curl 验证 |
### ❌ 缺失 / 进行中 ### ❌ 缺失 / 进行中
@@ -82,3 +83,4 @@
2. **metrics-server 已安装** — `kubectl top nodes` 正常返回 2. **metrics-server 已安装** — `kubectl top nodes` 正常返回
3. **自研 K8s 客户端** — 不用 client-go,保持轻量 net/http 方案 3. **自研 K8s 客户端** — 不用 client-go,保持轻量 net/http 方案
4. **两个数据路径**:ListNodes 实时调 API(含 metrics),SyncCluster 写 DB(缺 metrics)→ 前端不同页面读不同路径 4. **两个数据路径**:ListNodes 实时调 API(含 metrics),SyncCluster 写 DB(缺 metrics)→ 前端不同页面读不同路径
5. **Node 详情页** (M10 ✅) — `GetNodeDetail` handler 返回完整节点信息:container_runtime/architecture/taints/conditions/资源分配率/Pod列表,前端 `NodeDetailPage.tsx` 可视化展示
+30 -1
View File
@@ -183,9 +183,38 @@
## 后续规划(P2+) ## 后续规划(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) | | M11 | StatefulSet/DaemonSet 详情页(对标 Deployment) |
| M12 | 事件聚合 + 告警联动 | | M12 | 事件聚合 + 告警联动 |
| M13 | 多集群资源对比看板 | | M13 | 多集群资源对比看板 |
+46
View File
@@ -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 + 整体联调 ## M9: 服务/应用路由 Tab + 整体联调
### 验证用例 ### 验证用例
+29
View File
@@ -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
+307 -18
View File
@@ -241,22 +241,27 @@ func (h *KubernetesHandler) GetClusterDetail(c *gin.Context) {
Code: 200, Code: 200,
Message: "success", Message: "success",
Data: map[string]interface{}{ Data: map[string]interface{}{
"id": cluster.ID, "id": cluster.ID,
"name": cluster.Name, "name": cluster.Name,
"code": cluster.Code, "code": cluster.Code,
"version": cluster.Version, "version": cluster.Version,
"status": cluster.Status, "status": cluster.Status,
"config_type": cluster.ConfigType, "config_type": cluster.ConfigType,
"api_server": cluster.APIServer, "api_server": cluster.APIServer,
"api_endpoint": cluster.APIEndpoint, "api_endpoint": cluster.APIEndpoint,
"description": cluster.Description, "description": cluster.Description,
"has_token": cluster.Token != "", "has_token": cluster.Token != "",
"has_kube_config": cluster.KubeConfig != "", "has_kube_config": cluster.KubeConfig != "",
"node_count": cluster.NodeCount, "node_count": cluster.NodeCount,
"pod_count": cluster.PodCount, "pod_count": cluster.PodCount,
"labels": cluster.Labels, "service_count": cluster.ServiceCount,
"created_at": cluster.CreatedAt, "deployment_count": cluster.DeploymentCount,
"updated_at": cluster.UpdatedAt, "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}) 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列表 // ListPods 获取Pod列表
// @Summary 获取Pod列表 // @Summary 获取Pod列表
// @Description 获取指定集群的Pod列表 // @Description 获取指定集群的Pod列表
@@ -936,7 +1221,6 @@ func (h *KubernetesHandler) GetPodLogs(c *gin.Context) {
// 3 = error (server→client, JSON) // 3 = error (server→client, JSON)
// 4 = resize (client→server, JSON {"Height","Width"}) // 4 = resize (client→server, JSON {"Height","Width"})
func (h *KubernetesHandler) ExecTerminal(c *gin.Context) { func (h *KubernetesHandler) ExecTerminal(c *gin.Context) {
clusterID, _ := strconv.Atoi(c.Param("id"))
namespace := c.Param("namespace") namespace := c.Param("namespace")
podName := c.Param("podName") podName := c.Param("podName")
container := c.Query("container") container := c.Query("container")
@@ -945,7 +1229,7 @@ func (h *KubernetesHandler) ExecTerminal(c *gin.Context) {
command = "bash" command = "bash"
} }
client, cluster, err := h.getK8sClient(clusterID) client, cluster, err := h.getK8sClientByCode(c)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()})
return return
@@ -1526,7 +1810,12 @@ func (h *KubernetesHandler) resolveClusterCode(code string) int {
func (h *KubernetesHandler) getK8sClientByCode(c *gin.Context) (*k8sclient.Client, *models.KubernetesCluster, error) { func (h *KubernetesHandler) getK8sClientByCode(c *gin.Context) (*k8sclient.Client, *models.KubernetesCluster, error) {
clusterID, _ := strconv.Atoi(c.Param("id")) clusterID, _ := strconv.Atoi(c.Param("id"))
if clusterID == 0 { 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 { if clusterID == 0 {
return nil, nil, fmt.Errorf("cluster not found") return nil, nil, fmt.Errorf("cluster not found")
+4
View File
@@ -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.DELETE("/:id", k8sHandler.DeleteCluster)
clusters.POST("/:id/sync", k8sHandler.SyncCluster) // 同步集群资源 // Logan.Li clusters.POST("/:id/sync", k8sHandler.SyncCluster) // 同步集群资源 // Logan.Li
clusters.GET("/:id/nodes", k8sHandler.ListNodes) 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/pods", k8sHandler.ListPods)
clusters.GET("/:id/deployments", k8sHandler.ListDeployments) clusters.GET("/:id/deployments", k8sHandler.ListDeployments)
clusters.GET("/:id/services", k8sHandler.ListServices) 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) k8sHandler.ListDeploymentsNS(c)
}) })
// Node detail (cluster-scoped, not namespace-scoped) // M10
kubernetesRouter.GET("/:clusterCode/nodes/:name", k8sHandler.GetNodeDetail)
// Deployments // Deployments
kubernetesRouter.GET("/:clusterCode/namespace/:namespace/deployments", k8sHandler.ListDeploymentsNS) kubernetesRouter.GET("/:clusterCode/namespace/:namespace/deployments", k8sHandler.ListDeploymentsNS)
kubernetesRouter.POST("/:clusterCode/namespace/:namespace/deployments/:name/scale", k8sHandler.ScaleDeploymentNS) kubernetesRouter.POST("/:clusterCode/namespace/:namespace/deployments/:name/scale", k8sHandler.ScaleDeploymentNS)
+28 -7
View File
@@ -209,7 +209,15 @@ type NodeItem struct {
} }
type NodeSpec 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 { type NodeStatus struct {
@@ -223,15 +231,19 @@ type NodeStatus struct {
type ResourceList map[string]string type ResourceList map[string]string
type NodeCondition struct { type NodeCondition struct {
Type string `json:"type"` Type string `json:"type"`
Status string `json:"status"` Status string `json:"status"`
Reason string `json:"reason,omitempty"`
Message string `json:"message,omitempty"`
} }
type NodeInfo struct { type NodeInfo struct {
KubeletVersion string `json:"kubeletVersion"` KubeletVersion string `json:"kubeletVersion"`
OSImage string `json:"osImage"` OSImage string `json:"osImage"`
KernelVersion string `json:"kernelVersion"` KernelVersion string `json:"kernelVersion"`
ContainerRuntimeVersion string `json:"containerRuntimeVersion"` ContainerRuntimeVersion string `json:"containerRuntimeVersion"`
Architecture string `json:"architecture"`
OperatingSystem string `json:"operatingSystem"`
} }
type NodeAddress struct { type NodeAddress struct {
@@ -421,6 +433,15 @@ func (c *Client) ListNodes() (*NodeList, error) {
return &result, nil 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 // ListPods fetches all pods across namespaces
func (c *Client) ListPods() (*PodList, error) { func (c *Client) ListPods() (*PodList, error) {
var result PodList var result PodList
@@ -672,7 +693,7 @@ func (c *Client) ExecInPod(namespace, podName, container string, command []strin
dialer := websocket.Dialer{ dialer := websocket.Dialer{
TLSClientConfig: c.TLSConfig, 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, HandshakeTimeout: 10 * time.Second,
} }
header := http.Header{} header := http.Header{}
+47
View File
@@ -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
+3
View File
@@ -14,6 +14,7 @@ import { PlatformManager } from '@/components/cloud/PlatformManager';
import { CloudBilling } from '@/components/cloud/CloudBilling'; import { CloudBilling } from '@/components/cloud/CloudBilling';
import { KubernetesClusters } from '@/components/kubernetes/KubernetesClusters'; import { KubernetesClusters } from '@/components/kubernetes/KubernetesClusters';
import ClusterOverviewPage from '@/components/kubernetes/ClusterOverviewPage'; import ClusterOverviewPage from '@/components/kubernetes/ClusterOverviewPage';
import NodeDetailPage from '@/components/kubernetes/NodeDetailPage';
import NamespaceOverviewPage from '@/components/kubernetes/NamespaceOverviewPage'; import NamespaceOverviewPage from '@/components/kubernetes/NamespaceOverviewPage';
import NamespaceResourcePage from '@/components/kubernetes/NamespaceResourcePage'; import NamespaceResourcePage from '@/components/kubernetes/NamespaceResourcePage';
import DeploymentDetailPage from '@/components/kubernetes/DeploymentDetailPage'; import DeploymentDetailPage from '@/components/kubernetes/DeploymentDetailPage';
@@ -88,6 +89,8 @@ function App() {
<Route path="metrics/pods" element={<PodMetricsPage />} /> <Route path="metrics/pods" element={<PodMetricsPage />} />
<Route path="events" element={<EventsPage />} /> <Route path="events" element={<EventsPage />} />
<Route path="capacity" element={<CapacityPage />} /> <Route path="capacity" element={<CapacityPage />} />
{/* 节点详情 */}
<Route path="nodes/:nodeName" element={<NodeDetailPage />} />
{/* 架构拓扑 */} {/* 架构拓扑 */}
<Route path="topology" element={<TopologyView />} /> <Route path="topology" element={<TopologyView />} />
{/* 命名空间作用域 */} {/* 命名空间作用域 */}
@@ -338,11 +338,11 @@ export function KubernetesClusterCard({ clusters }: { clusters: any[] }) {
</div> </div>
<div className="grid grid-cols-4 gap-2 text-center"> <div className="grid grid-cols-4 gap-2 text-center">
<div> <div>
<div className="text-sm font-medium text-slate-200">{cluster.node_count ?? cluster.nodes ?? 0}</div> <div className="text-sm font-medium text-slate-200">{cluster.node_count ?? 0}</div>
<div className="text-xs text-slate-500">节点</div> <div className="text-xs text-slate-500">节点</div>
</div> </div>
<div> <div>
<div className="text-sm font-medium text-slate-200">{cluster.pod_count ?? cluster.pods ?? 0}</div> <div className="text-sm font-medium text-slate-200">{cluster.pod_count ?? 0}</div>
<div className="text-xs text-slate-500">Pod</div> <div className="text-xs text-slate-500">Pod</div>
</div> </div>
<div> <div>
@@ -158,8 +158,9 @@ export default function ClusterOverviewPage() {
const memPct = memMb > 0 ? (memUsageNorm / memMb) * 100 : 0; 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'; const color = (pct: number) => pct > 80 ? 'bg-red-500' : pct > 60 ? 'bg-yellow-500' : 'bg-emerald-500';
return ( return (
<TableRow key={n.name} className="border-slate-800 hover:bg-slate-800/30"> <TableRow key={n.name} className="border-slate-800 hover:bg-slate-800/30 cursor-pointer"
<TableCell className="font-medium text-slate-200">{n.name}</TableCell> onClick={() => navigate(`/kubernetes/${clusterCode}/nodes/${n.name}`)}>
<TableCell className="font-medium text-blue-400 hover:text-blue-300">{n.name}</TableCell>
<TableCell><Badge className={getNodeRole(n)}>{n.role || 'worker'}</Badge></TableCell> <TableCell><Badge className={getNodeRole(n)}>{n.role || 'worker'}</Badge></TableCell>
<TableCell><Badge className={n.status === 'Ready' ? 'bg-emerald-500/20 text-emerald-400' : 'bg-red-500/20 text-red-400'}>{n.status || 'Unknown'}</Badge></TableCell> <TableCell><Badge className={n.status === 'Ready' ? 'bg-emerald-500/20 text-emerald-400' : 'bg-red-500/20 text-red-400'}>{n.status || 'Unknown'}</Badge></TableCell>
<TableCell className="text-sm text-slate-400">{n.ip || '-'}</TableCell> <TableCell className="text-sm text-slate-400">{n.ip || '-'}</TableCell>
@@ -30,15 +30,14 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster: any; on
if (!nodes || nodes.length === 0) return { cpuPct: 0, memPct: 0 }; if (!nodes || nodes.length === 0) return { cpuPct: 0, memPct: 0 };
let totalCpuPct = 0, totalMemPct = 0, validNodes = 0; let totalCpuPct = 0, totalMemPct = 0, validNodes = 0;
for (const n of nodes) { for (const n of nodes) {
// ClusterOverviewPage 读的是 cpu_usage_m / mem_usage_mi(实时路径) // 后端 ListNodes 返回 cpu_usage_m (如 "122m"), mem_usage_mi (如 "2105Mi"), cpu (核数), memory_mb (MB)
// ClusterDetail 的 nodes 从 getNodes API 获取,也有这些字段
const cpuCores = n.cpu || 0; const cpuCores = n.cpu || 0;
const cpuUsageM = n.cpu_usage_m ? parseFloat(String(n.cpu_usage_m).replace('m', '')) : 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 cpuPct = cpuCores > 0 ? (cpuUsageM / 1000 / cpuCores) * 100 : 0;
const memMb = n.memory_mb || n.memory || 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 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 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) { if (cpuPct > 0 || memPct > 0) {
totalCpuPct += cpuPct; totalCpuPct += cpuPct;
totalMemPct += memPct; totalMemPct += memPct;
@@ -264,12 +263,12 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster: any; on
</Badge> </Badge>
</td> </td>
<td className="px-4 py-3 text-sm text-slate-300"> <td className="px-4 py-3 text-sm text-slate-300">
{Number(node.cpu_usage || 0).toFixed(1)}% / {node.cpu}核 {node.cpu_usage_m || '0m'} / {node.cpu}核
</td> </td>
<td className="px-4 py-3 text-sm text-slate-300"> <td className="px-4 py-3 text-sm text-slate-300">
{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' : '-'}
</td> </td>
<td className="px-4 py-3 text-sm text-slate-300">{node.pods ?? node.pod_count ?? 0}</td> <td className="px-4 py-3 text-sm text-slate-300">{node.max_pods ?? 0}</td>
<td className="px-4 py-3 text-sm text-slate-400">{node.os}</td> <td className="px-4 py-3 text-sm text-slate-400">{node.os}</td>
</tr> </tr>
)) ))
@@ -322,8 +321,8 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster: any; on
</Badge> </Badge>
</td> </td>
<td className="px-4 py-3 text-sm text-slate-300">{pod.restart_count ?? 0}</td> <td className="px-4 py-3 text-sm text-slate-300">{pod.restart_count ?? 0}</td>
<td className="px-4 py-3 text-sm text-slate-300">{Number(pod.cpu_usage || 0).toFixed(1)}m</td> <td className="px-4 py-3 text-sm text-slate-300">{pod.cpu_usage_m || '0m'}</td>
<td className="px-4 py-3 text-sm text-slate-300">{Number(pod.memory_usage || 0).toFixed(0)}Mi</td> <td className="px-4 py-3 text-sm text-slate-300">{pod.mem_usage_mi || '0Mi'}</td>
<td className="px-4 py-3 text-sm text-slate-400">{pod.node_name}</td> <td className="px-4 py-3 text-sm text-slate-400">{pod.node_name}</td>
</tr> </tr>
)) ))
@@ -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<any>(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 (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs">
<span className="text-slate-400">{label}</span>
<span className="text-slate-300 font-medium">
{used} / {total} {unit}
</span>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 h-2 rounded-full bg-slate-700/50 overflow-hidden">
<div className={`h-full rounded-full ${barColor} transition-all duration-500`} style={{ width: `${safePct}%` }} />
</div>
<span className={`text-xs font-semibold min-w-[40px] text-right ${color}`}>
{safePct.toFixed(0)}%
</span>
</div>
</div>
);
};
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 (
<div className="space-y-4">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-slate-500">
<Link to="/kubernetes" className="hover:text-slate-300 transition-colors">集群</Link>
<ChevronRight className="h-3 w-3" />
<Link to={`/kubernetes/${clusterCode}`} className="hover:text-slate-300 transition-colors">{clusterCode}</Link>
<ChevronRight className="h-3 w-3" />
<span className="text-slate-300">{nodeName}</span>
</div>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-slate-800 border border-slate-700">
<Server className="h-6 w-6 text-blue-400" />
</div>
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold text-slate-100">{nodeName}</h1>
<Badge className={data?.role === 'master' ? 'bg-blue-500/20 text-blue-400 border-blue-500/30' : 'bg-slate-500/20 text-slate-400 border-slate-500/30'}>
{data?.role || 'worker'}
</Badge>
<Badge className={data?.status === 'Ready' ? 'bg-emerald-500/20 text-emerald-400' : 'bg-red-500/20 text-red-400'}>
{data?.status || 'Unknown'}
</Badge>
</div>
<p className="text-sm text-slate-500 mt-0.5">
{data?.internal_ip && `${data.internal_ip}`}
{data?.hostname && ` · ${data.hostname}`}
</p>
</div>
</div>
<Button variant="outline" size="icon" className="h-9 w-9 border-slate-700 text-slate-400"
onClick={loadData} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
</Button>
</div>
{loading && !data ? (
<Card className="bg-slate-900 border-slate-800">
<CardContent className="py-16 flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-slate-500" />
</CardContent>
</Card>
) : !data ? (
<Card className="bg-slate-900 border-slate-800">
<CardContent className="py-16 text-center text-slate-500">
<AlertTriangle className="h-8 w-8 mx-auto mb-2 opacity-50" />
无法加载节点详情
</CardContent>
</Card>
) : (
<>
{/* Row 1: System Info + Basic Info */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* System Info */}
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<h2 className="text-sm font-semibold text-slate-200 mb-3 flex items-center gap-2">
<Info className="h-4 w-4 text-cyan-400" />系统信息
</h2>
<div className="space-y-2 text-xs">
<div className="flex items-center justify-between py-1.5 border-b border-slate-800/50">
<span className="text-slate-500">容器运行时</span>
<span className="text-slate-300 font-mono">{data.container_runtime || '-'}</span>
</div>
<div className="flex items-center justify-between py-1.5 border-b border-slate-800/50">
<span className="text-slate-500">Kubelet</span>
<span className="text-slate-300 font-mono">{data.kubelet_version || '-'}</span>
</div>
<div className="flex items-center justify-between py-1.5 border-b border-slate-800/50">
<span className="text-slate-500">操作系统</span>
<span className="text-slate-300">{data.os_image || '-'}</span>
</div>
<div className="flex items-center justify-between py-1.5 border-b border-slate-800/50">
<span className="text-slate-500">内核版本</span>
<span className="text-slate-300 font-mono">{data.kernel_version || '-'}</span>
</div>
<div className="flex items-center justify-between py-1.5 border-b border-slate-800/50">
<span className="text-slate-500">架构</span>
<span className="text-slate-300 font-mono">{data.architecture || '-'}</span>
</div>
<div className="flex items-center justify-between py-1.5 border-b border-slate-800/50">
<span className="text-slate-500">创建时间</span>
<span className="text-slate-300">{data.creation_time ? new Date(data.creation_time).toLocaleString('zh-CN') : '-'}</span>
</div>
<div className="flex items-center justify-between py-1.5">
<span className="text-slate-500">Pod CIDR</span>
<span className="text-slate-300 font-mono">{data.pod_cidr || '-'}</span>
</div>
</div>
</CardContent>
</Card>
{/* Resource Allocation */}
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<h2 className="text-sm font-semibold text-slate-200 mb-3 flex items-center gap-2">
<Activity className="h-4 w-4 text-emerald-400" />资源分配率
</h2>
<div className="space-y-4 mt-4">
{/* CPU allocation (requested vs allocatable) */}
<ResourceBar
label="CPU 已分配"
used={`${(data.cpu_request_m / 1000).toFixed(2)} 核`}
total={`${data.cpu_allocatable} 核`}
unit=""
pct={data.cpu_alloc_pct}
color="text-emerald-400"
/>
{/* CPU real usage (from metrics) */}
{data.cpu_usage_m && (
<ResourceBar
label="CPU 实时使用"
used={data.cpu_usage_m}
total={`${data.cpu_capacity} 核`}
unit=""
pct={(parseFloat(data.cpu_usage_m.replace('m', '')) / (data.cpu_capacity * 1000)) * 100}
color="text-blue-400"
/>
)}
{/* Memory allocation */}
<ResourceBar
label="内存已分配"
used={`${data.mem_request_mi} Mi`}
total={`${data.mem_allocatable_mb} Mi`}
unit=""
pct={data.mem_alloc_pct}
color="text-emerald-400"
/>
{/* Memory real usage */}
{data.mem_usage_mi && (
<ResourceBar
label="内存实时使用"
used={data.mem_usage_mi}
total={`${data.mem_capacity_mb} Mi`}
unit=""
pct={(parseFloat(data.mem_usage_mi.replace('Mi', '').replace('Ki', '').replace('Gi', '')) / data.mem_capacity_mb) * 100}
color="text-blue-400"
/>
)}
{/* Pod allocation */}
<ResourceBar
label="Pod 数量"
used={`${data.pod_count}`}
total={`${data.pod_allocatable}`}
unit=""
pct={data.pod_alloc_pct}
color="text-emerald-400"
/>
</div>
{/* Capacity vs Allocatable raw */}
<div className="mt-4 pt-3 border-t border-slate-800/50 space-y-1 text-[11px] text-slate-500">
<div className="flex justify-between">
<span>CPU 容量 / 可分配</span>
<span className="font-mono">{data.cpu_capacity} / {data.cpu_allocatable} 核</span>
</div>
<div className="flex justify-between">
<span>内存容量 / 可分配</span>
<span className="font-mono">{data.mem_capacity_mb} / {data.mem_allocatable_mb} Mi</span>
</div>
<div className="flex justify-between">
<span>Pod 容量 / 可分配</span>
<span className="font-mono">{data.pod_capacity} / {data.pod_allocatable}</span>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Row 2: Conditions + Taints */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Conditions */}
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<h2 className="text-sm font-semibold text-slate-200 mb-3 flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-blue-400" />节点条件
</h2>
{(!data.conditions || data.conditions.length === 0) ? (
<p className="text-xs text-slate-500 py-4 text-center">无条件数据</p>
) : (
<div className="space-y-2">
{data.conditions.map((cond: any, i: number) => (
<div key={i} className="flex items-start gap-2 text-xs py-1.5 border-b border-slate-800/30 last:border-0">
<Badge className={conditionColor(cond.type, cond.status)}>
{cond.status === 'True' ? <CheckCircle2 className="h-3 w-3 mr-1" /> : null}
{cond.type}
</Badge>
<div className="flex-1 min-w-0">
{cond.reason && <span className="text-slate-400 block">{cond.reason}</span>}
{cond.message && <span className="text-slate-600 text-[11px] block truncate">{cond.message}</span>}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Taints */}
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<h2 className="text-sm font-semibold text-slate-200 mb-3 flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-amber-400" />污点 (Taints)
</h2>
{(!data.taints || data.taints.length === 0) ? (
<p className="text-xs text-slate-500 py-4 text-center">无污点</p>
) : (
<div className="space-y-2">
{data.taints.map((t: any, i: number) => (
<div key={i} className="flex items-center gap-2 text-xs">
<Badge className={taintColor(t.effect)}>{t.effect}</Badge>
<span className="text-slate-300 font-mono">{t.key}</span>
{t.value && <span className="text-slate-500">= {t.value}</span>}
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
{/* Row 3: Labels */}
{data.labels && data.labels.length > 0 && (
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<h2 className="text-sm font-semibold text-slate-200 mb-3 flex items-center gap-2">
<Network className="h-4 w-4 text-slate-400" />标签 ({data.labels.length})
</h2>
<div className="flex flex-wrap gap-1.5">
{data.labels.map((l: any, i: number) => (
<div key={i} className="flex items-center gap-1 bg-slate-800/50 border border-slate-700/50 rounded px-2 py-0.5 text-[11px]">
<span className="text-slate-400">{l.key}</span>
<span className="text-slate-600">:</span>
<span className="text-slate-300">{l.value}</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Row 4: Pods on this node */}
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-0">
<div className="px-4 py-3 border-b border-slate-800">
<h2 className="text-sm font-semibold text-slate-200 flex items-center gap-2">
<Container className="h-4 w-4 text-cyan-400" />
节点上的 Pod
<Badge className="bg-slate-700/50 text-slate-300">{data.pods?.length || 0}</Badge>
</h2>
</div>
{(!data.pods || data.pods.length === 0) ? (
<p className="text-xs text-slate-500 py-8 text-center">该节点上暂无 Pod</p>
) : (
<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">状态</TableHead>
<TableHead className="text-slate-400">IP</TableHead>
<TableHead className="text-slate-400">CPU 请求</TableHead>
<TableHead className="text-slate-400">内存请求</TableHead>
<TableHead className="text-slate-400">重启次数</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.pods.map((p: any, i: number) => (
<TableRow key={i} className="border-slate-800 hover:bg-slate-800/30">
<TableCell className="text-sm text-slate-400">{p.namespace}</TableCell>
<TableCell className="text-sm font-medium text-slate-200">{p.name}</TableCell>
<TableCell>
<Badge className={
p.status === 'Running' ? 'bg-emerald-500/20 text-emerald-400' :
p.status === 'Pending' ? 'bg-amber-500/20 text-amber-400' :
'bg-red-500/20 text-red-400'
}>{p.status}</Badge>
</TableCell>
<TableCell className="text-xs text-slate-400 font-mono">{p.ip || '-'}</TableCell>
<TableCell className="text-xs text-slate-400">
{p.cpu_request_m > 0 ? `${p.cpu_request_m}m` : '-'}
</TableCell>
<TableCell className="text-xs text-slate-400">
{p.mem_request_mi > 0 ? `${p.mem_request_mi}Mi` : '-'}
</TableCell>
<TableCell className="text-xs">
{p.restart_count > 0 ? (
<span className="text-amber-400">{p.restart_count}</span>
) : (
<span className="text-slate-600">0</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</>
)}
</div>
);
}
@@ -45,7 +45,14 @@ export function PodFileBrowser({
setPreview(null); setPreview(null);
try { try {
const res: any = await kubernetesApi.listPodFiles(clusterCode, namespace, podName, path, container || undefined); 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); setEntries(list);
setCurrentPath(path); setCurrentPath(path);
} catch (e: any) { } catch (e: any) {
+3
View File
@@ -175,6 +175,9 @@ export const kubernetesApi = {
getNodes: async (clusterId: number | string) => { getNodes: async (clusterId: number | string) => {
return http.get(`/kubernetes-clusters/${clusterId}/nodes`); 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) => { getPods: async (clusterId: number | string) => {
return http.get(`/kubernetes-clusters/${clusterId}/pods`); return http.get(`/kubernetes-clusters/${clusterId}/pods`);
}, },
+17 -11
View File
@@ -17,21 +17,27 @@ export interface Server {
diskUsage: number; diskUsage: number;
} }
// Kubernetes集群 // Kubernetes集群 — 字段与后端 kubernetes_clusters 表 + JSON tag 一致(snake_case)
export interface KubernetesCluster { export interface KubernetesCluster {
id: string; id: number;
name: string; name: string;
code?: string; // 唯一标识,用于 Kuboard 路由 // Logan.Li code: string; // 唯一标识,必填 // Logan.Li
version: string; version: string;
nodes: number;
pods: number;
services: number;
deployments: number;
status: 'healthy' | 'warning' | 'error'; status: 'healthy' | 'warning' | 'error';
cpuUsage: number; config_type: number; // 0=token, 1=kubeconfig
memoryUsage: number; api_server: string;
createdAt: string; api_endpoint: string;
namespace: 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节点 // K8s节点