diff --git a/PROJECT_EVALUATION.md b/PROJECT_EVALUATION.md index be1aee1..e138fb4 100644 --- a/PROJECT_EVALUATION.md +++ b/PROJECT_EVALUATION.md @@ -38,6 +38,7 @@ | kubectl 终端 | ✅ KubectlShell(WS) | ✅ KubectlTerminal | ✅ | | 拓扑图 | ✅ topology get/save | ✅ TopologyView | ✅ | | **Node 详情页** | ✅ GetNodeDetail (M10) | ✅ NodeDetailPage | ✅ curl 验证 | +| **StatefulSet/DaemonSet 详情页** | ✅ GetStatefulSetDetail/GetDaemonSetDetail + YAML路由修复 (M11) | ✅ WorkloadViewPage 重写 (M11) | ✅ curl 验证 | ### ❌ 缺失 / 进行中 diff --git a/ROADMAP.md b/ROADMAP.md index 9542cd8..687e762 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -211,11 +211,42 @@ --- +### M11: StatefulSet/DaemonSet 详情页 [done] +**目标**: 节点名称可点击进入详情页,展示完整的系统信息、资源分配率、污点、条件、Pod 列表 + +**改动**: +- 后端: + - `routes.go`: 为 statefulsets/daemonsets 注册专门的 YAML 子路由(避免 Gin radix tree 与通配 `/:resource/:name/yaml` 冲突) + - `kubernetes.go`: `GetResourceYAMLNS` / `UpdateResourceYAMLNS` 添加 URL path fallback 提取 resource 类型(当 `c.Param("resource")` 为空时从 URL path 解析) +- 前端: + - `WorkloadViewPage.tsx` 重写 (~480行): + - **Bug 修复**: kind 从 `useLocation().pathname` 提取(原 `params.kind` 永远 undefined — 静态路径段不是 :param) + - **增强**: 更新策略卡片(RollingUpdate partition/maxUnavailable/maxSurge + podManagementPolicy + revisionHistoryLimit) + - **增强**: 选择器与标签卡片(selector.matchLabels + labels + annotations) + - **增强**: VolumeClaimTemplates 表格(StatefulSet 专有 — PVC 模板 name/accessModes/storageClass/size) + - **增强**: Init Containers 展示 + - **增强**: ContainerInfo 子组件(提取重复容器渲染逻辑) + - **增强**: DaemonSet 状态卡片增加"已更新"字段 + - `NamespaceResourcePage.tsx`: `handleDetail` 添加 statefulsets/daemonsets 点击导航(view 模式) + - App.tsx 路由已就绪(`:mode/StatefulSet/:name` + `:mode/DaemonSet/:name`) + - api.ts 方法已就绪(getStatefulSetDetail/getDaemonSetDetail/scaleStatefulSet/restartStatefulSet/restartDaemonSet) + +**验收标准**: +- [x] DS YAML API 返回 200 + apiVersion + kind=DaemonSet(路由冲突修复) +- [x] DS Detail API 返回 name/desired/current/ready/containers +- [x] Deployment YAML API 仍然正常(generic 路由未受影响) +- [x] SPA 路由 view/yaml 两种模式均返回 200 +- [x] TypeScript 零编译错误 + Vite 构建成功 +- [x] NamespaceResourcePage 点击 STS/DS 行可跳转详情页 +- [x] WorkloadViewPage 支持 view/yaml 双模式切换 +- [x] DaemonSet 详情页正确显示 kind=DaemonSet(非 StatefulSet) + +--- + ### 后续(P3+) | 模块 | 描述 | |------|------| -| M11 | StatefulSet/DaemonSet 详情页(对标 Deployment) | | M12 | 事件聚合 + 告警联动 | | M13 | 多集群资源对比看板 | diff --git a/TDD_PLAN.md b/TDD_PLAN.md index 83cb917..e24bb0f 100644 --- a/TDD_PLAN.md +++ b/TDD_PLAN.md @@ -339,3 +339,51 @@ cd frontend && npm run build 2>&1 | tail -3 - [x] Service Tab selector 双路径兼容(spec.selector.matchLabels || selector) - [x] TypeScript 零编译错误 + Vite 构建成功 - [x] 前端 bundle 已更新 + +--- + +## M11: StatefulSet/DaemonSet 详情页 + +### 验证用例 + +```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: DaemonSet YAML API(路由冲突修复验证) +curl -s "http://127.0.0.1:8080/api/v1/kubernetes/prod-cluster/namespace/kube-system/daemonsets/calico-node/yaml" \ + -H "Authorization: Bearer *** | head -3 +# 期望: apiVersion: apps/v1 / kind: DaemonSet / metadata: + +# U3: DaemonSet Detail API +curl -s "http://127.0.0.1:8080/api/v1/kubernetes/prod-cluster/namespace/kube-system/daemonsets/calico-node" \ + -H "Authorization: Bearer *** | jq '.data | {name: .metadata.name, desired: .status.desiredNumberScheduled, ready: .status.numberReady}' +# 期望: name="calico-node", desired=2, ready=2 + +# U4: Deployment YAML 仍正常(generic 路由未受影响) +curl -s "http://127.0.0.1:8080/api/v1/kubernetes/prod-cluster/namespace/devops/deployments/redis/yaml" \ + -H "Authorization: Bearer *** | head -1 +# 期望: apiVersion: apps/v1 + +# U5: SPA fallback — workload 路由 200 +curl -s -o /dev/null -w "%{http_code}" \ + "http://127.0.0.1/kubernetes/prod-cluster/namespace/kube-system/workload/view/DaemonSet/calico-node" +# 期望: 200 + +# U6: TypeScript + Vite 构建 +cd frontend && npx tsc --noEmit && npm run build 2>&1 | tail -3 +# 期望: 零错误 + built in Ns +``` + +### 通过标准 +- [x] U2: DS YAML API 返回 200 + apiVersion + kind=DaemonSet(Gin radix tree 路由冲突已修复) +- [x] U3: DS Detail API 返回 name/desired/current/ready/containers +- [x] U4: Deployment YAML API 仍正常(generic `/:resource/:name/yaml` 路由未受影响) +- [x] U5: SPA 路由 view/yaml 两种模式均返回 200 +- [x] U6: TypeScript 零编译错误 + Vite 构建成功 +- [x] NamespaceResourcePage 点击 STS/DS 行可跳转详情页 +- [x] WorkloadViewPage 支持 view/yaml 双模式切换 +- [x] WorkloadViewPage kind 从 useLocation 正确提取(DaemonSet 不被误判为 StatefulSet) +- [x] 更新策略/选择器/PVC模板/InitContainers 全部展示 diff --git a/backend/internal/api/handlers/kubernetes.go b/backend/internal/api/handlers/kubernetes.go index bcea573..c603df9 100644 --- a/backend/internal/api/handlers/kubernetes.go +++ b/backend/internal/api/handlers/kubernetes.go @@ -1830,6 +1830,35 @@ func (h *KubernetesHandler) getK8sClientByCode(c *gin.Context) (*k8sclient.Clien func (h *KubernetesHandler) ListDeploymentsNS(c *gin.Context) { h.listDeploymentsWithNS(c, c.Param("namespace")) } + +// GetDeploymentNS 获取单个 Deployment 详情(避免前端 404)// Logan.Li +func (h *KubernetesHandler) GetDeploymentNS(c *gin.Context) { + ns := c.Param("namespace") + name := c.Param("name") + clusterID, _ := strconv.Atoi(c.Param("id")) + if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } + client, _, err := h.getK8sClient(clusterID) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } + deps, err := client.ListDeployments() + if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + for _, d := range deps.Items { + if d.Metadata.Namespace == ns && d.Metadata.Name == name { + image := "" + if len(d.Spec.Template.Spec.Containers) > 0 { image = d.Spec.Template.Spec.Containers[0].Image } + c.JSON(200, models.Response{Code: 200, Data: map[string]interface{}{ + "namespace": d.Metadata.Namespace, + "name": d.Metadata.Name, + "replicas": d.Spec.Replicas, + "available_replicas": d.Status.AvailableReplicas, + "ready_replicas": d.Status.ReadyReplicas, + "image": image, + "paused": d.Spec.Paused, + }}) + return + } + } + c.JSON(404, models.Response{Code: 404, Message: "Deployment not found"}) +} func (h *KubernetesHandler) ListPodsNS(c *gin.Context) { h.listPodsWithNS(c, c.Param("namespace")) } @@ -2504,6 +2533,16 @@ func (h *KubernetesHandler) GetResourceYAMLNS(c *gin.Context) { if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } ns := c.Param("namespace") resource := c.Param("resource") + // Fallback: specific routes (statefulsets/:name/yaml, daemonsets/:name/yaml) have no :resource param + if resource == "" { + pathParts := strings.Split(c.Request.URL.Path, "/") + for _, p := range pathParts { + if p == "statefulsets" || p == "daemonsets" || p == "deployments" || p == "services" || p == "configmaps" || p == "secrets" { + resource = p + break + } + } + } name := c.Param("name") // Build API path based on resource type var apiPath string @@ -2549,6 +2588,16 @@ func (h *KubernetesHandler) UpdateResourceYAMLNS(c *gin.Context) { if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } ns := c.Param("namespace") resource := c.Param("resource") + // Fallback: specific routes (statefulsets/:name/yaml, daemonsets/:name/yaml) have no :resource param + if resource == "" { + pathParts := strings.Split(c.Request.URL.Path, "/") + for _, p := range pathParts { + if p == "statefulsets" || p == "daemonsets" || p == "deployments" || p == "services" || p == "configmaps" || p == "secrets" { + resource = p + break + } + } + } name := c.Param("name") yamlData, err := io.ReadAll(c.Request.Body) if err != nil { c.JSON(400, models.Response{Code: 400, Message: "读取body失败"}); return } @@ -2768,6 +2817,123 @@ func (h *KubernetesHandler) ListDaemonSetsNS(c *gin.Context) { } c.JSON(200, models.Response{Code: 200, Data: result}) } +// ScaleStatefulSetNS scales a StatefulSet (Kuboard code route) +func (h *KubernetesHandler) ScaleStatefulSetNS(c *gin.Context) { + clusterID := h.resolveClusterCode(c.Param("clusterCode")) + if clusterID == 0 { c.JSON(500, models.Response{Code: 500, Message: "cluster not found"}); return } + client, _, err := h.getK8sClient(clusterID) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } + ns := c.Param("namespace") + name := c.Param("name") + var req struct{ Replicas int `json:"replicas"` } + if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, models.Response{Code: 400, Message: "参数错误"}); return } + if err := client.ScaleStatefulSet(ns, name, req.Replicas); err != nil { + c.JSON(500, models.Response{Code: 500, Message: "扩缩容失败: " + err.Error()}); return + } + c.JSON(200, models.Response{Code: 200, Message: "StatefulSet 副本数已更新为 " + strconv.Itoa(req.Replicas)}) +} + +// ScaleStatefulSet scales a StatefulSet (ID route) +func (h *KubernetesHandler) ScaleStatefulSet(c *gin.Context) { + clusterID, _ := strconv.Atoi(c.Param("id")) + if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } + if clusterID == 0 { c.JSON(500, models.Response{Code: 500, Message: "cluster not found"}); return } + client, _, err := h.getK8sClient(clusterID) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } + ns := c.Param("namespace") + name := c.Param("name") + var req struct{ Replicas int `json:"replicas"` } + if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, models.Response{Code: 400, Message: "参数错误"}); return } + if err := client.ScaleStatefulSet(ns, name, req.Replicas); err != nil { + c.JSON(500, models.Response{Code: 500, Message: "扩缩容失败: " + err.Error()}); return + } + c.JSON(200, models.Response{Code: 200, Message: "StatefulSet 副本数已更新为 " + strconv.Itoa(req.Replicas)}) +} + +// RestartStatefulSetNS restarts a StatefulSet (Kuboard code route) +func (h *KubernetesHandler) RestartStatefulSetNS(c *gin.Context) { + clusterID := h.resolveClusterCode(c.Param("clusterCode")) + if clusterID == 0 { c.JSON(500, models.Response{Code: 500, Message: "cluster not found"}); return } + client, _, err := h.getK8sClient(clusterID) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } + ns := c.Param("namespace") + name := c.Param("name") + if err := client.RestartStatefulSet(ns, name); err != nil { + c.JSON(500, models.Response{Code: 500, Message: "重启失败: " + err.Error()}); return + } + c.JSON(200, models.Response{Code: 200, Message: "StatefulSet 滚动重启已触发"}) +} + +// RestartStatefulSet restarts a StatefulSet (ID route) +func (h *KubernetesHandler) RestartStatefulSet(c *gin.Context) { + clusterID, _ := strconv.Atoi(c.Param("id")) + if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } + if clusterID == 0 { c.JSON(500, models.Response{Code: 500, Message: "cluster not found"}); return } + client, _, err := h.getK8sClient(clusterID) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } + ns := c.Param("namespace") + name := c.Param("name") + if err := client.RestartStatefulSet(ns, name); err != nil { + c.JSON(500, models.Response{Code: 500, Message: "重启失败: " + err.Error()}); return + } + c.JSON(200, models.Response{Code: 200, Message: "StatefulSet 滚动重启已触发"}) +} + +// RestartDaemonSetNS restarts a DaemonSet (Kuboard code route) +func (h *KubernetesHandler) RestartDaemonSetNS(c *gin.Context) { + clusterID := h.resolveClusterCode(c.Param("clusterCode")) + if clusterID == 0 { c.JSON(500, models.Response{Code: 500, Message: "cluster not found"}); return } + client, _, err := h.getK8sClient(clusterID) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } + ns := c.Param("namespace") + name := c.Param("name") + if err := client.RestartDaemonSet(ns, name); err != nil { + c.JSON(500, models.Response{Code: 500, Message: "重启失败: " + err.Error()}); return + } + c.JSON(200, models.Response{Code: 200, Message: "DaemonSet 滚动重启已触发"}) +} + +// RestartDaemonSet restarts a DaemonSet (ID route) +func (h *KubernetesHandler) RestartDaemonSet(c *gin.Context) { + clusterID, _ := strconv.Atoi(c.Param("id")) + if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } + if clusterID == 0 { c.JSON(500, models.Response{Code: 500, Message: "cluster not found"}); return } + client, _, err := h.getK8sClient(clusterID) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } + ns := c.Param("namespace") + name := c.Param("name") + if err := client.RestartDaemonSet(ns, name); err != nil { + c.JSON(500, models.Response{Code: 500, Message: "重启失败: " + err.Error()}); return + } + c.JSON(200, models.Response{Code: 200, Message: "DaemonSet 滚动重启已触发"}) +} + +// GetStatefulSetDetailNS returns a single StatefulSet detail (Kuboard code route) +func (h *KubernetesHandler) GetStatefulSetDetailNS(c *gin.Context) { + clusterID := h.resolveClusterCode(c.Param("clusterCode")) + if clusterID == 0 { c.JSON(500, models.Response{Code: 500, Message: "cluster not found"}); return } + client, _, err := h.getK8sClient(clusterID) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } + ns := c.Param("namespace") + name := c.Param("name") + sts, err := client.GetStatefulSet(ns, name) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + c.JSON(200, models.Response{Code: 200, Data: sts}) +} + +// GetDaemonSetDetailNS returns a single DaemonSet detail (Kuboard code route) +func (h *KubernetesHandler) GetDaemonSetDetailNS(c *gin.Context) { + clusterID := h.resolveClusterCode(c.Param("clusterCode")) + if clusterID == 0 { c.JSON(500, models.Response{Code: 500, Message: "cluster not found"}); return } + client, _, err := h.getK8sClient(clusterID) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } + ns := c.Param("namespace") + name := c.Param("name") + ds, err := client.GetDaemonSet(ns, name) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + c.JSON(200, models.Response{Code: 200, Data: ds}) +} + // ============================================================================ // Pod Metrics (kubectl top pods) — per-container CPU/Memory usage via metrics-server // ============================================================================ diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index f7f18f7..0e0270a 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -342,6 +342,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c // Deployments kubernetesRouter.GET("/:clusterCode/namespace/:namespace/deployments", k8sHandler.ListDeploymentsNS) + kubernetesRouter.GET("/:clusterCode/namespace/:namespace/deployments/:name", k8sHandler.GetDeploymentNS) kubernetesRouter.POST("/:clusterCode/namespace/:namespace/deployments/:name/scale", k8sHandler.ScaleDeploymentNS) kubernetesRouter.POST("/:clusterCode/namespace/:namespace/deployments/:name/restart", k8sHandler.RestartDeploymentNS) kubernetesRouter.POST("/:clusterCode/namespace/:namespace/deployments/:name/pause", k8sHandler.PauseDeploymentNS) @@ -412,8 +413,21 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c // StatefulSet / DaemonSet kubernetesRouter.GET("/:clusterCode/namespace/:namespace/statefulsets", k8sHandler.ListStatefulSetsNS) kubernetesRouter.GET("/:clusterCode/namespace/:namespace/daemonsets", k8sHandler.ListDaemonSetsNS) + // StatefulSet operations (Kuboard code routes) + kubernetesRouter.GET("/:clusterCode/namespace/:namespace/statefulsets/:name", k8sHandler.GetStatefulSetDetailNS) + kubernetesRouter.POST("/:clusterCode/namespace/:namespace/statefulsets/:name/scale", k8sHandler.ScaleStatefulSetNS) + kubernetesRouter.POST("/:clusterCode/namespace/:namespace/statefulsets/:name/restart", k8sHandler.RestartStatefulSetNS) + // DaemonSet operations (Kuboard code routes) + kubernetesRouter.GET("/:clusterCode/namespace/:namespace/daemonsets/:name", k8sHandler.GetDaemonSetDetailNS) + kubernetesRouter.POST("/:clusterCode/namespace/:namespace/daemonsets/:name/restart", k8sHandler.RestartDaemonSetNS) - // YAML view/edit + // StatefulSet/DaemonSet YAML (specific routes to avoid Gin radix tree conflict with generic /:resource/:name/yaml) + kubernetesRouter.GET("/:clusterCode/namespace/:namespace/statefulsets/:name/yaml", k8sHandler.GetResourceYAMLNS) + kubernetesRouter.PUT("/:clusterCode/namespace/:namespace/statefulsets/:name/yaml", k8sHandler.UpdateResourceYAMLNS) + kubernetesRouter.GET("/:clusterCode/namespace/:namespace/daemonsets/:name/yaml", k8sHandler.GetResourceYAMLNS) + kubernetesRouter.PUT("/:clusterCode/namespace/:namespace/daemonsets/:name/yaml", k8sHandler.UpdateResourceYAMLNS) + + // YAML view/edit (generic — works for resources without specific static routes) kubernetesRouter.GET("/:clusterCode/namespace/:namespace/:resource/:name/yaml", k8sHandler.GetResourceYAMLNS) kubernetesRouter.PUT("/:clusterCode/namespace/:namespace/:resource/:name/yaml", k8sHandler.UpdateResourceYAMLNS) kubernetesRouter.DELETE("/:clusterCode/namespace/:namespace/:resource/:name/yaml", k8sHandler.DeleteResourceYAMLNS) diff --git a/backend/pkg/kubernetesclient/client.go b/backend/pkg/kubernetesclient/client.go index c8a2f6c..89ff04b 100644 --- a/backend/pkg/kubernetesclient/client.go +++ b/backend/pkg/kubernetesclient/client.go @@ -1205,6 +1205,75 @@ func (c *Client) ListDaemonSets() (*DaemonSetList, error) { return &result, nil } +// ScaleStatefulSet scales a StatefulSet to the given replica count +func (c *Client) ScaleStatefulSet(namespace, name string, replicas int) error { + body := map[string]interface{}{ + "spec": map[string]interface{}{ + "replicas": replicas, + }, + } + path := fmt.Sprintf("/apis/apps/v1/namespaces/%s/statefulsets/%s/scale", namespace, name) + return c.patch(path, body, nil) +} + +// RestartStatefulSet triggers a rolling restart of a StatefulSet +func (c *Client) RestartStatefulSet(namespace, name string) error { + body := map[string]interface{}{ + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "metadata": map[string]interface{}{ + "annotations": map[string]string{ + "kubectl.kubernetes.io/restartedAt": time.Now().UTC().Format(time.RFC3339), + }, + }, + }, + }, + } + path := fmt.Sprintf("/apis/apps/v1/namespaces/%s/statefulsets/%s", namespace, name) + return c.patch(path, body, nil) +} + +// RestartDaemonSet triggers a rolling restart of a DaemonSet +func (c *Client) RestartDaemonSet(namespace, name string) error { + body := map[string]interface{}{ + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "metadata": map[string]interface{}{ + "annotations": map[string]string{ + "kubectl.kubernetes.io/restartedAt": time.Now().UTC().Format(time.RFC3339), + }, + }, + }, + }, + } + path := fmt.Sprintf("/apis/apps/v1/namespaces/%s/daemonsets/%s", namespace, name) + return c.patch(path, body, nil) +} + +// GetStatefulSet fetches a single StatefulSet by name +func (c *Client) GetStatefulSet(namespace, name string) (*StatefulSetItem, error) { + var result struct { + StatefulSetItem + } + path := fmt.Sprintf("/apis/apps/v1/namespaces/%s/statefulsets/%s", namespace, name) + if err := c.get(path, &result); err != nil { + return nil, err + } + return &result.StatefulSetItem, nil +} + +// GetDaemonSet fetches a single DaemonSet by name +func (c *Client) GetDaemonSet(namespace, name string) (*DaemonSetItem, error) { + var result struct { + DaemonSetItem + } + path := fmt.Sprintf("/apis/apps/v1/namespaces/%s/daemonsets/%s", namespace, name) + if err := c.get(path, &result); err != nil { + return nil, err + } + return &result.DaemonSetItem, nil +} + // ============================================================================ // NodeMetrics (metrics-server, optional) // ============================================================================ diff --git a/docker-compose.yml b/docker-compose.yml index 76d710a..c0b503e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -74,6 +74,10 @@ services: timeout: 10s retries: 3 start_period: 40s + dns: + - 127.0.0.11 + - 114.114.114.114 + - 223.5.5.5 networks: - aiops-network diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 010c2af..c5f0302 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -132,7 +132,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1954,7 +1953,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.1.tgz", "integrity": "sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", @@ -4795,7 +4793,6 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.95.2.tgz", "integrity": "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA==", "license": "MIT", - "peer": true, "dependencies": { "@tanstack/query-core": "5.95.2" }, @@ -4958,7 +4955,6 @@ "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -4968,7 +4964,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4977,9 +4972,8 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "devOptional": true, + "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -5050,7 +5044,6 @@ "integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", @@ -5355,7 +5348,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5619,7 +5611,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6102,8 +6093,7 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/embla-carousel-react": { "version": "8.6.0", @@ -6198,7 +6188,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6790,7 +6779,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -7245,7 +7233,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7293,7 +7280,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7500,7 +7486,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -7531,7 +7516,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -7544,7 +7528,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.70.0.tgz", "integrity": "sha512-COOMajS4FI3Wuwrs3GPpi/Jeef/5W1DRR84Yl5/ShlT3dKVFUfoGiEZ/QE6Uw8P4T2/CLJdcTVYKvWBMQTEpvw==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -8106,7 +8089,6 @@ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -8263,7 +8245,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8488,7 +8469,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -8595,8 +8575,7 @@ "resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz", "integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==", "deprecated": "This package is now deprecated. Move to @xterm/xterm instead.", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/xterm-addon-fit": { "version": "0.8.0", @@ -8643,7 +8622,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 18d90dd..f76697f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,6 +18,7 @@ import NodeDetailPage from '@/components/kubernetes/NodeDetailPage'; import NamespaceOverviewPage from '@/components/kubernetes/NamespaceOverviewPage'; import NamespaceResourcePage from '@/components/kubernetes/NamespaceResourcePage'; import DeploymentDetailPage from '@/components/kubernetes/DeploymentDetailPage'; +import WorkloadViewPage from '@/components/kubernetes/WorkloadViewPage'; import { TopologyView } from '@/components/kubernetes/TopologyView'; import { KubernetesNodes } from '@/components/kubernetes/KubernetesNodes'; import { KubernetesWorkloads } from '@/components/kubernetes/KubernetesWorkloads'; @@ -99,7 +100,9 @@ function App() { } /> {/* 🆕 Kuboard 风格双模式工作负载路由 (view/edit) */} - } /> + } /> + } /> + } /> {/* 各类资源列表 */} diff --git a/frontend/src/components/kubernetes/NamespaceResourcePage.tsx b/frontend/src/components/kubernetes/NamespaceResourcePage.tsx index 8df2147..63184e7 100644 --- a/frontend/src/components/kubernetes/NamespaceResourcePage.tsx +++ b/frontend/src/components/kubernetes/NamespaceResourcePage.tsx @@ -204,8 +204,8 @@ const RESOURCE_CONFIG: Record ( - + renderRow: (s, opts) => ( + opts.onDetail?.(s)}> {s.name} {s.namespace} {s.replicas || 0} @@ -217,8 +217,8 @@ const RESOURCE_CONFIG: Record ( - + renderRow: (d, opts) => ( + opts.onDetail?.(d)}> {d.name} {d.namespace} {d.desired || 0} @@ -308,6 +308,10 @@ export default function NamespaceResourcePage() { if (type === 'deployments') { // Kuboard 风格双模式路由(默认 view 模式) navigate(`/kubernetes/${clusterCode}/namespace/${namespace}/workload/view/Deployment/${item.name}`); + } else if (type === 'statefulsets') { + navigate(`/kubernetes/${clusterCode}/namespace/${namespace}/workload/view/StatefulSet/${item.name}`); + } else if (type === 'daemonsets') { + navigate(`/kubernetes/${clusterCode}/namespace/${namespace}/workload/view/DaemonSet/${item.name}`); } }; diff --git a/frontend/src/components/kubernetes/PodExecTerminal.tsx b/frontend/src/components/kubernetes/PodExecTerminal.tsx index 5fef82d..e7c6da2 100644 --- a/frontend/src/components/kubernetes/PodExecTerminal.tsx +++ b/frontend/src/components/kubernetes/PodExecTerminal.tsx @@ -23,38 +23,21 @@ import { cn } from '@/lib/utils'; import { kubernetesApi } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; -/** - * Pod 级 exec 终端 — 进入容器执行命令 (kubectl exec -it) - * - * 复用 KubectlTerminal.tsx 的 xterm.js 模式,连接 Pod exec WebSocket 通道: - * ws://host/ws/kubernetes-exec/:clusterId/namespaces/:ns/pods/:pod?container=&token= - * - * 支持: - * - 容器选择下拉(默认第一个容器,可切换) - * - 自定义 shell(默认 bash,可切 sh) - * - Dialog/Modal 形式,全屏切换 - * - 终端 resize 透传(ResizeObserver + fit) - * - * @author Logan - */ export interface PodExecTerminalProps { open: boolean; onOpenChange: (open: boolean) => void; - /** 集群 ID(用于 ws 路由 :clusterId) */ clusterId: number | string; namespace: string; podName: string; - /** 可选:Pod 容器名列表,缺省时仅显示 shell 选择 */ containers?: string[]; - /** 初始容器 */ initialContainer?: string; } type ConnState = 'idle' | 'connecting' | 'connected' | 'error' | 'closed'; const SHELLS = [ - { value: 'bash', label: '/bin/bash' }, { value: 'sh', label: '/bin/sh' }, + { value: 'bash', label: '/bin/bash' }, ]; export function PodExecTerminal({ @@ -71,268 +54,287 @@ export function PodExecTerminal({ const termRef = useRef(null); const wsRef = useRef(null); const fitRef = useRef(null); + const mountedRef = useRef(false); const [connState, setConnState] = useState('idle'); const [fullscreen, setFullscreen] = useState(false); const [container, setContainer] = useState(initialContainer || containers[0] || ''); - const [shell, setShell] = useState('bash'); + const [shell, setShell] = useState('sh'); - // 重置全屏状态 + // ─── 重置 ─── useEffect(() => { - if (!open) setFullscreen(false); + if (!open) { + setFullscreen(false); + mountedRef.current = false; + } }, [open]); - // 容器列表变化时同步默认值 useEffect(() => { if (open && !container && containers.length > 0) { setContainer(initialContainer || containers[0]); } }, [open, containers, initialContainer, container]); - // 创建 xterm 实例(仅在 open 时创建一次,容器/shell 变化时重连 ws) + // ─── 连接 WebSocket ─── const connect = useCallback(() => { - if (!termRef.current) return; + console.log('[PodExec:connect] START clusterId=', clusterId, 'namespace=', namespace, 'podName=', podName, 'container=', container, 'shell=', shell); + + if (!termRef.current) { + console.error('[PodExec:connect] ABORT: termRef.current is null'); + return; + } const term = termRef.current; - // 关闭旧连接 if (wsRef.current) { + console.log('[PodExec:connect] closing previous WS'); try { wsRef.current.close(); } catch { /* noop */ } wsRef.current = null; } setConnState('connecting'); term.clear(); - term.writeln(`\x1b[36m[正在连接 ${podName}${container ? '/' + container : ''} ...]\x1b[0m`); + term.writeln(`\x1b[36m[正在连接 ${podName}${container ? '/' + container : ''} shell=${shell} ...]\x1b[0m`); const wsUrl = kubernetesApi.buildPodExecWsUrl(clusterId, namespace, podName, container || undefined, shell); + console.log('[PodExec:connect] WS URL:', wsUrl, '\n location.protocol=', window.location.protocol, '\n location.host=', window.location.host, '\n VITE_API_BASE_URL=', import.meta.env.VITE_API_BASE_URL); + const ws = new WebSocket(wsUrl); wsRef.current = ws; ws.onopen = () => { + console.log('[PodExec:connect] WS onopen — connected, readyState=', ws.readyState); setConnState('connected'); term.reset(); - // 发送初始 resize(command/shell 已在 WS URL 参数中指定) try { const { cols, rows } = term; + console.log('[PodExec:connect] sending resize:', cols, 'x', rows); ws.send(JSON.stringify({ status: 1, cols, rows })); - } catch { /* noop */ } - setTimeout(() => { try { term.focus(); } catch { /* noop */ } }, 100); + } catch (e) { console.error('[PodExec:connect] resize error:', e); } + const tryFocus = () => { try { term.focus(); } catch { /* noop */ } }; + tryFocus(); setTimeout(tryFocus, 100); setTimeout(tryFocus, 300); }; ws.onmessage = (event) => { try { const msg = JSON.parse(event.data); if (msg.status === 0 && typeof msg.data === 'string') { + console.debug('[PodExec:ws] RX stdout, len=', msg.data.length); term.write(msg.data); - } else if (msg.status === 1) { - // 服务端 resize 确认,忽略 } else if (msg.status === 2 || msg.error) { const errMsg = msg.data || msg.message || msg.error || '执行错误'; - term.write(errMsg.includes('\\x1b') ? errMsg : `\\r\\n\\x1b[31m${errMsg}\\x1b[0m\\r\\n`); + console.warn('[PodExec] error:', errMsg.substring(0, 200)); + if (errMsg.includes('not found') && shell === 'bash') { + term.writeln('\r\n\x1b[33m⚠ bash 不可用,请切换 /bin/sh 后重连。\x1b[0m'); + return; + } + term.write(errMsg.includes('\x1b') ? errMsg : `\r\n\x1b[31m${errMsg}\x1b[0m\r\n`); } } catch { if (typeof event.data === 'string') term.write(event.data); } }; - ws.onclose = () => { + ws.onclose = (ev) => { + console.log('[PodExec:connect] WS closed: code=', ev.code, 'reason=', ev.reason, 'wasClean=', ev.wasClean); setConnState((s) => (s === 'error' ? s : 'closed')); term.write('\r\n\x1b[33m[连接已断开]\x1b[0m\r\n'); }; - ws.onerror = () => { + ws.onerror = (ev) => { + console.error('[PodExec:connect] WS error event fired, readyState=', ws.readyState, 'type=', ev.type); setConnState('error'); term.write('\r\n\x1b[31m[连接发生错误]\x1b[0m\r\n'); }; }, [clusterId, namespace, podName, container, shell]); - // 创建/销毁 xterm + 绑定 onData(生命周期跟随 open) + // DEBUG: 组件入口 + console.log('[PodExec:render] open=', open, 'clusterId=', clusterId, 'podName=', podName, 'namespace=', namespace, 'containers=', containers.length, 'container=', container, 'shell=', shell, 'connState=', connState); + + // ─── 创建 xterm + 连接(用 rAF 重试等 Dialog 渲染完)─── useEffect(() => { - if (!open || !clusterId || !podName) return; + console.log('[PodExec:initEffect] TRIGGERED open=', open, 'clusterId=', clusterId, 'podName=', podName, 'namespace=', namespace); + if (!open) { console.log('[PodExec:initEffect] ABORT: open=false'); return; } + if (!clusterId) { console.error('[PodExec:initEffect] ABORT: clusterId is falsy:', clusterId); return; } + if (!podName) { console.error('[PodExec:initEffect] ABORT: podName is falsy:', podName); return; } - const term = new XTerm({ - cursorBlink: true, - fontSize: 14, - fontFamily: 'Menlo, Monaco, "Courier New", monospace', - allowProposedApi: true, - scrollback: 5000, - theme: { - background: '#0f172a', - foreground: '#e2e8f0', - cursor: '#38bdf8', - selectionBackground: '#1e40af', - black: '#1e293b', red: '#ef4444', green: '#10b981', - yellow: '#f59e0b', blue: '#3b82f6', magenta: '#a855f7', - cyan: '#06b6d4', white: '#e2e8f0', - brightBlack: '#475569', brightRed: '#f87171', brightGreen: '#34d399', - brightYellow: '#fbbf24', brightBlue: '#60a5fa', brightMagenta: '#c084fc', - brightCyan: '#22d3ee', brightWhite: '#f8fafc', - }, - }); - const fit = new FitAddon(); - term.loadAddon(fit); - termRef.current = term; - fitRef.current = fit; + let disposed = false; + let retryCount = 0; + const startTime = performance.now(); - const onData = (data: string) => { - if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { - wsRef.current.send(JSON.stringify({ status: 0, data })); + const tryInit = () => { + if (disposed) { console.log('[PodExec:tryInit] disposed, aborting'); return; } + const el = containerRef.current; + console.log('[PodExec:tryInit] retry=', retryCount, 'el=', el ? `DIV ${el.offsetWidth}x${el.offsetHeight}` : 'NULL', 'el.parentElement=', el?.parentElement ? 'YES' : 'NO'); + if (!el) { + retryCount++; + if (retryCount < 20) { + requestAnimationFrame(tryInit); + } else { + console.error('[PodExec:tryInit] FAILED: containerRef still null after 20 retries (', Math.round(performance.now()-startTime), 'ms)'); + } + return; } - }; - term.onData(onData); - if (containerRef.current) { - term.open(containerRef.current); - requestAnimationFrame(() => { - try { fit.fit(); } catch { /* noop */ } + console.log('[PodExec:tryInit] SUCCESS: container ready after', retryCount, 'retries, size:', el.offsetWidth, 'x', el.offsetHeight, 'elapsed:', Math.round(performance.now()-startTime), 'ms'); + + const term = new XTerm({ + cursorBlink: true, + fontSize: 14, + fontFamily: 'Menlo, Monaco, "Courier New", monospace', + allowProposedApi: true, + scrollback: 5000, + theme: { + background: '#0f172a', foreground: '#e2e8f0', cursor: '#38bdf8', + selectionBackground: '#1e40af', + black: '#1e293b', red: '#ef4444', green: '#10b981', + yellow: '#f59e0b', blue: '#3b82f6', magenta: '#a855f7', + cyan: '#06b6d4', white: '#e2e8f0', + brightBlack: '#475569', brightRed: '#f87171', brightGreen: '#34d399', + brightYellow: '#fbbf24', brightBlue: '#60a5fa', brightMagenta: '#c084fc', + brightCyan: '#22d3ee', brightWhite: '#f8fafc', + }, }); - } + const fit = new FitAddon(); + term.loadAddon(fit); + termRef.current = term; + fitRef.current = fit; - // 首次连接 - connect(); + term.onData((data: string) => { + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + console.debug('[PodExec:term] TX stdin, len=', data.length, 'bytes=', [...data].map(c => c.charCodeAt(0)).slice(0, 10)); + wsRef.current.send(JSON.stringify({ status: 0, data })); + } else { + console.warn('[PodExec:term] TX dropped — WS not open, readyState=', wsRef.current?.readyState); + } + }); + + term.open(el); + console.log('[PodExec:tryInit] xterm.open() done, rows=', term.rows, 'cols=', term.cols); + requestAnimationFrame(() => { try { fit.fit(); } catch { /* noop */ } }); + + mountedRef.current = true; + connect(); + }; + + requestAnimationFrame(tryInit); return () => { - try { - if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) { - wsRef.current.close(); - } - } catch { /* noop */ } - try { term.dispose(); } catch { /* noop */ } - termRef.current = null; - wsRef.current = null; - fitRef.current = null; - setConnState('idle'); + disposed = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, clusterId, podName]); - // 容器 / shell 变化时重连(需 term 已就绪) + // ─── cleanup on close ─── useEffect(() => { - if (open && termRef.current) { + if (!open) { + console.log('[PodExec:cleanup] dialog closed, cleaning up WS + xterm'); + try { wsRef.current?.close(); } catch { /* noop */ } + try { termRef.current?.dispose(); } catch { /* noop */ } + termRef.current = null; + wsRef.current = null; + fitRef.current = null; + mountedRef.current = false; + setConnState('idle'); + } + }, [open]); + + // ─── 容器/shell 变化时重连 ─── + useEffect(() => { + if (open && termRef.current && mountedRef.current) { + console.log('[PodExec:reconnect] container=', container, 'shell=', shell, 'mounted=', mountedRef.current); connect(); + } else { + console.log('[PodExec:reconnect] SKIP: open=', open, 'hasTerm=', !!termRef.current, 'mounted=', mountedRef.current); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [container, shell]); - // ResizeObserver 监听容器变化自动 fit + 透传 resize + // ─── ResizeObserver ─── useEffect(() => { if (!open) return; - const containerEl = containerRef.current; - if (!containerEl) return; + const el = containerRef.current; + if (!el) return; - let resizeTimer: ReturnType; + let timer: ReturnType; const ro = new ResizeObserver(() => { - clearTimeout(resizeTimer); - resizeTimer = setTimeout(() => { - if (fitRef.current && termRef.current && wsRef.current) { + clearTimeout(timer); + timer = setTimeout(() => { + if (fitRef.current && termRef.current) { try { fitRef.current.fit(); termRef.current.focus(); const { cols, rows } = termRef.current; - if (wsRef.current.readyState === WebSocket.OPEN) { + if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify({ status: 1, cols, rows })); } } catch { /* noop */ } } }, 60); }); - - ro.observe(containerEl); - return () => { - clearTimeout(resizeTimer); - ro.disconnect(); - }; + ro.observe(el); + return () => { clearTimeout(timer); ro.disconnect(); }; }, [open, fullscreen]); + // ─── UI ─── const statusColor = connState === 'connected' ? 'bg-emerald-400' : connState === 'error' ? 'bg-red-400' - : connState === 'closed' ? 'bg-slate-500' - : 'bg-amber-400'; + : connState === 'closed' ? 'bg-slate-500' : 'bg-amber-400'; const statusText = connState === 'connected' ? '已连接' : connState === 'connecting' ? '连接中' : connState === 'error' ? '连接错误' - : connState === 'closed' ? '已断开' - : '空闲'; + : connState === 'closed' ? '已断开' : '空闲'; + + const handleTerminalClick = useCallback(() => { + try { termRef.current?.focus(); } catch { /* noop */ } + }, []); return ( e.preventDefault()} className={cn( 'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0', '!duration-0 !transition-none', - fullscreen - ? '!max-w-none !w-screen !h-screen !rounded-none !translate-x-0 !translate-y-0 !left-0 !top-0' - : '' + fullscreen ? '!max-w-none !w-screen !h-screen !rounded-none !translate-x-0 !translate-y-0 !left-0 !top-0' : '' )} style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '960px', width: '92vw' }} > exec — {podName} 进入 Pod 容器执行命令 - {/* 头部工具栏 */} + {/* 工具栏 */}
- - exec — {podName} - + exec — {podName} {connState === 'connecting' && } {statusText}
-
- {/* 容器选择 */} {containers.length > 0 && ( )} - - {/* Shell 选择 — bash/sh 可选,默认 bash */} - - {/* 重连 */} - - - -
@@ -341,8 +343,9 @@ export function PodExecTerminal({ {/* 终端容器 */}
diff --git a/frontend/src/components/kubernetes/PodFileBrowser.tsx b/frontend/src/components/kubernetes/PodFileBrowser.tsx index 419e691..b928e2f 100644 --- a/frontend/src/components/kubernetes/PodFileBrowser.tsx +++ b/frontend/src/components/kubernetes/PodFileBrowser.tsx @@ -38,6 +38,7 @@ export function PodFileBrowser({ const [preview, setPreview] = useState<{ file: string; content: string } | null>(null); const [uploading, setUploading] = useState(false); const fileInputRef = useRef(null); + const workdirDetected = useRef(false); const loadFiles = useCallback(async (path: string) => { setLoading(true); @@ -64,8 +65,32 @@ export function PodFileBrowser({ }, [clusterCode, namespace, podName, container]); useEffect(() => { - if (open) loadFiles('/'); - }, [open, loadFiles]); + if (open) { + // 检测容器 WORKDIR 作为初始路径(执行 pwd 命令) + const detectWorkdir = async () => { + if (workdirDetected.current) { + loadFiles(currentPath); + return; + } + setLoading(true); + try { + const res: any = await kubernetesApi.execPod(clusterCode, namespace, podName, ['pwd'], container || undefined); + const stdout = (res?.stdout || res?.data?.stdout || '').trim(); + const workdir = stdout && stdout.startsWith('/') ? stdout : '/'; + workdirDetected.current = true; + setCurrentPath(workdir); + loadFiles(workdir); + } catch { + // pwd 失败则默认 / + workdirDetected.current = true; + loadFiles('/'); + } + }; + detectWorkdir(); + } else { + workdirDetected.current = false; + } + }, [open, container]); const handleClick = (entry: FileEntry) => { if (entry.isDir) { diff --git a/frontend/src/components/kubernetes/WorkloadViewPage.tsx b/frontend/src/components/kubernetes/WorkloadViewPage.tsx new file mode 100644 index 0000000..fd67280 --- /dev/null +++ b/frontend/src/components/kubernetes/WorkloadViewPage.tsx @@ -0,0 +1,572 @@ +// WorkloadViewPage.tsx — StatefulSet / DaemonSet 详情页(对标 DeploymentWorkload) +// 支持 view / yaml 双模式 +// 作者: Logan +import { useState, useEffect, useCallback } from 'react'; +import { useParams, useNavigate, useLocation } from 'react-router-dom'; +import { Card, CardContent, CardHeader, CardTitle } 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 { + ArrowLeft, RotateCcw, FileCode, Activity, Container, Terminal, + HardDrive, Minus, Plus, Boxes, Tag, Settings, Database, +} from 'lucide-react'; +import { kubernetesApi } from '@/services/api'; +import { useToast } from '@/hooks/use-toast'; +import { YamlViewer } from './YamlViewer'; +import { PodExecTerminal } from './PodExecTerminal'; +import { PodFileBrowser } from './PodFileBrowser'; +import { formatK8sTime, statusColor, STATUS_COLORS } from './k8s-yaml-utils'; + +type WorkloadKind = 'StatefulSet' | 'DaemonSet'; +type ViewMode = 'view' | 'yaml'; + +function StatCard({ label, value, sub, color }: { label: string; value: React.ReactNode; sub?: string; color?: string }) { + return ( + + +

{label}

+

{value}

+ {sub &&

{sub}

} +
+
+ ); +} + +// Helper: render key-value pairs as badges +function LabelBadges({ labels }: { labels: Record | undefined }) { + const entries = Object.entries(labels || {}); + if (entries.length === 0) return 无; + return ( +
+ {entries.map(([k, v]) => ( + + {k}={v} + + ))} +
+ ); +} + +export default function WorkloadViewPage() { + const params = useParams<{ clusterCode: string; namespace: string; name: string; mode?: string }>(); + const navigate = useNavigate(); + const location = useLocation(); + const { toast } = useToast(); + + const clusterCode = params.clusterCode || ''; + const namespace = params.namespace || ''; + const name = params.name || ''; + + // ⚠️ Fix: kind 是静态路径段(StatefulSet/DaemonSet),不是 :param + // 用 useLocation().pathname 解析,而非 useParams + const kind: WorkloadKind = location.pathname.includes('/DaemonSet/') ? 'DaemonSet' : 'StatefulSet'; + const mode: ViewMode = params.mode === 'yaml' ? 'yaml' : 'view'; + + const [manifest, setManifest] = useState(null); + const [pods, setPods] = useState([]); + const [loading, setLoading] = useState(true); + const [scaleModal, setScaleModal] = useState(false); + const [scaleValue, setScaleValue] = useState(1); + const [actionLoading, setActionLoading] = useState(false); + const [terminalPod, setTerminalPod] = useState(null); + const [fileBrowserPod, setFileBrowserPod] = useState(null); + const [yamlModal, setYamlModal] = useState(false); + + const loadData = useCallback(async () => { + setLoading(true); + try { + const [yamlRes, podsRes] = await Promise.all([ + kubernetesApi.getResourceYaml(clusterCode, namespace, kind.toLowerCase() + 's', name).catch(() => ''), + kubernetesApi.getPods(clusterCode).catch(() => []), + ]); + // yamlRes is raw YAML text — try to parse as JSON (K8s API accepts JSON as YAML superset) + let parsed: any = null; + if (yamlRes && typeof yamlRes === 'string') { + try { parsed = JSON.parse(yamlRes); } catch { parsed = { _raw: yamlRes }; } + } else if (yamlRes && typeof yamlRes === 'object') { + parsed = yamlRes; + } + setManifest(parsed); + + // Filter pods by name prefix and namespace + const podList = Array.isArray(podsRes) ? podsRes : (podsRes?.list || podsRes?.data || []); + const filtered = podList.filter((p: any) => { + const podName = p.name || p.metadata?.name || ''; + const podNs = p.namespace || p.namespace_name || ''; + return podName.startsWith(name) && (!podNs || podNs === namespace); + }); + setPods(filtered); + } catch (e) { + console.error('load error', e); + } finally { + setLoading(false); + } + }, [clusterCode, namespace, name, kind]); + + useEffect(() => { loadData(); }, [loadData]); + + const handleModeChange = (m: ViewMode) => { + if (m === mode) return; + navigate(`/kubernetes/${clusterCode}/namespace/${namespace}/workload/${m}/${kind}/${name}`, { replace: true }); + }; + + const handleRestart = async () => { + if (!confirm(`确认重启 ${kind} ${name}?\n将触发滚动重启。`)) return; + setActionLoading(true); + try { + if (kind === 'StatefulSet') { + await kubernetesApi.restartStatefulSet(clusterCode, namespace, name); + } else { + await kubernetesApi.restartDaemonSet(clusterCode, namespace, name); + } + toast({ title: '重启指令已下发', description: `${kind} 滚动重启已触发` }); + setTimeout(loadData, 1000); + } catch (e: any) { + toast({ title: '重启失败', description: e?.message || String(e), variant: 'destructive' }); + } finally { setActionLoading(false); } + }; + + const handleScale = async () => { + setActionLoading(true); + try { + await kubernetesApi.scaleStatefulSet(clusterCode, namespace, name, scaleValue); + toast({ title: '扩缩容成功', description: `StatefulSet 副本数已更新为 ${scaleValue}` }); + setScaleModal(false); + setTimeout(loadData, 1000); + } catch (e: any) { + toast({ title: '扩缩容失败', description: e?.message || String(e), variant: 'destructive' }); + } finally { setActionLoading(false); } + }; + + // Parse status from manifest + const spec = manifest?.spec || {}; + const status = manifest?.status || {}; + const containers: any[] = spec?.template?.spec?.containers || []; + const initContainers: any[] = spec?.template?.spec?.initContainers || []; + + // For StatefulSet + const replicas = spec.replicas ?? 1; + const readyReplicas = status.readyReplicas ?? 0; + const currentReplicas = status.currentReplicas ?? 0; + const updatedReplicas = status.updatedReplicas ?? 0; + + // For DaemonSet + const desiredScheduled = status.desiredNumberScheduled ?? 0; + const currentScheduled = status.currentNumberScheduled ?? 0; + const numberReady = status.numberReady ?? 0; + const numberAvailable = status.numberAvailable ?? 0; + const numberUpdated = status.updatedNumberScheduled ?? 0; + + const isSts = kind === 'StatefulSet'; + const isHealthy = isSts + ? (readyReplicas === replicas && replicas > 0) + : (numberReady === desiredScheduled && desiredScheduled > 0); + + // Update strategy + const updateStrategy = spec.updateStrategy || {}; + const strategyType = updateStrategy.type || (isSts ? 'RollingUpdate' : 'RollingUpdate'); + + // VolumeClaimTemplates (StatefulSet only) + const vcts: any[] = spec.volumeClaimTemplates || []; + + // Selector + const selectorLabels = spec.selector?.matchLabels || {}; + + // Labels & Annotations + const labels = manifest?.metadata?.labels || {}; + const annotations = manifest?.metadata?.annotations || {}; + + return ( +
+ {/* Header */} +
+
+ + +
+
+

{name}

+ {kind} + {namespace} + {isHealthy + ? 健康 + : 更新中} +
+

集群: {clusterCode}

+
+
+
+ {/* Mode tabs */} +
+ + +
+ {/* Actions */} + {isSts && ( + + )} + + +
+
+ + {loading ? ( +
加载中...
+ ) : mode === 'yaml' ? ( + + + {}} + inline + autoEdit + /> + + + ) : ( + <> + {/* Status cards */} +
+ {isSts ? ( + <> + + + + + + {formatK8sTime(manifest?.metadata?.creationTimestamp)}} color="#94a3b8" /> + + ) : ( + <> + + + + + + {formatK8sTime(manifest?.metadata?.creationTimestamp)}} color="#94a3b8" /> + + )} +
+ + {/* Update Strategy + Selector */} +
+ + + + 更新策略 + + + +
+ 策略类型 + {strategyType} +
+ {strategyType === 'RollingUpdate' && isSts && updateStrategy.rollingUpdate && ( + <> +
+ 分区 (partition) + {updateStrategy.rollingUpdate.partition ?? 0} +
+ {updateStrategy.rollingUpdate.maxUnavailable && ( +
+ 最大不可用 + {typeof updateStrategy.rollingUpdate.maxUnavailable === 'object' ? JSON.stringify(updateStrategy.rollingUpdate.maxUnavailable) : updateStrategy.rollingUpdate.maxUnavailable} +
+ )} + + )} + {strategyType === 'RollingUpdate' && !isSts && updateStrategy.rollingUpdate && ( + <> + {updateStrategy.rollingUpdate.maxUnavailable && ( +
+ 最大不可用 + {typeof updateStrategy.rollingUpdate.maxUnavailable === 'object' ? JSON.stringify(updateStrategy.rollingUpdate.maxUnavailable) : updateStrategy.rollingUpdate.maxUnavailable} +
+ )} + {updateStrategy.rollingUpdate.maxSurge && ( +
+ 最大 surge + {typeof updateStrategy.rollingUpdate.maxSurge === 'object' ? JSON.stringify(updateStrategy.rollingUpdate.maxSurge) : updateStrategy.rollingUpdate.maxSurge} +
+ )} + + )} + {isSts && ( +
+ Pod 管理策略 + {spec.podManagementPolicy || 'OrderedReady'} +
+ )} +
+ 修订历史限制 + {spec.revisionHistoryLimit ?? 10} +
+
+
+ + + + + 选择器与标签 + + + +
+

Selector (matchLabels)

+ +
+
+

Labels

+ +
+ {Object.keys(annotations).length > 0 && ( +
+

Annotations

+
+ {Object.entries(annotations).slice(0, 8).map(([k, v]) => ( + + {k} + + ))} +
+
+ )} +
+
+
+ + {/* VolumeClaimTemplates (StatefulSet only) */} + {isSts && vcts.length > 0 && ( + + + + 存储卷声明模板 (VolumeClaimTemplates) + + + + + + + 名称 + 访问模式 + 存储类 + 大小 + + + + {vcts.map((vct: any, idx: number) => { + const accessModes = (vct.spec?.accessModes || []).join(', '); + const storageClass = vct.spec?.storageClassName || vct.spec?.storageClassName || '-'; + const size = vct.spec?.resources?.requests?.storage || '-'; + return ( + + {vct.metadata?.name || '-'} + {accessModes || '-'} + {storageClass} + {size} + + ); + })} + +
+
+
+ )} + + {/* Container template */} + + + + 容器模板(Pod Template) + + + + {initContainers.length > 0 && ( +
+

Init Containers

+
+ {initContainers.map((c: any, idx: number) => ( + + ))} +
+
+ )} + {containers.length === 0 ? ( +

暂无容器数据,请查看 YAML

+ ) : ( +
+

Containers

+ {containers.map((c: any, idx: number) => ( + + ))} +
+ )} +
+
+ + {/* Pod list */} + + + + Pod 实例 ({pods.length}) + + + + + + + Pod 名称 + 状态 + 所在节点 + Pod IP + 重启 + 操作 + + + + {pods.length === 0 ? ( + 暂无 Pod + ) : pods.map((pod: any) => ( + + {pod.name} + + + {pod.status || 'Unknown'} + + + {pod.node_name || pod.node || '-'} + {pod.ip || pod.pod_ip || '-'} + 0 ? 'text-yellow-400' : 'text-slate-500'}>{pod.restart_count || 0} + +
+ + +
+
+
+ ))} +
+
+
+
+ + )} + + {/* Scale modal */} + {scaleModal && ( +
setScaleModal(false)}> +
e.stopPropagation()}> +

伸缩 StatefulSet

+
+ + {scaleValue} + +
+
+ + +
+
+
+ )} + + {/* YAML modal */} + {yamlModal && ( + setYamlModal(false)} + /> + )} + + {/* Pod terminal */} + {terminalPod && ( + { if (!o) setTerminalPod(null); }} + clusterId={clusterCode} + namespace={namespace} + podName={terminalPod.name} + containers={terminalPod.containers?.map((c: any) => c.name).filter(Boolean) || []} + /> + )} + + {/* Pod file browser */} + {fileBrowserPod && ( + { if (!o) setFileBrowserPod(null); }} + clusterCode={clusterCode} + namespace={namespace} + podName={fileBrowserPod.name} + containers={fileBrowserPod.containers?.map((c: any) => c.name).filter(Boolean) || []} + /> + )} +
+ ); +} + +// Container info sub-component +function ContainerInfo({ c }: { c: any }) { + return ( +
+
+
+ + {c.name} +
+ {c.image} +
+
+
+ CPU: {c.resources?.requests?.cpu || '-'}/{c.resources?.limits?.cpu || '-'} +
+
+ 内存: {c.resources?.requests?.memory || '-'}/{c.resources?.limits?.memory || '-'} +
+
+ 端口: + {(c.ports || []).map((p: any) => p.containerPort || p.port).join(', ') || '-'} + +
+
+
+ ); +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 2024279..a42d2d3 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -273,7 +273,9 @@ export const kubernetesApi = { if (command) params.set('command', command); if (token) params.set('token', token); const qs = params.toString(); - return `${wsBase}/ws/kubernetes-exec/${clusterId}/namespaces/${namespace}/pods/${podName}${qs ? `?${qs}` : ''}`; + const url = `${wsBase}/ws/kubernetes-exec/${clusterId}/namespaces/${namespace}/pods/${podName}${qs ? `?${qs}` : ''}`; + console.log('[PodExec:buildUrl]', { clusterId, namespace, podName, container, command, baseUrl, wsBase, url, hasToken: !!token, apiBase: import.meta.env.VITE_API_BASE_URL }); + return url; }, // === 🆕 容器文件浏览器 (Logan ) === @@ -439,6 +441,11 @@ export const kubernetesApi = { getDaemonSets: async (clusterCode: string, namespace: string) => { return http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/daemonsets`); }, + getStatefulSetDetail: async (clusterCode: string, namespace: string, name: string) => { return http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/statefulsets/${name}`); }, + getDaemonSetDetail: async (clusterCode: string, namespace: string, name: string) => { return http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/daemonsets/${name}`); }, + scaleStatefulSet: async (clusterCode: string, namespace: string, name: string, replicas: number) => { return http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/statefulsets/${name}/scale`, { replicas }); }, + restartStatefulSet: async (clusterCode: string, namespace: string, name: string) => { return http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/statefulsets/${name}/restart`, {}); }, + restartDaemonSet: async (clusterCode: string, namespace: string, name: string) => { return http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/daemonsets/${name}/restart`, {}); }, // === YAML === getResourceYaml: async (clusterCode: string, namespace: string, resource: string, name: string) => {