diff --git a/backend/internal/api/handlers/kubernetes.go b/backend/internal/api/handlers/kubernetes.go index 9c6171b..5b9d873 100644 --- a/backend/internal/api/handlers/kubernetes.go +++ b/backend/internal/api/handlers/kubernetes.go @@ -6,6 +6,8 @@ import ( "io" "log" "net/http" + "path" + "sort" "strconv" "strings" "time" @@ -1723,13 +1725,25 @@ 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") - container := c.Query("container") - tailLines, _ := strconv.Atoi(c.DefaultQuery("tail", "200")) 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.GetPodLogsStream(ns, podName, container, tailLines) + 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") @@ -1741,9 +1755,9 @@ func (h *KubernetesHandler) getPodLogsWithNS(c *gin.Context, ns string) { }) return } - logs, err := client.GetPodLogs(ns, podName, container, tailLines) + 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, Data: gin.H{"logs": logs, "pod": podName, "namespace": ns}}) + 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")) @@ -1938,20 +1952,30 @@ func (h *KubernetesHandler) GetResourceYAMLNS(c *gin.Context) { // Build API path based on resource type var apiPath string switch resource { - case "deployments": + case "deployments", "deployment": apiPath = fmt.Sprintf("/apis/apps/v1/namespaces/%s/deployments/%s", ns, name) - case "pods": + 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": + case "services", "service": apiPath = fmt.Sprintf("/api/v1/namespaces/%s/services/%s", ns, name) - case "ingresses": + case "ingresses", "ingress": apiPath = fmt.Sprintf("/apis/networking.k8s.io/v1/namespaces/%s/ingresses/%s", ns, name) - case "configmaps": + case "configmaps", "configmap": apiPath = fmt.Sprintf("/api/v1/namespaces/%s/configmaps/%s", ns, name) - case "secrets": + case "secrets", "secret": apiPath = fmt.Sprintf("/api/v1/namespaces/%s/secrets/%s", ns, name) - case "pvcs": + 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) default: c.JSON(400, models.Response{Code: 400, Message: "unsupported resource type: " + resource}); return } @@ -1974,12 +1998,30 @@ func (h *KubernetesHandler) UpdateResourceYAMLNS(c *gin.Context) { if err != nil { c.JSON(400, models.Response{Code: 400, Message: "读取body失败"}); return } var apiPath string switch resource { - case "deployments": + case "deployments", "deployment": apiPath = fmt.Sprintf("/apis/apps/v1/namespaces/%s/deployments/%s", ns, name) - case "pods": + 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": + 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) default: c.JSON(400, models.Response{Code: 400, Message: "unsupported resource type: " + resource}); return } @@ -2268,3 +2310,274 @@ func (h *KubernetesHandler) ApplyYAML(c *gin.Context) { 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 配合自定义 time-style 获取清晰可解析的字段 + cmd := []string{"ls", "-la", "--time-style=+%Y-%m-%dT%H:%M:%S", 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 := cleanExecOutput(result.Stdout) + 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] + modTime := fields[5] + // 文件名可能含空格:定位到 modTime 之后的内容作为 name + name := "" + if idx := strings.Index(line, modTime); idx >= 0 { + name = strings.TrimSpace(line[idx+len(modTime):]) + } + if name == "" { name = strings.Join(fields[6:], " ") } + 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 := cleanExecOutput(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) +} diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 50bf0a6..21df6bb 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -306,6 +306,14 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c clusters.POST("/:id/namespaces/:namespace/cronjobs/:name/suspend", k8sHandler.SuspendCronJob) clusters.POST("/:id/namespaces/:namespace/cronjobs/:name/trigger", k8sHandler.TriggerCronJob) clusters.DELETE("/:id/namespaces/:namespace/cronjobs/:name", k8sHandler.DeleteCronJob) + // ReplicaSet 列表 / Deployment 回滚 / 历史版本 // Logan.Li + clusters.GET("/:id/namespaces/:namespace/replicasets", k8sHandler.ListReplicaSetsNS) + clusters.GET("/:id/namespaces/:namespace/deployments/:name/revisions", k8sHandler.ListDeploymentRevisionsNS) + clusters.POST("/:id/namespaces/:namespace/deployments/:name/rollback", k8sHandler.RollbackDeploymentNS) + // Pod 文件浏览 / 下载 / 删除 // Logan.Li + clusters.POST("/:id/namespaces/:namespace/pods/:podName/files/list", k8sHandler.ListPodFilesNS) + clusters.GET("/:id/namespaces/:namespace/pods/:podName/files/download", k8sHandler.DownloadPodFileNS) + clusters.DELETE("/:id/namespaces/:namespace/pods/:podName", k8sHandler.DeletePodNS) // Namespace // Logan.Li clusters.GET("/:id/namespaces", k8sHandler.ListNamespaces) } @@ -349,6 +357,16 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c kubernetesRouter.POST("/:clusterCode/namespace/:namespace/cronjobs/:name/trigger", k8sHandler.TriggerCronJobNS) kubernetesRouter.DELETE("/:clusterCode/namespace/:namespace/cronjobs/:name", k8sHandler.DeleteCronJobNS) + // 🆕 ReplicaSet 列表 // Logan.Li + kubernetesRouter.GET("/:clusterCode/namespace/:namespace/replicasets", k8sHandler.ListReplicaSetsNS) + // 🆕 Deployment 回滚 / 发布历史 // Logan.Li + kubernetesRouter.GET("/:clusterCode/namespace/:namespace/deployments/:name/revisions", k8sHandler.ListDeploymentRevisionsNS) + kubernetesRouter.POST("/:clusterCode/namespace/:namespace/deployments/:name/rollback", k8sHandler.RollbackDeploymentNS) + // 🆕 Pod 文件浏览 / 下载 / 删除 // Logan.Li + kubernetesRouter.POST("/:clusterCode/namespace/:namespace/pods/:podName/files/list", k8sHandler.ListPodFilesNS) + kubernetesRouter.GET("/:clusterCode/namespace/:namespace/pods/:podName/files/download", k8sHandler.DownloadPodFileNS) + kubernetesRouter.DELETE("/:clusterCode/namespace/:namespace/pods/:podName", k8sHandler.DeletePodNS) + // ConfigMap / Secret kubernetesRouter.GET("/:clusterCode/namespace/:namespace/configmaps", k8sHandler.ListConfigMaps) kubernetesRouter.GET("/:clusterCode/namespace/:namespace/secrets", k8sHandler.ListSecrets) diff --git a/backend/pkg/kubernetesclient/client.go b/backend/pkg/kubernetesclient/client.go index 8efb4d2..e840722 100644 --- a/backend/pkg/kubernetesclient/client.go +++ b/backend/pkg/kubernetesclient/client.go @@ -1316,3 +1316,171 @@ func (c *Client) resourceAPIPath(apiVersion, kind, namespace, name string) (stri } return "/api/v1/namespaces/" + ns + "/" + resource + "/" + name, true } + +// ============================================================================ +// ReplicaSet — 用于 Deployment 回滚 / 历史版本查询 // Logan.Li +// ============================================================================ + +// ReplicaSetList response +type ReplicaSetList struct { + Items []ReplicaSetItem `json:"items"` +} + +// ReplicaSetItem 表示一个 ReplicaSet(含完整 spec/status/metadata,用于回滚取 template) +type ReplicaSetItem struct { + Metadata ObjectMeta `json:"metadata"` + Spec ReplicaSetSpec `json:"spec"` + Status ReplicaSetStatus `json:"status"` +} + +// ReplicaSetSpec 与 DeploymentSpec 结构一致,关键是 template +type ReplicaSetSpec struct { + Replicas int `json:"replicas"` + Template ReplicaSetTemplate `json:"template"` +} + +// ReplicaSetTemplate pod 模板 +type ReplicaSetTemplate struct { + Metadata ObjectMeta `json:"metadata"` + Spec PodSpec `json:"spec"` +} + +// ReplicaSetStatus 副本状态 +type ReplicaSetStatus struct { + Replicas int `json:"replicas"` + AvailableReplicas int `json:"availableReplicas"` + ReadyReplicas int `json:"readyReplicas"` +} + +// OwnerReference 资源归属引用 +type OwnerReference struct { + Kind string `json:"kind"` + Name string `json:"name"` +} + +// ReplicaSetOwnerMeta 在 ListReplicaSets 时额外携带 ownerReferences +type ReplicaSetOwnerMeta struct { + Metadata struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + Labels map[string]string `json:"labels"` + Annotations map[string]string `json:"annotations"` + CreationTimestamp string `json:"creationTimestamp"` + OwnerReferences []OwnerReference `json:"ownerReferences"` + } `json:"metadata"` + Spec struct { + Replicas int `json:"replicas"` + Template ReplicaSetTemplate `json:"template"` + } `json:"spec"` + Status ReplicaSetStatus `json:"status"` +} + +// ListReplicaSetsRaw 列出 namespace 下的 ReplicaSet(含 ownerReferences 原始字段) +func (c *Client) ListReplicaSetsRaw(namespace string) ([]ReplicaSetOwnerMeta, error) { + var result struct { + Items []ReplicaSetOwnerMeta `json:"items"` + } + path := fmt.Sprintf("/apis/apps/v1/namespaces/%s/replicasets", namespace) + if err := c.get(path, &result); err != nil { + return nil, err + } + return result.Items, nil +} + +// GetReplicaSet 获取单个 ReplicaSet 的完整 JSON(map 形式,便于取 template 直接 patch) +func (c *Client) GetReplicaSet(namespace, name string) (map[string]interface{}, error) { + path := fmt.Sprintf("/apis/apps/v1/namespaces/%s/replicasets/%s", namespace, name) + var result map[string]interface{} + if err := c.get(path, &result); err != nil { + return nil, err + } + return result, nil +} + +// PatchDeploymentSpecTemplate 用旧 ReplicaSet 的 template 回滚 Deployment +// 通过 merge-patch 将 deployment.spec.template 整体替换为 rsTemplate +func (c *Client) PatchDeploymentSpecTemplate(namespace, deploymentName string, rsTemplate map[string]interface{}) error { + body := map[string]interface{}{ + "spec": map[string]interface{}{ + "template": rsTemplate, + }, + } + path := fmt.Sprintf("/apis/apps/v1/namespaces/%s/deployments/%s", namespace, deploymentName) + return c.patch(path, body, nil) +} + +// ============================================================================ +// Pod 删除 / 高级日志参数 // Logan.Li +// ============================================================================ + +// DeletePod 删除指定 namespace 下的 Pod +func (c *Client) DeletePod(namespace, name string) error { + path := fmt.Sprintf("/api/v1/namespaces/%s/pods/%s", namespace, name) + return c.delete(path) +} + +// LogOptions 日志查询高级参数(container/sinceTime/previous/tailLines) +type LogOptions struct { + Container string + SinceTime string // RFC3339 格式,仅返回此时间之后的日志 + Previous bool // 返回已终止容器(previous instance)的日志 + TailLines int // 末尾 N 行,0 表示不限制 +} + +// buildLogPath 构造 K8s pod log API 的完整查询路径 +func (c *Client) buildLogPath(namespace, podName string, opts LogOptions, follow bool) string { + q := []string{} + if opts.Container != "" { + q = append(q, "container="+opts.Container) + } + if opts.TailLines > 0 { + q = append(q, fmt.Sprintf("tailLines=%d", opts.TailLines)) + } + if opts.SinceTime != "" { + q = append(q, "sinceTime="+opts.SinceTime) + } + if opts.Previous { + q = append(q, "previous=true") + } + if follow { + q = append(q, "follow=true") + } + path := fmt.Sprintf("/api/v1/namespaces/%s/pods/%s/log", namespace, podName) + if len(q) > 0 { + path += "?" + strings.Join(q, "&") + } + return path +} + +// GetPodLogsWithOptions 支持高级参数的日志查询(非流式) +func (c *Client) GetPodLogsWithOptions(namespace, podName string, opts LogOptions) (string, error) { + url := c.BaseURL + c.buildLogPath(namespace, podName, opts, false) + resp, err := c.HTTPClient.Get(url) + if err != nil { + return "", fmt.Errorf("GET logs: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + if resp.StatusCode >= 400 { + return "", fmt.Errorf("K8s API %d: %s", resp.StatusCode, string(body[:min(len(body), 300)])) + } + return string(body), nil +} + +// GetPodLogsStreamWithOptions 支持高级参数的日志流式查询(follow=true) +func (c *Client) GetPodLogsStreamWithOptions(namespace, podName string, opts LogOptions) (io.ReadCloser, error) { + url := c.BaseURL + c.buildLogPath(namespace, podName, opts, true) + resp, err := c.HTTPClient.Get(url) + if err != nil { + return nil, fmt.Errorf("GET logs stream: %w", err) + } + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return nil, fmt.Errorf("K8s API %d: %s", resp.StatusCode, string(body[:min(len(body), 300)])) + } + return resp.Body, nil +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 559c747..5616811 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -94,9 +94,14 @@ function App() { {/* 命名空间概览 */} } /> + {/* 🆕 Kuboard 风格双模式工作负载路由 (view/edit) */} + + } /> + {/* 各类资源列表 */} } /> + {/* 兼容旧路由 /deployments/:name → 默认 view 模式 */} } /> } /> diff --git a/frontend/src/components/kubernetes/DeploymentDetail.tsx b/frontend/src/components/kubernetes/DeploymentDetail.tsx index f120ac7..db2ddd2 100644 --- a/frontend/src/components/kubernetes/DeploymentDetail.tsx +++ b/frontend/src/components/kubernetes/DeploymentDetail.tsx @@ -9,14 +9,24 @@ import { Label } from '@/components/ui/label'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { ArrowLeft, Minus, Plus, RotateCw, Upload, FileText, Terminal, Gauge, Clock, Play, Pause, Trash2, RefreshCw, Loader2, - CheckCircle, AlertTriangle, XCircle, ExternalLink, + CheckCircle, AlertTriangle, XCircle, ExternalLink, FolderOpen, } from 'lucide-react'; import { kubernetesApi } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; import { useUserStore } from '@/store'; +import { PodExecTerminal } from './PodExecTerminal'; +import { PodLogsViewer } from './PodLogsViewer'; +import { PodFileBrowser } from './PodFileBrowser'; interface DeploymentDetailProps { clusterCode: string; @@ -44,6 +54,25 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac const [showLogs, setShowLogs] = useState(false); const [logTail, setLogTail] = useState(200); + // 🆕 Pod exec / logs / file browser (Logan ) + const [execPod, setExecPod] = useState<{ name: string; containers: string[] } | null>(null); + const [execOpen, setExecOpen] = useState(false); + const [logsViewerPod, setLogsViewerPod] = useState<{ name: string; containers: string[] } | null>(null); + const [logsViewerOpen, setLogsViewerOpen] = useState(false); + const [fileBrowserPod, setFileBrowserPod] = useState<{ name: string; containers: string[] } | null>(null); + const [fileBrowserOpen, setFileBrowserOpen] = useState(false); + // Logs tab 内的 Pod 选择器(增强版查看器入口) + const [logsTabPod, setLogsTabPod] = useState(''); + + // 从 Pod 对象提取容器名列表(兼容多种字段) + const extractContainers = (pod: any): string[] => { + const c = pod?.containers; + if (Array.isArray(c)) return c.map((x: any) => typeof x === 'string' ? x : (x?.name || x?.Name)).filter(Boolean); + if (Array.isArray(pod?.container_statuses)) return pod.container_statuses.map((x: any) => x?.name).filter(Boolean); + if (Array.isArray(pod?.containerNames)) return pod.containerNames.filter(Boolean); + return []; + }; + const loadData = useCallback(async () => { setLoading(true); try { @@ -359,13 +388,29 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
+
@@ -384,6 +429,19 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
Pod 日志
+ {showLogs && logPod && ( + + )} 最近 setLogTail(Number(e.target.value))} @@ -394,41 +452,45 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
- {!showLogs ? ( + {pods.length === 0 ? (
-

点击 Pod 列表中的「日志」按钮查看

+

暂无 Pod

) : ( -
-
- - {logPod} -
-
+                
+ + 选择 Pod 打开增强版日志查看器 + + + + 支持实时跟踪、多容器切换、previous 日志、关键字过滤、导出 + +
+ )} + {showLogs && logPod && ( +
+
                     {logs || '无日志内容'}
                   
@@ -499,6 +561,52 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac + + {/* 🆕 Pod exec 终端 */} + {execPod && ( + { + setExecOpen(o); + if (!o) setExecPod(null); + }} + clusterId={clusterCode} + clusterCode={clusterCode} + namespace={namespace} + podName={execPod.name} + containers={execPod.containers} + /> + )} + + {/* 🆕 增强版日志查看器 */} + {logsViewerPod && ( + { + setLogsViewerOpen(o); + if (!o) setLogsViewerPod(null); + }} + clusterCode={clusterCode} + namespace={namespace} + podName={logsViewerPod.name} + containers={logsViewerPod.containers} + /> + )} + + {/* 🆕 容器文件浏览器 */} + {fileBrowserPod && ( + { + setFileBrowserOpen(o); + if (!o) setFileBrowserPod(null); + }} + clusterCode={clusterCode} + namespace={namespace} + podName={fileBrowserPod.name} + containers={fileBrowserPod.containers} + /> + )}
); } diff --git a/frontend/src/components/kubernetes/DeploymentDetailPage.tsx b/frontend/src/components/kubernetes/DeploymentDetailPage.tsx index 8d8a350..544a0e5 100644 --- a/frontend/src/components/kubernetes/DeploymentDetailPage.tsx +++ b/frontend/src/components/kubernetes/DeploymentDetailPage.tsx @@ -1,18 +1,45 @@ +// DeploymentDetailPage.tsx — 路由包装:解析 cluster/namespace/mode/name +// 支持 Kuboard 风格双模式路由: +// view: /kubernetes/:c/namespace/:n/workload/view/Deployment/:name +// edit: /kubernetes/:c/namespace/:n/workload/edit/Deployment/:name +// 兼容旧路由: /kubernetes/:c/namespace/:n/deployments/:name(默认 view) +// 作者: Logan import { useParams, useNavigate } from 'react-router-dom'; -import { DeploymentDetail } from './DeploymentDetail'; +import { DeploymentWorkload } from './DeploymentWorkload'; export default function DeploymentDetailPage() { - const { clusterCode, namespace, name } = useParams<{ - clusterCode: string; namespace: string; name: string; + // 兼容两种路由参数命名:旧 :name 与新 :name(路径段不同) + const params = useParams<{ + clusterCode: string; + namespace: string; + name: string; + mode?: string; // 'view' | 'edit'(新路由) + workloadKind?: string; // 'Deployment'(新路由,预留扩展) }>(); + const navigate = useNavigate(); + const clusterCode = params.clusterCode || ''; + const namespace = params.namespace || ''; + const deploymentName = params.name || ''; + const currentMode: 'view' | 'edit' = params.mode === 'edit' ? 'edit' : 'view'; + + // 模式切换:在 view/edit 路由间跳转 + const handleModeChange = (m: 'view' | 'edit') => { + if (m === currentMode) return; + navigate( + `/kubernetes/${clusterCode}/namespace/${namespace}/workload/${m}/Deployment/${deploymentName}`, + { replace: true } + ); + }; return ( - navigate(-1)} + onModeChange={handleModeChange} /> ); } diff --git a/frontend/src/components/kubernetes/DeploymentEdit.tsx b/frontend/src/components/kubernetes/DeploymentEdit.tsx new file mode 100644 index 0000000..e0b4718 --- /dev/null +++ b/frontend/src/components/kubernetes/DeploymentEdit.tsx @@ -0,0 +1,374 @@ +// 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/DeploymentView.tsx b/frontend/src/components/kubernetes/DeploymentView.tsx new file mode 100644 index 0000000..6a9fbef --- /dev/null +++ b/frontend/src/components/kubernetes/DeploymentView.tsx @@ -0,0 +1,395 @@ +// DeploymentView.tsx — 增强只读详情看板(对标 Kuboard) +// 作者: Logan +import { useState, Fragment } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from '@/components/ui/table'; +import { + Clock, Cpu, MemoryStick, HardDrive, Network, Activity, + RotateCcw, FileCode, Server, Container, History, ChevronDown, ChevronRight, +} from 'lucide-react'; +import { kubernetesApi } from '@/services/api'; +import { useToast } from '@/hooks/use-toast'; +import { + formatK8sTime, formatDuration, elapsedSeconds, statusColor, STATUS_COLORS, +} from './k8s-yaml-utils'; + +interface PodItem { name: string; namespace?: string; status?: string; node_name?: string; node?: string; ip?: string; pod_ip?: string; restart_count?: number; age?: string; created_at?: string; start_time?: string; containers?: any[]; image?: string; images?: string[]; } + +interface ReplicaSetItem { name: string; namespace?: string; replicas?: number; ready_replicas?: number; available_replicas?: number; image?: string; revision?: number; status?: string; created_at?: string; controlled?: boolean; owner_deployment?: string; } + +interface DeploymentViewProps { + clusterCode: string; + namespace: string; + deployment: any; + pods: PodItem[]; + replicaSets: ReplicaSetItem[]; + revisions: any[]; + onRefresh: () => void; + onShowPodLogs: (pod: PodItem) => void; + onShowPodTerminal: (pod: PodItem) => void; + onShowPodYaml: (pod: PodItem) => void; +} + +/** 计算 Pod 从调度到就绪的各阶段耗时(基于条件时间线,兼容扁平字段) */ +function getPodTimeline(pod: PodItem) { + const cond: any[] = (pod as any).conditions || (pod as any).pod_conditions || []; + const find = (t: string) => cond.find((c: any) => (c.type || c.Type) === t); + const scheduled = find('PodScheduled') || {}; + const initialized = find('Initialized') || {}; + const ready = find('Ready') || {}; + const containersReady = find('ContainersReady') || {}; + const ts = (c: any) => c?.lastTransitionTime || c?.LastTransitionTime || c?.last_transition_time; + return { + scheduled: ts(scheduled), + initialized: ts(initialized), + containersReady: ts(containersReady), + ready: ts(ready), + startTime: pod.start_time || (pod as any).startTime, + }; +} + +function StatCard({ label, value, sub, color }: { label: string; value: React.ReactNode; sub?: string; color?: string }) { + return ( + + +

{label}

+

{value}

+ {sub &&

{sub}

} +
+
+ ); +} + +export function DeploymentView({ + clusterCode, namespace, deployment, pods, replicaSets, revisions, + onRefresh, onShowPodLogs, onShowPodTerminal, onShowPodYaml, +}: DeploymentViewProps) { + const navigate = useNavigate(); + const { toast } = useToast(); + const [expandedPod, setExpandedPod] = useState(null); + const [rollbackLoading, setRollbackLoading] = useState(null); + + const ready = deployment.ready_replicas || deployment.readyReplicas || 0; + const total = deployment.replicas || 0; + const available = deployment.available_replicas || deployment.availableReplicas || 0; + const updated = deployment.updated_replicas || deployment.updatedReplicas || 0; + const isHealthy = ready === total && total > 0; + + const createdAt = deployment.created_at || deployment.creationTimestamp || deployment.created_at; + const updatedAt = deployment.updated_at || deployment.last_updated; + + const handleRollback = async (rev: any) => { + const revision = rev.revision ?? rev.revision_number ?? rev.number; + if (!confirm(`确认回滚到修订版本 #${revision}?\n镜像: ${rev.image || '-'}`)) return; + setRollbackLoading(revision); + try { + await kubernetesApi.rollbackDeployment(clusterCode, namespace, deployment.name, Number(revision)); + toast({ title: '回滚指令已下发', description: `正在回滚到 revision #${revision}` }); + onRefresh(); + } catch (e: any) { + toast({ title: '回滚失败', description: e?.message || String(e), variant: 'destructive' }); + } finally { setRollbackLoading(null); } + }; + + const nodeClick = (nodeName: string | undefined) => { + if (!nodeName) return; + navigate(`/kubernetes/${clusterCode}`); + }; + + return ( +
+ {/* ============ 1. 状态总览 ============ */} +
+ + + + + {formatK8sTime(createdAt)}} color="#94a3b8" /> + {formatK8sTime(updatedAt)}} color="#94a3b8" /> +
+ + {/* ============ 2. 容器详情(CPU/内存/镜像/端口) ============ */} + + + 容器模板(Pod Template) + + + + + + + {/* ============ 3. Pod 状态时间线 + 详情列表 ============ */} + + + + Pod 实例 ({pods.length}) + + + + + + + + Pod 名称 + 状态 + 所在节点 + Pod IP + 重启 + 调度→就绪 + 启动时间 + 操作 + + + + {pods.length === 0 ? ( + 暂无 Pod + ) : pods.map((pod) => { + const tl = getPodTimeline(pod); + const schedToReady = elapsedSeconds(tl.scheduled, tl.ready); + const expanded = expandedPod === pod.name; + const nodeName = pod.node_name || pod.node; + return ( + + + + + + {pod.name} + + + {pod.status || 'Unknown'} + + + + + + {pod.ip || pod.pod_ip || '-'} + 0 ? 'text-yellow-400' : 'text-slate-500'}>{pod.restart_count || 0} + {formatDuration(schedToReady)} + {formatK8sTime(tl.startTime || pod.created_at)} + +
+ + + +
+
+
+ {expanded && ( + + + + + + )} +
+ ); + })} +
+
+
+
+ + {/* ============ 4. 历史版本(可回滚) ============ */} + + + 历史版本(Revision / Rollout) + + + + + + Revision + 镜像 + 状态 + 更新时间 + 操作 + + + + {revisions.length === 0 ? ( + 后端 /revisions 接口尚未就绪,等待后端联调 + ) : revisions.map((rev, idx) => { + const revision = rev.revision ?? rev.revision_number ?? rev.number ?? idx + 1; + const isLatest = idx === 0; + return ( + + #{revision} + {rev.image || rev.images?.[0] || '-'} + + {isLatest + ? 当前 + : 历史} + + {formatK8sTime(rev.created_at || rev.time)} + + + + + ); + })} + +
+
+
+ + {/* ============ 5. ReplicaSet 列表 ============ */} + + + ReplicaSet 列表 ({replicaSets.length}) + + + + + + 名称 + 副本 + 镜像 + Revision + 状态 + 创建时间 + + + + {replicaSets.length === 0 ? ( + 后端 /replicasets 接口尚未就绪,等待后端联调 + ) : replicaSets.map((rs) => { + const r: any = rs; + const rsReady = r.ready_replicas ?? r.readyReplicas ?? 0; + const rsTotal = r.replicas ?? 0; + const ok = rsReady === rsTotal && rsTotal > 0; + return ( + + + {r.name} + {r.controlled && 当前} + + {rsReady}/{rsTotal} + {r.image || '-'} + #{r.revision || '-'} + + + {r.status || (ok ? 'Ready' : 'Updating')} + + + {formatK8sTime(r.created_at)} + + ); + })} + +
+
+
+
+ ); +} + +// ============ 容器模板表(镜像/端口/资源/探针) ============ +function ContainersTable({ deployment }: { deployment: any }) { + const containers: any[] = deployment.containers || deployment.container_specs || deployment.spec_containers || []; + if (containers.length === 0) { + return

无容器详情数据(扁平字段缺少 containers,可查看 YAML)

; + } + return ( +
+ {containers.map((c, idx) => { + const cpuReq = c.resources?.requests?.cpu || c.cpu_request; + const cpuLim = c.resources?.limits?.cpu || c.cpu_limit; + const memReq = c.resources?.requests?.memory || c.memory_request; + const memLim = c.resources?.limits?.memory || c.memory_limit; + const ports: any[] = c.ports || c.container_ports || []; + return ( +
+
+
+ + {c.name || `container-${idx}`} +
+ {c.image} +
+
+
+ + CPU: {cpuReq || '-'}/{cpuLim || '-'} +
+
+ + 内存: {memReq || '-'}/{memLim || '-'} +
+
+ + 端口: {ports.map((p: any) => typeof p === 'object' ? (p.containerPort || p.port) : p).join(', ') || '-'} +
+
+ + 挂载: {(c.volume_mounts || c.volumeMounts || []).length || 0} +
+
+ {/* 探针 */} +
+ {c.liveness_probe || c.livenessProbe ? ( + ● 存活探针 + ) : ○ 无存活探针} + {c.readiness_probe || c.readinessProbe ? ( + ● 就绪探针 + ) : ○ 无就绪探针} + {c.startup_probe || c.startupProbe ? ( + ● 启动探针 + ) : null} +
+
+ ); + })} +
+ ); +} + +// ============ Pod 状态时间线(调度→初始化→容器就绪→就绪) ============ +function PodTimeline({ pod }: { pod: PodItem }) { + const tl = getPodTimeline(pod); + const stages = [ + { label: '调度', ts: tl.scheduled, color: STATUS_COLORS.blue }, + { label: '初始化', ts: tl.initialized, color: STATUS_COLORS.orange }, + { label: '容器就绪', ts: tl.containersReady, color: STATUS_COLORS.green }, + { label: 'Pod 就绪', ts: tl.ready, color: STATUS_COLORS.green }, + ]; + return ( +
+

状态时间线

+
+ {stages.map((s, i) => { + const prev = stages[i - 1]; + const dur = elapsedSeconds(prev?.ts, s.ts); + return ( +
+
+
+ {s.label} + {s.ts ? formatK8sTime(s.ts) : '-'} +
+ {i < stages.length - 1 && ( +
+
+ {dur != null ? formatDuration(dur) : ''} +
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/frontend/src/components/kubernetes/DeploymentWorkload.tsx b/frontend/src/components/kubernetes/DeploymentWorkload.tsx new file mode 100644 index 0000000..fe2dafc --- /dev/null +++ b/frontend/src/components/kubernetes/DeploymentWorkload.tsx @@ -0,0 +1,569 @@ +// DeploymentWorkload.tsx — Kuboard 风格 Deployment 详情主组件 +// - 顶部操作菜单: 编辑 | 预览/编辑YAML | 调整镜像版本 | 伸缩 | 日志/终端 | 重启 | 删除 | 元数据 | 运行时 | 服务 | 应用路由 +// - 双模式: view(只读增强看板)/ edit(表单编辑器) +// 作者: Logan +import { useState, useEffect, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Card, CardContent } 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 { + DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, +} from '@/components/ui/dropdown-menu'; +import { + ArrowLeft, Pencil, FileCode, Image as ImageIcon, Minus, Plus, RotateCw, + Trash2, FileText, Terminal, RefreshCw, Loader2, X, + ChevronDown, Cpu, Network, GitBranch, Info, + CheckCircle, AlertTriangle, XCircle, +} from 'lucide-react'; +import { kubernetesApi } from '@/services/api'; +import { useToast } from '@/hooks/use-toast'; +import { useUserStore } from '@/store'; +import { YamlViewer } from './YamlViewer'; +import { DeploymentView } from './DeploymentView'; +import { DeploymentEdit } from './DeploymentEdit'; + +export interface DeploymentWorkloadProps { + clusterCode: string; + namespace: string; + deploymentName: string; + /** 'view' | 'edit' — 来自路由参数 */ + mode: 'view' | 'edit'; + onBack: () => void; + /** 跳转到另一模式(用于"编辑"按钮、保存成功后切换) */ + onModeChange: (mode: 'view' | 'edit') => void; +} + +type ActionModal = + | { kind: 'yaml' } + | { kind: 'image' } + | { kind: 'scale' } + | { kind: 'podLogs'; pod: string } + | { kind: 'podYaml'; pod: string } + | { kind: 'delete' } + | null; + +export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mode, onBack, onModeChange }: DeploymentWorkloadProps) { + const navigate = useNavigate(); + const { toast } = useToast(); + const [deployment, setDeployment] = useState(null); + const [manifest, setManifest] = useState(null); + const [pods, setPods] = useState([]); + const [replicaSets, setReplicaSets] = useState([]); + const [revisions, setRevisions] = useState([]); + const [loading, setLoading] = useState(true); + const [actionLoading, setActionLoading] = useState(''); + const [modal, setModal] = useState(null); + // 调整镜像版本 + const [newImage, setNewImage] = useState(''); + // 伸缩 + const [scaleValue, setScaleValue] = useState(0); + + const toArray = (v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || v?.data || [])); + + const loadData = useCallback(async () => { + setLoading(true); + try { + const [dep, podRes, rsRes, revRes] = await Promise.all([ + kubernetesApi.getDeployment(clusterCode, namespace, deploymentName).catch(() => null), + kubernetesApi.getNamespacePods(clusterCode, namespace).catch(() => []), + kubernetesApi.getReplicaSets(clusterCode, namespace).catch(() => []), + kubernetesApi.getDeploymentRevisions(clusterCode, namespace, deploymentName).catch(() => []), + ]); + const depData = dep || (Array.isArray(dep) ? dep[0] : null); + setDeployment(depData); + setScaleValue(depData?.replicas || 0); + setNewImage(depData?.image || ''); + + const allPods = toArray(podRes); + // 过滤当前 Deployment 的 Pod(按名称前缀,兼容 ownerReference 不可用场景) + const depPods = allPods.filter((p: any) => + p.namespace === namespace && (p.name || '').startsWith(deploymentName) + ); + setPods(depPods); + + const allRs = toArray(rsRes); + const depRs = allRs.filter((rs: any) => + (rs.namespace === namespace) && + ((rs.owner_deployment === deploymentName) || (rs.name || '').startsWith(deploymentName)) + ); + setReplicaSets(depRs); + + setRevisions(toArray(revRes)); + } catch (e) { + console.error('加载详情失败:', e); + } finally { + setLoading(false); + } + }, [clusterCode, namespace, deploymentName]); + + // 加载 manifest(用于 edit 模式 + YAML 编辑) + const loadManifest = useCallback(async () => { + try { + const yamlText = await kubernetesApi.getResourceYaml(clusterCode, namespace, 'deployments', deploymentName); + // 简易 YAML→JSON:交给浏览器原生 JSON.parse 兜底(K8s API 多以 JSON 返回); + // 若是纯 YAML,则尝试用 js-yaml(如项目已安装),否则保留为 null 让编辑模式降级。 + let parsed: any = null; + try { parsed = JSON.parse(yamlText); } catch { + // 非纯 JSON,尝试动态加载 js-yaml + try { + const jsyaml: any = await import('js-yaml'); + parsed = jsyaml.load(yamlText); + } catch { + parsed = null; + } + } + setManifest(parsed || deployment); + } catch (e) { + console.warn('加载 manifest 失败(编辑功能降级):', e); + setManifest(deployment); + } + }, [clusterCode, namespace, deploymentName, deployment]); + + useEffect(() => { loadData(); }, [loadData]); + useEffect(() => { if (mode === 'edit' && !manifest) loadManifest(); }, [mode, manifest, loadManifest]); + + // ============ 操作 ============ + const handleScale = async (delta: number) => { + const newReplicas = (deployment?.replicas || 0) + delta; + if (newReplicas < 0) return; + setActionLoading('scale'); + try { + await kubernetesApi.scaleDeployment(clusterCode, namespace, deploymentName, newReplicas); + toast({ title: '扩缩容成功', description: `副本数已设置为 ${newReplicas}` }); + loadData(); + } catch (e: any) { + toast({ title: '操作失败', description: e?.message || String(e), variant: 'destructive' }); + } finally { setActionLoading(''); } + }; + + const handleScaleTo = async () => { + setActionLoading('scale'); + try { + await kubernetesApi.scaleDeployment(clusterCode, namespace, deploymentName, scaleValue); + toast({ title: '伸缩成功', description: `副本数已设置为 ${scaleValue}` }); + setModal(null); + loadData(); + } catch (e: any) { + toast({ title: '操作失败', description: e?.message || String(e), variant: 'destructive' }); + } finally { setActionLoading(''); } + }; + + const handleRestart = async () => { + if (!confirm(`确认滚动重启 Deployment "${deploymentName}"?`)) return; + setActionLoading('restart'); + try { + await kubernetesApi.restartDeployment(clusterCode, namespace, deploymentName); + toast({ title: '重启指令已下发', description: 'Deployment 正在滚动重启' }); + setTimeout(loadData, 1500); + } catch (e: any) { + toast({ title: '重启失败', description: e?.message || String(e), variant: 'destructive' }); + } finally { setActionLoading(''); } + }; + + const handleUpdateImage = async () => { + if (!newImage.trim()) return; + setActionLoading('image'); + try { + await kubernetesApi.updateDeploymentImage(clusterCode, namespace, deploymentName, newImage); + toast({ title: '镜像更新成功', description: `正在滚动更新为 ${newImage}` }); + setModal(null); + loadData(); + } catch (e: any) { + toast({ title: '更新失败', description: e?.message || String(e), variant: 'destructive' }); + } finally { setActionLoading(''); } + }; + + const handleDelete = async () => { + setActionLoading('delete'); + try { + // 复用通用删除:通过 PUT 空文档或调用隐式端点。若无专用端点,则提示。 + const tk = useUserStore.getState().token || ''; + const baseUrl = import.meta.env.VITE_API_BASE_URL || ''; + const url = `${baseUrl}/api/v1/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${deploymentName}`; + const resp = await fetch(url, { method: 'DELETE', headers: { Authorization: `Bearer ${tk}` } }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + toast({ title: '删除成功', description: `${deploymentName} 已删除` }); + setModal(null); + setTimeout(() => navigate(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments`), 800); + } catch (e: any) { + toast({ title: '删除失败', description: e?.message || String(e), variant: 'destructive' }); + } finally { setActionLoading(''); } + }; + + // 日志流(Pod 实时日志) + const handleShowPodLogs = (pod: any) => setModal({ kind: 'podLogs', pod: pod.name }); + const handleShowPodYaml = (pod: any) => setModal({ kind: 'podYaml', pod: pod.name }); + const handleShowPodTerminal = (_pod: any) => { + toast({ title: '容器终端', description: 'Pod exec 终端复用 KubectlTerminal 通道,开发中' }); + }; + + // ============ 渲染 ============ + if (loading) { + return ( + + + +

加载中...

+
+
+ ); + } + + if (!deployment) { + return ( + + + +

未找到 Deployment: {deploymentName}

+ +
+
+ ); + } + + const ready = deployment.ready_replicas || deployment.readyReplicas || 0; + const total = deployment.replicas || 0; + const isHealthy = ready === total && total > 0; + const viewBase = `/kubernetes/${clusterCode}/namespace/${namespace}/workload`; + + return ( +
+ {/* ============ 顶部:标题 + 模式标识 + 操作菜单 ============ */} +
+
+ +
+
+

{deploymentName}

+ {namespace} + Deployment + + {isHealthy ? : } + {ready}/{total} + +
+

+ 镜像: {deployment.image || '-'} +

+
+
+ +
+ {/* 模式切换 Tab */} +
+ + +
+ + + + {/* 顶部操作菜单(Kuboard 风格下拉) */} + + + + + + onModeChange('edit')}> + 编辑 + + setModal({ kind: 'yaml' })}> + 预览 / 编辑 YAML + + setModal({ kind: 'image' })}> + 调整镜像版本 + + { setScaleValue(deployment.replicas || 0); setModal({ kind: 'scale' }); }}> + 伸缩 + + toast({ title: '日志 / 终端', description: '请在 Pod 列表中点击对应按钮' })}> + 日志 / 终端 + + + 重启 + + setModal({ kind: 'delete' })}> + 删除 + + + toast({ title: '元数据', description: 'Labels / Annotations / Selectors,集成在 YAML 视图中' })}> + 元数据 + + toast({ title: '运行时', description: 'Pod 运行状态请见 Pod 实例区' })}> + 运行时 + + navigate(`${viewBase}/view/Service?selector=${encodeURIComponent(JSON.stringify(deployment.labels || {}))}`)}> + 服务 + + navigate(`${viewBase}/view/Ingress?namespace=${namespace}`)}> + 应用路由 + + + +
+
+ + {/* ============ 主体:view / edit ============ */} + {mode === 'view' ? ( + + ) : ( + { loadManifest(); loadData(); onModeChange('view'); }} + onCancel={() => onModeChange('view')} + /> + )} + + {/* ============ 弹窗:YAML ============ */} + {modal?.kind === 'yaml' && ( + setModal(null)} + onSaved={loadData} + /> + )} + + {/* 弹窗:Pod YAML */} + {modal?.kind === 'podYaml' && ( + setModal(null)} + /> + )} + + {/* ============ 弹窗:调整镜像版本 ============ */} + {modal?.kind === 'image' && ( + setModal(null)}> +
+
+ +

{deployment.image || '-'}

+
+
+ + setNewImage(e.target.value)} + placeholder="harbor.example.com/app:v2.0" + className="mt-1 bg-slate-800 border-slate-700 text-slate-200 text-sm font-mono" /> +
+
+ + +
+
+
+ )} + + {/* ============ 弹窗:伸缩 ============ */} + {modal?.kind === 'scale' && ( + setModal(null)}> +
+

当前副本: {deployment.replicas || 0}

+
+ + setScaleValue(Math.max(0, Number(e.target.value)))} + className="w-24 text-center text-lg font-bold bg-slate-800 border-slate-700 text-slate-200" /> + +
+
+ + +
+
+
+ )} + + {/* ============ 弹窗:删除 ============ */} + {modal?.kind === 'delete' && ( + setModal(null)}> +
+
+ + 此操作不可撤销 +
+

+ 确认删除 Deployment {namespace}/{deploymentName}? + 其管理的所有 Pod 将被一并回收。 +

+
+ + +
+
+
+ )} + + {/* ============ 弹窗:Pod 日志 ============ */} + {modal?.kind === 'podLogs' && ( + p.name === modal.pod)?.containers || []).map((c: any) => c.name).filter(Boolean)} + onClose={() => setModal(null)} + /> + )} +
+ ); +} + +// ============ 通用 Modal 外壳 ============ +function ModalShell({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) { + return ( +
+
e.stopPropagation()}> +
+

{title}

+ +
+ {children} +
+
+ ); +} + +// ============ Pod 日志弹窗(带容器切换 + 行数 + 实时流) ============ +function PodLogsModal({ clusterCode, namespace, podName, containers, onClose }: { + clusterCode: string; namespace: string; podName: string; containers: string[]; onClose: () => void; +}) { + const { toast } = useToast(); + const [logs, setLogs] = useState(''); + const [tail, setTail] = useState(200); + const [container, setContainer] = useState(containers[0] || ''); + const [loading, setLoading] = useState(false); + const [streaming, setStreaming] = useState(false); + + const loadLogs = async () => { + setLoading(true); + try { + const res: any = await kubernetesApi.getPodLogs(clusterCode, namespace, podName, tail, container || undefined); + setLogs(res?.data?.logs || res?.logs || res || '无日志'); + } catch { + setLogs('获取日志失败'); + } finally { setLoading(false); } + }; + + useEffect(() => { loadLogs(); }, [container, tail]); + + const startStream = async () => { + setStreaming(true); + const token = useUserStore.getState().token || ''; + const baseUrl = import.meta.env.VITE_API_BASE_URL || ''; + const cParam = container ? `&container=${encodeURIComponent(container)}` : ''; + const url = `${baseUrl}/api/v1/kubernetes/${clusterCode}/namespace/${namespace}/pods/${podName}/logs?tail=${tail}&stream=true${cParam}`; + try { + const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }); + const reader = resp.body?.getReader(); + if (!reader) return; + const decoder = new TextDecoder(); + let buf = ''; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + setLogs(buf); + } + } catch { + toast({ title: '流式日志连接中断', variant: 'destructive' }); + } finally { setStreaming(false); } + }; + + return ( +
+
e.stopPropagation()}> +
+
+ + {podName} + {container && {container}} +
+
+ {containers.length > 0 && ( + + )} + 最近 + setTail(Number(e.target.value) || 200)} + className="w-16 h-7 bg-slate-800 border-slate-700 text-slate-200 text-xs" /> + 行 + + + +
+
+
+
+            {logs || '无日志内容'}
+          
+
+
+
+ ); +} diff --git a/frontend/src/components/kubernetes/KubernetesClusterDialog.tsx b/frontend/src/components/kubernetes/KubernetesClusterDialog.tsx index d16f32f..379e93d 100644 --- a/frontend/src/components/kubernetes/KubernetesClusterDialog.tsx +++ b/frontend/src/components/kubernetes/KubernetesClusterDialog.tsx @@ -13,9 +13,11 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { kubernetesApi } from '@/services/api'; +import { toast } from 'sonner'; const emptyForm = { name: '', + code: '', version: '', description: '', config_type: 1, // 0=Token, 1=Kubeconfig @@ -51,6 +53,7 @@ export function KubernetesClusterDialog({ setHasKubeConfig(!!d.has_kube_config); setFormData({ name: d.name || '', + code: d.code || d.name?.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/^-+|-+$/g, '') || '', version: d.version || '', description: d.description || '', config_type: Number(d.config_type ?? 1), @@ -62,6 +65,7 @@ export function KubernetesClusterDialog({ // 降级:用列表数据 setFormData({ name: editingCluster.name || '', + code: editingCluster.code || editingCluster.name?.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/^-+|-+$/g, '') || '', version: editingCluster.version || '', description: editingCluster.description || '', config_type: Number(editingCluster.config_type ?? 1), @@ -93,11 +97,12 @@ export function KubernetesClusterDialog({ }; const handleSubmit = async () => { - if (!formData.name.trim()) return; + if (!formData.name.trim() || !formData.code.trim()) { toast.error('请填写集群名称和代码'); return; } try { setSubmitting(true); const payload = { name: formData.name, + code: formData.code, version: formData.version, description: formData.description, config_type: formData.config_type, @@ -136,11 +141,24 @@ export function KubernetesClusterDialog({ setFormData({ ...formData, name: e.target.value })} + onChange={(e) => { + const name = e.target.value; + const code = name.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/^-+|-+$/g, ''); + setFormData({ ...formData, name, code: formData.code || code }); + }} placeholder="例如:prod-cluster" className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]" />
+
+ + setFormData({ ...formData, code: e.target.value })} + placeholder="如: prod-cluster" + className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] font-mono text-sm" + /> +
+ + + +
+
+ + {/* 终端容器 */} +
+ + + ); +} diff --git a/frontend/src/components/kubernetes/PodFileBrowser.tsx b/frontend/src/components/kubernetes/PodFileBrowser.tsx new file mode 100644 index 0000000..77e7733 --- /dev/null +++ b/frontend/src/components/kubernetes/PodFileBrowser.tsx @@ -0,0 +1,552 @@ +import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; +import { + Dialog, + DialogContent, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Folder, FolderOpen, FileText, FileCode, FileArchive, FileImage, + File, Download, Eye, ChevronRight, ArrowLeft, ArrowRight, ArrowUp, + RotateCw, Loader2, Maximize2, Minimize2, X, Box, Home, HardDrive, + AlertCircle, FileType, Binary, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { kubernetesApi } from '@/services/api'; +import { useToast } from '@/hooks/use-toast'; + +/** + * 容器文件浏览器 — 浏览 Pod 内文件系统 + * + * - 调 POST .../pods/:pod/files/list {path:"/"} 获取目录 + * - 调 GET .../pods/:pod/files/download?path=/xxx 下载文件 + * - 面包屑导航 + 返回上级 + * - 文件类型图标 + * - 下载 / 查看文本(小文件预览) + * - 浏览器风格历史栈(前进 / 后退) + * + * 注:后端文件浏览器 API 并行开发中,UI 与 API 层先行; + * 返回结构兼容 {entries:[{name,isDir,size,modTime}]} 或数组。 + * + * @author Logan + */ +interface PodFileBrowserProps { + open: boolean; + onOpenChange: (open: boolean) => void; + clusterCode: string; + namespace: string; + podName: string; + containers?: string[]; + initialContainer?: string; +} + +interface FileEntry { + name: string; + isDir: boolean; + size?: number; + modTime?: string; + mode?: string; +} + +// 文本预览大小阈值(超过则提示下载) +const TEXT_PREVIEW_MAX = 256 * 1024; // 256KB +// 可预览的扩展名 +const TEXT_EXTENSIONS = new Set([ + '.txt', '.log', '.conf', '.cfg', '.ini', '.yaml', '.yml', '.json', + '.xml', '.md', '.sh', '.bash', '.env', '.properties', '.toml', + '.js', '.ts', '.jsx', '.tsx', '.py', '.go', '.java', '.rs', '.c', + '.cpp', '.h', '.hpp', '.css', '.html', '.sql', '.csv', '.gitignore', + '.dockerfile', '.makefile', +]); + +function normalizePath(p: string): string { + if (!p) return '/'; + // 去除重复斜杠 + let s = p.replace(/\/+/g, '/'); + if (!s.startsWith('/')) s = '/' + s; + if (s.length > 1 && s.endsWith('/')) s = s.slice(0, -1); + return s; +} + +function joinPath(base: string, name: string): string { + return normalizePath(base === '/' ? `/${name}` : `${base}/${name}`); +} + +function parentPath(p: string): string { + const n = normalizePath(p); + if (n === '/') return '/'; + const idx = n.lastIndexOf('/'); + return idx <= 0 ? '/' : n.slice(0, idx); +} + +function pathSegments(p: string): { name: string; path: string }[] { + const n = normalizePath(p); + const segs = [{ name: '/', path: '/' }]; + if (n === '/') return segs; + const parts = n.split('/').filter(Boolean); + let cur = ''; + for (const part of parts) { + cur = cur === '' ? `/${part}` : `${cur}/${part}`; + segs.push({ name: part, path: cur }); + } + return segs; +} + +function getExt(name: string): string { + const idx = name.lastIndexOf('.'); + if (idx === -1) return ''; + // 处理 .gitignore 这类 + if (idx === 0) return name.toLowerCase(); + return name.slice(idx).toLowerCase(); +} + +function getFileIcon(name: string, isDir: boolean) { + if (isDir) return Folder; + const ext = getExt(name); + if (['.yaml', '.yml', '.json', '.xml', '.toml', '.ini', '.conf', '.cfg', '.env', '.properties'].includes(ext)) + return FileCode; + if (['.js', '.ts', '.jsx', '.tsx', '.py', '.go', '.java', '.rs', '.c', '.cpp', '.h', '.hpp', '.sh', '.bash', '.sql'].includes(ext)) + return FileCode; + if (['.log', '.txt', '.md', '.csv'].includes(ext)) return FileText; + if (['.tar', '.gz', '.zip', '.tgz', '.bz2', '.xz', '.7z', '.rar'].includes(ext)) return FileArchive; + if (['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp', '.ico'].includes(ext)) return FileImage; + if (name.toLowerCase() === 'dockerfile' || ext === '.dockerfile') return FileType; + return File; +} + +function formatSize(bytes?: number): string { + if (bytes === undefined || bytes === null) return '-'; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`; + return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`; +} + +export function PodFileBrowser({ + open, + onOpenChange, + clusterCode, + namespace, + podName, + containers = [], + initialContainer, +}: PodFileBrowserProps) { + const { toast } = useToast(); + + const [fullscreen, setFullscreen] = useState(false); + const [container, setContainer] = useState(initialContainer || containers[0] || ''); + + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + // 浏览器风格历史栈 + const [historyStack, setHistoryStack] = useState(['/']); + const [historyIndex, setHistoryIndex] = useState(0); + const currentPath = historyStack[historyIndex] || '/'; + + // 预览 + const [preview, setPreview] = useState<{ path: string; content: string; loading: boolean } | null>(null); + + // 选中的条目(用于高亮) + const [selected, setSelected] = useState(''); + + useEffect(() => { + if (!open) setFullscreen(false); + }, [open]); + + useEffect(() => { + if (open && !container && containers.length > 0) { + setContainer(initialContainer || containers[0]); + } + }, [open, containers, initialContainer, container]); + + // 加载目录 + const loadPath = useCallback(async (path: string) => { + if (!podName) return; + const n = normalizePath(path); + setLoading(true); + setError(''); + setSelected(''); + try { + const res: any = await kubernetesApi.listPodFiles(clusterCode, namespace, podName, n, container || undefined); + // 兼容多种返回结构 + let arr: any[] = []; + if (Array.isArray(res)) arr = res; + else if (Array.isArray(res?.entries)) arr = res.entries; + else if (Array.isArray(res?.items)) arr = res.items; + else if (Array.isArray(res?.files)) arr = res.files; + else if (Array.isArray(res?.data)) arr = res.data; + else if (res?.data && Array.isArray(res.data.entries)) arr = res.data.entries; + + const list: FileEntry[] = arr.map((it: any) => ({ + name: it.name ?? it.Name ?? it.path?.split('/').pop() ?? '', + isDir: Boolean(it.isDir ?? it.IsDir ?? it.dir ?? it.directory ?? (it.mode?.startsWith('d'))), + size: it.size ?? it.Size, + modTime: it.modTime ?? it.ModTime ?? it.mtime ?? it.modified, + mode: it.mode ?? it.Mode, + })).filter((e) => e.name && e.name !== '.'); + + // 排序:目录优先,再按名称 + list.sort((a, b) => { + if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; + return a.name.localeCompare(b.name); + }); + + setEntries(list); + } catch (e: any) { + setError(e?.message || String(e)); + setEntries([]); + // 后端 API 并行开发中,错误时给出友好提示 + } finally { + setLoading(false); + } + }, [clusterCode, namespace, podName, container]); + + // 打开 / 路径变化时加载 + useEffect(() => { + if (open && podName) { + loadPath(currentPath); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, podName, currentPath, container]); + + // 浏览器风格导航 + const navigateTo = useCallback((path: string) => { + const n = normalizePath(path); + setHistoryStack((stack) => { + const next = stack.slice(0, historyIndex + 1); + // 避免重复 + if (next[next.length - 1] !== n) next.push(n); + return next; + }); + setHistoryIndex((i) => i + 1); + }, [historyIndex]); + + const goBack = useCallback(() => { + if (historyIndex > 0) { + setHistoryIndex(historyIndex - 1); + } + }, [historyIndex]); + + const goForward = useCallback(() => { + if (historyIndex < historyStack.length - 1) { + setHistoryIndex(historyIndex + 1); + } + }, [historyIndex, historyStack.length]); + + const goUp = useCallback(() => { + navigateTo(parentPath(currentPath)); + }, [currentPath, navigateTo]); + + const canGoBack = historyIndex > 0; + const canGoForward = historyIndex < historyStack.length - 1; + + // 进入目录 / 预览文件 + const handleEntryClick = useCallback((entry: FileEntry) => { + setSelected(entry.name); + const fullPath = joinPath(currentPath, entry.name); + if (entry.isDir) { + navigateTo(fullPath); + } else { + // 双击或点击预览文本文件 + const ext = getExt(entry.name); + const isText = TEXT_EXTENSIONS.has(ext) || (entry.size ?? 0) <= TEXT_PREVIEW_MAX && !Binary; + if (isText && (entry.size ?? 0) <= TEXT_PREVIEW_MAX) { + previewFile(fullPath); + } else if ((entry.size ?? 0) > TEXT_PREVIEW_MAX && isText) { + toast({ title: '文件较大', description: `超过 ${formatSize(TEXT_PREVIEW_MAX)},建议下载查看`, variant: 'default' }); + } + } + }, [currentPath, navigateTo, toast]); + + // 预览文本文件 + const previewFile = useCallback(async (path: string) => { + setPreview({ path, content: '', loading: true }); + try { + const text = await kubernetesApi.readPodFileText(clusterCode, namespace, podName, path, container || undefined); + setPreview({ path, content: text, loading: false }); + } catch (e: any) { + setPreview({ path, content: `[预览失败: ${e?.message || String(e)}]`, loading: false }); + toast({ title: '预览失败', description: e?.message || String(e), variant: 'destructive' }); + } + }, [clusterCode, namespace, podName, container, toast]); + + // 下载文件 + const handleDownload = useCallback((path: string) => { + const url = kubernetesApi.buildPodFileDownloadUrl(clusterCode, namespace, podName, path, container || undefined); + window.open(url, '_blank'); + toast({ title: '已开始下载', description: path }); + }, [clusterCode, namespace, podName, container, toast]); + + const breadcrumbs = useMemo(() => pathSegments(currentPath), [currentPath]); + + return ( + + e.preventDefault()} + className={cn( + 'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0', + '!duration-0 !transition-none', + fullscreen + ? '!max-w-none !w-screen !h-screen !rounded-none !translate-x-0 !translate-y-0 !left-0 !top-0' + : '' + )} + style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '1000px', width: '94vw' }} + > + 文件 — {podName} + 浏览 Pod 容器文件系统 + + {/* 头部 */} +
+
+ + 文件浏览器 — {podName} + {loading && } + {entries.length} 项 +
+
+ {containers.length > 0 && ( + + )} + + + +
+
+ + {/* 工具栏:导航按钮 + 面包屑 */} +
+ + + + + + {/* 面包屑 */} +
+ {breadcrumbs.map((seg, i) => ( +
+ {i > 0 && } + +
+ ))} +
+ {currentPath} +
+ + {/* 主体:左列表 / 右预览 */} +
+ {/* 文件列表 */} +
+ {error && ( +
+ +

加载失败

+

{error}

+

+ 文件浏览器 API 仍在并行开发中,请确认后端 POST .../files/list 已就绪。 +

+ +
+ )} + + {!error && loading && entries.length === 0 && ( +
+ 加载中… +
+ )} + + {!error && !loading && entries.length === 0 && ( +
+ + 空目录 +
+ )} + + {!error && entries.length > 0 && ( + + + + + + + + + + + {currentPath !== '/' && ( + + + + + + + )} + {entries.map((entry) => { + const Icon = getFileIcon(entry.name, entry.isDir); + const fullPath = joinPath(currentPath, entry.name); + const isSelected = selected === entry.name; + return ( + handleEntryClick(entry)} + > + + + + + + ); + })} + +
名称大小修改时间操作
+ + .. +
+
+ + + {entry.name} + +
+
+ {entry.isDir ? '-' : formatSize(entry.size)} + + {entry.modTime || '-'} + e.stopPropagation()}> +
+ {!entry.isDir && ( + <> + + + )} + +
+
+ )} +
+ + {/* 预览面板 */} + {preview && ( +
+
+
+ + + {preview.path} + +
+
+ + +
+
+
+ {preview.loading ? ( +
+ 加载中… +
+ ) : ( +
+                    {preview.content || '(空文件)'}
+                  
+ )} +
+
+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/kubernetes/PodLogsViewer.tsx b/frontend/src/components/kubernetes/PodLogsViewer.tsx new file mode 100644 index 0000000..ae81cbb --- /dev/null +++ b/frontend/src/components/kubernetes/PodLogsViewer.tsx @@ -0,0 +1,550 @@ +import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; +import { + Dialog, + DialogContent, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + ScrollText, Maximize2, Minimize2, X, Play, Pause, Download, Search, + RefreshCw, Loader2, Box, History, Clock, Filter, AlertCircle, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { kubernetesApi } from '@/services/api'; +import { useToast } from '@/hooks/use-toast'; + +/** + * 增强版 Pod 日志查看器 — 增强 DeploymentDetail 的日志体验 + * + * 功能: + * - 容器选择下拉(多容器 Pod) + * - 实时跟踪开关(tail -f 效果,用 fetch stream) + * - 行数限制选择(100 / 500 / 1000 / 全部) + * - previous 容器日志(上一次崩溃的日志) + * - 时间范围筛选(sinceTime) + * - 关键字过滤(前端过滤,高亮) + * - 下载按钮(导出当前日志为文件) + * - 暂停 / 继续(流式时) + * - 10000 行环形缓冲(借鉴 opsx-agent) + * + * @author Logan + */ +interface PodLogsViewerProps { + open: boolean; + onOpenChange: (open: boolean) => void; + clusterCode: string; + namespace: string; + podName: string; + /** 可选:Pod 容器名列表 */ + containers?: string[]; + initialContainer?: string; +} + +const TAIL_OPTIONS = [ + { value: '100', label: '最近 100 行' }, + { value: '500', label: '最近 500 行' }, + { value: '1000', label: '最近 1000 行' }, + { value: '0', label: '全部' }, +]; + +// 环形缓冲大小(借鉴 opsx-agent) +const RING_BUFFER_MAX = 10000; + +interface LogLine { + id: number; + text: string; +} + +export function PodLogsViewer({ + open, + onOpenChange, + clusterCode, + namespace, + podName, + containers = [], + initialContainer, +}: PodLogsViewerProps) { + const { toast } = useToast(); + + const [fullscreen, setFullscreen] = useState(false); + const [container, setContainer] = useState(initialContainer || containers[0] || ''); + const [tailLines, setTailLines] = useState(500); + const [previous, setPrevious] = useState(false); + const [sinceTime, setSinceTime] = useState(''); + const [keyword, setKeyword] = useState(''); + const [autoScroll, setAutoScroll] = useState(true); + + // 流式状态 + const [streaming, setStreaming] = useState(false); + const [paused, setPaused] = useState(false); + const [loading, setLoading] = useState(false); + + // 环形缓冲(使用 state 触发渲染,ref 持久存储) + const [lines, setLines] = useState([]); + const linesRef = useRef([]); + const lineIdRef = useRef(0); + const abortRef = useRef(null); + const pausedRef = useRef(false); + const autoScrollRef = useRef(true); + const scrollRef = useRef(null); + + useEffect(() => { pausedRef.current = paused; }, [paused]); + useEffect(() => { autoScrollRef.current = autoScroll; }, [autoScroll]); + + // 重置全屏状态 + useEffect(() => { + if (!open) setFullscreen(false); + }, [open]); + + // 容器列表变化时同步默认值 + useEffect(() => { + if (open && !container && containers.length > 0) { + setContainer(initialContainer || containers[0]); + } + }, [open, containers, initialContainer, container]); + + // 追加行到环形缓冲(批量更新) + const appendLines = useCallback((newTexts: string[]) => { + if (newTexts.length === 0) return; + const next = [...linesRef.current]; + for (const t of newTexts) { + next.push({ id: ++lineIdRef.current, text: t }); + } + // 环形缓冲裁剪 + if (next.length > RING_BUFFER_MAX) { + linesRef.current = next.slice(next.length - RING_BUFFER_MAX); + } else { + linesRef.current = next; + } + setLines(linesRef.current); + }, []); + + // 自动滚动到底部 + useEffect(() => { + if (autoScrollRef.current && scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [lines]); + + // 停止流 + const stopStream = useCallback(() => { + if (abortRef.current) { + abortRef.current.abort(); + abortRef.current = null; + } + setStreaming(false); + setPaused(false); + }, []); + + // 一次性拉取日志 + const fetchLogs = useCallback(async () => { + if (!podName) return; + stopStream(); + setLoading(true); + linesRef.current = []; + setLines([]); + try { + const res: any = await kubernetesApi.getPodLogsEnhanced(clusterCode, namespace, podName, { + container: container || undefined, + tailLines: tailLines, + previous, + sinceTime: sinceTime || undefined, + }); + const raw = res?.logs ?? res?.data?.logs ?? (typeof res === 'string' ? res : '') ?? ''; + const arr = typeof raw === 'string' ? raw.split('\n') : (Array.isArray(raw) ? raw : String(raw).split('\n')); + appendLines(arr); + } catch (e: any) { + appendLines([`\x1b[31m[获取日志失败: ${e?.message || String(e)}]\x1b[0m`]); + toast({ title: '获取日志失败', description: e?.message || String(e), variant: 'destructive' }); + } finally { + setLoading(false); + } + }, [clusterCode, namespace, podName, container, tailLines, previous, sinceTime, appendLines, stopStream, toast]); + + // 启动实时流(fetch ReadableStream,tail -f 效果) + const startStream = useCallback(async () => { + if (!podName) return; + stopStream(); + linesRef.current = []; + setLines([]); + setLoading(true); + setStreaming(true); + setPaused(false); + + const controller = new AbortController(); + abortRef.current = controller; + const url = kubernetesApi.buildPodLogsStreamUrl(clusterCode, namespace, podName, { + container: container || undefined, + tailLines: tailLines, + previous, + sinceTime: sinceTime || undefined, + }); + const token = (await import('@/store')).useUserStore.getState().token || ''; + + try { + const resp = await fetch(url, { + signal: controller.signal, + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + }); + if (!resp.ok || !resp.body) { + throw new Error(`HTTP ${resp.status}`); + } + setLoading(false); + const reader = resp.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + const flush = () => { + if (!buffer) return; + // 按行切分,保留最后未结束的行 + const parts = buffer.split('\n'); + buffer = parts.pop() ?? ''; + if (parts.length > 0) appendLines(parts); + }; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + // 暂停时累积但不写入缓冲(丢弃,保持实时性);这里改为暂停时不 append + buffer += decoder.decode(value, { stream: true }); + if (!pausedRef.current) { + flush(); + } else { + // 暂停期间丢弃缓冲内容,避免堆积 + buffer = ''; + } + } + flush(); // 收尾 + appendLines(['\x1b[33m— 流式结束 —\x1b[0m']); + } catch (e: any) { + if (e?.name === 'AbortError') { + appendLines(['\x1b[33m— 已停止 —\x1b[0m']); + } else { + appendLines([`\x1b[31m[流式连接中断: ${e?.message || String(e)}]\x1b[0m`]); + } + } finally { + setStreaming(false); + setPaused(false); + setLoading(false); + abortRef.current = null; + } + }, [clusterCode, namespace, podName, container, tailLines, previous, sinceTime, appendLines, stopStream]); + + // 打开时自动拉取一次 + useEffect(() => { + if (open && podName) { + fetchLogs(); + } + return () => { stopStream(); }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, podName]); + + // 参数变化时若正在流则重启;否则只重新拉取(用户手动控制更友好,这里仅在非流时自动刷新) + useEffect(() => { + if (!open) return; + if (streaming) { + // 重启流 + startStream(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [container, tailLines, previous, sinceTime]); + + // 关闭弹窗清理 + const handleClose = useCallback(() => { + stopStream(); + onOpenChange(false); + }, [stopStream, onOpenChange]); + + // 下载当前(过滤后)日志 + const handleDownload = useCallback(() => { + const content = filteredLines.map((l) => l.text).join('\n'); + const filename = `${podName}${container ? '-' + container : ''}-${previous ? 'previous-' : ''}${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '')}.log`; + const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = filename; + a.click(); + URL.revokeObjectURL(a.href); + toast({ title: '已导出', description: filename }); + }, [podName, container, previous, toast]); + + // 关键字过滤 + const filteredLines = useMemo(() => { + if (!keyword.trim()) return lines; + const kw = keyword.trim().toLowerCase(); + return lines.filter((l) => l.text.toLowerCase().includes(kw)); + }, [lines, keyword]); + + // 高亮关键字渲染 + const renderLine = (line: LogLine) => { + const text = line.text; + if (!keyword.trim()) return {text}; + const kw = keyword.trim(); + const lower = text.toLowerCase(); + const kwLower = kw.toLowerCase(); + const parts: React.ReactNode[] = []; + let idx = 0; + let key = 0; + while (idx < text.length) { + const found = lower.indexOf(kwLower, idx); + if (found === -1) { + parts.push({text.slice(idx)}); + break; + } + if (found > idx) parts.push({text.slice(idx, found)}); + parts.push( + + {text.slice(found, found + kw.length)} + + ); + idx = found + kw.length; + } + return <>{parts}; + }; + + const lineCount = lines.length; + const matchedCount = filteredLines.length; + + return ( + { if (!o) handleClose(); }}> + e.preventDefault()} + className={cn( + 'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0', + '!duration-0 !transition-none', + fullscreen + ? '!max-w-none !w-screen !h-screen !rounded-none !translate-x-0 !translate-y-0 !left-0 !top-0' + : '' + )} + style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '1100px', width: '94vw' }} + > + 日志 — {podName} + Pod 容器日志查看 + + {/* 头部工具栏 */} +
+
+
+ + 日志 — {podName} + {loading && } + + {streaming ? (paused ? '已暂停' : '实时跟踪') : '已停止'} · {matchedCount}{keyword ? `/${lineCount}` : ''} 行 + {lineCount >= RING_BUFFER_MAX && ' (缓冲已满)'} + +
+ +
+ + +
+
+ + {/* 控制栏 */} +
+ {/* 容器 */} + {containers.length > 0 && ( + + )} + + {/* 行数 */} + + + {/* previous */} + + + {/* sinceTime */} +
+ + { + const v = e.target.value; + setSinceTime(v ? new Date(v).toISOString() : ''); + }} + className="h-8 w-[170px] text-xs bg-[var(--dg-bg-hover)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" + title="起始时间(sinceTime)" + /> +
+ +
+ + {/* 关键字过滤 */} +
+ + setKeyword(e.target.value)} + placeholder="关键字过滤…" + className="h-8 w-[160px] pl-7 text-xs bg-[var(--dg-bg-hover)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" + /> +
+ + {/* 下载 */} + + + {/* 刷新(一次性) */} + + + {/* 实时跟踪 / 暂停 / 继续 */} + {!streaming ? ( + + ) : ( + <> + + + + )} +
+ + {/* 日志内容 */} +
{ + const el = e.currentTarget; + // 用户主动向上滚时关闭自动跟随 + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40; + if (!atBottom && autoScrollRef.current) setAutoScroll(false); + if (atBottom && !autoScrollRef.current) setAutoScroll(true); + }} + className={cn( + 'overflow-auto bg-[#0f172a] font-mono text-xs leading-relaxed p-3', + 'text-slate-300' + )} + style={{ height: fullscreen ? 'calc(100vh - 98px)' : '520px' }} + > + {lineCount === 0 && !loading && ( +
+ + 暂无日志 +
+ )} + {loading && lineCount === 0 && ( +
+ 加载中… +
+ )} + {filteredLines.map((line) => ( +
+ {renderLine(line)} +
+ ))} +
+ + {/* 底部状态条 */} +
+
+ 缓冲 {lineCount}/{RING_BUFFER_MAX} + {keyword && 匹配 {matchedCount}} + {container && {container}} + {previous && previous} +
+ +
+ +
+ ); +} diff --git a/frontend/src/components/kubernetes/YamlViewer.tsx b/frontend/src/components/kubernetes/YamlViewer.tsx index d1e562e..698fbc4 100644 --- a/frontend/src/components/kubernetes/YamlViewer.tsx +++ b/frontend/src/components/kubernetes/YamlViewer.tsx @@ -1,9 +1,9 @@ import { useState, useEffect } from 'react'; -import { X, Copy, Check, Save, RefreshCw, Loader2 } from 'lucide-react'; +import { X, Copy, Check, Save, RefreshCw, Loader2, Sparkles } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { useToast } from '@/hooks/use-toast'; import { kubernetesApi } from '@/services/api'; -import { useUserStore } from '@/store'; +import { cleanYAML } from './k8s-yaml-utils'; interface YamlViewerProps { clusterCode: string; @@ -12,11 +12,14 @@ interface YamlViewerProps { name: string; onClose: () => void; onSaved?: () => void; + /** 进入编辑模式前是否清理只读/噪音字段(managedFields/uid/status 等) */ + cleanBeforeEdit?: boolean; } -export function YamlViewer({ clusterCode, namespace, resource, name, onClose, onSaved }: YamlViewerProps) { +export function YamlViewer({ clusterCode, namespace, resource, name, onClose, onSaved, cleanBeforeEdit = false }: YamlViewerProps) { const { toast } = useToast(); - const [yaml, setYaml] = useState(''); + const [rawYaml, setRawYaml] = useState(''); // 原始(只读展示用) + const [editYaml, setEditYaml] = useState(''); // 编辑态(可能被清理过) const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [editing, setEditing] = useState(false); @@ -25,95 +28,108 @@ export function YamlViewer({ clusterCode, namespace, resource, name, onClose, on const loadYaml = async () => { setLoading(true); try { - const tk = useUserStore.getState().token || ''; - const baseUrl = import.meta.env.VITE_API_BASE_URL || ''; - const url = `${baseUrl}/api/v1/kubernetes/${clusterCode}/namespace/${namespace}/${resource}/${name}/yaml`; - const resp = await fetch(url, { headers: { Authorization: `Bearer ${tk}` } }); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const text = await resp.text(); - setYaml(text); + const text = await kubernetesApi.getResourceYaml(clusterCode, namespace, resource, name); + setRawYaml(text); + setEditYaml(text); } catch (e: any) { toast({ title: '获取YAML失败', description: e.message, variant: 'destructive' }); - setYaml(`# Error: ${e.message}`); + const err = `# Error: ${e.message}`; + setRawYaml(err); + setEditYaml(err); } finally { setLoading(false); } }; + const enterEdit = () => { + // 进入编辑前可选清理 + const cleaned = cleanBeforeEdit ? cleanYAML(rawYaml) : rawYaml; + setEditYaml(cleaned); + setEditing(true); + if (cleanBeforeEdit) { + toast({ title: '已清理只读字段', description: '已剥离 status / managedFields / uid 等' }); + } + }; + const saveYaml = async () => { setSaving(true); try { - const tk = useUserStore.getState().token || ''; - const baseUrl = import.meta.env.VITE_API_BASE_URL || ''; - const url = `${baseUrl}/api/v1/kubernetes/${clusterCode}/namespace/${namespace}/${resource}/${name}/yaml`; - const resp = await fetch(url, { - method: 'PUT', - headers: { Authorization: `Bearer ${tk}`, 'Content-Type': 'application/yaml' }, -body: yaml, -}); -if (!resp.ok) throw new Error(`HTTP ${resp.status}`); -toast({ title: 'YAML已应用', description: `${resource}/${name} 更新成功` }); -setEditing(false); -onSaved?.(); -} catch (e: any) { -toast({ title: '保存失败', description: e.message, variant: 'destructive' }); -} finally { setSaving(false); } -}; + await kubernetesApi.updateResourceYaml(clusterCode, namespace, resource, name, editYaml); + toast({ title: 'YAML已应用', description: `${resource}/${name} 更新成功` }); + setEditing(false); + setRawYaml(editYaml); + onSaved?.(); + } catch (e: any) { + toast({ title: '保存失败', description: e.message, variant: 'destructive' }); + } finally { setSaving(false); } + }; -const copyYaml = async () => { -await navigator.clipboard.writeText(yaml); -setCopied(true); -setTimeout(() => setCopied(false), 2000); -}; + const copyYaml = async () => { + const content = editing ? editYaml : rawYaml; + await navigator.clipboard.writeText(content); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; -useEffect(() => { loadYaml(); }, []); + useEffect(() => { loadYaml(); }, []); -return ( -
-
e.stopPropagation()}> -{/* Header */} -
-
-

{resource}/{name}

-

{namespace} · {clusterCode}

-
-
- -{!editing ? ( - -) : ( -<> - - - -)} - -
-
-{/* Body */} -
-{loading ? ( -
-) : editing ? ( -