From 4dd5e10b46b57549f4e6e348eaeadc7e0900483e Mon Sep 17 00:00:00 2001 From: attacker Date: Thu, 25 Jun 2026 04:51:21 +0800 Subject: [PATCH] =?UTF-8?q?refactor(k8s):=20Q2=20kubernetes.go=20=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E5=8C=96=E6=8B=86=E5=88=86=20=E2=80=94=204204?= =?UTF-8?q?=E8=A1=8C=E5=8D=95=E6=96=87=E4=BB=B6=E2=86=9212=E4=B8=AA?= =?UTF-8?q?=E5=9F=9F=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 handlers/kubernetes.go (4204行/134函数) 按业务域拆分为: - kubernetes.go (106L): struct + constructor + 共享helpers - k8s_cluster.go (642L): 集群CRUD + 同步 + RBAC - k8s_workload.go (696L): Deployment/STS/DS/ReplicaSet操作 - k8s_pod.go (653L): Pod日志/终端/exec/指标 - k8s_pod_files.go (206L): Pod文件浏览/上传/下载 - k8s_resource.go (351L): Service/Ingress/ConfigMap/Secret/PVC/Topology - k8s_hpa_cronjob.go (384L): HPA + CronJob - k8s_yaml.go (213L): YAML get/update/delete/apply - k8s_namespace.go (305L): Namespace CRUD - k8s_events.go (207L): 事件列表+聚合 - k8s_node.go (428L): Node详情+资源分配 - k8s_compare.go (146L): 多集群对比 验收: go build零错误, 425条路由不变, 26个K8s API端点回归测试全通过 --- PROJECT_EVALUATION.md | 2 +- ROADMAP.md | 34 +- TDD_PLAN.md | 10 +- backend/internal/api/handlers/k8s_cluster.go | 642 +++ backend/internal/api/handlers/k8s_compare.go | 146 + backend/internal/api/handlers/k8s_events.go | 207 + .../internal/api/handlers/k8s_hpa_cronjob.go | 384 ++ .../internal/api/handlers/k8s_namespace.go | 305 ++ backend/internal/api/handlers/k8s_node.go | 428 ++ backend/internal/api/handlers/k8s_pod.go | 653 +++ .../internal/api/handlers/k8s_pod_files.go | 206 + backend/internal/api/handlers/k8s_resource.go | 351 ++ backend/internal/api/handlers/k8s_workload.go | 696 +++ backend/internal/api/handlers/k8s_yaml.go | 213 + backend/internal/api/handlers/kubernetes.go | 4104 +---------------- 15 files changed, 4259 insertions(+), 4122 deletions(-) create mode 100644 backend/internal/api/handlers/k8s_cluster.go create mode 100644 backend/internal/api/handlers/k8s_compare.go create mode 100644 backend/internal/api/handlers/k8s_events.go create mode 100644 backend/internal/api/handlers/k8s_hpa_cronjob.go create mode 100644 backend/internal/api/handlers/k8s_namespace.go create mode 100644 backend/internal/api/handlers/k8s_node.go create mode 100644 backend/internal/api/handlers/k8s_pod.go create mode 100644 backend/internal/api/handlers/k8s_pod_files.go create mode 100644 backend/internal/api/handlers/k8s_resource.go create mode 100644 backend/internal/api/handlers/k8s_workload.go create mode 100644 backend/internal/api/handlers/k8s_yaml.go diff --git a/PROJECT_EVALUATION.md b/PROJECT_EVALUATION.md index 261a88f..e6db29c 100644 --- a/PROJECT_EVALUATION.md +++ b/PROJECT_EVALUATION.md @@ -122,7 +122,7 @@ | 问题 | 影响 | 范围 | 建议优先级 | |------|------|------|:----------:| -| `kubernetes.go` 4204 行/134 函数 | 维护困难,合并冲突高发 | 后端 | P0 | +| ~~`kubernetes.go` 4204 行/134 函数~~ → 已拆分为 12 个域文件(最大 696 行) | ✅ 已解决 | 后端 | ~~P0~~ done | | `api.ts` 1324 行/352 方法 | 前端 API 层难以维护 | 前端 | P0 | | 前端 **1026 处** `any` 类型 | 类型安全缺失,重构风险高 | 前端全局 | P0 | | K8s 双路由体系(集群域+Kuboard 域) | 路由冗余 104 条 | 后端 | P1 | diff --git a/ROADMAP.md b/ROADMAP.md index 177df4f..89ee53e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -42,28 +42,32 @@ - [ ] ESLint `no-unused-vars` 降至 < 20 处 - [ ] `console.*` 降至 < 10 处(仅保留 error 级别) -### Q2: 后端 kubernetes.go 模块化拆分 [pending] +### Q2: 后端 kubernetes.go 模块化拆分 [done] **问题**: 单文件 4204 行 / 134 函数,全仓最大文件 **方案**: ``` -handlers/kubernetes.go (4204L) - → handlers/k8s_cluster.go (集群 CRUD + 同步 + metrics) - → handlers/k8s_workload.go (Deployment/STS/DS 读写操作) - → handlers/k8s_pod.go (Pod 日志/终端/文件/exec) - → handlers/k8s_yaml.go (通用 YAML get/update) - → handlers/k8s_namespace.go (Namespace CRUD + 配额) - → handlers/k8s_events.go (事件列表 + 聚合) - → handlers/k8s_node.go (Node 详情 + 资源分配) - → handlers/k8s_compare.go (多集群对比) +handlers/kubernetes.go (4204L → 106L core) + → handlers/kubernetes.go (106L: struct + constructor + shared helpers) + → handlers/k8s_cluster.go (642L: 集群 CRUD + 同步 + RBAC) + → handlers/k8s_workload.go (696L: Deployment/STS/DS/ReplicaSet 读写操作) + → handlers/k8s_pod.go (653L: Pod 日志/终端/exec/指标) + → handlers/k8s_pod_files.go (206L: Pod 文件浏览/上传/下载) + → handlers/k8s_resource.go (351L: Service/Ingress/ConfigMap/Secret/PVC/Topology) + → handlers/k8s_hpa_cronjob.go (384L: HPA + CronJob) + → handlers/k8s_yaml.go (213L: 通用 YAML get/update/delete/apply) + → handlers/k8s_namespace.go (305L: Namespace CRUD + 配额) + → handlers/k8s_events.go (207L: 事件列表 + 聚合) + → handlers/k8s_node.go (428L: Node 详情 + 资源分配) + → handlers/k8s_compare.go (146L: 多集群对比) ``` **验收标准**: -- [ ] 拆分后单文件不超过 800 行 -- [ ] 所有路由不变,API 响应不变 -- [ ] `go build` 零错误 -- [ ] 411 条路由全部可访问(回归测试) +- [x] 拆分后单文件不超过 800 行(最大 k8s_workload.go 696 行) +- [x] 所有路由不变,API 响应不变(425 条路由,26 个核心端点回归测试全通过) +- [x] `go build` 零错误 +- [x] 425 条路由全部可访问(回归测试) ### Q3: 前端 api.ts 模块化拆分 [pending] @@ -182,7 +186,7 @@ services/api.ts (1324L) | ID | 模块 | 优先级 | 状态 | |----|------|:------:|:----:| | Q1 | 前端类型安全治理 | P0 | pending | -| Q2 | kubernetes.go 拆分 | P0 | pending | +| Q2 | kubernetes.go 拆分 | P0 | **done** | | Q3 | api.ts 拆分 | P0 | pending | | F1 | 告警静默规则 | P1 | pending | | F2 | 审计日志导出 | P1 | pending | diff --git a/TDD_PLAN.md b/TDD_PLAN.md index 3560d17..c5d4d3a 100644 --- a/TDD_PLAN.md +++ b/TDD_PLAN.md @@ -153,11 +153,11 @@ docker ps --format '{{.Names}} {{.Status}}' | grep aiops-backend ``` ### 通过标准 -- [ ] U1: `go build` 零错误 -- [ ] U2: 每个拆分文件 < 800 行 -- [ ] U3: 路由数量不变 -- [ ] U4: 7 个核心 API 端点全部返回正确数据 -- [ ] U5: 容器 healthy +- [x] U1: `go build` 零错误 +- [x] U2: 每个拆分文件 < 800 行(最大 k8s_workload.go 696 行,kubernetes.go 降至 106 行核心文件) +- [x] U3: 路由数量不变(425 条路由,routes.go 未修改) +- [x] U4: 26 个核心 API 端点全部返回正确数据(集群/Deployment/Pod/Service/HPA/CronJob/PVC/STS/DS/RBAC/YAML/Events/Namespace/Compare/Node/ReplicaSet/Topology 全通过) +- [x] U5: 容器 healthy --- diff --git a/backend/internal/api/handlers/k8s_cluster.go b/backend/internal/api/handlers/k8s_cluster.go new file mode 100644 index 0000000..7915300 --- /dev/null +++ b/backend/internal/api/handlers/k8s_cluster.go @@ -0,0 +1,642 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "strconv" + "time" + + "ezops/internal/models" + k8sclient "ezops/pkg/kubernetesclient" + + "github.com/gin-gonic/gin" +) + +func (h *KubernetesHandler) ListClusters(c *gin.Context) { + var query models.PageQuery + c.ShouldBindQuery(&query) + + var total int64 + h.db.Model(&models.KubernetesCluster{}).Count(&total) + + var clusters []models.KubernetesCluster + h.db.Offset(query.GetOffset()).Limit(query.GetLimit()).Find(&clusters) + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "success", + Data: models.ListResponse{ + List: clusters, + Total: total, + }, + }) +} + +// GetCluster 获取集群详情 +// @Summary 获取集群详情 +// @Description 获取指定K8s集群的详细信息 +// @Tags K8s集群 +// @Produce json +// @Security Bearer +// @Param id path int true "集群ID" +// @Success 200 {object} models.Response{data=models.KubernetesCluster} +// @Router /kubernetes-clusters/{id} [get] +func (h *KubernetesHandler) GetCluster(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + + var cluster models.KubernetesCluster + if err := h.db.First(&cluster, id).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{ + Code: 404, + Message: "集群不存在", + }) + return + } + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "success", + Data: cluster, + }) +} + +// CreateCluster 创建集群 +// @Summary 创建集群 +// @Description 创建新K8s集群 +// @Tags K8s集群 +// @Accept json +// @Produce json +// @Security Bearer +// @Param request body models.CreateKubernetesClusterRequest true "创建请求" +// @Success 200 {object} models.Response{data=models.KubernetesCluster} +// @Router /kubernetes-clusters [post] +func (h *KubernetesHandler) CreateCluster(c *gin.Context) { + var req models.CreateKubernetesClusterRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{ + Code: 400, + Message: "请求参数错误: " + err.Error(), + }) + return + } + + cluster := models.KubernetesCluster{ + Name: req.Name, + Code: req.Code, + Version: req.Version, + ConfigType: req.ConfigType, + APIServer: req.APIServer, + APIEndpoint: req.APIServer, // auto-sync + Token: req.Token, + KubeConfig: req.KubeConfig, + Description: req.Description, + Status: "healthy", + } + + if err := h.db.Create(&cluster).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{ + Code: 500, + Message: "创建失败", + }) + return + } + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "创建成功", + Data: cluster, + }) +} + +// UpdateCluster 更新集群 +// @Summary 更新集群 +// @Description 更新指定K8s集群信息 +// @Tags K8s集群 +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path int true "集群ID" +// @Param request body models.CreateKubernetesClusterRequest true "更新请求" +// @Success 200 {object} models.Response{data=models.KubernetesCluster} +// @Router /kubernetes-clusters/{id} [put] +func (h *KubernetesHandler) UpdateCluster(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + + // 用 map 接收,避免 binding:"required" 校验 // Logan.Li + var req map[string]interface{} + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{ + Code: 400, + Message: "请求参数错误: " + err.Error(), + }) + return + } + + var cluster models.KubernetesCluster + if err := h.db.First(&cluster, id).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{ + Code: 404, + Message: "集群不存在", + }) + return + } + + // 逐字段更新:空值不覆盖原值(防止凭据被清空) // Logan.Li + updates := map[string]interface{}{} + if v, ok := req["name"]; ok && v != nil && v != "" { + updates["name"] = v + } + if v, ok := req["version"]; ok { + updates["version"] = v + } + if v, ok := req["description"]; ok { + updates["description"] = v + } + if v, ok := req["config_type"]; ok { + updates["config_type"] = v + } + if v, ok := req["api_server"]; ok && v != nil && v != "" { + updates["api_server"] = v + updates["api_endpoint"] = v + } + // Token / KubeConfig:空值不覆盖(前端编辑时可能拿不到原值) + if v, ok := req["token"]; ok && v != nil && v != "" { + updates["token"] = v + } + if v, ok := req["kube_config"]; ok && v != nil && v != "" { + updates["kube_config"] = v + } + + if len(updates) == 0 { + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "无更新内容", + Data: cluster, + }) + return + } + + if err := h.db.Model(&cluster).Updates(updates).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{ + Code: 500, + Message: "更新失败", + }) + return + } + + h.db.First(&cluster, id) + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "更新成功", + Data: cluster, + }) +} + +// GetClusterDetail 返回集群完整信息(含 token/kube_config,供编辑回填) // Logan.Li +func (h *KubernetesHandler) GetClusterDetail(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + + var cluster models.KubernetesCluster + if err := h.db.First(&cluster, id).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{ + Code: 404, + Message: "集群不存在", + }) + return + } + + // 返回时手动脱敏:token/kube_config 只返回是否存在标记,不返回原文 + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "success", + Data: map[string]interface{}{ + "id": cluster.ID, + "name": cluster.Name, + "code": cluster.Code, + "version": cluster.Version, + "status": cluster.Status, + "config_type": cluster.ConfigType, + "api_server": cluster.APIServer, + "api_endpoint": cluster.APIEndpoint, + "description": cluster.Description, + "has_token": cluster.Token != "", + "has_kube_config": cluster.KubeConfig != "", + "node_count": cluster.NodeCount, + "pod_count": cluster.PodCount, + "service_count": cluster.ServiceCount, + "deployment_count": cluster.DeploymentCount, + "cpu_usage": cluster.CPUUsage, + "memory_usage": cluster.MemoryUsage, + "labels": cluster.Labels, + "last_sync_at": cluster.LastSyncAt, + "created_at": cluster.CreatedAt, + "updated_at": cluster.UpdatedAt, + }, + }) +} + +// DeleteCluster 删除集群 +// @Summary 删除集群 +// @Description 删除指定K8s集群 +// @Tags K8s集群 +// @Produce json +// @Security Bearer +// @Param id path int true "集群ID" +// @Success 200 {object} models.Response +// @Router /kubernetes-clusters/{id} [delete] +func (h *KubernetesHandler) DeleteCluster(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + + if err := h.db.Delete(&models.KubernetesCluster{}, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{ + Code: 500, + Message: "删除失败", + }) + return + } + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "删除成功", + }) +} + +// ListNodes 获取节点列表 +// @Summary 获取节点列表 +// @Description 获取指定集群的节点列表 +// @Tags K8s集群 +// @Produce json +// @Security Bearer +// @Param id path int true "集群ID" +// @Success 200 {object} models.Response{data=[]models.KubernetesNode} +// @Router /kubernetes-clusters/{id}/nodes [get] +func (h *KubernetesHandler) SyncCluster(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + + var cluster models.KubernetesCluster + if err := h.db.First(&cluster, id).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "集群不存在"}) + return + } + + if cluster.KubeConfig == "" && cluster.Token == "" { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "集群未配置凭证"}) + return + } + + client, err := k8sclient.NewClient(cluster.KubeConfig) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "连接K8s失败: " + err.Error()}) + return + } + + result := k8sclient.SyncResult{} + + // Fetch node metrics (best-effort — metrics-server may not be installed) // Logan.Li + nodeMetrics := map[string]k8sclient.NodeMetricsItem{} + if m, err := client.GetNodeMetrics(); err == nil { + for _, item := range m.Items { + nodeMetrics[item.Metadata.Name] = item + } + } else { + log.Printf("[K8s] sync: GetNodeMetrics failed (metrics-server not installed?): %v", err) + } + + // Fetch pod metrics (best-effort) // Logan.Li + podMetrics := map[string]k8sclient.PodMetricsItem{} // key: "namespace/podname" + if pm, err := client.ListPodMetrics(""); err == nil { + for _, item := range pm.Items { + podMetrics[item.Metadata.Namespace+"/"+item.Metadata.Name] = item + } + } + + // Sync nodes + if nodes, err := client.ListNodes(); err == nil { + // Use raw SQL for reliability (avoid GORM soft-delete / auto-timestamp issues) + h.db.Exec("DELETE FROM kubernetes_nodes WHERE cluster_id = ?", cluster.ID) + for _, n := range nodes.Items { + role := "worker" + if _, ok := n.Metadata.Labels["node-role.kubernetes.io/master"]; ok { + role = "master" + } else if _, ok := n.Metadata.Labels["node-role.kubernetes.io/control-plane"]; ok { + role = "master" + } + nodeStatus := "NotReady" + for _, cond := range n.Status.Conditions { + if cond.Type == "Ready" && cond.Status == "True" { + nodeStatus = "Ready" + break + } + } + ip := "" + for _, addr := range n.Status.Addresses { + if addr.Type == "InternalIP" { + ip = addr.Address + break + } + } + labelsJSON, _ := json.Marshal(n.Metadata.Labels) + now := time.Now() + // Compute CPU/memory usage percentage from metrics-server data // Logan.Li + cpuUsagePct, memUsagePct := 0.0, 0.0 + if m, ok := nodeMetrics[n.Metadata.Name]; ok { + cpuCores := parseIntResource(n.Status.Capacity["cpu"]) + memMB := parseMemResource(n.Status.Capacity["memory"]) + if cpuCores > 0 { + cpuMillicores := parseCpuNanoToMilli(m.Usage["cpu"]) + cpuUsagePct = float64(cpuMillicores) / float64(cpuCores*1000) * 100 + } + if memMB > 0 { + memMi := parseMemKiToMi(m.Usage["memory"]) + memUsagePct = float64(memMi) / float64(memMB) * 100 + } + } + err2 := h.db.Exec(`INSERT INTO kubernetes_nodes (cluster_id, name, ip, role, status, os, kubelet_version, cpu, memory, max_pods, cpu_usage, memory_usage, labels, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + cluster.ID, n.Metadata.Name, ip, role, nodeStatus, n.Status.NodeInfo.OSImage, n.Status.NodeInfo.KubeletVersion, + parseIntResource(n.Status.Capacity["cpu"]), parseMemResource(n.Status.Capacity["memory"]), parseIntResource(n.Status.Capacity["pods"]), + cpuUsagePct, memUsagePct, string(labelsJSON), now, now).Error + if err2 != nil { + log.Printf("[K8s] insert node %s error: %v", n.Metadata.Name, err2) + } else { + result.Nodes++ + } + } + } else { + log.Printf("[K8s] sync nodes error: %v", err) + } + + // Sync pods + if pods, err := client.ListPods(); err == nil { + h.db.Where("cluster_id = ?", cluster.ID).Delete(&models.KubernetesPod{}) + for _, p := range pods.Items { + image := "" + if len(p.Spec.Containers) > 0 { + image = p.Spec.Containers[0].Image + } + restarts := 0 + if len(p.Status.ContainerStatuses) > 0 { + restarts = p.Status.ContainerStatuses[0].RestartCount + } + labelsJSON, _ := json.Marshal(p.Metadata.Labels) + h.db.Create(&models.KubernetesPod{ + ClusterID: cluster.ID, + Namespace: p.Metadata.Namespace, + Name: p.Metadata.Name, + Status: p.Status.Phase, + NodeName: p.Spec.NodeName, + IP: p.Status.PodIP, + Image: image, + RestartCount: restarts, + Labels: string(labelsJSON), + }) + result.Pods++ + } + } else { + log.Printf("[K8s] sync pods error: %v", err) + } + + // Sync deployments + if deps, err := client.ListDeployments(); err == nil { + h.db.Where("cluster_id = ?", cluster.ID).Delete(&models.KubernetesDeployment{}) + for _, d := range deps.Items { + image := "" + if len(d.Spec.Template.Spec.Containers) > 0 { + image = d.Spec.Template.Spec.Containers[0].Image + } + labelsJSON, _ := json.Marshal(d.Metadata.Labels) + h.db.Create(&models.KubernetesDeployment{ + ClusterID: cluster.ID, + Namespace: d.Metadata.Namespace, + Name: d.Metadata.Name, + Replicas: d.Spec.Replicas, + AvailableReplicas: d.Status.AvailableReplicas, + ReadyReplicas: d.Status.ReadyReplicas, + Image: image, + Labels: string(labelsJSON), + }) + result.Deployments++ + } + } else { + log.Printf("[K8s] sync deps error: %v", err) + } + + // Sync services + if svcs, err := client.ListServices(); err == nil { + h.db.Where("cluster_id = ?", cluster.ID).Delete(&models.KubernetesService{}) + for _, s := range svcs.Items { + portsJSON, _ := json.Marshal(s.Spec.Ports) + selectorJSON, _ := json.Marshal(s.Spec.Selector) + labelsJSON, _ := json.Marshal(s.Metadata.Labels) + h.db.Create(&models.KubernetesService{ + ClusterID: cluster.ID, + Namespace: s.Metadata.Namespace, + Name: s.Metadata.Name, + Type: s.Spec.Type, + ClusterIP: s.Spec.ClusterIP, + Ports: string(portsJSON), + Selector: string(selectorJSON), + Labels: string(labelsJSON), + }) + result.Services++ + } + } + + // Sync ingresses + if ings, err := client.ListIngresses(); err == nil { + h.db.Where("cluster_id = ?", cluster.ID).Delete(&models.KubernetesIngress{}) + for _, ing := range ings.Items { + hosts := []string{} + for _, rule := range ing.Spec.Rules { + hosts = append(hosts, rule.Host) + } + hostsJSON, _ := json.Marshal(hosts) + tls := len(ing.Spec.TLS) > 0 + tlsHosts := []string{} + if tls { + for _, t := range ing.Spec.TLS { + tlsHosts = append(tlsHosts, t.Hosts...) + } + } + tlsHostsJSON, _ := json.Marshal(tlsHosts) + labelsJSON, _ := json.Marshal(ing.Metadata.Labels) + h.db.Create(&models.KubernetesIngress{ + ClusterID: cluster.ID, + Namespace: ing.Metadata.Namespace, + Name: ing.Metadata.Name, + Hosts: string(hostsJSON), + TLS: tls, + TLSHosts: string(tlsHostsJSON), + Labels: string(labelsJSON), + }) + result.Ingresses++ + } + } + + // Update cluster stats + now := time.Now() + h.db.Model(&cluster).Updates(map[string]interface{}{ + "node_count": result.Nodes, + "pod_count": result.Pods, + "deployment_count": result.Deployments, + "service_count": result.Services, + "last_sync_at": &now, + }) + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: fmt.Sprintf("同步完成: %d nodes, %d pods, %d deployments, %d services, %d ingresses", + result.Nodes, result.Pods, result.Deployments, result.Services, result.Ingresses), + Data: result, + }) +} + +// ============================================================================ +// Pod Logs +// ============================================================================ + +// GetPodLogs 获取 Pod 日志(支持 stream=true 实时流) +func (h *KubernetesHandler) ListServiceAccountsNS(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 + } + sas, err := client.ListServiceAccounts(c.Param("namespace")) + if err != nil { + c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } + type SASummary struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + SecretCount int `json:"secret_count"` + ImagePullSecrets []string `json:"image_pull_secrets"` + Age string `json:"age"` + } + result := make([]SASummary, 0, len(sas)) + for _, sa := range sas { + ipsNames := make([]string, 0, len(sa.ImagePullSecrets)) + for _, ips := range sa.ImagePullSecrets { + ipsNames = append(ipsNames, ips.Name) + } + result = append(result, SASummary{ + Namespace: sa.Metadata.Namespace, + Name: sa.Metadata.Name, + SecretCount: len(sa.Secrets), + ImagePullSecrets: ipsNames, + Age: formatAge(sa.Metadata.CreationTimestamp), + }) + } + c.JSON(200, models.Response{Code: 200, Data: result}) +} + +// ListRolesNS lists Roles in a namespace +func (h *KubernetesHandler) ListRolesNS(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 + } + roles, err := client.ListRoles(c.Param("namespace")) + if err != nil { + c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } + type RoleSummary struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + RuleCount int `json:"rule_count"` + Verbs []string `json:"verbs"` + Resources []string `json:"resources"` + Age string `json:"age"` + } + result := make([]RoleSummary, 0, len(roles.Items)) + for _, r := range roles.Items { + allVerbs := []string{} + allResources := []string{} + seen := map[string]bool{} + for _, rule := range r.Rules { + for _, v := range rule.Verbs { + if !seen[v] { + allVerbs = append(allVerbs, v) + seen[v] = true + } + } + for _, res := range rule.Resources { + if !seen[res] { + allResources = append(allResources, res) + seen[res] = true + } + } + } + result = append(result, RoleSummary{ + Namespace: r.Metadata.Namespace, + Name: r.Metadata.Name, + RuleCount: len(r.Rules), + Verbs: allVerbs, + Resources: allResources, + Age: formatAge(r.Metadata.CreationTimestamp), + }) + } + c.JSON(200, models.Response{Code: 200, Data: result}) +} + +// ListRoleBindingsNS lists RoleBindings in a namespace +func (h *KubernetesHandler) ListRoleBindingsNS(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 + } + rbs, err := client.ListRoleBindings(c.Param("namespace")) + if err != nil { + c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } + type RBSummary struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + RoleRefKind string `json:"role_ref_kind"` + RoleRefName string `json:"role_ref_name"` + Subjects []string `json:"subjects"` + Age string `json:"age"` + } + result := make([]RBSummary, 0, len(rbs.Items)) + for _, rb := range rbs.Items { + subjStrs := make([]string, 0, len(rb.Subjects)) + for _, s := range rb.Subjects { + if s.Namespace != "" && s.Namespace != rb.Metadata.Namespace { + subjStrs = append(subjStrs, fmt.Sprintf("%s/%s (%s)", s.Kind, s.Name, s.Namespace)) + } else { + subjStrs = append(subjStrs, fmt.Sprintf("%s/%s", s.Kind, s.Name)) + } + } + result = append(result, RBSummary{ + Namespace: rb.Metadata.Namespace, + Name: rb.Metadata.Name, + RoleRefKind: rb.RoleRef.Kind, + RoleRefName: rb.RoleRef.Name, + Subjects: subjStrs, + Age: formatAge(rb.Metadata.CreationTimestamp), + }) + } + c.JSON(200, models.Response{Code: 200, Data: result}) +} diff --git a/backend/internal/api/handlers/k8s_compare.go b/backend/internal/api/handlers/k8s_compare.go new file mode 100644 index 0000000..a6c3408 --- /dev/null +++ b/backend/internal/api/handlers/k8s_compare.go @@ -0,0 +1,146 @@ +package handlers + +import ( + "time" + + "ezops/internal/models" + + "github.com/gin-gonic/gin" +) + +func (h *KubernetesHandler) GetMultiClusterComparison(c *gin.Context) { + // 1. 获取所有集群 + var clusters []models.KubernetesCluster + h.db.Find(&clusters) + + type ClusterComparison struct { + ID uint `json:"id"` + Name string `json:"name"` + Code string `json:"code"` + Status string `json:"status"` + Version string `json:"version"` + NodeCount int `json:"node_count"` + PodCount int `json:"pod_count"` + DeploymentCount int `json:"deployment_count"` + ServiceCount int `json:"service_count"` + CPUUsage float64 `json:"cpu_usage"` + MemoryUsage float64 `json:"memory_usage"` + APIServer string `json:"api_server"` + LastSyncAt *time.Time `json:"last_sync_at"` + + // Node-level aggregated data from kubernetes_nodes table + TotalCPUCores int `json:"total_cpu_cores"` + TotalMemoryMB int64 `json:"total_memory_mb"` + MasterCount int `json:"master_count"` + WorkerCount int `json:"worker_count"` + HealthyNodes int `json:"healthy_nodes"` + UnhealthyNodes int `json:"unhealthy_nodes"` + } + + // 2. 获取所有节点的聚合数据(按 cluster_id 分组) + type NodeAgg struct { + ClusterID uint + NodeCount int + MasterCount int + WorkerCount int + HealthyNodes int + UnhealthyNodes int + TotalCPU int + TotalMemory int64 + AvgCPUUsage float64 + AvgMemUsage float64 + } + nodeAggMap := map[uint]*NodeAgg{} + { + var nodes []models.KubernetesNode + h.db.Find(&nodes) + for _, n := range nodes { + agg, ok := nodeAggMap[n.ClusterID] + if !ok { + agg = &NodeAgg{ClusterID: n.ClusterID} + nodeAggMap[n.ClusterID] = agg + } + agg.NodeCount++ + agg.TotalCPU += n.CPU + agg.TotalMemory += n.Memory + if n.CPUUsage > 0 || n.MemoryUsage > 0 { + if agg.AvgCPUUsage == 0 { agg.AvgCPUUsage = n.CPUUsage } else { agg.AvgCPUUsage = (agg.AvgCPUUsage*float64(agg.NodeCount-1) + n.CPUUsage) / float64(agg.NodeCount) } + if agg.AvgMemUsage == 0 { agg.AvgMemUsage = n.MemoryUsage } else { agg.AvgMemUsage = (agg.AvgMemUsage*float64(agg.NodeCount-1) + n.MemoryUsage) / float64(agg.NodeCount) } + } + if n.Role == "master" { + agg.MasterCount++ + } else { + agg.WorkerCount++ + } + if n.Status == "Ready" { + agg.HealthyNodes++ + } else { + agg.UnhealthyNodes++ + } + } + } + + // 3. 构建对比数据 + comparisons := []ClusterComparison{} + totalNodes, totalPods, totalDeployments, totalServices := 0, 0, 0, 0 + for _, cl := range clusters { + cc := ClusterComparison{ + ID: cl.ID, + Name: cl.Name, + Code: cl.Code, + Status: cl.Status, + Version: cl.Version, + NodeCount: cl.NodeCount, + PodCount: cl.PodCount, + DeploymentCount: cl.DeploymentCount, + ServiceCount: cl.ServiceCount, + CPUUsage: cl.CPUUsage, + MemoryUsage: cl.MemoryUsage, + APIServer: cl.APIServer, + LastSyncAt: cl.LastSyncAt, + } + // 优先用 node 级别的实时聚合数据 + if agg, ok := nodeAggMap[cl.ID]; ok && agg.NodeCount > 0 { + cc.TotalCPUCores = agg.TotalCPU + cc.TotalMemoryMB = agg.TotalMemory + cc.MasterCount = agg.MasterCount + cc.WorkerCount = agg.WorkerCount + cc.HealthyNodes = agg.HealthyNodes + cc.UnhealthyNodes = agg.UnhealthyNodes + // 如果集群级别的 cpu_usage 为 0,使用节点平均值 + if cc.CPUUsage == 0 && agg.AvgCPUUsage > 0 { + cc.CPUUsage = agg.AvgCPUUsage + } + if cc.MemoryUsage == 0 && agg.AvgMemUsage > 0 { + cc.MemoryUsage = agg.AvgMemUsage + } + } + totalNodes += cc.NodeCount + totalPods += cc.PodCount + totalDeployments += cc.DeploymentCount + totalServices += cc.ServiceCount + comparisons = append(comparisons, cc) + } + + // 4. 返回结果 + c.JSON(200, models.Response{ + Code: 200, + Message: "success", + Data: gin.H{ + "clusters": comparisons, + "totals": gin.H{ + "cluster_count": len(clusters), + "node_count": totalNodes, + "pod_count": totalPods, + "deployment_count": totalDeployments, + "service_count": totalServices, + }, + }, + }) +} + +// ============================================================================ +// RBAC — ServiceAccount / Role / RoleBinding (code-based) +// ============================================================================ + +// ListServiceAccountsNS lists ServiceAccounts in a namespace diff --git a/backend/internal/api/handlers/k8s_events.go b/backend/internal/api/handlers/k8s_events.go new file mode 100644 index 0000000..2f25ff8 --- /dev/null +++ b/backend/internal/api/handlers/k8s_events.go @@ -0,0 +1,207 @@ +package handlers + +import ( + "fmt" + "sort" + "strconv" + "time" + + "ezops/internal/models" + + "github.com/gin-gonic/gin" +) + +func (h *KubernetesHandler) ListEvents(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.Query("namespace") + eventType := c.Query("type") // Normal or Warning + events, err := client.ListEvents(ns) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + + type EventSummary struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Type string `json:"type"` + Reason string `json:"reason"` + Message string `json:"message"` + Object string `json:"object"` + Count int `json:"count"` + FirstTime string `json:"first_time"` + LastTime string `json:"last_time"` + } + result := []EventSummary{} + for _, e := range events.Items { + if eventType != "" && e.Type != eventType { continue } + object := fmt.Sprintf("%s/%s", e.InvolvedObject.Kind, e.InvolvedObject.Name) + if e.InvolvedObject.Namespace != "" { + object = fmt.Sprintf("%s/%s/%s", e.InvolvedObject.Namespace, e.InvolvedObject.Kind, e.InvolvedObject.Name) + } + firstTime := ""; if !e.FirstTimestamp.IsZero() { firstTime = e.FirstTimestamp.Format(time.RFC3339) } + lastTime := ""; if !e.LastTimestamp.IsZero() { lastTime = e.LastTimestamp.Format(time.RFC3339) } + result = append(result, EventSummary{ + Namespace: e.Metadata.Namespace, Name: e.Metadata.Name, Type: e.Type, Reason: e.Reason, + Message: e.Message, Object: object, Count: int(e.Count), + FirstTime: firstTime, LastTime: lastTime, + }) + } + c.JSON(200, models.Response{Code: 200, Data: result}) +} + +// ============================================================================ +// Event Aggregation — 事件聚合统计 (M12) +// ============================================================================ + +// GetEventsSummary returns aggregated event statistics for dashboard +func (h *KubernetesHandler) GetEventsSummary(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.Query("namespace") + events, err := client.ListEvents(ns) + if err != nil { + c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } + + // Time window filter (hours) + timeWindowHours := 0 + if tw := c.Query("hours"); tw != "" { + if v, err := strconv.Atoi(tw); err == nil && v > 0 { + timeWindowHours = v + } + } + now := time.Now() + cutoff := time.Time{} + if timeWindowHours > 0 { + cutoff = now.Add(-time.Duration(timeWindowHours) * time.Hour) + } + + // Aggregation maps + totalCount := 0 + warningCount := 0 + normalCount := 0 + byReason := map[string]int{} + byNamespace := map[string]int{} + byKind := map[string]int{} + recentWarnings := []map[string]interface{}{} + + for _, e := range events.Items { + // Time window filter + if !cutoff.IsZero() && !e.LastTimestamp.IsZero() && e.LastTimestamp.Before(cutoff) { + continue + } + + totalCount++ + count := int(e.Count) + if count == 0 { + count = 1 + } + + nsName := e.Metadata.Namespace + if nsName == "" { + nsName = e.InvolvedObject.Namespace + } + + if e.Type == "Warning" { + warningCount += count + byReason[e.Reason] += count + byNamespace[nsName] += count + byKind[e.InvolvedObject.Kind] += count + + // Collect recent warning events (top 10 by lastTimestamp) + if len(recentWarnings) < 50 { + recentWarnings = append(recentWarnings, map[string]interface{}{ + "namespace": nsName, + "type": e.Type, + "reason": e.Reason, + "message": e.Message, + "object": fmt.Sprintf("%s/%s", e.InvolvedObject.Kind, e.InvolvedObject.Name), + "count": count, + "last_time": e.LastTimestamp.Format(time.RFC3339), + "first_time": e.FirstTimestamp.Format(time.RFC3339), + }) + } + } else { + normalCount += count + } + } + + // Sort byReason by count desc → top reasons + type ReasonStat struct { + Reason string `json:"reason"` + Count int `json:"count"` + } + reasonList := make([]ReasonStat, 0, len(byReason)) + for r, cnt := range byReason { + reasonList = append(reasonList, ReasonStat{Reason: r, Count: cnt}) + } + sort.Slice(reasonList, func(i, j int) bool { return reasonList[i].Count > reasonList[j].Count }) + if len(reasonList) > 15 { + reasonList = reasonList[:15] + } + + // Sort byNamespace + type NsStat struct { + Namespace string `json:"namespace"` + Count int `json:"count"` + } + nsList := make([]NsStat, 0, len(byNamespace)) + for n, cnt := range byNamespace { + nsList = append(nsList, NsStat{Namespace: n, Count: cnt}) + } + sort.Slice(nsList, func(i, j int) bool { return nsList[i].Count > nsList[j].Count }) + if len(nsList) > 15 { + nsList = nsList[:15] + } + + // Sort byKind + type KindStat struct { + Kind string `json:"kind"` + Count int `json:"count"` + } + kindList := make([]KindStat, 0, len(byKind)) + for k, cnt := range byKind { + kindList = append(kindList, KindStat{Kind: k, Count: cnt}) + } + sort.Slice(kindList, func(i, j int) bool { return kindList[i].Count > kindList[j].Count }) + + // Sort recentWarnings by last_time desc + sort.Slice(recentWarnings, func(i, j int) bool { + ti, _ := recentWarnings[i]["last_time"].(string) + tj, _ := recentWarnings[j]["last_time"].(string) + return ti > tj + }) + if len(recentWarnings) > 10 { + recentWarnings = recentWarnings[:10] + } + + result := map[string]interface{}{ + "total": totalCount, + "warning_count": warningCount, + "normal_count": normalCount, + "by_reason": reasonList, + "by_namespace": nsList, + "by_kind": kindList, + "recent_warnings": recentWarnings, + } + + c.JSON(200, models.Response{Code: 200, Data: result}) +} + +// ============================================================================ +// Capacity Analysis — CPU/Memory request/limit per Deployment +// ============================================================================ + +// GetCapacityAnalysis returns resource analysis for all deployments diff --git a/backend/internal/api/handlers/k8s_hpa_cronjob.go b/backend/internal/api/handlers/k8s_hpa_cronjob.go new file mode 100644 index 0000000..6fda6d1 --- /dev/null +++ b/backend/internal/api/handlers/k8s_hpa_cronjob.go @@ -0,0 +1,384 @@ +package handlers + +import ( + "net/http" + "strconv" + + "ezops/internal/models" + k8sclient "ezops/pkg/kubernetesclient" + + "github.com/gin-gonic/gin" +) + +func (h *KubernetesHandler) ListHPAs(c *gin.Context) { + clusterID, _ := strconv.Atoi(c.Param("id")) + + client, _, err := h.getK8sClient(clusterID) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) + return + } + + hpas, err := client.ListHPAs() + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Data: hpas.Items}) +} + +// CreateHPA 创建 HPA +func (h *KubernetesHandler) CreateHPA(c *gin.Context) { + clusterID, _ := strconv.Atoi(c.Param("id")) + + var req k8sclient.CreateHPARequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误: " + err.Error()}) + return + } + if req.Name == "" || req.Namespace == "" || req.TargetName == "" { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "name/namespace/targetName 必填"}) + return + } + + client, _, err := h.getK8sClient(clusterID) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) + return + } + + hpa, err := client.CreateHPA(req) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建 HPA 失败: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "HPA 创建成功", Data: hpa}) +} + +// DeleteHPA 删除 HPA +func (h *KubernetesHandler) DeleteHPA(c *gin.Context) { + clusterID, _ := strconv.Atoi(c.Param("id")) + namespace := c.Param("namespace") + name := c.Param("name") + + client, _, err := h.getK8sClient(clusterID) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) + return + } + + if err := client.DeleteHPA(namespace, name); err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除 HPA 失败: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "HPA 已删除"}) +} + +// ============================================================================ +// CronJob 管理 +// ============================================================================ + +// ListCronJobs 获取 CronJob 列表 +func (h *KubernetesHandler) ListCronJobs(c *gin.Context) { + clusterID, _ := strconv.Atoi(c.Param("id")) + + client, _, err := h.getK8sClient(clusterID) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) + return + } + + cronjobs, err := client.ListCronJobs() + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Data: cronjobs.Items}) +} + +// CreateCronJob 创建 CronJob +func (h *KubernetesHandler) CreateCronJob(c *gin.Context) { + clusterID, _ := strconv.Atoi(c.Param("id")) + + var req k8sclient.CreateCronJobRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误: " + err.Error()}) + return + } + + client, _, err := h.getK8sClient(clusterID) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) + return + } + + cj, err := client.CreateCronJob(req) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建 CronJob 失败: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "CronJob 创建成功", Data: cj}) +} + +// SuspendCronJob 暂停/恢复 CronJob +func (h *KubernetesHandler) SuspendCronJob(c *gin.Context) { + clusterID, _ := strconv.Atoi(c.Param("id")) + namespace := c.Param("namespace") + name := c.Param("name") + + var req struct { + Suspend bool `json:"suspend"` + } + c.ShouldBindJSON(&req) + + client, _, err := h.getK8sClient(clusterID) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) + return + } + + if err := client.SuspendCronJob(namespace, name, req.Suspend); err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "操作失败: " + err.Error()}) + return + } + + action := "恢复" + if req.Suspend { + action = "暂停" + } + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "CronJob 已" + action}) +} + +// DeleteCronJob 删除 CronJob +func (h *KubernetesHandler) DeleteCronJob(c *gin.Context) { + clusterID, _ := strconv.Atoi(c.Param("id")) + namespace := c.Param("namespace") + name := c.Param("name") + + client, _, err := h.getK8sClient(clusterID) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) + return + } + + if err := client.DeleteCronJob(namespace, name); err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除 CronJob 失败: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "CronJob 已删除"}) +} + +// TriggerCronJob 手动触发 CronJob +func (h *KubernetesHandler) TriggerCronJob(c *gin.Context) { + clusterID, _ := strconv.Atoi(c.Param("id")) + namespace := c.Param("namespace") + name := c.Param("name") + + client, _, err := h.getK8sClient(clusterID) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) + return + } + + if err := client.TriggerCronJob(namespace, name); err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "触发失败: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "手动触发成功"}) +} + +// ============================================================================ +// Deployment CI 集成 (Webhook 更新镜像) +// ============================================================================ + +// UpdateDeploymentImage 更新 Deployment 容器镜像 (CI webhook) +func (h *KubernetesHandler) ListHPAsNS(c *gin.Context) { + h.listHPAsWithNS(c, c.Param("namespace")) +} +func (h *KubernetesHandler) CreateHPANS(c *gin.Context) { + h.createHPAWithNS(c, c.Param("namespace")) +} +func (h *KubernetesHandler) DeleteHPANS(c *gin.Context) { + h.deleteHPAWithNS(c, c.Param("namespace")) +} +func (h *KubernetesHandler) ListCronJobsNS(c *gin.Context) { + h.listCronJobsWithNS(c, c.Param("namespace")) +} +func (h *KubernetesHandler) CreateCronJobNS(c *gin.Context) { + h.createCronJobWithNS(c, c.Param("namespace")) +} +func (h *KubernetesHandler) SuspendCronJobNS(c *gin.Context) { + h.suspendCronJobWithNS(c, c.Param("namespace")) +} +func (h *KubernetesHandler) TriggerCronJobNS(c *gin.Context) { + h.triggerCronJobWithNS(c, c.Param("namespace")) +} +func (h *KubernetesHandler) DeleteCronJobNS(c *gin.Context) { + h.deleteCronJobWithNS(c, c.Param("namespace")) +} +func (h *KubernetesHandler) listHPAsWithNS(c *gin.Context, ns string) { + 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 } + hpas, err := client.ListHPAs() + if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + type HPASummary struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + TargetKind string `json:"target_kind"` + TargetName string `json:"target_name"` + MinReplicas int `json:"min_replicas"` + MaxReplicas int `json:"max_replicas"` + CPUPercent int `json:"cpu_percent"` + MemPercent int `json:"mem_percent"` + CurrentReplicas int `json:"current_replicas"` + } + result := []HPASummary{} + for _, h := range hpas.Items { + if h.Metadata.Namespace != ns { continue } + cpu := 0; mem := 0 + for _, m := range h.Spec.Metrics { + if m.Resource != nil { + if m.Resource.Name == "cpu" { cpu = m.Resource.Target.AverageUtilization } + if m.Resource.Name == "memory" { mem = m.Resource.Target.AverageUtilization } + } + } + result = append(result, HPASummary{ + Namespace: h.Metadata.Namespace, + Name: h.Metadata.Name, + TargetKind: h.Spec.ScaleTargetRef.Kind, + TargetName: h.Spec.ScaleTargetRef.Name, + MinReplicas: h.Spec.MinReplicas, + MaxReplicas: h.Spec.MaxReplicas, + CPUPercent: cpu, + MemPercent: mem, + CurrentReplicas: h.Status.CurrentReplicas, + }) + } + c.JSON(200, models.Response{Code: 200, Data: result}) +} +func (h *KubernetesHandler) createHPAWithNS(c *gin.Context, ns string) { + clusterID, _ := strconv.Atoi(c.Param("id")) + if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } + var req k8sclient.CreateHPARequest + if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, models.Response{Code: 400, Message: "参数错误"}); return } + if req.Namespace == "" { req.Namespace = ns } + client, _, err := h.getK8sClient(clusterID) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } + hpa, err := client.CreateHPA(req) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: "创建失败: " + err.Error()}); return } + c.JSON(200, models.Response{Code: 200, Data: hpa}) +} +func (h *KubernetesHandler) deleteHPAWithNS(c *gin.Context, ns string) { + 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 } + if err := client.DeleteHPA(ns, c.Param("name")); err != nil { c.JSON(500, models.Response{Code: 500, Message: "删除失败: " + err.Error()}); return } + c.JSON(200, models.Response{Code: 200, Message: "已删除"}) +} +func (h *KubernetesHandler) listCronJobsWithNS(c *gin.Context, ns string) { + 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 } + cjs, err := client.ListCronJobs() + if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + filtered := []k8sclient.CronJobItem{} + for _, cj := range cjs.Items { if cj.Metadata.Namespace == ns { filtered = append(filtered, cj) } } + c.JSON(200, models.Response{Code: 200, Data: filtered}) +} +func (h *KubernetesHandler) createCronJobWithNS(c *gin.Context, ns string) { + clusterID, _ := strconv.Atoi(c.Param("id")) + if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } + var req k8sclient.CreateCronJobRequest + if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, models.Response{Code: 400, Message: "参数错误"}); return } + if req.Namespace == "" { req.Namespace = ns } + client, _, err := h.getK8sClient(clusterID) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } + cj, err := client.CreateCronJob(req) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: "创建失败: " + err.Error()}); return } + c.JSON(200, models.Response{Code: 200, Data: cj}) +} +func (h *KubernetesHandler) suspendCronJobWithNS(c *gin.Context, ns string) { + clusterID, _ := strconv.Atoi(c.Param("id")) + if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } + var req struct { Suspend bool `json:"suspend"` }; c.ShouldBindJSON(&req) + client, _, err := h.getK8sClient(clusterID) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } + if err := client.SuspendCronJob(ns, c.Param("name"), req.Suspend); err != nil { c.JSON(500, models.Response{Code: 500, Message: "操作失败: " + err.Error()}); return } + action := "恢复"; if req.Suspend { action = "暂停" } + c.JSON(200, models.Response{Code: 200, Message: "CronJob 已" + action}) +} +func (h *KubernetesHandler) triggerCronJobWithNS(c *gin.Context, ns string) { + 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 } + if err := client.TriggerCronJob(ns, c.Param("name")); err != nil { c.JSON(500, models.Response{Code: 500, Message: "触发失败: " + err.Error()}); return } + c.JSON(200, models.Response{Code: 200, Message: "手动触发成功"}) +} +func (h *KubernetesHandler) deleteCronJobWithNS(c *gin.Context, ns string) { + 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 } + if err := client.DeleteCronJob(ns, c.Param("name")); err != nil { c.JSON(500, models.Response{Code: 500, Message: "删除失败: " + err.Error()}); return } + c.JSON(200, models.Response{Code: 200, Message: "已删除"}) +} +func (h *KubernetesHandler) ListHPAsAllNS(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 } + hpas, err := client.ListHPAs() + if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + type HPASummary struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + TargetKind string `json:"target_kind"` + TargetName string `json:"target_name"` + MinReplicas int `json:"min_replicas"` + MaxReplicas int `json:"max_replicas"` + CPUPercent int `json:"cpu_percent"` + MemPercent int `json:"mem_percent"` + CurrentReplicas int `json:"current_replicas"` + } + result := []HPASummary{} + for _, h := range hpas.Items { + cpu := 0; mem := 0 + for _, m := range h.Spec.Metrics { + if m.Resource != nil { + if m.Resource.Name == "cpu" { cpu = m.Resource.Target.AverageUtilization } + if m.Resource.Name == "memory" { mem = m.Resource.Target.AverageUtilization } + } + } + result = append(result, HPASummary{ + Namespace: h.Metadata.Namespace, + Name: h.Metadata.Name, + TargetKind: h.Spec.ScaleTargetRef.Kind, + TargetName: h.Spec.ScaleTargetRef.Name, + MinReplicas: h.Spec.MinReplicas, + MaxReplicas: h.Spec.MaxReplicas, + CPUPercent: cpu, + MemPercent: mem, + CurrentReplicas: h.Status.CurrentReplicas, + }) + } + c.JSON(200, models.Response{Code: 200, Data: result}) +} + +// ============================================================================ +// Helpers +// ============================================================================ + +// buildPodMetricsMap fetches pod metrics and returns a map keyed by "namespace/podname" → aggregated CPU/Memory strings. +// Best-effort: returns empty map if metrics-server is unavailable. // Logan.Li M2 diff --git a/backend/internal/api/handlers/k8s_namespace.go b/backend/internal/api/handlers/k8s_namespace.go new file mode 100644 index 0000000..d437398 --- /dev/null +++ b/backend/internal/api/handlers/k8s_namespace.go @@ -0,0 +1,305 @@ +package handlers + +import ( + "net/http" + "strconv" + + "ezops/internal/models" + k8sclient "ezops/pkg/kubernetesclient" + + "github.com/gin-gonic/gin" +) + +func (h *KubernetesHandler) ListNamespaces(c *gin.Context) { + clusterID, _ := strconv.Atoi(c.Param("id")) + // Support clusterCode param from new routes + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } + + client, _, err := h.getK8sClient(clusterID) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) + return + } + + namespaces, err := client.ListNamespaces() + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Data: namespaces}) +} + +// ============================================================================ +// Namespace Management (M14 — 资源配额 + CRUD) +// ============================================================================ + +// NamespaceDetail is the enriched namespace info for the management page +type NamespaceDetail struct { + Name string `json:"name"` + Status string `json:"status"` + Age string `json:"age"` + Labels map[string]string `json:"labels"` + Annotations map[string]string `json:"annotations,omitempty"` + ResourceCounts map[string]int `json:"resource_counts"` + HasResourceQuota bool `json:"has_resource_quota"` + HasLimitRange bool `json:"has_limit_range"` + ResourceQuotas []k8sclient.ResourceQuotaItem `json:"resource_quotas,omitempty"` + LimitRanges []k8sclient.LimitRangeItem `json:"limit_ranges,omitempty"` +} + +// GetNamespacesDetail returns all namespaces with metadata, resource counts and quota info +func (h *KubernetesHandler) GetNamespacesDetail(c *gin.Context) { + clusterID, _ := strconv.Atoi(c.Param("id")) + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } + if clusterID == 0 { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "cluster not specified"}) + return + } + client, _, err := h.getK8sClient(clusterID) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) + return + } + + nsList, err := client.ListNamespacesDetailed() + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } + + // Efficient: fetch all resources once, group by namespace + depCounts := map[string]int{} + if deps, err := client.ListDeployments(); err == nil { + for _, d := range deps.Items { + depCounts[d.Metadata.Namespace]++ + } + } + podCounts := map[string]int{} + if pods, err := client.ListPods(); err == nil { + for _, p := range pods.Items { + podCounts[p.Metadata.Namespace]++ + } + } + svcCounts := map[string]int{} + if svcs, err := client.ListServices(); err == nil { + for _, s := range svcs.Items { + svcCounts[s.Metadata.Namespace]++ + } + } + // All-namespace ResourceQuotas + quotaByNS := map[string][]k8sclient.ResourceQuotaItem{} + if quotas, err := client.ListResourceQuotas(""); err == nil { + for _, q := range quotas { + quotaByNS[q.Metadata.Namespace] = append(quotaByNS[q.Metadata.Namespace], q) + } + } + // All-namespace LimitRanges + lrByNS := map[string][]k8sclient.LimitRangeItem{} + if lrs, err := client.ListLimitRanges(""); err == nil { + for _, lr := range lrs { + lrByNS[lr.Metadata.Namespace] = append(lrByNS[lr.Metadata.Namespace], lr) + } + } + + result := make([]NamespaceDetail, 0, len(nsList.Items)) + for _, ns := range nsList.Items { + name := ns.Metadata.Name + nd := NamespaceDetail{ + Name: name, + Status: ns.Status.Phase, + Age: formatAge(ns.Metadata.CreationTimestamp), + Labels: ns.Metadata.Labels, + Annotations: ns.Metadata.Annotations, + ResourceCounts: map[string]int{ + "deployments": depCounts[name], + "pods": podCounts[name], + "services": svcCounts[name], + }, + } + if nd.Labels == nil { + nd.Labels = map[string]string{} + } + if qs, ok := quotaByNS[name]; ok && len(qs) > 0 { + nd.HasResourceQuota = true + nd.ResourceQuotas = qs + } + if lrs, ok := lrByNS[name]; ok && len(lrs) > 0 { + nd.HasLimitRange = true + nd.LimitRanges = lrs + } + result = append(result, nd) + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Data: result}) +} + +// GetNamespaceDetailHandler returns a single namespace detail with quota info +func (h *KubernetesHandler) GetNamespaceDetailHandler(c *gin.Context) { + clusterID, _ := strconv.Atoi(c.Param("id")) + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } + if clusterID == 0 { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "cluster not specified"}) + return + } + nsName := c.Param("namespace") + if nsName == "" { + nsName = c.Param("name") + } + + client, _, err := h.getK8sClient(clusterID) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) + return + } + + ns, err := client.GetNamespace(nsName) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } + + nd := NamespaceDetail{ + Name: ns.Metadata.Name, + Status: ns.Status.Phase, + Age: formatAge(ns.Metadata.CreationTimestamp), + Labels: ns.Metadata.Labels, + Annotations: ns.Metadata.Annotations, + ResourceCounts: map[string]int{}, + } + if nd.Labels == nil { + nd.Labels = map[string]string{} + } + + // Fetch resource counts (single namespace) + if deps, err := client.ListDeployments(); err == nil { + count := 0 + for _, d := range deps.Items { + if d.Metadata.Namespace == nsName { count++ } + } + nd.ResourceCounts["deployments"] = count + } + if pods, err := client.ListPods(); err == nil { + count := 0 + for _, p := range pods.Items { + if p.Metadata.Namespace == nsName { count++ } + } + nd.ResourceCounts["pods"] = count + } + if svcs, err := client.ListServices(); err == nil { + count := 0 + for _, s := range svcs.Items { + if s.Metadata.Namespace == nsName { count++ } + } + nd.ResourceCounts["services"] = count + } + + // Fetch quotas + limit ranges for this namespace + if quotas, err := client.ListResourceQuotas(nsName); err == nil && len(quotas) > 0 { + nd.HasResourceQuota = true + nd.ResourceQuotas = quotas + } + if lrs, err := client.ListLimitRanges(nsName); err == nil && len(lrs) > 0 { + nd.HasLimitRange = true + nd.LimitRanges = lrs + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Data: nd}) +} + +// CreateNamespaceHandler creates a new namespace +func (h *KubernetesHandler) CreateNamespaceHandler(c *gin.Context) { + clusterID, _ := strconv.Atoi(c.Param("id")) + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } + if clusterID == 0 { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "cluster not specified"}) + return + } + + var req struct { + Name string `json:"name" binding:"required"` + Labels map[string]string `json:"labels"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "name is required"}) + return + } + + // Safety: block critical system namespaces + systemNS := map[string]bool{ + "kube-system": true, "kube-public": true, "kube-node-lease": true, + "default": true, "kubernetes-dashboard": true, + } + if systemNS[req.Name] { + c.JSON(http.StatusForbidden, models.Response{Code: 403, Message: "cannot create system namespace: " + req.Name}) + return + } + + client, _, err := h.getK8sClient(clusterID) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) + return + } + + ns, err := client.CreateNamespace(req.Name, req.Labels) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Data: ns, Message: "命名空间创建成功"}) +} + +// DeleteNamespaceHandler deletes a namespace +func (h *KubernetesHandler) DeleteNamespaceHandler(c *gin.Context) { + clusterID, _ := strconv.Atoi(c.Param("id")) + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } + if clusterID == 0 { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "cluster not specified"}) + return + } + + nsName := c.Param("namespace") + if nsName == "" { + nsName = c.Param("name") + } + + // Safety: block deletion of critical system namespaces + systemNS := map[string]bool{ + "kube-system": true, "kube-public": true, "kube-node-lease": true, + "default": true, "devops": true, + } + if systemNS[nsName] { + c.JSON(http.StatusForbidden, models.Response{Code: 403, Message: "禁止删除系统命名空间: " + nsName}) + return + } + + client, _, err := h.getK8sClient(clusterID) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) + return + } + + if err := client.DeleteNamespace(nsName); err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "命名空间已删除"}) +} + +// ============================================================================ +// ConfigMap & Secret (Kuboard-style namespace resources) +// ============================================================================ + +// ListConfigMaps 获取命名空间下的 ConfigMap 列表 diff --git a/backend/internal/api/handlers/k8s_node.go b/backend/internal/api/handlers/k8s_node.go new file mode 100644 index 0000000..2601924 --- /dev/null +++ b/backend/internal/api/handlers/k8s_node.go @@ -0,0 +1,428 @@ +package handlers + +import ( + "fmt" + "net/http" + "strconv" + "strings" + + "ezops/internal/models" + k8sclient "ezops/pkg/kubernetesclient" + + "github.com/gin-gonic/gin" +) + +func (h *KubernetesHandler) ListNodes(c *gin.Context) { + clusterID, _ := strconv.Atoi(c.Param("id")) + + client, _, err := h.getK8sClient(clusterID) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) + return + } + nodes, err := client.ListNodes() + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } + + // Attempt to fetch metrics (best-effort, metrics-server may not be installed) + metrics := map[string]k8sclient.NodeMetricsItem{} + if m, err := client.GetNodeMetrics(); err == nil { + for _, item := range m.Items { + metrics[item.Metadata.Name] = item + } + } + + type NodeSummary struct { + Name string `json:"name"` + IP string `json:"ip"` + Role string `json:"role"` + Status string `json:"status"` + OS string `json:"os"` + Kubelet string `json:"kubelet_version"` + CPU int `json:"cpu"` + Memory int64 `json:"memory_mb"` + MaxPods int `json:"max_pods"` + // Metrics (optional, omitted if empty) + CPUUsageM string `json:"cpu_usage_m,omitempty"` // e.g. "122m" + MemUsageMi string `json:"mem_usage_mi,omitempty"` // e.g. "2105Mi" + } + result := []NodeSummary{} + for _, n := range nodes.Items { + role := "worker" + for k := range n.Metadata.Labels { + if k == "node-role.kubernetes.io/master" || k == "node-role.kubernetes.io/control-plane" { + role = "master" + break + } + } + nodeStatus := "NotReady" + for _, cond := range n.Status.Conditions { + if cond.Type == "Ready" && cond.Status == "True" { + nodeStatus = "Ready" + break + } + } + ip := "" + for _, addr := range n.Status.Addresses { + if addr.Type == "InternalIP" { + ip = addr.Address + break + } + } + s := NodeSummary{ + Name: n.Metadata.Name, + IP: ip, + Role: role, + Status: nodeStatus, + OS: n.Status.NodeInfo.OSImage, + Kubelet: n.Status.NodeInfo.KubeletVersion, + CPU: parseIntResource(n.Status.Capacity["cpu"]), + Memory: parseMemResource(n.Status.Capacity["memory"]), + MaxPods: parseIntResource(n.Status.Capacity["pods"]), + } + if m, ok := metrics[n.Metadata.Name]; ok { + // K8s metrics API returns CPU in nanocores ("80410173n"), memory in Ki ("2154292Ki") + s.CPUUsageM = formatCpuFromNano(m.Usage["cpu"]) + s.MemUsageMi = formatMemFromKi(m.Usage["memory"]) + } + result = append(result, s) + } + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "success", Data: result}) +} + +// GetNodeDetail 获取节点详情(PID/容器运行时/分配率)// M10 +// @Summary 获取节点详情 +// @Description 获取指定集群中单个节点的详细信息,包含系统信息、资源分配、污点、条件等 +// @Tags K8s集群 +// @Produce json +// @Security Bearer +// @Router /kubernetes/{clusterCode}/nodes/{name} [get] +func (h *KubernetesHandler) GetNodeDetail(c *gin.Context) { + clusterID, _ := strconv.Atoi(c.Param("id")) + if clusterID == 0 { + code := c.Param("clusterCode") + if code == "" { + code = c.Param("id") + } + clusterID = h.resolveClusterCode(code) + } + if clusterID == 0 { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "cluster not found"}) + return + } + + nodeName := c.Param("name") + if nodeName == "" { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "node name required"}) + return + } + + client, _, err := h.getK8sClient(clusterID) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) + return + } + + // 1. Get node detail + node, err := client.GetNode(nodeName) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } + + // 2. Get node metrics (best-effort) + var cpuUsageM, memUsageMi string + if m, err := client.GetNodeMetrics(); err == nil { + for _, item := range m.Items { + if item.Metadata.Name == nodeName { + cpuUsageM = formatCpuFromNano(item.Usage["cpu"]) + memUsageMi = formatMemFromKi(item.Usage["memory"]) + break + } + } + } + + // 3. Get pods on this node for allocation calculation + pods, _ := client.ListPods() + podCount := 0 + type podBrief struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Status string `json:"status"` + IP string `json:"ip"` + CPURequestM int `json:"cpu_request_m"` + MemRequestMi int64 `json:"mem_request_mi"` + RestartCount int `json:"restart_count"` + } + var podList []podBrief + totalCPURequestM := 0 + totalMemRequestMi := int64(0) + for _, p := range pods.Items { + if p.Spec.NodeName != nodeName { + continue + } + podCount++ + podCPUReq := 0 + podMemReq := int64(0) + for _, ctr := range p.Spec.Containers { + if req := ctr.Resources.Requests; req != nil { + podCPUReq += parseCpuToMilli(req["cpu"]) + podMemReq += parseMemToMi(req["memory"]) + } + } + totalCPURequestM += podCPUReq + totalMemRequestMi += podMemReq + + status := "Unknown" + if p.Status.Phase != "" { + status = p.Status.Phase + } + restartCount := 0 + for _, cs := range p.Status.ContainerStatuses { + restartCount += cs.RestartCount + } + podList = append(podList, podBrief{ + Namespace: p.Metadata.Namespace, + Name: p.Metadata.Name, + Status: status, + IP: p.Status.PodIP, + CPURequestM: podCPUReq, + MemRequestMi: podMemReq, + RestartCount: restartCount, + }) + } + + // 4. Determine role + role := "worker" + for k := range node.Metadata.Labels { + if k == "node-role.kubernetes.io/master" || k == "node-role.kubernetes.io/control-plane" { + role = "master" + break + } + } + + // 5. Determine status + nodeStatus := "NotReady" + for _, cond := range node.Status.Conditions { + if cond.Type == "Ready" && cond.Status == "True" { + nodeStatus = "Ready" + break + } + } + + // 6. Get IP + internalIP := "" + externalIP := "" + hostname := "" + for _, addr := range node.Status.Addresses { + switch addr.Type { + case "InternalIP": + internalIP = addr.Address + case "ExternalIP": + externalIP = addr.Address + case "Hostname": + hostname = addr.Address + } + } + + // 7. Build conditions list + type condInfo struct { + Type string `json:"type"` + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + Message string `json:"message,omitempty"` + } + conditions := []condInfo{} + for _, cond := range node.Status.Conditions { + conditions = append(conditions, condInfo{ + Type: cond.Type, + Status: cond.Status, + Reason: cond.Reason, + Message: cond.Message, + }) + } + + // 8. Build taints list + type taintInfo struct { + Key string `json:"key"` + Value string `json:"value"` + Effect string `json:"effect"` + } + taints := []taintInfo{} + for _, t := range node.Spec.Taints { + taints = append(taints, taintInfo{ + Key: t.Key, + Value: t.Value, + Effect: t.Effect, + }) + } + + // 9. Calculate allocation rate + capCPU := parseIntResource(node.Status.Capacity["cpu"]) + allocCPU := parseIntResource(node.Status.Allocatable["cpu"]) + capMem := parseMemResource(node.Status.Capacity["memory"]) + allocMem := parseMemResource(node.Status.Allocatable["memory"]) + capPods := parseIntResource(node.Status.Capacity["pods"]) + allocPods := parseIntResource(node.Status.Allocatable["pods"]) + + cpuReqCores := float64(totalCPURequestM) / 1000.0 + cpuAllocPct := 0.0 + if allocCPU > 0 { + cpuAllocPct = (cpuReqCores / float64(allocCPU)) * 100 + } + memAllocPct := 0.0 + if allocMem > 0 { + memAllocPct = (float64(totalMemRequestMi) / float64(allocMem)) * 100 + } + podAllocPct := 0.0 + if allocPods > 0 { + podAllocPct = (float64(podCount) / float64(allocPods)) * 100 + } + + // 10. Build labels list + type labelEntry struct { + Key string `json:"key"` + Value string `json:"value"` + } + labels := []labelEntry{} + for k, v := range node.Metadata.Labels { + labels = append(labels, labelEntry{Key: k, Value: v}) + } + + detail := map[string]interface{}{ + "name": node.Metadata.Name, + "role": role, + "status": nodeStatus, + "creation_time": node.Metadata.CreationTimestamp, + "internal_ip": internalIP, + "external_ip": externalIP, + "hostname": hostname, + "pod_cidr": node.Spec.PodCIDR, + "container_runtime": node.Status.NodeInfo.ContainerRuntimeVersion, + "kubelet_version": node.Status.NodeInfo.KubeletVersion, + "os_image": node.Status.NodeInfo.OSImage, + "kernel_version": node.Status.NodeInfo.KernelVersion, + "architecture": node.Status.NodeInfo.Architecture, + "operating_system": node.Status.NodeInfo.OperatingSystem, + // Capacity & Allocatable + "capacity": node.Status.Capacity, + "allocatable": node.Status.Allocatable, + // Metrics + "cpu_usage_m": cpuUsageM, + "mem_usage_mi": memUsageMi, + // Allocation + "pod_count": podCount, + "cpu_request_m": totalCPURequestM, + "mem_request_mi": totalMemRequestMi, + "cpu_alloc_pct": cpuAllocPct, + "mem_alloc_pct": memAllocPct, + "pod_alloc_pct": podAllocPct, + "cpu_capacity": capCPU, + "cpu_allocatable": allocCPU, + "mem_capacity_mb": capMem, + "mem_allocatable_mb": allocMem, + "pod_capacity": capPods, + "pod_allocatable": allocPods, + // Lists + "conditions": conditions, + "taints": taints, + "labels": labels, + "pods": podList, + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "success", Data: detail}) +} + +// parseCpuToMilli converts CPU resource string to millicores (e.g. "100m"→100, "2"→2000) +func parseCpuToMilli(val string) int { + if val == "" { + return 0 + } + if strings.HasSuffix(val, "m") { + var i int + fmt.Sscanf(strings.TrimSuffix(val, "m"), "%d", &i) + return i + } + var i int + fmt.Sscanf(val, "%d", &i) + return i * 1000 +} + +// parseMemToMi converts memory resource string to Mi (e.g. "256Mi"→256, "1Gi"→1024, "512Ki"→0) +func parseMemToMi(val string) int64 { + if val == "" { + return 0 + } + var num float64 + var unit string + fmt.Sscanf(val, "%f%s", &num, &unit) + switch unit { + case "Ki": + return int64(num / 1024) + case "Mi": + return int64(num) + case "Gi": + return int64(num * 1024) + case "Ti": + return int64(num * 1024 * 1024) + default: + return int64(num / 1024 / 1024) + } +} + +// ListPods 获取Pod列表 +// @Summary 获取Pod列表 +// @Description 获取指定集群的Pod列表 +// @Tags K8s集群 +// @Produce json +// @Security Bearer +// @Param id path int true "集群ID" +// @Param namespace query string false "命名空间" +// @Success 200 {object} models.Response{data=[]models.KubernetesPod} +// @Router /kubernetes-clusters/{id}/pods [get] +func (h *KubernetesHandler) GetCapacityAnalysis(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.Query("namespace") + deps, err := client.ListDeployments() + if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + + type ContainerResource struct { + Name string `json:"name"` + CPUReq string `json:"cpu_request"` + CPULim string `json:"cpu_limit"` + MemReq string `json:"mem_request"` + MemLim string `json:"mem_limit"` + } + type CapacityRow struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Replicas int `json:"replicas"` + Containers []ContainerResource `json:"containers"` + } + result := []CapacityRow{} + for _, d := range deps.Items { + if ns != "" && d.Metadata.Namespace != ns { continue } + containers := []ContainerResource{} + for _, c := range d.Spec.Template.Spec.Containers { + cpuReq := c.Resources.Requests["cpu"] + cpuLim := c.Resources.Limits["cpu"] + memReq := c.Resources.Requests["memory"] + memLim := c.Resources.Limits["memory"] + containers = append(containers, ContainerResource{c.Name, cpuReq, cpuLim, memReq, memLim}) + } + result = append(result, CapacityRow{d.Metadata.Namespace, d.Metadata.Name, d.Spec.Replicas, containers}) + } + c.JSON(200, models.Response{Code: 200, Data: result}) +} + +// ============================================================================ +// YAML Apply (kubectl apply -f) +// ============================================================================ + +// ApplyYAML applies a YAML manifest to create or update a resource diff --git a/backend/internal/api/handlers/k8s_pod.go b/backend/internal/api/handlers/k8s_pod.go new file mode 100644 index 0000000..e6217d8 --- /dev/null +++ b/backend/internal/api/handlers/k8s_pod.go @@ -0,0 +1,653 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "ezops/internal/models" + k8sclient "ezops/pkg/kubernetesclient" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +func (h *KubernetesHandler) ListPods(c *gin.Context) { + clusterID, _ := strconv.Atoi(c.Param("id")) + namespace := c.Query("namespace") + + client, _, err := h.getK8sClient(clusterID) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) + return + } + pods, err := client.ListPods() + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } + + // M2: inject pod metrics (best-effort) // Logan.Li + podMetrics := buildPodMetricsMap(client, namespace) + + type PodSummary struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Status string `json:"status"` + NodeName string `json:"node_name"` + IP string `json:"ip"` + RestartCount int `json:"restart_count"` + Image string `json:"image"` + CPUUsageM string `json:"cpu_usage_m,omitempty"` // M2: e.g. "50m" + MemUsageMi string `json:"mem_usage_mi,omitempty"` // M2: e.g. "128Mi" + } + result := []PodSummary{} + for _, p := range pods.Items { + if namespace != "" && p.Metadata.Namespace != namespace { + continue + } + image := "" + if len(p.Spec.Containers) > 0 { + image = p.Spec.Containers[0].Image + } + restarts := 0 + if len(p.Status.ContainerStatuses) > 0 { + restarts = p.Status.ContainerStatuses[0].RestartCount + } + ps := PodSummary{ + Namespace: p.Metadata.Namespace, + Name: p.Metadata.Name, + Status: p.Status.Phase, + NodeName: p.Spec.NodeName, + IP: p.Status.PodIP, + RestartCount: restarts, + Image: image, + } + if m, ok := podMetrics[p.Metadata.Namespace+"/"+p.Metadata.Name]; ok { + ps.CPUUsageM = m.CPU + ps.MemUsageMi = m.Mem + } + result = append(result, ps) + } + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "success", Data: result}) +} + +// ListDeployments 获取Deployment列表 +// @Summary 获取Deployment列表 +// @Description 获取指定集群的Deployment列表 +// @Tags K8s集群 +// @Produce json +// @Security Bearer +// @Param id path int true "集群ID" +// @Param namespace query string false "命名空间" +// @Success 200 {object} models.Response{data=[]models.KubernetesDeployment} +// @Router /kubernetes-clusters/{id}/deployments [get] +func (h *KubernetesHandler) GetPodLogs(c *gin.Context) { + clusterID, _ := strconv.Atoi(c.Param("id")) + namespace := c.Param("namespace") + podName := c.Param("podName") + container := c.Query("container") + tailLines, _ := strconv.Atoi(c.DefaultQuery("tail", "200")) + stream := c.Query("stream") == "true" + + client, _, err := h.getK8sClient(clusterID) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) + return + } + + if stream { + // Real-time streaming via chunked transfer + body, err := client.GetPodLogsStream(namespace, podName, container, tailLines) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "获取日志流失败: " + err.Error()}) + return + } + defer body.Close() + + c.Header("Content-Type", "text/plain; charset=utf-8") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Header("X-Content-Type-Options", "nosniff") + + c.Stream(func(w io.Writer) bool { + buf := make([]byte, 4096) + n, err := body.Read(buf) + if n > 0 { + w.Write(buf[:n]) + return true + } + if err != nil { + return false + } + return true + }) + return + } + + logs, err := client.GetPodLogs(namespace, podName, container, tailLines) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "获取日志失败: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Data: gin.H{"logs": logs, "pod": podName, "namespace": namespace}}) +} + +// ============================================================================ +// WebSocket Exec Terminal (交互式 bash) +// ============================================================================ + +// ExecTerminal WebSocket 终端 — 前端 WS → 后端 → K8s WebSocket channel 协议 +// +// 直接通过 K8s API 的 exec 端点(WebSocket channel.k8s.io v4 协议)建立 +// 交互式终端,无需在容器中安装 kubectl。 +// +// 数据流: +// +// Frontend WS (JSON) ←→ Backend ←→ K8s API WS (channel frames) +// +// K8s channel 协议: 每条消息 [1字节 channel ID] + [payload] +// +// 0 = stdin (client→server) +// 1 = stdout (server→client) +// 2 = stderr (server→client) +// 3 = error (server→client, JSON) +// 4 = resize (client→server, JSON {"Height","Width"}) +func (h *KubernetesHandler) ExecTerminal(c *gin.Context) { + namespace := c.Param("namespace") + podName := c.Param("podName") + container := c.Query("container") + command := c.Query("command") + if command == "" { + command = "bash" + } + + client, cluster, err := h.getK8sClientByCode(c) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) + return + } + + // Build exec WebSocket URL — https→wss / http→ws + execURL := client.BaseURL + if strings.HasPrefix(execURL, "https://") { + execURL = "wss://" + strings.TrimPrefix(execURL, "https://") + } else { + execURL = "ws://" + strings.TrimPrefix(execURL, "http://") + } + params := url.Values{} + params.Set("command", command) + params.Set("stdin", "true") + params.Set("stdout", "true") + params.Set("stderr", "true") + params.Set("tty", "true") + if container != "" { + params.Set("container", container) + } + execURL += fmt.Sprintf("/api/v1/namespaces/%s/pods/%s/exec?%s", + url.PathEscape(namespace), url.PathEscape(podName), params.Encode()) + + // Dial K8s API server via WebSocket with channel protocol subprotocol + k8sDialer := &websocket.Dialer{ + TLSClientConfig: client.TLSConfig, + Subprotocols: []string{"v4.channel.k8s.io", "v3.channel.k8s.io", "v5.channel.k8s.io"}, + HandshakeTimeout: 10 * time.Second, + } + k8sHeader := http.Header{} + if client.BearerToken != "" { + k8sHeader.Set("Authorization", "Bearer "+client.BearerToken) + } + k8sConn, _, err := k8sDialer.Dial(execURL, k8sHeader) + if err != nil { + log.Printf("[K8sExec] dial K8s exec WS failed: %v", err) + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s exec 连接失败: " + err.Error()}) + return + } + defer k8sConn.Close() + + // Upgrade frontend connection to WebSocket + frontendConn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + return + } + defer frontendConn.Close() + + done := make(chan struct{}, 2) + + // Frontend WS → K8s WS (stdin + resize) + go func() { + defer func() { done <- struct{}{} }() + for { + msgType, msg, err := frontendConn.ReadMessage() + if err != nil { + return + } + _ = msgType + + // Parse frontend JSON protocol: {status, data} or {status, cols, rows} + var wsMsg WSMessage + if err := json.Unmarshal(msg, &wsMsg); err != nil { + continue + } + + if wsMsg.Status == 0 && wsMsg.Data != "" { + // stdin: prepend channel 0 byte + frame := make([]byte, 1+len(wsMsg.Data)) + frame[0] = 0 // channel 0 = stdin + copy(frame[1:], wsMsg.Data) + if err := k8sConn.WriteMessage(websocket.BinaryMessage, frame); err != nil { + return + } + } else if wsMsg.Status == 1 && wsMsg.Cols > 0 { + // resize: channel 4 + JSON + resizeJSON, _ := json.Marshal(struct { + Height uint16 `json:"Height"` + Width uint16 `json:"Width"` + }{Height: uint16(wsMsg.Rows), Width: uint16(wsMsg.Cols)}) + frame := make([]byte, 1+len(resizeJSON)) + frame[0] = 4 // channel 4 = resize + copy(frame[1:], resizeJSON) + if err := k8sConn.WriteMessage(websocket.BinaryMessage, frame); err != nil { + return + } + } + } + }() + + // K8s WS → Frontend WS (stdout/stderr/error) + go func() { + defer func() { done <- struct{}{} }() + for { + msgType, msg, err := k8sConn.ReadMessage() + if err != nil { + // K8s connection closed — notify frontend + frontendConn.WriteJSON(WSMessage{Status: 2, Data: "\r\n\x1b[33m[K8s 连接已关闭]\x1b[0m\r\n"}) + return + } + _ = msgType + if len(msg) == 0 { + continue + } + + channel := msg[0] + payload := msg[1:] + + switch channel { + case 1, 2: // stdout / stderr → forward to frontend as terminal output + frontendConn.WriteJSON(WSMessage{Status: 0, Data: string(payload)}) + case 3: // error → forward as error message + errMsg := string(payload) + frontendConn.WriteJSON(WSMessage{Status: 2, Data: "\r\n\x1b[31m" + errMsg + "\x1b[0m\r\n"}) + } + } + }() + + <-done + _ = cluster +} + +// ============================================================================ +// Exec / File Browser +// ============================================================================ + +// ExecInPod 在 Pod 中执行命令 (文件浏览 / 单次命令) +func (h *KubernetesHandler) ExecInPod(c *gin.Context) { + clusterID, _ := strconv.Atoi(c.Param("id")) + namespace := c.Param("namespace") + podName := c.Param("podName") + + var req struct { + Command []string `json:"command"` + Container string `json:"container"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误"}) + return + } + if len(req.Command) == 0 { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "command 不能为空"}) + return + } + + client, _, err := h.getK8sClient(clusterID) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) + return + } + + result, err := client.ExecInPod(namespace, podName, req.Container, req.Command) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "执行失败: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Data: result}) +} + +// ============================================================================ +// Scale (副本数加减) +// ============================================================================ + +// ScaleDeployment 缩放 Deployment 副本数 +func (h *KubernetesHandler) ListPodsNS(c *gin.Context) { + h.listPodsWithNS(c, c.Param("namespace")) +} +func (h *KubernetesHandler) GetPodLogsNS(c *gin.Context) { + h.getPodLogsWithNS(c, c.Param("namespace")) +} +func (h *KubernetesHandler) ExecInPodNS(c *gin.Context) { + h.execInPodWithNS(c, c.Param("namespace")) +} + +// Internal helpers that resolve clusterCode and inject namespace +func (h *KubernetesHandler) listPodsWithNS(c *gin.Context, ns string) { + 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 } + pods, err := client.ListPods() + if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + // M2: inject pod metrics (best-effort) // Logan.Li + podMetrics := buildPodMetricsMap(client, ns) + type PodSummary struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Status string `json:"status"` + NodeName string `json:"node_name"` + IP string `json:"ip"` + RestartCount int `json:"restart_count"` + Image string `json:"image"` + CPUUsageM string `json:"cpu_usage_m,omitempty"` + MemUsageMi string `json:"mem_usage_mi,omitempty"` + } + result := []PodSummary{} + for _, p := range pods.Items { + if p.Metadata.Namespace != ns { continue } + image := ""; if len(p.Spec.Containers) > 0 { image = p.Spec.Containers[0].Image } + restarts := 0; if len(p.Status.ContainerStatuses) > 0 { restarts = p.Status.ContainerStatuses[0].RestartCount } + ps := PodSummary{ + Namespace: p.Metadata.Namespace, + Name: p.Metadata.Name, + Status: p.Status.Phase, + NodeName: p.Spec.NodeName, + IP: p.Status.PodIP, + RestartCount: restarts, + Image: image, + } + if m, ok := podMetrics[p.Metadata.Namespace+"/"+p.Metadata.Name]; ok { + ps.CPUUsageM = m.CPU + ps.MemUsageMi = m.Mem + } + result = append(result, ps) + } + c.JSON(200, models.Response{Code: 200, Data: result}) +} +func (h *KubernetesHandler) getPodLogsWithNS(c *gin.Context, ns string) { + clusterID, _ := strconv.Atoi(c.Param("id")) + if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } + podName := c.Param("podName") + stream := c.Query("stream") == "true" + // 日志增强参数:container / sinceTime / previous / tailLines(兼容 tail) // Logan.Li + tailLines := 0 + if v := c.Query("tailLines"); v != "" { + tailLines, _ = strconv.Atoi(v) + } + if tailLines == 0 { + tailLines, _ = strconv.Atoi(c.DefaultQuery("tail", "200")) + } + opts := k8sclient.LogOptions{ + Container: c.Query("container"), + TailLines: tailLines, + SinceTime: c.Query("sinceTime"), + Previous: c.Query("previous") == "true", + } + client, _, err := h.getK8sClient(clusterID) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } + if stream { + body, err := client.GetPodLogsStreamWithOptions(ns, podName, opts) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: "获取日志流失败: " + err.Error()}); return } + defer body.Close() + c.Header("Content-Type", "text/plain; charset=utf-8") + c.Header("Cache-Control", "no-cache") + c.Stream(func(w io.Writer) bool { + buf := make([]byte, 4096); n, err := body.Read(buf) + if n > 0 { w.Write(buf[:n]); return true } + return err == nil + }) + return + } + logs, err := client.GetPodLogsWithOptions(ns, podName, opts) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: "获取日志失败: " + err.Error()}); return } + c.JSON(200, models.Response{Code: 200, Message: "success", Data: gin.H{"logs": logs, "pod": podName, "namespace": ns}}) +} +func (h *KubernetesHandler) execInPodWithNS(c *gin.Context, ns string) { + clusterID, _ := strconv.Atoi(c.Param("id")) + if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } + podName := c.Param("podName") + var req struct { Command []string `json:"command"`; Container string `json:"container"` } + if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, models.Response{Code: 400, Message: "参数错误"}); return } + client, _, err := h.getK8sClient(clusterID) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } + result, err := client.ExecInPod(ns, podName, req.Container, req.Command) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: "执行失败: " + err.Error()}); return } + c.JSON(200, models.Response{Code: 200, Data: result}) +} + +// ============================================================================ +// All-namespace handlers (clusterCode-based, no namespace filter) +// ============================================================================ + +// ListDeploymentsAllNS lists all deployments across all namespaces using clusterCode. +func (h *KubernetesHandler) ListPodsAllNS(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 } + pods, err := client.ListPods() + if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + // M2: inject pod metrics (best-effort) // Logan.Li + podMetrics := buildPodMetricsMap(client, "") + type PodSummary struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Status string `json:"status"` + NodeName string `json:"node_name"` + IP string `json:"ip"` + RestartCount int `json:"restart_count"` + Image string `json:"image"` + CPUUsageM string `json:"cpu_usage_m,omitempty"` + MemUsageMi string `json:"mem_usage_mi,omitempty"` + } + result := []PodSummary{} + for _, p := range pods.Items { + image := ""; if len(p.Spec.Containers) > 0 { image = p.Spec.Containers[0].Image } + restarts := 0; if len(p.Status.ContainerStatuses) > 0 { restarts = p.Status.ContainerStatuses[0].RestartCount } + ps := PodSummary{ + Namespace: p.Metadata.Namespace, + Name: p.Metadata.Name, + Status: p.Status.Phase, + NodeName: p.Spec.NodeName, + IP: p.Status.PodIP, + RestartCount: restarts, + Image: image, + } + if m, ok := podMetrics[p.Metadata.Namespace+"/"+p.Metadata.Name]; ok { + ps.CPUUsageM = m.CPU + ps.MemUsageMi = m.Mem + } + result = append(result, ps) + } + c.JSON(200, models.Response{Code: 200, Data: result}) +} + +// ListHPAsAllNS lists all HPAs across all namespaces using clusterCode. +func buildPodMetricsMap(client *k8sclient.Client, namespace string) map[string]struct{ CPU, Mem string } { + m := map[string]struct{ CPU, Mem string }{} + if pm, err := client.ListPodMetrics(namespace); err == nil { + for _, item := range pm.Items { + totalCPU := 0 + totalMemMi := 0 + for _, c := range item.Containers { + totalCPU += parseCpuNanoToMilli(c.Usage["cpu"]) + totalMemMi += parseMemKiToMi(c.Usage["memory"]) + } + key := item.Metadata.Namespace + "/" + item.Metadata.Name + m[key] = struct{ CPU, Mem string }{ + CPU: fmt.Sprintf("%dm", totalCPU), + Mem: fmt.Sprintf("%dMi", totalMemMi), + } + } + } else { + log.Printf("[K8s] ListPodMetrics failed (metrics-server not installed?): %v", err) + } + return m +} + +func parseIntResource(val string) int { + if val == "" { + return 0 + } + var i int + fmt.Sscanf(val, "%d", &i) + return i +} + +func parseMemResource(val string) int64 { + if val == "" { + return 0 + } + var num float64 + var unit string + fmt.Sscanf(val, "%f%s", &num, &unit) + switch unit { + case "Ki": + return int64(num / 1024) + case "Mi": + return int64(num) + case "Gi": + return int64(num * 1024) + default: + return int64(num / 1024 / 1024) + } +} + +// formatCpuFromNano converts K8s metrics CPU (nanocores "80410173n") to millicores "80m" +func formatCpuFromNano(raw string) string { + if raw == "" { return "" } + val := raw + val = strings.TrimSuffix(val, "n") + var nano int64 + fmt.Sscanf(val, "%d", &nano) + milli := nano / 1000000 + if milli < 1 { milli = 1 } + return fmt.Sprintf("%dm", milli) +} + +// parseCpuNanoToMilli converts K8s metrics CPU (nanocores "100354992n") to millicores (int) // Logan.Li +func parseCpuNanoToMilli(raw string) int { + if raw == "" { return 0 } + val := strings.TrimSuffix(raw, "n") + var nano int64 + fmt.Sscanf(val, "%d", &nano) + milli := nano / 1000000 + if milli < 1 { milli = 1 } + return int(milli) +} + +// formatMemFromKi converts K8s metrics memory (Ki "2154292Ki") to Mi "2105Mi" +func formatMemFromKi(raw string) string { + if raw == "" { return "" } + val := strings.TrimSuffix(raw, "Ki") + var ki int64 + fmt.Sscanf(val, "%d", &ki) + mi := ki / 1024 + if mi < 1 { mi = 1 } + return fmt.Sprintf("%dMi", mi) +} + +// parseMemKiToMi converts K8s metrics memory (Ki "2154292Ki") to Mi (int) // Logan.Li +func parseMemKiToMi(raw string) int { + if raw == "" { return 0 } + val := strings.TrimSuffix(raw, "Ki") + var ki int64 + fmt.Sscanf(val, "%d", &ki) + return int(ki / 1024) +} + + +// GetResourceYAMLNS returns the YAML of a K8s resource +func (h *KubernetesHandler) GetPodMetrics(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.Query("namespace") + metrics, err := client.ListPodMetrics(ns) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: "metrics API error: " + err.Error()}); return } + + type ContainerMetric struct { + Name string `json:"name"` + CPU string `json:"cpu"` + Memory string `json:"memory"` + } + type PodMetric struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Containers []ContainerMetric `json:"containers"` + } + result := []PodMetric{} + for _, pm := range metrics.Items { + containers := []ContainerMetric{} + for _, c := range pm.Containers { + containers = append(containers, ContainerMetric{c.Name, c.Usage["cpu"], c.Usage["memory"]}) + } + result = append(result, PodMetric{pm.Metadata.Namespace, pm.Metadata.Name, containers}) + } + c.JSON(200, models.Response{Code: 200, Data: result}) +} + +// ============================================================================ +// Events (kubectl get events) +// ============================================================================ + +// ListEvents returns cluster events (optionally filtered by namespace and type) +func (h *KubernetesHandler) DeletePodNS(c *gin.Context) { + h.deletePodWithNS(c, c.Param("namespace")) +} +func (h *KubernetesHandler) deletePodWithNS(c *gin.Context, ns string) { + clusterID, _ := strconv.Atoi(c.Param("id")) + if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } + podName := c.Param("podName") + if podName == "" { c.JSON(400, models.Response{Code: 400, Message: "缺少 podName"}); return } + client, _, err := h.getK8sClient(clusterID) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } + if err := client.DeletePod(ns, podName); err != nil { + c.JSON(500, models.Response{Code: 500, Message: "删除 Pod 失败: " + err.Error()}); return + } + c.JSON(200, models.Response{Code: 200, Message: fmt.Sprintf("Pod %s 已删除", podName)}) +} + +// ============================================================================ +// Pod 文件列表 / 下载(复用 exec 能力) // Logan.Li +// ============================================================================ + +// cleanExecOutput 清理 exec 输出中残留的 SPDY 帧头等非打印字节(best-effort) +func cleanExecOutput(raw string) string { + var b strings.Builder + for _, r := range raw { + if r == '\n' || r == '\r' || r == ' ' || (r >= 0x20 && r < 0x7F) { + b.WriteRune(r) + } + } + return b.String() +} + +// ListPodFilesNS 列出 Pod 内指定路径的文件 diff --git a/backend/internal/api/handlers/k8s_pod_files.go b/backend/internal/api/handlers/k8s_pod_files.go new file mode 100644 index 0000000..e83341b --- /dev/null +++ b/backend/internal/api/handlers/k8s_pod_files.go @@ -0,0 +1,206 @@ +package handlers + +import ( + "encoding/base64" + "fmt" + "io" + "log" + "path" + "strconv" + "strings" + + "ezops/internal/models" + + "github.com/gin-gonic/gin" +) + +func (h *KubernetesHandler) ListPodFilesNS(c *gin.Context) { + h.listPodFilesWithNS(c, c.Param("namespace")) +} +func (h *KubernetesHandler) listPodFilesWithNS(c *gin.Context, ns string) { + clusterID, _ := strconv.Atoi(c.Param("id")) + if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } + podName := c.Param("podName") + var req struct { + Path string `json:"path"` + Container string `json:"container"` + } + if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, models.Response{Code: 400, Message: "参数错误: " + err.Error()}); return } + if req.Path == "" { req.Path = "/" } + client, _, err := h.getK8sClient(clusterID) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } + // ls -la — BusyBox ignores --time-style, use default "Mon DD HH:MM" format + cmd := []string{"ls", "-la", req.Path} + result, err := client.ExecInPod(ns, podName, req.Container, cmd) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: "执行 ls 失败: " + err.Error()}); return } + + type FileInfo struct { + Name string `json:"name"` + Size string `json:"size"` + Mode string `json:"mode"` + ModTime string `json:"mod_time"` + IsDir bool `json:"is_dir"` + } + files := []FileInfo{} + output := result.Stdout // WebSocket output is already clean text + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "total ") { continue } + fields := strings.Fields(line) + if len(fields) < 7 { continue } + mode := fields[0] + if len(mode) == 0 || (mode[0] != 'd' && mode[0] != '-' && mode[0] != 'l') { continue } + size := fields[4] + // BusyBox ls date format: "Jun 23 02:17" (3 fields: month day time) + // GNU ls --time-style: "2026-06-23T02:17:00" (1 field) + // Detect format: if fields[5] contains ":" it's time-only (BusyBox style) + var name, modTime string + if strings.Contains(fields[5], ":") || strings.Contains(fields[5], "T") { + // GNU style: single timestamp field + modTime = fields[5] + name = strings.Join(fields[6:], " ") + } else { + // BusyBox style: "Mon DD HH:MM" = 3 fields starting at index 5 + modTime = strings.Join(fields[5:8], " ") + name = strings.Join(fields[8:], " ") + } + if name == "" || name == "." || name == ".." { continue } + files = append(files, FileInfo{ + Name: name, + Size: size, + Mode: mode, + ModTime: modTime, + IsDir: mode[0] == 'd', + }) + } + c.JSON(200, models.Response{Code: 200, Message: "success", Data: gin.H{"path": req.Path, "files": files}}) +} + +// DownloadPodFileNS 下载 Pod 内指定路径的文件(通过 exec cat) +func (h *KubernetesHandler) DownloadPodFileNS(c *gin.Context) { + h.downloadPodFileWithNS(c, c.Param("namespace")) +} +func (h *KubernetesHandler) downloadPodFileWithNS(c *gin.Context, ns string) { + clusterID, _ := strconv.Atoi(c.Param("id")) + if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } + podName := c.Param("podName") + filePath := c.Query("path") + container := c.Query("container") + if filePath == "" { c.JSON(400, models.Response{Code: 400, Message: "path 参数不能为空"}); return } + client, _, err := h.getK8sClient(clusterID) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } + cmd := []string{"cat", filePath} + result, err := client.ExecInPod(ns, podName, container, cmd) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: "读取文件失败: " + err.Error()}); return } + content := result.Stdout + fileName := path.Base(filePath) + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileName)) + c.Header("Content-Type", "application/octet-stream") + c.String(200, content) +} + +// UploadPodFileNS 上传文件到 Pod 容器内指定目录(通过 exec base64 分块写入) +func (h *KubernetesHandler) UploadPodFileNS(c *gin.Context) { + h.uploadPodFileWithNS(c, c.Param("namespace")) +} +func (h *KubernetesHandler) uploadPodFileWithNS(c *gin.Context, ns string) { + clusterID, _ := strconv.Atoi(c.Param("id")) + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } + podName := c.Param("podName") + container := c.PostForm("container") + targetDir := c.PostForm("path") + if targetDir == "" { + targetDir = "/" + } + + // 读取上传的文件 + file, header, err := c.Request.FormFile("file") + if err != nil { + c.JSON(400, models.Response{Code: 400, Message: "文件上传失败: " + err.Error()}) + return + } + defer file.Close() + + // 限制文件大小 10MB + const maxUploadSize = 10 << 20 // 10MB + if header.Size > maxUploadSize { + c.JSON(400, models.Response{Code: 400, Message: fmt.Sprintf("文件过大: %d bytes, 上限 %d bytes", header.Size, maxUploadSize)}) + return + } + + content, err := io.ReadAll(file) + if err != nil { + c.JSON(500, models.Response{Code: 500, Message: "读取文件失败: " + err.Error()}) + return + } + + client, _, err := h.getK8sClient(clusterID) + if err != nil { + c.JSON(500, models.Response{Code: 500, Message: err.Error()}) + return + } + + // 目标完整路径 + targetFile := strings.TrimRight(targetDir, "/") + "/" + header.Filename + + // 确保目标目录存在 + _, _ = client.ExecInPod(ns, podName, container, []string{"mkdir", "-p", targetDir}) + + // base64 分块写入:每块 3000 字节 base64 (~2250 bytes 二进制),保证 URL 长度安全 + encoded := base64.StdEncoding.EncodeToString(content) + const chunkSize = 3000 + first := true + for i := 0; i < len(encoded); i += chunkSize { + end := i + chunkSize + if end > len(encoded) { + end = len(encoded) + } + chunk := encoded[i:end] + var cmd []string + if first { + // 首块:创建/覆盖文件 + cmd = []string{"sh", "-c", fmt.Sprintf("echo -n '%s' | base64 -d > '%s'", chunk, targetFile)} + first = false + } else { + // 后续块:追加 + cmd = []string{"sh", "-c", fmt.Sprintf("echo -n '%s' | base64 -d >> '%s'", chunk, targetFile)} + } + result, err := client.ExecInPod(ns, podName, container, cmd) + if err != nil { + stderr := "" + if result != nil { + stderr = result.Stderr + } + c.JSON(500, models.Response{Code: 500, Message: fmt.Sprintf("写入文件失败 (chunk %d): %s\n%s", i/chunkSize, err.Error(), stderr)}) + return + } + } + + // 验证文件大小 + verifyResult, verifyErr := client.ExecInPod(ns, podName, container, []string{"stat", "-c", "%s", targetFile}) + writtenSize := "" + if verifyErr == nil && verifyResult != nil { + writtenSize = strings.TrimSpace(verifyResult.Stdout) + } + + log.Printf("[UploadPodFile] %s/%s → %s (%d bytes, %d chunks)", ns, podName, targetFile, len(content), (len(encoded)+chunkSize-1)/chunkSize) + + c.JSON(200, models.Response{ + Code: 200, + Message: "上传成功", + Data: gin.H{ + "path": targetFile, + "name": header.Filename, + "size": len(content), + "verify": writtenSize, + }, + }) +} + +// ============================================================================ +// M13: 多集群资源对比看板 (Multi-Cluster Comparison Dashboard) // Logan.Li +// ============================================================================ + +// GetMultiClusterComparison 聚合所有集群的资源数据进行对比 diff --git a/backend/internal/api/handlers/k8s_resource.go b/backend/internal/api/handlers/k8s_resource.go new file mode 100644 index 0000000..ef4bb76 --- /dev/null +++ b/backend/internal/api/handlers/k8s_resource.go @@ -0,0 +1,351 @@ +package handlers + +import ( + "net/http" + "strconv" + + "ezops/internal/models" + + "github.com/gin-gonic/gin" +) + +func (h *KubernetesHandler) ListServices(c *gin.Context) { + clusterID, _ := strconv.Atoi(c.Param("id")) + namespace := c.Query("namespace") + + client, _, err := h.getK8sClient(clusterID) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) + return + } + svcs, err := client.ListServices() + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } + + type SvcSummary struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Type string `json:"type"` + ClusterIP string `json:"cluster_ip"` + Ports int `json:"port_count"` + } + result := []SvcSummary{} + for _, s := range svcs.Items { + if namespace != "" && s.Metadata.Namespace != namespace { + continue + } + result = append(result, SvcSummary{ + Namespace: s.Metadata.Namespace, + Name: s.Metadata.Name, + Type: s.Spec.Type, + ClusterIP: s.Spec.ClusterIP, + Ports: len(s.Spec.Ports), + }) + } + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "success", Data: result}) +} + +// ListIngresses 获取Ingress列表 +// @Summary 获取Ingress列表 +// @Description 获取指定集群的Ingress列表 +// @Tags K8s集群 +// @Produce json +// @Security Bearer +// @Param id path int true "集群ID" +// @Param namespace query string false "命名空间" +// @Success 200 {object} models.Response{data=[]models.KubernetesIngress} +// @Router /kubernetes-clusters/{id}/ingresses [get] +func (h *KubernetesHandler) ListIngresses(c *gin.Context) { + clusterID, _ := strconv.Atoi(c.Param("id")) + namespace := c.Query("namespace") + + client, _, err := h.getK8sClient(clusterID) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) + return + } + ings, err := client.ListIngresses() + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } + + type IngSummary struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Hosts []string `json:"hosts"` + TLS bool `json:"tls"` + } + result := []IngSummary{} + for _, ing := range ings.Items { + if namespace != "" && ing.Metadata.Namespace != namespace { + continue + } + hosts := []string{} + for _, rule := range ing.Spec.Rules { + if rule.Host != "" { + hosts = append(hosts, rule.Host) + } + } + result = append(result, IngSummary{ + Namespace: ing.Metadata.Namespace, + Name: ing.Metadata.Name, + Hosts: hosts, + TLS: len(ing.Spec.TLS) > 0, + }) + } + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "success", Data: result}) +} + +// GetTopology 获取集群拓扑布局 // Logan.Li +func (h *KubernetesHandler) GetTopology(c *gin.Context) { + clusterID := c.Param("id") + var layout models.TopologyLayout + result := h.db.Where("cluster_id = ?", clusterID).First(&layout) + if result.Error != nil { + c.JSON(http.StatusOK, models.Response{Code: 200, Data: &models.TopologyLayout{}}) + return + } + c.JSON(http.StatusOK, models.Response{Code: 200, Data: layout}) +} + +// SaveTopology 保存集群拓扑布局 // Logan.Li +func (h *KubernetesHandler) SaveTopology(c *gin.Context) { + clusterID, _ := strconv.Atoi(c.Param("id")) + var req struct { + LayoutData string `json:"layout_data"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误"}) + return + } + + var layout models.TopologyLayout + h.db.Where("cluster_id = ?", clusterID).First(&layout) + layout.ClusterID = uint(clusterID) + layout.LayoutData = req.LayoutData + if layout.ID == 0 { + h.db.Create(&layout) + } else { + h.db.Save(&layout) + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "保存成功"}) +} + + +// SyncCluster 同步集群资源(连接真实K8s API拉取数据) +func (h *KubernetesHandler) ListConfigMaps(c *gin.Context) { + clusterID, _ := strconv.Atoi(c.Param("id")) + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } + namespace := c.Param("namespace") + + client, _, err := h.getK8sClient(clusterID) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) + return + } + + cms, err := client.ListConfigMaps(namespace) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Data: cms.Items}) +} + +// ListSecrets 获取命名空间下的 Secret 列表(仅名称+类型,不含值) +func (h *KubernetesHandler) ListSecrets(c *gin.Context) { + clusterID, _ := strconv.Atoi(c.Param("id")) + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } + namespace := c.Param("namespace") + + client, _, err := h.getK8sClient(clusterID) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) + return + } + + secrets, err := client.ListSecrets(namespace) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Data: secrets.Items}) +} + +// ============================================================================ +// Helpers +// ============================================================================ + +// resolveClusterCode resolves a cluster code to its numeric ID +func (h *KubernetesHandler) ListServicesNS(c *gin.Context) { + h.listServicesWithNS(c, c.Param("namespace")) +} +func (h *KubernetesHandler) ListIngressesNS(c *gin.Context) { + h.listIngressesWithNS(c, c.Param("namespace")) +} +func (h *KubernetesHandler) listServicesWithNS(c *gin.Context, ns string) { + 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 } + svcs, err := client.ListServices() + if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + type SvcPortDetail struct { + Name string `json:"name,omitempty"` + Port int `json:"port"` + TargetPort interface{} `json:"targetPort"` + NodePort int `json:"nodePort,omitempty"` + Protocol string `json:"protocol,omitempty"` + } + type SvcSummary struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Type string `json:"type"` + ClusterIP string `json:"cluster_ip"` + Ports int `json:"ports"` + PortList []SvcPortDetail `json:"portList,omitempty"` + Selector map[string]string `json:"selector,omitempty"` + } + result := []SvcSummary{} + for _, s := range svcs.Items { + if s.Metadata.Namespace != ns { continue } + ports := []SvcPortDetail{} + for _, p := range s.Spec.Ports { + ports = append(ports, SvcPortDetail{ + Name: p.Name, Port: p.Port, TargetPort: p.TargetPort, + NodePort: p.NodePort, Protocol: p.Protocol, + }) + } + result = append(result, SvcSummary{ + Namespace: s.Metadata.Namespace, + Name: s.Metadata.Name, + Type: s.Spec.Type, + ClusterIP: s.Spec.ClusterIP, + Ports: len(s.Spec.Ports), + PortList: ports, + Selector: s.Spec.Selector, + }) + } + c.JSON(200, models.Response{Code: 200, Data: result}) +} +func (h *KubernetesHandler) listIngressesWithNS(c *gin.Context, ns string) { + 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 } + ings, err := client.ListIngresses() + if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + type PathDetail struct { + Path string `json:"path"` + PathType string `json:"pathType,omitempty"` + BackendSvc string `json:"backendService,omitempty"` + BackendPort string `json:"backendPort,omitempty"` + } + type RuleDetail struct { + Host string `json:"host"` + Paths []PathDetail `json:"paths"` + } + type IngSummary struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Hosts []string `json:"hosts"` + TLS bool `json:"tls"` + IngressClass string `json:"ingressClass,omitempty"` + Rules []RuleDetail `json:"rules,omitempty"` + } + result := []IngSummary{} + for _, ing := range ings.Items { + if ing.Metadata.Namespace != ns { continue } + hosts := []string{} + rules := []RuleDetail{} + for _, rule := range ing.Spec.Rules { + if rule.Host != "" { hosts = append(hosts, rule.Host) } + rd := RuleDetail{Host: rule.Host, Paths: []PathDetail{}} + if rule.HTTP != nil { + for _, p := range rule.HTTP.Paths { + pd := PathDetail{Path: p.Path, PathType: p.PathType, BackendSvc: p.Backend.Service.Name} + if p.Backend.Service.Port.Number > 0 { + pd.BackendPort = strconv.Itoa(p.Backend.Service.Port.Number) + } else if p.Backend.Service.Port.Name != "" { + pd.BackendPort = p.Backend.Service.Port.Name + } + rd.Paths = append(rd.Paths, pd) + } + } + rules = append(rules, rd) + } + // ingressClassName from annotations + ingClass := "" + if ing.Metadata.Annotations != nil { + ingClass = ing.Metadata.Annotations["kubernetes.io/ingress.class"] + } + result = append(result, IngSummary{ + Namespace: ing.Metadata.Namespace, + Name: ing.Metadata.Name, + Hosts: hosts, + TLS: len(ing.Spec.TLS) > 0, + IngressClass: ingClass, + Rules: rules, + }) + } + c.JSON(200, models.Response{Code: 200, Data: result}) +} +func (h *KubernetesHandler) ListPVCsNS(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") + pvcs, err := client.ListPVCs(ns) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + type PVCSummary struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Phase string `json:"phase"` + Size string `json:"size"` + StorageClass string `json:"storage_class"` + } + result := []PVCSummary{} + for _, p := range pvcs.Items { + size := "" + if p.Status.Capacity != nil { + if s, ok := p.Status.Capacity["storage"]; ok { size = s } + } + result = append(result, PVCSummary{ + Namespace: p.Metadata.Namespace, + Name: p.Metadata.Name, + Phase: p.Status.Phase, + Size: size, + StorageClass: p.Spec.StorageClassName, + }) + } + c.JSON(200, models.Response{Code: 200, Data: result}) +} + +// DeletePVCNS deletes a PVC (code-based) +func (h *KubernetesHandler) DeletePVCNS(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 } + if err := client.DeletePVC(c.Param("namespace"), c.Param("name")); err != nil { + c.JSON(500, models.Response{Code: 500, Message: "删除失败: " + err.Error()}); return + } + c.JSON(200, models.Response{Code: 200, Message: "已删除"}) +} + +// ============================================================================ +// StatefulSet / DaemonSet handlers (code-based) +// ============================================================================ + +// ListStatefulSetsNS lists StatefulSets in a namespace diff --git a/backend/internal/api/handlers/k8s_workload.go b/backend/internal/api/handlers/k8s_workload.go new file mode 100644 index 0000000..986efe6 --- /dev/null +++ b/backend/internal/api/handlers/k8s_workload.go @@ -0,0 +1,696 @@ +package handlers + +import ( + "fmt" + "net/http" + "sort" + "strconv" + + "ezops/internal/models" + + "github.com/gin-gonic/gin" +) + +func (h *KubernetesHandler) ListDeployments(c *gin.Context) { + clusterID, _ := strconv.Atoi(c.Param("id")) + namespace := c.Query("namespace") + + client, _, err := h.getK8sClient(clusterID) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) + return + } + deps, err := client.ListDeployments() + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } + + type DepSummary struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Replicas int `json:"replicas"` + AvailableReplicas int `json:"available_replicas"` + ReadyReplicas int `json:"ready_replicas"` + Image string `json:"image"` + } + result := []DepSummary{} + for _, d := range deps.Items { + if namespace != "" && d.Metadata.Namespace != namespace { + continue + } + image := "" + if len(d.Spec.Template.Spec.Containers) > 0 { + image = d.Spec.Template.Spec.Containers[0].Image + } + result = append(result, DepSummary{ + Namespace: d.Metadata.Namespace, + Name: d.Metadata.Name, + Replicas: d.Spec.Replicas, + AvailableReplicas: d.Status.AvailableReplicas, + ReadyReplicas: d.Status.ReadyReplicas, + Image: image, + }) + } + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "success", Data: result}) +} + +// ListServices 获取Service列表 +// @Summary 获取Service列表 +// @Description 获取指定集群的Service列表 +// @Tags K8s集群 +// @Produce json +// @Security Bearer +// @Param id path int true "集群ID" +// @Param namespace query string false "命名空间" +// @Success 200 {object} models.Response{data=[]models.KubernetesService} +// @Router /kubernetes-clusters/{id}/services [get] +func (h *KubernetesHandler) ScaleDeployment(c *gin.Context) { + clusterID, _ := strconv.Atoi(c.Param("id")) + namespace := c.Param("namespace") + name := c.Param("name") + + var req struct { + Replicas int `json:"replicas"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误"}) + return + } + if req.Replicas < 0 { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "副本数不能为负数"}) + return + } + + client, _, err := h.getK8sClient(clusterID) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) + return + } + + if err := client.ScaleDeployment(namespace, name, req.Replicas); err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "扩缩容失败: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Message: fmt.Sprintf("已设置为 %d 副本", req.Replicas)}) +} + +// RestartDeployment 重启 Deployment (滚动重启) +func (h *KubernetesHandler) RestartDeployment(c *gin.Context) { + clusterID, _ := strconv.Atoi(c.Param("id")) + namespace := c.Param("namespace") + name := c.Param("name") + + client, _, err := h.getK8sClient(clusterID) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) + return + } + + if err := client.RestartDeployment(namespace, name); err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "重启失败: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "重启指令已下发"}) +} + +// PauseDeployment 暂停 Deployment 滚动更新(spec.paused=true) +func (h *KubernetesHandler) PauseDeployment(c *gin.Context) { + clusterID, _ := strconv.Atoi(c.Param("id")) + namespace := c.Param("namespace") + name := c.Param("name") + + client, _, err := h.getK8sClient(clusterID) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) + return + } + + if err := client.PauseDeployment(namespace, name); err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "暂停更新失败: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "已暂停滚动更新"}) +} + +// ResumeDeployment 恢复 Deployment 滚动更新(spec.paused=false) +func (h *KubernetesHandler) ResumeDeployment(c *gin.Context) { + clusterID, _ := strconv.Atoi(c.Param("id")) + namespace := c.Param("namespace") + name := c.Param("name") + + client, _, err := h.getK8sClient(clusterID) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) + return + } + + if err := client.ResumeDeployment(namespace, name); err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "恢复更新失败: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "已恢复滚动更新"}) +} + +// ============================================================================ +// HPA 管理 +// ============================================================================ + +// ListHPAs 获取 HPA 列表 +func (h *KubernetesHandler) UpdateDeploymentImage(c *gin.Context) { + clusterID, _ := strconv.Atoi(c.Param("id")) + namespace := c.Param("namespace") + name := c.Param("name") + + var req struct { + Image string `json:"image"` + ContainerName string `json:"container_name"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误"}) + return + } + if req.Image == "" { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "image 不能为空"}) + return + } + + client, _, err := h.getK8sClient(clusterID) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) + return + } + + if err := client.UpdateDeploymentImage(namespace, name, req.ContainerName, req.Image); err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "更新镜像失败: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Message: fmt.Sprintf("镜像已更新为 %s,正在滚动更新...", req.Image)}) +} + +// ============================================================================ +// Namespace +// ============================================================================ + +// ListNamespaces 获取命名空间列表 +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) ScaleDeploymentNS(c *gin.Context) { + h.scaleDeploymentWithNS(c, c.Param("namespace")) +} +func (h *KubernetesHandler) RestartDeploymentNS(c *gin.Context) { + h.restartDeploymentWithNS(c, c.Param("namespace")) +} +func (h *KubernetesHandler) UpdateDeploymentImageNS(c *gin.Context) { + h.updateDeploymentImageWithNS(c, c.Param("namespace")) +} +func (h *KubernetesHandler) PauseDeploymentNS(c *gin.Context) { + h.pauseDeploymentWithNS(c, c.Param("namespace")) +} +func (h *KubernetesHandler) ResumeDeploymentNS(c *gin.Context) { + h.resumeDeploymentWithNS(c, c.Param("namespace")) +} +func (h *KubernetesHandler) listDeploymentsWithNS(c *gin.Context, ns string) { + 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 } + type DepSummary struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Replicas int `json:"replicas"` + AvailableReplicas int `json:"available_replicas"` + ReadyReplicas int `json:"ready_replicas"` + Image string `json:"image"` + Paused bool `json:"paused"` + Selector map[string]string `json:"selector,omitempty"` // matchLabels — 前端 Service Tab 关联用 + } + result := []DepSummary{} + for _, d := range deps.Items { + if d.Metadata.Namespace != ns { continue } + image := ""; if len(d.Spec.Template.Spec.Containers) > 0 { image = d.Spec.Template.Spec.Containers[0].Image } + result = append(result, DepSummary{ + Namespace: d.Metadata.Namespace, + Name: d.Metadata.Name, + Replicas: d.Spec.Replicas, + AvailableReplicas: d.Status.AvailableReplicas, + ReadyReplicas: d.Status.ReadyReplicas, + Image: image, + Paused: d.Spec.Paused, + Selector: func() map[string]string { if d.Spec.Selector != nil { return d.Spec.Selector.MatchLabels }; return nil }(), + }) + } + c.JSON(200, models.Response{Code: 200, Data: result}) +} +func (h *KubernetesHandler) scaleDeploymentWithNS(c *gin.Context, ns string) { + clusterID, _ := strconv.Atoi(c.Param("id")) + if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } + var req struct { Replicas int `json:"replicas"` } + if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, models.Response{Code: 400, Message: "参数错误"}); return } + client, _, err := h.getK8sClient(clusterID) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } + if err := client.ScaleDeployment(ns, c.Param("name"), req.Replicas); err != nil { c.JSON(500, models.Response{Code: 500, Message: "扩缩容失败: " + err.Error()}); return } + c.JSON(200, models.Response{Code: 200, Message: fmt.Sprintf("已设置为 %d 副本", req.Replicas)}) +} +func (h *KubernetesHandler) restartDeploymentWithNS(c *gin.Context, ns string) { + 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 } + if err := client.RestartDeployment(ns, c.Param("name")); err != nil { c.JSON(500, models.Response{Code: 500, Message: "重启失败: " + err.Error()}); return } + c.JSON(200, models.Response{Code: 200, Message: "重启指令已下发"}) +} +func (h *KubernetesHandler) updateDeploymentImageWithNS(c *gin.Context, ns string) { + clusterID, _ := strconv.Atoi(c.Param("id")) + if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } + var req struct { Image string `json:"image"`; ContainerName string `json:"container_name"` } + if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, models.Response{Code: 400, Message: "参数错误"}); return } + client, _, err := h.getK8sClient(clusterID) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } + if err := client.UpdateDeploymentImage(ns, c.Param("name"), req.ContainerName, req.Image); err != nil { c.JSON(500, models.Response{Code: 500, Message: "更新镜像失败: " + err.Error()}); return } + c.JSON(200, models.Response{Code: 200, Message: fmt.Sprintf("镜像已更新为 %s", req.Image)}) +} +func (h *KubernetesHandler) pauseDeploymentWithNS(c *gin.Context, ns string) { + 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 } + if err := client.PauseDeployment(ns, c.Param("name")); err != nil { c.JSON(500, models.Response{Code: 500, Message: "暂停更新失败: " + err.Error()}); return } + c.JSON(200, models.Response{Code: 200, Message: "已暂停滚动更新"}) +} +func (h *KubernetesHandler) resumeDeploymentWithNS(c *gin.Context, ns string) { + 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 } + if err := client.ResumeDeployment(ns, c.Param("name")); err != nil { c.JSON(500, models.Response{Code: 500, Message: "恢复更新失败: " + err.Error()}); return } + c.JSON(200, models.Response{Code: 200, Message: "已恢复滚动更新"}) +} +func (h *KubernetesHandler) ListDeploymentsAllNS(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 } + deps, err := client.ListDeployments() + if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + type DepSummary struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Replicas int `json:"replicas"` + AvailableReplicas int `json:"available_replicas"` + ReadyReplicas int `json:"ready_replicas"` + Image string `json:"image"` + } + result := []DepSummary{} + for _, d := range deps.Items { + image := ""; if len(d.Spec.Template.Spec.Containers) > 0 { image = d.Spec.Template.Spec.Containers[0].Image } + result = append(result, DepSummary{ + Namespace: d.Metadata.Namespace, + Name: d.Metadata.Name, + Replicas: d.Spec.Replicas, + AvailableReplicas: d.Status.AvailableReplicas, + ReadyReplicas: d.Status.ReadyReplicas, + Image: image, + }) + } + c.JSON(200, models.Response{Code: 200, Data: result}) +} + +// ListPodsAllNS lists all pods across all namespaces using clusterCode. +func (h *KubernetesHandler) ListStatefulSetsNS(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 } + sts, err := client.ListStatefulSets() + if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + ns := c.Param("namespace") + type StsSummary struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Replicas int `json:"replicas"` + Ready int `json:"ready"` + Image string `json:"image"` + } + result := []StsSummary{} + for _, s := range sts.Items { + if s.Metadata.Namespace != ns { continue } + image := ""; if len(s.Spec.Template.Spec.Containers) > 0 { image = s.Spec.Template.Spec.Containers[0].Image } + result = append(result, StsSummary{ + Namespace: s.Metadata.Namespace, + Name: s.Metadata.Name, + Replicas: s.Spec.Replicas, + Ready: s.Status.ReadyReplicas, + Image: image, + }) + } + c.JSON(200, models.Response{Code: 200, Data: result}) +} + +// ListDaemonSetsNS lists DaemonSets in a namespace +func (h *KubernetesHandler) ListDaemonSetsNS(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 } + ds, err := client.ListDaemonSets() + if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + ns := c.Param("namespace") + type DsSummary struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Desired int `json:"desired"` + Current int `json:"current"` + Ready int `json:"ready"` + Image string `json:"image"` + } + result := []DsSummary{} + for _, d := range ds.Items { + if d.Metadata.Namespace != ns { continue } + image := ""; if len(d.Spec.Template.Spec.Containers) > 0 { image = d.Spec.Template.Spec.Containers[0].Image } + result = append(result, DsSummary{ + Namespace: d.Metadata.Namespace, + Name: d.Metadata.Name, + Desired: d.Status.DesiredNumberScheduled, + Current: d.Status.CurrentNumberScheduled, + Ready: d.Status.NumberReady, + Image: image, + }) + } + 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 +// ============================================================================ + +// GetPodMetrics returns pod-level CPU/Memory metrics +func (h *KubernetesHandler) ListReplicaSetsNS(c *gin.Context) { + h.listReplicaSetsWithNS(c, c.Param("namespace")) +} +func (h *KubernetesHandler) listReplicaSetsWithNS(c *gin.Context, ns string) { + 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 } + items, err := client.ListReplicaSetsRaw(ns) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + type RSSummary struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Replicas int `json:"replicas"` + AvailableReplicas int `json:"available_replicas"` + ReadyReplicas int `json:"ready_replicas"` + Image string `json:"image"` + CreatedAt string `json:"created_at"` + Revision string `json:"revision"` + OwnerDeployment string `json:"owner_deployment"` + } + result := []RSSummary{} + for _, rs := range items { + image := "" + if len(rs.Spec.Template.Spec.Containers) > 0 { image = rs.Spec.Template.Spec.Containers[0].Image } + revision := rs.Metadata.Annotations["deployment.kubernetes.io/revision"] + owner := "" + for _, o := range rs.Metadata.OwnerReferences { + if o.Kind == "Deployment" { owner = o.Name; break } + } + result = append(result, RSSummary{ + Namespace: rs.Metadata.Namespace, + Name: rs.Metadata.Name, + Replicas: rs.Spec.Replicas, + AvailableReplicas: rs.Status.AvailableReplicas, + ReadyReplicas: rs.Status.ReadyReplicas, + Image: image, + CreatedAt: rs.Metadata.CreationTimestamp, + Revision: revision, + OwnerDeployment: owner, + }) + } + c.JSON(200, models.Response{Code: 200, Message: "success", Data: result}) +} + +// ListDeploymentRevisionsNS Deployment 发布历史(按 revision 倒序) +func (h *KubernetesHandler) ListDeploymentRevisionsNS(c *gin.Context) { + h.listDeploymentRevisionsWithNS(c, c.Param("namespace")) +} +func (h *KubernetesHandler) listDeploymentRevisionsWithNS(c *gin.Context, ns string) { + clusterID, _ := strconv.Atoi(c.Param("id")) + if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } + deploymentName := c.Param("name") + if deploymentName == "" { c.JSON(400, models.Response{Code: 400, Message: "缺少 deployment name"}); return } + client, _, err := h.getK8sClient(clusterID) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } + items, err := client.ListReplicaSetsRaw(ns) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + type Revision struct { + Revision string `json:"revision"` + ReplicaSet string `json:"replica_set"` + Replicas int `json:"replicas"` + Image string `json:"image"` + CreatedAt string `json:"created_at"` + Current bool `json:"current"` + } + result := []Revision{} + for _, rs := range items { + isOwner := false + for _, o := range rs.Metadata.OwnerReferences { + if o.Kind == "Deployment" && o.Name == deploymentName { isOwner = true; break } + } + if !isOwner { continue } + image := "" + if len(rs.Spec.Template.Spec.Containers) > 0 { image = rs.Spec.Template.Spec.Containers[0].Image } + result = append(result, Revision{ + Revision: rs.Metadata.Annotations["deployment.kubernetes.io/revision"], + ReplicaSet: rs.Metadata.Name, + Replicas: rs.Spec.Replicas, + Image: image, + CreatedAt: rs.Metadata.CreationTimestamp, + Current: rs.Spec.Replicas > 0, + }) + } + // 按 revision 数字倒序(最新在前) + sort.Slice(result, func(i, j int) bool { + ri, _ := strconv.Atoi(result[i].Revision) + rj, _ := strconv.Atoi(result[j].Revision) + return ri > rj + }) + c.JSON(200, models.Response{Code: 200, Message: "success", Data: result}) +} + +// RollbackDeploymentNS 回滚 Deployment 到指定 revision 或指定 ReplicaSet +func (h *KubernetesHandler) RollbackDeploymentNS(c *gin.Context) { + h.rollbackDeploymentWithNS(c, c.Param("namespace")) +} +func (h *KubernetesHandler) rollbackDeploymentWithNS(c *gin.Context, ns string) { + clusterID, _ := strconv.Atoi(c.Param("id")) + if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } + deploymentName := c.Param("name") + if deploymentName == "" { c.JSON(400, models.Response{Code: 400, Message: "缺少 deployment name"}); return } + var req struct { + Revision int `json:"revision"` + ReplicaSet string `json:"replicaSet"` + } + _ = c.ShouldBindJSON(&req) // 允许空 body(但下面会校验) + client, _, err := h.getK8sClient(clusterID) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } + + targetRS := req.ReplicaSet + // 通过 revision 定位 ReplicaSet + if targetRS == "" && req.Revision > 0 { + items, err := client.ListReplicaSetsRaw(ns) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: "获取 ReplicaSet 列表失败: " + err.Error()}); return } + for _, rs := range items { + isOwner := false + for _, o := range rs.Metadata.OwnerReferences { + if o.Kind == "Deployment" && o.Name == deploymentName { isOwner = true; break } + } + if !isOwner { continue } + if rs.Metadata.Annotations["deployment.kubernetes.io/revision"] == strconv.Itoa(req.Revision) { + targetRS = rs.Metadata.Name + break + } + } + if targetRS == "" { + c.JSON(404, models.Response{Code: 404, Message: fmt.Sprintf("revision %d 的 ReplicaSet 未找到", req.Revision)}) + return + } + } + if targetRS == "" { c.JSON(400, models.Response{Code: 400, Message: "需要 revision 或 replicaSet 参数"}); return } + + // 取 ReplicaSet 的 template,patch 到 Deployment.spec.template 实现回滚 + rsJSON, err := client.GetReplicaSet(ns, targetRS) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: "获取 ReplicaSet 失败: " + err.Error()}); return } + specMap, ok := rsJSON["spec"].(map[string]interface{}) + if !ok { c.JSON(500, models.Response{Code: 500, Message: "ReplicaSet 缺少 spec"}); return } + template, ok := specMap["template"].(map[string]interface{}) + if !ok { c.JSON(500, models.Response{Code: 500, Message: "ReplicaSet 缺少 template"}); return } + + if err := client.PatchDeploymentSpecTemplate(ns, deploymentName, template); err != nil { + c.JSON(500, models.Response{Code: 500, Message: "回滚失败: " + err.Error()}); return + } + c.JSON(200, models.Response{ + Code: 200, + Message: fmt.Sprintf("Deployment %s 已回滚到 %s", deploymentName, targetRS), + Data: gin.H{"replicaSet": targetRS, "revision": req.Revision}, + }) +} + +// ============================================================================ +// Pod 删除 // Logan.Li +// ============================================================================ + +// DeletePodNS 删除指定 Pod diff --git a/backend/internal/api/handlers/k8s_yaml.go b/backend/internal/api/handlers/k8s_yaml.go new file mode 100644 index 0000000..274e76d --- /dev/null +++ b/backend/internal/api/handlers/k8s_yaml.go @@ -0,0 +1,213 @@ +package handlers + +import ( + "fmt" + "io" + "strings" + + "ezops/internal/models" + + "github.com/gin-gonic/gin" +) + +func (h *KubernetesHandler) GetResourceYAMLNS(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") + 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 + switch resource { + case "deployments", "deployment": + apiPath = fmt.Sprintf("/apis/apps/v1/namespaces/%s/deployments/%s", ns, name) + case "statefulsets", "statefulset": + apiPath = fmt.Sprintf("/apis/apps/v1/namespaces/%s/statefulsets/%s", ns, name) + case "daemonsets", "daemonset": + apiPath = fmt.Sprintf("/apis/apps/v1/namespaces/%s/daemonsets/%s", ns, name) + case "replicasets", "replicaset": + apiPath = fmt.Sprintf("/apis/apps/v1/namespaces/%s/replicasets/%s", ns, name) + case "pods", "pod": + apiPath = fmt.Sprintf("/api/v1/namespaces/%s/pods/%s", ns, name) + case "services", "service": + apiPath = fmt.Sprintf("/api/v1/namespaces/%s/services/%s", ns, name) + case "ingresses", "ingress": + apiPath = fmt.Sprintf("/apis/networking.k8s.io/v1/namespaces/%s/ingresses/%s", ns, name) + case "configmaps", "configmap": + apiPath = fmt.Sprintf("/api/v1/namespaces/%s/configmaps/%s", ns, name) + case "secrets", "secret": + apiPath = fmt.Sprintf("/api/v1/namespaces/%s/secrets/%s", ns, name) + case "pvcs", "persistentvolumeclaim": + apiPath = fmt.Sprintf("/api/v1/namespaces/%s/persistentvolumeclaims/%s", ns, name) + case "hpas", "hpa", "horizontalpodautoscaler": + apiPath = fmt.Sprintf("/apis/autoscaling/v2/namespaces/%s/horizontalpodautoscalers/%s", ns, name) + case "cronjobs", "cronjob": + apiPath = fmt.Sprintf("/apis/batch/v1/namespaces/%s/cronjobs/%s", ns, name) + case "serviceaccounts", "serviceaccount": + apiPath = fmt.Sprintf("/api/v1/namespaces/%s/serviceaccounts/%s", ns, name) + case "roles", "role": + apiPath = fmt.Sprintf("/apis/rbac.authorization.k8s.io/v1/namespaces/%s/roles/%s", ns, name) + case "rolebindings", "rolebinding": + apiPath = fmt.Sprintf("/apis/rbac.authorization.k8s.io/v1/namespaces/%s/rolebindings/%s", ns, name) + default: + c.JSON(400, models.Response{Code: 400, Message: "unsupported resource type: " + resource}); return + } + yamlBytes, err := client.GetResourceYAML(apiPath) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: "获取YAML失败: " + err.Error()}); return } + c.Header("Content-Type", "application/yaml") + c.String(200, string(yamlBytes)) +} + +// UpdateResourceYAMLNS applies YAML to update a K8s resource +func (h *KubernetesHandler) UpdateResourceYAMLNS(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") + 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 } + var apiPath string + switch resource { + case "deployments", "deployment": + apiPath = fmt.Sprintf("/apis/apps/v1/namespaces/%s/deployments/%s", ns, name) + case "statefulsets", "statefulset": + apiPath = fmt.Sprintf("/apis/apps/v1/namespaces/%s/statefulsets/%s", ns, name) + case "daemonsets", "daemonset": + apiPath = fmt.Sprintf("/apis/apps/v1/namespaces/%s/daemonsets/%s", ns, name) + case "replicasets", "replicaset": + apiPath = fmt.Sprintf("/apis/apps/v1/namespaces/%s/replicasets/%s", ns, name) + case "pods", "pod": + apiPath = fmt.Sprintf("/api/v1/namespaces/%s/pods/%s", ns, name) + case "services", "service": + apiPath = fmt.Sprintf("/api/v1/namespaces/%s/services/%s", ns, name) + case "ingresses", "ingress": + apiPath = fmt.Sprintf("/apis/networking.k8s.io/v1/namespaces/%s/ingresses/%s", ns, name) + case "configmaps", "configmap": + apiPath = fmt.Sprintf("/api/v1/namespaces/%s/configmaps/%s", ns, name) + case "secrets", "secret": + apiPath = fmt.Sprintf("/api/v1/namespaces/%s/secrets/%s", ns, name) + case "pvcs", "persistentvolumeclaim": + apiPath = fmt.Sprintf("/api/v1/namespaces/%s/persistentvolumeclaims/%s", ns, name) + case "hpas", "hpa", "horizontalpodautoscaler": + apiPath = fmt.Sprintf("/apis/autoscaling/v2/namespaces/%s/horizontalpodautoscalers/%s", ns, name) + case "cronjobs", "cronjob": + apiPath = fmt.Sprintf("/apis/batch/v1/namespaces/%s/cronjobs/%s", ns, name) + case "serviceaccounts", "serviceaccount": + apiPath = fmt.Sprintf("/api/v1/namespaces/%s/serviceaccounts/%s", ns, name) + case "roles", "role": + apiPath = fmt.Sprintf("/apis/rbac.authorization.k8s.io/v1/namespaces/%s/roles/%s", ns, name) + case "rolebindings", "rolebinding": + apiPath = fmt.Sprintf("/apis/rbac.authorization.k8s.io/v1/namespaces/%s/rolebindings/%s", ns, name) + default: + c.JSON(400, models.Response{Code: 400, Message: "unsupported resource type: " + resource}); return + } + if err := client.UpdateResourceYAML(apiPath, yamlData); err != nil { + c.JSON(500, models.Response{Code: 500, Message: "更新失败: " + err.Error()}); return + } + c.JSON(200, models.Response{Code: 200, Message: "YAML已应用"}) +} + +// resourceAPIPath builds the K8s API path for common resource types (shared by Get/Update/Delete) +func resourceAPIPath(ns, resource, name string) (string, error) { + switch resource { + case "deployments", "deployment": + return fmt.Sprintf("/apis/apps/v1/namespaces/%s/deployments/%s", ns, name), nil + case "statefulsets", "statefulset": + return fmt.Sprintf("/apis/apps/v1/namespaces/%s/statefulsets/%s", ns, name), nil + case "daemonsets", "daemonset": + return fmt.Sprintf("/apis/apps/v1/namespaces/%s/daemonsets/%s", ns, name), nil + case "replicasets", "replicaset": + return fmt.Sprintf("/apis/apps/v1/namespaces/%s/replicasets/%s", ns, name), nil + case "pods", "pod": + return fmt.Sprintf("/api/v1/namespaces/%s/pods/%s", ns, name), nil + case "services", "service": + return fmt.Sprintf("/api/v1/namespaces/%s/services/%s", ns, name), nil + case "ingresses", "ingress": + return fmt.Sprintf("/apis/networking.k8s.io/v1/namespaces/%s/ingresses/%s", ns, name), nil + case "configmaps", "configmap": + return fmt.Sprintf("/api/v1/namespaces/%s/configmaps/%s", ns, name), nil + case "secrets", "secret": + return fmt.Sprintf("/api/v1/namespaces/%s/secrets/%s", ns, name), nil + case "pvcs", "persistentvolumeclaim": + return fmt.Sprintf("/api/v1/namespaces/%s/persistentvolumeclaims/%s", ns, name), nil + case "hpas", "hpa", "horizontalpodautoscaler": + return fmt.Sprintf("/apis/autoscaling/v2/namespaces/%s/horizontalpodautoscalers/%s", ns, name), nil + case "cronjobs", "cronjob": + return fmt.Sprintf("/apis/batch/v1/namespaces/%s/cronjobs/%s", ns, name), nil + case "serviceaccounts", "serviceaccount": + return fmt.Sprintf("/api/v1/namespaces/%s/serviceaccounts/%s", ns, name), nil + case "roles", "role": + return fmt.Sprintf("/apis/rbac.authorization.k8s.io/v1/namespaces/%s/roles/%s", ns, name), nil + case "rolebindings", "rolebinding": + return fmt.Sprintf("/apis/rbac.authorization.k8s.io/v1/namespaces/%s/rolebindings/%s", ns, name), nil + default: + return "", fmt.Errorf("unsupported resource type: %s", resource) + } +} + +// DeleteResourceYAMLNS deletes a K8s resource (Service/Ingress/ConfigMap etc.) +func (h *KubernetesHandler) DeleteResourceYAMLNS(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") + resource := c.Param("resource") + name := c.Param("name") + apiPath, err := resourceAPIPath(ns, resource, name) + if err != nil { c.JSON(400, models.Response{Code: 400, Message: err.Error()}); return } + if err := client.DeleteResource(apiPath); err != nil { + c.JSON(500, models.Response{Code: 500, Message: "删除失败: " + err.Error()}); return + } + c.JSON(200, models.Response{Code: 200, Message: "资源已删除"}) +} + +// getK8sClient builds a K8s API client from cluster's stored kubeconfig +func (h *KubernetesHandler) ApplyYAML(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 } + + var req struct { + YAML string `json:"yaml"` + } + if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, models.Response{Code: 400, Message: "参数错误: " + err.Error()}); return } + if req.YAML == "" { c.JSON(400, models.Response{Code: 400, Message: "YAML 内容不能为空"}); return } + + result, err := client.ApplyYAML(req.YAML) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: "Apply 失败: " + err.Error()}); return } + + c.JSON(200, models.Response{Code: 200, Message: "资源已应用", Data: result}) +} + +// ============================================================================ +// ReplicaSet 列表 / Deployment 回滚 / 历史版本 // Logan.Li +// ============================================================================ + +// ListReplicaSetsNS 列出 namespace 下的 ReplicaSet diff --git a/backend/internal/api/handlers/kubernetes.go b/backend/internal/api/handlers/kubernetes.go index b315704..dbde30d 100644 --- a/backend/internal/api/handlers/kubernetes.go +++ b/backend/internal/api/handlers/kubernetes.go @@ -1,25 +1,15 @@ package handlers import ( - "encoding/base64" - "encoding/json" "fmt" - "io" - "log" - "net/http" - "net/url" - "path" - "sort" + "gorm.io/gorm" "strconv" - "strings" "time" "ezops/internal/models" - "ezops/pkg/kubernetesclient" + k8sclient "ezops/pkg/kubernetesclient" "github.com/gin-gonic/gin" - "github.com/gorilla/websocket" - "gorm.io/gorm" ) // KubernetesHandler K8s处理器 @@ -32,2035 +22,10 @@ func NewKubernetesHandler(db *gorm.DB) *KubernetesHandler { return &KubernetesHandler{db: db} } -// ListClusters 获取集群列表 -// @Summary 获取集群列表 -// @Description 获取所有K8s集群列表 -// @Tags K8s集群 -// @Produce json -// @Security Bearer -// @Param page query int false "页码" -// @Param page_size query int false "每页数量" -// @Success 200 {object} models.Response{data=models.ListResponse} -// @Router /kubernetes-clusters [get] -func (h *KubernetesHandler) ListClusters(c *gin.Context) { - var query models.PageQuery - c.ShouldBindQuery(&query) - - var total int64 - h.db.Model(&models.KubernetesCluster{}).Count(&total) - - var clusters []models.KubernetesCluster - h.db.Offset(query.GetOffset()).Limit(query.GetLimit()).Find(&clusters) - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "success", - Data: models.ListResponse{ - List: clusters, - Total: total, - }, - }) -} - -// GetCluster 获取集群详情 -// @Summary 获取集群详情 -// @Description 获取指定K8s集群的详细信息 -// @Tags K8s集群 -// @Produce json -// @Security Bearer -// @Param id path int true "集群ID" -// @Success 200 {object} models.Response{data=models.KubernetesCluster} -// @Router /kubernetes-clusters/{id} [get] -func (h *KubernetesHandler) GetCluster(c *gin.Context) { - id, _ := strconv.Atoi(c.Param("id")) - - var cluster models.KubernetesCluster - if err := h.db.First(&cluster, id).Error; err != nil { - c.JSON(http.StatusNotFound, models.Response{ - Code: 404, - Message: "集群不存在", - }) - return - } - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "success", - Data: cluster, - }) -} - -// CreateCluster 创建集群 -// @Summary 创建集群 -// @Description 创建新K8s集群 -// @Tags K8s集群 -// @Accept json -// @Produce json -// @Security Bearer -// @Param request body models.CreateKubernetesClusterRequest true "创建请求" -// @Success 200 {object} models.Response{data=models.KubernetesCluster} -// @Router /kubernetes-clusters [post] -func (h *KubernetesHandler) CreateCluster(c *gin.Context) { - var req models.CreateKubernetesClusterRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, models.Response{ - Code: 400, - Message: "请求参数错误: " + err.Error(), - }) - return - } - - cluster := models.KubernetesCluster{ - Name: req.Name, - Code: req.Code, - Version: req.Version, - ConfigType: req.ConfigType, - APIServer: req.APIServer, - APIEndpoint: req.APIServer, // auto-sync - Token: req.Token, - KubeConfig: req.KubeConfig, - Description: req.Description, - Status: "healthy", - } - - if err := h.db.Create(&cluster).Error; err != nil { - c.JSON(http.StatusInternalServerError, models.Response{ - Code: 500, - Message: "创建失败", - }) - return - } - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "创建成功", - Data: cluster, - }) -} - -// UpdateCluster 更新集群 -// @Summary 更新集群 -// @Description 更新指定K8s集群信息 -// @Tags K8s集群 -// @Accept json -// @Produce json -// @Security Bearer -// @Param id path int true "集群ID" -// @Param request body models.CreateKubernetesClusterRequest true "更新请求" -// @Success 200 {object} models.Response{data=models.KubernetesCluster} -// @Router /kubernetes-clusters/{id} [put] -func (h *KubernetesHandler) UpdateCluster(c *gin.Context) { - id, _ := strconv.Atoi(c.Param("id")) - - // 用 map 接收,避免 binding:"required" 校验 // Logan.Li - var req map[string]interface{} - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, models.Response{ - Code: 400, - Message: "请求参数错误: " + err.Error(), - }) - return - } - - var cluster models.KubernetesCluster - if err := h.db.First(&cluster, id).Error; err != nil { - c.JSON(http.StatusNotFound, models.Response{ - Code: 404, - Message: "集群不存在", - }) - return - } - - // 逐字段更新:空值不覆盖原值(防止凭据被清空) // Logan.Li - updates := map[string]interface{}{} - if v, ok := req["name"]; ok && v != nil && v != "" { - updates["name"] = v - } - if v, ok := req["version"]; ok { - updates["version"] = v - } - if v, ok := req["description"]; ok { - updates["description"] = v - } - if v, ok := req["config_type"]; ok { - updates["config_type"] = v - } - if v, ok := req["api_server"]; ok && v != nil && v != "" { - updates["api_server"] = v - updates["api_endpoint"] = v - } - // Token / KubeConfig:空值不覆盖(前端编辑时可能拿不到原值) - if v, ok := req["token"]; ok && v != nil && v != "" { - updates["token"] = v - } - if v, ok := req["kube_config"]; ok && v != nil && v != "" { - updates["kube_config"] = v - } - - if len(updates) == 0 { - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "无更新内容", - Data: cluster, - }) - return - } - - if err := h.db.Model(&cluster).Updates(updates).Error; err != nil { - c.JSON(http.StatusInternalServerError, models.Response{ - Code: 500, - Message: "更新失败", - }) - return - } - - h.db.First(&cluster, id) - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "更新成功", - Data: cluster, - }) -} - -// GetClusterDetail 返回集群完整信息(含 token/kube_config,供编辑回填) // Logan.Li -func (h *KubernetesHandler) GetClusterDetail(c *gin.Context) { - id, _ := strconv.Atoi(c.Param("id")) - - var cluster models.KubernetesCluster - if err := h.db.First(&cluster, id).Error; err != nil { - c.JSON(http.StatusNotFound, models.Response{ - Code: 404, - Message: "集群不存在", - }) - return - } - - // 返回时手动脱敏:token/kube_config 只返回是否存在标记,不返回原文 - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "success", - Data: map[string]interface{}{ - "id": cluster.ID, - "name": cluster.Name, - "code": cluster.Code, - "version": cluster.Version, - "status": cluster.Status, - "config_type": cluster.ConfigType, - "api_server": cluster.APIServer, - "api_endpoint": cluster.APIEndpoint, - "description": cluster.Description, - "has_token": cluster.Token != "", - "has_kube_config": cluster.KubeConfig != "", - "node_count": cluster.NodeCount, - "pod_count": cluster.PodCount, - "service_count": cluster.ServiceCount, - "deployment_count": cluster.DeploymentCount, - "cpu_usage": cluster.CPUUsage, - "memory_usage": cluster.MemoryUsage, - "labels": cluster.Labels, - "last_sync_at": cluster.LastSyncAt, - "created_at": cluster.CreatedAt, - "updated_at": cluster.UpdatedAt, - }, - }) -} - -// DeleteCluster 删除集群 -// @Summary 删除集群 -// @Description 删除指定K8s集群 -// @Tags K8s集群 -// @Produce json -// @Security Bearer -// @Param id path int true "集群ID" -// @Success 200 {object} models.Response -// @Router /kubernetes-clusters/{id} [delete] -func (h *KubernetesHandler) DeleteCluster(c *gin.Context) { - id, _ := strconv.Atoi(c.Param("id")) - - if err := h.db.Delete(&models.KubernetesCluster{}, id).Error; err != nil { - c.JSON(http.StatusInternalServerError, models.Response{ - Code: 500, - Message: "删除失败", - }) - return - } - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: "删除成功", - }) -} - -// ListNodes 获取节点列表 -// @Summary 获取节点列表 -// @Description 获取指定集群的节点列表 -// @Tags K8s集群 -// @Produce json -// @Security Bearer -// @Param id path int true "集群ID" -// @Success 200 {object} models.Response{data=[]models.KubernetesNode} -// @Router /kubernetes-clusters/{id}/nodes [get] -func (h *KubernetesHandler) ListNodes(c *gin.Context) { - clusterID, _ := strconv.Atoi(c.Param("id")) - - client, _, err := h.getK8sClient(clusterID) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) - return - } - nodes, err := client.ListNodes() - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) - return - } - - // Attempt to fetch metrics (best-effort, metrics-server may not be installed) - metrics := map[string]k8sclient.NodeMetricsItem{} - if m, err := client.GetNodeMetrics(); err == nil { - for _, item := range m.Items { - metrics[item.Metadata.Name] = item - } - } - - type NodeSummary struct { - Name string `json:"name"` - IP string `json:"ip"` - Role string `json:"role"` - Status string `json:"status"` - OS string `json:"os"` - Kubelet string `json:"kubelet_version"` - CPU int `json:"cpu"` - Memory int64 `json:"memory_mb"` - MaxPods int `json:"max_pods"` - // Metrics (optional, omitted if empty) - CPUUsageM string `json:"cpu_usage_m,omitempty"` // e.g. "122m" - MemUsageMi string `json:"mem_usage_mi,omitempty"` // e.g. "2105Mi" - } - result := []NodeSummary{} - for _, n := range nodes.Items { - role := "worker" - for k := range n.Metadata.Labels { - if k == "node-role.kubernetes.io/master" || k == "node-role.kubernetes.io/control-plane" { - role = "master" - break - } - } - nodeStatus := "NotReady" - for _, cond := range n.Status.Conditions { - if cond.Type == "Ready" && cond.Status == "True" { - nodeStatus = "Ready" - break - } - } - ip := "" - for _, addr := range n.Status.Addresses { - if addr.Type == "InternalIP" { - ip = addr.Address - break - } - } - s := NodeSummary{ - Name: n.Metadata.Name, - IP: ip, - Role: role, - Status: nodeStatus, - OS: n.Status.NodeInfo.OSImage, - Kubelet: n.Status.NodeInfo.KubeletVersion, - CPU: parseIntResource(n.Status.Capacity["cpu"]), - Memory: parseMemResource(n.Status.Capacity["memory"]), - MaxPods: parseIntResource(n.Status.Capacity["pods"]), - } - if m, ok := metrics[n.Metadata.Name]; ok { - // K8s metrics API returns CPU in nanocores ("80410173n"), memory in Ki ("2154292Ki") - s.CPUUsageM = formatCpuFromNano(m.Usage["cpu"]) - s.MemUsageMi = formatMemFromKi(m.Usage["memory"]) - } - result = append(result, s) - } - c.JSON(http.StatusOK, models.Response{Code: 200, Message: "success", Data: result}) -} - -// GetNodeDetail 获取节点详情(PID/容器运行时/分配率)// M10 -// @Summary 获取节点详情 -// @Description 获取指定集群中单个节点的详细信息,包含系统信息、资源分配、污点、条件等 -// @Tags K8s集群 -// @Produce json -// @Security Bearer -// @Router /kubernetes/{clusterCode}/nodes/{name} [get] -func (h *KubernetesHandler) GetNodeDetail(c *gin.Context) { - clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { - code := c.Param("clusterCode") - if code == "" { - code = c.Param("id") - } - clusterID = h.resolveClusterCode(code) - } - if clusterID == 0 { - c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "cluster not found"}) - return - } - - nodeName := c.Param("name") - if nodeName == "" { - c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "node name required"}) - return - } - - client, _, err := h.getK8sClient(clusterID) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) - return - } - - // 1. Get node detail - node, err := client.GetNode(nodeName) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) - return - } - - // 2. Get node metrics (best-effort) - var cpuUsageM, memUsageMi string - if m, err := client.GetNodeMetrics(); err == nil { - for _, item := range m.Items { - if item.Metadata.Name == nodeName { - cpuUsageM = formatCpuFromNano(item.Usage["cpu"]) - memUsageMi = formatMemFromKi(item.Usage["memory"]) - break - } - } - } - - // 3. Get pods on this node for allocation calculation - pods, _ := client.ListPods() - podCount := 0 - type podBrief struct { - Namespace string `json:"namespace"` - Name string `json:"name"` - Status string `json:"status"` - IP string `json:"ip"` - CPURequestM int `json:"cpu_request_m"` - MemRequestMi int64 `json:"mem_request_mi"` - RestartCount int `json:"restart_count"` - } - var podList []podBrief - totalCPURequestM := 0 - totalMemRequestMi := int64(0) - for _, p := range pods.Items { - if p.Spec.NodeName != nodeName { - continue - } - podCount++ - podCPUReq := 0 - podMemReq := int64(0) - for _, ctr := range p.Spec.Containers { - if req := ctr.Resources.Requests; req != nil { - podCPUReq += parseCpuToMilli(req["cpu"]) - podMemReq += parseMemToMi(req["memory"]) - } - } - totalCPURequestM += podCPUReq - totalMemRequestMi += podMemReq - - status := "Unknown" - if p.Status.Phase != "" { - status = p.Status.Phase - } - restartCount := 0 - for _, cs := range p.Status.ContainerStatuses { - restartCount += cs.RestartCount - } - podList = append(podList, podBrief{ - Namespace: p.Metadata.Namespace, - Name: p.Metadata.Name, - Status: status, - IP: p.Status.PodIP, - CPURequestM: podCPUReq, - MemRequestMi: podMemReq, - RestartCount: restartCount, - }) - } - - // 4. Determine role - role := "worker" - for k := range node.Metadata.Labels { - if k == "node-role.kubernetes.io/master" || k == "node-role.kubernetes.io/control-plane" { - role = "master" - break - } - } - - // 5. Determine status - nodeStatus := "NotReady" - for _, cond := range node.Status.Conditions { - if cond.Type == "Ready" && cond.Status == "True" { - nodeStatus = "Ready" - break - } - } - - // 6. Get IP - internalIP := "" - externalIP := "" - hostname := "" - for _, addr := range node.Status.Addresses { - switch addr.Type { - case "InternalIP": - internalIP = addr.Address - case "ExternalIP": - externalIP = addr.Address - case "Hostname": - hostname = addr.Address - } - } - - // 7. Build conditions list - type condInfo struct { - Type string `json:"type"` - Status string `json:"status"` - Reason string `json:"reason,omitempty"` - Message string `json:"message,omitempty"` - } - conditions := []condInfo{} - for _, cond := range node.Status.Conditions { - conditions = append(conditions, condInfo{ - Type: cond.Type, - Status: cond.Status, - Reason: cond.Reason, - Message: cond.Message, - }) - } - - // 8. Build taints list - type taintInfo struct { - Key string `json:"key"` - Value string `json:"value"` - Effect string `json:"effect"` - } - taints := []taintInfo{} - for _, t := range node.Spec.Taints { - taints = append(taints, taintInfo{ - Key: t.Key, - Value: t.Value, - Effect: t.Effect, - }) - } - - // 9. Calculate allocation rate - capCPU := parseIntResource(node.Status.Capacity["cpu"]) - allocCPU := parseIntResource(node.Status.Allocatable["cpu"]) - capMem := parseMemResource(node.Status.Capacity["memory"]) - allocMem := parseMemResource(node.Status.Allocatable["memory"]) - capPods := parseIntResource(node.Status.Capacity["pods"]) - allocPods := parseIntResource(node.Status.Allocatable["pods"]) - - cpuReqCores := float64(totalCPURequestM) / 1000.0 - cpuAllocPct := 0.0 - if allocCPU > 0 { - cpuAllocPct = (cpuReqCores / float64(allocCPU)) * 100 - } - memAllocPct := 0.0 - if allocMem > 0 { - memAllocPct = (float64(totalMemRequestMi) / float64(allocMem)) * 100 - } - podAllocPct := 0.0 - if allocPods > 0 { - podAllocPct = (float64(podCount) / float64(allocPods)) * 100 - } - - // 10. Build labels list - type labelEntry struct { - Key string `json:"key"` - Value string `json:"value"` - } - labels := []labelEntry{} - for k, v := range node.Metadata.Labels { - labels = append(labels, labelEntry{Key: k, Value: v}) - } - - detail := map[string]interface{}{ - "name": node.Metadata.Name, - "role": role, - "status": nodeStatus, - "creation_time": node.Metadata.CreationTimestamp, - "internal_ip": internalIP, - "external_ip": externalIP, - "hostname": hostname, - "pod_cidr": node.Spec.PodCIDR, - "container_runtime": node.Status.NodeInfo.ContainerRuntimeVersion, - "kubelet_version": node.Status.NodeInfo.KubeletVersion, - "os_image": node.Status.NodeInfo.OSImage, - "kernel_version": node.Status.NodeInfo.KernelVersion, - "architecture": node.Status.NodeInfo.Architecture, - "operating_system": node.Status.NodeInfo.OperatingSystem, - // Capacity & Allocatable - "capacity": node.Status.Capacity, - "allocatable": node.Status.Allocatable, - // Metrics - "cpu_usage_m": cpuUsageM, - "mem_usage_mi": memUsageMi, - // Allocation - "pod_count": podCount, - "cpu_request_m": totalCPURequestM, - "mem_request_mi": totalMemRequestMi, - "cpu_alloc_pct": cpuAllocPct, - "mem_alloc_pct": memAllocPct, - "pod_alloc_pct": podAllocPct, - "cpu_capacity": capCPU, - "cpu_allocatable": allocCPU, - "mem_capacity_mb": capMem, - "mem_allocatable_mb": allocMem, - "pod_capacity": capPods, - "pod_allocatable": allocPods, - // Lists - "conditions": conditions, - "taints": taints, - "labels": labels, - "pods": podList, - } - - c.JSON(http.StatusOK, models.Response{Code: 200, Message: "success", Data: detail}) -} - -// parseCpuToMilli converts CPU resource string to millicores (e.g. "100m"→100, "2"→2000) -func parseCpuToMilli(val string) int { - if val == "" { - return 0 - } - if strings.HasSuffix(val, "m") { - var i int - fmt.Sscanf(strings.TrimSuffix(val, "m"), "%d", &i) - return i - } - var i int - fmt.Sscanf(val, "%d", &i) - return i * 1000 -} - -// parseMemToMi converts memory resource string to Mi (e.g. "256Mi"→256, "1Gi"→1024, "512Ki"→0) -func parseMemToMi(val string) int64 { - if val == "" { - return 0 - } - var num float64 - var unit string - fmt.Sscanf(val, "%f%s", &num, &unit) - switch unit { - case "Ki": - return int64(num / 1024) - case "Mi": - return int64(num) - case "Gi": - return int64(num * 1024) - case "Ti": - return int64(num * 1024 * 1024) - default: - return int64(num / 1024 / 1024) - } -} - -// ListPods 获取Pod列表 -// @Summary 获取Pod列表 -// @Description 获取指定集群的Pod列表 -// @Tags K8s集群 -// @Produce json -// @Security Bearer -// @Param id path int true "集群ID" -// @Param namespace query string false "命名空间" -// @Success 200 {object} models.Response{data=[]models.KubernetesPod} -// @Router /kubernetes-clusters/{id}/pods [get] -func (h *KubernetesHandler) ListPods(c *gin.Context) { - clusterID, _ := strconv.Atoi(c.Param("id")) - namespace := c.Query("namespace") - - client, _, err := h.getK8sClient(clusterID) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) - return - } - pods, err := client.ListPods() - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) - return - } - - // M2: inject pod metrics (best-effort) // Logan.Li - podMetrics := buildPodMetricsMap(client, namespace) - - type PodSummary struct { - Namespace string `json:"namespace"` - Name string `json:"name"` - Status string `json:"status"` - NodeName string `json:"node_name"` - IP string `json:"ip"` - RestartCount int `json:"restart_count"` - Image string `json:"image"` - CPUUsageM string `json:"cpu_usage_m,omitempty"` // M2: e.g. "50m" - MemUsageMi string `json:"mem_usage_mi,omitempty"` // M2: e.g. "128Mi" - } - result := []PodSummary{} - for _, p := range pods.Items { - if namespace != "" && p.Metadata.Namespace != namespace { - continue - } - image := "" - if len(p.Spec.Containers) > 0 { - image = p.Spec.Containers[0].Image - } - restarts := 0 - if len(p.Status.ContainerStatuses) > 0 { - restarts = p.Status.ContainerStatuses[0].RestartCount - } - ps := PodSummary{ - Namespace: p.Metadata.Namespace, - Name: p.Metadata.Name, - Status: p.Status.Phase, - NodeName: p.Spec.NodeName, - IP: p.Status.PodIP, - RestartCount: restarts, - Image: image, - } - if m, ok := podMetrics[p.Metadata.Namespace+"/"+p.Metadata.Name]; ok { - ps.CPUUsageM = m.CPU - ps.MemUsageMi = m.Mem - } - result = append(result, ps) - } - c.JSON(http.StatusOK, models.Response{Code: 200, Message: "success", Data: result}) -} - -// ListDeployments 获取Deployment列表 -// @Summary 获取Deployment列表 -// @Description 获取指定集群的Deployment列表 -// @Tags K8s集群 -// @Produce json -// @Security Bearer -// @Param id path int true "集群ID" -// @Param namespace query string false "命名空间" -// @Success 200 {object} models.Response{data=[]models.KubernetesDeployment} -// @Router /kubernetes-clusters/{id}/deployments [get] -func (h *KubernetesHandler) ListDeployments(c *gin.Context) { - clusterID, _ := strconv.Atoi(c.Param("id")) - namespace := c.Query("namespace") - - client, _, err := h.getK8sClient(clusterID) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) - return - } - deps, err := client.ListDeployments() - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) - return - } - - type DepSummary struct { - Namespace string `json:"namespace"` - Name string `json:"name"` - Replicas int `json:"replicas"` - AvailableReplicas int `json:"available_replicas"` - ReadyReplicas int `json:"ready_replicas"` - Image string `json:"image"` - } - result := []DepSummary{} - for _, d := range deps.Items { - if namespace != "" && d.Metadata.Namespace != namespace { - continue - } - image := "" - if len(d.Spec.Template.Spec.Containers) > 0 { - image = d.Spec.Template.Spec.Containers[0].Image - } - result = append(result, DepSummary{ - Namespace: d.Metadata.Namespace, - Name: d.Metadata.Name, - Replicas: d.Spec.Replicas, - AvailableReplicas: d.Status.AvailableReplicas, - ReadyReplicas: d.Status.ReadyReplicas, - Image: image, - }) - } - c.JSON(http.StatusOK, models.Response{Code: 200, Message: "success", Data: result}) -} - -// ListServices 获取Service列表 -// @Summary 获取Service列表 -// @Description 获取指定集群的Service列表 -// @Tags K8s集群 -// @Produce json -// @Security Bearer -// @Param id path int true "集群ID" -// @Param namespace query string false "命名空间" -// @Success 200 {object} models.Response{data=[]models.KubernetesService} -// @Router /kubernetes-clusters/{id}/services [get] -func (h *KubernetesHandler) ListServices(c *gin.Context) { - clusterID, _ := strconv.Atoi(c.Param("id")) - namespace := c.Query("namespace") - - client, _, err := h.getK8sClient(clusterID) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) - return - } - svcs, err := client.ListServices() - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) - return - } - - type SvcSummary struct { - Namespace string `json:"namespace"` - Name string `json:"name"` - Type string `json:"type"` - ClusterIP string `json:"cluster_ip"` - Ports int `json:"port_count"` - } - result := []SvcSummary{} - for _, s := range svcs.Items { - if namespace != "" && s.Metadata.Namespace != namespace { - continue - } - result = append(result, SvcSummary{ - Namespace: s.Metadata.Namespace, - Name: s.Metadata.Name, - Type: s.Spec.Type, - ClusterIP: s.Spec.ClusterIP, - Ports: len(s.Spec.Ports), - }) - } - c.JSON(http.StatusOK, models.Response{Code: 200, Message: "success", Data: result}) -} - -// ListIngresses 获取Ingress列表 -// @Summary 获取Ingress列表 -// @Description 获取指定集群的Ingress列表 -// @Tags K8s集群 -// @Produce json -// @Security Bearer -// @Param id path int true "集群ID" -// @Param namespace query string false "命名空间" -// @Success 200 {object} models.Response{data=[]models.KubernetesIngress} -// @Router /kubernetes-clusters/{id}/ingresses [get] -func (h *KubernetesHandler) ListIngresses(c *gin.Context) { - clusterID, _ := strconv.Atoi(c.Param("id")) - namespace := c.Query("namespace") - - client, _, err := h.getK8sClient(clusterID) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) - return - } - ings, err := client.ListIngresses() - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) - return - } - - type IngSummary struct { - Namespace string `json:"namespace"` - Name string `json:"name"` - Hosts []string `json:"hosts"` - TLS bool `json:"tls"` - } - result := []IngSummary{} - for _, ing := range ings.Items { - if namespace != "" && ing.Metadata.Namespace != namespace { - continue - } - hosts := []string{} - for _, rule := range ing.Spec.Rules { - if rule.Host != "" { - hosts = append(hosts, rule.Host) - } - } - result = append(result, IngSummary{ - Namespace: ing.Metadata.Namespace, - Name: ing.Metadata.Name, - Hosts: hosts, - TLS: len(ing.Spec.TLS) > 0, - }) - } - c.JSON(http.StatusOK, models.Response{Code: 200, Message: "success", Data: result}) -} - -// GetTopology 获取集群拓扑布局 // Logan.Li -func (h *KubernetesHandler) GetTopology(c *gin.Context) { - clusterID := c.Param("id") - var layout models.TopologyLayout - result := h.db.Where("cluster_id = ?", clusterID).First(&layout) - if result.Error != nil { - c.JSON(http.StatusOK, models.Response{Code: 200, Data: &models.TopologyLayout{}}) - return - } - c.JSON(http.StatusOK, models.Response{Code: 200, Data: layout}) -} - -// SaveTopology 保存集群拓扑布局 // Logan.Li -func (h *KubernetesHandler) SaveTopology(c *gin.Context) { - clusterID, _ := strconv.Atoi(c.Param("id")) - var req struct { - LayoutData string `json:"layout_data"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误"}) - return - } - - var layout models.TopologyLayout - h.db.Where("cluster_id = ?", clusterID).First(&layout) - layout.ClusterID = uint(clusterID) - layout.LayoutData = req.LayoutData - if layout.ID == 0 { - h.db.Create(&layout) - } else { - h.db.Save(&layout) - } - - c.JSON(http.StatusOK, models.Response{Code: 200, Message: "保存成功"}) -} - - -// SyncCluster 同步集群资源(连接真实K8s API拉取数据) -func (h *KubernetesHandler) SyncCluster(c *gin.Context) { - id, _ := strconv.Atoi(c.Param("id")) - - var cluster models.KubernetesCluster - if err := h.db.First(&cluster, id).Error; err != nil { - c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "集群不存在"}) - return - } - - if cluster.KubeConfig == "" && cluster.Token == "" { - c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "集群未配置凭证"}) - return - } - - client, err := k8sclient.NewClient(cluster.KubeConfig) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "连接K8s失败: " + err.Error()}) - return - } - - result := k8sclient.SyncResult{} - - // Fetch node metrics (best-effort — metrics-server may not be installed) // Logan.Li - nodeMetrics := map[string]k8sclient.NodeMetricsItem{} - if m, err := client.GetNodeMetrics(); err == nil { - for _, item := range m.Items { - nodeMetrics[item.Metadata.Name] = item - } - } else { - log.Printf("[K8s] sync: GetNodeMetrics failed (metrics-server not installed?): %v", err) - } - - // Fetch pod metrics (best-effort) // Logan.Li - podMetrics := map[string]k8sclient.PodMetricsItem{} // key: "namespace/podname" - if pm, err := client.ListPodMetrics(""); err == nil { - for _, item := range pm.Items { - podMetrics[item.Metadata.Namespace+"/"+item.Metadata.Name] = item - } - } - - // Sync nodes - if nodes, err := client.ListNodes(); err == nil { - // Use raw SQL for reliability (avoid GORM soft-delete / auto-timestamp issues) - h.db.Exec("DELETE FROM kubernetes_nodes WHERE cluster_id = ?", cluster.ID) - for _, n := range nodes.Items { - role := "worker" - if _, ok := n.Metadata.Labels["node-role.kubernetes.io/master"]; ok { - role = "master" - } else if _, ok := n.Metadata.Labels["node-role.kubernetes.io/control-plane"]; ok { - role = "master" - } - nodeStatus := "NotReady" - for _, cond := range n.Status.Conditions { - if cond.Type == "Ready" && cond.Status == "True" { - nodeStatus = "Ready" - break - } - } - ip := "" - for _, addr := range n.Status.Addresses { - if addr.Type == "InternalIP" { - ip = addr.Address - break - } - } - labelsJSON, _ := json.Marshal(n.Metadata.Labels) - now := time.Now() - // Compute CPU/memory usage percentage from metrics-server data // Logan.Li - cpuUsagePct, memUsagePct := 0.0, 0.0 - if m, ok := nodeMetrics[n.Metadata.Name]; ok { - cpuCores := parseIntResource(n.Status.Capacity["cpu"]) - memMB := parseMemResource(n.Status.Capacity["memory"]) - if cpuCores > 0 { - cpuMillicores := parseCpuNanoToMilli(m.Usage["cpu"]) - cpuUsagePct = float64(cpuMillicores) / float64(cpuCores*1000) * 100 - } - if memMB > 0 { - memMi := parseMemKiToMi(m.Usage["memory"]) - memUsagePct = float64(memMi) / float64(memMB) * 100 - } - } - err2 := h.db.Exec(`INSERT INTO kubernetes_nodes (cluster_id, name, ip, role, status, os, kubelet_version, cpu, memory, max_pods, cpu_usage, memory_usage, labels, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - cluster.ID, n.Metadata.Name, ip, role, nodeStatus, n.Status.NodeInfo.OSImage, n.Status.NodeInfo.KubeletVersion, - parseIntResource(n.Status.Capacity["cpu"]), parseMemResource(n.Status.Capacity["memory"]), parseIntResource(n.Status.Capacity["pods"]), - cpuUsagePct, memUsagePct, string(labelsJSON), now, now).Error - if err2 != nil { - log.Printf("[K8s] insert node %s error: %v", n.Metadata.Name, err2) - } else { - result.Nodes++ - } - } - } else { - log.Printf("[K8s] sync nodes error: %v", err) - } - - // Sync pods - if pods, err := client.ListPods(); err == nil { - h.db.Where("cluster_id = ?", cluster.ID).Delete(&models.KubernetesPod{}) - for _, p := range pods.Items { - image := "" - if len(p.Spec.Containers) > 0 { - image = p.Spec.Containers[0].Image - } - restarts := 0 - if len(p.Status.ContainerStatuses) > 0 { - restarts = p.Status.ContainerStatuses[0].RestartCount - } - labelsJSON, _ := json.Marshal(p.Metadata.Labels) - h.db.Create(&models.KubernetesPod{ - ClusterID: cluster.ID, - Namespace: p.Metadata.Namespace, - Name: p.Metadata.Name, - Status: p.Status.Phase, - NodeName: p.Spec.NodeName, - IP: p.Status.PodIP, - Image: image, - RestartCount: restarts, - Labels: string(labelsJSON), - }) - result.Pods++ - } - } else { - log.Printf("[K8s] sync pods error: %v", err) - } - - // Sync deployments - if deps, err := client.ListDeployments(); err == nil { - h.db.Where("cluster_id = ?", cluster.ID).Delete(&models.KubernetesDeployment{}) - for _, d := range deps.Items { - image := "" - if len(d.Spec.Template.Spec.Containers) > 0 { - image = d.Spec.Template.Spec.Containers[0].Image - } - labelsJSON, _ := json.Marshal(d.Metadata.Labels) - h.db.Create(&models.KubernetesDeployment{ - ClusterID: cluster.ID, - Namespace: d.Metadata.Namespace, - Name: d.Metadata.Name, - Replicas: d.Spec.Replicas, - AvailableReplicas: d.Status.AvailableReplicas, - ReadyReplicas: d.Status.ReadyReplicas, - Image: image, - Labels: string(labelsJSON), - }) - result.Deployments++ - } - } else { - log.Printf("[K8s] sync deps error: %v", err) - } - - // Sync services - if svcs, err := client.ListServices(); err == nil { - h.db.Where("cluster_id = ?", cluster.ID).Delete(&models.KubernetesService{}) - for _, s := range svcs.Items { - portsJSON, _ := json.Marshal(s.Spec.Ports) - selectorJSON, _ := json.Marshal(s.Spec.Selector) - labelsJSON, _ := json.Marshal(s.Metadata.Labels) - h.db.Create(&models.KubernetesService{ - ClusterID: cluster.ID, - Namespace: s.Metadata.Namespace, - Name: s.Metadata.Name, - Type: s.Spec.Type, - ClusterIP: s.Spec.ClusterIP, - Ports: string(portsJSON), - Selector: string(selectorJSON), - Labels: string(labelsJSON), - }) - result.Services++ - } - } - - // Sync ingresses - if ings, err := client.ListIngresses(); err == nil { - h.db.Where("cluster_id = ?", cluster.ID).Delete(&models.KubernetesIngress{}) - for _, ing := range ings.Items { - hosts := []string{} - for _, rule := range ing.Spec.Rules { - hosts = append(hosts, rule.Host) - } - hostsJSON, _ := json.Marshal(hosts) - tls := len(ing.Spec.TLS) > 0 - tlsHosts := []string{} - if tls { - for _, t := range ing.Spec.TLS { - tlsHosts = append(tlsHosts, t.Hosts...) - } - } - tlsHostsJSON, _ := json.Marshal(tlsHosts) - labelsJSON, _ := json.Marshal(ing.Metadata.Labels) - h.db.Create(&models.KubernetesIngress{ - ClusterID: cluster.ID, - Namespace: ing.Metadata.Namespace, - Name: ing.Metadata.Name, - Hosts: string(hostsJSON), - TLS: tls, - TLSHosts: string(tlsHostsJSON), - Labels: string(labelsJSON), - }) - result.Ingresses++ - } - } - - // Update cluster stats - now := time.Now() - h.db.Model(&cluster).Updates(map[string]interface{}{ - "node_count": result.Nodes, - "pod_count": result.Pods, - "deployment_count": result.Deployments, - "service_count": result.Services, - "last_sync_at": &now, - }) - - c.JSON(http.StatusOK, models.Response{ - Code: 200, - Message: fmt.Sprintf("同步完成: %d nodes, %d pods, %d deployments, %d services, %d ingresses", - result.Nodes, result.Pods, result.Deployments, result.Services, result.Ingresses), - Data: result, - }) -} - // ============================================================================ -// Pod Logs +// Shared helpers (used by all k8s_*.go files in this package) // ============================================================================ -// GetPodLogs 获取 Pod 日志(支持 stream=true 实时流) -func (h *KubernetesHandler) GetPodLogs(c *gin.Context) { - clusterID, _ := strconv.Atoi(c.Param("id")) - namespace := c.Param("namespace") - podName := c.Param("podName") - container := c.Query("container") - tailLines, _ := strconv.Atoi(c.DefaultQuery("tail", "200")) - stream := c.Query("stream") == "true" - - client, _, err := h.getK8sClient(clusterID) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) - return - } - - if stream { - // Real-time streaming via chunked transfer - body, err := client.GetPodLogsStream(namespace, podName, container, tailLines) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "获取日志流失败: " + err.Error()}) - return - } - defer body.Close() - - c.Header("Content-Type", "text/plain; charset=utf-8") - c.Header("Cache-Control", "no-cache") - c.Header("Connection", "keep-alive") - c.Header("X-Content-Type-Options", "nosniff") - - c.Stream(func(w io.Writer) bool { - buf := make([]byte, 4096) - n, err := body.Read(buf) - if n > 0 { - w.Write(buf[:n]) - return true - } - if err != nil { - return false - } - return true - }) - return - } - - logs, err := client.GetPodLogs(namespace, podName, container, tailLines) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "获取日志失败: " + err.Error()}) - return - } - - c.JSON(http.StatusOK, models.Response{Code: 200, Data: gin.H{"logs": logs, "pod": podName, "namespace": namespace}}) -} - -// ============================================================================ -// WebSocket Exec Terminal (交互式 bash) -// ============================================================================ - -// ExecTerminal WebSocket 终端 — 前端 WS → 后端 → K8s WebSocket channel 协议 -// -// 直接通过 K8s API 的 exec 端点(WebSocket channel.k8s.io v4 协议)建立 -// 交互式终端,无需在容器中安装 kubectl。 -// -// 数据流: -// -// Frontend WS (JSON) ←→ Backend ←→ K8s API WS (channel frames) -// -// K8s channel 协议: 每条消息 [1字节 channel ID] + [payload] -// -// 0 = stdin (client→server) -// 1 = stdout (server→client) -// 2 = stderr (server→client) -// 3 = error (server→client, JSON) -// 4 = resize (client→server, JSON {"Height","Width"}) -func (h *KubernetesHandler) ExecTerminal(c *gin.Context) { - namespace := c.Param("namespace") - podName := c.Param("podName") - container := c.Query("container") - command := c.Query("command") - if command == "" { - command = "bash" - } - - client, cluster, err := h.getK8sClientByCode(c) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) - return - } - - // Build exec WebSocket URL — https→wss / http→ws - execURL := client.BaseURL - if strings.HasPrefix(execURL, "https://") { - execURL = "wss://" + strings.TrimPrefix(execURL, "https://") - } else { - execURL = "ws://" + strings.TrimPrefix(execURL, "http://") - } - params := url.Values{} - params.Set("command", command) - params.Set("stdin", "true") - params.Set("stdout", "true") - params.Set("stderr", "true") - params.Set("tty", "true") - if container != "" { - params.Set("container", container) - } - execURL += fmt.Sprintf("/api/v1/namespaces/%s/pods/%s/exec?%s", - url.PathEscape(namespace), url.PathEscape(podName), params.Encode()) - - // Dial K8s API server via WebSocket with channel protocol subprotocol - k8sDialer := &websocket.Dialer{ - TLSClientConfig: client.TLSConfig, - Subprotocols: []string{"v4.channel.k8s.io", "v3.channel.k8s.io", "v5.channel.k8s.io"}, - HandshakeTimeout: 10 * time.Second, - } - k8sHeader := http.Header{} - if client.BearerToken != "" { - k8sHeader.Set("Authorization", "Bearer "+client.BearerToken) - } - k8sConn, _, err := k8sDialer.Dial(execURL, k8sHeader) - if err != nil { - log.Printf("[K8sExec] dial K8s exec WS failed: %v", err) - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s exec 连接失败: " + err.Error()}) - return - } - defer k8sConn.Close() - - // Upgrade frontend connection to WebSocket - frontendConn, err := upgrader.Upgrade(c.Writer, c.Request, nil) - if err != nil { - return - } - defer frontendConn.Close() - - done := make(chan struct{}, 2) - - // Frontend WS → K8s WS (stdin + resize) - go func() { - defer func() { done <- struct{}{} }() - for { - msgType, msg, err := frontendConn.ReadMessage() - if err != nil { - return - } - _ = msgType - - // Parse frontend JSON protocol: {status, data} or {status, cols, rows} - var wsMsg WSMessage - if err := json.Unmarshal(msg, &wsMsg); err != nil { - continue - } - - if wsMsg.Status == 0 && wsMsg.Data != "" { - // stdin: prepend channel 0 byte - frame := make([]byte, 1+len(wsMsg.Data)) - frame[0] = 0 // channel 0 = stdin - copy(frame[1:], wsMsg.Data) - if err := k8sConn.WriteMessage(websocket.BinaryMessage, frame); err != nil { - return - } - } else if wsMsg.Status == 1 && wsMsg.Cols > 0 { - // resize: channel 4 + JSON - resizeJSON, _ := json.Marshal(struct { - Height uint16 `json:"Height"` - Width uint16 `json:"Width"` - }{Height: uint16(wsMsg.Rows), Width: uint16(wsMsg.Cols)}) - frame := make([]byte, 1+len(resizeJSON)) - frame[0] = 4 // channel 4 = resize - copy(frame[1:], resizeJSON) - if err := k8sConn.WriteMessage(websocket.BinaryMessage, frame); err != nil { - return - } - } - } - }() - - // K8s WS → Frontend WS (stdout/stderr/error) - go func() { - defer func() { done <- struct{}{} }() - for { - msgType, msg, err := k8sConn.ReadMessage() - if err != nil { - // K8s connection closed — notify frontend - frontendConn.WriteJSON(WSMessage{Status: 2, Data: "\r\n\x1b[33m[K8s 连接已关闭]\x1b[0m\r\n"}) - return - } - _ = msgType - if len(msg) == 0 { - continue - } - - channel := msg[0] - payload := msg[1:] - - switch channel { - case 1, 2: // stdout / stderr → forward to frontend as terminal output - frontendConn.WriteJSON(WSMessage{Status: 0, Data: string(payload)}) - case 3: // error → forward as error message - errMsg := string(payload) - frontendConn.WriteJSON(WSMessage{Status: 2, Data: "\r\n\x1b[31m" + errMsg + "\x1b[0m\r\n"}) - } - } - }() - - <-done - _ = cluster -} - -// ============================================================================ -// Exec / File Browser -// ============================================================================ - -// ExecInPod 在 Pod 中执行命令 (文件浏览 / 单次命令) -func (h *KubernetesHandler) ExecInPod(c *gin.Context) { - clusterID, _ := strconv.Atoi(c.Param("id")) - namespace := c.Param("namespace") - podName := c.Param("podName") - - var req struct { - Command []string `json:"command"` - Container string `json:"container"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误"}) - return - } - if len(req.Command) == 0 { - c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "command 不能为空"}) - return - } - - client, _, err := h.getK8sClient(clusterID) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) - return - } - - result, err := client.ExecInPod(namespace, podName, req.Container, req.Command) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "执行失败: " + err.Error()}) - return - } - - c.JSON(http.StatusOK, models.Response{Code: 200, Data: result}) -} - -// ============================================================================ -// Scale (副本数加减) -// ============================================================================ - -// ScaleDeployment 缩放 Deployment 副本数 -func (h *KubernetesHandler) ScaleDeployment(c *gin.Context) { - clusterID, _ := strconv.Atoi(c.Param("id")) - namespace := c.Param("namespace") - name := c.Param("name") - - var req struct { - Replicas int `json:"replicas"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误"}) - return - } - if req.Replicas < 0 { - c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "副本数不能为负数"}) - return - } - - client, _, err := h.getK8sClient(clusterID) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) - return - } - - if err := client.ScaleDeployment(namespace, name, req.Replicas); err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "扩缩容失败: " + err.Error()}) - return - } - - c.JSON(http.StatusOK, models.Response{Code: 200, Message: fmt.Sprintf("已设置为 %d 副本", req.Replicas)}) -} - -// RestartDeployment 重启 Deployment (滚动重启) -func (h *KubernetesHandler) RestartDeployment(c *gin.Context) { - clusterID, _ := strconv.Atoi(c.Param("id")) - namespace := c.Param("namespace") - name := c.Param("name") - - client, _, err := h.getK8sClient(clusterID) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) - return - } - - if err := client.RestartDeployment(namespace, name); err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "重启失败: " + err.Error()}) - return - } - - c.JSON(http.StatusOK, models.Response{Code: 200, Message: "重启指令已下发"}) -} - -// PauseDeployment 暂停 Deployment 滚动更新(spec.paused=true) -func (h *KubernetesHandler) PauseDeployment(c *gin.Context) { - clusterID, _ := strconv.Atoi(c.Param("id")) - namespace := c.Param("namespace") - name := c.Param("name") - - client, _, err := h.getK8sClient(clusterID) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) - return - } - - if err := client.PauseDeployment(namespace, name); err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "暂停更新失败: " + err.Error()}) - return - } - - c.JSON(http.StatusOK, models.Response{Code: 200, Message: "已暂停滚动更新"}) -} - -// ResumeDeployment 恢复 Deployment 滚动更新(spec.paused=false) -func (h *KubernetesHandler) ResumeDeployment(c *gin.Context) { - clusterID, _ := strconv.Atoi(c.Param("id")) - namespace := c.Param("namespace") - name := c.Param("name") - - client, _, err := h.getK8sClient(clusterID) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) - return - } - - if err := client.ResumeDeployment(namespace, name); err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "恢复更新失败: " + err.Error()}) - return - } - - c.JSON(http.StatusOK, models.Response{Code: 200, Message: "已恢复滚动更新"}) -} - -// ============================================================================ -// HPA 管理 -// ============================================================================ - -// ListHPAs 获取 HPA 列表 -func (h *KubernetesHandler) ListHPAs(c *gin.Context) { - clusterID, _ := strconv.Atoi(c.Param("id")) - - client, _, err := h.getK8sClient(clusterID) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) - return - } - - hpas, err := client.ListHPAs() - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) - return - } - - c.JSON(http.StatusOK, models.Response{Code: 200, Data: hpas.Items}) -} - -// CreateHPA 创建 HPA -func (h *KubernetesHandler) CreateHPA(c *gin.Context) { - clusterID, _ := strconv.Atoi(c.Param("id")) - - var req k8sclient.CreateHPARequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误: " + err.Error()}) - return - } - if req.Name == "" || req.Namespace == "" || req.TargetName == "" { - c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "name/namespace/targetName 必填"}) - return - } - - client, _, err := h.getK8sClient(clusterID) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) - return - } - - hpa, err := client.CreateHPA(req) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建 HPA 失败: " + err.Error()}) - return - } - - c.JSON(http.StatusOK, models.Response{Code: 200, Message: "HPA 创建成功", Data: hpa}) -} - -// DeleteHPA 删除 HPA -func (h *KubernetesHandler) DeleteHPA(c *gin.Context) { - clusterID, _ := strconv.Atoi(c.Param("id")) - namespace := c.Param("namespace") - name := c.Param("name") - - client, _, err := h.getK8sClient(clusterID) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) - return - } - - if err := client.DeleteHPA(namespace, name); err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除 HPA 失败: " + err.Error()}) - return - } - - c.JSON(http.StatusOK, models.Response{Code: 200, Message: "HPA 已删除"}) -} - -// ============================================================================ -// CronJob 管理 -// ============================================================================ - -// ListCronJobs 获取 CronJob 列表 -func (h *KubernetesHandler) ListCronJobs(c *gin.Context) { - clusterID, _ := strconv.Atoi(c.Param("id")) - - client, _, err := h.getK8sClient(clusterID) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) - return - } - - cronjobs, err := client.ListCronJobs() - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) - return - } - - c.JSON(http.StatusOK, models.Response{Code: 200, Data: cronjobs.Items}) -} - -// CreateCronJob 创建 CronJob -func (h *KubernetesHandler) CreateCronJob(c *gin.Context) { - clusterID, _ := strconv.Atoi(c.Param("id")) - - var req k8sclient.CreateCronJobRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误: " + err.Error()}) - return - } - - client, _, err := h.getK8sClient(clusterID) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) - return - } - - cj, err := client.CreateCronJob(req) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建 CronJob 失败: " + err.Error()}) - return - } - - c.JSON(http.StatusOK, models.Response{Code: 200, Message: "CronJob 创建成功", Data: cj}) -} - -// SuspendCronJob 暂停/恢复 CronJob -func (h *KubernetesHandler) SuspendCronJob(c *gin.Context) { - clusterID, _ := strconv.Atoi(c.Param("id")) - namespace := c.Param("namespace") - name := c.Param("name") - - var req struct { - Suspend bool `json:"suspend"` - } - c.ShouldBindJSON(&req) - - client, _, err := h.getK8sClient(clusterID) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) - return - } - - if err := client.SuspendCronJob(namespace, name, req.Suspend); err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "操作失败: " + err.Error()}) - return - } - - action := "恢复" - if req.Suspend { - action = "暂停" - } - c.JSON(http.StatusOK, models.Response{Code: 200, Message: "CronJob 已" + action}) -} - -// DeleteCronJob 删除 CronJob -func (h *KubernetesHandler) DeleteCronJob(c *gin.Context) { - clusterID, _ := strconv.Atoi(c.Param("id")) - namespace := c.Param("namespace") - name := c.Param("name") - - client, _, err := h.getK8sClient(clusterID) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) - return - } - - if err := client.DeleteCronJob(namespace, name); err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除 CronJob 失败: " + err.Error()}) - return - } - - c.JSON(http.StatusOK, models.Response{Code: 200, Message: "CronJob 已删除"}) -} - -// TriggerCronJob 手动触发 CronJob -func (h *KubernetesHandler) TriggerCronJob(c *gin.Context) { - clusterID, _ := strconv.Atoi(c.Param("id")) - namespace := c.Param("namespace") - name := c.Param("name") - - client, _, err := h.getK8sClient(clusterID) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) - return - } - - if err := client.TriggerCronJob(namespace, name); err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "触发失败: " + err.Error()}) - return - } - - c.JSON(http.StatusOK, models.Response{Code: 200, Message: "手动触发成功"}) -} - -// ============================================================================ -// Deployment CI 集成 (Webhook 更新镜像) -// ============================================================================ - -// UpdateDeploymentImage 更新 Deployment 容器镜像 (CI webhook) -func (h *KubernetesHandler) UpdateDeploymentImage(c *gin.Context) { - clusterID, _ := strconv.Atoi(c.Param("id")) - namespace := c.Param("namespace") - name := c.Param("name") - - var req struct { - Image string `json:"image"` - ContainerName string `json:"container_name"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误"}) - return - } - if req.Image == "" { - c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "image 不能为空"}) - return - } - - client, _, err := h.getK8sClient(clusterID) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) - return - } - - if err := client.UpdateDeploymentImage(namespace, name, req.ContainerName, req.Image); err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "更新镜像失败: " + err.Error()}) - return - } - - c.JSON(http.StatusOK, models.Response{Code: 200, Message: fmt.Sprintf("镜像已更新为 %s,正在滚动更新...", req.Image)}) -} - -// ============================================================================ -// Namespace -// ============================================================================ - -// ListNamespaces 获取命名空间列表 -func (h *KubernetesHandler) ListNamespaces(c *gin.Context) { - clusterID, _ := strconv.Atoi(c.Param("id")) - // Support clusterCode param from new routes - if clusterID == 0 { - clusterID = h.resolveClusterCode(c.Param("clusterCode")) - } - - client, _, err := h.getK8sClient(clusterID) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) - return - } - - namespaces, err := client.ListNamespaces() - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) - return - } - - c.JSON(http.StatusOK, models.Response{Code: 200, Data: namespaces}) -} - -// ============================================================================ -// Namespace Management (M14 — 资源配额 + CRUD) -// ============================================================================ - -// NamespaceDetail is the enriched namespace info for the management page -type NamespaceDetail struct { - Name string `json:"name"` - Status string `json:"status"` - Age string `json:"age"` - Labels map[string]string `json:"labels"` - Annotations map[string]string `json:"annotations,omitempty"` - ResourceCounts map[string]int `json:"resource_counts"` - HasResourceQuota bool `json:"has_resource_quota"` - HasLimitRange bool `json:"has_limit_range"` - ResourceQuotas []k8sclient.ResourceQuotaItem `json:"resource_quotas,omitempty"` - LimitRanges []k8sclient.LimitRangeItem `json:"limit_ranges,omitempty"` -} - -// GetNamespacesDetail returns all namespaces with metadata, resource counts and quota info -func (h *KubernetesHandler) GetNamespacesDetail(c *gin.Context) { - clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { - clusterID = h.resolveClusterCode(c.Param("clusterCode")) - } - if clusterID == 0 { - c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "cluster not specified"}) - return - } - client, _, err := h.getK8sClient(clusterID) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) - return - } - - nsList, err := client.ListNamespacesDetailed() - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) - return - } - - // Efficient: fetch all resources once, group by namespace - depCounts := map[string]int{} - if deps, err := client.ListDeployments(); err == nil { - for _, d := range deps.Items { - depCounts[d.Metadata.Namespace]++ - } - } - podCounts := map[string]int{} - if pods, err := client.ListPods(); err == nil { - for _, p := range pods.Items { - podCounts[p.Metadata.Namespace]++ - } - } - svcCounts := map[string]int{} - if svcs, err := client.ListServices(); err == nil { - for _, s := range svcs.Items { - svcCounts[s.Metadata.Namespace]++ - } - } - // All-namespace ResourceQuotas - quotaByNS := map[string][]k8sclient.ResourceQuotaItem{} - if quotas, err := client.ListResourceQuotas(""); err == nil { - for _, q := range quotas { - quotaByNS[q.Metadata.Namespace] = append(quotaByNS[q.Metadata.Namespace], q) - } - } - // All-namespace LimitRanges - lrByNS := map[string][]k8sclient.LimitRangeItem{} - if lrs, err := client.ListLimitRanges(""); err == nil { - for _, lr := range lrs { - lrByNS[lr.Metadata.Namespace] = append(lrByNS[lr.Metadata.Namespace], lr) - } - } - - result := make([]NamespaceDetail, 0, len(nsList.Items)) - for _, ns := range nsList.Items { - name := ns.Metadata.Name - nd := NamespaceDetail{ - Name: name, - Status: ns.Status.Phase, - Age: formatAge(ns.Metadata.CreationTimestamp), - Labels: ns.Metadata.Labels, - Annotations: ns.Metadata.Annotations, - ResourceCounts: map[string]int{ - "deployments": depCounts[name], - "pods": podCounts[name], - "services": svcCounts[name], - }, - } - if nd.Labels == nil { - nd.Labels = map[string]string{} - } - if qs, ok := quotaByNS[name]; ok && len(qs) > 0 { - nd.HasResourceQuota = true - nd.ResourceQuotas = qs - } - if lrs, ok := lrByNS[name]; ok && len(lrs) > 0 { - nd.HasLimitRange = true - nd.LimitRanges = lrs - } - result = append(result, nd) - } - - c.JSON(http.StatusOK, models.Response{Code: 200, Data: result}) -} - -// GetNamespaceDetailHandler returns a single namespace detail with quota info -func (h *KubernetesHandler) GetNamespaceDetailHandler(c *gin.Context) { - clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { - clusterID = h.resolveClusterCode(c.Param("clusterCode")) - } - if clusterID == 0 { - c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "cluster not specified"}) - return - } - nsName := c.Param("namespace") - if nsName == "" { - nsName = c.Param("name") - } - - client, _, err := h.getK8sClient(clusterID) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) - return - } - - ns, err := client.GetNamespace(nsName) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) - return - } - - nd := NamespaceDetail{ - Name: ns.Metadata.Name, - Status: ns.Status.Phase, - Age: formatAge(ns.Metadata.CreationTimestamp), - Labels: ns.Metadata.Labels, - Annotations: ns.Metadata.Annotations, - ResourceCounts: map[string]int{}, - } - if nd.Labels == nil { - nd.Labels = map[string]string{} - } - - // Fetch resource counts (single namespace) - if deps, err := client.ListDeployments(); err == nil { - count := 0 - for _, d := range deps.Items { - if d.Metadata.Namespace == nsName { count++ } - } - nd.ResourceCounts["deployments"] = count - } - if pods, err := client.ListPods(); err == nil { - count := 0 - for _, p := range pods.Items { - if p.Metadata.Namespace == nsName { count++ } - } - nd.ResourceCounts["pods"] = count - } - if svcs, err := client.ListServices(); err == nil { - count := 0 - for _, s := range svcs.Items { - if s.Metadata.Namespace == nsName { count++ } - } - nd.ResourceCounts["services"] = count - } - - // Fetch quotas + limit ranges for this namespace - if quotas, err := client.ListResourceQuotas(nsName); err == nil && len(quotas) > 0 { - nd.HasResourceQuota = true - nd.ResourceQuotas = quotas - } - if lrs, err := client.ListLimitRanges(nsName); err == nil && len(lrs) > 0 { - nd.HasLimitRange = true - nd.LimitRanges = lrs - } - - c.JSON(http.StatusOK, models.Response{Code: 200, Data: nd}) -} - -// CreateNamespaceHandler creates a new namespace -func (h *KubernetesHandler) CreateNamespaceHandler(c *gin.Context) { - clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { - clusterID = h.resolveClusterCode(c.Param("clusterCode")) - } - if clusterID == 0 { - c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "cluster not specified"}) - return - } - - var req struct { - Name string `json:"name" binding:"required"` - Labels map[string]string `json:"labels"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "name is required"}) - return - } - - // Safety: block critical system namespaces - systemNS := map[string]bool{ - "kube-system": true, "kube-public": true, "kube-node-lease": true, - "default": true, "kubernetes-dashboard": true, - } - if systemNS[req.Name] { - c.JSON(http.StatusForbidden, models.Response{Code: 403, Message: "cannot create system namespace: " + req.Name}) - return - } - - client, _, err := h.getK8sClient(clusterID) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) - return - } - - ns, err := client.CreateNamespace(req.Name, req.Labels) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) - return - } - - c.JSON(http.StatusOK, models.Response{Code: 200, Data: ns, Message: "命名空间创建成功"}) -} - -// DeleteNamespaceHandler deletes a namespace -func (h *KubernetesHandler) DeleteNamespaceHandler(c *gin.Context) { - clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { - clusterID = h.resolveClusterCode(c.Param("clusterCode")) - } - if clusterID == 0 { - c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "cluster not specified"}) - return - } - - nsName := c.Param("namespace") - if nsName == "" { - nsName = c.Param("name") - } - - // Safety: block deletion of critical system namespaces - systemNS := map[string]bool{ - "kube-system": true, "kube-public": true, "kube-node-lease": true, - "default": true, "devops": true, - } - if systemNS[nsName] { - c.JSON(http.StatusForbidden, models.Response{Code: 403, Message: "禁止删除系统命名空间: " + nsName}) - return - } - - client, _, err := h.getK8sClient(clusterID) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) - return - } - - if err := client.DeleteNamespace(nsName); err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) - return - } - - c.JSON(http.StatusOK, models.Response{Code: 200, Message: "命名空间已删除"}) -} - -// ============================================================================ -// ConfigMap & Secret (Kuboard-style namespace resources) -// ============================================================================ - -// ListConfigMaps 获取命名空间下的 ConfigMap 列表 -func (h *KubernetesHandler) ListConfigMaps(c *gin.Context) { - clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { - clusterID = h.resolveClusterCode(c.Param("clusterCode")) - } - namespace := c.Param("namespace") - - client, _, err := h.getK8sClient(clusterID) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) - return - } - - cms, err := client.ListConfigMaps(namespace) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) - return - } - - c.JSON(http.StatusOK, models.Response{Code: 200, Data: cms.Items}) -} - -// ListSecrets 获取命名空间下的 Secret 列表(仅名称+类型,不含值) -func (h *KubernetesHandler) ListSecrets(c *gin.Context) { - clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { - clusterID = h.resolveClusterCode(c.Param("clusterCode")) - } - namespace := c.Param("namespace") - - client, _, err := h.getK8sClient(clusterID) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) - return - } - - secrets, err := client.ListSecrets(namespace) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) - return - } - - c.JSON(http.StatusOK, models.Response{Code: 200, Data: secrets.Items}) -} - -// ============================================================================ -// Helpers -// ============================================================================ - -// resolveClusterCode resolves a cluster code to its numeric ID func (h *KubernetesHandler) resolveClusterCode(code string) int { if code == "" { return 0 @@ -2119,883 +84,6 @@ func (h *KubernetesHandler) getK8sClientByCode(c *gin.Context) (*k8sclient.Clien // NS wrappers — read namespace from path (Kuboard-style routes) // ============================================================================ -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")) -} -func (h *KubernetesHandler) ListServicesNS(c *gin.Context) { - h.listServicesWithNS(c, c.Param("namespace")) -} -func (h *KubernetesHandler) ListIngressesNS(c *gin.Context) { - h.listIngressesWithNS(c, c.Param("namespace")) -} -func (h *KubernetesHandler) ListHPAsNS(c *gin.Context) { - h.listHPAsWithNS(c, c.Param("namespace")) -} -func (h *KubernetesHandler) CreateHPANS(c *gin.Context) { - h.createHPAWithNS(c, c.Param("namespace")) -} -func (h *KubernetesHandler) DeleteHPANS(c *gin.Context) { - h.deleteHPAWithNS(c, c.Param("namespace")) -} -func (h *KubernetesHandler) ListCronJobsNS(c *gin.Context) { - h.listCronJobsWithNS(c, c.Param("namespace")) -} -func (h *KubernetesHandler) CreateCronJobNS(c *gin.Context) { - h.createCronJobWithNS(c, c.Param("namespace")) -} -func (h *KubernetesHandler) SuspendCronJobNS(c *gin.Context) { - h.suspendCronJobWithNS(c, c.Param("namespace")) -} -func (h *KubernetesHandler) TriggerCronJobNS(c *gin.Context) { - h.triggerCronJobWithNS(c, c.Param("namespace")) -} -func (h *KubernetesHandler) DeleteCronJobNS(c *gin.Context) { - h.deleteCronJobWithNS(c, c.Param("namespace")) -} -func (h *KubernetesHandler) ScaleDeploymentNS(c *gin.Context) { - h.scaleDeploymentWithNS(c, c.Param("namespace")) -} -func (h *KubernetesHandler) RestartDeploymentNS(c *gin.Context) { - h.restartDeploymentWithNS(c, c.Param("namespace")) -} -func (h *KubernetesHandler) UpdateDeploymentImageNS(c *gin.Context) { - h.updateDeploymentImageWithNS(c, c.Param("namespace")) -} -func (h *KubernetesHandler) PauseDeploymentNS(c *gin.Context) { - h.pauseDeploymentWithNS(c, c.Param("namespace")) -} -func (h *KubernetesHandler) ResumeDeploymentNS(c *gin.Context) { - h.resumeDeploymentWithNS(c, c.Param("namespace")) -} -func (h *KubernetesHandler) GetPodLogsNS(c *gin.Context) { - h.getPodLogsWithNS(c, c.Param("namespace")) -} -func (h *KubernetesHandler) ExecInPodNS(c *gin.Context) { - h.execInPodWithNS(c, c.Param("namespace")) -} - -// Internal helpers that resolve clusterCode and inject namespace -func (h *KubernetesHandler) listDeploymentsWithNS(c *gin.Context, ns string) { - 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 } - type DepSummary struct { - Namespace string `json:"namespace"` - Name string `json:"name"` - Replicas int `json:"replicas"` - AvailableReplicas int `json:"available_replicas"` - ReadyReplicas int `json:"ready_replicas"` - Image string `json:"image"` - Paused bool `json:"paused"` - Selector map[string]string `json:"selector,omitempty"` // matchLabels — 前端 Service Tab 关联用 - } - result := []DepSummary{} - for _, d := range deps.Items { - if d.Metadata.Namespace != ns { continue } - image := ""; if len(d.Spec.Template.Spec.Containers) > 0 { image = d.Spec.Template.Spec.Containers[0].Image } - result = append(result, DepSummary{ - Namespace: d.Metadata.Namespace, - Name: d.Metadata.Name, - Replicas: d.Spec.Replicas, - AvailableReplicas: d.Status.AvailableReplicas, - ReadyReplicas: d.Status.ReadyReplicas, - Image: image, - Paused: d.Spec.Paused, - Selector: func() map[string]string { if d.Spec.Selector != nil { return d.Spec.Selector.MatchLabels }; return nil }(), - }) - } - c.JSON(200, models.Response{Code: 200, Data: result}) -} -func (h *KubernetesHandler) listPodsWithNS(c *gin.Context, ns string) { - 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 } - pods, err := client.ListPods() - if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } - // M2: inject pod metrics (best-effort) // Logan.Li - podMetrics := buildPodMetricsMap(client, ns) - type PodSummary struct { - Namespace string `json:"namespace"` - Name string `json:"name"` - Status string `json:"status"` - NodeName string `json:"node_name"` - IP string `json:"ip"` - RestartCount int `json:"restart_count"` - Image string `json:"image"` - CPUUsageM string `json:"cpu_usage_m,omitempty"` - MemUsageMi string `json:"mem_usage_mi,omitempty"` - } - result := []PodSummary{} - for _, p := range pods.Items { - if p.Metadata.Namespace != ns { continue } - image := ""; if len(p.Spec.Containers) > 0 { image = p.Spec.Containers[0].Image } - restarts := 0; if len(p.Status.ContainerStatuses) > 0 { restarts = p.Status.ContainerStatuses[0].RestartCount } - ps := PodSummary{ - Namespace: p.Metadata.Namespace, - Name: p.Metadata.Name, - Status: p.Status.Phase, - NodeName: p.Spec.NodeName, - IP: p.Status.PodIP, - RestartCount: restarts, - Image: image, - } - if m, ok := podMetrics[p.Metadata.Namespace+"/"+p.Metadata.Name]; ok { - ps.CPUUsageM = m.CPU - ps.MemUsageMi = m.Mem - } - result = append(result, ps) - } - c.JSON(200, models.Response{Code: 200, Data: result}) -} -func (h *KubernetesHandler) listServicesWithNS(c *gin.Context, ns string) { - 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 } - svcs, err := client.ListServices() - if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } - type SvcPortDetail struct { - Name string `json:"name,omitempty"` - Port int `json:"port"` - TargetPort interface{} `json:"targetPort"` - NodePort int `json:"nodePort,omitempty"` - Protocol string `json:"protocol,omitempty"` - } - type SvcSummary struct { - Namespace string `json:"namespace"` - Name string `json:"name"` - Type string `json:"type"` - ClusterIP string `json:"cluster_ip"` - Ports int `json:"ports"` - PortList []SvcPortDetail `json:"portList,omitempty"` - Selector map[string]string `json:"selector,omitempty"` - } - result := []SvcSummary{} - for _, s := range svcs.Items { - if s.Metadata.Namespace != ns { continue } - ports := []SvcPortDetail{} - for _, p := range s.Spec.Ports { - ports = append(ports, SvcPortDetail{ - Name: p.Name, Port: p.Port, TargetPort: p.TargetPort, - NodePort: p.NodePort, Protocol: p.Protocol, - }) - } - result = append(result, SvcSummary{ - Namespace: s.Metadata.Namespace, - Name: s.Metadata.Name, - Type: s.Spec.Type, - ClusterIP: s.Spec.ClusterIP, - Ports: len(s.Spec.Ports), - PortList: ports, - Selector: s.Spec.Selector, - }) - } - c.JSON(200, models.Response{Code: 200, Data: result}) -} -func (h *KubernetesHandler) listIngressesWithNS(c *gin.Context, ns string) { - 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 } - ings, err := client.ListIngresses() - if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } - type PathDetail struct { - Path string `json:"path"` - PathType string `json:"pathType,omitempty"` - BackendSvc string `json:"backendService,omitempty"` - BackendPort string `json:"backendPort,omitempty"` - } - type RuleDetail struct { - Host string `json:"host"` - Paths []PathDetail `json:"paths"` - } - type IngSummary struct { - Namespace string `json:"namespace"` - Name string `json:"name"` - Hosts []string `json:"hosts"` - TLS bool `json:"tls"` - IngressClass string `json:"ingressClass,omitempty"` - Rules []RuleDetail `json:"rules,omitempty"` - } - result := []IngSummary{} - for _, ing := range ings.Items { - if ing.Metadata.Namespace != ns { continue } - hosts := []string{} - rules := []RuleDetail{} - for _, rule := range ing.Spec.Rules { - if rule.Host != "" { hosts = append(hosts, rule.Host) } - rd := RuleDetail{Host: rule.Host, Paths: []PathDetail{}} - if rule.HTTP != nil { - for _, p := range rule.HTTP.Paths { - pd := PathDetail{Path: p.Path, PathType: p.PathType, BackendSvc: p.Backend.Service.Name} - if p.Backend.Service.Port.Number > 0 { - pd.BackendPort = strconv.Itoa(p.Backend.Service.Port.Number) - } else if p.Backend.Service.Port.Name != "" { - pd.BackendPort = p.Backend.Service.Port.Name - } - rd.Paths = append(rd.Paths, pd) - } - } - rules = append(rules, rd) - } - // ingressClassName from annotations - ingClass := "" - if ing.Metadata.Annotations != nil { - ingClass = ing.Metadata.Annotations["kubernetes.io/ingress.class"] - } - result = append(result, IngSummary{ - Namespace: ing.Metadata.Namespace, - Name: ing.Metadata.Name, - Hosts: hosts, - TLS: len(ing.Spec.TLS) > 0, - IngressClass: ingClass, - Rules: rules, - }) - } - c.JSON(200, models.Response{Code: 200, Data: result}) -} -func (h *KubernetesHandler) listHPAsWithNS(c *gin.Context, ns string) { - 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 } - hpas, err := client.ListHPAs() - if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } - type HPASummary struct { - Namespace string `json:"namespace"` - Name string `json:"name"` - TargetKind string `json:"target_kind"` - TargetName string `json:"target_name"` - MinReplicas int `json:"min_replicas"` - MaxReplicas int `json:"max_replicas"` - CPUPercent int `json:"cpu_percent"` - MemPercent int `json:"mem_percent"` - CurrentReplicas int `json:"current_replicas"` - } - result := []HPASummary{} - for _, h := range hpas.Items { - if h.Metadata.Namespace != ns { continue } - cpu := 0; mem := 0 - for _, m := range h.Spec.Metrics { - if m.Resource != nil { - if m.Resource.Name == "cpu" { cpu = m.Resource.Target.AverageUtilization } - if m.Resource.Name == "memory" { mem = m.Resource.Target.AverageUtilization } - } - } - result = append(result, HPASummary{ - Namespace: h.Metadata.Namespace, - Name: h.Metadata.Name, - TargetKind: h.Spec.ScaleTargetRef.Kind, - TargetName: h.Spec.ScaleTargetRef.Name, - MinReplicas: h.Spec.MinReplicas, - MaxReplicas: h.Spec.MaxReplicas, - CPUPercent: cpu, - MemPercent: mem, - CurrentReplicas: h.Status.CurrentReplicas, - }) - } - c.JSON(200, models.Response{Code: 200, Data: result}) -} -func (h *KubernetesHandler) createHPAWithNS(c *gin.Context, ns string) { - clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } - var req k8sclient.CreateHPARequest - if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, models.Response{Code: 400, Message: "参数错误"}); return } - if req.Namespace == "" { req.Namespace = ns } - client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } - hpa, err := client.CreateHPA(req) - if err != nil { c.JSON(500, models.Response{Code: 500, Message: "创建失败: " + err.Error()}); return } - c.JSON(200, models.Response{Code: 200, Data: hpa}) -} -func (h *KubernetesHandler) deleteHPAWithNS(c *gin.Context, ns string) { - 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 } - if err := client.DeleteHPA(ns, c.Param("name")); err != nil { c.JSON(500, models.Response{Code: 500, Message: "删除失败: " + err.Error()}); return } - c.JSON(200, models.Response{Code: 200, Message: "已删除"}) -} -func (h *KubernetesHandler) listCronJobsWithNS(c *gin.Context, ns string) { - 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 } - cjs, err := client.ListCronJobs() - if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } - filtered := []k8sclient.CronJobItem{} - for _, cj := range cjs.Items { if cj.Metadata.Namespace == ns { filtered = append(filtered, cj) } } - c.JSON(200, models.Response{Code: 200, Data: filtered}) -} -func (h *KubernetesHandler) createCronJobWithNS(c *gin.Context, ns string) { - clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } - var req k8sclient.CreateCronJobRequest - if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, models.Response{Code: 400, Message: "参数错误"}); return } - if req.Namespace == "" { req.Namespace = ns } - client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } - cj, err := client.CreateCronJob(req) - if err != nil { c.JSON(500, models.Response{Code: 500, Message: "创建失败: " + err.Error()}); return } - c.JSON(200, models.Response{Code: 200, Data: cj}) -} -func (h *KubernetesHandler) suspendCronJobWithNS(c *gin.Context, ns string) { - clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } - var req struct { Suspend bool `json:"suspend"` }; c.ShouldBindJSON(&req) - client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } - if err := client.SuspendCronJob(ns, c.Param("name"), req.Suspend); err != nil { c.JSON(500, models.Response{Code: 500, Message: "操作失败: " + err.Error()}); return } - action := "恢复"; if req.Suspend { action = "暂停" } - c.JSON(200, models.Response{Code: 200, Message: "CronJob 已" + action}) -} -func (h *KubernetesHandler) triggerCronJobWithNS(c *gin.Context, ns string) { - 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 } - if err := client.TriggerCronJob(ns, c.Param("name")); err != nil { c.JSON(500, models.Response{Code: 500, Message: "触发失败: " + err.Error()}); return } - c.JSON(200, models.Response{Code: 200, Message: "手动触发成功"}) -} -func (h *KubernetesHandler) deleteCronJobWithNS(c *gin.Context, ns string) { - 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 } - if err := client.DeleteCronJob(ns, c.Param("name")); err != nil { c.JSON(500, models.Response{Code: 500, Message: "删除失败: " + err.Error()}); return } - c.JSON(200, models.Response{Code: 200, Message: "已删除"}) -} -func (h *KubernetesHandler) scaleDeploymentWithNS(c *gin.Context, ns string) { - clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } - var req struct { Replicas int `json:"replicas"` } - if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, models.Response{Code: 400, Message: "参数错误"}); return } - client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } - if err := client.ScaleDeployment(ns, c.Param("name"), req.Replicas); err != nil { c.JSON(500, models.Response{Code: 500, Message: "扩缩容失败: " + err.Error()}); return } - c.JSON(200, models.Response{Code: 200, Message: fmt.Sprintf("已设置为 %d 副本", req.Replicas)}) -} -func (h *KubernetesHandler) restartDeploymentWithNS(c *gin.Context, ns string) { - 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 } - if err := client.RestartDeployment(ns, c.Param("name")); err != nil { c.JSON(500, models.Response{Code: 500, Message: "重启失败: " + err.Error()}); return } - c.JSON(200, models.Response{Code: 200, Message: "重启指令已下发"}) -} -func (h *KubernetesHandler) updateDeploymentImageWithNS(c *gin.Context, ns string) { - clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } - var req struct { Image string `json:"image"`; ContainerName string `json:"container_name"` } - if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, models.Response{Code: 400, Message: "参数错误"}); return } - client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } - if err := client.UpdateDeploymentImage(ns, c.Param("name"), req.ContainerName, req.Image); err != nil { c.JSON(500, models.Response{Code: 500, Message: "更新镜像失败: " + err.Error()}); return } - c.JSON(200, models.Response{Code: 200, Message: fmt.Sprintf("镜像已更新为 %s", req.Image)}) -} -func (h *KubernetesHandler) pauseDeploymentWithNS(c *gin.Context, ns string) { - 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 } - if err := client.PauseDeployment(ns, c.Param("name")); err != nil { c.JSON(500, models.Response{Code: 500, Message: "暂停更新失败: " + err.Error()}); return } - c.JSON(200, models.Response{Code: 200, Message: "已暂停滚动更新"}) -} -func (h *KubernetesHandler) resumeDeploymentWithNS(c *gin.Context, ns string) { - 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 } - if err := client.ResumeDeployment(ns, c.Param("name")); err != nil { c.JSON(500, models.Response{Code: 500, Message: "恢复更新失败: " + err.Error()}); return } - c.JSON(200, models.Response{Code: 200, Message: "已恢复滚动更新"}) -} -func (h *KubernetesHandler) getPodLogsWithNS(c *gin.Context, ns string) { - clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } - podName := c.Param("podName") - stream := c.Query("stream") == "true" - // 日志增强参数:container / sinceTime / previous / tailLines(兼容 tail) // Logan.Li - tailLines := 0 - if v := c.Query("tailLines"); v != "" { - tailLines, _ = strconv.Atoi(v) - } - if tailLines == 0 { - tailLines, _ = strconv.Atoi(c.DefaultQuery("tail", "200")) - } - opts := k8sclient.LogOptions{ - Container: c.Query("container"), - TailLines: tailLines, - SinceTime: c.Query("sinceTime"), - Previous: c.Query("previous") == "true", - } - client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } - if stream { - body, err := client.GetPodLogsStreamWithOptions(ns, podName, opts) - if err != nil { c.JSON(500, models.Response{Code: 500, Message: "获取日志流失败: " + err.Error()}); return } - defer body.Close() - c.Header("Content-Type", "text/plain; charset=utf-8") - c.Header("Cache-Control", "no-cache") - c.Stream(func(w io.Writer) bool { - buf := make([]byte, 4096); n, err := body.Read(buf) - if n > 0 { w.Write(buf[:n]); return true } - return err == nil - }) - return - } - logs, err := client.GetPodLogsWithOptions(ns, podName, opts) - if err != nil { c.JSON(500, models.Response{Code: 500, Message: "获取日志失败: " + err.Error()}); return } - c.JSON(200, models.Response{Code: 200, Message: "success", Data: gin.H{"logs": logs, "pod": podName, "namespace": ns}}) -} -func (h *KubernetesHandler) execInPodWithNS(c *gin.Context, ns string) { - clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } - podName := c.Param("podName") - var req struct { Command []string `json:"command"`; Container string `json:"container"` } - if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, models.Response{Code: 400, Message: "参数错误"}); return } - client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } - result, err := client.ExecInPod(ns, podName, req.Container, req.Command) - if err != nil { c.JSON(500, models.Response{Code: 500, Message: "执行失败: " + err.Error()}); return } - c.JSON(200, models.Response{Code: 200, Data: result}) -} - -// ============================================================================ -// All-namespace handlers (clusterCode-based, no namespace filter) -// ============================================================================ - -// ListDeploymentsAllNS lists all deployments across all namespaces using clusterCode. -func (h *KubernetesHandler) ListDeploymentsAllNS(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 } - deps, err := client.ListDeployments() - if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } - type DepSummary struct { - Namespace string `json:"namespace"` - Name string `json:"name"` - Replicas int `json:"replicas"` - AvailableReplicas int `json:"available_replicas"` - ReadyReplicas int `json:"ready_replicas"` - Image string `json:"image"` - } - result := []DepSummary{} - for _, d := range deps.Items { - image := ""; if len(d.Spec.Template.Spec.Containers) > 0 { image = d.Spec.Template.Spec.Containers[0].Image } - result = append(result, DepSummary{ - Namespace: d.Metadata.Namespace, - Name: d.Metadata.Name, - Replicas: d.Spec.Replicas, - AvailableReplicas: d.Status.AvailableReplicas, - ReadyReplicas: d.Status.ReadyReplicas, - Image: image, - }) - } - c.JSON(200, models.Response{Code: 200, Data: result}) -} - -// ListPodsAllNS lists all pods across all namespaces using clusterCode. -func (h *KubernetesHandler) ListPodsAllNS(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 } - pods, err := client.ListPods() - if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } - // M2: inject pod metrics (best-effort) // Logan.Li - podMetrics := buildPodMetricsMap(client, "") - type PodSummary struct { - Namespace string `json:"namespace"` - Name string `json:"name"` - Status string `json:"status"` - NodeName string `json:"node_name"` - IP string `json:"ip"` - RestartCount int `json:"restart_count"` - Image string `json:"image"` - CPUUsageM string `json:"cpu_usage_m,omitempty"` - MemUsageMi string `json:"mem_usage_mi,omitempty"` - } - result := []PodSummary{} - for _, p := range pods.Items { - image := ""; if len(p.Spec.Containers) > 0 { image = p.Spec.Containers[0].Image } - restarts := 0; if len(p.Status.ContainerStatuses) > 0 { restarts = p.Status.ContainerStatuses[0].RestartCount } - ps := PodSummary{ - Namespace: p.Metadata.Namespace, - Name: p.Metadata.Name, - Status: p.Status.Phase, - NodeName: p.Spec.NodeName, - IP: p.Status.PodIP, - RestartCount: restarts, - Image: image, - } - if m, ok := podMetrics[p.Metadata.Namespace+"/"+p.Metadata.Name]; ok { - ps.CPUUsageM = m.CPU - ps.MemUsageMi = m.Mem - } - result = append(result, ps) - } - c.JSON(200, models.Response{Code: 200, Data: result}) -} - -// ListHPAsAllNS lists all HPAs across all namespaces using clusterCode. -func (h *KubernetesHandler) ListHPAsAllNS(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 } - hpas, err := client.ListHPAs() - if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } - type HPASummary struct { - Namespace string `json:"namespace"` - Name string `json:"name"` - TargetKind string `json:"target_kind"` - TargetName string `json:"target_name"` - MinReplicas int `json:"min_replicas"` - MaxReplicas int `json:"max_replicas"` - CPUPercent int `json:"cpu_percent"` - MemPercent int `json:"mem_percent"` - CurrentReplicas int `json:"current_replicas"` - } - result := []HPASummary{} - for _, h := range hpas.Items { - cpu := 0; mem := 0 - for _, m := range h.Spec.Metrics { - if m.Resource != nil { - if m.Resource.Name == "cpu" { cpu = m.Resource.Target.AverageUtilization } - if m.Resource.Name == "memory" { mem = m.Resource.Target.AverageUtilization } - } - } - result = append(result, HPASummary{ - Namespace: h.Metadata.Namespace, - Name: h.Metadata.Name, - TargetKind: h.Spec.ScaleTargetRef.Kind, - TargetName: h.Spec.ScaleTargetRef.Name, - MinReplicas: h.Spec.MinReplicas, - MaxReplicas: h.Spec.MaxReplicas, - CPUPercent: cpu, - MemPercent: mem, - CurrentReplicas: h.Status.CurrentReplicas, - }) - } - c.JSON(200, models.Response{Code: 200, Data: result}) -} - -// ============================================================================ -// Helpers -// ============================================================================ - -// buildPodMetricsMap fetches pod metrics and returns a map keyed by "namespace/podname" → aggregated CPU/Memory strings. -// Best-effort: returns empty map if metrics-server is unavailable. // Logan.Li M2 -func buildPodMetricsMap(client *k8sclient.Client, namespace string) map[string]struct{ CPU, Mem string } { - m := map[string]struct{ CPU, Mem string }{} - if pm, err := client.ListPodMetrics(namespace); err == nil { - for _, item := range pm.Items { - totalCPU := 0 - totalMemMi := 0 - for _, c := range item.Containers { - totalCPU += parseCpuNanoToMilli(c.Usage["cpu"]) - totalMemMi += parseMemKiToMi(c.Usage["memory"]) - } - key := item.Metadata.Namespace + "/" + item.Metadata.Name - m[key] = struct{ CPU, Mem string }{ - CPU: fmt.Sprintf("%dm", totalCPU), - Mem: fmt.Sprintf("%dMi", totalMemMi), - } - } - } else { - log.Printf("[K8s] ListPodMetrics failed (metrics-server not installed?): %v", err) - } - return m -} - -func parseIntResource(val string) int { - if val == "" { - return 0 - } - var i int - fmt.Sscanf(val, "%d", &i) - return i -} - -func parseMemResource(val string) int64 { - if val == "" { - return 0 - } - var num float64 - var unit string - fmt.Sscanf(val, "%f%s", &num, &unit) - switch unit { - case "Ki": - return int64(num / 1024) - case "Mi": - return int64(num) - case "Gi": - return int64(num * 1024) - default: - return int64(num / 1024 / 1024) - } -} - -// formatCpuFromNano converts K8s metrics CPU (nanocores "80410173n") to millicores "80m" -func formatCpuFromNano(raw string) string { - if raw == "" { return "" } - val := raw - val = strings.TrimSuffix(val, "n") - var nano int64 - fmt.Sscanf(val, "%d", &nano) - milli := nano / 1000000 - if milli < 1 { milli = 1 } - return fmt.Sprintf("%dm", milli) -} - -// parseCpuNanoToMilli converts K8s metrics CPU (nanocores "100354992n") to millicores (int) // Logan.Li -func parseCpuNanoToMilli(raw string) int { - if raw == "" { return 0 } - val := strings.TrimSuffix(raw, "n") - var nano int64 - fmt.Sscanf(val, "%d", &nano) - milli := nano / 1000000 - if milli < 1 { milli = 1 } - return int(milli) -} - -// formatMemFromKi converts K8s metrics memory (Ki "2154292Ki") to Mi "2105Mi" -func formatMemFromKi(raw string) string { - if raw == "" { return "" } - val := strings.TrimSuffix(raw, "Ki") - var ki int64 - fmt.Sscanf(val, "%d", &ki) - mi := ki / 1024 - if mi < 1 { mi = 1 } - return fmt.Sprintf("%dMi", mi) -} - -// parseMemKiToMi converts K8s metrics memory (Ki "2154292Ki") to Mi (int) // Logan.Li -func parseMemKiToMi(raw string) int { - if raw == "" { return 0 } - val := strings.TrimSuffix(raw, "Ki") - var ki int64 - fmt.Sscanf(val, "%d", &ki) - return int(ki / 1024) -} - - -// GetResourceYAMLNS returns the YAML of a K8s resource -func (h *KubernetesHandler) GetResourceYAMLNS(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") - 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 - switch resource { - case "deployments", "deployment": - apiPath = fmt.Sprintf("/apis/apps/v1/namespaces/%s/deployments/%s", ns, name) - case "statefulsets", "statefulset": - apiPath = fmt.Sprintf("/apis/apps/v1/namespaces/%s/statefulsets/%s", ns, name) - case "daemonsets", "daemonset": - apiPath = fmt.Sprintf("/apis/apps/v1/namespaces/%s/daemonsets/%s", ns, name) - case "replicasets", "replicaset": - apiPath = fmt.Sprintf("/apis/apps/v1/namespaces/%s/replicasets/%s", ns, name) - case "pods", "pod": - apiPath = fmt.Sprintf("/api/v1/namespaces/%s/pods/%s", ns, name) - case "services", "service": - apiPath = fmt.Sprintf("/api/v1/namespaces/%s/services/%s", ns, name) - case "ingresses", "ingress": - apiPath = fmt.Sprintf("/apis/networking.k8s.io/v1/namespaces/%s/ingresses/%s", ns, name) - case "configmaps", "configmap": - apiPath = fmt.Sprintf("/api/v1/namespaces/%s/configmaps/%s", ns, name) - case "secrets", "secret": - apiPath = fmt.Sprintf("/api/v1/namespaces/%s/secrets/%s", ns, name) - case "pvcs", "persistentvolumeclaim": - apiPath = fmt.Sprintf("/api/v1/namespaces/%s/persistentvolumeclaims/%s", ns, name) - case "hpas", "hpa", "horizontalpodautoscaler": - apiPath = fmt.Sprintf("/apis/autoscaling/v2/namespaces/%s/horizontalpodautoscalers/%s", ns, name) - case "cronjobs", "cronjob": - apiPath = fmt.Sprintf("/apis/batch/v1/namespaces/%s/cronjobs/%s", ns, name) - case "serviceaccounts", "serviceaccount": - apiPath = fmt.Sprintf("/api/v1/namespaces/%s/serviceaccounts/%s", ns, name) - case "roles", "role": - apiPath = fmt.Sprintf("/apis/rbac.authorization.k8s.io/v1/namespaces/%s/roles/%s", ns, name) - case "rolebindings", "rolebinding": - apiPath = fmt.Sprintf("/apis/rbac.authorization.k8s.io/v1/namespaces/%s/rolebindings/%s", ns, name) - default: - c.JSON(400, models.Response{Code: 400, Message: "unsupported resource type: " + resource}); return - } - yamlBytes, err := client.GetResourceYAML(apiPath) - if err != nil { c.JSON(500, models.Response{Code: 500, Message: "获取YAML失败: " + err.Error()}); return } - c.Header("Content-Type", "application/yaml") - c.String(200, string(yamlBytes)) -} - -// UpdateResourceYAMLNS applies YAML to update a K8s resource -func (h *KubernetesHandler) UpdateResourceYAMLNS(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") - 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 } - var apiPath string - switch resource { - case "deployments", "deployment": - apiPath = fmt.Sprintf("/apis/apps/v1/namespaces/%s/deployments/%s", ns, name) - case "statefulsets", "statefulset": - apiPath = fmt.Sprintf("/apis/apps/v1/namespaces/%s/statefulsets/%s", ns, name) - case "daemonsets", "daemonset": - apiPath = fmt.Sprintf("/apis/apps/v1/namespaces/%s/daemonsets/%s", ns, name) - case "replicasets", "replicaset": - apiPath = fmt.Sprintf("/apis/apps/v1/namespaces/%s/replicasets/%s", ns, name) - case "pods", "pod": - apiPath = fmt.Sprintf("/api/v1/namespaces/%s/pods/%s", ns, name) - case "services", "service": - apiPath = fmt.Sprintf("/api/v1/namespaces/%s/services/%s", ns, name) - case "ingresses", "ingress": - apiPath = fmt.Sprintf("/apis/networking.k8s.io/v1/namespaces/%s/ingresses/%s", ns, name) - case "configmaps", "configmap": - apiPath = fmt.Sprintf("/api/v1/namespaces/%s/configmaps/%s", ns, name) - case "secrets", "secret": - apiPath = fmt.Sprintf("/api/v1/namespaces/%s/secrets/%s", ns, name) - case "pvcs", "persistentvolumeclaim": - apiPath = fmt.Sprintf("/api/v1/namespaces/%s/persistentvolumeclaims/%s", ns, name) - case "hpas", "hpa", "horizontalpodautoscaler": - apiPath = fmt.Sprintf("/apis/autoscaling/v2/namespaces/%s/horizontalpodautoscalers/%s", ns, name) - case "cronjobs", "cronjob": - apiPath = fmt.Sprintf("/apis/batch/v1/namespaces/%s/cronjobs/%s", ns, name) - case "serviceaccounts", "serviceaccount": - apiPath = fmt.Sprintf("/api/v1/namespaces/%s/serviceaccounts/%s", ns, name) - case "roles", "role": - apiPath = fmt.Sprintf("/apis/rbac.authorization.k8s.io/v1/namespaces/%s/roles/%s", ns, name) - case "rolebindings", "rolebinding": - apiPath = fmt.Sprintf("/apis/rbac.authorization.k8s.io/v1/namespaces/%s/rolebindings/%s", ns, name) - default: - c.JSON(400, models.Response{Code: 400, Message: "unsupported resource type: " + resource}); return - } - if err := client.UpdateResourceYAML(apiPath, yamlData); err != nil { - c.JSON(500, models.Response{Code: 500, Message: "更新失败: " + err.Error()}); return - } - c.JSON(200, models.Response{Code: 200, Message: "YAML已应用"}) -} - -// resourceAPIPath builds the K8s API path for common resource types (shared by Get/Update/Delete) -func resourceAPIPath(ns, resource, name string) (string, error) { - switch resource { - case "deployments", "deployment": - return fmt.Sprintf("/apis/apps/v1/namespaces/%s/deployments/%s", ns, name), nil - case "statefulsets", "statefulset": - return fmt.Sprintf("/apis/apps/v1/namespaces/%s/statefulsets/%s", ns, name), nil - case "daemonsets", "daemonset": - return fmt.Sprintf("/apis/apps/v1/namespaces/%s/daemonsets/%s", ns, name), nil - case "replicasets", "replicaset": - return fmt.Sprintf("/apis/apps/v1/namespaces/%s/replicasets/%s", ns, name), nil - case "pods", "pod": - return fmt.Sprintf("/api/v1/namespaces/%s/pods/%s", ns, name), nil - case "services", "service": - return fmt.Sprintf("/api/v1/namespaces/%s/services/%s", ns, name), nil - case "ingresses", "ingress": - return fmt.Sprintf("/apis/networking.k8s.io/v1/namespaces/%s/ingresses/%s", ns, name), nil - case "configmaps", "configmap": - return fmt.Sprintf("/api/v1/namespaces/%s/configmaps/%s", ns, name), nil - case "secrets", "secret": - return fmt.Sprintf("/api/v1/namespaces/%s/secrets/%s", ns, name), nil - case "pvcs", "persistentvolumeclaim": - return fmt.Sprintf("/api/v1/namespaces/%s/persistentvolumeclaims/%s", ns, name), nil - case "hpas", "hpa", "horizontalpodautoscaler": - return fmt.Sprintf("/apis/autoscaling/v2/namespaces/%s/horizontalpodautoscalers/%s", ns, name), nil - case "cronjobs", "cronjob": - return fmt.Sprintf("/apis/batch/v1/namespaces/%s/cronjobs/%s", ns, name), nil - case "serviceaccounts", "serviceaccount": - return fmt.Sprintf("/api/v1/namespaces/%s/serviceaccounts/%s", ns, name), nil - case "roles", "role": - return fmt.Sprintf("/apis/rbac.authorization.k8s.io/v1/namespaces/%s/roles/%s", ns, name), nil - case "rolebindings", "rolebinding": - return fmt.Sprintf("/apis/rbac.authorization.k8s.io/v1/namespaces/%s/rolebindings/%s", ns, name), nil - default: - return "", fmt.Errorf("unsupported resource type: %s", resource) - } -} - -// DeleteResourceYAMLNS deletes a K8s resource (Service/Ingress/ConfigMap etc.) -func (h *KubernetesHandler) DeleteResourceYAMLNS(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") - resource := c.Param("resource") - name := c.Param("name") - apiPath, err := resourceAPIPath(ns, resource, name) - if err != nil { c.JSON(400, models.Response{Code: 400, Message: err.Error()}); return } - if err := client.DeleteResource(apiPath); err != nil { - c.JSON(500, models.Response{Code: 500, Message: "删除失败: " + err.Error()}); return - } - c.JSON(200, models.Response{Code: 200, Message: "资源已删除"}) -} - -// getK8sClient builds a K8s API client from cluster's stored kubeconfig func (h *KubernetesHandler) getK8sClient(clusterID int) (*k8sclient.Client, *models.KubernetesCluster, error) { var cluster models.KubernetesCluster if err := h.db.First(&cluster, clusterID).Error; err != nil { @@ -3016,1189 +104,3 @@ func (h *KubernetesHandler) getK8sClient(clusterID int) (*k8sclient.Client, *mod // ============================================================================ // ListPVCsNS lists PersistentVolumeClaims in a namespace (code-based) -func (h *KubernetesHandler) ListPVCsNS(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") - pvcs, err := client.ListPVCs(ns) - if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } - type PVCSummary struct { - Namespace string `json:"namespace"` - Name string `json:"name"` - Phase string `json:"phase"` - Size string `json:"size"` - StorageClass string `json:"storage_class"` - } - result := []PVCSummary{} - for _, p := range pvcs.Items { - size := "" - if p.Status.Capacity != nil { - if s, ok := p.Status.Capacity["storage"]; ok { size = s } - } - result = append(result, PVCSummary{ - Namespace: p.Metadata.Namespace, - Name: p.Metadata.Name, - Phase: p.Status.Phase, - Size: size, - StorageClass: p.Spec.StorageClassName, - }) - } - c.JSON(200, models.Response{Code: 200, Data: result}) -} - -// DeletePVCNS deletes a PVC (code-based) -func (h *KubernetesHandler) DeletePVCNS(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 } - if err := client.DeletePVC(c.Param("namespace"), c.Param("name")); err != nil { - c.JSON(500, models.Response{Code: 500, Message: "删除失败: " + err.Error()}); return - } - c.JSON(200, models.Response{Code: 200, Message: "已删除"}) -} - -// ============================================================================ -// StatefulSet / DaemonSet handlers (code-based) -// ============================================================================ - -// ListStatefulSetsNS lists StatefulSets in a namespace -func (h *KubernetesHandler) ListStatefulSetsNS(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 } - sts, err := client.ListStatefulSets() - if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } - ns := c.Param("namespace") - type StsSummary struct { - Namespace string `json:"namespace"` - Name string `json:"name"` - Replicas int `json:"replicas"` - Ready int `json:"ready"` - Image string `json:"image"` - } - result := []StsSummary{} - for _, s := range sts.Items { - if s.Metadata.Namespace != ns { continue } - image := ""; if len(s.Spec.Template.Spec.Containers) > 0 { image = s.Spec.Template.Spec.Containers[0].Image } - result = append(result, StsSummary{ - Namespace: s.Metadata.Namespace, - Name: s.Metadata.Name, - Replicas: s.Spec.Replicas, - Ready: s.Status.ReadyReplicas, - Image: image, - }) - } - c.JSON(200, models.Response{Code: 200, Data: result}) -} - -// ListDaemonSetsNS lists DaemonSets in a namespace -func (h *KubernetesHandler) ListDaemonSetsNS(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 } - ds, err := client.ListDaemonSets() - if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } - ns := c.Param("namespace") - type DsSummary struct { - Namespace string `json:"namespace"` - Name string `json:"name"` - Desired int `json:"desired"` - Current int `json:"current"` - Ready int `json:"ready"` - Image string `json:"image"` - } - result := []DsSummary{} - for _, d := range ds.Items { - if d.Metadata.Namespace != ns { continue } - image := ""; if len(d.Spec.Template.Spec.Containers) > 0 { image = d.Spec.Template.Spec.Containers[0].Image } - result = append(result, DsSummary{ - Namespace: d.Metadata.Namespace, - Name: d.Metadata.Name, - Desired: d.Status.DesiredNumberScheduled, - Current: d.Status.CurrentNumberScheduled, - Ready: d.Status.NumberReady, - Image: image, - }) - } - 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 -// ============================================================================ - -// GetPodMetrics returns pod-level CPU/Memory metrics -func (h *KubernetesHandler) GetPodMetrics(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.Query("namespace") - metrics, err := client.ListPodMetrics(ns) - if err != nil { c.JSON(500, models.Response{Code: 500, Message: "metrics API error: " + err.Error()}); return } - - type ContainerMetric struct { - Name string `json:"name"` - CPU string `json:"cpu"` - Memory string `json:"memory"` - } - type PodMetric struct { - Namespace string `json:"namespace"` - Name string `json:"name"` - Containers []ContainerMetric `json:"containers"` - } - result := []PodMetric{} - for _, pm := range metrics.Items { - containers := []ContainerMetric{} - for _, c := range pm.Containers { - containers = append(containers, ContainerMetric{c.Name, c.Usage["cpu"], c.Usage["memory"]}) - } - result = append(result, PodMetric{pm.Metadata.Namespace, pm.Metadata.Name, containers}) - } - c.JSON(200, models.Response{Code: 200, Data: result}) -} - -// ============================================================================ -// Events (kubectl get events) -// ============================================================================ - -// ListEvents returns cluster events (optionally filtered by namespace and type) -func (h *KubernetesHandler) ListEvents(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.Query("namespace") - eventType := c.Query("type") // Normal or Warning - events, err := client.ListEvents(ns) - if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } - - type EventSummary struct { - Namespace string `json:"namespace"` - Name string `json:"name"` - Type string `json:"type"` - Reason string `json:"reason"` - Message string `json:"message"` - Object string `json:"object"` - Count int `json:"count"` - FirstTime string `json:"first_time"` - LastTime string `json:"last_time"` - } - result := []EventSummary{} - for _, e := range events.Items { - if eventType != "" && e.Type != eventType { continue } - object := fmt.Sprintf("%s/%s", e.InvolvedObject.Kind, e.InvolvedObject.Name) - if e.InvolvedObject.Namespace != "" { - object = fmt.Sprintf("%s/%s/%s", e.InvolvedObject.Namespace, e.InvolvedObject.Kind, e.InvolvedObject.Name) - } - firstTime := ""; if !e.FirstTimestamp.IsZero() { firstTime = e.FirstTimestamp.Format(time.RFC3339) } - lastTime := ""; if !e.LastTimestamp.IsZero() { lastTime = e.LastTimestamp.Format(time.RFC3339) } - result = append(result, EventSummary{ - Namespace: e.Metadata.Namespace, Name: e.Metadata.Name, Type: e.Type, Reason: e.Reason, - Message: e.Message, Object: object, Count: int(e.Count), - FirstTime: firstTime, LastTime: lastTime, - }) - } - c.JSON(200, models.Response{Code: 200, Data: result}) -} - -// ============================================================================ -// Event Aggregation — 事件聚合统计 (M12) -// ============================================================================ - -// GetEventsSummary returns aggregated event statistics for dashboard -func (h *KubernetesHandler) GetEventsSummary(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.Query("namespace") - events, err := client.ListEvents(ns) - if err != nil { - c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) - return - } - - // Time window filter (hours) - timeWindowHours := 0 - if tw := c.Query("hours"); tw != "" { - if v, err := strconv.Atoi(tw); err == nil && v > 0 { - timeWindowHours = v - } - } - now := time.Now() - cutoff := time.Time{} - if timeWindowHours > 0 { - cutoff = now.Add(-time.Duration(timeWindowHours) * time.Hour) - } - - // Aggregation maps - totalCount := 0 - warningCount := 0 - normalCount := 0 - byReason := map[string]int{} - byNamespace := map[string]int{} - byKind := map[string]int{} - recentWarnings := []map[string]interface{}{} - - for _, e := range events.Items { - // Time window filter - if !cutoff.IsZero() && !e.LastTimestamp.IsZero() && e.LastTimestamp.Before(cutoff) { - continue - } - - totalCount++ - count := int(e.Count) - if count == 0 { - count = 1 - } - - nsName := e.Metadata.Namespace - if nsName == "" { - nsName = e.InvolvedObject.Namespace - } - - if e.Type == "Warning" { - warningCount += count - byReason[e.Reason] += count - byNamespace[nsName] += count - byKind[e.InvolvedObject.Kind] += count - - // Collect recent warning events (top 10 by lastTimestamp) - if len(recentWarnings) < 50 { - recentWarnings = append(recentWarnings, map[string]interface{}{ - "namespace": nsName, - "type": e.Type, - "reason": e.Reason, - "message": e.Message, - "object": fmt.Sprintf("%s/%s", e.InvolvedObject.Kind, e.InvolvedObject.Name), - "count": count, - "last_time": e.LastTimestamp.Format(time.RFC3339), - "first_time": e.FirstTimestamp.Format(time.RFC3339), - }) - } - } else { - normalCount += count - } - } - - // Sort byReason by count desc → top reasons - type ReasonStat struct { - Reason string `json:"reason"` - Count int `json:"count"` - } - reasonList := make([]ReasonStat, 0, len(byReason)) - for r, cnt := range byReason { - reasonList = append(reasonList, ReasonStat{Reason: r, Count: cnt}) - } - sort.Slice(reasonList, func(i, j int) bool { return reasonList[i].Count > reasonList[j].Count }) - if len(reasonList) > 15 { - reasonList = reasonList[:15] - } - - // Sort byNamespace - type NsStat struct { - Namespace string `json:"namespace"` - Count int `json:"count"` - } - nsList := make([]NsStat, 0, len(byNamespace)) - for n, cnt := range byNamespace { - nsList = append(nsList, NsStat{Namespace: n, Count: cnt}) - } - sort.Slice(nsList, func(i, j int) bool { return nsList[i].Count > nsList[j].Count }) - if len(nsList) > 15 { - nsList = nsList[:15] - } - - // Sort byKind - type KindStat struct { - Kind string `json:"kind"` - Count int `json:"count"` - } - kindList := make([]KindStat, 0, len(byKind)) - for k, cnt := range byKind { - kindList = append(kindList, KindStat{Kind: k, Count: cnt}) - } - sort.Slice(kindList, func(i, j int) bool { return kindList[i].Count > kindList[j].Count }) - - // Sort recentWarnings by last_time desc - sort.Slice(recentWarnings, func(i, j int) bool { - ti, _ := recentWarnings[i]["last_time"].(string) - tj, _ := recentWarnings[j]["last_time"].(string) - return ti > tj - }) - if len(recentWarnings) > 10 { - recentWarnings = recentWarnings[:10] - } - - result := map[string]interface{}{ - "total": totalCount, - "warning_count": warningCount, - "normal_count": normalCount, - "by_reason": reasonList, - "by_namespace": nsList, - "by_kind": kindList, - "recent_warnings": recentWarnings, - } - - c.JSON(200, models.Response{Code: 200, Data: result}) -} - -// ============================================================================ -// Capacity Analysis — CPU/Memory request/limit per Deployment -// ============================================================================ - -// GetCapacityAnalysis returns resource analysis for all deployments -func (h *KubernetesHandler) GetCapacityAnalysis(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.Query("namespace") - deps, err := client.ListDeployments() - if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } - - type ContainerResource struct { - Name string `json:"name"` - CPUReq string `json:"cpu_request"` - CPULim string `json:"cpu_limit"` - MemReq string `json:"mem_request"` - MemLim string `json:"mem_limit"` - } - type CapacityRow struct { - Namespace string `json:"namespace"` - Name string `json:"name"` - Replicas int `json:"replicas"` - Containers []ContainerResource `json:"containers"` - } - result := []CapacityRow{} - for _, d := range deps.Items { - if ns != "" && d.Metadata.Namespace != ns { continue } - containers := []ContainerResource{} - for _, c := range d.Spec.Template.Spec.Containers { - cpuReq := c.Resources.Requests["cpu"] - cpuLim := c.Resources.Limits["cpu"] - memReq := c.Resources.Requests["memory"] - memLim := c.Resources.Limits["memory"] - containers = append(containers, ContainerResource{c.Name, cpuReq, cpuLim, memReq, memLim}) - } - result = append(result, CapacityRow{d.Metadata.Namespace, d.Metadata.Name, d.Spec.Replicas, containers}) - } - c.JSON(200, models.Response{Code: 200, Data: result}) -} - -// ============================================================================ -// YAML Apply (kubectl apply -f) -// ============================================================================ - -// ApplyYAML applies a YAML manifest to create or update a resource -func (h *KubernetesHandler) ApplyYAML(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 } - - var req struct { - YAML string `json:"yaml"` - } - if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, models.Response{Code: 400, Message: "参数错误: " + err.Error()}); return } - if req.YAML == "" { c.JSON(400, models.Response{Code: 400, Message: "YAML 内容不能为空"}); return } - - result, err := client.ApplyYAML(req.YAML) - if err != nil { c.JSON(500, models.Response{Code: 500, Message: "Apply 失败: " + err.Error()}); return } - - c.JSON(200, models.Response{Code: 200, Message: "资源已应用", Data: result}) -} - -// ============================================================================ -// ReplicaSet 列表 / Deployment 回滚 / 历史版本 // Logan.Li -// ============================================================================ - -// ListReplicaSetsNS 列出 namespace 下的 ReplicaSet -func (h *KubernetesHandler) ListReplicaSetsNS(c *gin.Context) { - h.listReplicaSetsWithNS(c, c.Param("namespace")) -} -func (h *KubernetesHandler) listReplicaSetsWithNS(c *gin.Context, ns string) { - 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 } - items, err := client.ListReplicaSetsRaw(ns) - if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } - type RSSummary struct { - Namespace string `json:"namespace"` - Name string `json:"name"` - Replicas int `json:"replicas"` - AvailableReplicas int `json:"available_replicas"` - ReadyReplicas int `json:"ready_replicas"` - Image string `json:"image"` - CreatedAt string `json:"created_at"` - Revision string `json:"revision"` - OwnerDeployment string `json:"owner_deployment"` - } - result := []RSSummary{} - for _, rs := range items { - image := "" - if len(rs.Spec.Template.Spec.Containers) > 0 { image = rs.Spec.Template.Spec.Containers[0].Image } - revision := rs.Metadata.Annotations["deployment.kubernetes.io/revision"] - owner := "" - for _, o := range rs.Metadata.OwnerReferences { - if o.Kind == "Deployment" { owner = o.Name; break } - } - result = append(result, RSSummary{ - Namespace: rs.Metadata.Namespace, - Name: rs.Metadata.Name, - Replicas: rs.Spec.Replicas, - AvailableReplicas: rs.Status.AvailableReplicas, - ReadyReplicas: rs.Status.ReadyReplicas, - Image: image, - CreatedAt: rs.Metadata.CreationTimestamp, - Revision: revision, - OwnerDeployment: owner, - }) - } - c.JSON(200, models.Response{Code: 200, Message: "success", Data: result}) -} - -// ListDeploymentRevisionsNS Deployment 发布历史(按 revision 倒序) -func (h *KubernetesHandler) ListDeploymentRevisionsNS(c *gin.Context) { - h.listDeploymentRevisionsWithNS(c, c.Param("namespace")) -} -func (h *KubernetesHandler) listDeploymentRevisionsWithNS(c *gin.Context, ns string) { - clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } - deploymentName := c.Param("name") - if deploymentName == "" { c.JSON(400, models.Response{Code: 400, Message: "缺少 deployment name"}); return } - client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } - items, err := client.ListReplicaSetsRaw(ns) - if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } - type Revision struct { - Revision string `json:"revision"` - ReplicaSet string `json:"replica_set"` - Replicas int `json:"replicas"` - Image string `json:"image"` - CreatedAt string `json:"created_at"` - Current bool `json:"current"` - } - result := []Revision{} - for _, rs := range items { - isOwner := false - for _, o := range rs.Metadata.OwnerReferences { - if o.Kind == "Deployment" && o.Name == deploymentName { isOwner = true; break } - } - if !isOwner { continue } - image := "" - if len(rs.Spec.Template.Spec.Containers) > 0 { image = rs.Spec.Template.Spec.Containers[0].Image } - result = append(result, Revision{ - Revision: rs.Metadata.Annotations["deployment.kubernetes.io/revision"], - ReplicaSet: rs.Metadata.Name, - Replicas: rs.Spec.Replicas, - Image: image, - CreatedAt: rs.Metadata.CreationTimestamp, - Current: rs.Spec.Replicas > 0, - }) - } - // 按 revision 数字倒序(最新在前) - sort.Slice(result, func(i, j int) bool { - ri, _ := strconv.Atoi(result[i].Revision) - rj, _ := strconv.Atoi(result[j].Revision) - return ri > rj - }) - c.JSON(200, models.Response{Code: 200, Message: "success", Data: result}) -} - -// RollbackDeploymentNS 回滚 Deployment 到指定 revision 或指定 ReplicaSet -func (h *KubernetesHandler) RollbackDeploymentNS(c *gin.Context) { - h.rollbackDeploymentWithNS(c, c.Param("namespace")) -} -func (h *KubernetesHandler) rollbackDeploymentWithNS(c *gin.Context, ns string) { - clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } - deploymentName := c.Param("name") - if deploymentName == "" { c.JSON(400, models.Response{Code: 400, Message: "缺少 deployment name"}); return } - var req struct { - Revision int `json:"revision"` - ReplicaSet string `json:"replicaSet"` - } - _ = c.ShouldBindJSON(&req) // 允许空 body(但下面会校验) - client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } - - targetRS := req.ReplicaSet - // 通过 revision 定位 ReplicaSet - if targetRS == "" && req.Revision > 0 { - items, err := client.ListReplicaSetsRaw(ns) - if err != nil { c.JSON(500, models.Response{Code: 500, Message: "获取 ReplicaSet 列表失败: " + err.Error()}); return } - for _, rs := range items { - isOwner := false - for _, o := range rs.Metadata.OwnerReferences { - if o.Kind == "Deployment" && o.Name == deploymentName { isOwner = true; break } - } - if !isOwner { continue } - if rs.Metadata.Annotations["deployment.kubernetes.io/revision"] == strconv.Itoa(req.Revision) { - targetRS = rs.Metadata.Name - break - } - } - if targetRS == "" { - c.JSON(404, models.Response{Code: 404, Message: fmt.Sprintf("revision %d 的 ReplicaSet 未找到", req.Revision)}) - return - } - } - if targetRS == "" { c.JSON(400, models.Response{Code: 400, Message: "需要 revision 或 replicaSet 参数"}); return } - - // 取 ReplicaSet 的 template,patch 到 Deployment.spec.template 实现回滚 - rsJSON, err := client.GetReplicaSet(ns, targetRS) - if err != nil { c.JSON(500, models.Response{Code: 500, Message: "获取 ReplicaSet 失败: " + err.Error()}); return } - specMap, ok := rsJSON["spec"].(map[string]interface{}) - if !ok { c.JSON(500, models.Response{Code: 500, Message: "ReplicaSet 缺少 spec"}); return } - template, ok := specMap["template"].(map[string]interface{}) - if !ok { c.JSON(500, models.Response{Code: 500, Message: "ReplicaSet 缺少 template"}); return } - - if err := client.PatchDeploymentSpecTemplate(ns, deploymentName, template); err != nil { - c.JSON(500, models.Response{Code: 500, Message: "回滚失败: " + err.Error()}); return - } - c.JSON(200, models.Response{ - Code: 200, - Message: fmt.Sprintf("Deployment %s 已回滚到 %s", deploymentName, targetRS), - Data: gin.H{"replicaSet": targetRS, "revision": req.Revision}, - }) -} - -// ============================================================================ -// Pod 删除 // Logan.Li -// ============================================================================ - -// DeletePodNS 删除指定 Pod -func (h *KubernetesHandler) DeletePodNS(c *gin.Context) { - h.deletePodWithNS(c, c.Param("namespace")) -} -func (h *KubernetesHandler) deletePodWithNS(c *gin.Context, ns string) { - clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } - podName := c.Param("podName") - if podName == "" { c.JSON(400, models.Response{Code: 400, Message: "缺少 podName"}); return } - client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } - if err := client.DeletePod(ns, podName); err != nil { - c.JSON(500, models.Response{Code: 500, Message: "删除 Pod 失败: " + err.Error()}); return - } - c.JSON(200, models.Response{Code: 200, Message: fmt.Sprintf("Pod %s 已删除", podName)}) -} - -// ============================================================================ -// Pod 文件列表 / 下载(复用 exec 能力) // Logan.Li -// ============================================================================ - -// cleanExecOutput 清理 exec 输出中残留的 SPDY 帧头等非打印字节(best-effort) -func cleanExecOutput(raw string) string { - var b strings.Builder - for _, r := range raw { - if r == '\n' || r == '\r' || r == ' ' || (r >= 0x20 && r < 0x7F) { - b.WriteRune(r) - } - } - return b.String() -} - -// ListPodFilesNS 列出 Pod 内指定路径的文件 -func (h *KubernetesHandler) ListPodFilesNS(c *gin.Context) { - h.listPodFilesWithNS(c, c.Param("namespace")) -} -func (h *KubernetesHandler) listPodFilesWithNS(c *gin.Context, ns string) { - clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } - podName := c.Param("podName") - var req struct { - Path string `json:"path"` - Container string `json:"container"` - } - if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, models.Response{Code: 400, Message: "参数错误: " + err.Error()}); return } - if req.Path == "" { req.Path = "/" } - client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } - // ls -la — BusyBox ignores --time-style, use default "Mon DD HH:MM" format - cmd := []string{"ls", "-la", req.Path} - result, err := client.ExecInPod(ns, podName, req.Container, cmd) - if err != nil { c.JSON(500, models.Response{Code: 500, Message: "执行 ls 失败: " + err.Error()}); return } - - type FileInfo struct { - Name string `json:"name"` - Size string `json:"size"` - Mode string `json:"mode"` - ModTime string `json:"mod_time"` - IsDir bool `json:"is_dir"` - } - files := []FileInfo{} - output := result.Stdout // WebSocket output is already clean text - for _, line := range strings.Split(output, "\n") { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "total ") { continue } - fields := strings.Fields(line) - if len(fields) < 7 { continue } - mode := fields[0] - if len(mode) == 0 || (mode[0] != 'd' && mode[0] != '-' && mode[0] != 'l') { continue } - size := fields[4] - // BusyBox ls date format: "Jun 23 02:17" (3 fields: month day time) - // GNU ls --time-style: "2026-06-23T02:17:00" (1 field) - // Detect format: if fields[5] contains ":" it's time-only (BusyBox style) - var name, modTime string - if strings.Contains(fields[5], ":") || strings.Contains(fields[5], "T") { - // GNU style: single timestamp field - modTime = fields[5] - name = strings.Join(fields[6:], " ") - } else { - // BusyBox style: "Mon DD HH:MM" = 3 fields starting at index 5 - modTime = strings.Join(fields[5:8], " ") - name = strings.Join(fields[8:], " ") - } - if name == "" || name == "." || name == ".." { continue } - files = append(files, FileInfo{ - Name: name, - Size: size, - Mode: mode, - ModTime: modTime, - IsDir: mode[0] == 'd', - }) - } - c.JSON(200, models.Response{Code: 200, Message: "success", Data: gin.H{"path": req.Path, "files": files}}) -} - -// DownloadPodFileNS 下载 Pod 内指定路径的文件(通过 exec cat) -func (h *KubernetesHandler) DownloadPodFileNS(c *gin.Context) { - h.downloadPodFileWithNS(c, c.Param("namespace")) -} -func (h *KubernetesHandler) downloadPodFileWithNS(c *gin.Context, ns string) { - clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } - podName := c.Param("podName") - filePath := c.Query("path") - container := c.Query("container") - if filePath == "" { c.JSON(400, models.Response{Code: 400, Message: "path 参数不能为空"}); return } - client, _, err := h.getK8sClient(clusterID) - if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } - cmd := []string{"cat", filePath} - result, err := client.ExecInPod(ns, podName, container, cmd) - if err != nil { c.JSON(500, models.Response{Code: 500, Message: "读取文件失败: " + err.Error()}); return } - content := result.Stdout - fileName := path.Base(filePath) - c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileName)) - c.Header("Content-Type", "application/octet-stream") - c.String(200, content) -} - -// UploadPodFileNS 上传文件到 Pod 容器内指定目录(通过 exec base64 分块写入) -func (h *KubernetesHandler) UploadPodFileNS(c *gin.Context) { - h.uploadPodFileWithNS(c, c.Param("namespace")) -} -func (h *KubernetesHandler) uploadPodFileWithNS(c *gin.Context, ns string) { - clusterID, _ := strconv.Atoi(c.Param("id")) - if clusterID == 0 { - clusterID = h.resolveClusterCode(c.Param("clusterCode")) - } - podName := c.Param("podName") - container := c.PostForm("container") - targetDir := c.PostForm("path") - if targetDir == "" { - targetDir = "/" - } - - // 读取上传的文件 - file, header, err := c.Request.FormFile("file") - if err != nil { - c.JSON(400, models.Response{Code: 400, Message: "文件上传失败: " + err.Error()}) - return - } - defer file.Close() - - // 限制文件大小 10MB - const maxUploadSize = 10 << 20 // 10MB - if header.Size > maxUploadSize { - c.JSON(400, models.Response{Code: 400, Message: fmt.Sprintf("文件过大: %d bytes, 上限 %d bytes", header.Size, maxUploadSize)}) - return - } - - content, err := io.ReadAll(file) - if err != nil { - c.JSON(500, models.Response{Code: 500, Message: "读取文件失败: " + err.Error()}) - return - } - - client, _, err := h.getK8sClient(clusterID) - if err != nil { - c.JSON(500, models.Response{Code: 500, Message: err.Error()}) - return - } - - // 目标完整路径 - targetFile := strings.TrimRight(targetDir, "/") + "/" + header.Filename - - // 确保目标目录存在 - _, _ = client.ExecInPod(ns, podName, container, []string{"mkdir", "-p", targetDir}) - - // base64 分块写入:每块 3000 字节 base64 (~2250 bytes 二进制),保证 URL 长度安全 - encoded := base64.StdEncoding.EncodeToString(content) - const chunkSize = 3000 - first := true - for i := 0; i < len(encoded); i += chunkSize { - end := i + chunkSize - if end > len(encoded) { - end = len(encoded) - } - chunk := encoded[i:end] - var cmd []string - if first { - // 首块:创建/覆盖文件 - cmd = []string{"sh", "-c", fmt.Sprintf("echo -n '%s' | base64 -d > '%s'", chunk, targetFile)} - first = false - } else { - // 后续块:追加 - cmd = []string{"sh", "-c", fmt.Sprintf("echo -n '%s' | base64 -d >> '%s'", chunk, targetFile)} - } - result, err := client.ExecInPod(ns, podName, container, cmd) - if err != nil { - stderr := "" - if result != nil { - stderr = result.Stderr - } - c.JSON(500, models.Response{Code: 500, Message: fmt.Sprintf("写入文件失败 (chunk %d): %s\n%s", i/chunkSize, err.Error(), stderr)}) - return - } - } - - // 验证文件大小 - verifyResult, verifyErr := client.ExecInPod(ns, podName, container, []string{"stat", "-c", "%s", targetFile}) - writtenSize := "" - if verifyErr == nil && verifyResult != nil { - writtenSize = strings.TrimSpace(verifyResult.Stdout) - } - - log.Printf("[UploadPodFile] %s/%s → %s (%d bytes, %d chunks)", ns, podName, targetFile, len(content), (len(encoded)+chunkSize-1)/chunkSize) - - c.JSON(200, models.Response{ - Code: 200, - Message: "上传成功", - Data: gin.H{ - "path": targetFile, - "name": header.Filename, - "size": len(content), - "verify": writtenSize, - }, - }) -} - -// ============================================================================ -// M13: 多集群资源对比看板 (Multi-Cluster Comparison Dashboard) // Logan.Li -// ============================================================================ - -// GetMultiClusterComparison 聚合所有集群的资源数据进行对比 -func (h *KubernetesHandler) GetMultiClusterComparison(c *gin.Context) { - // 1. 获取所有集群 - var clusters []models.KubernetesCluster - h.db.Find(&clusters) - - type ClusterComparison struct { - ID uint `json:"id"` - Name string `json:"name"` - Code string `json:"code"` - Status string `json:"status"` - Version string `json:"version"` - NodeCount int `json:"node_count"` - PodCount int `json:"pod_count"` - DeploymentCount int `json:"deployment_count"` - ServiceCount int `json:"service_count"` - CPUUsage float64 `json:"cpu_usage"` - MemoryUsage float64 `json:"memory_usage"` - APIServer string `json:"api_server"` - LastSyncAt *time.Time `json:"last_sync_at"` - - // Node-level aggregated data from kubernetes_nodes table - TotalCPUCores int `json:"total_cpu_cores"` - TotalMemoryMB int64 `json:"total_memory_mb"` - MasterCount int `json:"master_count"` - WorkerCount int `json:"worker_count"` - HealthyNodes int `json:"healthy_nodes"` - UnhealthyNodes int `json:"unhealthy_nodes"` - } - - // 2. 获取所有节点的聚合数据(按 cluster_id 分组) - type NodeAgg struct { - ClusterID uint - NodeCount int - MasterCount int - WorkerCount int - HealthyNodes int - UnhealthyNodes int - TotalCPU int - TotalMemory int64 - AvgCPUUsage float64 - AvgMemUsage float64 - } - nodeAggMap := map[uint]*NodeAgg{} - { - var nodes []models.KubernetesNode - h.db.Find(&nodes) - for _, n := range nodes { - agg, ok := nodeAggMap[n.ClusterID] - if !ok { - agg = &NodeAgg{ClusterID: n.ClusterID} - nodeAggMap[n.ClusterID] = agg - } - agg.NodeCount++ - agg.TotalCPU += n.CPU - agg.TotalMemory += n.Memory - if n.CPUUsage > 0 || n.MemoryUsage > 0 { - if agg.AvgCPUUsage == 0 { agg.AvgCPUUsage = n.CPUUsage } else { agg.AvgCPUUsage = (agg.AvgCPUUsage*float64(agg.NodeCount-1) + n.CPUUsage) / float64(agg.NodeCount) } - if agg.AvgMemUsage == 0 { agg.AvgMemUsage = n.MemoryUsage } else { agg.AvgMemUsage = (agg.AvgMemUsage*float64(agg.NodeCount-1) + n.MemoryUsage) / float64(agg.NodeCount) } - } - if n.Role == "master" { - agg.MasterCount++ - } else { - agg.WorkerCount++ - } - if n.Status == "Ready" { - agg.HealthyNodes++ - } else { - agg.UnhealthyNodes++ - } - } - } - - // 3. 构建对比数据 - comparisons := []ClusterComparison{} - totalNodes, totalPods, totalDeployments, totalServices := 0, 0, 0, 0 - for _, cl := range clusters { - cc := ClusterComparison{ - ID: cl.ID, - Name: cl.Name, - Code: cl.Code, - Status: cl.Status, - Version: cl.Version, - NodeCount: cl.NodeCount, - PodCount: cl.PodCount, - DeploymentCount: cl.DeploymentCount, - ServiceCount: cl.ServiceCount, - CPUUsage: cl.CPUUsage, - MemoryUsage: cl.MemoryUsage, - APIServer: cl.APIServer, - LastSyncAt: cl.LastSyncAt, - } - // 优先用 node 级别的实时聚合数据 - if agg, ok := nodeAggMap[cl.ID]; ok && agg.NodeCount > 0 { - cc.TotalCPUCores = agg.TotalCPU - cc.TotalMemoryMB = agg.TotalMemory - cc.MasterCount = agg.MasterCount - cc.WorkerCount = agg.WorkerCount - cc.HealthyNodes = agg.HealthyNodes - cc.UnhealthyNodes = agg.UnhealthyNodes - // 如果集群级别的 cpu_usage 为 0,使用节点平均值 - if cc.CPUUsage == 0 && agg.AvgCPUUsage > 0 { - cc.CPUUsage = agg.AvgCPUUsage - } - if cc.MemoryUsage == 0 && agg.AvgMemUsage > 0 { - cc.MemoryUsage = agg.AvgMemUsage - } - } - totalNodes += cc.NodeCount - totalPods += cc.PodCount - totalDeployments += cc.DeploymentCount - totalServices += cc.ServiceCount - comparisons = append(comparisons, cc) - } - - // 4. 返回结果 - c.JSON(200, models.Response{ - Code: 200, - Message: "success", - Data: gin.H{ - "clusters": comparisons, - "totals": gin.H{ - "cluster_count": len(clusters), - "node_count": totalNodes, - "pod_count": totalPods, - "deployment_count": totalDeployments, - "service_count": totalServices, - }, - }, - }) -} - -// ============================================================================ -// RBAC — ServiceAccount / Role / RoleBinding (code-based) -// ============================================================================ - -// ListServiceAccountsNS lists ServiceAccounts in a namespace -func (h *KubernetesHandler) ListServiceAccountsNS(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 - } - sas, err := client.ListServiceAccounts(c.Param("namespace")) - if err != nil { - c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) - return - } - type SASummary struct { - Namespace string `json:"namespace"` - Name string `json:"name"` - SecretCount int `json:"secret_count"` - ImagePullSecrets []string `json:"image_pull_secrets"` - Age string `json:"age"` - } - result := make([]SASummary, 0, len(sas)) - for _, sa := range sas { - ipsNames := make([]string, 0, len(sa.ImagePullSecrets)) - for _, ips := range sa.ImagePullSecrets { - ipsNames = append(ipsNames, ips.Name) - } - result = append(result, SASummary{ - Namespace: sa.Metadata.Namespace, - Name: sa.Metadata.Name, - SecretCount: len(sa.Secrets), - ImagePullSecrets: ipsNames, - Age: formatAge(sa.Metadata.CreationTimestamp), - }) - } - c.JSON(200, models.Response{Code: 200, Data: result}) -} - -// ListRolesNS lists Roles in a namespace -func (h *KubernetesHandler) ListRolesNS(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 - } - roles, err := client.ListRoles(c.Param("namespace")) - if err != nil { - c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) - return - } - type RoleSummary struct { - Namespace string `json:"namespace"` - Name string `json:"name"` - RuleCount int `json:"rule_count"` - Verbs []string `json:"verbs"` - Resources []string `json:"resources"` - Age string `json:"age"` - } - result := make([]RoleSummary, 0, len(roles.Items)) - for _, r := range roles.Items { - allVerbs := []string{} - allResources := []string{} - seen := map[string]bool{} - for _, rule := range r.Rules { - for _, v := range rule.Verbs { - if !seen[v] { - allVerbs = append(allVerbs, v) - seen[v] = true - } - } - for _, res := range rule.Resources { - if !seen[res] { - allResources = append(allResources, res) - seen[res] = true - } - } - } - result = append(result, RoleSummary{ - Namespace: r.Metadata.Namespace, - Name: r.Metadata.Name, - RuleCount: len(r.Rules), - Verbs: allVerbs, - Resources: allResources, - Age: formatAge(r.Metadata.CreationTimestamp), - }) - } - c.JSON(200, models.Response{Code: 200, Data: result}) -} - -// ListRoleBindingsNS lists RoleBindings in a namespace -func (h *KubernetesHandler) ListRoleBindingsNS(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 - } - rbs, err := client.ListRoleBindings(c.Param("namespace")) - if err != nil { - c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) - return - } - type RBSummary struct { - Namespace string `json:"namespace"` - Name string `json:"name"` - RoleRefKind string `json:"role_ref_kind"` - RoleRefName string `json:"role_ref_name"` - Subjects []string `json:"subjects"` - Age string `json:"age"` - } - result := make([]RBSummary, 0, len(rbs.Items)) - for _, rb := range rbs.Items { - subjStrs := make([]string, 0, len(rb.Subjects)) - for _, s := range rb.Subjects { - if s.Namespace != "" && s.Namespace != rb.Metadata.Namespace { - subjStrs = append(subjStrs, fmt.Sprintf("%s/%s (%s)", s.Kind, s.Name, s.Namespace)) - } else { - subjStrs = append(subjStrs, fmt.Sprintf("%s/%s", s.Kind, s.Name)) - } - } - result = append(result, RBSummary{ - Namespace: rb.Metadata.Namespace, - Name: rb.Metadata.Name, - RoleRefKind: rb.RoleRef.Kind, - RoleRefName: rb.RoleRef.Name, - Subjects: subjStrs, - Age: formatAge(rb.Metadata.CreationTimestamp), - }) - } - c.JSON(200, models.Response{Code: 200, Data: result}) -}