diff --git a/backend/internal/api/handlers/k8s_export.go b/backend/internal/api/handlers/k8s_export.go new file mode 100644 index 0000000..657f5a5 --- /dev/null +++ b/backend/internal/api/handlers/k8s_export.go @@ -0,0 +1,244 @@ +package handlers + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "fmt" + "io" + "log" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "gopkg.in/yaml.v3" +) + +// yamlEntry 表示一个待打包的 YAML 资源 +type yamlEntry struct { + folder string // tar 内子目录 + name string // 文件名(不含 .yaml) + yaml []byte // 清理后的 YAML +} + +// ExportResourcesNS 导出指定命名空间的 Deployment + Service + Ingress + ConfigMap + Secret 为 tar.gz +func (h *KubernetesHandler) ExportResourcesNS(c *gin.Context) { + clusterID := h.resolveClusterCode(c.Param("clusterCode")) + if clusterID == 0 { + c.JSON(500, gin.H{"code": 500, "message": "cluster not found"}) + return + } + client, _, err := h.getK8sClient(clusterID) + if err != nil { + c.JSON(500, gin.H{"code": 500, "message": err.Error()}) + return + } + ns := c.Param("namespace") + if ns == "" { + c.JSON(400, gin.H{"code": 400, "message": "namespace is required"}) + return + } + + // 资源类型 → API 路径模板 + 子目录名 + type resourceDef struct { + apiPathTmpl string // %s = namespace + folder string // tar 内子目录 + kind string + apiVersion string + } + resourceDefs := []resourceDef{ + {"/apis/apps/v1/namespaces/%s/deployments", "deployments", "Deployment", "apps/v1"}, + {"/api/v1/namespaces/%s/services", "services", "Service", "v1"}, + {"/apis/networking.k8s.io/v1/namespaces/%s/ingresses", "ingresses", "Ingress", "networking.k8s.io/v1"}, + {"/api/v1/namespaces/%s/configmaps", "configmaps", "ConfigMap", "v1"}, + {"/api/v1/namespaces/%s/secrets", "secrets", "Secret", "v1"}, + } + + // 收集所有资源的 YAML + var entries []yamlEntry + totalCount := 0 + + for _, rd := range resourceDefs { + listPath := fmt.Sprintf(rd.apiPathTmpl, ns) + yamlBytes, err := client.GetResourceYAML(listPath) + if err != nil { + log.Printf("[ExportResources] list %s failed: %v", rd.folder, err) + continue + } + + // 解析 YAML list(K8s List 返回 kind: XxxList) + var listObj map[string]interface{} + if err := yaml.Unmarshal(yamlBytes, &listObj); err != nil { + log.Printf("[ExportResources] parse %s list failed: %v", rd.folder, err) + continue + } + + items, ok := listObj["items"].([]interface{}) + if !ok || len(items) == 0 { + continue + } + + for _, item := range items { + itemMap, ok := item.(map[string]interface{}) + if !ok { + continue + } + + // 获取资源名 + meta, _ := itemMap["metadata"].(map[string]interface{}) + name, _ := meta["name"].(string) + if name == "" { + continue + } + + // 设置 apiVersion 和 kind + itemMap["apiVersion"] = rd.apiVersion + itemMap["kind"] = rd.kind + + // 清理运行时字段 + cleanResourceYAML(itemMap) + + // 序列化为 YAML + cleanedBytes, err := yaml.Marshal(itemMap) + if err != nil { + log.Printf("[ExportResources] marshal %s/%s failed: %v", rd.folder, name, err) + continue + } + + entries = append(entries, yamlEntry{ + folder: rd.folder, + name: name, + yaml: cleanedBytes, + }) + totalCount++ + } + } + + if totalCount == 0 { + c.JSON(404, gin.H{"code": 404, "message": "命名空间 " + ns + " 没有可导出的资源"}) + return + } + + // 构建 tar.gz + tarGz, err := buildTarGz(entries, ns) + if err != nil { + c.JSON(500, gin.H{"code": 500, "message": "打包失败: " + err.Error()}) + return + } + + filename := fmt.Sprintf("%s-%s.tar.gz", ns, time.Now().Format("20060102-150405")) + c.Header("Content-Type", "application/gzip") + c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + c.Header("Content-Length", fmt.Sprintf("%d", len(tarGz))) + c.Data(http.StatusOK, "application/gzip", tarGz) + + log.Printf("[ExportResources] exported %d resources from namespace %s (cluster %d)", totalCount, ns, clusterID) +} + +// cleanResourceYAML 清理 K8s 资源中的运行时字段(通用,适用于所有资源类型) +func cleanResourceYAML(obj map[string]interface{}) { + // 清理 metadata + if meta, ok := obj["metadata"].(map[string]interface{}); ok { + delete(meta, "managedFields") + delete(meta, "creationTimestamp") + delete(meta, "resourceVersion") + delete(meta, "uid") + delete(meta, "selfLink") + delete(meta, "generation") + delete(meta, "ownerReferences") + delete(meta, "finalizers") + delete(meta, "generateName") + + // 清理运行时 annotations + if annotations, ok := meta["annotations"].(map[string]interface{}); ok { + cleanRuntimeAnnotations(annotations) + if len(annotations) == 0 { + delete(meta, "annotations") + } + } + } + + // 删除 status + delete(obj, "status") + + // 清理 spec 中的运行时字段 + if spec, ok := obj["spec"].(map[string]interface{}); ok { + // Service: 移除动态分配的 clusterIP 和 nodePorts + if obj["kind"] == "Service" { + delete(spec, "clusterIP") + delete(spec, "clusterIPs") + // nodePorts 在 ports 数组内 — 仅移除 NodePort 类型的 nodePort 字段 + if ports, ok := spec["ports"].([]interface{}); ok { + for _, p := range ports { + if pm, ok := p.(map[string]interface{}); ok { + delete(pm, "nodePort") + } + } + } + } + + // Deployment: 清理 template.metadata 和 template.spec 中的运行时字段 + if obj["kind"] == "Deployment" { + if template, ok := spec["template"].(map[string]interface{}); ok { + if tMeta, ok := template["metadata"].(map[string]interface{}); ok { + delete(tMeta, "creationTimestamp") + if tAnn, ok := tMeta["annotations"].(map[string]interface{}); ok { + cleanRuntimeAnnotations(tAnn) + if len(tAnn) == 0 { + delete(tMeta, "annotations") + } + } + } + if tSpec, ok := template["spec"].(map[string]interface{}); ok { + delete(tSpec, "nodeName") + } + } + } + } +} + +// cleanRuntimeAnnotations 移除 K8s 系统/运行时 annotations +func cleanRuntimeAnnotations(annotations map[string]interface{}) { + runtimeKeys := []string{ + "kubectl.kubernetes.io/last-applied-configuration", + "deployment.kubernetes.io/revision", + "control-plane.alpha.kubernetes.io/leader", + "autoscaling.alpha.kubernetes.io/conditions", + "pv.kubernetes.io/bind-completed", + "pv.kubernetes.io/bound-by-controller", + } + for _, k := range runtimeKeys { + delete(annotations, k) + } +} + +// buildTarGz 构建 tar.gz 包 +func buildTarGz(entries []yamlEntry, namespace string) ([]byte, error) { + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + for _, e := range entries { + // 路径: {namespace}/{folder}/{name}.yaml + path := fmt.Sprintf("%s/%s/%s.yaml", namespace, e.folder, e.name) + header := &tar.Header{ + Name: path, + Mode: 0644, + Size: int64(len(e.yaml)), + } + if err := tw.WriteHeader(header); err != nil { + return nil, fmt.Errorf("tar write header %s: %w", path, err) + } + if _, err := io.Copy(tw, bytes.NewReader(e.yaml)); err != nil { + return nil, fmt.Errorf("tar write body %s: %w", path, err) + } + } + + if err := tw.Close(); err != nil { + return nil, fmt.Errorf("tar close: %w", err) + } + if err := gw.Close(); err != nil { + return nil, fmt.Errorf("gzip close: %w", err) + } + return buf.Bytes(), nil +} diff --git a/backend/internal/api/handlers/kubernetes.go b/backend/internal/api/handlers/kubernetes.go index 4230536..5249e71 100644 --- a/backend/internal/api/handlers/kubernetes.go +++ b/backend/internal/api/handlers/kubernetes.go @@ -1678,22 +1678,40 @@ func (h *KubernetesHandler) listServicesWithNS(c *gin.Context, ns string) { 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"` + 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), + 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}) @@ -1705,22 +1723,57 @@ func (h *KubernetesHandler) listIngressesWithNS(c *gin.Context, ns string) { 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"` + 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{} - for _, rule := range ing.Spec.Rules { if rule.Host != "" { hosts = append(hosts, rule.Host) } } + 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, + 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}) @@ -2243,6 +2296,55 @@ func (h *KubernetesHandler) UpdateResourceYAMLNS(c *gin.Context) { 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 + 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 diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 95316fe..2102a15 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -397,6 +397,8 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c kubernetesRouter.GET("/:clusterCode/capacity", k8sHandler.GetCapacityAnalysis) // 🆕 YAML Apply kubernetesRouter.POST("/:clusterCode/yaml/apply", k8sHandler.ApplyYAML) + // 🆕 批量导出资源 (Deployment+Service+Ingress+ConfigMap+Secret) // Logan.Li + kubernetesRouter.GET("/:clusterCode/namespace/:namespace/export", k8sHandler.ExportResourcesNS) // PVC (存储) kubernetesRouter.GET("/:clusterCode/namespace/:namespace/pvcs", k8sHandler.ListPVCsNS) @@ -409,6 +411,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c // YAML view/edit kubernetesRouter.GET("/:clusterCode/namespace/:namespace/:resource/:name/yaml", k8sHandler.GetResourceYAMLNS) kubernetesRouter.PUT("/:clusterCode/namespace/:namespace/:resource/:name/yaml", k8sHandler.UpdateResourceYAMLNS) + kubernetesRouter.DELETE("/:clusterCode/namespace/:namespace/:resource/:name/yaml", k8sHandler.DeleteResourceYAMLNS) } // 告警路由 diff --git a/backend/pkg/kubernetesclient/client.go b/backend/pkg/kubernetesclient/client.go index ce1fa00..469ce75 100644 --- a/backend/pkg/kubernetesclient/client.go +++ b/backend/pkg/kubernetesclient/client.go @@ -349,7 +349,32 @@ type IngressTLS struct { } type IngressRule struct { - Host string `json:"host"` + Host string `json:"host"` + HTTP *IngressHTTP `json:"http,omitempty"` +} + +type IngressHTTP struct { + Paths []IngressPath `json:"paths"` +} + +type IngressPath struct { + Path string `json:"path"` + PathType string `json:"pathType"` + Backend IngressBackend `json:"backend"` +} + +type IngressBackend struct { + Service IngressBackendService `json:"service"` +} + +type IngressBackendService struct { + Name string `json:"name"` + Port IngressServicePort `json:"port"` +} + +type IngressServicePort struct { + Number int `json:"number,omitempty"` + Name string `json:"name,omitempty"` } // ObjectMeta @@ -1264,6 +1289,11 @@ func (c *Client) UpdateResourceYAML(apiPath string, yamlData []byte) error { return c.putRaw(apiPath, "application/yaml", yamlData) } +// DeleteResource deletes a K8s resource by apiPath (generic) +func (c *Client) DeleteResource(apiPath string) error { + return c.delete(apiPath) +} + // applyRaw parses YAML and applies it via create-or-update pattern func (c *Client) applyRaw(yamlContent string) (map[string]interface{}, error) { // Parse YAML to extract resource metadata diff --git a/frontend/src/components/kubernetes/DeploymentEdit.tsx b/frontend/src/components/kubernetes/DeploymentEdit.tsx deleted file mode 100644 index e0b4718..0000000 --- a/frontend/src/components/kubernetes/DeploymentEdit.tsx +++ /dev/null @@ -1,374 +0,0 @@ -// DeploymentEdit.tsx — 表单式编辑器(环境变量/资源/探针/挂载) -// 作者: Logan -// 说明: 前端 UI 先行;保存通过 PUT application/yaml(清理后的 manifest)落地。 -import { useState, useMemo } from 'react'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Textarea } from '@/components/ui/textarea'; -import { Badge } from '@/components/ui/badge'; -import { Plus, Trash2, Save, Loader2, Container, Cpu, MemoryStick, Heart, FolderOpen } from 'lucide-react'; -import { useToast } from '@/hooks/use-toast'; -import { kubernetesApi } from '@/services/api'; -import { cleanYAML } from './k8s-yaml-utils'; - -interface ContainerSpec { - name: string; - image: string; - resources?: { - requests?: { cpu?: string; memory?: string }; - limits?: { cpu?: string; memory?: string }; - }; - env?: Array<{ name: string; value?: string; valueFrom?: any }>; - livenessProbe?: any; - readinessProbe?: any; - startupProbe?: any; - volumeMounts?: Array<{ name: string; mountPath: string; readOnly?: boolean }>; - ports?: Array<{ containerPort: number; name?: string; protocol?: string }>; -} - -interface DeploymentEditProps { - clusterCode: string; - namespace: string; - deploymentName: string; - /** 从后端拿到的 manifest 对象(spec.template.spec.containers 为编辑目标) */ - manifest: any | null; - onSaved: () => void; - onCancel: () => void; -} - -/** 把扁平的 deployment 字段或后端 manifest 归一化为可编辑的容器 spec 数组 */ -function toContainerSpecs(deployment: any): ContainerSpec[] { - // 优先用 manifest 里的结构化容器 - const fromManifest = deployment?.spec?.template?.spec?.containers; - if (Array.isArray(fromManifest) && fromManifest.length > 0) { - return fromManifest.map((c: any) => ({ - name: c.name, - image: c.image, - resources: c.resources || {}, - env: Array.isArray(c.env) ? c.env : [], - livenessProbe: c.livenessProbe || null, - readinessProbe: c.readinessProbe || null, - startupProbe: c.startupProbe || null, - volumeMounts: Array.isArray(c.volumeMounts) ? c.volumeMounts : [], - ports: Array.isArray(c.ports) ? c.ports : [], - })); - } - // 降级:从扁平字段构造单个容器 - return [{ - name: deployment?.container_name || deployment?.name || 'main', - image: deployment?.image || '', - resources: { - requests: { cpu: deployment?.cpu_request, memory: deployment?.memory_request }, - limits: { cpu: deployment?.cpu_limit, memory: deployment?.memory_limit }, - }, - env: [], - volumeMounts: [], - ports: [], - }]; -} - -export function DeploymentEdit({ clusterCode, namespace, deploymentName, manifest, onSaved, onCancel }: DeploymentEditProps) { - const { toast } = useToast(); - const [saving, setSaving] = useState(false); - const [rawManifest, setRawManifest] = useState(manifest || {}); - const [containers, setContainers] = useState(() => toContainerSpecs(manifest)); - - // 副本数 & 镜像策略顶层字段 - const replicas = rawManifest?.spec?.replicas ?? manifest?.replicas ?? 1; - const setReplicas = (n: number) => - setRawManifest((m: any) => ({ ...m, spec: { ...(m.spec || {}), replicas: n } })); - - const updateContainer = (idx: number, patch: Partial) => { - setContainers(prev => prev.map((c, i) => (i === idx ? { ...c, ...patch } : c))); - }; - - // ============ Env ============ - const addEnv = (ci: number) => { - const env = [...(containers[ci].env || []), { name: '', value: '' }]; - updateContainer(ci, { env }); - }; - const updateEnv = (ci: number, ei: number, patch: Partial<{ name: string; value: string }>) => { - const env = (containers[ci].env || []).map((e, i) => (i === ei ? { ...e, ...patch } : e)); - updateContainer(ci, { env }); - }; - const removeEnv = (ci: number, ei: number) => { - updateContainer(ci, { env: (containers[ci].env || []).filter((_, i) => i !== ei) }); - }; - - // ============ Resources ============ - const updateResource = (ci: number, kind: 'requests' | 'limits', key: 'cpu' | 'memory', val: string) => { - const c = containers[ci]; - const resources = { ...(c.resources || {}) }; - resources[kind] = { ...(resources[kind] || {}), [key]: val }; - updateContainer(ci, { resources }); - }; - - // ============ Probe(简化:仅编辑 httpGet.path/port + initialDelaySeconds + periodSeconds) ============ - const updateProbe = (ci: number, probeType: 'livenessProbe' | 'readinessProbe' | 'startupProbe', patch: any) => { - const c = containers[ci]; - const existing = c[probeType] || {}; - updateContainer(ci, { [probeType]: { ...existing, ...patch } } as any); - }; - const toggleProbe = (ci: number, probeType: 'livenessProbe' | 'readinessProbe' | 'startupProbe') => { - const c = containers[ci]; - if (c[probeType]) { - updateContainer(ci, { [probeType]: null } as any); - } else { - updateContainer(ci, { [probeType]: { httpGet: { path: '/', port: 8080 }, initialDelaySeconds: 10, periodSeconds: 10 } } as any); - } - }; - - // ============ VolumeMount ============ - const addMount = (ci: number) => { - const mounts = [...(containers[ci].volumeMounts || []), { name: '', mountPath: '' }]; - updateContainer(ci, { volumeMounts: mounts }); - }; - const updateMount = (ci: number, mi: number, patch: Partial<{ name: string; mountPath: string }>) => { - const mounts = (containers[ci].volumeMounts || []).map((m, i) => (i === mi ? { ...m, ...patch } : m)); - updateContainer(ci, { volumeMounts: mounts }); - }; - const removeMount = (ci: number, mi: number) => { - updateContainer(ci, { volumeMounts: (containers[ci].volumeMounts || []).filter((_, i) => i !== mi) }); - }; - - // ============ 序列化为 YAML 并保存 ============ - const buildManifest = useMemo(() => { - const base = JSON.parse(JSON.stringify(rawManifest && Object.keys(rawManifest).length ? rawManifest : manifest || {})); - base.spec = base.spec || {}; - base.spec.replicas = replicas; - base.spec.template = base.spec.template || {}; - base.spec.template.spec = base.spec.template.spec || {}; - base.spec.template.spec.containers = containers.map(c => { - const out: any = { name: c.name, image: c.image }; - if (c.resources && Object.keys(c.resources).length) out.resources = c.resources; - if (c.env && c.env.length) out.env = c.env.filter(e => e.name); - if (c.volumeMounts && c.volumeMounts.length) out.volumeMounts = c.volumeMounts; - if (c.ports && c.ports.length) out.ports = c.ports; - if (c.livenessProbe) out.livenessProbe = c.livenessProbe; - if (c.readinessProbe) out.readinessProbe = c.readinessProbe; - if (c.startupProbe) out.startupProbe = c.startupProbe; - return out; - }); - return base; - }, [rawManifest, manifest, replicas, containers]); - - // 简易 YAML 序列化(不引入 js-yaml,仅处理扁平/二层结构用于编辑落地) - // 复杂嵌套交给后端 application/yaml 端点容忍 JSON-ish YAML;大多数 K8s API server 接受 JSON 文档作为 YAML 提交。 - const save = async () => { - setSaving(true); - try { - const json = JSON.stringify(buildManifest, null, 2); - // 后端 yaml 端点一般同时接受 JSON 文档(YAML 是 JSON 超集) - await kubernetesApi.updateResourceYaml(clusterCode, namespace, 'deployments', deploymentName, json); - toast({ title: '编辑已保存', description: `${deploymentName} 更新已提交` }); - onSaved(); - } catch (e: any) { - toast({ title: '保存失败', description: e?.message || String(e), variant: 'destructive' }); - } finally { setSaving(false); } - }; - - if (!manifest) { - return ( - - - 未获取到 manifest 结构化数据,请使用「编辑 YAML」模式直接修改原文。 - - - ); - } - - return ( -
- {/* 顶层:副本数 */} - - 副本与镜像策略 - -
-
- - setReplicas(Number(e.target.value))} - className="w-32 mt-1 bg-slate-800 border-slate-700 text-slate-200" /> -
-
-
-
- - {/* 每个容器一组编辑卡片 */} - {containers.map((c, ci) => ( - - - - - 容器: {c.name} - - - - {/* 镜像 */} -
- - updateContainer(ci, { image: e.target.value })} - placeholder="harbor.example.com/app:v1.0" - className="mt-1 bg-slate-800 border-slate-700 text-slate-200 font-mono text-sm" /> -
- - {/* 资源 requests/limits */} -
-
- - 资源 requests / limits -
-
-
- - updateResource(ci, 'requests', 'cpu', e.target.value)} - className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" /> -
-
- - updateResource(ci, 'limits', 'cpu', e.target.value)} - className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" /> -
-
- - updateResource(ci, 'requests', 'memory', e.target.value)} - className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" /> -
-
- - updateResource(ci, 'limits', 'memory', e.target.value)} - className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" /> -
-
-
- - {/* 探针 */} -
-
- - 健康探针 -
-
- {(['livenessProbe', 'readinessProbe', 'startupProbe'] as const).map(pt => { - const labels = { livenessProbe: '存活探针', readinessProbe: '就绪探针', startupProbe: '启动探针' }; - const probe = (c as any)[pt]; - const enabled = !!probe; - return ( -
-
- {labels[pt]} - -
- {enabled && probe?.httpGet && ( -
-
- - updateProbe(ci, pt, { httpGet: { ...probe.httpGet, path: e.target.value } })} - className="bg-slate-800 border-slate-700 text-slate-200 text-xs h-7 font-mono" /> -
-
- - updateProbe(ci, pt, { httpGet: { ...probe.httpGet, port: Number(e.target.value) } })} - className="bg-slate-800 border-slate-700 text-slate-200 text-xs h-7 font-mono" /> -
-
- - updateProbe(ci, pt, { initialDelaySeconds: Number(e.target.value) })} - className="bg-slate-800 border-slate-700 text-slate-200 text-xs h-7 font-mono" /> -
-
- )} -
- ); - })} -
-
- - {/* 环境变量 */} -
-
-
- 环境变量 - {c.env?.length || 0} -
- -
-
- {(c.env || []).map((env, ei) => ( -
- updateEnv(ci, ei, { name: e.target.value })} - className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" /> - updateEnv(ci, ei, { value: e.target.value })} - className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" /> - -
- ))} - {(!c.env || c.env.length === 0) &&

无环境变量

} -
-
- - {/* 卷挂载 */} -
-
-
- - Volume 挂载 -
- -
-
- {(c.volumeMounts || []).map((m, mi) => ( -
- updateMount(ci, mi, { name: e.target.value })} - className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" /> - updateMount(ci, mi, { mountPath: e.target.value })} - className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" /> - -
- ))} - {(!c.volumeMounts || c.volumeMounts.length === 0) &&

无卷挂载

} -
-
-
-
- ))} - - {/* 保存栏 */} -
-

- 保存将通过 PUT application/yaml 提交(自动剥离 status/managedFields 等只读字段) -

-
- - -
-
-
- ); -} diff --git a/frontend/src/components/kubernetes/DeploymentEditAdvanced.tsx b/frontend/src/components/kubernetes/DeploymentEditAdvanced.tsx new file mode 100644 index 0000000..da6b62b --- /dev/null +++ b/frontend/src/components/kubernetes/DeploymentEditAdvanced.tsx @@ -0,0 +1,632 @@ +// DeploymentEditAdvanced.tsx — 编辑器「高级设置」Tab (M8) +// 编辑内容:更新策略 / 历史版本 / 调度(节点选择/容忍) / DNS / 安全上下文 / 终止宽限期 / 调度器 / SA +// 作者: Logan +import { useState, useEffect } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { + Save, Loader2, Settings, RefreshCw, Server, Shield, Clock, Network, Plus, Trash2, Globe, MapPin, +} from 'lucide-react'; +import { useToast } from '@/hooks/use-toast'; +import { kubernetesApi } from '@/services/api'; + +interface Props { + clusterCode: string; + namespace: string; + deploymentName: string; + manifest: any | null; + onSaved: () => void; + onCancel: () => void; +} + +interface Toleration { + key: string; + operator: string; // Equal | Exists + value: string; + effect: string; // NoSchedule | PreferNoSchedule | NoExecute + tolerationSeconds: string; +} + +export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName, manifest, onSaved, onCancel }: Props) { + const { toast } = useToast(); + const [saving, setSaving] = useState(false); + const [loading, setLoading] = useState(!manifest); + const [rawManifest, setRawManifest] = useState(manifest || {}); + + // 更新策略 + const [strategyType, setStrategyType] = useState<'RollingUpdate' | 'Recreate'>('RollingUpdate'); + const [maxSurge, setMaxSurge] = useState('25%'); + const [maxUnavailable, setMaxUnavailable] = useState('25%'); + + // 历史/进度 + const [revisionHistoryLimit, setRevisionHistoryLimit] = useState('10'); + const [progressDeadlineSeconds, setProgressDeadlineSeconds] = useState('600'); + + // Pod 级别 + const [nodeSelectorKv, setNodeSelectorKv] = useState>([]); + const [tolerations, setTolerations] = useState([]); + const [dnsPolicy, setDnsPolicy] = useState('ClusterFirst'); + const [restartPolicy, setRestartPolicy] = useState('Always'); + const [schedulerName, setSchedulerName] = useState('default-scheduler'); + const [terminationGracePeriodSeconds, setTerminationGracePeriodSeconds] = useState('30'); + const [serviceAccountName, setServiceAccountName] = useState(''); + + // Pod 安全上下文 + const [podFsGroup, setPodFsGroup] = useState(''); + const [podRunAsUser, setPodRunAsUser] = useState(''); + const [podRunAsGroup, setPodRunAsGroup] = useState(''); + const [podRunAsNonRoot, setPodRunAsNonRoot] = useState(false); + + // 主机设置(M8 补全) + const [hostNetwork, setHostNetwork] = useState(false); + const [hostIPC, setHostIPC] = useState(false); + const [hostPID, setHostPID] = useState(false); + const [shareProcessNamespace, setShareProcessNamespace] = useState(false); + + // hostAliases(M8 补全) + const [hostAliases, setHostAliases] = useState>([]); + + // dnsConfig(M8 补全) + const [dnsNameservers, setDnsNameservers] = useState(''); + const [dnsSearches, setDnsSearches] = useState(''); + const [dnsOptions, setDnsOptions] = useState>([]); + + useEffect(() => { + const m = manifest; + if (!m) { setLoading(false); return; } + setRawManifest(m); + const spec = m?.spec || {}; + const podSpec = spec?.template?.spec || {}; + + // 更新策略 + const strat = spec.strategy || {}; + setStrategyType(strat.type || 'RollingUpdate'); + const ru = strat.rollingUpdate || {}; + setMaxSurge(String(ru.maxSurge ?? '25%')); + setMaxUnavailable(String(ru.maxUnavailable ?? '25%')); + + // 历史/进度 + setRevisionHistoryLimit(spec.revisionHistoryLimit != null ? String(spec.revisionHistoryLimit) : '10'); + setProgressDeadlineSeconds(spec.progressDeadlineSeconds != null ? String(spec.progressDeadlineSeconds) : '600'); + + // nodeSelector + const ns = podSpec.nodeSelector || {}; + setNodeSelectorKv(Object.entries(ns).map(([k, v]) => ({ key: k, value: String(v) }))); + + // tolerations + const tols: any[] = Array.isArray(podSpec.tolerations) ? podSpec.tolerations : []; + setTolerations(tols.map(t => ({ + key: t.key || '', + operator: t.operator || 'Equal', + value: t.value || '', + effect: t.effect || '', + tolerationSeconds: t.tolerationSeconds != null ? String(t.tolerationSeconds) : '', + }))); + + setDnsPolicy(podSpec.dnsPolicy || 'ClusterFirst'); + setRestartPolicy(podSpec.restartPolicy || 'Always'); + setSchedulerName(podSpec.schedulerName || 'default-scheduler'); + setTerminationGracePeriodSeconds(podSpec.terminationGracePeriodSeconds != null ? String(podSpec.terminationGracePeriodSeconds) : '30'); + setServiceAccountName(podSpec.serviceAccountName || ''); + + // Pod securityContext + const sc = podSpec.securityContext || {}; + setPodFsGroup(sc.fsGroup != null ? String(sc.fsGroup) : ''); + setPodRunAsUser(sc.runAsUser != null ? String(sc.runAsUser) : ''); + setPodRunAsGroup(sc.runAsGroup != null ? String(sc.runAsGroup) : ''); + setPodRunAsNonRoot(!!sc.runAsNonRoot); + + // 主机设置(M8 补全) + setHostNetwork(!!podSpec.hostNetwork); + setHostIPC(!!podSpec.hostIPC); + setHostPID(!!podSpec.hostPID); + setShareProcessNamespace(!!podSpec.shareProcessNamespace); + + // hostAliases(M8 补全) + const ha: any[] = Array.isArray(podSpec.hostAliases) ? podSpec.hostAliases : []; + setHostAliases(ha.map(a => ({ + ip: a.ip || '', + hostnames: Array.isArray(a.hostnames) ? a.hostnames.join(', ') : '', + }))); + + // dnsConfig(M8 补全) + const dc = podSpec.dnsConfig || {}; + setDnsNameservers(Array.isArray(dc.nameservers) ? dc.nameservers.join('\n') : ''); + setDnsSearches(Array.isArray(dc.searches) ? dc.searches.join('\n') : ''); + const opts: any[] = Array.isArray(dc.options) ? dc.options : []; + setDnsOptions(opts.map(o => ({ name: o.name || '', value: o.value || '' }))); + + setLoading(false); + }, [manifest]); + + // nodeSelector 操作 + const addNs = () => setNodeSelectorKv(prev => [...prev, { key: '', value: '' }]); + const removeNs = (i: number) => setNodeSelectorKv(prev => prev.filter((_, idx) => idx !== i)); + const updateNs = (i: number, patch: Partial<{ key: string; value: string }>) => + setNodeSelectorKv(prev => prev.map((n, idx) => (idx === i ? { ...n, ...patch } : n))); + + // tolerations 操作 + const addTol = () => setTolerations(prev => [...prev, { key: '', operator: 'Equal', value: '', effect: 'NoSchedule', tolerationSeconds: '' }]); + const removeTol = (i: number) => setTolerations(prev => prev.filter((_, idx) => idx !== i)); + const updateTol = (i: number, patch: Partial) => + setTolerations(prev => prev.map((t, idx) => (idx === i ? { ...t, ...patch } : t))); + + const save = async () => { + setSaving(true); + try { + const base = JSON.parse(JSON.stringify(rawManifest)); + base.spec = base.spec || {}; + base.spec.template = base.spec.template || {}; + base.spec.template.spec = base.spec.template.spec || {}; + const podSpec = base.spec.template.spec; + + // 更新策略 + base.spec.strategy = { type: strategyType }; + if (strategyType === 'RollingUpdate') { + base.spec.strategy.rollingUpdate = { maxSurge, maxUnavailable }; + } + + // 历史/进度 + if (revisionHistoryLimit) base.spec.revisionHistoryLimit = Number(revisionHistoryLimit); + if (progressDeadlineSeconds) base.spec.progressDeadlineSeconds = Number(progressDeadlineSeconds); + + // nodeSelector + const nsObj: Record = {}; + for (const { key, value } of nodeSelectorKv) { + const k = key.trim(); + if (k) nsObj[k] = value; + } + if (Object.keys(nsObj).length > 0) podSpec.nodeSelector = nsObj; + else delete podSpec.nodeSelector; + + // tolerations + const tolsK8s = tolerations + .filter(t => t.key || t.operator === 'Exists') + .map(t => { + const out: any = {}; + if (t.key) out.key = t.key; + if (t.operator) out.operator = t.operator; + if (t.value && t.operator !== 'Exists') out.value = t.value; + if (t.effect) out.effect = t.effect; + if (t.tolerationSeconds && t.effect === 'NoExecute') out.tolerationSeconds = Number(t.tolerationSeconds); + return out; + }); + if (tolsK8s.length > 0) podSpec.tolerations = tolsK8s; + else delete podSpec.tolerations; + + // 其他 Pod 级别 + if (dnsPolicy) podSpec.dnsPolicy = dnsPolicy; + if (restartPolicy) podSpec.restartPolicy = restartPolicy; + if (schedulerName) podSpec.schedulerName = schedulerName; + if (terminationGracePeriodSeconds) podSpec.terminationGracePeriodSeconds = Number(terminationGracePeriodSeconds); + if (serviceAccountName) podSpec.serviceAccountName = serviceAccountName; + + // Pod 安全上下文 + const sc: any = {}; + if (podFsGroup) sc.fsGroup = Number(podFsGroup); + if (podRunAsUser) sc.runAsUser = Number(podRunAsUser); + if (podRunAsGroup) sc.runAsGroup = Number(podRunAsGroup); + if (podRunAsNonRoot) sc.runAsNonRoot = true; + if (Object.keys(sc).length > 0) podSpec.securityContext = sc; + else delete podSpec.securityContext; + + // 主机设置(M8 补全)— 只在为 true 时写入,false 删除字段 + if (hostNetwork) podSpec.hostNetwork = true; else delete podSpec.hostNetwork; + if (hostIPC) podSpec.hostIPC = true; else delete podSpec.hostIPC; + if (hostPID) podSpec.hostPID = true; else delete podSpec.hostPID; + if (shareProcessNamespace) podSpec.shareProcessNamespace = true; else delete podSpec.shareProcessNamespace; + + // hostAliases(M8 补全) + const haK8s = hostAliases + .filter(a => a.ip.trim()) + .map(a => ({ + ip: a.ip.trim(), + hostnames: a.hostnames.split(/[,\s]+/).map((s: string) => s.trim()).filter(Boolean), + })) + .filter(a => a.hostnames.length > 0); + if (haK8s.length > 0) podSpec.hostAliases = haK8s; + else delete podSpec.hostAliases; + + // dnsConfig(M8 补全)— 仅 dnsPolicy=None 时 dnsConfig 才有意义,但仍允许编辑 + const dcObj: any = {}; + const ns = dnsNameservers.split('\n').map(s => s.trim()).filter(Boolean); + if (ns.length > 0) dcObj.nameservers = ns; + const sr = dnsSearches.split('\n').map(s => s.trim()).filter(Boolean); + if (sr.length > 0) dcObj.searches = sr; + const dkOpts = dnsOptions.filter(o => o.name.trim()).map(o => { + const out: any = { name: o.name.trim() }; + if (o.value.trim()) out.value = o.value.trim(); + return out; + }); + if (dkOpts.length > 0) dcObj.options = dkOpts; + if (Object.keys(dcObj).length > 0) podSpec.dnsConfig = dcObj; + else delete podSpec.dnsConfig; + + const json = JSON.stringify(base, null, 2); + await kubernetesApi.updateResourceYaml(clusterCode, namespace, 'deployments', deploymentName, json); + toast({ title: '高级设置已保存', description: `${deploymentName} 更新已提交` }); + onSaved(); + } catch (e: any) { + toast({ title: '保存失败', description: e?.message || String(e), variant: 'destructive' }); + } finally { setSaving(false); } + }; + + if (loading) { + return ( + + + +

加载中...

+
+
+ ); + } + + if (!manifest) { + return ( + + + 未获取到 manifest 数据,请使用 YAML 编辑模式直接修改。 + + + ); + } + + return ( +
+ {/* 更新策略 */} + + + + 更新策略 (Strategy) + + + +
+ + +
+ {strategyType === 'RollingUpdate' && ( +
+
+ + setMaxSurge(e.target.value)} + placeholder="25%" className="h-8 mt-1 bg-slate-800 border-slate-700 text-slate-200 text-sm font-mono" /> +
+
+ + setMaxUnavailable(e.target.value)} + placeholder="25%" className="h-8 mt-1 bg-slate-800 border-slate-700 text-slate-200 text-sm font-mono" /> +
+
+ )} +
+
+ + {/* 历史版本 / 进度 */} + + + + 历史版本 & 进度 + + + +
+ + setRevisionHistoryLimit(e.target.value)} + className="h-8 mt-1 bg-slate-800 border-slate-700 text-slate-200 text-sm font-mono" /> +
+
+ + setProgressDeadlineSeconds(e.target.value)} + className="h-8 mt-1 bg-slate-800 border-slate-700 text-slate-200 text-sm font-mono" /> +
+
+
+ + {/* 调度:nodeSelector + tolerations */} + + + + 调度 (Scheduling) + + + + {/* nodeSelector */} +
+
+ + +
+ {nodeSelectorKv.length === 0 ? ( +

未配置

+ ) : nodeSelectorKv.map((n, i) => ( +
+ updateNs(i, { key: e.target.value })} + placeholder="disktype" className="flex-1 h-7 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" /> + : + updateNs(i, { value: e.target.value })} + placeholder="ssd" className="flex-1 h-7 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" /> + +
+ ))} +
+ + {/* tolerations */} +
+
+ + +
+ {tolerations.length === 0 ? ( +

未配置

+ ) : tolerations.map((t, i) => ( +
+
+ + updateTol(i, { key: e.target.value })} + placeholder="node-role.kubernetes.io/control-plane" + className="h-7 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" /> +
+
+ + +
+
+ + updateTol(i, { value: e.target.value })} + disabled={t.operator === 'Exists'} + className="h-7 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono disabled:opacity-40" /> +
+
+ + +
+
+ updateTol(i, { tolerationSeconds: e.target.value })} + disabled={t.effect !== 'NoExecute'} placeholder="秒" + className="h-7 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono disabled:opacity-40" /> + +
+
+ ))} +
+
+
+ + {/* Pod 级别杂项 */} + + + + Pod 级别 + + + +
+ + +
+
+ + +
+
+ + setSchedulerName(e.target.value)} + className="h-8 mt-1 bg-slate-800 border-slate-700 text-slate-200 text-sm font-mono" /> +
+
+ + setTerminationGracePeriodSeconds(e.target.value)} + className="h-8 mt-1 bg-slate-800 border-slate-700 text-slate-200 text-sm font-mono" /> +
+
+ + setServiceAccountName(e.target.value)} + placeholder="default" className="h-8 mt-1 bg-slate-800 border-slate-700 text-slate-200 text-sm font-mono" /> +
+
+
+ + {/* Pod 安全上下文 */} + + + + Pod 安全上下文 (securityContext) + + + +
+ + setPodFsGroup(e.target.value)} + placeholder="1000" className="h-8 mt-1 bg-slate-800 border-slate-700 text-slate-200 text-sm font-mono" /> +
+
+ + setPodRunAsUser(e.target.value)} + placeholder="1000" className="h-8 mt-1 bg-slate-800 border-slate-700 text-slate-200 text-sm font-mono" /> +
+
+ + setPodRunAsGroup(e.target.value)} + placeholder="1000" className="h-8 mt-1 bg-slate-800 border-slate-700 text-slate-200 text-sm font-mono" /> +
+
+ +
+
+
+ + {/* 主机设置(M8 补全) */} + + + + 主机设置 (Host Namespace) + + + + + + + + + + + {/* DNS 配置(M8 补全) */} + + + + DNS 配置 (dnsConfig) + + + +

+ 提示:dnsConfig 通常与 dnsPolicy: None 搭配使用。每行一个值。 +

+
+
+ +