feat(k8s): M11 StatefulSet/DaemonSet 详情页 — WorkloadViewPage 重写 + YAML 路由冲突修复
后端: - routes.go: 为 statefulsets/daemonsets 注册专门 YAML 子路由(Gin radix tree 冲突) - kubernetes.go: GetResourceYAMLNS/UpdateResourceYAMLNS 添加 URL path fallback 前端: - WorkloadViewPage.tsx 重写: 修复 kind 提取 bug(useLocation) + 更新策略/选择器/PVC模板/InitContainers - NamespaceResourcePage.tsx: handleDetail 添加 STS/DS 点击导航 验证: DS YAML API 200 ✅ | DS Detail 200 ✅ | Dep YAML 200 ✅ | SPA路由 200 ✅ | TS零错误 ✅
This commit is contained in:
1 parent
87b7755094
commit
d1c557bb6c
14 files changed
+1081
-156
No files matched your search
@@ -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 验证 |
|
||||
|
||||
### ❌ 缺失 / 进行中
|
||||
|
||||
|
||||
+32
-1
@@ -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 | 多集群资源对比看板 |
|
||||
|
||||
|
||||
+48
@@ -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 全部展示
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
// ============================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Generated
+3
-25
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<Route index element={<NamespaceOverviewPage />} />
|
||||
{/* 🆕 Kuboard 风格双模式工作负载路由 (view/edit) */}
|
||||
<Route path="workload">
|
||||
<Route path=":mode/:workloadKind/:name" element={<DeploymentDetailPage />} />
|
||||
<Route path=":mode/Deployment/:name" element={<DeploymentDetailPage />} />
|
||||
<Route path=":mode/StatefulSet/:name" element={<WorkloadViewPage />} />
|
||||
<Route path=":mode/DaemonSet/:name" element={<WorkloadViewPage />} />
|
||||
</Route>
|
||||
{/* 各类资源列表 */}
|
||||
<Route path="deployments">
|
||||
|
||||
@@ -204,8 +204,8 @@ const RESOURCE_CONFIG: Record<ResourceType, {
|
||||
statefulsets: {
|
||||
title: 'StatefulSets', subtitle: '有状态应用', icon: Boxes,
|
||||
columns: ['名称', '命名空间', '副本', '就绪', '镜像'],
|
||||
renderRow: (s) => (
|
||||
<TableRow key={s.name} className="border-slate-800 hover:bg-slate-800/30">
|
||||
renderRow: (s, opts) => (
|
||||
<TableRow key={s.name} className="border-slate-800 hover:bg-slate-800/30 cursor-pointer" onClick={() => opts.onDetail?.(s)}>
|
||||
<TableCell className="font-medium text-slate-200">{s.name}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{s.namespace}</Badge></TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{s.replicas || 0}</TableCell>
|
||||
@@ -217,8 +217,8 @@ const RESOURCE_CONFIG: Record<ResourceType, {
|
||||
daemonsets: {
|
||||
title: 'DaemonSets', subtitle: '守护进程集', icon: Boxes,
|
||||
columns: ['名称', '命名空间', '期望', '当前', '就绪', '镜像'],
|
||||
renderRow: (d) => (
|
||||
<TableRow key={d.name} className="border-slate-800 hover:bg-slate-800/30">
|
||||
renderRow: (d, opts) => (
|
||||
<TableRow key={d.name} className="border-slate-800 hover:bg-slate-800/30 cursor-pointer" onClick={() => opts.onDetail?.(d)}>
|
||||
<TableCell className="font-medium text-slate-200">{d.name}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{d.namespace}</Badge></TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{d.desired || 0}</TableCell>
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 <admin@attacker.club>
|
||||
*/
|
||||
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,84 +54,128 @@ export function PodExecTerminal({
|
||||
const termRef = useRef<XTerm | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const fitRef = useRef<FitAddon | null>(null);
|
||||
const mountedRef = useRef(false);
|
||||
|
||||
const [connState, setConnState] = useState<ConnState>('idle');
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [container, setContainer] = useState<string>(initialContainer || containers[0] || '');
|
||||
const [shell, setShell] = useState<string>('bash');
|
||||
const [shell, setShell] = useState<string>('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; }
|
||||
|
||||
let disposed = false;
|
||||
let retryCount = 0;
|
||||
const startTime = performance.now();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -157,9 +184,7 @@ export function PodExecTerminal({
|
||||
allowProposedApi: true,
|
||||
scrollback: 5000,
|
||||
theme: {
|
||||
background: '#0f172a',
|
||||
foreground: '#e2e8f0',
|
||||
cursor: '#38bdf8',
|
||||
background: '#0f172a', foreground: '#e2e8f0', cursor: '#38bdf8',
|
||||
selectionBackground: '#1e40af',
|
||||
black: '#1e293b', red: '#ef4444', green: '#10b981',
|
||||
yellow: '#f59e0b', blue: '#3b82f6', magenta: '#a855f7',
|
||||
@@ -174,165 +199,142 @@ export function PodExecTerminal({
|
||||
termRef.current = term;
|
||||
fitRef.current = fit;
|
||||
|
||||
const onData = (data: string) => {
|
||||
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.onData(onData);
|
||||
|
||||
if (containerRef.current) {
|
||||
term.open(containerRef.current);
|
||||
requestAnimationFrame(() => {
|
||||
try { fit.fit(); } catch { /* noop */ }
|
||||
});
|
||||
}
|
||||
|
||||
// 首次连接
|
||||
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<typeof setTimeout>;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
onOpenAutoFocus={(e) => 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' }}
|
||||
>
|
||||
<DialogTitle className="sr-only">exec — {podName}</DialogTitle>
|
||||
<DialogDescription className="sr-only">进入 Pod 容器执行命令</DialogDescription>
|
||||
|
||||
{/* 头部工具栏 */}
|
||||
{/* 工具栏 */}
|
||||
<div className="flex items-center justify-between gap-2 px-4 py-2 border-b border-[var(--dg-border)] flex-wrap">
|
||||
<div className="flex items-center gap-2 text-sm min-w-0">
|
||||
<div className={cn('w-2 h-2 rounded-full shrink-0', statusColor)} title={statusText} />
|
||||
<Terminal className="h-4 w-4 text-blue-400 shrink-0" />
|
||||
<span className="text-[var(--dg-text-primary)] font-medium truncate">
|
||||
exec — {podName}
|
||||
</span>
|
||||
<span className="text-[var(--dg-text-primary)] font-medium truncate">exec — {podName}</span>
|
||||
{connState === 'connecting' && <Loader2 className="h-3 w-3 animate-spin text-[var(--dg-text-muted)]" />}
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">{statusText}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 容器选择 */}
|
||||
{containers.length > 0 && (
|
||||
<Select value={container} onValueChange={setContainer}>
|
||||
<SelectTrigger className="h-8 w-[150px] text-xs bg-[var(--dg-bg-hover)] border-[var(--dg-border)]">
|
||||
<Box className="h-3 w-3 mr-1 text-[var(--dg-text-muted)]" />
|
||||
<SelectValue placeholder="容器" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{containers.map((c) => (
|
||||
<SelectItem key={c} value={c}>{c}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
<SelectContent>{containers.map((c) => <SelectItem key={c} value={c}>{c}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{/* Shell 选择 — bash/sh 可选,默认 bash */}
|
||||
<Select value={shell} onValueChange={setShell}>
|
||||
<SelectTrigger className="h-8 w-[110px] text-xs bg-[var(--dg-bg-hover)] border-[var(--dg-border)]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SHELLS.map(s => (
|
||||
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
<SelectContent>{SHELLS.map(s => <SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 重连 */}
|
||||
<Button
|
||||
variant="ghost" size="sm"
|
||||
className="h-8 px-2 text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]"
|
||||
onClick={() => { connect(); toast({ title: '正在重连…' }); }}
|
||||
title="重连"
|
||||
>
|
||||
<Button variant="ghost" size="sm" className="h-8 px-2 text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]"
|
||||
onClick={() => { connect(); toast({ title: '正在重连…' }); }} title="重连">
|
||||
<RotateCw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
<button
|
||||
onClick={() => setFullscreen(!fullscreen)}
|
||||
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
|
||||
title={fullscreen ? '退出全屏' : '全屏'}
|
||||
>
|
||||
<button onClick={() => setFullscreen(!fullscreen)} className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]" title={fullscreen ? '退出全屏' : '全屏'}>
|
||||
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
|
||||
title="关闭"
|
||||
>
|
||||
<button onClick={() => onOpenChange(false)} className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]" title="关闭">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -341,8 +343,9 @@ export function PodExecTerminal({
|
||||
{/* 终端容器 */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="bg-[#0f172a]"
|
||||
className="bg-[#0f172a] cursor-text"
|
||||
style={{ height: fullscreen ? 'calc(100vh - 49px)' : '480px' }}
|
||||
onClick={handleTerminalClick}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -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<HTMLInputElement>(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) {
|
||||
|
||||
@@ -0,0 +1,572 @@
|
||||
// WorkloadViewPage.tsx — StatefulSet / DaemonSet 详情页(对标 DeploymentWorkload)
|
||||
// 支持 view / yaml 双模式
|
||||
// 作者: Logan <admin@attacker.club>
|
||||
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 (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-3">
|
||||
<p className="text-xs text-slate-500">{label}</p>
|
||||
<p className="text-base font-bold mt-0.5" style={{ color: color || '#e2e8f0' }}>{value}</p>
|
||||
{sub && <p className="text-[10px] text-slate-600 mt-0.5">{sub}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper: render key-value pairs as badges
|
||||
function LabelBadges({ labels }: { labels: Record<string, string> | undefined }) {
|
||||
const entries = Object.entries(labels || {});
|
||||
if (entries.length === 0) return <span className="text-xs text-slate-600">无</span>;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{entries.map(([k, v]) => (
|
||||
<Badge key={k} variant="outline" className="border-slate-700 text-slate-400 text-[10px] font-mono">
|
||||
{k}={v}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<any>(null);
|
||||
const [pods, setPods] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [scaleModal, setScaleModal] = useState(false);
|
||||
const [scaleValue, setScaleValue] = useState(1);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [terminalPod, setTerminalPod] = useState<any>(null);
|
||||
const [fileBrowserPod, setFileBrowserPod] = useState<any>(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 (
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-200" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Boxes className="h-5 w-5 text-blue-400" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-lg font-bold text-slate-100">{name}</h1>
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-400">{kind}</Badge>
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-400">{namespace}</Badge>
|
||||
{isHealthy
|
||||
? <Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30">健康</Badge>
|
||||
: <Badge className="bg-amber-500/20 text-amber-400 border-amber-500/30">更新中</Badge>}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-0.5">集群: {clusterCode}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Mode tabs */}
|
||||
<div className="flex items-center gap-1 bg-slate-900 border border-slate-800 rounded-lg p-0.5">
|
||||
<button
|
||||
className={`px-3 py-1 text-xs rounded transition-colors ${mode === 'view' ? 'bg-blue-500/20 text-blue-400' : 'text-slate-400 hover:text-slate-200'}`}
|
||||
onClick={() => handleModeChange('view')}
|
||||
>查看</button>
|
||||
<button
|
||||
className={`px-3 py-1 text-xs rounded transition-colors ${mode === 'yaml' ? 'bg-blue-500/20 text-blue-400' : 'text-slate-400 hover:text-slate-200'}`}
|
||||
onClick={() => handleModeChange('yaml')}
|
||||
>YAML</button>
|
||||
</div>
|
||||
{/* Actions */}
|
||||
{isSts && (
|
||||
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300 hover:text-slate-100"
|
||||
onClick={() => { setScaleValue(replicas); setScaleModal(true); }}>
|
||||
<Boxes className="h-3.5 w-3.5 mr-1" />伸缩
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300 hover:text-slate-100"
|
||||
disabled={actionLoading} onClick={handleRestart}>
|
||||
<RotateCcw className="h-3.5 w-3.5 mr-1" />{actionLoading ? '重启中...' : '重启'}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300 hover:text-slate-100"
|
||||
onClick={() => setYamlModal(true)}>
|
||||
<FileCode className="h-3.5 w-3.5 mr-1" />YAML
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center text-slate-500 py-20">加载中...</div>
|
||||
) : mode === 'yaml' ? (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-4">
|
||||
<YamlViewer
|
||||
clusterCode={clusterCode}
|
||||
namespace={namespace}
|
||||
resource={kind.toLowerCase() + 's'}
|
||||
name={name}
|
||||
onClose={() => {}}
|
||||
inline
|
||||
autoEdit
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{/* Status cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||
{isSts ? (
|
||||
<>
|
||||
<StatCard label="副本就绪" value={`${readyReplicas}/${replicas}`} color={isHealthy ? STATUS_COLORS.green : STATUS_COLORS.orange} sub={isHealthy ? '全部就绪' : '部分未就绪'} />
|
||||
<StatCard label="当前副本" value={currentReplicas} color={STATUS_COLORS.blue} />
|
||||
<StatCard label="已更新副本" value={updatedReplicas} color={STATUS_COLORS.green} />
|
||||
<StatCard label="服务名" value={spec.serviceName || '-'} color="#94a3b8" />
|
||||
<StatCard label="Pod 数量" value={pods.length} color="#94a3b8" />
|
||||
<StatCard label="创建时间" value={<span className="text-xs">{formatK8sTime(manifest?.metadata?.creationTimestamp)}</span>} color="#94a3b8" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<StatCard label="期望调度" value={desiredScheduled} color={STATUS_COLORS.blue} />
|
||||
<StatCard label="当前调度" value={currentScheduled} color="#94a3b8" />
|
||||
<StatCard label="就绪数量" value={numberReady} color={isHealthy ? STATUS_COLORS.green : STATUS_COLORS.orange} />
|
||||
<StatCard label="可用数量" value={numberAvailable} color={STATUS_COLORS.green} />
|
||||
<StatCard label="已更新" value={numberUpdated} color="#94a3b8" />
|
||||
<StatCard label="创建时间" value={<span className="text-xs">{formatK8sTime(manifest?.metadata?.creationTimestamp)}</span>} color="#94a3b8" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Update Strategy + Selector */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
|
||||
<Settings className="h-4 w-4 text-amber-400" />更新策略
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 space-y-2 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">策略类型</span>
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-300">{strategyType}</Badge>
|
||||
</div>
|
||||
{strategyType === 'RollingUpdate' && isSts && updateStrategy.rollingUpdate && (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">分区 (partition)</span>
|
||||
<span className="text-slate-300 font-mono">{updateStrategy.rollingUpdate.partition ?? 0}</span>
|
||||
</div>
|
||||
{updateStrategy.rollingUpdate.maxUnavailable && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">最大不可用</span>
|
||||
<span className="text-slate-300 font-mono">{typeof updateStrategy.rollingUpdate.maxUnavailable === 'object' ? JSON.stringify(updateStrategy.rollingUpdate.maxUnavailable) : updateStrategy.rollingUpdate.maxUnavailable}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{strategyType === 'RollingUpdate' && !isSts && updateStrategy.rollingUpdate && (
|
||||
<>
|
||||
{updateStrategy.rollingUpdate.maxUnavailable && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">最大不可用</span>
|
||||
<span className="text-slate-300 font-mono">{typeof updateStrategy.rollingUpdate.maxUnavailable === 'object' ? JSON.stringify(updateStrategy.rollingUpdate.maxUnavailable) : updateStrategy.rollingUpdate.maxUnavailable}</span>
|
||||
</div>
|
||||
)}
|
||||
{updateStrategy.rollingUpdate.maxSurge && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">最大 surge</span>
|
||||
<span className="text-slate-300 font-mono">{typeof updateStrategy.rollingUpdate.maxSurge === 'object' ? JSON.stringify(updateStrategy.rollingUpdate.maxSurge) : updateStrategy.rollingUpdate.maxSurge}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isSts && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Pod 管理策略</span>
|
||||
<span className="text-slate-300 font-mono">{spec.podManagementPolicy || 'OrderedReady'}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">修订历史限制</span>
|
||||
<span className="text-slate-300 font-mono">{spec.revisionHistoryLimit ?? 10}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
|
||||
<Tag className="h-4 w-4 text-purple-400" />选择器与标签
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 space-y-3 text-xs">
|
||||
<div>
|
||||
<p className="text-slate-500 mb-1.5">Selector (matchLabels)</p>
|
||||
<LabelBadges labels={selectorLabels} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-500 mb-1.5">Labels</p>
|
||||
<LabelBadges labels={labels} />
|
||||
</div>
|
||||
{Object.keys(annotations).length > 0 && (
|
||||
<div>
|
||||
<p className="text-slate-500 mb-1.5">Annotations</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{Object.entries(annotations).slice(0, 8).map(([k, v]) => (
|
||||
<Badge key={k} variant="outline" className="border-slate-700 text-slate-500 text-[10px] font-mono max-w-[300px] truncate">
|
||||
{k}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* VolumeClaimTemplates (StatefulSet only) */}
|
||||
{isSts && vcts.length > 0 && (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
|
||||
<Database className="h-4 w-4 text-cyan-400" />存储卷声明模板 (VolumeClaimTemplates)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<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">大小</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{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 (
|
||||
<TableRow key={idx} className="border-slate-800">
|
||||
<TableCell className="font-medium text-slate-200">{vct.metadata?.name || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{accessModes || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{storageClass}</TableCell>
|
||||
<TableCell className="text-sm text-slate-300 font-mono">{size}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Container template */}
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
|
||||
<Container className="h-4 w-4 text-blue-400" />容器模板(Pod Template)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
{initContainers.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<p className="text-[11px] text-slate-500 mb-2 uppercase tracking-wide">Init Containers</p>
|
||||
<div className="space-y-2">
|
||||
{initContainers.map((c: any, idx: number) => (
|
||||
<ContainerInfo key={`init-${idx}`} c={c} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{containers.length === 0 ? (
|
||||
<p className="text-xs text-slate-500 py-3">暂无容器数据,请查看 YAML</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-[11px] text-slate-500 mb-1 uppercase tracking-wide">Containers</p>
|
||||
{containers.map((c: any, idx: number) => (
|
||||
<ContainerInfo key={idx} c={c} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pod list */}
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-emerald-400" />Pod 实例 ({pods.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">Pod 名称</TableHead>
|
||||
<TableHead className="text-slate-400">状态</TableHead>
|
||||
<TableHead className="text-slate-400">所在节点</TableHead>
|
||||
<TableHead className="text-slate-400">Pod IP</TableHead>
|
||||
<TableHead className="text-slate-400">重启</TableHead>
|
||||
<TableHead className="text-slate-400">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pods.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={6} className="text-center text-slate-500 py-8">暂无 Pod</TableCell></TableRow>
|
||||
) : pods.map((pod: any) => (
|
||||
<TableRow key={pod.name} className="border-slate-800 hover:bg-slate-800/30">
|
||||
<TableCell className="font-medium text-slate-200">{pod.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge style={{ background: `${statusColor(pod.status)}22`, color: statusColor(pod.status), border: `1px solid ${statusColor(pod.status)}44` }}>
|
||||
{pod.status || 'Unknown'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{pod.node_name || pod.node || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-slate-400 font-mono">{pod.ip || pod.pod_ip || '-'}</TableCell>
|
||||
<TableCell className={(pod.restart_count || 0) > 0 ? 'text-yellow-400' : 'text-slate-500'}>{pod.restart_count || 0}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-emerald-400 hover:text-emerald-300 hover:bg-emerald-500/10"
|
||||
onClick={() => setTerminalPod(pod)}>
|
||||
<Terminal className="h-3.5 w-3.5 mr-1" />终端
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-amber-400 hover:text-amber-300 hover:bg-amber-500/10"
|
||||
onClick={() => setFileBrowserPod(pod)}>
|
||||
<HardDrive className="h-3.5 w-3.5 mr-1" />文件浏览器
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Scale modal */}
|
||||
{scaleModal && (
|
||||
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center" onClick={() => setScaleModal(false)}>
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-lg p-6 w-80" onClick={e => e.stopPropagation()}>
|
||||
<h3 className="text-sm font-semibold text-slate-200 mb-4">伸缩 StatefulSet</h3>
|
||||
<div className="flex items-center justify-center gap-4 mb-4">
|
||||
<Button variant="outline" size="icon" className="h-9 w-9" onClick={() => setScaleValue(Math.max(0, scaleValue - 1))}>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-2xl font-bold text-slate-100 w-12 text-center">{scaleValue}</span>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9" onClick={() => setScaleValue(scaleValue + 1)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" className="flex-1 border-slate-700 text-slate-400" onClick={() => setScaleModal(false)}>取消</Button>
|
||||
<Button size="sm" className="flex-1 bg-blue-600 hover:bg-blue-500" disabled={actionLoading} onClick={handleScale}>
|
||||
{actionLoading ? '执行中...' : '确认'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* YAML modal */}
|
||||
{yamlModal && (
|
||||
<YamlViewer
|
||||
clusterCode={clusterCode}
|
||||
namespace={namespace}
|
||||
resource={kind.toLowerCase() + 's'}
|
||||
name={name}
|
||||
onClose={() => setYamlModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Pod terminal */}
|
||||
{terminalPod && (
|
||||
<PodExecTerminal
|
||||
open={true}
|
||||
onOpenChange={(o) => { 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 && (
|
||||
<PodFileBrowser
|
||||
open={true}
|
||||
onOpenChange={(o) => { if (!o) setFileBrowserPod(null); }}
|
||||
clusterCode={clusterCode}
|
||||
namespace={namespace}
|
||||
podName={fileBrowserPod.name}
|
||||
containers={fileBrowserPod.containers?.map((c: any) => c.name).filter(Boolean) || []}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Container info sub-component
|
||||
function ContainerInfo({ c }: { c: any }) {
|
||||
return (
|
||||
<div className="border border-slate-800 rounded-lg p-3 bg-slate-950/30">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Container className="h-4 w-4 text-blue-400" />
|
||||
<span className="text-sm font-semibold text-slate-200">{c.name}</span>
|
||||
</div>
|
||||
<code className="text-[11px] text-slate-400 font-mono truncate max-w-[400px]">{c.image}</code>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-xs">
|
||||
<div className="text-slate-400">
|
||||
CPU: <span className="text-slate-200 font-mono">{c.resources?.requests?.cpu || '-'}/{c.resources?.limits?.cpu || '-'}</span>
|
||||
</div>
|
||||
<div className="text-slate-400">
|
||||
内存: <span className="text-slate-200 font-mono">{c.resources?.requests?.memory || '-'}/{c.resources?.limits?.memory || '-'}</span>
|
||||
</div>
|
||||
<div className="text-slate-400">
|
||||
端口: <span className="text-slate-200 font-mono">
|
||||
{(c.ports || []).map((p: any) => p.containerPort || p.port).join(', ') || '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <admin@attacker.club>) ===
|
||||
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user