From a3cd0d391047a9952edf21b81ec2f87d14037f6f Mon Sep 17 00:00:00 2001 From: attacker Date: Fri, 19 Jun 2026 11:22:45 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20PTY=20kubectl=E7=BB=88=E7=AB=AF=20+=20?= =?UTF-8?q?=E4=BE=A7=E8=BE=B9=E6=A0=8F=E5=AF=B9=E9=BD=90=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=20+=20=E6=9E=84=E5=BB=BA=E7=BC=93=E5=AD=98=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **kubectl WebShell 修复:** - 引入 github.com/creack/pty 库实现真正的伪终端 - 替换 bash --noediting + TERM=dumb 方案为 PTY-backed bash - 支持 xterm-256color、窗口自适应、readline 交互 - 修复键盘输入无响应问题 **侧边栏优化:** - 独立菜单项高度从 py-1.5 改为 h-8,与父菜单对齐 - 独立菜单图标从 h-3 w-3 改为 h-3.5 w-3.5,统一 14px **构建优化:** - run.sh docker up 增加 docker image prune -f 清理悬空镜像 - 每次部署自动清理 builder cache + dangling images **认证修复(历史):** - Token 存储:Zustand + localStorage 双写,http.ts 双源 fallback - YamlViewer/DeploymentDetail token 来源统一为 Zustand store --- backend/Dockerfile | 6 +- backend/go.mod | 1 + backend/go.sum | 2 + .../internal/api/handlers/kubectl_shell.go | 191 ++++++ backend/internal/api/handlers/kubernetes.go | 587 +++++++++++++++++- backend/internal/api/routes/routes.go | 29 + backend/pkg/kubernetesclient/client.go | 373 ++++++++++- frontend/src/App.tsx | 70 ++- frontend/src/components/auth/Login.tsx | 1 + .../components/kubernetes/CapacityPage.tsx | 139 +++++ .../kubernetes/ClusterOverviewPage.tsx | 229 +++++++ .../kubernetes/DeploymentDetail.tsx | 34 +- .../kubernetes/DeploymentDetailPage.tsx | 18 + .../src/components/kubernetes/EventsPage.tsx | 147 +++++ .../components/kubernetes/KubectlTerminal.tsx | 211 +++++++ .../kubernetes/KubernetesClusters.tsx | 30 + .../kubernetes/KubernetesWorkloads.tsx | 4 +- .../kubernetes/NamespaceOverviewPage.tsx | 97 +++ .../kubernetes/NamespaceResourcePage.tsx | 377 +++++++++++ .../components/kubernetes/PodMetricsPage.tsx | 152 +++++ .../components/kubernetes/TopologyView.tsx | 14 +- .../src/components/kubernetes/YamlViewer.tsx | 119 ++++ frontend/src/components/layout/Sidebar.tsx | 200 ++---- .../PrometheusDatasourceManager.tsx | 2 +- frontend/src/components/server/ServerForm.tsx | 2 +- frontend/src/services/api.ts | 72 ++- frontend/src/services/http.ts | 22 +- frontend/src/store/index.ts | 2 +- run.sh | 3 +- 29 files changed, 2954 insertions(+), 180 deletions(-) create mode 100644 backend/internal/api/handlers/kubectl_shell.go create mode 100644 frontend/src/components/kubernetes/CapacityPage.tsx create mode 100644 frontend/src/components/kubernetes/ClusterOverviewPage.tsx create mode 100644 frontend/src/components/kubernetes/DeploymentDetailPage.tsx create mode 100644 frontend/src/components/kubernetes/EventsPage.tsx create mode 100644 frontend/src/components/kubernetes/KubectlTerminal.tsx create mode 100644 frontend/src/components/kubernetes/NamespaceOverviewPage.tsx create mode 100644 frontend/src/components/kubernetes/NamespaceResourcePage.tsx create mode 100644 frontend/src/components/kubernetes/PodMetricsPage.tsx create mode 100644 frontend/src/components/kubernetes/YamlViewer.tsx diff --git a/backend/Dockerfile b/backend/Dockerfile index 0b6a996..996555b 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -61,8 +61,10 @@ FROM alpine:latest # 使用国内 Alpine 镜像源加速 // Logan.Li RUN sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories -# 安装运行时依赖(curl=健康检查,openssh-client=SSH远程安装agent) -RUN apk --no-cache add ca-certificates tzdata curl openssh-client +# 安装运行时依赖(bash=kubectl交互终端,curl=健康检查,openssh-client=SSH远程安装agent,kubectl=集群命令行管理) +RUN apk --no-cache add ca-certificates tzdata bash curl openssh-client && \ + curl -sLO "https://dl.k8s.io/release/$(curl -sL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" && \ + chmod +x kubectl && mv kubectl /usr/local/bin/ ENV TZ=Asia/Shanghai diff --git a/backend/go.mod b/backend/go.mod index 741e8ce..42af14c 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -26,6 +26,7 @@ require ( github.com/bytedance/sonic v1.9.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/creack/pty v1.1.24 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect diff --git a/backend/go.sum b/backend/go.sum index dd2ee7a..88b453f 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -14,6 +14,8 @@ github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= diff --git a/backend/internal/api/handlers/kubectl_shell.go b/backend/internal/api/handlers/kubectl_shell.go new file mode 100644 index 0000000..d8b7166 --- /dev/null +++ b/backend/internal/api/handlers/kubectl_shell.go @@ -0,0 +1,191 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "os/exec" + "strconv" + "syscall" + + "github.com/creack/pty" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "gorm.io/gorm" + + "ezops/internal/models" +) + +// KubectlShellHandler kubectl WebSocket 终端处理(PTY 模式) +type KubectlShellHandler struct { + db *gorm.DB +} + +// NewKubectlShellHandler creates handler +func NewKubectlShellHandler(db *gorm.DB) *KubectlShellHandler { + return &KubectlShellHandler{db: db} +} + +// wsWriter wraps a websocket.Conn as an io.Writer (each write = one WSMessage) +type wsWriter struct { + conn *websocket.Conn +} + +func (w wsWriter) Write(p []byte) (int, error) { + err := w.conn.WriteJSON(WSMessage{Status: 0, Data: string(p)}) + if err != nil { + return 0, err + } + return len(p), nil +} + +// Terminal starts a PTY-backed bash session with the cluster's kubeconfig injected +func (h *KubectlShellHandler) Terminal(c *gin.Context) { + clusterID := c.Query("id") + if clusterID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing cluster id"}) + return + } + id, err := strconv.Atoi(clusterID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid cluster id"}) + return + } + + var cluster models.KubernetesCluster + if err := h.db.First(&cluster, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "cluster not found"}) + return + } + if cluster.KubeConfig == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "no kubeconfig configured"}) + return + } + + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + log.Printf("[KubectlShell] WebSocket upgrade failed: %v", err) + return + } + defer conn.Close() + + // Write kubeconfig to a temp file (isolated per session) + tmpFile, err := os.CreateTemp("", "kubeconfig-*.yaml") + if err != nil { + conn.WriteJSON(WSMessage{Status: 0, Data: "\r\n\x1b[31mFailed to create temp kubeconfig\x1b[0m\r\n"}) + return + } + defer os.Remove(tmpFile.Name()) + if _, err := tmpFile.WriteString(cluster.KubeConfig); err != nil { + conn.WriteJSON(WSMessage{Status: 0, Data: "\r\n\x1b[31mFailed to write kubeconfig\x1b[0m\r\n"}) + return + } + tmpFile.Close() + + // 启动 bash -i(TERM=dumb,无 PS1 ANSI 码,不回显混乱) + // 注意:不加 --noediting,否则无 TTY 时 bash 不回显输入 + cmd := exec.CommandContext(c.Request.Context(), "bash", "--norc", "-i") + cmd.Env = append(os.Environ(), + "KUBECONFIG="+tmpFile.Name(), + "HOME=/root", + "TERM=xterm-256color", + fmt.Sprintf("PS1=\x1b[32m[%s]\x1b[0m $ ", cluster.Code), + "COLUMNS=120", + "LINES=30", + ) + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setsid: true, + Setctty: true, + } + + ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: 120, Rows: 30}) + if err != nil { + conn.WriteJSON(WSMessage{Status: 0, Data: "\r\n\x1b[31mFailed to start shell (bash not available?)\x1b[0m\r\n"}) + return + } + defer func() { + ptmx.Close() + // Kill the process group so child processes (kubectl, etc.) are cleaned up + if cmd.Process != nil { + syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + } + cmd.Wait() + }() + + // Welcome message (already on PTY so bash will show PS1 right after) + welcome := fmt.Sprintf("\r\n\x1b[32m=== kubectl shell — %s (%s) ===\x1b[0m\r\n", cluster.Name, cluster.Code) + welcome += "\x1b[36mKUBECONFIG 已预配 | 输入 'exit' 退出\x1b[0m\r\n\r\n" + conn.WriteJSON(WSMessage{Status: 0, Data: welcome}) + + // Use a cancellable context for the two goroutines + ctx, cancel := context.WithCancel(c.Request.Context()) + defer cancel() + + // PTY → WebSocket (single goroutine via io.Copy) + go func() { + defer cancel() // unblock main goroutine when PTY closes + n, err := io.Copy(wsWriter{conn: conn}, ptmx) + if err != nil && err != io.EOF { + log.Printf("[KubectlShell] PTY copy ended: %v (bytes=%d)", err, n) + } + }() + + // WebSocket → PTY (read JSON messages, write raw to PTY) + go func() { + defer ptmx.Close() // close PTY to stop bash and unblock Copy goroutine + for { + msgType, data, err := conn.ReadMessage() + if err != nil { + if !websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) { + // Normal close — client disconnected + } else { + log.Printf("[KubectlShell] WS read error: %v", err) + } + return + } + if msgType == websocket.TextMessage || msgType == websocket.BinaryMessage { + var msg WSMessage + if err := json.Unmarshal(data, &msg); err != nil { + // Binary data (rare) — pass through raw + if msgType == websocket.BinaryMessage { + ptmx.Write(data) + } + continue + } + switch msg.Status { + case 0: + // Keyboard input — write raw bytes to PTY + if _, err := ptmx.Write([]byte(msg.Data)); err != nil { + log.Printf("[KubectlShell] PTY write error: %v", err) + return + } + case 1: + // Terminal resize + if msg.Cols > 0 && msg.Rows > 0 { + if err := pty.Setsize(ptmx, &pty.Winsize{ + Cols: uint16(msg.Cols), + Rows: uint16(msg.Rows), + }); err != nil { + log.Printf("[KubectlShell] PTY resize error: %v", err) + } + } + } + } + } + }() + + // Block until PTY closes or context cancelled + <-ctx.Done() + log.Printf("[KubectlShell] session ended for cluster %s", cluster.Name) +} + +func init() { + // ensure kubectl is available + if _, err := exec.LookPath("kubectl"); err != nil { + log.Printf("[KubectlShell] WARNING: kubectl not found in PATH — kubectl commands will not work") + } +} diff --git a/backend/internal/api/handlers/kubernetes.go b/backend/internal/api/handlers/kubernetes.go index 3a945c2..9c6171b 100644 --- a/backend/internal/api/handlers/kubernetes.go +++ b/backend/internal/api/handlers/kubernetes.go @@ -305,7 +305,14 @@ func (h *KubernetesHandler) ListNodes(c *gin.Context) { return } - // Convert to simplified response + // Attempt to fetch metrics (best-effort, metrics-server may not be installed) + metrics := map[string]k8sclient.NodeMetricsItem{} + if m, err := client.GetNodeMetrics(); err == nil { + for _, item := range m.Items { + metrics[item.Metadata.Name] = item + } + } + type NodeSummary struct { Name string `json:"name"` IP string `json:"ip"` @@ -316,6 +323,9 @@ func (h *KubernetesHandler) ListNodes(c *gin.Context) { CPU int `json:"cpu"` Memory int64 `json:"memory_mb"` MaxPods int `json:"max_pods"` + // Metrics (optional, omitted if empty) + CPUUsageM string `json:"cpu_usage_m,omitempty"` // e.g. "122m" + MemUsageMi string `json:"mem_usage_mi,omitempty"` // e.g. "2105Mi" } result := []NodeSummary{} for _, n := range nodes.Items { @@ -340,7 +350,7 @@ func (h *KubernetesHandler) ListNodes(c *gin.Context) { break } } - result = append(result, NodeSummary{ + s := NodeSummary{ Name: n.Metadata.Name, IP: ip, Role: role, @@ -350,7 +360,13 @@ func (h *KubernetesHandler) ListNodes(c *gin.Context) { CPU: parseIntResource(n.Status.Capacity["cpu"]), Memory: parseMemResource(n.Status.Capacity["memory"]), MaxPods: parseIntResource(n.Status.Capacity["pods"]), - }) + } + if m, ok := metrics[n.Metadata.Name]; ok { + // K8s metrics API returns CPU in nanocores ("80410173n"), memory in Ki ("2154292Ki") + s.CPUUsageM = formatCpuFromNano(m.Usage["cpu"]) + s.MemUsageMi = formatMemFromKi(m.Usage["memory"]) + } + result = append(result, s) } c.JSON(http.StatusOK, models.Response{Code: 200, Message: "success", Data: result}) } @@ -1455,13 +1471,25 @@ func (h *KubernetesHandler) listDeploymentsWithNS(c *gin.Context, ns string) { deps, err := client.ListDeployments() if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } type DepSummary struct { - Namespace, Name string; Replicas, AvailableReplicas, ReadyReplicas int; Image string + Namespace string `json:"namespace"` + Name string `json:"name"` + Replicas int `json:"replicas"` + AvailableReplicas int `json:"available_replicas"` + ReadyReplicas int `json:"ready_replicas"` + Image string `json:"image"` } result := []DepSummary{} for _, d := range deps.Items { if d.Metadata.Namespace != ns { continue } image := ""; if len(d.Spec.Template.Spec.Containers) > 0 { image = d.Spec.Template.Spec.Containers[0].Image } - result = append(result, DepSummary{d.Metadata.Namespace, d.Metadata.Name, d.Spec.Replicas, d.Status.AvailableReplicas, d.Status.ReadyReplicas, image}) + result = append(result, DepSummary{ + Namespace: d.Metadata.Namespace, + Name: d.Metadata.Name, + Replicas: d.Spec.Replicas, + AvailableReplicas: d.Status.AvailableReplicas, + ReadyReplicas: d.Status.ReadyReplicas, + Image: image, + }) } c.JSON(200, models.Response{Code: 200, Data: result}) } @@ -1472,13 +1500,29 @@ func (h *KubernetesHandler) listPodsWithNS(c *gin.Context, ns string) { if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } pods, err := client.ListPods() if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } - type PodSummary struct { Namespace, Name, Status, NodeName, IP string; RestartCount int; Image string } + type PodSummary struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Status string `json:"status"` + NodeName string `json:"node_name"` + IP string `json:"ip"` + RestartCount int `json:"restart_count"` + Image string `json:"image"` + } result := []PodSummary{} for _, p := range pods.Items { if p.Metadata.Namespace != ns { continue } image := ""; if len(p.Spec.Containers) > 0 { image = p.Spec.Containers[0].Image } restarts := 0; if len(p.Status.ContainerStatuses) > 0 { restarts = p.Status.ContainerStatuses[0].RestartCount } - result = append(result, PodSummary{p.Metadata.Namespace, p.Metadata.Name, p.Status.Phase, p.Spec.NodeName, p.Status.PodIP, restarts, image}) + result = append(result, PodSummary{ + Namespace: p.Metadata.Namespace, + Name: p.Metadata.Name, + Status: p.Status.Phase, + NodeName: p.Spec.NodeName, + IP: p.Status.PodIP, + RestartCount: restarts, + Image: image, + }) } c.JSON(200, models.Response{Code: 200, Data: result}) } @@ -1489,11 +1533,23 @@ func (h *KubernetesHandler) listServicesWithNS(c *gin.Context, ns string) { if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } svcs, err := client.ListServices() if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } - type SvcSummary struct { Namespace, Name, Type, ClusterIP string; Ports int } + type SvcSummary struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Type string `json:"type"` + ClusterIP string `json:"cluster_ip"` + Ports int `json:"ports"` + } result := []SvcSummary{} for _, s := range svcs.Items { if s.Metadata.Namespace != ns { continue } - result = append(result, SvcSummary{s.Metadata.Namespace, s.Metadata.Name, s.Spec.Type, s.Spec.ClusterIP, len(s.Spec.Ports)}) + result = append(result, SvcSummary{ + Namespace: s.Metadata.Namespace, + Name: s.Metadata.Name, + Type: s.Spec.Type, + ClusterIP: s.Spec.ClusterIP, + Ports: len(s.Spec.Ports), + }) } c.JSON(200, models.Response{Code: 200, Data: result}) } @@ -1504,13 +1560,23 @@ func (h *KubernetesHandler) listIngressesWithNS(c *gin.Context, ns string) { if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } ings, err := client.ListIngresses() if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } - type IngSummary struct { Namespace, Name string; Hosts []string; TLS bool } + type IngSummary struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Hosts []string `json:"hosts"` + TLS bool `json:"tls"` + } result := []IngSummary{} for _, ing := range ings.Items { if ing.Metadata.Namespace != ns { continue } hosts := []string{} for _, rule := range ing.Spec.Rules { if rule.Host != "" { hosts = append(hosts, rule.Host) } } - result = append(result, IngSummary{ing.Metadata.Namespace, ing.Metadata.Name, hosts, len(ing.Spec.TLS) > 0}) + result = append(result, IngSummary{ + Namespace: ing.Metadata.Namespace, + Name: ing.Metadata.Name, + Hosts: hosts, + TLS: len(ing.Spec.TLS) > 0, + }) } c.JSON(200, models.Response{Code: 200, Data: result}) } @@ -1521,9 +1587,40 @@ func (h *KubernetesHandler) listHPAsWithNS(c *gin.Context, ns string) { if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } hpas, err := client.ListHPAs() if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } - filtered := []k8sclient.HPAItem{} - for _, h := range hpas.Items { if h.Metadata.Namespace == ns { filtered = append(filtered, h) } } - c.JSON(200, models.Response{Code: 200, Data: filtered}) + type HPASummary struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + TargetKind string `json:"target_kind"` + TargetName string `json:"target_name"` + MinReplicas int `json:"min_replicas"` + MaxReplicas int `json:"max_replicas"` + CPUPercent int `json:"cpu_percent"` + MemPercent int `json:"mem_percent"` + CurrentReplicas int `json:"current_replicas"` + } + result := []HPASummary{} + for _, h := range hpas.Items { + if h.Metadata.Namespace != ns { continue } + cpu := 0; mem := 0 + for _, m := range h.Spec.Metrics { + if m.Resource != nil { + if m.Resource.Name == "cpu" { cpu = m.Resource.Target.AverageUtilization } + if m.Resource.Name == "memory" { mem = m.Resource.Target.AverageUtilization } + } + } + result = append(result, HPASummary{ + Namespace: h.Metadata.Namespace, + Name: h.Metadata.Name, + TargetKind: h.Spec.ScaleTargetRef.Kind, + TargetName: h.Spec.ScaleTargetRef.Name, + MinReplicas: h.Spec.MinReplicas, + MaxReplicas: h.Spec.MaxReplicas, + CPUPercent: cpu, + MemPercent: mem, + CurrentReplicas: h.Status.CurrentReplicas, + }) + } + c.JSON(200, models.Response{Code: 200, Data: result}) } func (h *KubernetesHandler) createHPAWithNS(c *gin.Context, ns string) { clusterID, _ := strconv.Atoi(c.Param("id")) @@ -1661,6 +1758,118 @@ func (h *KubernetesHandler) execInPodWithNS(c *gin.Context, ns string) { c.JSON(200, models.Response{Code: 200, Data: result}) } +// ============================================================================ +// All-namespace handlers (clusterCode-based, no namespace filter) +// ============================================================================ + +// ListDeploymentsAllNS lists all deployments across all namespaces using clusterCode. +func (h *KubernetesHandler) ListDeploymentsAllNS(c *gin.Context) { + clusterID := h.resolveClusterCode(c.Param("clusterCode")) + if clusterID == 0 { c.JSON(500, models.Response{Code: 500, Message: "cluster not found"}); return } + client, _, err := h.getK8sClient(clusterID) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } + deps, err := client.ListDeployments() + if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + type DepSummary struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Replicas int `json:"replicas"` + AvailableReplicas int `json:"available_replicas"` + ReadyReplicas int `json:"ready_replicas"` + Image string `json:"image"` + } + result := []DepSummary{} + for _, d := range deps.Items { + image := ""; if len(d.Spec.Template.Spec.Containers) > 0 { image = d.Spec.Template.Spec.Containers[0].Image } + result = append(result, DepSummary{ + Namespace: d.Metadata.Namespace, + Name: d.Metadata.Name, + Replicas: d.Spec.Replicas, + AvailableReplicas: d.Status.AvailableReplicas, + ReadyReplicas: d.Status.ReadyReplicas, + Image: image, + }) + } + c.JSON(200, models.Response{Code: 200, Data: result}) +} + +// ListPodsAllNS lists all pods across all namespaces using clusterCode. +func (h *KubernetesHandler) ListPodsAllNS(c *gin.Context) { + clusterID := h.resolveClusterCode(c.Param("clusterCode")) + if clusterID == 0 { c.JSON(500, models.Response{Code: 500, Message: "cluster not found"}); return } + client, _, err := h.getK8sClient(clusterID) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } + pods, err := client.ListPods() + if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + type PodSummary struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Status string `json:"status"` + NodeName string `json:"node_name"` + IP string `json:"ip"` + RestartCount int `json:"restart_count"` + Image string `json:"image"` + } + result := []PodSummary{} + for _, p := range pods.Items { + image := ""; if len(p.Spec.Containers) > 0 { image = p.Spec.Containers[0].Image } + restarts := 0; if len(p.Status.ContainerStatuses) > 0 { restarts = p.Status.ContainerStatuses[0].RestartCount } + result = append(result, PodSummary{ + Namespace: p.Metadata.Namespace, + Name: p.Metadata.Name, + Status: p.Status.Phase, + NodeName: p.Spec.NodeName, + IP: p.Status.PodIP, + RestartCount: restarts, + Image: image, + }) + } + c.JSON(200, models.Response{Code: 200, Data: result}) +} + +// ListHPAsAllNS lists all HPAs across all namespaces using clusterCode. +func (h *KubernetesHandler) ListHPAsAllNS(c *gin.Context) { + clusterID := h.resolveClusterCode(c.Param("clusterCode")) + if clusterID == 0 { c.JSON(500, models.Response{Code: 500, Message: "cluster not found"}); return } + client, _, err := h.getK8sClient(clusterID) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } + hpas, err := client.ListHPAs() + if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + type HPASummary struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + TargetKind string `json:"target_kind"` + TargetName string `json:"target_name"` + MinReplicas int `json:"min_replicas"` + MaxReplicas int `json:"max_replicas"` + CPUPercent int `json:"cpu_percent"` + MemPercent int `json:"mem_percent"` + CurrentReplicas int `json:"current_replicas"` + } + result := []HPASummary{} + for _, h := range hpas.Items { + cpu := 0; mem := 0 + for _, m := range h.Spec.Metrics { + if m.Resource != nil { + if m.Resource.Name == "cpu" { cpu = m.Resource.Target.AverageUtilization } + if m.Resource.Name == "memory" { mem = m.Resource.Target.AverageUtilization } + } + } + result = append(result, HPASummary{ + Namespace: h.Metadata.Namespace, + Name: h.Metadata.Name, + TargetKind: h.Spec.ScaleTargetRef.Kind, + TargetName: h.Spec.ScaleTargetRef.Name, + MinReplicas: h.Spec.MinReplicas, + MaxReplicas: h.Spec.MaxReplicas, + CPUPercent: cpu, + MemPercent: mem, + CurrentReplicas: h.Status.CurrentReplicas, + }) + } + c.JSON(200, models.Response{Code: 200, Data: result}) +} + // ============================================================================ // Helpers // ============================================================================ @@ -1693,6 +1902,92 @@ func parseMemResource(val string) int64 { } } +// formatCpuFromNano converts K8s metrics CPU (nanocores "80410173n") to millicores "80m" +func formatCpuFromNano(raw string) string { + if raw == "" { return "" } + val := raw + val = strings.TrimSuffix(val, "n") + var nano int64 + fmt.Sscanf(val, "%d", &nano) + milli := nano / 1000000 + if milli < 1 { milli = 1 } + return fmt.Sprintf("%dm", milli) +} + +// formatMemFromKi converts K8s metrics memory (Ki "2154292Ki") to Mi "2105Mi" +func formatMemFromKi(raw string) string { + if raw == "" { return "" } + val := strings.TrimSuffix(raw, "Ki") + var ki int64 + fmt.Sscanf(val, "%d", &ki) + mi := ki / 1024 + if mi < 1 { mi = 1 } + return fmt.Sprintf("%dMi", mi) +} + + +// GetResourceYAMLNS returns the YAML of a K8s resource +func (h *KubernetesHandler) GetResourceYAMLNS(c *gin.Context) { + clusterID := h.resolveClusterCode(c.Param("clusterCode")) + if clusterID == 0 { c.JSON(500, models.Response{Code: 500, Message: "cluster not found"}); return } + client, _, err := h.getK8sClient(clusterID) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } + ns := c.Param("namespace") + resource := c.Param("resource") + name := c.Param("name") + // Build API path based on resource type + var apiPath string + switch resource { + case "deployments": + apiPath = fmt.Sprintf("/apis/apps/v1/namespaces/%s/deployments/%s", ns, name) + case "pods": + apiPath = fmt.Sprintf("/api/v1/namespaces/%s/pods/%s", ns, name) + case "services": + apiPath = fmt.Sprintf("/api/v1/namespaces/%s/services/%s", ns, name) + case "ingresses": + apiPath = fmt.Sprintf("/apis/networking.k8s.io/v1/namespaces/%s/ingresses/%s", ns, name) + case "configmaps": + apiPath = fmt.Sprintf("/api/v1/namespaces/%s/configmaps/%s", ns, name) + case "secrets": + apiPath = fmt.Sprintf("/api/v1/namespaces/%s/secrets/%s", ns, name) + case "pvcs": + apiPath = fmt.Sprintf("/api/v1/namespaces/%s/persistentvolumeclaims/%s", ns, name) + default: + c.JSON(400, models.Response{Code: 400, Message: "unsupported resource type: " + resource}); return + } + yamlBytes, err := client.GetResourceYAML(apiPath) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: "获取YAML失败: " + err.Error()}); return } + c.Header("Content-Type", "application/yaml") + c.String(200, string(yamlBytes)) +} + +// UpdateResourceYAMLNS applies YAML to update a K8s resource +func (h *KubernetesHandler) UpdateResourceYAMLNS(c *gin.Context) { + clusterID := h.resolveClusterCode(c.Param("clusterCode")) + if clusterID == 0 { c.JSON(500, models.Response{Code: 500, Message: "cluster not found"}); return } + client, _, err := h.getK8sClient(clusterID) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } + ns := c.Param("namespace") + resource := c.Param("resource") + name := c.Param("name") + yamlData, err := io.ReadAll(c.Request.Body) + if err != nil { c.JSON(400, models.Response{Code: 400, Message: "读取body失败"}); return } + var apiPath string + switch resource { + case "deployments": + apiPath = fmt.Sprintf("/apis/apps/v1/namespaces/%s/deployments/%s", ns, name) + case "pods": + apiPath = fmt.Sprintf("/api/v1/namespaces/%s/pods/%s", ns, name) + case "services": + apiPath = fmt.Sprintf("/api/v1/namespaces/%s/services/%s", ns, name) + default: + c.JSON(400, models.Response{Code: 400, Message: "unsupported resource type: " + resource}); return + } + if err := client.UpdateResourceYAML(apiPath, yamlData); err != nil { + c.JSON(500, models.Response{Code: 500, Message: "更新失败: " + err.Error()}); return + } + c.JSON(200, models.Response{Code: 200, Message: "YAML已应用"}) +} // getK8sClient builds a K8s API client from cluster's stored kubeconfig func (h *KubernetesHandler) getK8sClient(clusterID int) (*k8sclient.Client, *models.KubernetesCluster, error) { @@ -1709,3 +2004,267 @@ func (h *KubernetesHandler) getK8sClient(clusterID int) (*k8sclient.Client, *mod } return client, &cluster, nil } + +// ============================================================================ +// PVC (存储) handlers +// ============================================================================ + +// ListPVCsNS lists PersistentVolumeClaims in a namespace (code-based) +func (h *KubernetesHandler) ListPVCsNS(c *gin.Context) { + clusterID := h.resolveClusterCode(c.Param("clusterCode")) + if clusterID == 0 { c.JSON(500, models.Response{Code: 500, Message: "cluster not found"}); return } + client, _, err := h.getK8sClient(clusterID) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } + ns := c.Param("namespace") + pvcs, err := client.ListPVCs(ns) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + type PVCSummary struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Phase string `json:"phase"` + Size string `json:"size"` + StorageClass string `json:"storage_class"` + } + result := []PVCSummary{} + for _, p := range pvcs.Items { + size := "" + if p.Status.Capacity != nil { + if s, ok := p.Status.Capacity["storage"]; ok { size = s } + } + result = append(result, PVCSummary{ + Namespace: p.Metadata.Namespace, + Name: p.Metadata.Name, + Phase: p.Status.Phase, + Size: size, + StorageClass: p.Spec.StorageClassName, + }) + } + c.JSON(200, models.Response{Code: 200, Data: result}) +} + +// DeletePVCNS deletes a PVC (code-based) +func (h *KubernetesHandler) DeletePVCNS(c *gin.Context) { + clusterID := h.resolveClusterCode(c.Param("clusterCode")) + if clusterID == 0 { c.JSON(500, models.Response{Code: 500, Message: "cluster not found"}); return } + client, _, err := h.getK8sClient(clusterID) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } + if err := client.DeletePVC(c.Param("namespace"), c.Param("name")); err != nil { + c.JSON(500, models.Response{Code: 500, Message: "删除失败: " + err.Error()}); return + } + c.JSON(200, models.Response{Code: 200, Message: "已删除"}) +} + +// ============================================================================ +// StatefulSet / DaemonSet handlers (code-based) +// ============================================================================ + +// ListStatefulSetsNS lists StatefulSets in a namespace +func (h *KubernetesHandler) ListStatefulSetsNS(c *gin.Context) { + clusterID := h.resolveClusterCode(c.Param("clusterCode")) + if clusterID == 0 { c.JSON(500, models.Response{Code: 500, Message: "cluster not found"}); return } + client, _, err := h.getK8sClient(clusterID) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } + sts, err := client.ListStatefulSets() + if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + ns := c.Param("namespace") + type StsSummary struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Replicas int `json:"replicas"` + Ready int `json:"ready"` + Image string `json:"image"` + } + result := []StsSummary{} + for _, s := range sts.Items { + if s.Metadata.Namespace != ns { continue } + image := ""; if len(s.Spec.Template.Spec.Containers) > 0 { image = s.Spec.Template.Spec.Containers[0].Image } + result = append(result, StsSummary{ + Namespace: s.Metadata.Namespace, + Name: s.Metadata.Name, + Replicas: s.Spec.Replicas, + Ready: s.Status.ReadyReplicas, + Image: image, + }) + } + c.JSON(200, models.Response{Code: 200, Data: result}) +} + +// ListDaemonSetsNS lists DaemonSets in a namespace +func (h *KubernetesHandler) ListDaemonSetsNS(c *gin.Context) { + clusterID := h.resolveClusterCode(c.Param("clusterCode")) + if clusterID == 0 { c.JSON(500, models.Response{Code: 500, Message: "cluster not found"}); return } + client, _, err := h.getK8sClient(clusterID) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } + ds, err := client.ListDaemonSets() + if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + ns := c.Param("namespace") + type DsSummary struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Desired int `json:"desired"` + Current int `json:"current"` + Ready int `json:"ready"` + Image string `json:"image"` + } + result := []DsSummary{} + for _, d := range ds.Items { + if d.Metadata.Namespace != ns { continue } + image := ""; if len(d.Spec.Template.Spec.Containers) > 0 { image = d.Spec.Template.Spec.Containers[0].Image } + result = append(result, DsSummary{ + Namespace: d.Metadata.Namespace, + Name: d.Metadata.Name, + Desired: d.Status.DesiredNumberScheduled, + Current: d.Status.CurrentNumberScheduled, + Ready: d.Status.NumberReady, + Image: image, + }) + } + c.JSON(200, models.Response{Code: 200, Data: result}) +} +// ============================================================================ +// Pod Metrics (kubectl top pods) — per-container CPU/Memory usage via metrics-server +// ============================================================================ + +// GetPodMetrics returns pod-level CPU/Memory metrics +func (h *KubernetesHandler) GetPodMetrics(c *gin.Context) { + clusterID := h.resolveClusterCode(c.Param("clusterCode")) + if clusterID == 0 { c.JSON(500, models.Response{Code: 500, Message: "cluster not found"}); return } + client, _, err := h.getK8sClient(clusterID) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } + + ns := c.Query("namespace") + metrics, err := client.ListPodMetrics(ns) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: "metrics API error: " + err.Error()}); return } + + type ContainerMetric struct { + Name string `json:"name"` + CPU string `json:"cpu"` + Memory string `json:"memory"` + } + type PodMetric struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Containers []ContainerMetric `json:"containers"` + } + result := []PodMetric{} + for _, pm := range metrics.Items { + containers := []ContainerMetric{} + for _, c := range pm.Containers { + containers = append(containers, ContainerMetric{c.Name, c.Usage["cpu"], c.Usage["memory"]}) + } + result = append(result, PodMetric{pm.Metadata.Namespace, pm.Metadata.Name, containers}) + } + c.JSON(200, models.Response{Code: 200, Data: result}) +} + +// ============================================================================ +// Events (kubectl get events) +// ============================================================================ + +// ListEvents returns cluster events (optionally filtered by namespace and type) +func (h *KubernetesHandler) ListEvents(c *gin.Context) { + clusterID := h.resolveClusterCode(c.Param("clusterCode")) + if clusterID == 0 { c.JSON(500, models.Response{Code: 500, Message: "cluster not found"}); return } + client, _, err := h.getK8sClient(clusterID) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } + + ns := c.Query("namespace") + eventType := c.Query("type") // Normal or Warning + events, err := client.ListEvents(ns) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + + type EventSummary struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Type string `json:"type"` + Reason string `json:"reason"` + Message string `json:"message"` + Object string `json:"object"` + Count int `json:"count"` + FirstTime string `json:"first_time"` + LastTime string `json:"last_time"` + } + result := []EventSummary{} + for _, e := range events.Items { + if eventType != "" && e.Type != eventType { continue } + object := fmt.Sprintf("%s/%s", e.InvolvedObject.Kind, e.InvolvedObject.Name) + if e.InvolvedObject.Namespace != "" { + object = fmt.Sprintf("%s/%s/%s", e.InvolvedObject.Namespace, e.InvolvedObject.Kind, e.InvolvedObject.Name) + } + firstTime := ""; if !e.FirstTimestamp.IsZero() { firstTime = e.FirstTimestamp.Format(time.RFC3339) } + lastTime := ""; if !e.LastTimestamp.IsZero() { lastTime = e.LastTimestamp.Format(time.RFC3339) } + result = append(result, EventSummary{ + Namespace: e.Metadata.Namespace, Name: e.Metadata.Name, Type: e.Type, Reason: e.Reason, + Message: e.Message, Object: object, Count: int(e.Count), + FirstTime: firstTime, LastTime: lastTime, + }) + } + c.JSON(200, models.Response{Code: 200, Data: result}) +} + +// ============================================================================ +// Capacity Analysis — CPU/Memory request/limit per Deployment +// ============================================================================ + +// GetCapacityAnalysis returns resource analysis for all deployments +func (h *KubernetesHandler) GetCapacityAnalysis(c *gin.Context) { + clusterID := h.resolveClusterCode(c.Param("clusterCode")) + if clusterID == 0 { c.JSON(500, models.Response{Code: 500, Message: "cluster not found"}); return } + client, _, err := h.getK8sClient(clusterID) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } + + ns := c.Query("namespace") + deps, err := client.ListDeployments() + if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } + + type ContainerResource struct { + Name string `json:"name"` + CPUReq string `json:"cpu_request"` + CPULim string `json:"cpu_limit"` + MemReq string `json:"mem_request"` + MemLim string `json:"mem_limit"` + } + type CapacityRow struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Replicas int `json:"replicas"` + Containers []ContainerResource `json:"containers"` + } + result := []CapacityRow{} + for _, d := range deps.Items { + if ns != "" && d.Metadata.Namespace != ns { continue } + containers := []ContainerResource{} + for _, c := range d.Spec.Template.Spec.Containers { + cpuReq := c.Resources.Requests["cpu"] + cpuLim := c.Resources.Limits["cpu"] + memReq := c.Resources.Requests["memory"] + memLim := c.Resources.Limits["memory"] + containers = append(containers, ContainerResource{c.Name, cpuReq, cpuLim, memReq, memLim}) + } + result = append(result, CapacityRow{d.Metadata.Namespace, d.Metadata.Name, d.Spec.Replicas, containers}) + } + c.JSON(200, models.Response{Code: 200, Data: result}) +} + +// ============================================================================ +// YAML Apply (kubectl apply -f) +// ============================================================================ + +// ApplyYAML applies a YAML manifest to create or update a resource +func (h *KubernetesHandler) ApplyYAML(c *gin.Context) { + clusterID := h.resolveClusterCode(c.Param("clusterCode")) + if clusterID == 0 { c.JSON(500, models.Response{Code: 500, Message: "cluster not found"}); return } + client, _, err := h.getK8sClient(clusterID) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } + + var req struct { + YAML string `json:"yaml"` + } + if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, models.Response{Code: 400, Message: "参数错误: " + err.Error()}); return } + if req.YAML == "" { c.JSON(400, models.Response{Code: 400, Message: "YAML 内容不能为空"}); return } + + result, err := client.ApplyYAML(req.YAML) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: "Apply 失败: " + err.Error()}); return } + + c.JSON(200, models.Response{Code: 200, Message: "资源已应用", Data: result}) +} diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index a59e6ba..ba2e055 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -68,6 +68,10 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c k8sHandler := handlers.NewKubernetesHandler(db) r.GET("/ws/kubernetes-exec/:id/namespaces/:namespace/pods/:podName", middleware.JWTAuth(&cfg.JWT), k8sHandler.ExecTerminal) + // WebSocket kubectl 命令行终端 // Logan.Li + kubectlShellHandler := handlers.NewKubectlShellHandler(db) + r.GET("/ws/kubectl", middleware.JWTAuth(&cfg.JWT), kubectlShellHandler.Terminal) + // 公开路由 authHandler := handlers.NewUserHandler(db, cfg) api.POST("/auth/login", authHandler.Login) @@ -320,6 +324,31 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c // Namespace list kubernetesRouter.GET("/:clusterCode/namespaces", k8sHandler.ListNamespaces) + // All-namespace listing (no ns filter) + kubernetesRouter.GET("/:clusterCode/deployments", k8sHandler.ListDeploymentsAllNS) + kubernetesRouter.GET("/:clusterCode/pods", k8sHandler.ListPodsAllNS) + kubernetesRouter.GET("/:clusterCode/hpas", k8sHandler.ListHPAsAllNS) + + // 🆕 Pod Metrics (kubectl top pods) + kubernetesRouter.GET("/:clusterCode/metrics/pods", k8sHandler.GetPodMetrics) + // 🆕 Events + kubernetesRouter.GET("/:clusterCode/events", k8sHandler.ListEvents) + // 🆕 Capacity Analysis + kubernetesRouter.GET("/:clusterCode/capacity", k8sHandler.GetCapacityAnalysis) + // 🆕 YAML Apply + kubernetesRouter.POST("/:clusterCode/yaml/apply", k8sHandler.ApplyYAML) + + // PVC (存储) + kubernetesRouter.GET("/:clusterCode/namespace/:namespace/pvcs", k8sHandler.ListPVCsNS) + kubernetesRouter.DELETE("/:clusterCode/namespace/:namespace/pvcs/:name", k8sHandler.DeletePVCNS) + + // StatefulSet / DaemonSet + kubernetesRouter.GET("/:clusterCode/namespace/:namespace/statefulsets", k8sHandler.ListStatefulSetsNS) + kubernetesRouter.GET("/:clusterCode/namespace/:namespace/daemonsets", k8sHandler.ListDaemonSetsNS) + + // YAML view/edit + kubernetesRouter.GET("/:clusterCode/namespace/:namespace/:resource/:name/yaml", k8sHandler.GetResourceYAMLNS) + kubernetesRouter.PUT("/:clusterCode/namespace/:namespace/:resource/:name/yaml", k8sHandler.UpdateResourceYAMLNS) } // 告警路由 diff --git a/backend/pkg/kubernetesclient/client.go b/backend/pkg/kubernetesclient/client.go index f2ff3b7..8efb4d2 100644 --- a/backend/pkg/kubernetesclient/client.go +++ b/backend/pkg/kubernetesclient/client.go @@ -151,6 +151,37 @@ func (c *Client) get(path string, result interface{}) error { return nil } +// getRaw performs a GET request returning raw bytes with custom Accept header +func (c *Client) getRaw(path string, accept string) ([]byte, error) { + req, err := http.NewRequest("GET", c.BaseURL+path, nil) + if err != nil { return nil, err } + req.Header.Set("Accept", accept) + resp, err := c.HTTPClient.Do(req) + if err != nil { return nil, fmt.Errorf("GET %s: %w", path, err) } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { return nil, fmt.Errorf("read body: %w", err) } + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("K8s API error %d: %s", resp.StatusCode, string(body[:min(len(body), 200)])) + } + return body, nil +} + +// putRaw performs a PUT request with raw body and custom Content-Type +func (c *Client) putRaw(path string, contentType string, data []byte) error { + req, err := http.NewRequest("PUT", c.BaseURL+path, strings.NewReader(string(data))) + if err != nil { return err } + req.Header.Set("Content-Type", contentType) + resp, err := c.HTTPClient.Do(req) + if err != nil { return fmt.Errorf("PUT %s: %w", path, err) } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("K8s API error %d: %s", resp.StatusCode, string(body[:min(len(body), 300)])) + } + return nil +} + // --- K8s API response types --- // NodeList response @@ -212,7 +243,14 @@ type PodSpec struct { } type Container struct { - Image string `json:"image"` + Name string `json:"name"` + Image string `json:"image"` + Resources ContainerRes `json:"resources"` +} + +type ContainerRes struct { + Requests map[string]string `json:"requests"` + Limits map[string]string `json:"limits"` } type PodStatus struct { @@ -945,3 +983,336 @@ func (c *Client) ListSecrets(namespace string) (*SecretList, error) { } return &SecretList{Items: items}, nil } + +// ============================================================================ +// PersistentVolumeClaim (存储) +// ============================================================================ + +type PVCList struct { + Items []PVCItem `json:"items"` +} + +type PVCItem struct { + Metadata ObjectMeta `json:"metadata"` + Spec PVCSpec `json:"spec"` + Status struct { + Phase string `json:"phase"` + Capacity map[string]string `json:"capacity"` + } `json:"status"` +} + +type PVCSpec struct { + AccessModes []string `json:"accessModes"` + Resources struct { + Requests map[string]string `json:"requests"` + } `json:"resources"` + StorageClassName string `json:"storageClassName"` +} + +// ListPVCs fetches PersistentVolumeClaims in a namespace +func (c *Client) ListPVCs(namespace string) (*PVCList, error) { + var result PVCList + path := fmt.Sprintf("/api/v1/namespaces/%s/persistentvolumeclaims", namespace) + if err := c.get(path, &result); err != nil { + return nil, err + } + return &result, nil +} + +// DeletePVC deletes a PersistentVolumeClaim +func (c *Client) DeletePVC(namespace, name string) error { + path := fmt.Sprintf("/api/v1/namespaces/%s/persistentvolumeclaims/%s", namespace, name) + return c.delete(path) +} + +// ============================================================================ +// StatefulSet +// ============================================================================ + +type StatefulSetList struct { + Items []StatefulSetItem `json:"items"` +} + +type StatefulSetItem struct { + Metadata ObjectMeta `json:"metadata"` + Spec StatefulSetSpec `json:"spec"` + Status struct { + Replicas int `json:"replicas"` + ReadyReplicas int `json:"readyReplicas"` + } `json:"status"` +} + +type StatefulSetSpec struct { + Replicas int `json:"replicas"` + Template struct { + Spec struct { + Containers []struct { + Name string `json:"name"` + Image string `json:"image"` + } `json:"containers"` + } `json:"spec"` + } `json:"template"` +} + +// ListStatefulSets fetches StatefulSets across all namespaces +func (c *Client) ListStatefulSets() (*StatefulSetList, error) { + var result StatefulSetList + if err := c.get("/apis/apps/v1/statefulsets", &result); err != nil { + return nil, err + } + return &result, nil +} + +// ============================================================================ +// DaemonSet +// ============================================================================ + +type DaemonSetList struct { + Items []DaemonSetItem `json:"items"` +} + +type DaemonSetItem struct { + Metadata ObjectMeta `json:"metadata"` + Spec DaemonSetSpec `json:"spec"` + Status struct { + DesiredNumberScheduled int `json:"desiredNumberScheduled"` + CurrentNumberScheduled int `json:"currentNumberScheduled"` + NumberReady int `json:"numberReady"` + } `json:"status"` +} + +type DaemonSetSpec struct { + Template struct { + Spec struct { + Containers []struct { + Name string `json:"name"` + Image string `json:"image"` + } `json:"containers"` + } `json:"spec"` + } `json:"template"` +} + +// ListDaemonSets fetches DaemonSets across all namespaces +func (c *Client) ListDaemonSets() (*DaemonSetList, error) { + var result DaemonSetList + if err := c.get("/apis/apps/v1/daemonsets", &result); err != nil { + return nil, err + } + return &result, nil +} + +// ============================================================================ +// NodeMetrics (metrics-server, optional) +// ============================================================================ + +type NodeMetricsList struct { + Items []NodeMetricsItem `json:"items"` +} + +type NodeMetricsItem struct { + Metadata ObjectMeta `json:"metadata"` + Usage map[string]string `json:"usage"` // cpu, memory +} + +// GetNodeMetrics fetches node CPU/memory usage from metrics-server +func (c *Client) GetNodeMetrics() (*NodeMetricsList, error) { + var result NodeMetricsList + if err := c.get("/apis/metrics.k8s.io/v1beta1/nodes", &result); err != nil { + return nil, err + } + return &result, nil +} + +// ============================================================================ +// PodMetrics (metrics-server, per-container CPU/Memory) +// ============================================================================ + +type PodMetricsList struct { + Items []PodMetricsItem `json:"items"` +} + +type PodMetricsItem struct { + Metadata ObjectMeta `json:"metadata"` + Containers []ContainerMetrics `json:"containers"` +} + +type ContainerMetrics struct { + Name string `json:"name"` + Usage map[string]string `json:"usage"` // cpu, memory +} + +// ListPodMetrics fetches pod CPU/memory usage, optionally filtered by namespace +func (c *Client) ListPodMetrics(namespace string) (*PodMetricsList, error) { + var result PodMetricsList + path := "/apis/metrics.k8s.io/v1beta1/pods" + if namespace != "" { + path = fmt.Sprintf("/apis/metrics.k8s.io/v1beta1/namespaces/%s/pods", namespace) + } + if err := c.get(path, &result); err != nil { + return nil, err + } + return &result, nil +} + +// ============================================================================ +// Events +// ============================================================================ + +type EventList struct { + Items []EventItem `json:"items"` +} + +type EventItem struct { + Metadata ObjectMeta `json:"metadata"` + InvolvedObject InvolvedObject `json:"involvedObject"` + Type string `json:"type"` + Reason string `json:"reason"` + Message string `json:"message"` + Count int32 `json:"count"` + FirstTimestamp time.Time `json:"firstTimestamp"` + LastTimestamp time.Time `json:"lastTimestamp"` + EventTime time.Time `json:"eventTime"` +} + +type InvolvedObject struct { + Kind string `json:"kind"` + Name string `json:"name"` + Namespace string `json:"namespace"` +} + +// ListEvents fetches cluster events, optionally filtered by namespace +func (c *Client) ListEvents(namespace string) (*EventList, error) { + var result EventList + path := "/api/v1/events" + if namespace != "" { + path = fmt.Sprintf("/api/v1/namespaces/%s/events", namespace) + } + if err := c.get(path, &result); err != nil { + return nil, err + } + return &result, nil +} + +// ============================================================================ +// YAML Apply (Dynamic resource apply) +// ============================================================================ + +// ApplyYAML applies a raw YAML manifest using the K8s discovery API +func (c *Client) ApplyYAML(yamlContent string) (map[string]interface{}, error) { + return c.applyRaw(yamlContent) +} + +// GetResourceYAML fetches raw YAML for any K8s resource by API path +func (c *Client) GetResourceYAML(apiPath string) ([]byte, error) { + return c.getRaw(apiPath, "application/yaml") +} + +// UpdateResourceYAML applies raw YAML to update a K8s resource +func (c *Client) UpdateResourceYAML(apiPath string, yamlData []byte) error { + return c.putRaw(apiPath, "application/yaml", yamlData) +} + +// applyRaw parses YAML and applies it via create-or-update pattern +func (c *Client) applyRaw(yamlContent string) (map[string]interface{}, error) { + // Parse YAML to extract resource metadata + var obj struct { + APIVersion string `yaml:"apiVersion"` + Kind string `yaml:"kind"` + Metadata struct { + Name string `yaml:"name"` + Namespace string `yaml:"namespace"` + } `yaml:"metadata"` + } + if err := yaml.Unmarshal([]byte(yamlContent), &obj); err != nil { + return nil, fmt.Errorf("YAML 解析失败: %w", err) + } + if obj.Kind == "" || obj.Metadata.Name == "" { + return nil, fmt.Errorf("YAML 中缺少 kind 或 name") + } + + // Build API path from kind + apiPath, isNamespaced := c.resourceAPIPath(obj.APIVersion, obj.Kind, obj.Metadata.Namespace, obj.Metadata.Name) + if apiPath == "" { + return nil, fmt.Errorf("不支持的资源类型: %s/%s", obj.APIVersion, obj.Kind) + } + + // Try GET first — if 200, it exists, do PUT. If 404, do POST. + parentPath := apiPath[:strings.LastIndex(apiPath, "/")] // without /name suffix + _, getErr := c.getRaw(apiPath, "application/yaml") + if getErr == nil { + // Resource exists → PUT update + if err := c.putRaw(apiPath, "application/yaml", []byte(yamlContent)); err != nil { + return nil, fmt.Errorf("更新资源失败: %w", err) + } + return map[string]interface{}{"action": "updated", "path": apiPath}, nil + } + + // Resource doesn't exist → POST create (for namespaced) or POST to parent + var result map[string]interface{} + if isNamespaced && obj.Metadata.Namespace == "" { + obj.Metadata.Namespace = "default" + parentPath = strings.Replace(parentPath, "/namespaces//", "/namespaces/default/", 1) + } + if err := c.post(parentPath, []byte(yamlContent), &result); err != nil { + return nil, fmt.Errorf("创建资源失败: %w", err) + } + return map[string]interface{}{"action": "created", "path": parentPath, "result": result}, nil +} + +// resourceAPIPath returns the K8s REST API path for a given resource kind +func (c *Client) resourceAPIPath(apiVersion, kind, namespace, name string) (string, bool) { + resource := strings.ToLower(kind) + "s" + switch kind { + case "Endpoints": resource = "endpoints" + case "PodMetrics": resource = "pods" + } + + var groupPrefix string + if apiVersion != "" && apiVersion != "v1" { + parts := strings.SplitN(apiVersion, "/", 2) + if len(parts) == 2 { + groupPrefix = "/apis/" + parts[0] + "/" + parts[1] + } else { + groupPrefix = "/apis/" + parts[0] + } + } + + isNamespaced := true + switch kind { + case "Namespace", "Node", "ClusterRole", "ClusterRoleBinding", "PersistentVolume": + isNamespaced = false + } + + if !isNamespaced { + if groupPrefix != "" { + return groupPrefix + "/" + resource + "/" + name, false + } + return "/api/v1/" + resource + "/" + name, false + } + + ns := namespace + if ns == "" { ns = "default" } + + if groupPrefix != "" { + switch kind { + case "Deployment", "StatefulSet", "DaemonSet", "ReplicaSet": + return groupPrefix + "/namespaces/" + ns + "/" + resource + "/" + name, true + case "HorizontalPodAutoscaler": + return groupPrefix + "/namespaces/" + ns + "/" + strings.ToLower(kind) + "s/" + name, true + case "Ingress": + return groupPrefix + "/namespaces/" + ns + "/" + resource + "/" + name, true + default: + return groupPrefix + "/namespaces/" + ns + "/" + resource + "/" + name, true + } + } + + // Core v1 API + coreResources := map[string]string{ + "Pod": "pods", "Service": "services", "ConfigMap": "configmaps", + "Secret": "secrets", "PersistentVolumeClaim": "persistentvolumeclaims", + } + if r, ok := coreResources[kind]; ok { + return "/api/v1/namespaces/" + ns + "/" + r + "/" + name, true + } + return "/api/v1/namespaces/" + ns + "/" + resource + "/" + name, true +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cfa4b61..c13ae15 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,9 +13,16 @@ import { RegionManager } from '@/components/cloud/RegionManager'; import { PlatformManager } from '@/components/cloud/PlatformManager'; import { CloudBilling } from '@/components/cloud/CloudBilling'; import { KubernetesClusters } from '@/components/kubernetes/KubernetesClusters'; +import ClusterOverviewPage from '@/components/kubernetes/ClusterOverviewPage'; +import NamespaceOverviewPage from '@/components/kubernetes/NamespaceOverviewPage'; +import NamespaceResourcePage from '@/components/kubernetes/NamespaceResourcePage'; +import DeploymentDetailPage from '@/components/kubernetes/DeploymentDetailPage'; +import { TopologyView } from '@/components/kubernetes/TopologyView'; import { KubernetesNodes } from '@/components/kubernetes/KubernetesNodes'; import { KubernetesWorkloads } from '@/components/kubernetes/KubernetesWorkloads'; -import { TopologyView } from '@/components/kubernetes/TopologyView'; +import PodMetricsPage from '@/components/kubernetes/PodMetricsPage'; +import EventsPage from '@/components/kubernetes/EventsPage'; +import CapacityPage from '@/components/kubernetes/CapacityPage'; import { AlertManager } from '@/components/monitor/AlertManager'; import { AppReleaseManager } from '@/components/deploy/AppRelease'; import { ApprovalFlowManager } from '@/components/approval/ApprovalFlow'; @@ -63,11 +70,68 @@ function App() { } /> } /> } /> + + {/* ================================================================ */} + {/* Kubernetes — Kuboard-style routes */} + {/* ================================================================ */} + {/* 集群列表 */} + + } /> + {/* 集群概览 (节点 + 命名空间列表) */} + + } /> + {/* 🆕 资源用量 / 事件 / 容量分析 */} + } /> + } /> + } /> + {/* 架构拓扑 */} + } /> + {/* 命名空间作用域 */} + + {/* 命名空间概览 */} + } /> + {/* 各类资源列表 */} + + } /> + } /> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + {/* Kubernetes 资源快捷入口 (自动跳转至首个集群) */} + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + {/* 资源监控(Agent 指标 + 中间件 + SNMP + 拨测) */} } /> - } /> + {/* 旧路由兼容:节点管理、拓扑 */} } /> - } /> } /> {/* 应用发布 */} } /> diff --git a/frontend/src/components/auth/Login.tsx b/frontend/src/components/auth/Login.tsx index 9ed7a47..49f77d2 100644 --- a/frontend/src/components/auth/Login.tsx +++ b/frontend/src/components/auth/Login.tsx @@ -98,6 +98,7 @@ export function Login() { try { const result = await userApi.login(username, password); useUserStore.getState().login(result.user, result.token); + localStorage.setItem('token', result.token); // backward compat navigate('/', { replace: true }); } catch (err: unknown) { const message = err instanceof Error ? err.message : '登录失败,请检查用户名和密码'; diff --git a/frontend/src/components/kubernetes/CapacityPage.tsx b/frontend/src/components/kubernetes/CapacityPage.tsx new file mode 100644 index 0000000..15c9a25 --- /dev/null +++ b/frontend/src/components/kubernetes/CapacityPage.tsx @@ -0,0 +1,139 @@ +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useParams, useNavigate, Link } from 'react-router-dom'; +import { Card, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from '@/components/ui/select'; +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from '@/components/ui/table'; +import { RefreshCw, ChevronRight, BarChart3, Download, Cpu, HardDrive } from 'lucide-react'; +import { kubernetesApi } from '@/services/api'; + +export default function CapacityPage() { + const { clusterCode } = useParams<{ clusterCode: string }>(); + const navigate = useNavigate(); + const [loading, setLoading] = useState(false); + const [capacity, setCapacity] = useState([]); + const [clusters, setClusters] = useState([]); + const [namespaces, setNamespaces] = useState([]); + const [selectedNs, setSelectedNs] = useState('all'); + const [activeCluster, setActiveCluster] = useState(clusterCode || ''); + + useEffect(() => { + kubernetesApi.getClusters().then((res: any) => { + const list = res?.list || []; + setClusters(list); + if (!activeCluster && list.length > 0) { + const code = list[0].code || String(list[0].id); + setActiveCluster(code); + navigate(`/kubernetes/${code}/capacity`, { replace: true }); + } + }).catch(() => {}); + }, []); + + useEffect(() => { + if (!activeCluster) return; + kubernetesApi.getNamespaces(activeCluster).then((res: any) => { + setNamespaces(Array.isArray(res) ? res : (res?.list || res?.data || [])); + }).catch(() => {}); + }, [activeCluster]); + + const loadCapacity = useCallback(async () => { + if (!activeCluster) return; + setLoading(true); + try { + const ns = selectedNs === 'all' ? undefined : selectedNs; + const res: any = await kubernetesApi.getCapacity(activeCluster, ns); + setCapacity(Array.isArray(res) ? res : (res?.list || [])); + } catch { setCapacity([]); } finally { setLoading(false); } + }, [activeCluster, selectedNs]); + + useEffect(() => { loadCapacity(); }, [loadCapacity]); + + const rows = useMemo(() => + capacity.flatMap((d: any) => + (d.containers || []).map((c: any) => ({ + namespace: d.namespace, + deployment: d.name, + replicas: d.replicas, + container: c.name, + cpu_req: c.cpu_request || '-', + cpu_lim: c.cpu_limit || '-', + mem_req: c.mem_request || '-', + mem_lim: c.mem_limit || '-', + })) + ), [capacity]); + + const exportCSV = () => kubernetesApi.exportAsCSV(`capacity-${activeCluster}.csv`, rows); + + return ( +
+
+ 集群 + {activeCluster && <>{activeCluster}} + 容量分析 +
+ +
+

资源容量分析

+
+ + + + +
+
+ + + + + + Deployment + 命名空间 + 副本 + 容器 + CPU Req + CPU Lim + Mem Req + Mem Lim + + + {rows.length === 0 ? ( + 暂无数据{loading ? ⏳ : ''} + ) : rows.map((r, i) => ( + + {r.deployment} + {r.namespace} + {r.replicas} + {r.container} + {r.cpu_req} + {r.cpu_lim} + {r.mem_req} + {r.mem_lim} + + ))} + +
+
+
+
+ ); +} diff --git a/frontend/src/components/kubernetes/ClusterOverviewPage.tsx b/frontend/src/components/kubernetes/ClusterOverviewPage.tsx new file mode 100644 index 0000000..a49c77b --- /dev/null +++ b/frontend/src/components/kubernetes/ClusterOverviewPage.tsx @@ -0,0 +1,229 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useParams, useNavigate, Link } from 'react-router-dom'; +import { Card, CardContent } 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 { + RefreshCw, Loader2, Cpu, Server, Container, + ChevronRight, Cloud, Boxes, Network, FileText, Shield, Activity, Clock, +} from 'lucide-react'; +import { kubernetesApi } from '@/services/api'; +import { useToast } from '@/hooks/use-toast'; + +const NAMESPACE_ICON: Record> = { + 'kube-system': Server, + 'kube-public': Shield, + 'kube-node-lease': Activity, + 'default': Cloud, +}; + +export default function ClusterOverviewPage() { + const { clusterCode } = useParams<{ clusterCode: string }>(); + const navigate = useNavigate(); + const { toast } = useToast(); + + const [clusters, setClusters] = useState([]); + const [nodes, setNodes] = useState([]); + const [namespaces, setNamespaces] = useState([]); + const [clusterInfo, setClusterInfo] = useState(null); + const [loading, setLoading] = useState(false); + + // Load cluster list + current cluster info + const loadClusterInfo = useCallback(async () => { + try { + const res: any = await kubernetesApi.getClusters(); + const list = res?.list || []; + setClusters(list); + const current = list.find((c: any) => (c.code || String(c.id)) === clusterCode); + setClusterInfo(current || null); + if (!current && list.length > 0 && clusterCode) { + toast({ title: '集群未找到', description: `找不到集群 ${clusterCode}`, variant: 'destructive' }); + } + } catch { setClusters([]); } + }, [clusterCode]); + + const loadData = useCallback(async () => { + if (!clusterCode) return; + setLoading(true); + try { + const [nodesRes, nsRes] = await Promise.all([ + kubernetesApi.getNodes(clusterCode).catch(() => []), + kubernetesApi.getNamespaces(clusterCode).catch(() => []), + ]); + setNodes(Array.isArray(nodesRes) ? nodesRes : (nodesRes?.list || nodesRes?.data || [])); + const nsList: string[] = Array.isArray(nsRes) ? nsRes : (nsRes?.list || nsRes?.data || []); + setNamespaces(nsList); + } catch { setNodes([]); setNamespaces([]); } finally { setLoading(false); } + }, [clusterCode]); + + useEffect(() => { loadClusterInfo(); }, [loadClusterInfo]); + useEffect(() => { loadData(); }, [loadData]); + + const getNodeRole = (node: any) => { + if (node?.role === 'master' || node?.role === 'control-plane') return 'bg-blue-500/20 text-blue-400 border-blue-500/30'; + return 'bg-slate-500/20 text-slate-400 border-slate-500/30'; + }; + + return ( +
+ {/* Breadcrumb */} +
+ 集群 + + {clusterCode} +
+ + {/* Header */} +
+
+

{clusterCode}

+

+ 集群概览 + {clusterInfo?.version ? ` — Kubernetes ${clusterInfo.version}` : ''} +

+
+
+ +
+
+ + {/* Stats cards */} +
+ + +
+

节点

{nodes.length}

+ +
+
+
+ + +
+

命名空间

{namespaces.length}

+ +
+
+
+ + +
+

版本

{clusterInfo?.version || '-'}

+ +
+
+
+
+ + {/* Nodes table */} +
+

+ 节点列表 +

+ + + + + + 名称 + 角色 + 状态 + IP + OS + Kubelet + CPU + 内存 + + + + {loading ? ( + 加载中... + ) : nodes.length === 0 ? ( + 暂无节点 + ) : ( + nodes.map((n: any) => { + const cpuCores = n.cpu || 0; + const cpuUsageM = n.cpu_usage_m ? parseFloat(n.cpu_usage_m.replace('m','')) : 0; + const cpuPct = cpuCores > 0 ? (cpuUsageM / 1000 / cpuCores) * 100 : 0; + const memMb = n.memory_mb || 0; + const memUsageMi = n.mem_usage_mi ? parseFloat(n.mem_usage_mi.replace('Mi','').replace('Ki','').replace('Gi','')) : 0; + // Normalize to MiB + const memUsageNorm = n.mem_usage_mi?.includes('Ki') ? memUsageMi / 1024 : n.mem_usage_mi?.includes('Gi') ? memUsageMi * 1024 : memUsageMi; + const memPct = memMb > 0 ? (memUsageNorm / memMb) * 100 : 0; + const color = (pct: number) => pct > 80 ? 'bg-red-500' : pct > 60 ? 'bg-yellow-500' : 'bg-emerald-500'; + return ( + + {n.name} + {n.role || 'worker'} + {n.status || 'Unknown'} + {n.ip || '-'} + {n.os || '-'} + {n.kubelet_version || '-'} + + {n.cpu_usage_m ? ( +
+
+
+
+ {cpuPct.toFixed(0)}% +
+ ) : ( + {cpuCores} 核 + )} + + + {n.mem_usage_mi ? ( +
+
+
+
+ {memPct.toFixed(0)}% +
+ ) : ( + {memMb}Mi + )} + + + ); + }) + )} + +
+
+
+
+ + {/* Namespace list */} +
+

+ 命名空间 +

+
+ {namespaces.map((ns) => { + const Icon = NAMESPACE_ICON[ns] || Container; + return ( + +
+
+ + {ns} +
+ +
+ + ); + })} +
+
+
+ ); +} diff --git a/frontend/src/components/kubernetes/DeploymentDetail.tsx b/frontend/src/components/kubernetes/DeploymentDetail.tsx index d6400e1..f120ac7 100644 --- a/frontend/src/components/kubernetes/DeploymentDetail.tsx +++ b/frontend/src/components/kubernetes/DeploymentDetail.tsx @@ -16,6 +16,7 @@ import { } from 'lucide-react'; import { kubernetesApi } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; +import { useUserStore } from '@/store'; interface DeploymentDetailProps { clusterCode: string; @@ -69,10 +70,9 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac ); setPods(depPods); - // Filter HPAs targeting this deployment + // Filter HPAs targeting this deployment (flattened data) const depHpas = allHpas.filter((h: any) => { - const ref = h.spec?.scaleTargetRef; - return ref?.name === deploymentName && h.metadata?.namespace === namespace; + return h.target_name === deploymentName && h.namespace === namespace; }); setHpas(depHpas); } catch (e) { @@ -134,9 +134,9 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac const handleHpaToggle = async (hpa: any) => { // Not direct toggle — delete HPA - if (!confirm(`确定删除 HPA "${hpa.metadata?.name}"?`)) return; + if (!confirm(`确定删除 HPA "${hpa.name}"?`)) return; try { - await kubernetesApi.deleteHPA(clusterCode, namespace, hpa.metadata?.name); + await kubernetesApi.deleteHPA(clusterCode, namespace, hpa.name); toast({ title: 'HPA 已删除' }); loadData(); } catch (e: any) { @@ -406,7 +406,7 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac onClick={async () => { setLogs(''); // Stream mode - const token = localStorage.getItem('token') || ''; + 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}` } }); @@ -471,18 +471,18 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac {hpas.map((hpa: any) => { - const spec = hpa.spec || {}; - const status = hpa.status || {}; - const metrics = (spec.metrics || []).map((m: any) => { - if (m.resource) return `${m.resource.name}: ${m.resource.target.averageUtilization}%`; - return m.type; - }).join(', '); + const cpu = hpa.cpu_percent || 0; + const mem = hpa.mem_percent || 0; + const metricsParts = []; + if (cpu) metricsParts.push(`CPU: ${cpu}%`); + if (mem) metricsParts.push(`MEM: ${mem}%`); + const metrics = metricsParts.join(', ') || '-'; return ( - - {hpa.metadata?.name} - {spec.minReplicas} / {spec.maxReplicas} - {metrics || '-'} - {status.currentReplicas || 0} / {status.desiredReplicas || 0} + + {hpa.name} + {hpa.min_replicas} / {hpa.max_replicas} + {metrics} + {hpa.current_replicas || 0} + + + +
+ +

总数

{events.length}

+
+ +

Warning

{warningCount}

+
+ +

Normal

{normalCount}

+
+
+ + + + + + 类型 + 原因 + 对象 + 消息 + 次数 + 最后时间 + + + {events.length === 0 ? ( + 暂无事件{loading ? ⏳ : ''} + ) : events.map((e, i) => ( + + + {e.type === 'Warning' ? ( + Warning + ) : ( + Normal + )} + + {e.reason || '-'} + {e.object || '-'} + {e.message || '-'} + {e.count || 1} + {e.last_time?.replace('T', ' ').substring(0, 19) || '-'} + + ))} + +
+
+
+ + ); +} diff --git a/frontend/src/components/kubernetes/KubectlTerminal.tsx b/frontend/src/components/kubernetes/KubectlTerminal.tsx new file mode 100644 index 0000000..41a6f63 --- /dev/null +++ b/frontend/src/components/kubernetes/KubectlTerminal.tsx @@ -0,0 +1,211 @@ +import { useState, useRef, useEffect } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Terminal as XTerm } from 'xterm'; +import { FitAddon } from 'xterm-addon-fit'; +import 'xterm/css/xterm.css'; +import { Terminal, Maximize2, Minimize2, X, Loader2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useUserStore } from '@/store'; + +interface KubectlTerminalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + clusterId: number | string; + clusterName: string; +} + +export function KubectlTerminal({ open, onOpenChange, clusterId, clusterName }: KubectlTerminalProps) { + const containerRef = useRef(null); + const termRef = useRef(null); + const wsRef = useRef(null); + const fitRef = useRef(null); + const [connected, setConnected] = useState(false); + const [fullscreen, setFullscreen] = useState(false); + const [error, setError] = useState(''); + + // 重置全屏状态 + useEffect(() => { + if (!open) setFullscreen(false); + }, [open]); + + // 连接终端 + useEffect(() => { + if (!open || !clusterId) return; + + const term = new XTerm({ + cursorBlink: true, + fontSize: 14, + fontFamily: 'Menlo, Monaco, "Courier New", monospace', + allowProposedApi: true, + 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; + + // WebSocket + const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const baseUrl = import.meta.env.VITE_API_BASE_URL || `${proto}//${window.location.host}`; + const token = useUserStore.getState().token || ''; + const wsUrl = `${baseUrl.replace(/^http/, 'ws').replace('/api/v1', '')}/ws/kubectl?token=${encodeURIComponent(token)}&id=${clusterId}`; + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + term.onData((data) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ status: 0, data })); + } + }); + + ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); + if (msg.status === 0 && typeof msg.data === 'string') { + term.write(msg.data); + } + } catch { + if (typeof event.data === 'string') term.write(event.data); + } + }; + + ws.onopen = () => { + setConnected(true); + setError(''); + if (containerRef.current) { + term.open(containerRef.current); + requestAnimationFrame(() => { + try { + fit.fit(); + } catch { /* noop */ } + }); + // 延迟 focus:确保 Dialog 渲染完成后再聚焦终端 + setTimeout(() => { + try { + term.focus(); + } catch { /* noop */ } + }, 150); + } + }; + + ws.onclose = () => { + setConnected(false); + term.write('\r\n\x1b[31m[连接已断开]\x1b[0m\r\n'); + }; + + ws.onerror = () => { + setError('WebSocket 连接失败'); + term.write('\r\n\x1b[31m[连接发生错误]\x1b[0m\r\n'); + }; + + return () => { + try { + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + ws.close(); + } + term.dispose(); + } catch { /* noop */ } + termRef.current = null; + wsRef.current = null; + setConnected(false); + }; + }, [open, clusterId]); + + // ResizeObserver 监听容器变化自动 fit + useEffect(() => { + if (!open) return; + const container = containerRef.current; + if (!container) return; + + let resizeTimer: ReturnType; + 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(container); + return () => { + clearTimeout(resizeTimer); + ro.disconnect(); + }; + }, [open]); + + return ( + + e.preventDefault()} + className={cn( + 'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0', + '!duration-0 !transition-none', + fullscreen + ? '!max-w-none !w-screen !h-screen !rounded-none !translate-x-0 !translate-y-0 !left-0 !top-0' + : '' + )} + style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '900px', width: '90vw' }} + > + kubectl — {clusterName} + Kubernetes 集群命令行终端 + + {/* 头部 */} +
+
+
+ + kubectl — {clusterName} + {!connected && !error && } +
+
+ + +
+
+ + {/* 终端容器 */} +
+ +
+ ); +} diff --git a/frontend/src/components/kubernetes/KubernetesClusters.tsx b/frontend/src/components/kubernetes/KubernetesClusters.tsx index 233b88c..284f47a 100644 --- a/frontend/src/components/kubernetes/KubernetesClusters.tsx +++ b/frontend/src/components/kubernetes/KubernetesClusters.tsx @@ -26,11 +26,13 @@ import { Eye, Trash2, Search, + Container, } from 'lucide-react'; import { kubernetesApi } from '@/services/api'; import type { KubernetesCluster } from '@/types'; import { KubernetesClusterDetail } from './KubernetesClusterDetail'; import { KubernetesClusterDialog } from './KubernetesClusterDialog'; +import { KubectlTerminal } from './KubectlTerminal'; export function KubernetesClusters() { const [clusters, setClusters] = useState([]); @@ -42,6 +44,10 @@ export function KubernetesClusters() { const [dialogOpen, setDialogOpen] = useState(false); const [editingCluster, setEditingCluster] = useState(null); + // kubectl 终端 + const [kubectlCluster, setKubectlCluster] = useState<{id: number; name: string} | null>(null); + const [kubectlTerminalOpen, setKubectlTerminalOpen] = useState(false); + const loadData = async () => { try { setLoading(true); @@ -273,6 +279,17 @@ export function KubernetesClusters() { 配置 + { + e.stopPropagation(); + setKubectlCluster({ id: cluster.id, name: cluster.name }); + setKubectlTerminalOpen(true); + }} + > + + kubectl + + + {/* kubectl 终端 */} + {kubectlCluster && ( + { + setKubectlTerminalOpen(open); + if (!open) setKubectlCluster(null); + }} + clusterId={kubectlCluster.id} + clusterName={kubectlCluster.name} + /> + )} ); } diff --git a/frontend/src/components/kubernetes/KubernetesWorkloads.tsx b/frontend/src/components/kubernetes/KubernetesWorkloads.tsx index 68876b0..737589d0 100644 --- a/frontend/src/components/kubernetes/KubernetesWorkloads.tsx +++ b/frontend/src/components/kubernetes/KubernetesWorkloads.tsx @@ -72,8 +72,8 @@ export function KubernetesWorkloads() { setPods(toArray(podRes)); } else { const [depRes, podRes] = await Promise.all([ - kubernetesApi.getDeployments(clusterCode).catch(() => []), - kubernetesApi.getPods(clusterCode).catch(() => []), + kubernetesApi.getAllDeployments(clusterCode).catch(() => []), + kubernetesApi.getAllPods(clusterCode).catch(() => []), ]); const toArray = (v: any) => (Array.isArray(v) ? v : (v?.list || [])); setDeployments(toArray(depRes)); diff --git a/frontend/src/components/kubernetes/NamespaceOverviewPage.tsx b/frontend/src/components/kubernetes/NamespaceOverviewPage.tsx new file mode 100644 index 0000000..974cada --- /dev/null +++ b/frontend/src/components/kubernetes/NamespaceOverviewPage.tsx @@ -0,0 +1,97 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useParams, Link } from 'react-router-dom'; +import { Card, CardContent } from '@/components/ui/card'; +import { + RefreshCw, Loader2, ChevronRight, Cloud, + Boxes, Container, Network, FileText, Shield, Activity, Clock, +} from 'lucide-react'; +import { kubernetesApi } from '@/services/api'; + +const RESOURCE_LINKS = [ + { type: 'deployments', label: 'Deployments', icon: Boxes, desc: '工作负载副本管理' }, + { type: 'pods', label: 'Pods', icon: Container, desc: '容器实例运行状态' }, + { type: 'cronjobs', label: 'CronJobs', icon: Clock, desc: '定时任务管理' }, + { type: 'services', label: 'Services', icon: Network, desc: '服务发现与负载均衡' }, + { type: 'ingresses', label: 'Ingresses', icon: Network, desc: '外部流量路由规则' }, + { type: 'configmaps', label: 'ConfigMaps', icon: FileText, desc: '配置字典' }, + { type: 'secrets', label: 'Secrets', icon: Shield, desc: '密钥与凭证' }, + { type: 'hpas', label: 'HPAs', icon: Activity, desc: '水平自动伸缩策略' }, +]; + +export default function NamespaceOverviewPage() { + const { clusterCode, namespace } = useParams<{ clusterCode: string; namespace: string }>(); + const [counts, setCounts] = useState>({}); + const [loading, setLoading] = useState(false); + + const loadCounts = useCallback(async () => { + if (!clusterCode || !namespace) return; + setLoading(true); + try { + const results = await Promise.allSettled([ + kubernetesApi.getNamespaceDeployments(clusterCode, namespace!), + kubernetesApi.getNamespacePods(clusterCode, namespace!), + kubernetesApi.getNamespaceServices(clusterCode, namespace!), + kubernetesApi.getNamespaceIngresses(clusterCode, namespace!), + kubernetesApi.getNamespaceConfigMaps(clusterCode, namespace!), + kubernetesApi.getNamespaceSecrets(clusterCode, namespace!), + kubernetesApi.getHPAs(clusterCode, namespace!), + kubernetesApi.getCronJobs(clusterCode, namespace!), + ]); + const toArr = (v: any) => Array.isArray(v) ? v : (v?.list || v?.data || []); + setCounts({ + deployments: toArr(results[0].status === 'fulfilled' ? results[0].value : []).length, + pods: toArr(results[1].status === 'fulfilled' ? results[1].value : []).length, + services: toArr(results[2].status === 'fulfilled' ? results[2].value : []).length, + ingresses: toArr(results[3].status === 'fulfilled' ? results[3].value : []).length, + configmaps: toArr(results[4].status === 'fulfilled' ? results[4].value : []).length, + secrets: toArr(results[5].status === 'fulfilled' ? results[5].value : []).length, + hpas: toArr(results[6].status === 'fulfilled' ? results[6].value : []).length, + cronjobs: toArr(results[7].status === 'fulfilled' ? results[7].value : []).length, + }); + } catch { setCounts({}); } finally { setLoading(false); } + }, [clusterCode, namespace]); + + useEffect(() => { loadCounts(); }, [loadCounts]); + + return ( +
+ {/* Breadcrumb */} +
+ 集群 + + {clusterCode} + + {namespace} +
+ + {/* Header */} +
+
+

+ + {namespace} +

+

命名空间概览

+
+
+ + {/* Resource grid */} +
+ {RESOURCE_LINKS.map((r) => ( + +
+ + +
+
{loading ? '-' : (counts[r.type] ?? 0)}
+
{r.label}
+
{r.desc}
+ + ))} +
+
+ ); +} diff --git a/frontend/src/components/kubernetes/NamespaceResourcePage.tsx b/frontend/src/components/kubernetes/NamespaceResourcePage.tsx new file mode 100644 index 0000000..8a1c008 --- /dev/null +++ b/frontend/src/components/kubernetes/NamespaceResourcePage.tsx @@ -0,0 +1,377 @@ +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useParams, useNavigate, useLocation, Link } from 'react-router-dom'; +import { Card, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from '@/components/ui/select'; +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from '@/components/ui/table'; +import { + RefreshCw, Search, Loader2, Boxes, Container, + Network, FileText, Shield, Activity, Clock, ChevronRight, + Download, FileCode, +} from 'lucide-react'; +import { YamlViewer } from './YamlViewer'; +import { kubernetesApi } from '@/services/api'; +import { useToast } from '@/hooks/use-toast'; + +type ResourceType = + | 'deployments' | 'pods' | 'services' | 'ingresses' + | 'configmaps' | 'secrets' | 'hpas' | 'cronjobs' + | 'pvcs' | 'statefulsets' | 'daemonsets'; + +const RESOURCE_CONFIG: Record; + columns: string[]; + renderRow: (item: any, opts: { clusterCode: string; namespace: string; onDetail?: (item: any) => void; onYaml?: (name: string) => void }) => React.ReactNode; +}> = { + deployments: { + title: 'Deployments', subtitle: 'Deployment 工作负载运行状态', icon: Boxes, + columns: ['名称', '命名空间', '副本', '镜像', '操作'], + renderRow: (d, opts) => ( + opts.onDetail?.(d)}> + {d.name} + {d.namespace} + + {(() => { + const ready = d.ready_replicas || 0; const total = d.replicas || 0; + const pct = total > 0 ? (ready / total) * 100 : 0; const done = ready === total; + return ( +
+
+
+
+ {ready}/{total} +
+ ); + })()} + + {d.image || '-'} + e.stopPropagation()}> +
+ +
+
+ + ), + }, + pods: { + title: 'Pods', subtitle: 'Pod 实例运行状态', icon: Container, + columns: ['名称', '命名空间', '状态', '节点', '重启', '镜像', '操作'], + renderRow: (p, opts) => { + const statusMap: Record = { Running: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30', Pending: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30', Failed: 'bg-red-500/20 text-red-400 border-red-500/30' }; + return ( + + {p.name} + {p.namespace} + {p.status || 'Unknown'} + {p.node_name || '-'} + 0 ? 'text-yellow-400' : 'text-slate-500')}>{p.restart_count || 0} + {p.image || '-'} + + + ); + }, + }, + services: { + title: 'Services', subtitle: 'Service 网络服务', icon: Network, + columns: ['名称', '命名空间', '类型', 'Cluster IP', '端口', '操作'], + renderRow: (s, opts) => ( + + {s.name} + {s.namespace} + {s.type || '-'} + {s.cluster_ip || '-'} + {s.ports || '-'} + + + ), + }, + ingresses: { + title: 'Ingresses', subtitle: 'Ingress 路由规则', icon: Network, + columns: ['名称', '命名空间', 'Hosts', 'TLS', '操作'], + renderRow: (i, opts) => { + const hosts = i.hosts || []; + return ( + + {i.name} + {i.namespace} + {hosts.length > 0 ? hosts.join(', ') : '-'} + {i.tls ? 'HTTPS' : 'HTTP'} + + + ); + }, + }, + configmaps: { + title: 'ConfigMaps', subtitle: '配置字典', icon: FileText, + columns: ['名称', '命名空间', '键数量', '操作'], + renderRow: (c, opts) => ( + + {c.name} + {c.namespace} + {c.key_count || '-'} + + + ), + }, + secrets: { + title: 'Secrets', subtitle: '密钥管理(仅名称与类型)', icon: Shield, + columns: ['名称', '命名空间', '类型', '操作'], + renderRow: (s, opts) => ( + + {s.name} + {s.namespace} + {s.type || '-'} + + + ), + }, + hpas: { + title: 'HPAs', subtitle: '水平自动伸缩', icon: Activity, + columns: ['名称', '命名空间', '目标', '最小/最大', 'CPU%', 'Mem%'], + renderRow: (h, opts) => ( + + {h.name} + {h.namespace} + {h.target_kind}/{h.target_name} + {h.min_replicas}/{h.max_replicas} + {h.cpu_percent ?? '-'}% + {h.mem_percent ?? '-'}% + + ), + }, + cronjobs: { + title: 'CronJobs', subtitle: '定时任务', icon: Clock, + columns: ['名称', '命名空间', '调度', '状态', '上次执行'], + renderRow: (cj) => ( + + {cj.name} + {cj.namespace} + {cj.schedule || '-'} + {cj.suspend ? '已暂停' : '运行中'} + {cj.last_schedule || '-'} + + ), + }, + pvcs: { + title: '存储卷声明', subtitle: 'PersistentVolumeClaim', icon: Boxes, + columns: ['名称', '命名空间', '状态', '大小', 'StorageClass'], + renderRow: (p, opts) => ( + + {p.name} + {p.namespace} + {p.phase || '-'} + {p.size || '-'} + {p.storage_class || '-'} + + ), + }, + statefulsets: { + title: 'StatefulSets', subtitle: '有状态应用', icon: Boxes, + columns: ['名称', '命名空间', '副本', '就绪', '镜像'], + renderRow: (s) => ( + + {s.name} + {s.namespace} + {s.replicas || 0} + {s.ready || 0} + {s.image || '-'} + + ), + }, + daemonsets: { + title: 'DaemonSets', subtitle: '守护进程集', icon: Boxes, + columns: ['名称', '命名空间', '期望', '当前', '就绪', '镜像'], + renderRow: (d) => ( + + {d.name} + {d.namespace} + {d.desired || 0} + {d.current || 0} + {d.ready || 0} + {d.image || '-'} + + ), + }, +}; + +export default function NamespaceResourcePage() { + const { clusterCode, namespace } = useParams<{ clusterCode: string; namespace: string }>(); + const location = useLocation(); + const navigate = useNavigate(); + const { toast } = useToast(); + const type = useMemo(() => { + const segments = location.pathname.split('/'); + const last = segments[segments.length - 1]; + if (last && RESOURCE_CONFIG[last as ResourceType]) return last as ResourceType; + if (segments.length >= 2) { + const secondLast = segments[segments.length - 2]; + if (secondLast && RESOURCE_CONFIG[secondLast as ResourceType]) return secondLast as ResourceType; + } + return 'deployments'; + }, [location.pathname]); + const config = RESOURCE_CONFIG[type]; + + const [clusters, setClusters] = useState([]); + const [namespaces, setNamespaces] = useState([]); + const [data, setData] = useState([]); + const [loading, setLoading] = useState(false); + const [search, setSearch] = useState(''); + const [yamlItem, setYamlItem] = useState<{resource: string; name: string} | null>(null); + + useEffect(() => { + kubernetesApi.getClusters().then((res: any) => { + const list = res?.list || []; + setClusters(list); + if (!clusterCode && list.length > 0) { + const firstCode = list[0].code || String(list[0].id); + navigate(`/kubernetes/${firstCode}/namespace/${namespace || 'default'}/${type}`, { replace: true }); + } + }).catch(() => setClusters([])); + }, []); + + useEffect(() => { + if (!clusterCode) return; + kubernetesApi.getNamespaces(clusterCode).then((res: any) => { + const nsList: string[] = Array.isArray(res) ? res : (res?.list || res?.data || []); + setNamespaces(nsList); + if (!namespace && nsList.length > 0) { + navigate(`/kubernetes/${clusterCode}/namespace/${nsList[0]}/${type}`, { replace: true }); + } + }).catch(() => setNamespaces([])); + }, [clusterCode]); + + const loadData = useCallback(async () => { + if (!clusterCode || !namespace) return; + setLoading(true); + try { + let res: any = []; + switch (type) { + case 'deployments': res = await kubernetesApi.getNamespaceDeployments(clusterCode, namespace).catch(() => []); break; + case 'pods': res = await kubernetesApi.getNamespacePods(clusterCode, namespace).catch(() => []); break; + case 'services': res = await kubernetesApi.getNamespaceServices(clusterCode, namespace).catch(() => []); break; + case 'ingresses': res = await kubernetesApi.getNamespaceIngresses(clusterCode, namespace).catch(() => []); break; + case 'configmaps': res = await kubernetesApi.getNamespaceConfigMaps(clusterCode, namespace).catch(() => []); break; + case 'secrets': res = await kubernetesApi.getNamespaceSecrets(clusterCode, namespace).catch(() => []); break; + case 'hpas': res = await kubernetesApi.getHPAs(clusterCode, namespace).catch(() => []); break; + case 'cronjobs': res = await kubernetesApi.getCronJobs(clusterCode, namespace).catch(() => []); break; + case 'pvcs': res = await kubernetesApi.getPVCs(clusterCode, namespace).catch(() => []); break; + case 'statefulsets': res = await kubernetesApi.getStatefulSets(clusterCode, namespace).catch(() => []); break; + case 'daemonsets': res = await kubernetesApi.getDaemonSets(clusterCode, namespace).catch(() => []); break; + } + setData(Array.isArray(res) ? res : (res?.list || res?.data || [])); + } catch { setData([]); } finally { setLoading(false); } + }, [clusterCode, namespace, type]); + + useEffect(() => { loadData(); }, [loadData]); + + const filtered = data.filter((d: any) => (d.name || '').toLowerCase().includes(search.toLowerCase())); + + const handleDetail = (item: any) => { + if (type === 'deployments') { + navigate(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${item.name}`); + } + }; + + const handleYaml = (name: string) => setYamlItem({ resource: type, name }); + const handleExportCSV = () => kubernetesApi.exportAsCSV(`${type}-${clusterCode}-${namespace}.csv`, filtered); + const handleExportJSON = () => kubernetesApi.exportAsJSON(`${type}-${clusterCode}-${namespace}.json`, filtered); + + const sidebarResources = useMemo(() => [ + { type: 'deployments' as ResourceType, label: 'Deployments', icon: Boxes }, + { type: 'statefulsets' as ResourceType, label: 'StatefulSets', icon: Boxes }, + { type: 'daemonsets' as ResourceType, label: 'DaemonSets', icon: Boxes }, + { type: 'pods' as ResourceType, label: 'Pods', icon: Container }, + { type: 'cronjobs' as ResourceType, label: 'CronJobs', icon: Clock }, + { type: 'services' as ResourceType, label: 'Services', icon: Network }, + { type: 'ingresses' as ResourceType, label: 'Ingresses', icon: Network }, + { type: 'configmaps' as ResourceType, label: 'ConfigMaps', icon: FileText }, + { type: 'secrets' as ResourceType, label: 'Secrets', icon: Shield }, + { type: 'hpas' as ResourceType, label: 'HPAs', icon: Activity }, + { type: 'pvcs' as ResourceType, label: '存储卷', icon: Boxes }, + ], []); + + if (!config) return
未知资源类型: {type}
; + + return ( +
+ {yamlItem && ( + setYamlItem(null)} + onSaved={loadData} + /> + )} +
+ 集群 + {clusterCode && <>{clusterCode}} + {namespace && <>{namespace}} + {config.title} +
+
+
+

{config.title}

+

{config.subtitle}

+
+
+ + + + + +
+
+
+ {sidebarResources.map((r) => ( + + {r.label} + + ))} +
+
+ + setSearch(e.target.value)} className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200" /> +
+ + + + {config.columns.map((col) => {col})} + + {loading ? ( + 加载中... + ) : filtered.length === 0 ? ( + 暂无 {config.title} + ) : ( + filtered.map((item) => config.renderRow(item, { clusterCode: clusterCode || '', namespace: namespace || '', onDetail: handleDetail, onYaml: handleYaml })) + )} + +
+
+
+
+ ); +} diff --git a/frontend/src/components/kubernetes/PodMetricsPage.tsx b/frontend/src/components/kubernetes/PodMetricsPage.tsx new file mode 100644 index 0000000..60d07b2 --- /dev/null +++ b/frontend/src/components/kubernetes/PodMetricsPage.tsx @@ -0,0 +1,152 @@ +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useParams, useNavigate, Link } from 'react-router-dom'; +import { Card, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from '@/components/ui/select'; +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from '@/components/ui/table'; +import { Cpu, HardDrive, RefreshCw, ChevronRight, Activity } from 'lucide-react'; +import { kubernetesApi } from '@/services/api'; + +export default function PodMetricsPage() { + const { clusterCode } = useParams<{ clusterCode: string }>(); + const navigate = useNavigate(); + const [loading, setLoading] = useState(false); + const [metrics, setMetrics] = useState([]); + const [clusters, setClusters] = useState([]); + const [namespaces, setNamespaces] = useState([]); + const [selectedNs, setSelectedNs] = useState('all'); + const [activeCluster, setActiveCluster] = useState(clusterCode || ''); + + useEffect(() => { + kubernetesApi.getClusters().then((res: any) => { + const list = res?.list || []; + setClusters(list); + if (!activeCluster && list.length > 0) { + const code = list[0].code || String(list[0].id); + setActiveCluster(code); + navigate(`/kubernetes/${code}/metrics/pods`, { replace: true }); + } + }).catch(() => {}); + }, []); + + useEffect(() => { + if (!activeCluster) return; + kubernetesApi.getNamespaces(activeCluster).then((res: any) => { + setNamespaces(Array.isArray(res) ? res : (res?.list || res?.data || [])); + }).catch(() => {}); + }, [activeCluster]); + + const loadMetrics = useCallback(async () => { + if (!activeCluster) return; + setLoading(true); + try { + const ns = selectedNs === 'all' ? undefined : selectedNs; + const res: any = await kubernetesApi.getPodMetrics(activeCluster, ns); + setMetrics(Array.isArray(res) ? res : (res?.list || [])); + } catch { setMetrics([]); } finally { setLoading(false); } + }, [activeCluster, selectedNs]); + + useEffect(() => { loadMetrics(); }, [loadMetrics]); + + // Flatten for table + const rows = useMemo(() => + metrics.flatMap((pm: any) => + (pm.containers || []).map((c: any) => ({ + pod: pm.name, + namespace: pm.namespace, + container: c.name, + cpu: c.cpu || '0m', + memory: c.memory || '0Mi', + })) + ), [metrics]); + + const totalCPU = useMemo(() => rows.reduce((s, r) => s + (parseInt(r.cpu) || 0), 0), [rows]); + const totalMem = useMemo(() => { + let sum = 0; + rows.forEach(r => { + const v = r.memory; + if (v.endsWith('Mi')) sum += parseInt(v); + else if (v.endsWith('Ki')) sum += parseInt(v) / 1024; + else if (v.endsWith('Gi')) sum += parseInt(v) * 1024; + }); + return sum; + }, [rows]); + + return ( +
+
+ 集群 + {activeCluster && <>{activeCluster}} + Pod 资源用量 +
+ +
+

Pod 资源用量

+
+ + + +
+
+ + {/* Summary cards */} +
+ +

Pod 数

{metrics.length}

+
+ +

总 CPU

{totalCPU}m

+
+ +

总内存

{totalMem.toFixed(0)}Mi

+
+
+ + + + + + Pod + 命名空间 + 容器 + CPU + Memory + + + {rows.length === 0 ? ( + 暂无数据{loading ? ⏳ : ''} + ) : rows.map((r, i) => ( + + {r.pod} + {r.namespace} + {r.container} + {r.cpu} + {r.memory} + + ))} + +
+
+
+
+ ); +} diff --git a/frontend/src/components/kubernetes/TopologyView.tsx b/frontend/src/components/kubernetes/TopologyView.tsx index 6a43f88..37a9f0d 100644 --- a/frontend/src/components/kubernetes/TopologyView.tsx +++ b/frontend/src/components/kubernetes/TopologyView.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback, useRef } from 'react'; +import { useParams } from 'react-router-dom'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -24,6 +25,7 @@ import { } from './topology-helpers'; export function TopologyView() { + const { clusterCode } = useParams<{ clusterCode?: string }>(); const _confirm = useConfirm(); const [clusters, setClusters] = useState([]); const [selectedClusterId, setSelectedClusterId] = useState(''); @@ -53,9 +55,17 @@ export function TopologyView() { const res: any = await kubernetesApi.getClusters(); const list = res?.list || []; setClusters(list); - if (list.length > 0 && !selectedClusterId) setSelectedClusterId(String(list[0].id)); + // Auto-select from URL param (clusterCode) if provided, otherwise first cluster + if (!selectedClusterId) { + if (clusterCode) { + const matched = list.find((c: any) => (c.code || String(c.id)) === clusterCode); + if (matched) setSelectedClusterId(String(matched.id)); + } else if (list.length > 0) { + setSelectedClusterId(String(list[0].id)); + } + } } catch { setClusters([]); } - }, []); // eslint-disable-line react-hooks/exhaustive-deps + }, [clusterCode]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { loadClusters(); }, [loadClusters]); diff --git a/frontend/src/components/kubernetes/YamlViewer.tsx b/frontend/src/components/kubernetes/YamlViewer.tsx new file mode 100644 index 0000000..d1e562e --- /dev/null +++ b/frontend/src/components/kubernetes/YamlViewer.tsx @@ -0,0 +1,119 @@ +import { useState, useEffect } from 'react'; +import { X, Copy, Check, Save, RefreshCw, Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useToast } from '@/hooks/use-toast'; +import { kubernetesApi } from '@/services/api'; +import { useUserStore } from '@/store'; + +interface YamlViewerProps { + clusterCode: string; + namespace: string; + resource: string; // e.g. "deployments", "services" + name: string; + onClose: () => void; + onSaved?: () => void; +} + +export function YamlViewer({ clusterCode, namespace, resource, name, onClose, onSaved }: YamlViewerProps) { + const { toast } = useToast(); + const [yaml, setYaml] = useState(''); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [editing, setEditing] = useState(false); + const [copied, setCopied] = useState(false); + + 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); + } catch (e: any) { + toast({ title: '获取YAML失败', description: e.message, variant: 'destructive' }); + setYaml(`# Error: ${e.message}`); + } finally { setLoading(false); } + }; + + 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); } +}; + +const copyYaml = async () => { +await navigator.clipboard.writeText(yaml); +setCopied(true); +setTimeout(() => setCopied(false), 2000); +}; + +useEffect(() => { loadYaml(); }, []); + +return ( +
+
e.stopPropagation()}> +{/* Header */} +
+
+

{resource}/{name}

+

{namespace} · {clusterCode}

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