feat: PTY kubectl终端 + 侧边栏对齐修复 + 构建缓存清理

**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
This commit is contained in:
attacker committed 2026-06-19 11:22:45 +00:00
1 parent cac1414917
commit a3cd0d3910
29 files changed
+2954 -180

No files matched your search

+4 -2
View File
@@ -61,8 +61,10 @@ FROM alpine:latest
# 使用国内 Alpine 镜像源加速 // Logan.Li # 使用国内 Alpine 镜像源加速 // Logan.Li
RUN sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories RUN sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories
# 安装运行时依赖(curl=健康检查,openssh-client=SSH远程安装agent) # 安装运行时依赖(bash=kubectl交互终端,curl=健康检查,openssh-client=SSH远程安装agent,kubectl=集群命令行管理)
RUN apk --no-cache add ca-certificates tzdata curl openssh-client 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 ENV TZ=Asia/Shanghai
+1
View File
@@ -26,6 +26,7 @@ require (
github.com/bytedance/sonic v1.9.1 // indirect github.com/bytedance/sonic v1.9.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // 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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect
+2
View File
@@ -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-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 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= 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.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.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@@ -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")
}
}
+573 -14
View File
@@ -305,7 +305,14 @@ func (h *KubernetesHandler) ListNodes(c *gin.Context) {
return 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 { type NodeSummary struct {
Name string `json:"name"` Name string `json:"name"`
IP string `json:"ip"` IP string `json:"ip"`
@@ -316,6 +323,9 @@ func (h *KubernetesHandler) ListNodes(c *gin.Context) {
CPU int `json:"cpu"` CPU int `json:"cpu"`
Memory int64 `json:"memory_mb"` Memory int64 `json:"memory_mb"`
MaxPods int `json:"max_pods"` 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{} result := []NodeSummary{}
for _, n := range nodes.Items { for _, n := range nodes.Items {
@@ -340,7 +350,7 @@ func (h *KubernetesHandler) ListNodes(c *gin.Context) {
break break
} }
} }
result = append(result, NodeSummary{ s := NodeSummary{
Name: n.Metadata.Name, Name: n.Metadata.Name,
IP: ip, IP: ip,
Role: role, Role: role,
@@ -350,7 +360,13 @@ func (h *KubernetesHandler) ListNodes(c *gin.Context) {
CPU: parseIntResource(n.Status.Capacity["cpu"]), CPU: parseIntResource(n.Status.Capacity["cpu"]),
Memory: parseMemResource(n.Status.Capacity["memory"]), Memory: parseMemResource(n.Status.Capacity["memory"]),
MaxPods: parseIntResource(n.Status.Capacity["pods"]), 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}) 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() deps, err := client.ListDeployments()
if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return }
type DepSummary struct { 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{} result := []DepSummary{}
for _, d := range deps.Items { for _, d := range deps.Items {
if d.Metadata.Namespace != ns { continue } if d.Metadata.Namespace != ns { continue }
image := ""; if len(d.Spec.Template.Spec.Containers) > 0 { image = d.Spec.Template.Spec.Containers[0].Image } 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}) 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 } if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return }
pods, err := client.ListPods() pods, err := client.ListPods()
if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } 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{} result := []PodSummary{}
for _, p := range pods.Items { for _, p := range pods.Items {
if p.Metadata.Namespace != ns { continue } if p.Metadata.Namespace != ns { continue }
image := ""; if len(p.Spec.Containers) > 0 { image = p.Spec.Containers[0].Image } 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 } 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}) 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 } if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return }
svcs, err := client.ListServices() svcs, err := client.ListServices()
if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } 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{} result := []SvcSummary{}
for _, s := range svcs.Items { for _, s := range svcs.Items {
if s.Metadata.Namespace != ns { continue } 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}) 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 } if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return }
ings, err := client.ListIngresses() ings, err := client.ListIngresses()
if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } 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{} result := []IngSummary{}
for _, ing := range ings.Items { for _, ing := range ings.Items {
if ing.Metadata.Namespace != ns { continue } if ing.Metadata.Namespace != ns { continue }
hosts := []string{} hosts := []string{}
for _, rule := range ing.Spec.Rules { if rule.Host != "" { hosts = append(hosts, rule.Host) } } 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}) 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 } if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return }
hpas, err := client.ListHPAs() hpas, err := client.ListHPAs()
if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return } if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return }
filtered := []k8sclient.HPAItem{} type HPASummary struct {
for _, h := range hpas.Items { if h.Metadata.Namespace == ns { filtered = append(filtered, h) } } Namespace string `json:"namespace"`
c.JSON(200, models.Response{Code: 200, Data: filtered}) 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) { func (h *KubernetesHandler) createHPAWithNS(c *gin.Context, ns string) {
clusterID, _ := strconv.Atoi(c.Param("id")) 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}) 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 // 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 // getK8sClient builds a K8s API client from cluster's stored kubeconfig
func (h *KubernetesHandler) getK8sClient(clusterID int) (*k8sclient.Client, *models.KubernetesCluster, error) { 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 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})
}
+29
View File
@@ -68,6 +68,10 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
k8sHandler := handlers.NewKubernetesHandler(db) k8sHandler := handlers.NewKubernetesHandler(db)
r.GET("/ws/kubernetes-exec/:id/namespaces/:namespace/pods/:podName", middleware.JWTAuth(&cfg.JWT), k8sHandler.ExecTerminal) 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) authHandler := handlers.NewUserHandler(db, cfg)
api.POST("/auth/login", authHandler.Login) 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 // Namespace list
kubernetesRouter.GET("/:clusterCode/namespaces", k8sHandler.ListNamespaces) 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)
} }
// 告警路由 // 告警路由
+372 -1
View File
@@ -151,6 +151,37 @@ func (c *Client) get(path string, result interface{}) error {
return nil 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 --- // --- K8s API response types ---
// NodeList response // NodeList response
@@ -212,7 +243,14 @@ type PodSpec struct {
} }
type Container 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 { type PodStatus struct {
@@ -945,3 +983,336 @@ func (c *Client) ListSecrets(namespace string) (*SecretList, error) {
} }
return &SecretList{Items: items}, nil 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
}
+67 -3
View File
@@ -13,9 +13,16 @@ import { RegionManager } from '@/components/cloud/RegionManager';
import { PlatformManager } from '@/components/cloud/PlatformManager'; import { PlatformManager } from '@/components/cloud/PlatformManager';
import { CloudBilling } from '@/components/cloud/CloudBilling'; import { CloudBilling } from '@/components/cloud/CloudBilling';
import { KubernetesClusters } from '@/components/kubernetes/KubernetesClusters'; 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 { KubernetesNodes } from '@/components/kubernetes/KubernetesNodes';
import { KubernetesWorkloads } from '@/components/kubernetes/KubernetesWorkloads'; 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 { AlertManager } from '@/components/monitor/AlertManager';
import { AppReleaseManager } from '@/components/deploy/AppRelease'; import { AppReleaseManager } from '@/components/deploy/AppRelease';
import { ApprovalFlowManager } from '@/components/approval/ApprovalFlow'; import { ApprovalFlowManager } from '@/components/approval/ApprovalFlow';
@@ -63,11 +70,68 @@ function App() {
<Route path="regions" element={<RegionManager />} /> <Route path="regions" element={<RegionManager />} />
<Route path="platforms" element={<PlatformManager />} /> <Route path="platforms" element={<PlatformManager />} />
<Route path="cloud-billing" element={<CloudBilling />} /> <Route path="cloud-billing" element={<CloudBilling />} />
{/* ================================================================ */}
{/* Kubernetes — Kuboard-style routes */}
{/* ================================================================ */}
{/* 集群列表 */}
<Route path="kubernetes">
<Route index element={<KubernetesClusters />} />
{/* 集群概览 (节点 + 命名空间列表) */}
<Route path=":clusterCode">
<Route index element={<ClusterOverviewPage />} />
{/* 🆕 资源用量 / 事件 / 容量分析 */}
<Route path="metrics/pods" element={<PodMetricsPage />} />
<Route path="events" element={<EventsPage />} />
<Route path="capacity" element={<CapacityPage />} />
{/* 架构拓扑 */}
<Route path="topology" element={<TopologyView />} />
{/* 命名空间作用域 */}
<Route path="namespace/:namespace">
{/* 命名空间概览 */}
<Route index element={<NamespaceOverviewPage />} />
{/* 各类资源列表 */}
<Route path="deployments">
<Route index element={<NamespaceResourcePage />} />
<Route path=":name" element={<DeploymentDetailPage />} />
</Route>
<Route path="pods" element={<NamespaceResourcePage />} />
<Route path="services" element={<NamespaceResourcePage />} />
<Route path="ingresses" element={<NamespaceResourcePage />} />
<Route path="configmaps" element={<NamespaceResourcePage />} />
<Route path="secrets" element={<NamespaceResourcePage />} />
<Route path="hpas" element={<NamespaceResourcePage />} />
<Route path="cronjobs" element={<NamespaceResourcePage />} />
<Route path="pvcs" element={<NamespaceResourcePage />} />
<Route path="statefulsets" element={<NamespaceResourcePage />} />
<Route path="daemonsets" element={<NamespaceResourcePage />} />
</Route>
</Route>
</Route>
{/* Kubernetes 资源快捷入口 (自动跳转至首个集群) */}
<Route path="workloads">
<Route path="deployments" element={<NamespaceResourcePage />} />
<Route path="statefulsets" element={<NamespaceResourcePage />} />
<Route path="daemonsets" element={<NamespaceResourcePage />} />
<Route path="pods" element={<NamespaceResourcePage />} />
<Route path="cronjobs" element={<NamespaceResourcePage />} />
<Route path="services" element={<NamespaceResourcePage />} />
<Route path="ingresses" element={<NamespaceResourcePage />} />
<Route path="configmaps" element={<NamespaceResourcePage />} />
<Route path="secrets" element={<NamespaceResourcePage />} />
<Route path="pvcs" element={<NamespaceResourcePage />} />
<Route path="hpas" element={<NamespaceResourcePage />} />
<Route path="metrics" element={<PodMetricsPage />} />
<Route path="events" element={<EventsPage />} />
<Route path="capacity" element={<CapacityPage />} />
<Route index element={<NamespaceResourcePage />} />
</Route>
{/* 资源监控(Agent 指标 + 中间件 + SNMP + 拨测) */} {/* 资源监控(Agent 指标 + 中间件 + SNMP + 拨测) */}
<Route path="monitor-center" element={<MonitorCenter />} /> <Route path="monitor-center" element={<MonitorCenter />} />
<Route path="clusters" element={<KubernetesClusters />} /> {/* 旧路由兼容:节点管理、拓扑 */}
<Route path="nodes" element={<KubernetesNodes />} /> <Route path="nodes" element={<KubernetesNodes />} />
<Route path="workloads" element={<KubernetesWorkloads />} />
<Route path="topology" element={<TopologyView />} /> <Route path="topology" element={<TopologyView />} />
{/* 应用发布 */} {/* 应用发布 */}
<Route path="pipelines" element={<PipelineManager />} /> <Route path="pipelines" element={<PipelineManager />} />
+1
View File
@@ -98,6 +98,7 @@ export function Login() {
try { try {
const result = await userApi.login(username, password); const result = await userApi.login(username, password);
useUserStore.getState().login(result.user, result.token); useUserStore.getState().login(result.user, result.token);
localStorage.setItem('token', result.token); // backward compat
navigate('/', { replace: true }); navigate('/', { replace: true });
} catch (err: unknown) { } catch (err: unknown) {
const message = err instanceof Error ? err.message : '登录失败,请检查用户名和密码'; const message = err instanceof Error ? err.message : '登录失败,请检查用户名和密码';
@@ -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<any[]>([]);
const [clusters, setClusters] = useState<any[]>([]);
const [namespaces, setNamespaces] = useState<string[]>([]);
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 (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-slate-500">
<Link to="/kubernetes" className="hover:text-slate-300">集群</Link>
{activeCluster && <><ChevronRight className="h-3 w-3" /><Link to={`/kubernetes/${activeCluster}`} className="hover:text-slate-300">{activeCluster}</Link></>}
<ChevronRight className="h-3 w-3" /><span className="text-slate-300">容量分析</span>
</div>
<div className="flex items-center justify-between flex-wrap gap-3">
<h2 className="text-lg font-semibold text-slate-100 flex items-center gap-2"><BarChart3 className="h-5 w-5 text-purple-400" />资源容量分析</h2>
<div className="flex items-center gap-2">
<Select value={activeCluster} onValueChange={v => { setActiveCluster(v); navigate(`/kubernetes/${v}/capacity`); }}>
<SelectTrigger className="w-40 h-8 text-xs bg-slate-800 border-slate-700"><SelectValue /></SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
{clusters.map((c: any) => <SelectItem key={c.id} value={c.code || String(c.id)} className="text-xs">{c.name || c.code}</SelectItem>)}
</SelectContent>
</Select>
<Select value={selectedNs} onValueChange={setSelectedNs}>
<SelectTrigger className="w-32 h-8 text-xs bg-slate-800 border-slate-700"><SelectValue placeholder="所有命名空间" /></SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectItem value="all" className="text-xs">所有命名空间</SelectItem>
{namespaces.map(n => <SelectItem key={n} value={n} className="text-xs">{n}</SelectItem>)}
</SelectContent>
</Select>
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300 text-xs" onClick={loadCapacity} disabled={loading}>
<RefreshCw className={`h-3 w-3 mr-1 ${loading ? 'animate-spin' : ''}`} />刷新
</Button>
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300 text-xs" onClick={exportCSV}>
<Download className="h-3 w-3 mr-1" />CSV
</Button>
</div>
</div>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-0">
<Table>
<TableHeader><TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">Deployment</TableHead>
<TableHead className="text-slate-400">命名空间</TableHead>
<TableHead className="text-slate-400">副本</TableHead>
<TableHead className="text-slate-400">容器</TableHead>
<TableHead className="text-slate-400"><Cpu className="h-3 w-3 inline mr-1" />CPU Req</TableHead>
<TableHead className="text-slate-400"><Cpu className="h-3 w-3 inline mr-1" />CPU Lim</TableHead>
<TableHead className="text-slate-400"><HardDrive className="h-3 w-3 inline mr-1" />Mem Req</TableHead>
<TableHead className="text-slate-400"><HardDrive className="h-3 w-3 inline mr-1" />Mem Lim</TableHead>
</TableRow></TableHeader>
<TableBody>
{rows.length === 0 ? (
<TableRow><TableCell colSpan={8} className="text-center text-slate-500 py-8">暂无数据{loading ? <span className="animate-spin inline-block ml-2">⏳</span> : ''}</TableCell></TableRow>
) : rows.map((r, i) => (
<TableRow key={`${r.deployment}-${r.container}-${i}`} className="border-slate-800 hover:bg-slate-800/30">
<TableCell className="font-medium text-slate-200 text-sm">{r.deployment}</TableCell>
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400 text-xs">{r.namespace}</Badge></TableCell>
<TableCell className="text-sm text-slate-300">{r.replicas}</TableCell>
<TableCell className="text-sm text-slate-400">{r.container}</TableCell>
<TableCell className={`text-sm ${r.cpu_req !== '-' ? 'text-blue-400' : 'text-slate-600'}`}>{r.cpu_req}</TableCell>
<TableCell className={`text-sm ${r.cpu_lim !== '-' ? 'text-cyan-400' : 'text-slate-600'}`}>{r.cpu_lim}</TableCell>
<TableCell className={`text-sm ${r.mem_req !== '-' ? 'text-emerald-400' : 'text-slate-600'}`}>{r.mem_req}</TableCell>
<TableCell className={`text-sm ${r.mem_lim !== '-' ? 'text-teal-400' : 'text-slate-600'}`}>{r.mem_lim}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
@@ -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<string, React.ComponentType<any>> = {
'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<any[]>([]);
const [nodes, setNodes] = useState<any[]>([]);
const [namespaces, setNamespaces] = useState<string[]>([]);
const [clusterInfo, setClusterInfo] = useState<any>(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 (
<div className="space-y-4">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-slate-500">
<Link to="/kubernetes" className="hover:text-slate-300 transition-colors">集群</Link>
<ChevronRight className="h-3 w-3" />
<span className="text-slate-300">{clusterCode}</span>
</div>
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-100">{clusterCode}</h1>
<p className="text-sm text-slate-500 mt-1">
集群概览
{clusterInfo?.version ? ` — Kubernetes ${clusterInfo.version}` : ''}
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" className="h-9 w-9 border-slate-700 text-slate-400"
onClick={loadData} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
</Button>
</div>
</div>
{/* Stats cards */}
<div className="grid grid-cols-3 md:grid-cols-3 gap-4">
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div><p className="text-sm text-slate-500">节点</p><p className="text-2xl font-bold text-blue-400">{nodes.length}</p></div>
<Cpu className="h-8 w-8 opacity-30 text-blue-400" />
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div><p className="text-sm text-slate-500">命名空间</p><p className="text-2xl font-bold text-emerald-400">{namespaces.length}</p></div>
<Boxes className="h-8 w-8 opacity-30 text-emerald-400" />
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div><p className="text-sm text-slate-500">版本</p><p className="text-lg font-bold text-slate-300">{clusterInfo?.version || '-'}</p></div>
<Server className="h-8 w-8 opacity-30 text-slate-400" />
</div>
</CardContent>
</Card>
</div>
{/* Nodes table */}
<div>
<h2 className="text-lg font-semibold text-slate-100 mb-3 flex items-center gap-2">
<Cpu className="h-4 w-4 text-blue-400" />节点列表
</h2>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">名称</TableHead>
<TableHead className="text-slate-400">角色</TableHead>
<TableHead className="text-slate-400">状态</TableHead>
<TableHead className="text-slate-400">IP</TableHead>
<TableHead className="text-slate-400">OS</TableHead>
<TableHead className="text-slate-400">Kubelet</TableHead>
<TableHead className="text-slate-400">CPU</TableHead>
<TableHead className="text-slate-400">内存</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow><TableCell colSpan={8} className="text-center text-slate-500 py-12"><Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />加载中...</TableCell></TableRow>
) : nodes.length === 0 ? (
<TableRow><TableCell colSpan={8} className="text-center text-slate-500 py-12"><Cpu className="h-8 w-8 mx-auto mb-2 opacity-50" />暂无节点</TableCell></TableRow>
) : (
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 (
<TableRow key={n.name} className="border-slate-800 hover:bg-slate-800/30">
<TableCell className="font-medium text-slate-200">{n.name}</TableCell>
<TableCell><Badge className={getNodeRole(n)}>{n.role || 'worker'}</Badge></TableCell>
<TableCell><Badge className={n.status === 'Ready' ? 'bg-emerald-500/20 text-emerald-400' : 'bg-red-500/20 text-red-400'}>{n.status || 'Unknown'}</Badge></TableCell>
<TableCell className="text-sm text-slate-400">{n.ip || '-'}</TableCell>
<TableCell className="text-sm text-slate-400 max-w-[200px] truncate">{n.os || '-'}</TableCell>
<TableCell className="text-sm text-slate-400">{n.kubelet_version || '-'}</TableCell>
<TableCell className="text-xs">
{n.cpu_usage_m ? (
<div className="flex items-center gap-1.5 min-w-[80px]">
<div className="flex-1 h-1.5 rounded-full bg-slate-700/50 overflow-hidden">
<div className={`h-full rounded-full ${color(cpuPct)}`} style={{ width: `${Math.min(cpuPct, 100)}%` }} />
</div>
<span className="text-slate-400">{cpuPct.toFixed(0)}%</span>
</div>
) : (
<span className="text-slate-500">{cpuCores} 核</span>
)}
</TableCell>
<TableCell className="text-xs">
{n.mem_usage_mi ? (
<div className="flex items-center gap-1.5 min-w-[80px]">
<div className="flex-1 h-1.5 rounded-full bg-slate-700/50 overflow-hidden">
<div className={`h-full rounded-full ${color(memPct)}`} style={{ width: `${Math.min(memPct, 100)}%` }} />
</div>
<span className="text-slate-400">{memPct.toFixed(0)}%</span>
</div>
) : (
<span className="text-slate-500">{memMb}Mi</span>
)}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
{/* Namespace list */}
<div>
<h2 className="text-lg font-semibold text-slate-100 mb-3 flex items-center gap-2">
<Boxes className="h-4 w-4 text-emerald-400" />命名空间
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{namespaces.map((ns) => {
const Icon = NAMESPACE_ICON[ns] || Container;
return (
<Link key={ns}
to={`/kubernetes/${clusterCode}/namespace/${ns}`}
className="bg-slate-900 border border-slate-800 rounded-lg p-4 hover:border-slate-600 hover:bg-slate-800/50 transition-all group"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Icon className="h-5 w-5 text-slate-500 group-hover:text-slate-300 transition-colors" />
<span className="text-sm font-medium text-slate-300 group-hover:text-slate-100 transition-colors">{ns}</span>
</div>
<ChevronRight className="h-4 w-4 text-slate-600 group-hover:text-slate-400 transition-colors" />
</div>
</Link>
);
})}
</div>
</div>
</div>
);
}
@@ -16,6 +16,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { kubernetesApi } from '@/services/api'; import { kubernetesApi } from '@/services/api';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import { useUserStore } from '@/store';
interface DeploymentDetailProps { interface DeploymentDetailProps {
clusterCode: string; clusterCode: string;
@@ -69,10 +70,9 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
); );
setPods(depPods); setPods(depPods);
// Filter HPAs targeting this deployment // Filter HPAs targeting this deployment (flattened data)
const depHpas = allHpas.filter((h: any) => { const depHpas = allHpas.filter((h: any) => {
const ref = h.spec?.scaleTargetRef; return h.target_name === deploymentName && h.namespace === namespace;
return ref?.name === deploymentName && h.metadata?.namespace === namespace;
}); });
setHpas(depHpas); setHpas(depHpas);
} catch (e) { } catch (e) {
@@ -134,9 +134,9 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
const handleHpaToggle = async (hpa: any) => { const handleHpaToggle = async (hpa: any) => {
// Not direct toggle — delete HPA // Not direct toggle — delete HPA
if (!confirm(`确定删除 HPA "${hpa.metadata?.name}"?`)) return; if (!confirm(`确定删除 HPA "${hpa.name}"?`)) return;
try { try {
await kubernetesApi.deleteHPA(clusterCode, namespace, hpa.metadata?.name); await kubernetesApi.deleteHPA(clusterCode, namespace, hpa.name);
toast({ title: 'HPA 已删除' }); toast({ title: 'HPA 已删除' });
loadData(); loadData();
} catch (e: any) { } catch (e: any) {
@@ -406,7 +406,7 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
onClick={async () => { onClick={async () => {
setLogs(''); setLogs('');
// Stream mode // 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`; const url = `${window.location.protocol}//${window.location.host}/api/v1/kubernetes/${clusterCode}/namespace/${namespace}/pods/${logPod}/logs?tail=${logTail}&stream=true`;
try { try {
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }); const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
@@ -471,18 +471,18 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{hpas.map((hpa: any) => { {hpas.map((hpa: any) => {
const spec = hpa.spec || {}; const cpu = hpa.cpu_percent || 0;
const status = hpa.status || {}; const mem = hpa.mem_percent || 0;
const metrics = (spec.metrics || []).map((m: any) => { const metricsParts = [];
if (m.resource) return `${m.resource.name}: ${m.resource.target.averageUtilization}%`; if (cpu) metricsParts.push(`CPU: ${cpu}%`);
return m.type; if (mem) metricsParts.push(`MEM: ${mem}%`);
}).join(', '); const metrics = metricsParts.join(', ') || '-';
return ( return (
<TableRow key={hpa.metadata?.name} className="border-slate-800 hover:bg-slate-800/30"> <TableRow key={hpa.name} className="border-slate-800 hover:bg-slate-800/30">
<TableCell className="font-medium text-slate-200">{hpa.metadata?.name}</TableCell> <TableCell className="font-medium text-slate-200">{hpa.name}</TableCell>
<TableCell className="text-slate-300">{spec.minReplicas} / {spec.maxReplicas}</TableCell> <TableCell className="text-slate-300">{hpa.min_replicas} / {hpa.max_replicas}</TableCell>
<TableCell className="text-sm text-slate-400">{metrics || '-'}</TableCell> <TableCell className="text-sm text-slate-400">{metrics}</TableCell>
<TableCell className="text-slate-300">{status.currentReplicas || 0} / {status.desiredReplicas || 0}</TableCell> <TableCell className="text-slate-300">{hpa.current_replicas || 0}</TableCell>
<TableCell> <TableCell>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-400 hover:text-red-300" <Button variant="ghost" size="icon" className="h-7 w-7 text-red-400 hover:text-red-300"
onClick={() => handleHpaToggle(hpa)} title="删除 HPA"> onClick={() => handleHpaToggle(hpa)} title="删除 HPA">
@@ -0,0 +1,18 @@
import { useParams, useNavigate } from 'react-router-dom';
import { DeploymentDetail } from './DeploymentDetail';
export default function DeploymentDetailPage() {
const { clusterCode, namespace, name } = useParams<{
clusterCode: string; namespace: string; name: string;
}>();
const navigate = useNavigate();
return (
<DeploymentDetail
clusterCode={clusterCode || ''}
namespace={namespace || ''}
deploymentName={name || ''}
onBack={() => navigate(-1)}
/>
);
}
@@ -0,0 +1,147 @@
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, Bell, AlertTriangle, Info } from 'lucide-react';
import { kubernetesApi } from '@/services/api';
export default function EventsPage() {
const { clusterCode } = useParams<{ clusterCode: string }>();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [events, setEvents] = useState<any[]>([]);
const [clusters, setClusters] = useState<any[]>([]);
const [namespaces, setNamespaces] = useState<string[]>([]);
const [selectedNs, setSelectedNs] = useState('all');
const [filterType, setFilterType] = 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}/events`, { 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 loadEvents = useCallback(async () => {
if (!activeCluster) return;
setLoading(true);
try {
const ns = selectedNs === 'all' ? undefined : selectedNs;
const ft = filterType === 'all' ? undefined : filterType;
const res: any = await kubernetesApi.getEvents(activeCluster, ns, ft);
setEvents(Array.isArray(res) ? res : (res?.list || []));
} catch { setEvents([]); } finally { setLoading(false); }
}, [activeCluster, selectedNs, filterType]);
useEffect(() => { loadEvents(); }, [loadEvents]);
const warningCount = useMemo(() => events.filter(e => e.type === 'Warning').length, [events]);
const normalCount = useMemo(() => events.filter(e => e.type === 'Normal').length, [events]);
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-slate-500">
<Link to="/kubernetes" className="hover:text-slate-300">集群</Link>
{activeCluster && <><ChevronRight className="h-3 w-3" /><Link to={`/kubernetes/${activeCluster}`} className="hover:text-slate-300">{activeCluster}</Link></>}
<ChevronRight className="h-3 w-3" /><span className="text-slate-300">事件</span>
</div>
<div className="flex items-center justify-between flex-wrap gap-3">
<h2 className="text-lg font-semibold text-slate-100 flex items-center gap-2"><Bell className="h-5 w-5 text-yellow-400" />集群事件</h2>
<div className="flex items-center gap-2">
<Select value={activeCluster} onValueChange={v => { setActiveCluster(v); navigate(`/kubernetes/${v}/events`); }}>
<SelectTrigger className="w-40 h-8 text-xs bg-slate-800 border-slate-700"><SelectValue /></SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
{clusters.map((c: any) => <SelectItem key={c.id} value={c.code || String(c.id)} className="text-xs">{c.name || c.code}</SelectItem>)}
</SelectContent>
</Select>
<Select value={selectedNs} onValueChange={setSelectedNs}>
<SelectTrigger className="w-32 h-8 text-xs bg-slate-800 border-slate-700"><SelectValue placeholder="所有命名空间" /></SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectItem value="all" className="text-xs">所有命名空间</SelectItem>
{namespaces.map(n => <SelectItem key={n} value={n} className="text-xs">{n}</SelectItem>)}
</SelectContent>
</Select>
<Select value={filterType} onValueChange={setFilterType}>
<SelectTrigger className="w-28 h-8 text-xs bg-slate-800 border-slate-700"><SelectValue placeholder="所有类型" /></SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectItem value="all" className="text-xs">所有类型</SelectItem>
<SelectItem value="Warning" className="text-xs">⚠ Warning</SelectItem>
<SelectItem value="Normal" className="text-xs">ℹ Normal</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300 text-xs" onClick={loadEvents} disabled={loading}>
<RefreshCw className={`h-3 w-3 mr-1 ${loading ? 'animate-spin' : ''}`} />刷新
</Button>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<Card className="bg-slate-900 border-slate-800"><CardContent className="p-3 text-center">
<p className="text-xs text-slate-500">总数</p><p className="text-xl font-bold text-slate-200">{events.length}</p>
</CardContent></Card>
<Card className="bg-slate-900 border-red-900/30"><CardContent className="p-3 text-center">
<p className="text-xs text-slate-500">Warning</p><p className="text-xl font-bold text-red-400">{warningCount}</p>
</CardContent></Card>
<Card className="bg-slate-900 border-emerald-900/30"><CardContent className="p-3 text-center">
<p className="text-xs text-slate-500">Normal</p><p className="text-xl font-bold text-emerald-400">{normalCount}</p>
</CardContent></Card>
</div>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-0">
<Table>
<TableHeader><TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400 w-16">类型</TableHead>
<TableHead className="text-slate-400">原因</TableHead>
<TableHead className="text-slate-400">对象</TableHead>
<TableHead className="text-slate-400">消息</TableHead>
<TableHead className="text-slate-400 w-12">次数</TableHead>
<TableHead className="text-slate-400 w-36">最后时间</TableHead>
</TableRow></TableHeader>
<TableBody>
{events.length === 0 ? (
<TableRow><TableCell colSpan={6} className="text-center text-slate-500 py-8">暂无事件{loading ? <span className="animate-spin inline-block ml-2">⏳</span> : ''}</TableCell></TableRow>
) : events.map((e, i) => (
<TableRow key={`${e.name}-${i}`} className="border-slate-800 hover:bg-slate-800/30">
<TableCell>
{e.type === 'Warning' ? (
<Badge className="bg-red-500/20 text-red-400 border-red-500/30 text-xs"><AlertTriangle className="h-3 w-3 mr-1 inline" />Warning</Badge>
) : (
<Badge className="bg-blue-500/20 text-blue-400 border-blue-500/30 text-xs"><Info className="h-3 w-3 mr-1 inline" />Normal</Badge>
)}
</TableCell>
<TableCell className="text-sm text-slate-300">{e.reason || '-'}</TableCell>
<TableCell className="text-xs text-slate-500 font-mono">{e.object || '-'}</TableCell>
<TableCell className="text-xs text-slate-400 max-w-[350px] truncate" title={e.message}>{e.message || '-'}</TableCell>
<TableCell className="text-sm text-slate-400">{e.count || 1}</TableCell>
<TableCell className="text-xs text-slate-500">{e.last_time?.replace('T', ' ').substring(0, 19) || '-'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
@@ -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<HTMLDivElement>(null);
const termRef = useRef<XTerm | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const fitRef = useRef<FitAddon | null>(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<typeof setTimeout>;
const ro = new ResizeObserver(() => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
if (fitRef.current && termRef.current && wsRef.current) {
try {
fitRef.current.fit();
termRef.current.focus();
const { cols, rows } = termRef.current;
if (wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ status: 1, cols, rows }));
}
} catch { /* noop */ }
}
}, 60);
});
ro.observe(container);
return () => {
clearTimeout(resizeTimer);
ro.disconnect();
};
}, [open]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
onOpenAutoFocus={(e) => e.preventDefault()}
className={cn(
'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0',
'!duration-0 !transition-none',
fullscreen
? '!max-w-none !w-screen !h-screen !rounded-none !translate-x-0 !translate-y-0 !left-0 !top-0'
: ''
)}
style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '900px', width: '90vw' }}
>
<DialogTitle className="sr-only">kubectl — {clusterName}</DialogTitle>
<DialogDescription className="sr-only">Kubernetes 集群命令行终端</DialogDescription>
{/* 头部 */}
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--dg-border)]">
<div className="flex items-center gap-2 text-sm">
<div className={cn('w-2 h-2 rounded-full', connected ? 'bg-emerald-400' : error ? 'bg-red-400' : 'bg-yellow-400')} />
<Terminal className="h-4 w-4 text-blue-400" />
<span className="text-[var(--dg-text-primary)] font-medium">kubectl — {clusterName}</span>
{!connected && !error && <Loader2 className="h-3 w-3 animate-spin text-[var(--dg-text-muted)]" />}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setFullscreen(!fullscreen)}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
>
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</button>
<button
onClick={() => onOpenChange(false)}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* 终端容器 */}
<div
ref={containerRef}
className="bg-[#0f172a]"
style={{ height: fullscreen ? 'calc(100vh - 49px)' : '480px' }}
/>
</DialogContent>
</Dialog>
);
}
@@ -26,11 +26,13 @@ import {
Eye, Eye,
Trash2, Trash2,
Search, Search,
Container,
} from 'lucide-react'; } from 'lucide-react';
import { kubernetesApi } from '@/services/api'; import { kubernetesApi } from '@/services/api';
import type { KubernetesCluster } from '@/types'; import type { KubernetesCluster } from '@/types';
import { KubernetesClusterDetail } from './KubernetesClusterDetail'; import { KubernetesClusterDetail } from './KubernetesClusterDetail';
import { KubernetesClusterDialog } from './KubernetesClusterDialog'; import { KubernetesClusterDialog } from './KubernetesClusterDialog';
import { KubectlTerminal } from './KubectlTerminal';
export function KubernetesClusters() { export function KubernetesClusters() {
const [clusters, setClusters] = useState<any[]>([]); const [clusters, setClusters] = useState<any[]>([]);
@@ -42,6 +44,10 @@ export function KubernetesClusters() {
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editingCluster, setEditingCluster] = useState<any>(null); const [editingCluster, setEditingCluster] = useState<any>(null);
// kubectl 终端
const [kubectlCluster, setKubectlCluster] = useState<{id: number; name: string} | null>(null);
const [kubectlTerminalOpen, setKubectlTerminalOpen] = useState(false);
const loadData = async () => { const loadData = async () => {
try { try {
setLoading(true); setLoading(true);
@@ -273,6 +279,17 @@ export function KubernetesClusters() {
<Settings className="h-4 w-4 mr-2" /> <Settings className="h-4 w-4 mr-2" />
配置 配置
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem
className="text-emerald-400 focus:bg-slate-700 focus:text-emerald-300"
onClick={(e) => {
e.stopPropagation();
setKubectlCluster({ id: cluster.id, name: cluster.name });
setKubectlTerminalOpen(true);
}}
>
<Container className="h-4 w-4 mr-2" />
kubectl
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-slate-700" /> <DropdownMenuSeparator className="bg-slate-700" />
<DropdownMenuItem <DropdownMenuItem
className="text-red-400 focus:bg-slate-700 focus:text-red-400" className="text-red-400 focus:bg-slate-700 focus:text-red-400"
@@ -355,6 +372,19 @@ export function KubernetesClusters() {
editingCluster={editingCluster} editingCluster={editingCluster}
onSaved={loadData} onSaved={loadData}
/> />
{/* kubectl 终端 */}
{kubectlCluster && (
<KubectlTerminal
open={kubectlTerminalOpen}
onOpenChange={(open) => {
setKubectlTerminalOpen(open);
if (!open) setKubectlCluster(null);
}}
clusterId={kubectlCluster.id}
clusterName={kubectlCluster.name}
/>
)}
</div> </div>
); );
} }
@@ -72,8 +72,8 @@ export function KubernetesWorkloads() {
setPods(toArray(podRes)); setPods(toArray(podRes));
} else { } else {
const [depRes, podRes] = await Promise.all([ const [depRes, podRes] = await Promise.all([
kubernetesApi.getDeployments(clusterCode).catch(() => []), kubernetesApi.getAllDeployments(clusterCode).catch(() => []),
kubernetesApi.getPods(clusterCode).catch(() => []), kubernetesApi.getAllPods(clusterCode).catch(() => []),
]); ]);
const toArray = (v: any) => (Array.isArray(v) ? v : (v?.list || [])); const toArray = (v: any) => (Array.isArray(v) ? v : (v?.list || []));
setDeployments(toArray(depRes)); setDeployments(toArray(depRes));
@@ -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<Record<string, number>>({});
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 (
<div className="space-y-4">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-slate-500">
<Link to="/kubernetes" className="hover:text-slate-300 transition-colors">集群</Link>
<ChevronRight className="h-3 w-3" />
<Link to={`/kubernetes/${clusterCode}`} className="hover:text-slate-300 transition-colors">{clusterCode}</Link>
<ChevronRight className="h-3 w-3" />
<span className="text-slate-300">{namespace}</span>
</div>
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-100 flex items-center gap-2">
<Cloud className="h-6 w-6 text-blue-400" />
{namespace}
</h1>
<p className="text-sm text-slate-500 mt-1">命名空间概览</p>
</div>
</div>
{/* Resource grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{RESOURCE_LINKS.map((r) => (
<Link key={r.type}
to={`/kubernetes/${clusterCode}/namespace/${namespace}/${r.type}`}
className="bg-slate-900 border border-slate-800 rounded-lg p-4 hover:border-slate-600 hover:bg-slate-800/50 transition-all group"
>
<div className="flex items-center justify-between mb-2">
<r.icon className="h-5 w-5 text-slate-500 group-hover:text-blue-400 transition-colors" />
<ChevronRight className="h-4 w-4 text-slate-600 group-hover:text-slate-400 transition-colors" />
</div>
<div className="text-lg font-bold text-slate-100">{loading ? '-' : (counts[r.type] ?? 0)}</div>
<div className="text-sm text-slate-500">{r.label}</div>
<div className="text-xs text-slate-600 mt-1">{r.desc}</div>
</Link>
))}
</div>
</div>
);
}
@@ -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<ResourceType, {
title: string;
subtitle: string;
icon: React.ComponentType<any>;
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) => (
<TableRow key={d.name} className="border-slate-800 hover:bg-slate-800/30 cursor-pointer" onClick={() => opts.onDetail?.(d)}>
<TableCell className="font-medium text-slate-200">{d.name}</TableCell>
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{d.namespace}</Badge></TableCell>
<TableCell>
{(() => {
const ready = d.ready_replicas || 0; const total = d.replicas || 0;
const pct = total > 0 ? (ready / total) * 100 : 0; const done = ready === total;
return (
<div className="flex items-center gap-2 min-w-[100px]">
<div className="flex-1 h-1.5 rounded-full bg-slate-700/50 overflow-hidden">
<div className={`h-full rounded-full ${done ? 'bg-emerald-500' : 'bg-yellow-500'}`} style={{ width: `${pct}%` }} />
</div>
<span className={`text-xs ${done ? 'text-emerald-400' : 'text-yellow-400'}`}>{ready}/{total}</span>
</div>
);
})()}
</TableCell>
<TableCell className="text-xs text-slate-500 max-w-[200px] truncate">{d.image || '-'}</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-0.5">
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400" title="查看YAML"
onClick={() => opts.onYaml?.(d.name)}><FileCode className="h-3.5 w-3.5" /></Button>
</div>
</TableCell>
</TableRow>
),
},
pods: {
title: 'Pods', subtitle: 'Pod 实例运行状态', icon: Container,
columns: ['名称', '命名空间', '状态', '节点', '重启', '镜像', '操作'],
renderRow: (p, opts) => {
const statusMap: Record<string, string> = { 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 (
<TableRow key={p.name} className="border-slate-800 hover:bg-slate-800/30">
<TableCell className="font-medium text-slate-200">{p.name}</TableCell>
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{p.namespace}</Badge></TableCell>
<TableCell><Badge className={statusMap[p.status] || 'bg-slate-500/20 text-slate-400'}>{p.status || 'Unknown'}</Badge></TableCell>
<TableCell className="text-sm text-slate-400">{p.node_name || '-'}</TableCell>
<TableCell className={(p.restart_count > 0 ? 'text-yellow-400' : 'text-slate-500')}>{p.restart_count || 0}</TableCell>
<TableCell className="text-xs text-slate-500 max-w-[200px] truncate">{p.image || '-'}</TableCell>
<TableCell><Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400" title="查看YAML" onClick={() => opts.onYaml?.(p.name)}><FileCode className="h-3.5 w-3.5" /></Button></TableCell>
</TableRow>
);
},
},
services: {
title: 'Services', subtitle: 'Service 网络服务', icon: Network,
columns: ['名称', '命名空间', '类型', 'Cluster IP', '端口', '操作'],
renderRow: (s, opts) => (
<TableRow key={s.name} className="border-slate-800 hover:bg-slate-800/30">
<TableCell className="font-medium text-slate-200">{s.name}</TableCell>
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{s.namespace}</Badge></TableCell>
<TableCell><Badge className="bg-blue-500/20 text-blue-400 border-blue-500/30">{s.type || '-'}</Badge></TableCell>
<TableCell className="text-sm text-slate-400">{s.cluster_ip || '-'}</TableCell>
<TableCell className="text-sm text-slate-400">{s.ports || '-'}</TableCell>
<TableCell><Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400" title="查看YAML" onClick={() => opts.onYaml?.(s.name)}><FileCode className="h-3.5 w-3.5" /></Button></TableCell>
</TableRow>
),
},
ingresses: {
title: 'Ingresses', subtitle: 'Ingress 路由规则', icon: Network,
columns: ['名称', '命名空间', 'Hosts', 'TLS', '操作'],
renderRow: (i, opts) => {
const hosts = i.hosts || [];
return (
<TableRow key={i.name} className="border-slate-800 hover:bg-slate-800/30">
<TableCell className="font-medium text-slate-200">{i.name}</TableCell>
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{i.namespace}</Badge></TableCell>
<TableCell className="text-sm text-slate-400 max-w-[250px] truncate">{hosts.length > 0 ? hosts.join(', ') : '-'}</TableCell>
<TableCell><Badge className={i.tls ? 'bg-emerald-500/20 text-emerald-400' : 'bg-slate-500/20 text-slate-400'}>{i.tls ? 'HTTPS' : 'HTTP'}</Badge></TableCell>
<TableCell><Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400" title="查看YAML" onClick={() => opts.onYaml?.(i.name)}><FileCode className="h-3.5 w-3.5" /></Button></TableCell>
</TableRow>
);
},
},
configmaps: {
title: 'ConfigMaps', subtitle: '配置字典', icon: FileText,
columns: ['名称', '命名空间', '键数量', '操作'],
renderRow: (c, opts) => (
<TableRow key={c.name} className="border-slate-800 hover:bg-slate-800/30">
<TableCell className="font-medium text-slate-200">{c.name}</TableCell>
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{c.namespace}</Badge></TableCell>
<TableCell className="text-sm text-slate-400">{c.key_count || '-'}</TableCell>
<TableCell><Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400" title="查看YAML" onClick={() => opts.onYaml?.(c.name)}><FileCode className="h-3.5 w-3.5" /></Button></TableCell>
</TableRow>
),
},
secrets: {
title: 'Secrets', subtitle: '密钥管理(仅名称与类型)', icon: Shield,
columns: ['名称', '命名空间', '类型', '操作'],
renderRow: (s, opts) => (
<TableRow key={s.name} className="border-slate-800 hover:bg-slate-800/30">
<TableCell className="font-medium text-slate-200">{s.name}</TableCell>
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{s.namespace}</Badge></TableCell>
<TableCell className="text-sm text-slate-400">{s.type || '-'}</TableCell>
<TableCell><Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400" title="查看YAML" onClick={() => opts.onYaml?.(s.name)}><FileCode className="h-3.5 w-3.5" /></Button></TableCell>
</TableRow>
),
},
hpas: {
title: 'HPAs', subtitle: '水平自动伸缩', icon: Activity,
columns: ['名称', '命名空间', '目标', '最小/最大', 'CPU%', 'Mem%'],
renderRow: (h, opts) => (
<TableRow key={h.name} className="border-slate-800 hover:bg-slate-800/30">
<TableCell className="font-medium text-slate-200">{h.name}</TableCell>
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{h.namespace}</Badge></TableCell>
<TableCell className="text-sm text-slate-400">{h.target_kind}/{h.target_name}</TableCell>
<TableCell className="text-sm text-slate-400">{h.min_replicas}/{h.max_replicas}</TableCell>
<TableCell className="text-sm text-slate-400">{h.cpu_percent ?? '-'}%</TableCell>
<TableCell className="text-sm text-slate-400">{h.mem_percent ?? '-'}%</TableCell>
</TableRow>
),
},
cronjobs: {
title: 'CronJobs', subtitle: '定时任务', icon: Clock,
columns: ['名称', '命名空间', '调度', '状态', '上次执行'],
renderRow: (cj) => (
<TableRow key={cj.name} className="border-slate-800 hover:bg-slate-800/30">
<TableCell className="font-medium text-slate-200">{cj.name}</TableCell>
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{cj.namespace}</Badge></TableCell>
<TableCell className="text-sm font-mono text-slate-400">{cj.schedule || '-'}</TableCell>
<TableCell><Badge className={cj.suspend ? 'bg-yellow-500/20 text-yellow-400' : 'bg-emerald-500/20 text-emerald-400'}>{cj.suspend ? '已暂停' : '运行中'}</Badge></TableCell>
<TableCell className="text-sm text-slate-400">{cj.last_schedule || '-'}</TableCell>
</TableRow>
),
},
pvcs: {
title: '存储卷声明', subtitle: 'PersistentVolumeClaim', icon: Boxes,
columns: ['名称', '命名空间', '状态', '大小', 'StorageClass'],
renderRow: (p, opts) => (
<TableRow key={p.name} className="border-slate-800 hover:bg-slate-800/30">
<TableCell className="font-medium text-slate-200">{p.name}</TableCell>
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{p.namespace}</Badge></TableCell>
<TableCell><Badge className={p.phase === 'Bound' ? 'bg-emerald-500/20 text-emerald-400' : 'bg-yellow-500/20 text-yellow-400'}>{p.phase || '-'}</Badge></TableCell>
<TableCell className="text-sm text-slate-400">{p.size || '-'}</TableCell>
<TableCell className="text-sm text-slate-400">{p.storage_class || '-'}</TableCell>
</TableRow>
),
},
statefulsets: {
title: 'StatefulSets', subtitle: '有状态应用', icon: Boxes,
columns: ['名称', '命名空间', '副本', '就绪', '镜像'],
renderRow: (s) => (
<TableRow key={s.name} className="border-slate-800 hover:bg-slate-800/30">
<TableCell className="font-medium text-slate-200">{s.name}</TableCell>
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{s.namespace}</Badge></TableCell>
<TableCell className="text-sm text-slate-400">{s.replicas || 0}</TableCell>
<TableCell className="text-sm text-slate-400">{s.ready || 0}</TableCell>
<TableCell className="text-xs text-slate-500 max-w-[200px] truncate">{s.image || '-'}</TableCell>
</TableRow>
),
},
daemonsets: {
title: 'DaemonSets', subtitle: '守护进程集', icon: Boxes,
columns: ['名称', '命名空间', '期望', '当前', '就绪', '镜像'],
renderRow: (d) => (
<TableRow key={d.name} className="border-slate-800 hover:bg-slate-800/30">
<TableCell className="font-medium text-slate-200">{d.name}</TableCell>
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{d.namespace}</Badge></TableCell>
<TableCell className="text-sm text-slate-400">{d.desired || 0}</TableCell>
<TableCell className="text-sm text-slate-400">{d.current || 0}</TableCell>
<TableCell className="text-sm text-slate-400">{d.ready || 0}</TableCell>
<TableCell className="text-xs text-slate-500 max-w-[200px] truncate">{d.image || '-'}</TableCell>
</TableRow>
),
},
};
export default function NamespaceResourcePage() {
const { clusterCode, namespace } = useParams<{ clusterCode: string; namespace: string }>();
const location = useLocation();
const navigate = useNavigate();
const { toast } = useToast();
const type = useMemo<ResourceType>(() => {
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<any[]>([]);
const [namespaces, setNamespaces] = useState<string[]>([]);
const [data, setData] = useState<any[]>([]);
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 <div className="text-slate-400 p-8 text-center">未知资源类型: {type}</div>;
return (
<div className="space-y-4">
{yamlItem && (
<YamlViewer
clusterCode={clusterCode || ''}
namespace={namespace || ''}
resource={yamlItem.resource}
name={yamlItem.name}
onClose={() => setYamlItem(null)}
onSaved={loadData}
/>
)}
<div className="flex items-center gap-2 text-sm text-slate-500">
<Link to="/kubernetes" className="hover:text-slate-300 transition-colors">集群</Link>
{clusterCode && <><ChevronRight className="h-3 w-3" /><Link to={`/kubernetes/${clusterCode}`} className="hover:text-slate-300 transition-colors">{clusterCode}</Link></>}
{namespace && <><ChevronRight className="h-3 w-3" /><Link to={`/kubernetes/${clusterCode}/namespace/${namespace}`} className="hover:text-slate-300 transition-colors">{namespace}</Link></>}
<ChevronRight className="h-3 w-3" /><span className="text-slate-300">{config.title}</span>
</div>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-100">{config.title}</h1>
<p className="text-sm text-slate-500 mt-1">{config.subtitle}</p>
</div>
<div className="flex items-center gap-2">
<Select value={clusterCode || ''} onValueChange={(v) => navigate(`/kubernetes/${v}/namespace/${namespace || 'default'}/${type}`)}>
<SelectTrigger className="w-48 h-9 bg-slate-800/50 border-slate-700 text-slate-200"><SelectValue placeholder="选择集群" /></SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">{clusters.map((c) => <SelectItem key={c.id} value={c.code || String(c.id)}>{c.name}</SelectItem>)}</SelectContent>
</Select>
<Select value={namespace || ''} onValueChange={(v) => navigate(`/kubernetes/${clusterCode}/namespace/${v}/${type}`)}>
<SelectTrigger className="w-40 h-9 bg-slate-800/50 border-slate-700 text-slate-200"><SelectValue placeholder="命名空间" /></SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700 max-h-[300px]">{namespaces.map((ns) => <SelectItem key={ns} value={ns}>{ns}</SelectItem>)}</SelectContent>
</Select>
<Button variant="outline" size="icon" className="h-9 w-9 border-slate-700 text-slate-400" onClick={loadData} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
</Button>
<Button variant="outline" size="sm" className="h-9 border-slate-700 text-slate-400 text-xs" onClick={handleExportCSV} title="导出CSV">
<Download className="h-3 w-3 mr-1" />CSV
</Button>
<Button variant="outline" size="sm" className="h-9 border-slate-700 text-slate-400 text-xs" onClick={handleExportJSON} title="导出JSON">
<Download className="h-3 w-3 mr-1" />JSON
</Button>
</div>
</div>
<div className="flex items-center gap-1 bg-slate-900/50 border border-slate-800 rounded-lg p-1 overflow-x-auto">
{sidebarResources.map((r) => (
<Link key={r.type} to={`/kubernetes/${clusterCode}/namespace/${namespace}/${r.type}`}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md transition-colors whitespace-nowrap ${r.type === type ? 'bg-blue-600/20 text-blue-400' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'}`}>
<r.icon className="h-3 w-3" />{r.label}
</Link>
))}
</div>
<div className="relative w-56">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<Input placeholder="搜索..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200" />
</div>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-0">
<Table>
<TableHeader><TableRow className="border-slate-800 hover:bg-transparent">{config.columns.map((col) => <TableHead key={col} className="text-slate-400">{col}</TableHead>)}</TableRow></TableHeader>
<TableBody>
{loading ? (
<TableRow><TableCell colSpan={config.columns.length} className="text-center text-slate-500 py-12"><Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />加载中...</TableCell></TableRow>
) : filtered.length === 0 ? (
<TableRow><TableCell colSpan={config.columns.length} className="text-center text-slate-500 py-12"><config.icon className="h-8 w-8 mx-auto mb-2 opacity-50" />暂无 {config.title}</TableCell></TableRow>
) : (
filtered.map((item) => config.renderRow(item, { clusterCode: clusterCode || '', namespace: namespace || '', onDetail: handleDetail, onYaml: handleYaml }))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
@@ -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<any[]>([]);
const [clusters, setClusters] = useState<any[]>([]);
const [namespaces, setNamespaces] = useState<string[]>([]);
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 (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-slate-500">
<Link to="/kubernetes" className="hover:text-slate-300">集群</Link>
{activeCluster && <><ChevronRight className="h-3 w-3" /><Link to={`/kubernetes/${activeCluster}`} className="hover:text-slate-300">{activeCluster}</Link></>}
<ChevronRight className="h-3 w-3" /><span className="text-slate-300">Pod 资源用量</span>
</div>
<div className="flex items-center justify-between flex-wrap gap-3">
<h2 className="text-lg font-semibold text-slate-100 flex items-center gap-2"><Activity className="h-5 w-5 text-blue-400" />Pod 资源用量</h2>
<div className="flex items-center gap-2">
<Select value={activeCluster} onValueChange={v => { setActiveCluster(v); navigate(`/kubernetes/${v}/metrics/pods`); }}>
<SelectTrigger className="w-44 h-8 text-xs bg-slate-800 border-slate-700"><SelectValue /></SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
{clusters.map((c: any) => <SelectItem key={c.id} value={c.code || String(c.id)} className="text-xs">{c.name || c.code}</SelectItem>)}
</SelectContent>
</Select>
<Select value={selectedNs} onValueChange={setSelectedNs}>
<SelectTrigger className="w-36 h-8 text-xs bg-slate-800 border-slate-700"><SelectValue placeholder="所有命名空间" /></SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectItem value="all" className="text-xs">所有命名空间</SelectItem>
{namespaces.map(n => <SelectItem key={n} value={n} className="text-xs">{n}</SelectItem>)}
</SelectContent>
</Select>
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300 text-xs" onClick={loadMetrics} disabled={loading}>
<RefreshCw className={`h-3 w-3 mr-1 ${loading ? 'animate-spin' : ''}`} />刷新
</Button>
</div>
</div>
{/* Summary cards */}
<div className="grid grid-cols-3 gap-3">
<Card className="bg-slate-900 border-slate-800"><CardContent className="p-3 text-center">
<p className="text-xs text-slate-500">Pod 数</p><p className="text-xl font-bold text-slate-200">{metrics.length}</p>
</CardContent></Card>
<Card className="bg-slate-900 border-slate-800"><CardContent className="p-3 text-center">
<p className="text-xs text-slate-500">总 CPU</p><p className="text-xl font-bold text-blue-400">{totalCPU}m</p>
</CardContent></Card>
<Card className="bg-slate-900 border-slate-800"><CardContent className="p-3 text-center">
<p className="text-xs text-slate-500">总内存</p><p className="text-xl font-bold text-emerald-400">{totalMem.toFixed(0)}Mi</p>
</CardContent></Card>
</div>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-0">
<Table>
<TableHeader><TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">Pod</TableHead>
<TableHead className="text-slate-400">命名空间</TableHead>
<TableHead className="text-slate-400">容器</TableHead>
<TableHead className="text-slate-400"><Cpu className="h-3 w-3 inline mr-1" />CPU</TableHead>
<TableHead className="text-slate-400"><HardDrive className="h-3 w-3 inline mr-1" />Memory</TableHead>
</TableRow></TableHeader>
<TableBody>
{rows.length === 0 ? (
<TableRow><TableCell colSpan={5} className="text-center text-slate-500 py-8">暂无数据{loading ? <span className="animate-spin inline-block ml-2">⏳</span> : ''}</TableCell></TableRow>
) : rows.map((r, i) => (
<TableRow key={`${r.pod}-${r.container}-${i}`} className="border-slate-800 hover:bg-slate-800/30">
<TableCell className="font-medium text-slate-200 text-sm">{r.pod}</TableCell>
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400 text-xs">{r.namespace}</Badge></TableCell>
<TableCell className="text-sm text-slate-400">{r.container}</TableCell>
<TableCell className="text-sm text-blue-400">{r.cpu}</TableCell>
<TableCell className="text-sm text-emerald-400">{r.memory}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { useParams } from 'react-router-dom';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -24,6 +25,7 @@ import {
} from './topology-helpers'; } from './topology-helpers';
export function TopologyView() { export function TopologyView() {
const { clusterCode } = useParams<{ clusterCode?: string }>();
const _confirm = useConfirm(); const _confirm = useConfirm();
const [clusters, setClusters] = useState<any[]>([]); const [clusters, setClusters] = useState<any[]>([]);
const [selectedClusterId, setSelectedClusterId] = useState<string>(''); const [selectedClusterId, setSelectedClusterId] = useState<string>('');
@@ -53,9 +55,17 @@ export function TopologyView() {
const res: any = await kubernetesApi.getClusters(); const res: any = await kubernetesApi.getClusters();
const list = res?.list || []; const list = res?.list || [];
setClusters(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([]); } } catch { setClusters([]); }
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, [clusterCode]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { loadClusters(); }, [loadClusters]); useEffect(() => { loadClusters(); }, [loadClusters]);
@@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
<div className="bg-slate-900 border border-slate-700 rounded-xl w-[90vw] max-w-4xl max-h-[80vh] flex flex-col shadow-2xl" onClick={e => e.stopPropagation()}>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-slate-700">
<div>
<h2 className="text-lg font-bold text-slate-100">{resource}/{name}</h2>
<p className="text-xs text-slate-500">{namespace} · {clusterCode}</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300" onClick={copyYaml}>
{copied ? <Check className="h-3 w-3 mr-1 text-emerald-400" /> : <Copy className="h-3 w-3 mr-1" />}
{copied ? '已复制' : '复制'}
</Button>
{!editing ? (
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300" onClick={() => setEditing(true)}>
编辑
</Button>
) : (
<>
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300" onClick={() => { setEditing(false); loadYaml(); }}>
取消
</Button>
<Button size="sm" className="h-8 bg-blue-600 hover:bg-blue-700" onClick={saveYaml} disabled={saving}>
{saving ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Save className="h-3 w-3 mr-1" />}
保存
</Button>
</>
)}
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400" onClick={onClose}><X className="h-4 w-4" /></Button>
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-auto p-4">
{loading ? (
<div className="flex items-center justify-center h-full"><Loader2 className="h-6 w-6 animate-spin text-slate-500" /></div>
) : editing ? (
<textarea
value={yaml}
onChange={e => setYaml(e.target.value)}
className="w-full h-full min-h-[500px] bg-slate-950 text-emerald-400 font-mono text-xs p-4 rounded border border-slate-700 focus:outline-none focus:border-blue-500 resize-none"
spellCheck={false}
/>
) : (
<pre className="text-emerald-400 font-mono text-xs whitespace-pre overflow-x-auto">{yaml}</pre>
)}
</div>
</div>
</div>
);
}
+67 -133
View File
@@ -3,37 +3,12 @@ import { NavLink, useLocation } from 'react-router-dom';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useThemeStore, useMenuStore } from '@/store'; import { useThemeStore, useMenuStore } from '@/store';
import { import {
LayoutDashboard, LayoutDashboard, Server, Cloud, Rocket, Bell, Monitor,
Server, CheckSquare, PlayCircle, Settings, ChevronDown, ChevronRight,
Cloud, Terminal, Cpu, Database, FileCode, Shield, Users,
Rocket, Menu, Network, CloudCog, Globe, MapPin, Key,
Bell, Brain, FileTerminal, Activity, BarChart3, GitBranch,
CheckSquare, Bot, AlertTriangle, Container, Boxes, Clock,
PlayCircle,
Settings,
ChevronDown,
ChevronRight,
Terminal,
Cpu,
Database,
FileCode,
Shield,
Users,
Menu,
X,
Network,
CloudCog,
Globe,
MapPin,
Key,
Brain,
FileTerminal,
Activity,
BarChart3,
GitBranch,
Monitor,
Bot,
AlertTriangle,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
@@ -49,9 +24,7 @@ interface MenuItem {
const menuItems: MenuItem[] = [ const menuItems: MenuItem[] = [
{ id: 'dashboard', name: '仪表盘', icon: LayoutDashboard, path: '/' }, { id: 'dashboard', name: '仪表盘', icon: LayoutDashboard, path: '/' },
{ {
id: 'assets', id: 'assets', name: '资产管理', icon: Server,
name: '资产管理',
icon: Server,
children: [ children: [
{ id: 'servers', name: '服务器管理', icon: Database, path: '/servers' }, { id: 'servers', name: '服务器管理', icon: Database, path: '/servers' },
{ id: 'cloud-accounts', name: '云账号管理', icon: Key, path: '/cloud-accounts' }, { id: 'cloud-accounts', name: '云账号管理', icon: Key, path: '/cloud-accounts' },
@@ -62,29 +35,50 @@ const menuItems: MenuItem[] = [
], ],
}, },
{ {
id: 'kubernetes', id: 'kubernetes', name: 'Kubernetes', icon: Cloud,
name: 'Kubernetes',
icon: Cloud,
children: [ children: [
{ id: 'clusters', name: '集群列表', icon: Cloud, path: '/clusters' }, { id: 'k8s-clusters', name: '集群列表', icon: Cloud, path: '/kubernetes' },
{ id: 'nodes', name: '节点管理', icon: Cpu, path: '/nodes' }, { id: 'k8s-nodes', name: '节点管理', icon: Cpu, path: '/nodes' },
{ id: 'workloads', name: '工作负载', icon: Database, path: '/workloads' }, {
{ id: 'topology', name: '架构拓扑', icon: Network, path: '/topology' }, id: 'k8s-workload', name: '工作负载', icon: Boxes,
children: [
{ id: 'k8s-deployments', name: 'Deployments', icon: Boxes, path: '/workloads/deployments' },
{ id: 'k8s-statefulsets', name: 'StatefulSets', icon: Boxes, path: '/workloads/statefulsets' },
{ id: 'k8s-daemonsets', name: 'DaemonSets', icon: Boxes, path: '/workloads/daemonsets' },
{ id: 'k8s-cronjobs', name: 'CronJobs', icon: Clock, path: '/workloads/cronjobs' },
{ id: 'k8s-pods', name: 'Pods', icon: Container, path: '/workloads/pods' },
],
},
{
id: 'k8s-network', name: '网络', icon: Network,
children: [
{ id: 'k8s-services', name: 'Services', icon: Network, path: '/workloads/services' },
{ id: 'k8s-ingresses', name: 'Ingresses', icon: Globe, path: '/workloads/ingresses' },
],
},
{
id: 'k8s-config', name: '配置中心', icon: Settings,
children: [
{ id: 'k8s-configmaps', name: 'ConfigMaps', icon: FileCode, path: '/workloads/configmaps' },
{ id: 'k8s-secrets', name: 'Secrets', icon: Shield, path: '/workloads/secrets' },
],
},
{ id: 'k8s-storage', name: '存储', icon: Database, path: '/workloads/pvcs' },
{ id: 'k8s-metrics', name: '资源用量', icon: Activity, path: '/workloads/metrics' },
{ id: 'k8s-events', name: '事件', icon: Bell, path: '/workloads/events' },
{ id: 'k8s-capacity', name: '容量分析', icon: BarChart3, path: '/workloads/capacity' },
{ id: 'k8s-topology', name: '架构拓扑', icon: Network, path: '/topology' },
], ],
}, },
{ {
id: 'deploy', id: 'deploy', name: '应用发布', icon: Rocket,
name: '应用发布',
icon: Rocket,
children: [ children: [
{ id: 'pipelines', name: '流水线管理', icon: GitBranch, path: '/pipelines' }, { id: 'pipelines', name: '流水线管理', icon: GitBranch, path: '/pipelines' },
{ id: 'releases', name: '发布管理', icon: Rocket, path: '/releases' }, { id: 'releases', name: '发布管理', icon: Rocket, path: '/releases' },
], ],
}, },
{ {
id: 'monitor', id: 'monitor', name: '监控中心', icon: Bell,
name: '监控中心',
icon: Bell,
children: [ children: [
{ id: 'grafana', name: '监控大屏', icon: Monitor, path: '/dashboards' }, { id: 'grafana', name: '监控大屏', icon: Monitor, path: '/dashboards' },
{ id: 'monitor-center', name: '资源监控', icon: Activity, path: '/monitor-center' }, { id: 'monitor-center', name: '资源监控', icon: Activity, path: '/monitor-center' },
@@ -94,9 +88,7 @@ const menuItems: MenuItem[] = [
], ],
}, },
{ {
id: 'automation', id: 'automation', name: '自动化运维', icon: PlayCircle,
name: '自动化运维',
icon: PlayCircle,
children: [ children: [
{ id: 'cronjobs', name: '定时任务', icon: PlayCircle, path: '/cronjobs' }, { id: 'cronjobs', name: '定时任务', icon: PlayCircle, path: '/cronjobs' },
{ id: 'scripts', name: '脚本库', icon: FileCode, path: '/scripts' }, { id: 'scripts', name: '脚本库', icon: FileCode, path: '/scripts' },
@@ -104,9 +96,7 @@ const menuItems: MenuItem[] = [
], ],
}, },
{ {
id: 'aiops', id: 'aiops', name: 'AIOps智能', icon: Brain,
name: 'AIOps智能',
icon: Brain,
children: [ children: [
{ id: 'ai-assistant', name: '智能助手', icon: Bot, path: '/ai-assistant' }, { id: 'ai-assistant', name: '智能助手', icon: Bot, path: '/ai-assistant' },
{ id: 'ai-alert-analyzer', name: '告警分析', icon: AlertTriangle, path: '/ai-alert-analyzer' }, { id: 'ai-alert-analyzer', name: '告警分析', icon: AlertTriangle, path: '/ai-alert-analyzer' },
@@ -115,9 +105,7 @@ const menuItems: MenuItem[] = [
}, },
{ id: 'approval', name: '资源申请', icon: CheckSquare, path: '/approval' }, { id: 'approval', name: '资源申请', icon: CheckSquare, path: '/approval' },
{ {
id: 'system', id: 'system', name: '系统管理', icon: Settings,
name: '系统管理',
icon: Settings,
children: [ children: [
{ id: 'users', name: '用户管理', icon: Users, path: '/users' }, { id: 'users', name: '用户管理', icon: Users, path: '/users' },
{ id: 'roles', name: '角色权限', icon: Shield, path: '/roles' }, { id: 'roles', name: '角色权限', icon: Shield, path: '/roles' },
@@ -135,7 +123,11 @@ function MenuItemComponent({ item, depth = 0 }: { item: MenuItem; depth?: number
const { sidebarCollapsed } = useThemeStore(); const { sidebarCollapsed } = useThemeStore();
const hasChildren = item.children && item.children.length > 0; const hasChildren = item.children && item.children.length > 0;
const isOpen = openMenus.includes(item.id); const isOpen = openMenus.includes(item.id);
const isActive = item.path === location.pathname || activeMenu === item.id; const isActive = item.path
? location.pathname === item.path || location.pathname.startsWith(item.path + '/')
: (item.id === 'kubernetes' && location.pathname.startsWith('/kubernetes')) ||
(item.id === 'k8s-workload' && location.pathname.startsWith('/workloads')) ||
activeMenu === item.id;
const handleClick = () => { const handleClick = () => {
if (hasChildren) { if (hasChildren) {
@@ -151,38 +143,22 @@ function MenuItemComponent({ item, depth = 0 }: { item: MenuItem; depth?: number
<Button <Button
variant={isActive ? 'secondary' : 'ghost'} variant={isActive ? 'secondary' : 'ghost'}
size="icon" size="icon"
className={cn( className={cn('w-10 h-10 mx-auto mb-1', isActive && 'bg-blue-600/20 text-blue-400')}
'w-10 h-10 mx-auto mb-1',
isActive && 'bg-blue-600/20 text-blue-400'
)}
asChild={!hasChildren} asChild={!hasChildren}
> >
{hasChildren ? ( {hasChildren ? (
<span onClick={handleClick}> <span onClick={handleClick}><item.icon className="h-5 w-5" /></span>
<item.icon className="h-5 w-5" />
</span>
) : ( ) : (
<NavLink to={item.path || '#'} onClick={handleClick}> <NavLink to={item.path || '#'} onClick={handleClick}><item.icon className="h-5 w-5" /></NavLink>
<item.icon className="h-5 w-5" />
</NavLink>
)} )}
</Button> </Button>
{/* Tooltip */}
<div className="absolute left-full top-1/2 -translate-y-1/2 ml-2 px-3 py-1.5 bg-slate-800 text-slate-100 text-sm rounded-md opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all whitespace-nowrap z-50 border border-slate-700"> <div className="absolute left-full top-1/2 -translate-y-1/2 ml-2 px-3 py-1.5 bg-slate-800 text-slate-100 text-sm rounded-md opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all whitespace-nowrap z-50 border border-slate-700">
{item.name} {item.name}
{hasChildren && ( {hasChildren && (
<div className="absolute left-0 top-0 mt-2 ml-2 w-48 bg-slate-800 rounded-md border border-slate-700 shadow-lg py-1"> <div className="absolute left-0 top-0 mt-2 ml-2 w-48 bg-slate-800 rounded-md border border-slate-700 shadow-lg py-1">
{item.children?.map((child) => ( {item.children?.map((child) => (
<NavLink <NavLink key={child.id} to={child.path || '#'}
key={child.id} className={({ isActive }) => cn('block px-4 py-2 text-sm hover:bg-slate-700 transition-colors', isActive ? 'text-blue-400 bg-blue-600/10' : 'text-slate-300')}>
to={child.path || '#'}
className={({ isActive }) =>
cn(
'block px-4 py-2 text-sm hover:bg-slate-700 transition-colors',
isActive ? 'text-blue-400 bg-blue-600/10' : 'text-slate-300'
)
}
>
{child.name} {child.name}
</NavLink> </NavLink>
))} ))}
@@ -194,46 +170,24 @@ function MenuItemComponent({ item, depth = 0 }: { item: MenuItem; depth?: number
} }
return ( return (
<div className={cn('mb-0.5', depth > 0 && 'ml-4')}> <div className={cn('mb-0.5', depth > 0 && 'ml-3')}>
{hasChildren ? ( {hasChildren ? (
<> <>
<Button <Button
variant={isActive ? 'secondary' : 'ghost'} variant={isActive ? 'secondary' : 'ghost'}
className={cn( className={cn('w-full justify-between h-8 px-2.5 text-xs font-normal', isActive ? 'bg-blue-600/20 text-blue-400 hover:bg-blue-600/30' : 'text-slate-300 hover:text-slate-100 hover:bg-slate-800')}
'w-full justify-between h-9 px-3 text-sm font-normal',
isActive ? 'bg-blue-600/20 text-blue-400 hover:bg-blue-600/30' : 'text-slate-300 hover:text-slate-100 hover:bg-slate-800'
)}
onClick={handleClick} onClick={handleClick}
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-1.5">
<item.icon className="h-4 w-4" /> <item.icon className="h-3.5 w-3.5" />
<span>{item.name}</span> <span>{item.name}</span>
</span> </span>
{isOpen ? ( {isOpen ? <ChevronDown className="h-3 w-3 text-slate-500" /> : <ChevronRight className="h-3 w-3 text-slate-500" />}
<ChevronDown className="h-4 w-4 text-slate-500" />
) : (
<ChevronRight className="h-4 w-4 text-slate-500" />
)}
</Button> </Button>
{isOpen && ( {isOpen && (
<div className="mt-0.5 space-y-0.5"> <div className="mt-0.5 space-y-0.5">
{item.children?.map((child) => ( {item.children?.map((child) => (
<NavLink <MenuItemComponent key={child.id} item={child} depth={depth + 1} />
key={child.id}
to={child.path || '#'}
onClick={() => setActiveMenu(child.id)}
className={({ isActive }) =>
cn(
'flex items-center gap-2 px-3 py-2 text-sm rounded-md transition-colors',
isActive
? 'bg-blue-600/20 text-blue-400'
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800'
)
}
>
<child.icon className="h-3.5 w-3.5" />
<span>{child.name}</span>
</NavLink>
))} ))}
</div> </div>
)} )}
@@ -243,15 +197,11 @@ function MenuItemComponent({ item, depth = 0 }: { item: MenuItem; depth?: number
to={item.path || '#'} to={item.path || '#'}
onClick={handleClick} onClick={handleClick}
className={({ isActive }) => className={({ isActive }) =>
cn( cn('flex items-center gap-1.5 h-8 px-2.5 text-xs rounded-md transition-colors',
'flex items-center gap-2 px-3 py-2 text-sm rounded-md transition-colors', isActive ? 'bg-blue-600/20 text-blue-400' : 'text-slate-400 hover:text-slate-100 hover:bg-slate-800')
isActive
? 'bg-blue-600/20 text-blue-400'
: 'text-slate-300 hover:text-slate-100 hover:bg-slate-800'
)
} }
> >
<item.icon className="h-4 w-4" /> <item.icon className="h-3.5 w-3.5" />
<span>{item.name}</span> <span>{item.name}</span>
</NavLink> </NavLink>
)} )}
@@ -261,15 +211,8 @@ function MenuItemComponent({ item, depth = 0 }: { item: MenuItem; depth?: number
export function Sidebar() { export function Sidebar() {
const { sidebarCollapsed, toggleSidebar } = useThemeStore(); const { sidebarCollapsed, toggleSidebar } = useThemeStore();
return ( return (
<aside <aside className={cn('fixed left-0 top-0 z-40 h-screen bg-slate-900 border-r border-slate-800 transition-all duration-300', sidebarCollapsed ? 'w-16' : 'w-64')}>
className={cn(
'fixed left-0 top-0 z-40 h-screen bg-slate-900 border-r border-slate-800 transition-all duration-300',
sidebarCollapsed ? 'w-16' : 'w-64'
)}
>
{/* Logo */}
<div className="flex items-center justify-between h-14 px-4 border-b border-slate-800"> <div className="flex items-center justify-between h-14 px-4 border-b border-slate-800">
{!sidebarCollapsed && ( {!sidebarCollapsed && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -285,23 +228,14 @@ export function Sidebar() {
</div> </div>
)} )}
{!sidebarCollapsed && ( {!sidebarCollapsed && (
<Button <Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100" onClick={toggleSidebar}>
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-slate-100"
onClick={toggleSidebar}
>
<Menu className="h-4 w-4" /> <Menu className="h-4 w-4" />
</Button> </Button>
)} )}
</div> </div>
{/* Menu */}
<ScrollArea className="h-[calc(100vh-3.5rem)]"> <ScrollArea className="h-[calc(100vh-3.5rem)]">
<div className={cn('p-2', sidebarCollapsed && 'px-1')}> <div className={cn('p-1.5', sidebarCollapsed && 'px-1')}>
{menuItems.map((item) => ( {menuItems.map((item) => <MenuItemComponent key={item.id} item={item} />)}
<MenuItemComponent key={item.id} item={item} />
))}
</div> </div>
</ScrollArea> </ScrollArea>
</aside> </aside>
@@ -661,7 +661,7 @@ export default function PrometheusDatasourceManager() {
<SelectValue placeholder="Choose" /> <SelectValue placeholder="Choose" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="">无</SelectItem> <SelectItem value="__none">无</SelectItem>
{sources.filter(s => s.id !== editingId).map(s => ( {sources.filter(s => s.id !== editingId).map(s => (
<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem> <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
))} ))}
@@ -208,7 +208,7 @@ export function ServerForm({
<SelectValue placeholder="选择已有凭证..." /> <SelectValue placeholder="选择已有凭证..." />
</SelectTrigger> </SelectTrigger>
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"> <SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
<SelectItem value="">手动输入(不关联凭证)</SelectItem> <SelectItem value="__manual">手动输入(不关联凭证)</SelectItem>
{credentials.map((cred) => ( {credentials.map((cred) => (
<SelectItem key={cred.id} value={String(cred.id)}> <SelectItem key={cred.id} value={String(cred.id)}>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
+71 -1
View File
@@ -219,7 +219,7 @@ export const kubernetesApi = {
// === HPA 管理 (Logan.Li) === // === HPA 管理 (Logan.Li) ===
getHPAs: async (clusterCode: string, namespace?: string) => { getHPAs: async (clusterCode: string, namespace?: string) => {
if (namespace) return http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/hpas`); if (namespace) return http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/hpas`);
return http.get(`/kubernetes-clusters/${clusterCode}/hpas`); // fallback old route return http.get(`/kubernetes/${clusterCode}/hpas`); // AllNS route
}, },
createHPA: async (clusterCode: string, data: { name: string; namespace: string; targetKind: string; targetName: string; minReplicas: number; maxReplicas: number; cpuPercent: number; memPercent: number }) => { createHPA: async (clusterCode: string, data: { name: string; namespace: string; targetKind: string; targetName: string; minReplicas: number; maxReplicas: number; cpuPercent: number; memPercent: number }) => {
return http.post(`/kubernetes/${clusterCode}/namespace/${data.namespace}/hpas`, data); return http.post(`/kubernetes/${clusterCode}/namespace/${data.namespace}/hpas`, data);
@@ -270,6 +270,76 @@ export const kubernetesApi = {
getNamespaces: async (clusterCode: string) => { getNamespaces: async (clusterCode: string) => {
return http.get(`/kubernetes/${clusterCode}/namespaces`); return http.get(`/kubernetes/${clusterCode}/namespaces`);
}, },
// === All-namespace listing (Logan.Li) ===
getAllDeployments: async (clusterCode: string) => {
return http.get(`/kubernetes/${clusterCode}/deployments`);
},
getAllPods: async (clusterCode: string) => {
return http.get(`/kubernetes/${clusterCode}/pods`);
},
// === PVC (存储) ===
getPVCs: async (clusterCode: string, namespace: string) => {
return http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/pvcs`);
},
deletePVC: async (clusterCode: string, namespace: string, name: string) => {
return http.delete(`/kubernetes/${clusterCode}/namespace/${namespace}/pvcs/${name}`);
},
// === StatefulSet / DaemonSet ===
getStatefulSets: async (clusterCode: string, namespace: string) => {
return http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/statefulsets`);
},
getDaemonSets: async (clusterCode: string, namespace: string) => {
return http.get(`/kubernetes/${clusterCode}/namespace/${namespace}/daemonsets`);
},
// === YAML ===
getResourceYaml: async (clusterCode: string, namespace: string, resource: string, name: string) => {
return http.getRaw(`/kubernetes/${clusterCode}/namespace/${namespace}/${resource}/${name}/yaml`);
},
updateResourceYaml: async (clusterCode: string, namespace: string, resource: string, name: string, yaml: string) => {
return http.putRaw(`/kubernetes/${clusterCode}/namespace/${namespace}/${resource}/${name}/yaml`, yaml, 'application/yaml');
},
// === Export CSV ===
exportAsCSV: (filename: string, data: any[]) => {
if (data.length === 0) return;
const headers = Object.keys(data[0]);
const csv = [headers.join(','), ...data.map((row: any) => headers.map(h => JSON.stringify(row[h] ?? '')).join(','))].join('\n');
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filename; a.click();
},
exportAsJSON: (filename: string, data: any[]) => {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filename; a.click();
},
// === 🆕 Pod Metrics (kubectl top pods) ===
getPodMetrics: async (clusterCode: string, namespace?: string) => {
const params = namespace ? { namespace } : undefined;
return http.get(`/kubernetes/${clusterCode}/metrics/pods`, params);
},
// === 🆕 Events ===
getEvents: async (clusterCode: string, namespace?: string, type?: string) => {
const params: any = {};
if (namespace) params.namespace = namespace;
if (type) params.type = type;
return http.get(`/kubernetes/${clusterCode}/events`, params);
},
// === 🆕 Capacity Analysis ===
getCapacity: async (clusterCode: string, namespace?: string) => {
const params = namespace ? { namespace } : undefined;
return http.get(`/kubernetes/${clusterCode}/capacity`, params);
},
// === 🆕 YAML Apply ===
applyYAML: async (clusterCode: string, yaml: string) => {
return http.post(`/kubernetes/${clusterCode}/yaml/apply`, { yaml });
},
}; };
// ==================== 告警 API ==================== // ==================== 告警 API ====================
+21 -1
View File
@@ -36,7 +36,7 @@ async function request<T = any>(
} }
// 获取 token // 获取 token
const token = useUserStore.getState().token; const token = useUserStore.getState().token || localStorage.getItem('token');
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -77,6 +77,26 @@ export const http = {
request<T>('PUT', url, body), request<T>('PUT', url, body),
delete: <T = any>(url: string, body?: any) => delete: <T = any>(url: string, body?: any) =>
request<T>('DELETE', url, body), request<T>('DELETE', url, body),
getRaw: async (url: string) => {
const tk = useUserStore.getState().token;
const baseUrl = import.meta.env.VITE_API_BASE_URL || '';
const resp = await fetch(`${baseUrl}/api/v1${url}`, { headers: { Authorization: `Bearer ${tk}` } });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
return resp.text();
},
putRaw: async (url: string, body: string, contentType: string) => {
const tk = useUserStore.getState().token;
const baseUrl = import.meta.env.VITE_API_BASE_URL || '';
const resp = await fetch(`${baseUrl}/api/v1${url}`, {
method: 'PUT',
headers: { Authorization: `Bearer ${tk}`, 'Content-Type': contentType },
body,
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
return resp.json();
},
}; };
export default http; export default http;
+1 -1
View File
@@ -35,7 +35,7 @@ export const useUserStore = create<UserState>()(
setUser: (user) => set({ user }), setUser: (user) => set({ user }),
setToken: (token) => set({ token }), setToken: (token) => set({ token }),
login: (user, token) => set({ user, token, isAuthenticated: true }), login: (user, token) => set({ user, token, isAuthenticated: true }),
logout: () => set({ user: null, token: null, isAuthenticated: false }), logout: () => { localStorage.removeItem('token'); set({ user: null, token: null, isAuthenticated: false }); },
}), }),
{ {
name: 'user-storage', name: 'user-storage',
+2 -1
View File
@@ -319,10 +319,11 @@ cmd_docker() {
banner "========== Docker Compose 启动 ==========" banner "========== Docker Compose 启动 =========="
$DOCKER_COMPOSE up -d --build $DOCKER_COMPOSE up -d --build
echo "" echo ""
# 清理历史 build cache,防止占满磁盘 # 清理历史 build cache 和悬空镜像,防止占满磁盘
info "清理历史构建缓存..." info "清理历史构建缓存..."
local before_prune=$(df -h / | awk 'NR==2{print $5}') local before_prune=$(df -h / | awk 'NR==2{print $5}')
docker builder prune -f 2>/dev/null || true docker builder prune -f 2>/dev/null || true
docker image prune -f 2>/dev/null || true
local after_prune=$(df -h / | awk 'NR==2{print $5}') local after_prune=$(df -h / | awk 'NR==2{print $5}')
info "磁盘使用: ${before_prune} → ${after_prune}" info "磁盘使用: ${before_prune} → ${after_prune}"
echo "" echo ""