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() {
+ {data?.internal_ip && `${data.internal_ip}`} + {data?.hostname && ` · ${data.hostname}`} +
+无条件数据
+ ) : ( +无污点
+ ) : ( +该节点上暂无 Pod
+ ) : ( +