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:
1 parent
cac1414917
commit
a3cd0d3910
29 files changed
+2954
-180
No files matched your search
+4
-2
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 告警路由
|
// 告警路由
|
||||||
|
|||||||
@@ -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
@@ -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 />} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 ====================
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
|||||||
Reference in new issue
Block a user