fix: K8s 集群添加缺少 code 必填字段

- KubernetesClusterDialog: 新增 code 表单字段
- 名称变更自动生成 code(小写+连字符)
- handleSubmit 增加 code 校验 + toast 提示
- payload 增加 code 字段(后端 binding:required)
This commit is contained in:
attacker committed 2026-06-21 09:50:07 +00:00
1 parent f24fe43ac0
commit ce438c9f05
18 files changed
+3941 -144

No files matched your search

+328 -15
View File
@@ -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)
}
+18
View File
@@ -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)
+168
View File
@@ -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
}
+5
View File
@@ -94,9 +94,14 @@ function App() {
<Route path="namespace/:namespace">
{/* 命名空间概览 */}
<Route index element={<NamespaceOverviewPage />} />
{/* 🆕 Kuboard 风格双模式工作负载路由 (view/edit) */}
<Route path="workload">
<Route path=":mode/:workloadKind/:name" element={<DeploymentDetailPage />} />
</Route>
{/* 各类资源列表 */}
<Route path="deployments">
<Route index element={<NamespaceResourcePage />} />
{/* 兼容旧路由 /deployments/:name → 默认 view 模式 */}
<Route path=":name" element={<DeploymentDetailPage />} />
</Route>
<Route path="pods" element={<NamespaceResourcePage />} />
@@ -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 <admin@attacker.club>)
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<string>('');
// 从 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
<TableCell>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400"
title="查看日志" onClick={() => handleLoadLogs(pod.name)}>
title="查看日志"
onClick={() => {
setLogsViewerPod({ name: pod.name, containers: extractContainers(pod) });
setLogsViewerOpen(true);
}}>
<FileText className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-emerald-400"
title="终端" onClick={() => toast({ title: '终端功能开发中', description: '需要 SPDY/WebSocket 支持' })}>
title="exec 终端"
onClick={() => {
setExecPod({ name: pod.name, containers: extractContainers(pod) });
setExecOpen(true);
}}>
<Terminal className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-amber-400"
title="文件浏览器"
onClick={() => {
setFileBrowserPod({ name: pod.name, containers: extractContainers(pod) });
setFileBrowserOpen(true);
}}>
<FolderOpen className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
@@ -384,6 +429,19 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
<div className="flex items-center justify-between">
<CardTitle className="text-sm text-slate-400">Pod 日志</CardTitle>
<div className="flex items-center gap-2">
{showLogs && logPod && (
<Button
variant="outline" size="sm"
className="border-blue-700 text-blue-400 text-xs h-7"
onClick={() => {
const pod = pods.find((p: any) => p.name === logPod);
setLogsViewerPod({ name: logPod, containers: pod ? extractContainers(pod) : [] });
setLogsViewerOpen(true);
}}
>
<ExternalLink className="h-3 w-3 mr-1" />增强版日志
</Button>
)}
<span className="text-xs text-slate-500">最近</span>
<Input
type="number" value={logTail} onChange={(e) => setLogTail(Number(e.target.value))}
@@ -394,41 +452,45 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
</div>
</CardHeader>
<CardContent>
{!showLogs ? (
{pods.length === 0 ? (
<div className="text-center py-12 text-slate-500">
<FileText className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>点击 Pod 列表中的「日志」按钮查看</p>
<p>暂无 Pod</p>
</div>
) : (
<div>
<div className="flex items-center gap-2 mb-2">
<Button variant="outline" size="sm" className="border-slate-700 text-slate-300 text-xs h-7"
onClick={async () => {
setLogs('');
// Stream mode
const token = useUserStore.getState().token || '';
const url = `${window.location.protocol}//${window.location.host}/api/v1/kubernetes/${clusterCode}/namespace/${namespace}/pods/${logPod}/logs?tail=${logTail}&stream=true`;
try {
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
const reader = resp.body?.getReader();
if (!reader) return;
const decoder = new TextDecoder();
let streamLogs = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
streamLogs += decoder.decode(value, { stream: true });
setLogs(streamLogs);
}
} catch (e) {
setLogs('流式连接中断');
}
}}>
<RefreshCw className="h-3 w-3 mr-1" />实时流
</Button>
<span className="text-xs text-slate-500">{logPod}</span>
</div>
<pre className="bg-slate-950 rounded-lg p-4 text-xs text-slate-300 font-mono overflow-auto max-h-[500px] whitespace-pre-wrap">
<div className="flex items-center gap-3 py-10 justify-center flex-wrap">
<FileText className="h-5 w-5 text-blue-400" />
<span className="text-sm text-slate-400">选择 Pod 打开增强版日志查看器</span>
<Select value={logsTabPod} onValueChange={setLogsTabPod}>
<SelectTrigger className="w-[280px] h-8 bg-slate-800 border-slate-700 text-slate-200 text-xs">
<SelectValue placeholder="选择 Pod…" />
</SelectTrigger>
<SelectContent>
{pods.map((p: any) => (
<SelectItem key={p.name} value={p.name}>{p.name}</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
className="bg-blue-600 hover:bg-blue-700 h-8"
disabled={!logsTabPod}
onClick={() => {
const pod = pods.find((p: any) => p.name === logsTabPod);
setLogsViewerPod({ name: logsTabPod, containers: pod ? extractContainers(pod) : [] });
setLogsViewerOpen(true);
}}
>
<ExternalLink className="h-3 w-3 mr-1" />打开日志
</Button>
<span className="text-xs text-slate-600 block w-full text-center mt-2">
支持实时跟踪、多容器切换、previous 日志、关键字过滤、导出
</span>
</div>
)}
{showLogs && logPod && (
<div className="mt-2">
<pre className="bg-slate-950 rounded-lg p-4 text-xs text-slate-300 font-mono overflow-auto max-h-[300px] whitespace-pre-wrap">
{logs || '无日志内容'}
</pre>
</div>
@@ -499,6 +561,52 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
</Card>
</TabsContent>
</Tabs>
{/* 🆕 Pod exec 终端 */}
{execPod && (
<PodExecTerminal
open={execOpen}
onOpenChange={(o) => {
setExecOpen(o);
if (!o) setExecPod(null);
}}
clusterId={clusterCode}
clusterCode={clusterCode}
namespace={namespace}
podName={execPod.name}
containers={execPod.containers}
/>
)}
{/* 🆕 增强版日志查看器 */}
{logsViewerPod && (
<PodLogsViewer
open={logsViewerOpen}
onOpenChange={(o) => {
setLogsViewerOpen(o);
if (!o) setLogsViewerPod(null);
}}
clusterCode={clusterCode}
namespace={namespace}
podName={logsViewerPod.name}
containers={logsViewerPod.containers}
/>
)}
{/* 🆕 容器文件浏览器 */}
{fileBrowserPod && (
<PodFileBrowser
open={fileBrowserOpen}
onOpenChange={(o) => {
setFileBrowserOpen(o);
if (!o) setFileBrowserPod(null);
}}
clusterCode={clusterCode}
namespace={namespace}
podName={fileBrowserPod.name}
containers={fileBrowserPod.containers}
/>
)}
</div>
);
}
@@ -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 <admin@attacker.club>
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 (
<DeploymentDetail
clusterCode={clusterCode || ''}
namespace={namespace || ''}
deploymentName={name || ''}
<DeploymentWorkload
clusterCode={clusterCode}
namespace={namespace}
deploymentName={deploymentName}
mode={currentMode}
onBack={() => navigate(-1)}
onModeChange={handleModeChange}
/>
);
}
@@ -0,0 +1,374 @@
// DeploymentEdit.tsx — 表单式编辑器(环境变量/资源/探针/挂载)
// 作者: Logan <admin@attacker.club>
// 说明: 前端 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<any>(manifest || {});
const [containers, setContainers] = useState<ContainerSpec[]>(() => 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<ContainerSpec>) => {
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 (
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-8 text-center text-slate-500">
未获取到 manifest 结构化数据,请使用「编辑 YAML」模式直接修改原文。
</CardContent>
</Card>
);
}
return (
<div className="space-y-4">
{/* 顶层:副本数 */}
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3"><CardTitle className="text-sm text-slate-200">副本与镜像策略</CardTitle></CardHeader>
<CardContent className="pt-0">
<div className="flex items-center gap-4">
<div>
<Label className="text-slate-400 text-xs">副本数 (replicas)</Label>
<Input type="number" min={0} value={replicas}
onChange={e => setReplicas(Number(e.target.value))}
className="w-32 mt-1 bg-slate-800 border-slate-700 text-slate-200" />
</div>
</div>
</CardContent>
</Card>
{/* 每个容器一组编辑卡片 */}
{containers.map((c, ci) => (
<Card key={ci} className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
<Container className="h-4 w-4 text-blue-400" />
容器: <span className="font-mono text-blue-300">{c.name}</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 镜像 */}
<div>
<Label className="text-slate-400 text-xs">镜像 (image)</Label>
<Input value={c.image} onChange={e => 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" />
</div>
{/* 资源 requests/limits */}
<div>
<div className="flex items-center gap-2 mb-2">
<Cpu className="h-4 w-4 text-amber-400" />
<span className="text-xs text-slate-300 font-semibold">资源 requests / limits</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
<div>
<Label className="text-[11px] text-slate-500">CPU 请求</Label>
<Input value={c.resources?.requests?.cpu || ''} placeholder="100m"
onChange={e => updateResource(ci, 'requests', 'cpu', e.target.value)}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
</div>
<div>
<Label className="text-[11px] text-slate-500">CPU 上限</Label>
<Input value={c.resources?.limits?.cpu || ''} placeholder="500m"
onChange={e => updateResource(ci, 'limits', 'cpu', e.target.value)}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
</div>
<div>
<Label className="text-[11px] text-slate-500">内存请求</Label>
<Input value={c.resources?.requests?.memory || ''} placeholder="128Mi"
onChange={e => updateResource(ci, 'requests', 'memory', e.target.value)}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
</div>
<div>
<Label className="text-[11px] text-slate-500">内存上限</Label>
<Input value={c.resources?.limits?.memory || ''} placeholder="512Mi"
onChange={e => updateResource(ci, 'limits', 'memory', e.target.value)}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
</div>
</div>
</div>
{/* 探针 */}
<div>
<div className="flex items-center gap-2 mb-2">
<Heart className="h-4 w-4 text-rose-400" />
<span className="text-xs text-slate-300 font-semibold">健康探针</span>
</div>
<div className="space-y-2">
{(['livenessProbe', 'readinessProbe', 'startupProbe'] as const).map(pt => {
const labels = { livenessProbe: '存活探针', readinessProbe: '就绪探针', startupProbe: '启动探针' };
const probe = (c as any)[pt];
const enabled = !!probe;
return (
<div key={pt} className="border border-slate-800 rounded-md p-2 bg-slate-950/30">
<div className="flex items-center justify-between">
<span className="text-xs text-slate-300">{labels[pt]}</span>
<Button size="sm" variant={enabled ? 'destructive' : 'outline'}
className="h-6 text-[11px]"
onClick={() => toggleProbe(ci, pt)}>
{enabled ? '移除' : '启用'}
</Button>
</div>
{enabled && probe?.httpGet && (
<div className="grid grid-cols-3 gap-2 mt-2">
<div>
<Label className="text-[10px] text-slate-500">路径</Label>
<Input value={probe.httpGet.path || ''} placeholder="/healthz"
onChange={e => 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" />
</div>
<div>
<Label className="text-[10px] text-slate-500">端口</Label>
<Input value={probe.httpGet.port ?? ''} placeholder="8080"
onChange={e => 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" />
</div>
<div>
<Label className="text-[10px] text-slate-500">延迟(s)</Label>
<Input type="number" value={probe.initialDelaySeconds ?? ''} placeholder="10"
onChange={e => updateProbe(ci, pt, { initialDelaySeconds: Number(e.target.value) })}
className="bg-slate-800 border-slate-700 text-slate-200 text-xs h-7 font-mono" />
</div>
</div>
)}
</div>
);
})}
</div>
</div>
{/* 环境变量 */}
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-xs text-slate-300 font-semibold">环境变量</span>
<Badge variant="outline" className="border-slate-700 text-slate-500 text-[10px]">{c.env?.length || 0}</Badge>
</div>
<Button size="sm" variant="outline" className="h-7 border-slate-700 text-slate-300 text-xs" onClick={() => addEnv(ci)}>
<Plus className="h-3 w-3 mr-1" />新增
</Button>
</div>
<div className="space-y-1.5">
{(c.env || []).map((env, ei) => (
<div key={ei} className="grid grid-cols-[1fr_1.4fr_auto] gap-2">
<Input value={env.name} placeholder="KEY"
onChange={e => updateEnv(ci, ei, { name: e.target.value })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
<Input value={env.value || ''} placeholder="value"
onChange={e => updateEnv(ci, ei, { value: e.target.value })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-400 hover:text-red-300"
onClick={() => removeEnv(ci, ei)}><Trash2 className="h-3.5 w-3.5" /></Button>
</div>
))}
{(!c.env || c.env.length === 0) && <p className="text-[11px] text-slate-600">无环境变量</p>}
</div>
</div>
{/* 卷挂载 */}
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<FolderOpen className="h-3.5 w-3.5 text-emerald-400" />
<span className="text-xs text-slate-300 font-semibold">Volume 挂载</span>
</div>
<Button size="sm" variant="outline" className="h-7 border-slate-700 text-slate-300 text-xs" onClick={() => addMount(ci)}>
<Plus className="h-3 w-3 mr-1" />新增
</Button>
</div>
<div className="space-y-1.5">
{(c.volumeMounts || []).map((m, mi) => (
<div key={mi} className="grid grid-cols-[1fr_1.4fr_auto] gap-2">
<Input value={m.name} placeholder="volume 名称"
onChange={e => updateMount(ci, mi, { name: e.target.value })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
<Input value={m.mountPath} placeholder="/path/to/mount"
onChange={e => updateMount(ci, mi, { mountPath: e.target.value })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-400 hover:text-red-300"
onClick={() => removeMount(ci, mi)}><Trash2 className="h-3.5 w-3.5" /></Button>
</div>
))}
{(!c.volumeMounts || c.volumeMounts.length === 0) && <p className="text-[11px] text-slate-600">无卷挂载</p>}
</div>
</div>
</CardContent>
</Card>
))}
{/* 保存栏 */}
<div className="sticky bottom-0 bg-slate-950/80 backdrop-blur border-t border-slate-800 p-3 flex items-center justify-between">
<p className="text-[11px] text-slate-500">
保存将通过 <code className="text-slate-400">PUT application/yaml</code> 提交(自动剥离 status/managedFields 等只读字段)
</p>
<div className="flex gap-2">
<Button variant="outline" className="border-slate-700 text-slate-400" onClick={onCancel}>取消</Button>
<Button className="bg-blue-600 hover:bg-blue-700" disabled={saving} onClick={save}>
{saving ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Save className="h-4 w-4 mr-1" />}
保存
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,395 @@
// DeploymentView.tsx — 增强只读详情看板(对标 Kuboard)
// 作者: Logan <admin@attacker.club>
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 (
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-3">
<p className="text-xs text-slate-500">{label}</p>
<p className="text-base font-bold mt-0.5" style={{ color: color || '#e2e8f0' }}>{value}</p>
{sub && <p className="text-[10px] text-slate-600 mt-0.5">{sub}</p>}
</CardContent>
</Card>
);
}
export function DeploymentView({
clusterCode, namespace, deployment, pods, replicaSets, revisions,
onRefresh, onShowPodLogs, onShowPodTerminal, onShowPodYaml,
}: DeploymentViewProps) {
const navigate = useNavigate();
const { toast } = useToast();
const [expandedPod, setExpandedPod] = useState<string | null>(null);
const [rollbackLoading, setRollbackLoading] = useState<number | null>(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 (
<div className="space-y-4">
{/* ============ 1. 状态总览 ============ */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
<StatCard label="副本就绪" value={`${ready}/${total}`} color={isHealthy ? STATUS_COLORS.green : STATUS_COLORS.orange} sub={isHealthy ? '全部就绪' : '部分未就绪'} />
<StatCard label="可用副本" value={available} color={STATUS_COLORS.blue} />
<StatCard label="已更新副本" value={updated} color={STATUS_COLORS.green} />
<StatCard label="策略" value={deployment.strategy || 'RollingUpdate'} color="#94a3b8" />
<StatCard label="创建时间" value={<span className="text-xs">{formatK8sTime(createdAt)}</span>} color="#94a3b8" />
<StatCard label="更新时间" value={<span className="text-xs">{formatK8sTime(updatedAt)}</span>} color="#94a3b8" />
</div>
{/* ============ 2. 容器详情(CPU/内存/镜像/端口) ============ */}
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-slate-200 flex items-center gap-2"><Container className="h-4 w-4 text-blue-400" />容器模板(Pod Template)</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<ContainersTable deployment={deployment} />
</CardContent>
</Card>
{/* ============ 3. Pod 状态时间线 + 详情列表 ============ */}
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
<Activity className="h-4 w-4 text-emerald-400" />Pod 实例 ({pods.length})
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400 w-8"></TableHead>
<TableHead className="text-slate-400">Pod 名称</TableHead>
<TableHead className="text-slate-400">状态</TableHead>
<TableHead className="text-slate-400">所在节点</TableHead>
<TableHead className="text-slate-400">Pod IP</TableHead>
<TableHead className="text-slate-400">重启</TableHead>
<TableHead className="text-slate-400">调度→就绪</TableHead>
<TableHead className="text-slate-400">启动时间</TableHead>
<TableHead className="text-slate-400">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pods.length === 0 ? (
<TableRow><TableCell colSpan={9} className="text-center text-slate-500 py-8">暂无 Pod</TableCell></TableRow>
) : 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 (
<Fragment key={pod.name}>
<TableRow className="border-slate-800 hover:bg-slate-800/30">
<TableCell>
<Button variant="ghost" size="icon" className="h-6 w-6 text-slate-500" onClick={() => setExpandedPod(expanded ? null : pod.name)}>
{expanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</Button>
</TableCell>
<TableCell className="font-medium text-slate-200">{pod.name}</TableCell>
<TableCell>
<Badge style={{ background: `${statusColor(pod.status)}22`, color: statusColor(pod.status), border: `1px solid ${statusColor(pod.status)}44` }}>
{pod.status || 'Unknown'}
</Badge>
</TableCell>
<TableCell>
<button className="text-sm text-blue-400 hover:underline inline-flex items-center gap-1" onClick={() => nodeClick(nodeName)} title="跳转节点详情">
<Server className="h-3 w-3" />{nodeName || '-'}
</button>
</TableCell>
<TableCell className="text-sm text-slate-400 font-mono">{pod.ip || pod.pod_ip || '-'}</TableCell>
<TableCell className={(pod.restart_count || 0) > 0 ? 'text-yellow-400' : 'text-slate-500'}>{pod.restart_count || 0}</TableCell>
<TableCell className="text-xs text-slate-400">{formatDuration(schedToReady)}</TableCell>
<TableCell className="text-xs text-slate-500">{formatK8sTime(tl.startTime || pod.created_at)}</TableCell>
<TableCell>
<div className="flex items-center gap-0.5">
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400" title="日志" onClick={() => onShowPodLogs(pod)}><FileCode className="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-emerald-400" title="终端" onClick={() => onShowPodTerminal(pod)}><Container className="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-amber-400" title="YAML" onClick={() => onShowPodYaml(pod)}><FileCode className="h-3.5 w-3.5" /></Button>
</div>
</TableCell>
</TableRow>
{expanded && (
<TableRow className="border-slate-800 bg-slate-950/40">
<TableCell colSpan={9} className="p-4">
<PodTimeline pod={pod} />
</TableCell>
</TableRow>
)}
</Fragment>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
{/* ============ 4. 历史版本(可回滚) ============ */}
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-slate-200 flex items-center gap-2"><History className="h-4 w-4 text-purple-400" />历史版本(Revision / Rollout)</CardTitle>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">Revision</TableHead>
<TableHead className="text-slate-400">镜像</TableHead>
<TableHead className="text-slate-400">状态</TableHead>
<TableHead className="text-slate-400">更新时间</TableHead>
<TableHead className="text-slate-400">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{revisions.length === 0 ? (
<TableRow><TableCell colSpan={5} className="text-center text-slate-500 py-6 text-xs">后端 /revisions 接口尚未就绪,等待后端联调</TableCell></TableRow>
) : revisions.map((rev, idx) => {
const revision = rev.revision ?? rev.revision_number ?? rev.number ?? idx + 1;
const isLatest = idx === 0;
return (
<TableRow key={revision} className="border-slate-800 hover:bg-slate-800/30">
<TableCell className="font-mono text-slate-200">#{revision}</TableCell>
<TableCell className="text-xs font-mono text-slate-400 max-w-[280px] truncate">{rev.image || rev.images?.[0] || '-'}</TableCell>
<TableCell>
{isLatest
? <Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30">当前</Badge>
: <Badge className="bg-slate-700/40 text-slate-400 border-slate-600">历史</Badge>}
</TableCell>
<TableCell className="text-xs text-slate-500">{formatK8sTime(rev.created_at || rev.time)}</TableCell>
<TableCell>
<Button variant="ghost" size="sm" className="h-7 text-amber-400 hover:text-amber-300 disabled:opacity-40"
disabled={isLatest || rollbackLoading !== null}
onClick={() => handleRollback(rev)}>
{rollbackLoading === revision ? '回滚中...' : <><RotateCcw className="h-3 w-3 mr-1" />回滚</>}
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
{/* ============ 5. ReplicaSet 列表 ============ */}
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-slate-200 flex items-center gap-2"><Container className="h-4 w-4 text-cyan-400" />ReplicaSet 列表 ({replicaSets.length})</CardTitle>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">名称</TableHead>
<TableHead className="text-slate-400">副本</TableHead>
<TableHead className="text-slate-400">镜像</TableHead>
<TableHead className="text-slate-400">Revision</TableHead>
<TableHead className="text-slate-400">状态</TableHead>
<TableHead className="text-slate-400">创建时间</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{replicaSets.length === 0 ? (
<TableRow><TableCell colSpan={6} className="text-center text-slate-500 py-6 text-xs">后端 /replicasets 接口尚未就绪,等待后端联调</TableCell></TableRow>
) : 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 (
<TableRow key={r.name} className="border-slate-800 hover:bg-slate-800/30">
<TableCell className="font-medium text-slate-200">
{r.name}
{r.controlled && <Badge variant="outline" className="ml-2 border-emerald-700 text-emerald-400 text-[10px]">当前</Badge>}
</TableCell>
<TableCell className="text-slate-300">{rsReady}/{rsTotal}</TableCell>
<TableCell className="text-xs font-mono text-slate-400 max-w-[240px] truncate">{r.image || '-'}</TableCell>
<TableCell className="text-xs text-slate-400 font-mono">#{r.revision || '-'}</TableCell>
<TableCell>
<Badge style={{ background: `${ok ? STATUS_COLORS.green : STATUS_COLORS.orange}22`, color: ok ? STATUS_COLORS.green : STATUS_COLORS.orange, border: `1px solid ${ok ? STATUS_COLORS.green : STATUS_COLORS.orange}44` }}>
{r.status || (ok ? 'Ready' : 'Updating')}
</Badge>
</TableCell>
<TableCell className="text-xs text-slate-500">{formatK8sTime(r.created_at)}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
// ============ 容器模板表(镜像/端口/资源/探针) ============
function ContainersTable({ deployment }: { deployment: any }) {
const containers: any[] = deployment.containers || deployment.container_specs || deployment.spec_containers || [];
if (containers.length === 0) {
return <p className="text-xs text-slate-500 py-3">无容器详情数据(扁平字段缺少 containers,可查看 YAML)</p>;
}
return (
<div className="space-y-3">
{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 (
<div key={idx} className="border border-slate-800 rounded-lg p-3 bg-slate-950/30">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Container className="h-4 w-4 text-blue-400" />
<span className="text-sm font-semibold text-slate-200">{c.name || `container-${idx}`}</span>
</div>
<code className="text-[11px] text-slate-400 font-mono truncate max-w-[400px]">{c.image}</code>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
<div className="flex items-center gap-1.5 text-slate-400">
<Cpu className="h-3.5 w-3.5 text-amber-400" />
<span>CPU: <span className="text-slate-200 font-mono">{cpuReq || '-'}/{cpuLim || '-'}</span></span>
</div>
<div className="flex items-center gap-1.5 text-slate-400">
<MemoryStick className="h-3.5 w-3.5 text-purple-400" />
<span>内存: <span className="text-slate-200 font-mono">{memReq || '-'}/{memLim || '-'}</span></span>
</div>
<div className="flex items-center gap-1.5 text-slate-400">
<Network className="h-3.5 w-3.5 text-cyan-400" />
<span>端口: <span className="text-slate-200 font-mono">{ports.map((p: any) => typeof p === 'object' ? (p.containerPort || p.port) : p).join(', ') || '-'}</span></span>
</div>
<div className="flex items-center gap-1.5 text-slate-400">
<HardDrive className="h-3.5 w-3.5 text-emerald-400" />
<span>挂载: <span className="text-slate-200 font-mono">{(c.volume_mounts || c.volumeMounts || []).length || 0}</span></span>
</div>
</div>
{/* 探针 */}
<div className="mt-2 flex flex-wrap gap-3 text-[11px] text-slate-500">
{c.liveness_probe || c.livenessProbe ? (
<span className="text-emerald-500">● 存活探针</span>
) : <span className="text-slate-700">○ 无存活探针</span>}
{c.readiness_probe || c.readinessProbe ? (
<span className="text-blue-500">● 就绪探针</span>
) : <span className="text-slate-700">○ 无就绪探针</span>}
{c.startup_probe || c.startupProbe ? (
<span className="text-purple-500">● 启动探针</span>
) : null}
</div>
</div>
);
})}
</div>
);
}
// ============ 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 (
<div>
<p className="text-xs text-slate-400 mb-2 flex items-center gap-1"><Clock className="h-3 w-3" />状态时间线</p>
<div className="flex items-center gap-1 overflow-x-auto pb-1">
{stages.map((s, i) => {
const prev = stages[i - 1];
const dur = elapsedSeconds(prev?.ts, s.ts);
return (
<div key={s.label} className="flex items-center">
<div className="flex flex-col items-center min-w-[80px]">
<div className="h-2.5 w-2.5 rounded-full" style={{ background: s.ts ? s.color : '#334155' }} />
<span className="text-[10px] mt-1 font-medium" style={{ color: s.ts ? s.color : '#475569' }}>{s.label}</span>
<span className="text-[9px] text-slate-600">{s.ts ? formatK8sTime(s.ts) : '-'}</span>
</div>
{i < stages.length - 1 && (
<div className="flex flex-col items-center mx-1 min-w-[40px]">
<div className="h-0.5 w-10" style={{ background: '#334155' }} />
<span className="text-[9px] text-slate-600 mt-0.5">{dur != null ? formatDuration(dur) : ''}</span>
</div>
)}
</div>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,569 @@
// DeploymentWorkload.tsx — Kuboard 风格 Deployment 详情主组件
// - 顶部操作菜单: 编辑 | 预览/编辑YAML | 调整镜像版本 | 伸缩 | 日志/终端 | 重启 | 删除 | 元数据 | 运行时 | 服务 | 应用路由
// - 双模式: view(只读增强看板)/ edit(表单编辑器)
// 作者: Logan <admin@attacker.club>
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<any>(null);
const [manifest, setManifest] = useState<any>(null);
const [pods, setPods] = useState<any[]>([]);
const [replicaSets, setReplicaSets] = useState<any[]>([]);
const [revisions, setRevisions] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState('');
const [modal, setModal] = useState<ActionModal>(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 (
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-12 text-center">
<Loader2 className="h-6 w-6 animate-spin mx-auto text-slate-500" />
<p className="text-slate-500 mt-3">加载中...</p>
</CardContent>
</Card>
);
}
if (!deployment) {
return (
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-12 text-center">
<XCircle className="h-8 w-8 mx-auto text-red-400" />
<p className="text-slate-400 mt-3">未找到 Deployment: {deploymentName}</p>
<Button variant="outline" className="mt-4" onClick={onBack}>返回</Button>
</CardContent>
</Card>
);
}
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 (
<div className="space-y-4">
{/* ============ 顶部:标题 + 模式标识 + 操作菜单 ============ */}
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-3 min-w-0">
<Button variant="ghost" size="sm" className="text-slate-400" onClick={onBack}>
<ArrowLeft className="h-4 w-4 mr-1" />返回
</Button>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-xl font-bold text-slate-100 truncate">{deploymentName}</h1>
<Badge variant="outline" className="border-slate-700 text-slate-400">{namespace}</Badge>
<Badge variant="outline" className="border-slate-700 text-slate-400">Deployment</Badge>
<Badge
className={isHealthy
? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30'
: 'bg-amber-500/20 text-amber-400 border-amber-500/30'}
>
{isHealthy ? <CheckCircle className="h-3 w-3 mr-1" /> : <AlertTriangle className="h-3 w-3 mr-1" />}
{ready}/{total}
</Badge>
</div>
<p className="text-xs text-slate-500 mt-0.5 truncate">
镜像: <code className="text-slate-400">{deployment.image || '-'}</code>
</p>
</div>
</div>
<div className="flex items-center gap-2">
{/* 模式切换 Tab */}
<div className="inline-flex rounded-md border border-slate-700 overflow-hidden">
<button
className={`px-3 py-1.5 text-xs font-medium transition-colors ${mode === 'view' ? 'bg-slate-700 text-slate-100' : 'bg-transparent text-slate-400 hover:text-slate-200'}`}
onClick={() => onModeChange('view')}
>
查看模式
</button>
<button
className={`px-3 py-1.5 text-xs font-medium transition-colors ${mode === 'edit' ? 'bg-blue-600 text-white' : 'bg-transparent text-slate-400 hover:text-slate-200'}`}
onClick={() => onModeChange('edit')}
>
<Pencil className="h-3 w-3 inline mr-1" />编辑模式
</button>
</div>
<Button variant="outline" size="sm" className="border-slate-700 text-slate-400" onClick={loadData} title="刷新">
<RefreshCw className="h-4 w-4" />
</Button>
{/* 顶部操作菜单(Kuboard 风格下拉) */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="border-slate-700 text-slate-200">
操作 <ChevronDown className="h-3.5 w-3.5 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52 bg-slate-900 border-slate-700">
<DropdownMenuItem className="text-slate-200 hover:bg-slate-800 cursor-pointer" onClick={() => onModeChange('edit')}>
<Pencil className="h-4 w-4 mr-2 text-blue-400" /> 编辑
</DropdownMenuItem>
<DropdownMenuItem className="text-slate-200 hover:bg-slate-800 cursor-pointer" onClick={() => setModal({ kind: 'yaml' })}>
<FileCode className="h-4 w-4 mr-2 text-emerald-400" /> 预览 / 编辑 YAML
</DropdownMenuItem>
<DropdownMenuItem className="text-slate-200 hover:bg-slate-800 cursor-pointer" onClick={() => setModal({ kind: 'image' })}>
<ImageIcon className="h-4 w-4 mr-2 text-purple-400" /> 调整镜像版本
</DropdownMenuItem>
<DropdownMenuItem className="text-slate-200 hover:bg-slate-800 cursor-pointer" onClick={() => { setScaleValue(deployment.replicas || 0); setModal({ kind: 'scale' }); }}>
<Minus className="h-4 w-4 mr-2 text-amber-400" /> 伸缩
</DropdownMenuItem>
<DropdownMenuItem className="text-slate-200 hover:bg-slate-800 cursor-pointer" onClick={() => toast({ title: '日志 / 终端', description: '请在 Pod 列表中点击对应按钮' })}>
<Terminal className="h-4 w-4 mr-2 text-cyan-400" /> 日志 / 终端
</DropdownMenuItem>
<DropdownMenuItem className="text-slate-200 hover:bg-slate-800 cursor-pointer" onClick={handleRestart}>
<RotateCw className="h-4 w-4 mr-2 text-blue-400" /> 重启
</DropdownMenuItem>
<DropdownMenuItem className="text-red-400 hover:bg-slate-800 cursor-pointer" onClick={() => setModal({ kind: 'delete' })}>
<Trash2 className="h-4 w-4 mr-2" /> 删除
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-slate-800" />
<DropdownMenuItem className="text-slate-300 hover:bg-slate-800 cursor-pointer" onClick={() => toast({ title: '元数据', description: 'Labels / Annotations / Selectors,集成在 YAML 视图中' })}>
<Info className="h-4 w-4 mr-2 text-slate-400" /> 元数据
</DropdownMenuItem>
<DropdownMenuItem className="text-slate-300 hover:bg-slate-800 cursor-pointer" onClick={() => toast({ title: '运行时', description: 'Pod 运行状态请见 Pod 实例区' })}>
<Cpu className="h-4 w-4 mr-2 text-slate-400" /> 运行时
</DropdownMenuItem>
<DropdownMenuItem className="text-slate-300 hover:bg-slate-800 cursor-pointer"
onClick={() => navigate(`${viewBase}/view/Service?selector=${encodeURIComponent(JSON.stringify(deployment.labels || {}))}`)}>
<Network className="h-4 w-4 mr-2 text-slate-400" /> 服务
</DropdownMenuItem>
<DropdownMenuItem className="text-slate-300 hover:bg-slate-800 cursor-pointer"
onClick={() => navigate(`${viewBase}/view/Ingress?namespace=${namespace}`)}>
<GitBranch className="h-4 w-4 mr-2 text-slate-400" /> 应用路由
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* ============ 主体:view / edit ============ */}
{mode === 'view' ? (
<DeploymentView
clusterCode={clusterCode}
namespace={namespace}
deployment={deployment}
pods={pods}
replicaSets={replicaSets}
revisions={revisions}
onRefresh={loadData}
onShowPodLogs={handleShowPodLogs}
onShowPodTerminal={handleShowPodTerminal}
onShowPodYaml={handleShowPodYaml}
/>
) : (
<DeploymentEdit
clusterCode={clusterCode}
namespace={namespace}
deploymentName={deploymentName}
manifest={manifest}
onSaved={() => { loadManifest(); loadData(); onModeChange('view'); }}
onCancel={() => onModeChange('view')}
/>
)}
{/* ============ 弹窗:YAML ============ */}
{modal?.kind === 'yaml' && (
<YamlViewer
clusterCode={clusterCode}
namespace={namespace}
resource="deployments"
name={deploymentName}
cleanBeforeEdit
onClose={() => setModal(null)}
onSaved={loadData}
/>
)}
{/* 弹窗:Pod YAML */}
{modal?.kind === 'podYaml' && (
<YamlViewer
clusterCode={clusterCode}
namespace={namespace}
resource="pods"
name={modal.pod}
cleanBeforeEdit
onClose={() => setModal(null)}
/>
)}
{/* ============ 弹窗:调整镜像版本 ============ */}
{modal?.kind === 'image' && (
<ModalShell title="调整镜像版本" onClose={() => setModal(null)}>
<div className="space-y-3">
<div>
<Label className="text-slate-400 text-sm">当前镜像</Label>
<p className="text-xs text-slate-500 font-mono mt-1 bg-slate-800 rounded px-3 py-2">{deployment.image || '-'}</p>
</div>
<div>
<Label className="text-slate-400 text-sm">新镜像地址</Label>
<Input value={newImage} onChange={e => 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" />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" className="border-slate-700 text-slate-400" onClick={() => setModal(null)}>取消</Button>
<Button className="bg-blue-600 hover:bg-blue-700" disabled={actionLoading === 'image' || !newImage.trim()} onClick={handleUpdateImage}>
{actionLoading === 'image' ? <Loader2 className="h-4 w-4 animate-spin" /> : '更新镜像'}
</Button>
</div>
</div>
</ModalShell>
)}
{/* ============ 弹窗:伸缩 ============ */}
{modal?.kind === 'scale' && (
<ModalShell title="伸缩 (Scale)" onClose={() => setModal(null)}>
<div className="space-y-3">
<p className="text-xs text-slate-500">当前副本: {deployment.replicas || 0}</p>
<div className="flex items-center justify-center gap-3 py-3">
<Button variant="outline" size="icon" className="border-slate-700 text-slate-300"
disabled={actionLoading === 'scale' || scaleValue <= 0}
onClick={() => setScaleValue(v => Math.max(0, v - 1))}>
<Minus className="h-4 w-4" />
</Button>
<Input type="number" min={0} value={scaleValue}
onChange={e => 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" />
<Button variant="outline" size="icon" className="border-slate-700 text-slate-300"
disabled={actionLoading === 'scale'}
onClick={() => setScaleValue(v => v + 1)}>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" className="border-slate-700 text-slate-400" onClick={() => setModal(null)}>取消</Button>
<Button className="bg-blue-600 hover:bg-blue-700" disabled={actionLoading === 'scale'} onClick={handleScaleTo}>
{actionLoading === 'scale' ? <Loader2 className="h-4 w-4 animate-spin" /> : '确认'}
</Button>
</div>
</div>
</ModalShell>
)}
{/* ============ 弹窗:删除 ============ */}
{modal?.kind === 'delete' && (
<ModalShell title="删除 Deployment" onClose={() => setModal(null)}>
<div className="space-y-3">
<div className="flex items-center gap-2 text-red-400">
<AlertTriangle className="h-5 w-5" />
<span className="font-semibold">此操作不可撤销</span>
</div>
<p className="text-sm text-slate-400">
确认删除 Deployment <code className="text-slate-200 font-mono">{namespace}/{deploymentName}</code>?
其管理的所有 Pod 将被一并回收。
</p>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" className="border-slate-700 text-slate-400" onClick={() => setModal(null)}>取消</Button>
<Button variant="destructive" disabled={actionLoading === 'delete'} onClick={handleDelete}>
{actionLoading === 'delete' ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Trash2 className="h-4 w-4 mr-1" />}
确认删除
</Button>
</div>
</div>
</ModalShell>
)}
{/* ============ 弹窗:Pod 日志 ============ */}
{modal?.kind === 'podLogs' && (
<PodLogsModal
clusterCode={clusterCode}
namespace={namespace}
podName={modal.pod}
containers={(pods.find(p => p.name === modal.pod)?.containers || []).map((c: any) => c.name).filter(Boolean)}
onClose={() => setModal(null)}
/>
)}
</div>
);
}
// ============ 通用 Modal 外壳 ============
function ModalShell({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
<div className="bg-slate-900 border border-slate-700 rounded-xl p-6 w-[460px] max-w-[90vw] shadow-2xl" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-base font-bold text-slate-100">{title}</h2>
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400" onClick={onClose}><X className="h-4 w-4" /></Button>
</div>
{children}
</div>
</div>
);
}
// ============ 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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
<div className="bg-slate-900 border border-slate-700 rounded-xl w-[90vw] max-w-4xl max-h-[80vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-3 border-b border-slate-700">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-blue-400" />
<span className="text-sm font-semibold text-slate-200">{podName}</span>
{container && <Badge variant="outline" className="border-slate-700 text-slate-400 text-[10px]">{container}</Badge>}
</div>
<div className="flex items-center gap-2">
{containers.length > 0 && (
<select value={container} onChange={e => setContainer(e.target.value)}
className="bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-2 py-1">
{containers.map(c => <option key={c} value={c}>{c}</option>)}
</select>
)}
<span className="text-[11px] text-slate-500">最近</span>
<Input type="number" value={tail} onChange={e => setTail(Number(e.target.value) || 200)}
className="w-16 h-7 bg-slate-800 border-slate-700 text-slate-200 text-xs" />
<span className="text-[11px] text-slate-500">行</span>
<Button size="sm" variant="outline" className="h-7 border-slate-700 text-slate-300 text-xs" onClick={loadLogs} disabled={loading}>
{loading ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
</Button>
<Button size="sm" variant="outline" className="h-7 border-slate-700 text-emerald-400 text-xs" onClick={startStream} disabled={streaming}>
{streaming ? '流中...' : '实时流'}
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400" onClick={onClose}><X className="h-4 w-4" /></Button>
</div>
</div>
<div className="flex-1 overflow-auto p-3">
<pre className="bg-slate-950 rounded p-3 text-xs text-slate-300 font-mono whitespace-pre-wrap max-h-[60vh] overflow-auto">
{logs || '无日志内容'}
</pre>
</div>
</div>
</div>
);
}
@@ -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({
<Label className="text-[var(--dg-text-secondary)]">集群名称</Label>
<Input
value={formData.name}
onChange={(e) => 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)]"
/>
</div>
<div className="space-y-2">
<Label className="text-[var(--dg-text-secondary)]">集群代码 (Code)</Label>
<Input
value={formData.code}
onChange={(e) => 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"
/>
</div>
<div className="space-y-2">
<Label className="text-[var(--dg-text-secondary)]">版本(可选)</Label>
<Input
@@ -233,7 +251,7 @@ export function KubernetesClusterDialog({
</Button>
<Button
onClick={handleSubmit}
disabled={submitting || !formData.name.trim()}
disabled={submitting || !formData.name.trim() || !formData.code.trim()}
className="bg-[var(--dg-blue)] hover:opacity-90 text-white"
>
{submitting ? '保存中...' : editingCluster ? '保存' : '添加'}
@@ -277,7 +277,8 @@ export default function NamespaceResourcePage() {
const handleDetail = (item: any) => {
if (type === 'deployments') {
navigate(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${item.name}`);
// Kuboard 风格双模式路由(默认 view 模式)
navigate(`/kubernetes/${clusterCode}/namespace/${namespace}/workload/view/Deployment/${item.name}`);
}
};
@@ -0,0 +1,353 @@
import { useState, useRef, useEffect, useCallback } 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 { Terminal as XTerm } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import 'xterm/css/xterm.css';
import {
Terminal, Maximize2, Minimize2, X, Loader2, RotateCw, Box,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { kubernetesApi } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
/**
* Pod 级 exec 终端 — 进入容器执行命令 (kubectl exec -it)
*
* 复用 KubectlTerminal.tsx 的 xterm.js 模式,连接 Pod exec WebSocket 通道:
* ws://host/ws/kubernetes-exec/:clusterId/namespaces/:ns/pods/:pod?container=&token=
*
* 支持:
* - 容器选择下拉(默认第一个容器,可切换)
* - 自定义 shell(默认 bash,可切 sh)
* - Dialog/Modal 形式,全屏切换
* - 终端 resize 透传(ResizeObserver + fit)
*
* @author Logan <admin@attacker.club>
*/
interface PodExecTerminalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** 集群 ID(用于 ws 路由 :clusterId) */
clusterId: number | string;
/** 集群 code(备用,部分后端用 code) */
clusterCode?: string;
namespace: string;
podName: string;
/** 可选:Pod 容器名列表,缺省时仅显示 shell 选择 */
containers?: string[];
/** 初始容器 */
initialContainer?: string;
}
type ConnState = 'idle' | 'connecting' | 'connected' | 'error' | 'closed';
const SHELLS = [
{ value: 'bash', label: '/bin/bash' },
{ value: 'sh', label: '/bin/sh' },
{ value: 'sh', label: '/bin/ash' },
];
export function PodExecTerminal({
open,
onOpenChange,
clusterId,
namespace,
podName,
containers = [],
initialContainer,
}: PodExecTerminalProps) {
const { toast } = useToast();
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<XTerm | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const [connState, setConnState] = useState<ConnState>('idle');
const [fullscreen, setFullscreen] = useState(false);
const [container, setContainer] = useState<string>(initialContainer || containers[0] || '');
const [shell, setShell] = useState<string>('bash');
// 重置全屏状态
useEffect(() => {
if (!open) setFullscreen(false);
}, [open]);
// 容器列表变化时同步默认值
useEffect(() => {
if (open && !container && containers.length > 0) {
setContainer(initialContainer || containers[0]);
}
}, [open, containers, initialContainer, container]);
// 创建 xterm 实例(仅在 open 时创建一次,容器/shell 变化时重连 ws)
const connect = useCallback(() => {
if (!termRef.current) return;
const term = termRef.current;
// 关闭旧连接
if (wsRef.current) {
try { wsRef.current.close(); } catch { /* noop */ }
wsRef.current = null;
}
setConnState('connecting');
term.clear();
term.writeln(`\x1b[36m[正在连接 ${podName}${container ? '/' + container : ''} ...]\x1b[0m`);
const wsUrl = kubernetesApi.buildPodExecWsUrl(clusterId, namespace, podName, container || undefined);
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
setConnState('connected');
term.reset();
// 发送初始 resize + 启动 shell
try {
const { cols, rows } = term;
ws.send(JSON.stringify({ status: 1, cols, rows }));
// 启动 shell 进程:标准 remote exec 协议 (status:0 data)
ws.send(JSON.stringify({ status: 0, data: `${shell}\r` }));
} catch { /* noop */ }
setTimeout(() => { try { term.focus(); } catch { /* noop */ } }, 100);
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.status === 0 && typeof msg.data === 'string') {
term.write(msg.data);
} else if (msg.status === 1) {
// 服务端 resize 确认,忽略
} else if (msg.status === 2 || msg.error) {
term.write(`\r\n\x1b[31m${msg.message || msg.error || '执行错误'}\x1b[0m\r\n`);
}
} catch {
if (typeof event.data === 'string') term.write(event.data);
}
};
ws.onclose = () => {
setConnState((s) => (s === 'error' ? s : 'closed'));
term.write('\r\n\x1b[33m[连接已断开]\x1b[0m\r\n');
};
ws.onerror = () => {
setConnState('error');
term.write('\r\n\x1b[31m[连接发生错误]\x1b[0m\r\n');
};
}, [clusterId, namespace, podName, container, shell]);
// 创建/销毁 xterm + 绑定 onData(生命周期跟随 open)
useEffect(() => {
if (!open || !clusterId || !podName) return;
const term = new XTerm({
cursorBlink: true,
fontSize: 14,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
allowProposedApi: true,
scrollback: 5000,
theme: {
background: '#0f172a',
foreground: '#e2e8f0',
cursor: '#38bdf8',
selectionBackground: '#1e40af',
black: '#1e293b', red: '#ef4444', green: '#10b981',
yellow: '#f59e0b', blue: '#3b82f6', magenta: '#a855f7',
cyan: '#06b6d4', white: '#e2e8f0',
brightBlack: '#475569', brightRed: '#f87171', brightGreen: '#34d399',
brightYellow: '#fbbf24', brightBlue: '#60a5fa', brightMagenta: '#c084fc',
brightCyan: '#22d3ee', brightWhite: '#f8fafc',
},
});
const fit = new FitAddon();
term.loadAddon(fit);
termRef.current = term;
fitRef.current = fit;
const onData = (data: string) => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ status: 0, data }));
}
};
term.onData(onData);
if (containerRef.current) {
term.open(containerRef.current);
requestAnimationFrame(() => {
try { fit.fit(); } catch { /* noop */ }
});
}
// 首次连接
connect();
return () => {
try {
if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) {
wsRef.current.close();
}
} catch { /* noop */ }
try { term.dispose(); } catch { /* noop */ }
termRef.current = null;
wsRef.current = null;
fitRef.current = null;
setConnState('idle');
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, clusterId, podName]);
// 容器 / shell 变化时重连(需 term 已就绪)
useEffect(() => {
if (open && termRef.current) {
connect();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [container, shell]);
// ResizeObserver 监听容器变化自动 fit + 透传 resize
useEffect(() => {
if (!open) return;
const containerEl = containerRef.current;
if (!containerEl) return;
let resizeTimer: ReturnType<typeof setTimeout>;
const ro = new ResizeObserver(() => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
if (fitRef.current && termRef.current && wsRef.current) {
try {
fitRef.current.fit();
termRef.current.focus();
const { cols, rows } = termRef.current;
if (wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ status: 1, cols, rows }));
}
} catch { /* noop */ }
}
}, 60);
});
ro.observe(containerEl);
return () => {
clearTimeout(resizeTimer);
ro.disconnect();
};
}, [open, fullscreen]);
const statusColor = connState === 'connected' ? 'bg-emerald-400'
: connState === 'error' ? 'bg-red-400'
: connState === 'closed' ? 'bg-slate-500'
: 'bg-amber-400';
const statusText = connState === 'connected' ? '已连接'
: connState === 'connecting' ? '连接中'
: connState === 'error' ? '连接错误'
: connState === 'closed' ? '已断开'
: '空闲';
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
onOpenAutoFocus={(e) => e.preventDefault()}
className={cn(
'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0',
'!duration-0 !transition-none',
fullscreen
? '!max-w-none !w-screen !h-screen !rounded-none !translate-x-0 !translate-y-0 !left-0 !top-0'
: ''
)}
style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '960px', width: '92vw' }}
>
<DialogTitle className="sr-only">exec — {podName}</DialogTitle>
<DialogDescription className="sr-only">进入 Pod 容器执行命令</DialogDescription>
{/* 头部工具栏 */}
<div className="flex items-center justify-between gap-2 px-4 py-2 border-b border-[var(--dg-border)] flex-wrap">
<div className="flex items-center gap-2 text-sm min-w-0">
<div className={cn('w-2 h-2 rounded-full shrink-0', statusColor)} title={statusText} />
<Terminal className="h-4 w-4 text-blue-400 shrink-0" />
<span className="text-[var(--dg-text-primary)] font-medium truncate">
exec — {podName}
</span>
{connState === 'connecting' && <Loader2 className="h-3 w-3 animate-spin text-[var(--dg-text-muted)]" />}
<span className="text-xs text-[var(--dg-text-muted)]">{statusText}</span>
</div>
<div className="flex items-center gap-2">
{/* 容器选择 */}
{containers.length > 0 && (
<Select value={container} onValueChange={setContainer}>
<SelectTrigger className="h-8 w-[150px] text-xs bg-[var(--dg-bg-hover)] border-[var(--dg-border)]">
<Box className="h-3 w-3 mr-1 text-[var(--dg-text-muted)]" />
<SelectValue placeholder="容器" />
</SelectTrigger>
<SelectContent>
{containers.map((c) => (
<SelectItem key={c} value={c}>{c}</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* Shell 选择 */}
<Select value={shell} onValueChange={setShell}>
<SelectTrigger className="h-8 w-[100px] text-xs bg-[var(--dg-bg-hover)] border-[var(--dg-border)]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="bash">/bin/bash</SelectItem>
<SelectItem value="sh">/bin/sh</SelectItem>
</SelectContent>
</Select>
{/* 重连 */}
<Button
variant="ghost" size="sm"
className="h-8 px-2 text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]"
onClick={() => { connect(); toast({ title: '正在重连…' }); }}
title="重连"
>
<RotateCw className="h-3.5 w-3.5" />
</Button>
<button
onClick={() => setFullscreen(!fullscreen)}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
title={fullscreen ? '退出全屏' : '全屏'}
>
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</button>
<button
onClick={() => onOpenChange(false)}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
title="关闭"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* 终端容器 */}
<div
ref={containerRef}
className="bg-[#0f172a]"
style={{ height: fullscreen ? 'calc(100vh - 49px)' : '480px' }}
/>
</DialogContent>
</Dialog>
);
}
@@ -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 <admin@attacker.club>
*/
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<string>(initialContainer || containers[0] || '');
const [entries, setEntries] = useState<FileEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
// 浏览器风格历史栈
const [historyStack, setHistoryStack] = useState<string[]>(['/']);
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<string>('');
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
onOpenAutoFocus={(e) => e.preventDefault()}
className={cn(
'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0',
'!duration-0 !transition-none',
fullscreen
? '!max-w-none !w-screen !h-screen !rounded-none !translate-x-0 !translate-y-0 !left-0 !top-0'
: ''
)}
style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '1000px', width: '94vw' }}
>
<DialogTitle className="sr-only">文件 — {podName}</DialogTitle>
<DialogDescription className="sr-only">浏览 Pod 容器文件系统</DialogDescription>
{/* 头部 */}
<div className="flex items-center justify-between gap-2 px-4 py-2 border-b border-[var(--dg-border)] flex-wrap">
<div className="flex items-center gap-2 text-sm min-w-0">
<HardDrive className="h-4 w-4 text-blue-400 shrink-0" />
<span className="text-[var(--dg-text-primary)] font-medium truncate">文件浏览器 — {podName}</span>
{loading && <Loader2 className="h-3 w-3 animate-spin text-[var(--dg-text-muted)]" />}
<span className="text-xs text-[var(--dg-text-muted)]">{entries.length} 项</span>
</div>
<div className="flex items-center gap-1">
{containers.length > 0 && (
<Select value={container} onValueChange={setContainer}>
<SelectTrigger className="h-8 w-[150px] text-xs bg-[var(--dg-bg-hover)] border-[var(--dg-border)]">
<Box className="h-3 w-3 mr-1 text-[var(--dg-text-muted)]" />
<SelectValue placeholder="容器" />
</SelectTrigger>
<SelectContent>
{containers.map((c) => (
<SelectItem key={c} value={c}>{c}</SelectItem>
))}
</SelectContent>
</Select>
)}
<Button variant="ghost" size="sm" className="h-8 px-2" onClick={() => loadPath(currentPath)} title="刷新">
<RotateCw className={cn('h-3.5 w-3.5', loading && 'animate-spin')} />
</Button>
<button
onClick={() => setFullscreen(!fullscreen)}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
title={fullscreen ? '退出全屏' : '全屏'}
>
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</button>
<button
onClick={() => onOpenChange(false)}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
title="关闭"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* 工具栏:导航按钮 + 面包屑 */}
<div className="flex items-center gap-1 px-4 py-2 border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] flex-wrap">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" disabled={!canGoBack} onClick={goBack} title="后退">
<ArrowLeft className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" disabled={!canGoForward} onClick={goForward} title="前进">
<ArrowRight className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" disabled={currentPath === '/'} onClick={goUp} title="返回上级">
<ArrowUp className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => navigateTo('/')} title="根目录">
<Home className="h-4 w-4" />
</Button>
{/* 面包屑 */}
<div className="flex items-center gap-0.5 ml-2 flex-1 min-w-0 overflow-x-auto">
{breadcrumbs.map((seg, i) => (
<div key={seg.path} className="flex items-center shrink-0">
{i > 0 && <ChevronRight className="h-3 w-3 text-[var(--dg-text-muted)] mx-0.5" />}
<button
className={cn(
'px-1.5 py-0.5 rounded text-xs hover:bg-[var(--dg-bg-hover)]',
i === breadcrumbs.length - 1
? 'text-[var(--dg-text-primary)] font-medium'
: 'text-[var(--dg-text-secondary)]'
)}
onClick={() => navigateTo(seg.path)}
>
{seg.name === '/' ? <Home className="h-3 w-3" /> : seg.name}
</button>
</div>
))}
</div>
<span className="text-xs text-[var(--dg-text-muted)] font-mono shrink-0">{currentPath}</span>
</div>
{/* 主体:左列表 / 右预览 */}
<div className="flex" style={{ height: fullscreen ? 'calc(100vh - 98px)' : '480px' }}>
{/* 文件列表 */}
<div className={cn('flex-1 overflow-auto', preview && 'border-r border-[var(--dg-border)]')}>
{error && (
<div className="flex flex-col items-center justify-center h-full text-center p-6">
<AlertCircle className="h-8 w-8 text-amber-400 mb-2" />
<p className="text-sm text-[var(--dg-text-primary)] mb-1">加载失败</p>
<p className="text-xs text-[var(--dg-text-muted)] mb-3 max-w-md">{error}</p>
<p className="text-xs text-[var(--dg-text-muted)] mb-3 max-w-md">
文件浏览器 API 仍在并行开发中,请确认后端 <code className="px-1 bg-[var(--dg-bg-hover)] rounded">POST .../files/list</code> 已就绪。
</p>
<Button variant="outline" size="sm" onClick={() => loadPath(currentPath)}>
<RotateCw className="h-3.5 w-3.5 mr-1" />重试
</Button>
</div>
)}
{!error && loading && entries.length === 0 && (
<div className="flex items-center justify-center h-full text-[var(--dg-text-muted)]">
<Loader2 className="h-4 w-4 animate-spin mr-2" />加载中…
</div>
)}
{!error && !loading && entries.length === 0 && (
<div className="flex flex-col items-center justify-center h-full text-[var(--dg-text-muted)]">
<FolderOpen className="h-8 w-8 mb-2 opacity-50" />
<span className="text-sm">空目录</span>
</div>
)}
{!error && entries.length > 0 && (
<table className="w-full text-sm">
<thead className="sticky top-0 bg-[var(--dg-bg-secondary)] border-b border-[var(--dg-border)]">
<tr className="text-xs text-[var(--dg-text-muted)]">
<th className="text-left font-normal px-3 py-1.5">名称</th>
<th className="text-right font-normal px-3 py-1.5 w-28">大小</th>
<th className="text-left font-normal px-3 py-1.5 w-40">修改时间</th>
<th className="text-right font-normal px-3 py-1.5 w-28">操作</th>
</tr>
</thead>
<tbody>
{currentPath !== '/' && (
<tr
className="border-b border-[var(--dg-border)]/50 hover:bg-[var(--dg-bg-hover)] cursor-pointer"
onClick={goUp}
>
<td className="px-3 py-1.5 text-[var(--dg-text-secondary)] flex items-center gap-2">
<ArrowUp className="h-4 w-4" />
<span className="italic">..</span>
</td>
<td className="px-3 py-1.5"></td>
<td className="px-3 py-1.5"></td>
<td className="px-3 py-1.5"></td>
</tr>
)}
{entries.map((entry) => {
const Icon = getFileIcon(entry.name, entry.isDir);
const fullPath = joinPath(currentPath, entry.name);
const isSelected = selected === entry.name;
return (
<tr
key={entry.name}
className={cn(
'border-b border-[var(--dg-border)]/50 hover:bg-[var(--dg-bg-hover)] cursor-pointer',
isSelected && 'bg-[var(--dg-bg-hover)]'
)}
onClick={() => handleEntryClick(entry)}
>
<td className="px-3 py-1.5">
<div className="flex items-center gap-2 min-w-0">
<Icon className={cn(
'h-4 w-4 shrink-0',
entry.isDir ? 'text-blue-400' : 'text-[var(--dg-text-muted)]'
)} />
<span className={cn(
'truncate',
entry.isDir ? 'text-[var(--dg-text-primary)] font-medium' : 'text-[var(--dg-text-secondary)]'
)} title={entry.name}>
{entry.name}
</span>
</div>
</td>
<td className="px-3 py-1.5 text-right text-xs text-[var(--dg-text-muted)] font-mono">
{entry.isDir ? '-' : formatSize(entry.size)}
</td>
<td className="px-3 py-1.5 text-xs text-[var(--dg-text-muted)] font-mono">
{entry.modTime || '-'}
</td>
<td className="px-3 py-1.5 text-right" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end gap-0.5">
{!entry.isDir && (
<>
<button
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] hover:text-blue-400"
title="预览文本"
onClick={() => previewFile(fullPath)}
>
<Eye className="h-3.5 w-3.5" />
</button>
</>
)}
<button
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] hover:text-emerald-400"
title={entry.isDir ? '目录不支持下载' : '下载'}
disabled={entry.isDir}
onClick={() => !entry.isDir && handleDownload(fullPath)}
>
<Download className="h-3.5 w-3.5" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
{/* 预览面板 */}
{preview && (
<div className="flex flex-col w-[45%] min-w-[300px] bg-[#0f172a]">
<div className="flex items-center justify-between px-3 py-1.5 border-b border-[var(--dg-border)]">
<div className="flex items-center gap-2 text-xs min-w-0">
<Eye className="h-3.5 w-3.5 text-blue-400 shrink-0" />
<span className="text-[var(--dg-text-primary)] truncate font-mono" title={preview.path}>
{preview.path}
</span>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<button
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] hover:text-emerald-400"
title="下载"
onClick={() => handleDownload(preview.path)}
>
<Download className="h-3.5 w-3.5" />
</button>
<button
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
title="关闭预览"
onClick={() => setPreview(null)}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
<div className="flex-1 overflow-auto p-3">
{preview.loading ? (
<div className="flex items-center justify-center h-full text-[var(--dg-text-muted)]">
<Loader2 className="h-4 w-4 animate-spin mr-2" />加载中…
</div>
) : (
<pre className="text-xs text-slate-300 font-mono whitespace-pre-wrap break-all">
{preview.content || '(空文件)'}
</pre>
)}
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -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 <admin@attacker.club>
*/
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<string>(initialContainer || containers[0] || '');
const [tailLines, setTailLines] = useState<number>(500);
const [previous, setPrevious] = useState(false);
const [sinceTime, setSinceTime] = useState<string>('');
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<LogLine[]>([]);
const linesRef = useRef<LogLine[]>([]);
const lineIdRef = useRef(0);
const abortRef = useRef<AbortController | null>(null);
const pausedRef = useRef(false);
const autoScrollRef = useRef(true);
const scrollRef = useRef<HTMLDivElement>(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 <span>{text}</span>;
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(<span key={key++}>{text.slice(idx)}</span>);
break;
}
if (found > idx) parts.push(<span key={key++}>{text.slice(idx, found)}</span>);
parts.push(
<mark key={key++} className="bg-amber-400/40 text-amber-200 rounded px-0.5">
{text.slice(found, found + kw.length)}
</mark>
);
idx = found + kw.length;
}
return <>{parts}</>;
};
const lineCount = lines.length;
const matchedCount = filteredLines.length;
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) handleClose(); }}>
<DialogContent
showCloseButton={false}
onOpenAutoFocus={(e) => e.preventDefault()}
className={cn(
'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0',
'!duration-0 !transition-none',
fullscreen
? '!max-w-none !w-screen !h-screen !rounded-none !translate-x-0 !translate-y-0 !left-0 !top-0'
: ''
)}
style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '1100px', width: '94vw' }}
>
<DialogTitle className="sr-only">日志 — {podName}</DialogTitle>
<DialogDescription className="sr-only">Pod 容器日志查看</DialogDescription>
{/* 头部工具栏 */}
<div className="flex items-center justify-between gap-2 px-4 py-2 border-b border-[var(--dg-border)] flex-wrap">
<div className="flex items-center gap-2 text-sm min-w-0">
<div className={cn(
'w-2 h-2 rounded-full shrink-0',
streaming ? (paused ? 'bg-amber-400' : 'bg-emerald-400 animate-pulse') : 'bg-slate-500'
)} />
<ScrollText className="h-4 w-4 text-blue-400 shrink-0" />
<span className="text-[var(--dg-text-primary)] font-medium truncate">日志 — {podName}</span>
{loading && <Loader2 className="h-3 w-3 animate-spin text-[var(--dg-text-muted)]" />}
<span className="text-xs text-[var(--dg-text-muted)]">
{streaming ? (paused ? '已暂停' : '实时跟踪') : '已停止'} · {matchedCount}{keyword ? `/${lineCount}` : ''} 行
{lineCount >= RING_BUFFER_MAX && ' (缓冲已满)'}
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setFullscreen(!fullscreen)}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
title={fullscreen ? '退出全屏' : '全屏'}
>
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</button>
<button
onClick={handleClose}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
title="关闭"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* 控制栏 */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] flex-wrap">
{/* 容器 */}
{containers.length > 0 && (
<Select value={container} onValueChange={setContainer}>
<SelectTrigger className="h-8 w-[150px] text-xs bg-[var(--dg-bg-hover)] border-[var(--dg-border)]">
<Box className="h-3 w-3 mr-1 text-[var(--dg-text-muted)]" />
<SelectValue placeholder="容器" />
</SelectTrigger>
<SelectContent>
{containers.map((c) => (
<SelectItem key={c} value={c}>{c}</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* 行数 */}
<Select value={String(tailLines)} onValueChange={(v) => setTailLines(Number(v))}>
<SelectTrigger className="h-8 w-[130px] text-xs bg-[var(--dg-bg-hover)] border-[var(--dg-border)]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TAIL_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
{/* previous */}
<Button
variant={previous ? 'default' : 'outline'}
size="sm"
className={cn('h-8 text-xs', previous ? 'bg-amber-600 hover:bg-amber-700' : 'border-[var(--dg-border)] text-[var(--dg-text-secondary)]')}
onClick={() => setPrevious(!previous)}
title="查看上一次(崩溃)容器的日志"
>
<History className="h-3.5 w-3.5 mr-1" />
previous
</Button>
{/* sinceTime */}
<div className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5 text-[var(--dg-text-muted)]" />
<Input
type="datetime-local"
value={sinceTime ? sinceTime.slice(0, 16) : ''}
onChange={(e) => {
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)"
/>
</div>
<div className="flex-1" />
{/* 关键字过滤 */}
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-[var(--dg-text-muted)]" />
<Input
value={keyword}
onChange={(e) => 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)]"
/>
</div>
{/* 下载 */}
<Button
variant="outline" size="sm"
className="h-8 text-xs border-[var(--dg-border)] text-[var(--dg-text-secondary)]"
onClick={handleDownload}
title="导出当前日志"
disabled={filteredLines.length === 0}
>
<Download className="h-3.5 w-3.5 mr-1" />导出
</Button>
{/* 刷新(一次性) */}
<Button
variant="outline" size="sm"
className="h-8 text-xs border-[var(--dg-border)] text-[var(--dg-text-secondary)]"
onClick={fetchLogs}
disabled={loading}
title="重新拉取"
>
<RefreshCw className={cn('h-3.5 w-3.5 mr-1', loading && 'animate-spin')} />刷新
</Button>
{/* 实时跟踪 / 暂停 / 继续 */}
{!streaming ? (
<Button
size="sm"
className="h-8 text-xs bg-emerald-600 hover:bg-emerald-700"
onClick={startStream}
disabled={loading}
title="实时跟踪(tail -f)"
>
<Play className="h-3.5 w-3.5 mr-1" />实时
</Button>
) : (
<>
<Button
variant="outline" size="sm"
className={cn('h-8 text-xs', paused ? 'border-emerald-500 text-emerald-400' : 'border-amber-500 text-amber-400')}
onClick={() => setPaused(!paused)}
title={paused ? '继续' : '暂停'}
>
{paused ? <Play className="h-3.5 w-3.5 mr-1" /> : <Pause className="h-3.5 w-3.5 mr-1" />}
{paused ? '继续' : '暂停'}
</Button>
<Button
variant="outline" size="sm"
className="h-8 text-xs border-red-500 text-red-400"
onClick={stopStream}
title="停止"
>
<Pause className="h-3.5 w-3.5 mr-1" />停止
</Button>
</>
)}
</div>
{/* 日志内容 */}
<div
ref={scrollRef}
onScroll={(e) => {
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 && (
<div className="flex flex-col items-center justify-center h-full text-[var(--dg-text-muted)]">
<AlertCircle className="h-8 w-8 mb-2 opacity-50" />
<span>暂无日志</span>
</div>
)}
{loading && lineCount === 0 && (
<div className="flex items-center justify-center h-full text-[var(--dg-text-muted)]">
<Loader2 className="h-4 w-4 animate-spin mr-2" />加载中…
</div>
)}
{filteredLines.map((line) => (
<div key={line.id} className="whitespace-pre-wrap break-all hover:bg-white/5 px-1">
{renderLine(line)}
</div>
))}
</div>
{/* 底部状态条 */}
<div className="flex items-center justify-between px-4 py-1.5 border-t border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] text-xs text-[var(--dg-text-muted)]">
<div className="flex items-center gap-3">
<span className="flex items-center gap-1"><Filter className="h-3 w-3" />缓冲 {lineCount}/{RING_BUFFER_MAX}</span>
{keyword && <span>匹配 {matchedCount}</span>}
{container && <span className="flex items-center gap-1"><Box className="h-3 w-3" />{container}</span>}
{previous && <span className="text-amber-400 flex items-center gap-1"><History className="h-3 w-3" />previous</span>}
</div>
<button
className={cn('flex items-center gap-1', autoScroll ? 'text-emerald-400' : 'text-[var(--dg-text-muted)]')}
onClick={() => {
setAutoScroll(!autoScroll);
if (!autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}}
>
<Maximize2 className="h-3 w-3" />{autoScroll ? '跟随底部' : '已暂停跟随'}
</button>
</div>
</DialogContent>
</Dialog>
);
}
+100 -84
View File
@@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
<div className="bg-slate-900 border border-slate-700 rounded-xl w-[90vw] max-w-4xl max-h-[80vh] flex flex-col shadow-2xl" onClick={e => e.stopPropagation()}>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-slate-700">
<div>
<h2 className="text-lg font-bold text-slate-100">{resource}/{name}</h2>
<p className="text-xs text-slate-500">{namespace} · {clusterCode}</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300" onClick={copyYaml}>
{copied ? <Check className="h-3 w-3 mr-1 text-emerald-400" /> : <Copy className="h-3 w-3 mr-1" />}
{copied ? '已复制' : '复制'}
</Button>
{!editing ? (
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300" onClick={() => setEditing(true)}>
编辑
</Button>
) : (
<>
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300" onClick={() => { setEditing(false); loadYaml(); }}>
取消
</Button>
<Button size="sm" className="h-8 bg-blue-600 hover:bg-blue-700" onClick={saveYaml} disabled={saving}>
{saving ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Save className="h-3 w-3 mr-1" />}
保存
</Button>
</>
)}
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400" onClick={onClose}><X className="h-4 w-4" /></Button>
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-auto p-4">
{loading ? (
<div className="flex items-center justify-center h-full"><Loader2 className="h-6 w-6 animate-spin text-slate-500" /></div>
) : editing ? (
<textarea
value={yaml}
onChange={e => setYaml(e.target.value)}
className="w-full h-full min-h-[500px] bg-slate-950 text-emerald-400 font-mono text-xs p-4 rounded border border-slate-700 focus:outline-none focus:border-blue-500 resize-none"
spellCheck={false}
/>
) : (
<pre className="text-emerald-400 font-mono text-xs whitespace-pre overflow-x-auto">{yaml}</pre>
)}
</div>
</div>
</div>
);
const display = editing ? editYaml : rawYaml;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
<div className="bg-slate-900 border border-slate-700 rounded-xl w-[90vw] max-w-4xl max-h-[80vh] flex flex-col shadow-2xl" onClick={e => e.stopPropagation()}>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-slate-700">
<div>
<h2 className="text-lg font-bold text-slate-100">{resource}/{name}</h2>
<p className="text-xs text-slate-500">{namespace} · {clusterCode}</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300" onClick={copyYaml}>
{copied ? <Check className="h-3 w-3 mr-1 text-emerald-400" /> : <Copy className="h-3 w-3 mr-1" />}
{copied ? '已复制' : '复制'}
</Button>
{!editing ? (
<>
{cleanBeforeEdit && (
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300" onClick={enterEdit} title="清理 status / managedFields 等只读字段后编辑">
<Sparkles className="h-3 w-3 mr-1 text-amber-400" />清理并编辑
</Button>
)}
{!cleanBeforeEdit && (
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300" onClick={() => setEditing(true)}>
编辑
</Button>
)}
</>
) : (
<>
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300" onClick={() => { setEditing(false); loadYaml(); }}>
取消
</Button>
<Button size="sm" className="h-8 bg-blue-600 hover:bg-blue-700" onClick={saveYaml} disabled={saving}>
{saving ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Save className="h-3 w-3 mr-1" />}
保存
</Button>
</>
)}
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400" onClick={onClose}><X className="h-4 w-4" /></Button>
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-auto p-4">
{loading ? (
<div className="flex items-center justify-center h-full"><Loader2 className="h-6 w-6 animate-spin text-slate-500" /></div>
) : editing ? (
<textarea
value={editYaml}
onChange={e => setEditYaml(e.target.value)}
className="w-full h-full min-h-[500px] bg-slate-950 text-emerald-400 font-mono text-xs p-4 rounded border border-slate-700 focus:outline-none focus:border-blue-500 resize-none"
spellCheck={false}
/>
) : (
<pre className="text-emerald-400 font-mono text-xs whitespace-pre overflow-x-auto">{display}</pre>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,194 @@
// k8s-yaml-utils.ts — YAML 编辑前清理工具(借鉴 opsx-agent cleanYAML 设计)
// 不依赖 js-yaml;基于文本行级解析,针对 K8s 标准 manifest 结构清理。
// 作者: Logan <admin@attacker.club>
/**
* 编辑前清理 YAML 文本:
* - 移除 status 整块(K8s 服务端字段)
* - 移除 metadata 中的只读/噪音字段:uid / resourceVersion / managedFields /
* creationTimestamp / generation / selfLink / ownerReferences / resourceVersion
* - 移除 annotations 中的 kubectl.kubernetes.io/last-applied-configuration
* - 规范化行尾,保留可读缩进
*/
export function cleanYAML(yaml: string): string {
if (!yaml) return '';
const lines = yaml.replace(/\r\n/g, '\n').split('\n');
const out: string[] = [];
let i = 0;
// metadata 下需要整块移除的字段(值为多行块或对象)
const METADATA_BLOCK_KEYS = new Set([
'managedFields',
'ownerReferences',
]);
// metadata 下需要单行移除的字段
const METADATA_LINE_KEYS = new Set([
'uid',
'resourceVersion',
'creationTimestamp',
'generation',
'selfLink',
'clusterName',
'managedFields', // 兜底
]);
while (i < lines.length) {
const line = lines[i];
const trimmed = line.trimStart();
const indent = line.length - trimmed.length;
// 1) 顶层 status: 整块移除(indent === 0)
if (indent === 0 && /^status\s*:/.test(trimmed)) {
i++;
// 跳过所有后续属于 status 块的行(缩进 > 0 或空行),直到下一个顶层 key
while (i < lines.length) {
const l = lines[i];
const t = l.trimStart();
if (t === '' || t.startsWith('#')) { i++; continue; }
const ind = l.length - t.length;
if (ind === 0) break; // 回到顶层
i++;
}
continue;
}
// 2) metadata 子字段清理
// 匹配形如 " uid: xxx" 或 " managedFields:" 等
const metaKeyMatch = trimmed.match(/^([a-zA-Z_][a-zA-Z0-9_-]*)\s*:/);
if (metaKeyMatch && indent >= 2 && indent <= 6) {
const key = metaKeyMatch[1];
// 单行移除
if (METADATA_LINE_KEYS.has(key)) {
// 若该行值非空(如 "uid: xxx"),直接跳过该行
// 若该行值为空(如 "managedFields:"),需要跳过其下属缩进块
const afterColon = trimmed.slice(metaKeyMatch[0].length).trim();
if (afterColon === '') {
i++;
while (i < lines.length) {
const l = lines[i];
const t = l.trimStart();
if (t === '' || t.startsWith('#')) { i++; continue; }
const ind = l.length - t.length;
if (ind <= indent) break;
i++;
}
} else {
i++;
}
continue;
}
// managedFields 块移除(即使有内联内容也整块跳过)
if (METADATA_BLOCK_KEYS.has(key)) {
i++;
while (i < lines.length) {
const l = lines[i];
const t = l.trimStart();
if (t === '' || t.startsWith('#')) { i++; continue; }
const ind = l.length - t.length;
if (ind <= indent) break;
i++;
}
continue;
}
}
// 3) annotations 内 last-applied-configuration 移除
// 形如 " kubectl.kubernetes.io/last-applied-configuration: >-"
// 可能跨多行(block scalar)
if (/kubectl\.kubernetes\.io\/last-applied-configuration\s*:/.test(trimmed)) {
const afterColon = trimmed.split(':').slice(1).join(':').trim();
// 多行 block scalar 标记:>- >- | |- 等
const isBlockScalar = /^[>|]/.test(afterColon);
i++;
if (isBlockScalar) {
while (i < lines.length) {
const l = lines[i];
const t = l.trimStart();
if (t === '') { i++; continue; }
const ind = l.length - t.length;
if (ind <= indent) break;
i++;
}
}
continue;
}
out.push(line);
i++;
}
// 末尾保持一个换行
let result = out.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd();
return result + '\n';
}
/** 解析简单 "key: value" 行(顶层或指定缩进),用于表单回填兜底。 */
export function parseScalar(line: string): { key: string; value: string } | null {
const m = line.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/);
if (!m) return null;
return { key: m[1], value: m[2].trim().replace(/^["']|["']$/g, '') };
}
/** 状态色(与 opsx-agent 一致) */
export const STATUS_COLORS = {
green: '#10b981',
orange: '#f59e0b',
red: '#ef4444',
blue: '#326CE5', // Kubernetes 官方蓝
} as const;
/** 根据 Pod/Deployment 状态返回对应色值 */
export function statusColor(status: string | undefined): string {
if (!status) return '#64748b';
const s = status.toLowerCase();
if (['running', 'available', 'ready', 'active', 'completed', 'succeeded'].includes(s)) return STATUS_COLORS.green;
if (['pending', 'updating', 'progressing', 'containercreating'].includes(s)) return STATUS_COLORS.orange;
if (['failed', 'error', 'crashloopbackoff', 'imagepullbackoff', 'errimageneverpull'].includes(s)) return STATUS_COLORS.red;
return STATUS_COLORS.blue;
}
/** 按镜像聚合 ReplicaSet / Revision 列表(同镜像合并展示) */
export function aggregateByImage<T extends { image?: string; images?: string[] }>(items: T[]): Array<T & { _count: number }> {
const map = new Map<string, T & { _count: number }>();
for (const it of items) {
const img = it.image || (Array.isArray(it.images) ? it.images[0] : '') || 'unknown';
const key = img;
const existing = map.get(key);
if (existing) {
existing._count += 1;
} else {
map.set(key, { ...it, _count: 1 });
}
}
return Array.from(map.values());
}
/** 格式化 K8s 时间戳(ISO / RFC3339)为本地可读格式 */
export function formatK8sTime(ts: string | undefined | null): string {
if (!ts) return '-';
const d = new Date(ts);
if (isNaN(d.getTime())) return ts;
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
/** 计算两个时间戳之间的相对耗时(秒/分/时),用于"调度耗时""就绪耗时"展示 */
export function elapsedSeconds(start?: string | null, end?: string | null): number | null {
if (!start || !end) return null;
const s = new Date(start).getTime();
const e = new Date(end).getTime();
if (isNaN(s) || isNaN(e) || e < s) return null;
return Math.max(0, Math.round((e - s) / 1000));
}
/** 把秒数格式化为 "1m23s" / "2h5m" / "45s" */
export function formatDuration(sec: number | null | undefined): string {
if (sec == null) return '-';
if (sec < 60) return `${sec}s`;
if (sec < 3600) return `${Math.floor(sec / 60)}m${sec % 60}s`;
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
return `${h}h${m}m`;
}
+126
View File
@@ -203,9 +203,112 @@ export const kubernetesApi = {
if (container) params.set('container', container);
return http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/pods/${podName}/logs?${params}`);
},
// 🆕 增强版 Pod 日志查询 (Logan <admin@attacker.club>)
// 支持容器选择 / 行数限制 / previous 容器日志 / sinceTime 时间范围
getPodLogsEnhanced: async (
clusterCode: string,
namespace: string,
podName: string,
opts: {
container?: string;
tailLines?: number; // 0 / undefined => 全部
previous?: boolean;
sinceTime?: string; // RFC3339,如 2024-01-01T00:00:00Z
} = {}
) => {
const params = new URLSearchParams();
if (opts.container) params.set('container', opts.container);
if (opts.tailLines && opts.tailLines > 0) params.set('tailLines', String(opts.tailLines));
if (opts.previous) params.set('previous', 'true');
if (opts.sinceTime) params.set('sinceTime', opts.sinceTime);
const qs = params.toString();
return http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/pods/${podName}/logs${qs ? `?${qs}` : ''}`);
},
// 🆕 构建日志流式 URL(tail -f 效果,fetch ReadableStream)(Logan <admin@attacker.club>)
buildPodLogsStreamUrl: (
clusterCode: string,
namespace: string,
podName: string,
opts: {
container?: string;
tailLines?: number;
previous?: boolean;
sinceTime?: string;
} = {}
) => {
const params = new URLSearchParams();
params.set('stream', 'true');
if (opts.container) params.set('container', opts.container);
if (opts.tailLines && opts.tailLines > 0) params.set('tailLines', String(opts.tailLines));
if (opts.previous) params.set('previous', 'true');
if (opts.sinceTime) params.set('sinceTime', opts.sinceTime);
const baseUrl = import.meta.env.VITE_API_BASE_URL || `${window.location.protocol}//${window.location.host}`;
return `${baseUrl}/api/v1/kubernetes/${clusterCode}/namespace/${namespace}/pods/${podName}/logs?${params}`;
},
execPod: async (clusterCode: string, namespace: string, podName: string, command: string[], container?: string) => {
return http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/pods/${podName}/exec`, { command, container });
},
// 🆕 Pod 级 exec WebSocket URL (Logan <admin@attacker.club>)
// ws://host/ws/kubernetes-exec/:clusterId/namespaces/:ns/pods/:pod?container=&token=
buildPodExecWsUrl: (
clusterId: number | string,
namespace: string,
podName: string,
container?: string
) => {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const baseUrl = import.meta.env.VITE_API_BASE_URL || `${proto}//${window.location.host}`;
const wsBase = baseUrl.replace(/^http/, 'ws').replace('/api/v1', '');
const token = useUserStore.getState().token || '';
const params = new URLSearchParams();
if (container) params.set('container', container);
if (token) params.set('token', token);
const qs = params.toString();
return `${wsBase}/ws/kubernetes-exec/${clusterId}/namespaces/${namespace}/pods/${podName}${qs ? `?${qs}` : ''}`;
},
// === 🆕 容器文件浏览器 (Logan <admin@attacker.club>) ===
// 后端 API 并行开发中,UI 先行;返回结构兼容 {entries:[{name,isDir,size,modTime}]} 或数组
listPodFiles: async (
clusterCode: string,
namespace: string,
podName: string,
path: string,
container?: string
) => {
return http.post(
`/kubernetes/${clusterCode}/namespace/${namespace}/pods/${podName}/files/list`,
{ path, container }
);
},
// 构建文件下载 URL(带 token,供 window.open / fetch 使用)
buildPodFileDownloadUrl: (
clusterCode: string,
namespace: string,
podName: string,
path: string,
container?: string
) => {
const params = new URLSearchParams({ path });
if (container) params.set('container', container);
const token = useUserStore.getState().token || '';
if (token) params.set('token', token);
const baseUrl = import.meta.env.VITE_API_BASE_URL || `${window.location.protocol}//${window.location.host}`;
return `${baseUrl}/api/v1/kubernetes/${clusterCode}/namespace/${namespace}/pods/${podName}/files/download?${params}`;
},
// 读取容器内文本文件(小文件预览)
readPodFileText: async (
clusterCode: string,
namespace: string,
podName: string,
path: string,
container?: string
) => {
const url = kubernetesApi.buildPodFileDownloadUrl(clusterCode, namespace, podName, path, container);
const resp = await fetch(url);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
return resp.text();
},
scaleDeployment: async (clusterCode: string, namespace: string, name: string, replicas: number) => {
return http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${name}/scale`, { replicas });
},
@@ -340,6 +443,29 @@ export const kubernetesApi = {
applyYAML: async (clusterCode: string, yaml: string) => {
return http.post(`/kubernetes/${clusterCode}/yaml/apply`, { yaml });
},
// === 🆕 ReplicaSet / Revision / Rollback (Logan.Li — Kuboard-style) ===
// 后端并行开发中,前端按约定路径预先对接;后端就绪后自动生效。
getReplicaSets: async (clusterCode: string, namespace: string) => {
return http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/replicasets`);
},
getDeploymentRevisions: async (clusterCode: string, namespace: string, name: string) => {
return http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${name}/revisions`);
},
rollbackDeployment: async (clusterCode: string, namespace: string, name: string, revision: number) => {
return http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${name}/rollback`, { revision });
},
// 单个 Deployment 详情(如后端提供专用端点则用之,否则从列表过滤)
getDeployment: async (clusterCode: string, namespace: string, name: string) => {
try {
return await http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${name}`);
} catch {
// 降级:从列表中过滤
const res: any = await http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments`);
const list = Array.isArray(res) ? res : (res?.list || res?.items || res?.data || []);
return list.find((d: any) => d.name === name) || null;
}
},
};
// ==================== 告警 API ====================
+10
View File
@@ -0,0 +1,10 @@
// js-yaml 类型声明兜底
// 项目直接依赖的 js-yaml 包未自带 .d.ts,此处给出最小声明,避免 TS7016。
// 如后续安装 @types/js-yaml,可删除本文件。
declare module 'js-yaml' {
export function load(text: string, opts?: any): any;
export function dump(obj: any, opts?: any): string;
export const YAML_SCHEMA: any;
const _default: { load: typeof load; dump: typeof dump };
export default _default;
}