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
|
||||
RUN sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories
|
||||
|
||||
# 安装运行时依赖(curl=健康检查,openssh-client=SSH远程安装agent)
|
||||
RUN apk --no-cache add ca-certificates tzdata curl openssh-client
|
||||
# 安装运行时依赖(bash=kubectl交互终端,curl=健康检查,openssh-client=SSH远程安装agent,kubectl=集群命令行管理)
|
||||
RUN apk --no-cache add ca-certificates tzdata bash curl openssh-client && \
|
||||
curl -sLO "https://dl.k8s.io/release/$(curl -sL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" && \
|
||||
chmod +x kubectl && mv kubectl /usr/local/bin/
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ require (
|
||||
github.com/bytedance/sonic v1.9.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||
github.com/creack/pty v1.1.24 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||
|
||||
@@ -14,6 +14,8 @@ github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// Convert to simplified response
|
||||
// Attempt to fetch metrics (best-effort, metrics-server may not be installed)
|
||||
metrics := map[string]k8sclient.NodeMetricsItem{}
|
||||
if m, err := client.GetNodeMetrics(); err == nil {
|
||||
for _, item := range m.Items {
|
||||
metrics[item.Metadata.Name] = item
|
||||
}
|
||||
}
|
||||
|
||||
type NodeSummary struct {
|
||||
Name string `json:"name"`
|
||||
IP string `json:"ip"`
|
||||
@@ -316,6 +323,9 @@ func (h *KubernetesHandler) ListNodes(c *gin.Context) {
|
||||
CPU int `json:"cpu"`
|
||||
Memory int64 `json:"memory_mb"`
|
||||
MaxPods int `json:"max_pods"`
|
||||
// Metrics (optional, omitted if empty)
|
||||
CPUUsageM string `json:"cpu_usage_m,omitempty"` // e.g. "122m"
|
||||
MemUsageMi string `json:"mem_usage_mi,omitempty"` // e.g. "2105Mi"
|
||||
}
|
||||
result := []NodeSummary{}
|
||||
for _, n := range nodes.Items {
|
||||
@@ -340,7 +350,7 @@ func (h *KubernetesHandler) ListNodes(c *gin.Context) {
|
||||
break
|
||||
}
|
||||
}
|
||||
result = append(result, NodeSummary{
|
||||
s := NodeSummary{
|
||||
Name: n.Metadata.Name,
|
||||
IP: ip,
|
||||
Role: role,
|
||||
@@ -350,7 +360,13 @@ func (h *KubernetesHandler) ListNodes(c *gin.Context) {
|
||||
CPU: parseIntResource(n.Status.Capacity["cpu"]),
|
||||
Memory: parseMemResource(n.Status.Capacity["memory"]),
|
||||
MaxPods: parseIntResource(n.Status.Capacity["pods"]),
|
||||
})
|
||||
}
|
||||
if m, ok := metrics[n.Metadata.Name]; ok {
|
||||
// K8s metrics API returns CPU in nanocores ("80410173n"), memory in Ki ("2154292Ki")
|
||||
s.CPUUsageM = formatCpuFromNano(m.Usage["cpu"])
|
||||
s.MemUsageMi = formatMemFromKi(m.Usage["memory"])
|
||||
}
|
||||
result = append(result, s)
|
||||
}
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "success", Data: result})
|
||||
}
|
||||
@@ -1455,13 +1471,25 @@ func (h *KubernetesHandler) listDeploymentsWithNS(c *gin.Context, ns string) {
|
||||
deps, err := client.ListDeployments()
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return }
|
||||
type DepSummary struct {
|
||||
Namespace, Name string; Replicas, AvailableReplicas, ReadyReplicas int; Image string
|
||||
Namespace string `json:"namespace"`
|
||||
Name string `json:"name"`
|
||||
Replicas int `json:"replicas"`
|
||||
AvailableReplicas int `json:"available_replicas"`
|
||||
ReadyReplicas int `json:"ready_replicas"`
|
||||
Image string `json:"image"`
|
||||
}
|
||||
result := []DepSummary{}
|
||||
for _, d := range deps.Items {
|
||||
if d.Metadata.Namespace != ns { continue }
|
||||
image := ""; if len(d.Spec.Template.Spec.Containers) > 0 { image = d.Spec.Template.Spec.Containers[0].Image }
|
||||
result = append(result, DepSummary{d.Metadata.Namespace, d.Metadata.Name, d.Spec.Replicas, d.Status.AvailableReplicas, d.Status.ReadyReplicas, image})
|
||||
result = append(result, DepSummary{
|
||||
Namespace: d.Metadata.Namespace,
|
||||
Name: d.Metadata.Name,
|
||||
Replicas: d.Spec.Replicas,
|
||||
AvailableReplicas: d.Status.AvailableReplicas,
|
||||
ReadyReplicas: d.Status.ReadyReplicas,
|
||||
Image: image,
|
||||
})
|
||||
}
|
||||
c.JSON(200, models.Response{Code: 200, Data: result})
|
||||
}
|
||||
@@ -1472,13 +1500,29 @@ func (h *KubernetesHandler) listPodsWithNS(c *gin.Context, ns string) {
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return }
|
||||
pods, err := client.ListPods()
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return }
|
||||
type PodSummary struct { Namespace, Name, Status, NodeName, IP string; RestartCount int; Image string }
|
||||
type PodSummary struct {
|
||||
Namespace string `json:"namespace"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
NodeName string `json:"node_name"`
|
||||
IP string `json:"ip"`
|
||||
RestartCount int `json:"restart_count"`
|
||||
Image string `json:"image"`
|
||||
}
|
||||
result := []PodSummary{}
|
||||
for _, p := range pods.Items {
|
||||
if p.Metadata.Namespace != ns { continue }
|
||||
image := ""; if len(p.Spec.Containers) > 0 { image = p.Spec.Containers[0].Image }
|
||||
restarts := 0; if len(p.Status.ContainerStatuses) > 0 { restarts = p.Status.ContainerStatuses[0].RestartCount }
|
||||
result = append(result, PodSummary{p.Metadata.Namespace, p.Metadata.Name, p.Status.Phase, p.Spec.NodeName, p.Status.PodIP, restarts, image})
|
||||
result = append(result, PodSummary{
|
||||
Namespace: p.Metadata.Namespace,
|
||||
Name: p.Metadata.Name,
|
||||
Status: p.Status.Phase,
|
||||
NodeName: p.Spec.NodeName,
|
||||
IP: p.Status.PodIP,
|
||||
RestartCount: restarts,
|
||||
Image: image,
|
||||
})
|
||||
}
|
||||
c.JSON(200, models.Response{Code: 200, Data: result})
|
||||
}
|
||||
@@ -1489,11 +1533,23 @@ func (h *KubernetesHandler) listServicesWithNS(c *gin.Context, ns string) {
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return }
|
||||
svcs, err := client.ListServices()
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return }
|
||||
type SvcSummary struct { Namespace, Name, Type, ClusterIP string; Ports int }
|
||||
type SvcSummary struct {
|
||||
Namespace string `json:"namespace"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
ClusterIP string `json:"cluster_ip"`
|
||||
Ports int `json:"ports"`
|
||||
}
|
||||
result := []SvcSummary{}
|
||||
for _, s := range svcs.Items {
|
||||
if s.Metadata.Namespace != ns { continue }
|
||||
result = append(result, SvcSummary{s.Metadata.Namespace, s.Metadata.Name, s.Spec.Type, s.Spec.ClusterIP, len(s.Spec.Ports)})
|
||||
result = append(result, SvcSummary{
|
||||
Namespace: s.Metadata.Namespace,
|
||||
Name: s.Metadata.Name,
|
||||
Type: s.Spec.Type,
|
||||
ClusterIP: s.Spec.ClusterIP,
|
||||
Ports: len(s.Spec.Ports),
|
||||
})
|
||||
}
|
||||
c.JSON(200, models.Response{Code: 200, Data: result})
|
||||
}
|
||||
@@ -1504,13 +1560,23 @@ func (h *KubernetesHandler) listIngressesWithNS(c *gin.Context, ns string) {
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return }
|
||||
ings, err := client.ListIngresses()
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return }
|
||||
type IngSummary struct { Namespace, Name string; Hosts []string; TLS bool }
|
||||
type IngSummary struct {
|
||||
Namespace string `json:"namespace"`
|
||||
Name string `json:"name"`
|
||||
Hosts []string `json:"hosts"`
|
||||
TLS bool `json:"tls"`
|
||||
}
|
||||
result := []IngSummary{}
|
||||
for _, ing := range ings.Items {
|
||||
if ing.Metadata.Namespace != ns { continue }
|
||||
hosts := []string{}
|
||||
for _, rule := range ing.Spec.Rules { if rule.Host != "" { hosts = append(hosts, rule.Host) } }
|
||||
result = append(result, IngSummary{ing.Metadata.Namespace, ing.Metadata.Name, hosts, len(ing.Spec.TLS) > 0})
|
||||
result = append(result, IngSummary{
|
||||
Namespace: ing.Metadata.Namespace,
|
||||
Name: ing.Metadata.Name,
|
||||
Hosts: hosts,
|
||||
TLS: len(ing.Spec.TLS) > 0,
|
||||
})
|
||||
}
|
||||
c.JSON(200, models.Response{Code: 200, Data: result})
|
||||
}
|
||||
@@ -1521,9 +1587,40 @@ func (h *KubernetesHandler) listHPAsWithNS(c *gin.Context, ns string) {
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return }
|
||||
hpas, err := client.ListHPAs()
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return }
|
||||
filtered := []k8sclient.HPAItem{}
|
||||
for _, h := range hpas.Items { if h.Metadata.Namespace == ns { filtered = append(filtered, h) } }
|
||||
c.JSON(200, models.Response{Code: 200, Data: filtered})
|
||||
type HPASummary struct {
|
||||
Namespace string `json:"namespace"`
|
||||
Name string `json:"name"`
|
||||
TargetKind string `json:"target_kind"`
|
||||
TargetName string `json:"target_name"`
|
||||
MinReplicas int `json:"min_replicas"`
|
||||
MaxReplicas int `json:"max_replicas"`
|
||||
CPUPercent int `json:"cpu_percent"`
|
||||
MemPercent int `json:"mem_percent"`
|
||||
CurrentReplicas int `json:"current_replicas"`
|
||||
}
|
||||
result := []HPASummary{}
|
||||
for _, h := range hpas.Items {
|
||||
if h.Metadata.Namespace != ns { continue }
|
||||
cpu := 0; mem := 0
|
||||
for _, m := range h.Spec.Metrics {
|
||||
if m.Resource != nil {
|
||||
if m.Resource.Name == "cpu" { cpu = m.Resource.Target.AverageUtilization }
|
||||
if m.Resource.Name == "memory" { mem = m.Resource.Target.AverageUtilization }
|
||||
}
|
||||
}
|
||||
result = append(result, HPASummary{
|
||||
Namespace: h.Metadata.Namespace,
|
||||
Name: h.Metadata.Name,
|
||||
TargetKind: h.Spec.ScaleTargetRef.Kind,
|
||||
TargetName: h.Spec.ScaleTargetRef.Name,
|
||||
MinReplicas: h.Spec.MinReplicas,
|
||||
MaxReplicas: h.Spec.MaxReplicas,
|
||||
CPUPercent: cpu,
|
||||
MemPercent: mem,
|
||||
CurrentReplicas: h.Status.CurrentReplicas,
|
||||
})
|
||||
}
|
||||
c.JSON(200, models.Response{Code: 200, Data: result})
|
||||
}
|
||||
func (h *KubernetesHandler) createHPAWithNS(c *gin.Context, ns string) {
|
||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
||||
@@ -1661,6 +1758,118 @@ func (h *KubernetesHandler) execInPodWithNS(c *gin.Context, ns string) {
|
||||
c.JSON(200, models.Response{Code: 200, Data: result})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// All-namespace handlers (clusterCode-based, no namespace filter)
|
||||
// ============================================================================
|
||||
|
||||
// ListDeploymentsAllNS lists all deployments across all namespaces using clusterCode.
|
||||
func (h *KubernetesHandler) ListDeploymentsAllNS(c *gin.Context) {
|
||||
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
|
||||
if clusterID == 0 { c.JSON(500, models.Response{Code: 500, Message: "cluster not found"}); return }
|
||||
client, _, err := h.getK8sClient(clusterID)
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return }
|
||||
deps, err := client.ListDeployments()
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return }
|
||||
type DepSummary struct {
|
||||
Namespace string `json:"namespace"`
|
||||
Name string `json:"name"`
|
||||
Replicas int `json:"replicas"`
|
||||
AvailableReplicas int `json:"available_replicas"`
|
||||
ReadyReplicas int `json:"ready_replicas"`
|
||||
Image string `json:"image"`
|
||||
}
|
||||
result := []DepSummary{}
|
||||
for _, d := range deps.Items {
|
||||
image := ""; if len(d.Spec.Template.Spec.Containers) > 0 { image = d.Spec.Template.Spec.Containers[0].Image }
|
||||
result = append(result, DepSummary{
|
||||
Namespace: d.Metadata.Namespace,
|
||||
Name: d.Metadata.Name,
|
||||
Replicas: d.Spec.Replicas,
|
||||
AvailableReplicas: d.Status.AvailableReplicas,
|
||||
ReadyReplicas: d.Status.ReadyReplicas,
|
||||
Image: image,
|
||||
})
|
||||
}
|
||||
c.JSON(200, models.Response{Code: 200, Data: result})
|
||||
}
|
||||
|
||||
// ListPodsAllNS lists all pods across all namespaces using clusterCode.
|
||||
func (h *KubernetesHandler) ListPodsAllNS(c *gin.Context) {
|
||||
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
|
||||
if clusterID == 0 { c.JSON(500, models.Response{Code: 500, Message: "cluster not found"}); return }
|
||||
client, _, err := h.getK8sClient(clusterID)
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return }
|
||||
pods, err := client.ListPods()
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return }
|
||||
type PodSummary struct {
|
||||
Namespace string `json:"namespace"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
NodeName string `json:"node_name"`
|
||||
IP string `json:"ip"`
|
||||
RestartCount int `json:"restart_count"`
|
||||
Image string `json:"image"`
|
||||
}
|
||||
result := []PodSummary{}
|
||||
for _, p := range pods.Items {
|
||||
image := ""; if len(p.Spec.Containers) > 0 { image = p.Spec.Containers[0].Image }
|
||||
restarts := 0; if len(p.Status.ContainerStatuses) > 0 { restarts = p.Status.ContainerStatuses[0].RestartCount }
|
||||
result = append(result, PodSummary{
|
||||
Namespace: p.Metadata.Namespace,
|
||||
Name: p.Metadata.Name,
|
||||
Status: p.Status.Phase,
|
||||
NodeName: p.Spec.NodeName,
|
||||
IP: p.Status.PodIP,
|
||||
RestartCount: restarts,
|
||||
Image: image,
|
||||
})
|
||||
}
|
||||
c.JSON(200, models.Response{Code: 200, Data: result})
|
||||
}
|
||||
|
||||
// ListHPAsAllNS lists all HPAs across all namespaces using clusterCode.
|
||||
func (h *KubernetesHandler) ListHPAsAllNS(c *gin.Context) {
|
||||
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
|
||||
if clusterID == 0 { c.JSON(500, models.Response{Code: 500, Message: "cluster not found"}); return }
|
||||
client, _, err := h.getK8sClient(clusterID)
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return }
|
||||
hpas, err := client.ListHPAs()
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return }
|
||||
type HPASummary struct {
|
||||
Namespace string `json:"namespace"`
|
||||
Name string `json:"name"`
|
||||
TargetKind string `json:"target_kind"`
|
||||
TargetName string `json:"target_name"`
|
||||
MinReplicas int `json:"min_replicas"`
|
||||
MaxReplicas int `json:"max_replicas"`
|
||||
CPUPercent int `json:"cpu_percent"`
|
||||
MemPercent int `json:"mem_percent"`
|
||||
CurrentReplicas int `json:"current_replicas"`
|
||||
}
|
||||
result := []HPASummary{}
|
||||
for _, h := range hpas.Items {
|
||||
cpu := 0; mem := 0
|
||||
for _, m := range h.Spec.Metrics {
|
||||
if m.Resource != nil {
|
||||
if m.Resource.Name == "cpu" { cpu = m.Resource.Target.AverageUtilization }
|
||||
if m.Resource.Name == "memory" { mem = m.Resource.Target.AverageUtilization }
|
||||
}
|
||||
}
|
||||
result = append(result, HPASummary{
|
||||
Namespace: h.Metadata.Namespace,
|
||||
Name: h.Metadata.Name,
|
||||
TargetKind: h.Spec.ScaleTargetRef.Kind,
|
||||
TargetName: h.Spec.ScaleTargetRef.Name,
|
||||
MinReplicas: h.Spec.MinReplicas,
|
||||
MaxReplicas: h.Spec.MaxReplicas,
|
||||
CPUPercent: cpu,
|
||||
MemPercent: mem,
|
||||
CurrentReplicas: h.Status.CurrentReplicas,
|
||||
})
|
||||
}
|
||||
c.JSON(200, models.Response{Code: 200, Data: result})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
@@ -1693,6 +1902,92 @@ func parseMemResource(val string) int64 {
|
||||
}
|
||||
}
|
||||
|
||||
// formatCpuFromNano converts K8s metrics CPU (nanocores "80410173n") to millicores "80m"
|
||||
func formatCpuFromNano(raw string) string {
|
||||
if raw == "" { return "" }
|
||||
val := raw
|
||||
val = strings.TrimSuffix(val, "n")
|
||||
var nano int64
|
||||
fmt.Sscanf(val, "%d", &nano)
|
||||
milli := nano / 1000000
|
||||
if milli < 1 { milli = 1 }
|
||||
return fmt.Sprintf("%dm", milli)
|
||||
}
|
||||
|
||||
// formatMemFromKi converts K8s metrics memory (Ki "2154292Ki") to Mi "2105Mi"
|
||||
func formatMemFromKi(raw string) string {
|
||||
if raw == "" { return "" }
|
||||
val := strings.TrimSuffix(raw, "Ki")
|
||||
var ki int64
|
||||
fmt.Sscanf(val, "%d", &ki)
|
||||
mi := ki / 1024
|
||||
if mi < 1 { mi = 1 }
|
||||
return fmt.Sprintf("%dMi", mi)
|
||||
}
|
||||
|
||||
|
||||
// GetResourceYAMLNS returns the YAML of a K8s resource
|
||||
func (h *KubernetesHandler) GetResourceYAMLNS(c *gin.Context) {
|
||||
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
|
||||
if clusterID == 0 { c.JSON(500, models.Response{Code: 500, Message: "cluster not found"}); return }
|
||||
client, _, err := h.getK8sClient(clusterID)
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return }
|
||||
ns := c.Param("namespace")
|
||||
resource := c.Param("resource")
|
||||
name := c.Param("name")
|
||||
// Build API path based on resource type
|
||||
var apiPath string
|
||||
switch resource {
|
||||
case "deployments":
|
||||
apiPath = fmt.Sprintf("/apis/apps/v1/namespaces/%s/deployments/%s", ns, name)
|
||||
case "pods":
|
||||
apiPath = fmt.Sprintf("/api/v1/namespaces/%s/pods/%s", ns, name)
|
||||
case "services":
|
||||
apiPath = fmt.Sprintf("/api/v1/namespaces/%s/services/%s", ns, name)
|
||||
case "ingresses":
|
||||
apiPath = fmt.Sprintf("/apis/networking.k8s.io/v1/namespaces/%s/ingresses/%s", ns, name)
|
||||
case "configmaps":
|
||||
apiPath = fmt.Sprintf("/api/v1/namespaces/%s/configmaps/%s", ns, name)
|
||||
case "secrets":
|
||||
apiPath = fmt.Sprintf("/api/v1/namespaces/%s/secrets/%s", ns, name)
|
||||
case "pvcs":
|
||||
apiPath = fmt.Sprintf("/api/v1/namespaces/%s/persistentvolumeclaims/%s", ns, name)
|
||||
default:
|
||||
c.JSON(400, models.Response{Code: 400, Message: "unsupported resource type: " + resource}); return
|
||||
}
|
||||
yamlBytes, err := client.GetResourceYAML(apiPath)
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: "获取YAML失败: " + err.Error()}); return }
|
||||
c.Header("Content-Type", "application/yaml")
|
||||
c.String(200, string(yamlBytes))
|
||||
}
|
||||
|
||||
// UpdateResourceYAMLNS applies YAML to update a K8s resource
|
||||
func (h *KubernetesHandler) UpdateResourceYAMLNS(c *gin.Context) {
|
||||
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
|
||||
if clusterID == 0 { c.JSON(500, models.Response{Code: 500, Message: "cluster not found"}); return }
|
||||
client, _, err := h.getK8sClient(clusterID)
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return }
|
||||
ns := c.Param("namespace")
|
||||
resource := c.Param("resource")
|
||||
name := c.Param("name")
|
||||
yamlData, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil { c.JSON(400, models.Response{Code: 400, Message: "读取body失败"}); return }
|
||||
var apiPath string
|
||||
switch resource {
|
||||
case "deployments":
|
||||
apiPath = fmt.Sprintf("/apis/apps/v1/namespaces/%s/deployments/%s", ns, name)
|
||||
case "pods":
|
||||
apiPath = fmt.Sprintf("/api/v1/namespaces/%s/pods/%s", ns, name)
|
||||
case "services":
|
||||
apiPath = fmt.Sprintf("/api/v1/namespaces/%s/services/%s", ns, name)
|
||||
default:
|
||||
c.JSON(400, models.Response{Code: 400, Message: "unsupported resource type: " + resource}); return
|
||||
}
|
||||
if err := client.UpdateResourceYAML(apiPath, yamlData); err != nil {
|
||||
c.JSON(500, models.Response{Code: 500, Message: "更新失败: " + err.Error()}); return
|
||||
}
|
||||
c.JSON(200, models.Response{Code: 200, Message: "YAML已应用"})
|
||||
}
|
||||
|
||||
// getK8sClient builds a K8s API client from cluster's stored kubeconfig
|
||||
func (h *KubernetesHandler) getK8sClient(clusterID int) (*k8sclient.Client, *models.KubernetesCluster, error) {
|
||||
@@ -1709,3 +2004,267 @@ func (h *KubernetesHandler) getK8sClient(clusterID int) (*k8sclient.Client, *mod
|
||||
}
|
||||
return client, &cluster, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PVC (存储) handlers
|
||||
// ============================================================================
|
||||
|
||||
// ListPVCsNS lists PersistentVolumeClaims in a namespace (code-based)
|
||||
func (h *KubernetesHandler) ListPVCsNS(c *gin.Context) {
|
||||
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
|
||||
if clusterID == 0 { c.JSON(500, models.Response{Code: 500, Message: "cluster not found"}); return }
|
||||
client, _, err := h.getK8sClient(clusterID)
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return }
|
||||
ns := c.Param("namespace")
|
||||
pvcs, err := client.ListPVCs(ns)
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return }
|
||||
type PVCSummary struct {
|
||||
Namespace string `json:"namespace"`
|
||||
Name string `json:"name"`
|
||||
Phase string `json:"phase"`
|
||||
Size string `json:"size"`
|
||||
StorageClass string `json:"storage_class"`
|
||||
}
|
||||
result := []PVCSummary{}
|
||||
for _, p := range pvcs.Items {
|
||||
size := ""
|
||||
if p.Status.Capacity != nil {
|
||||
if s, ok := p.Status.Capacity["storage"]; ok { size = s }
|
||||
}
|
||||
result = append(result, PVCSummary{
|
||||
Namespace: p.Metadata.Namespace,
|
||||
Name: p.Metadata.Name,
|
||||
Phase: p.Status.Phase,
|
||||
Size: size,
|
||||
StorageClass: p.Spec.StorageClassName,
|
||||
})
|
||||
}
|
||||
c.JSON(200, models.Response{Code: 200, Data: result})
|
||||
}
|
||||
|
||||
// DeletePVCNS deletes a PVC (code-based)
|
||||
func (h *KubernetesHandler) DeletePVCNS(c *gin.Context) {
|
||||
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
|
||||
if clusterID == 0 { c.JSON(500, models.Response{Code: 500, Message: "cluster not found"}); return }
|
||||
client, _, err := h.getK8sClient(clusterID)
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return }
|
||||
if err := client.DeletePVC(c.Param("namespace"), c.Param("name")); err != nil {
|
||||
c.JSON(500, models.Response{Code: 500, Message: "删除失败: " + err.Error()}); return
|
||||
}
|
||||
c.JSON(200, models.Response{Code: 200, Message: "已删除"})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// StatefulSet / DaemonSet handlers (code-based)
|
||||
// ============================================================================
|
||||
|
||||
// ListStatefulSetsNS lists StatefulSets in a namespace
|
||||
func (h *KubernetesHandler) ListStatefulSetsNS(c *gin.Context) {
|
||||
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
|
||||
if clusterID == 0 { c.JSON(500, models.Response{Code: 500, Message: "cluster not found"}); return }
|
||||
client, _, err := h.getK8sClient(clusterID)
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return }
|
||||
sts, err := client.ListStatefulSets()
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return }
|
||||
ns := c.Param("namespace")
|
||||
type StsSummary struct {
|
||||
Namespace string `json:"namespace"`
|
||||
Name string `json:"name"`
|
||||
Replicas int `json:"replicas"`
|
||||
Ready int `json:"ready"`
|
||||
Image string `json:"image"`
|
||||
}
|
||||
result := []StsSummary{}
|
||||
for _, s := range sts.Items {
|
||||
if s.Metadata.Namespace != ns { continue }
|
||||
image := ""; if len(s.Spec.Template.Spec.Containers) > 0 { image = s.Spec.Template.Spec.Containers[0].Image }
|
||||
result = append(result, StsSummary{
|
||||
Namespace: s.Metadata.Namespace,
|
||||
Name: s.Metadata.Name,
|
||||
Replicas: s.Spec.Replicas,
|
||||
Ready: s.Status.ReadyReplicas,
|
||||
Image: image,
|
||||
})
|
||||
}
|
||||
c.JSON(200, models.Response{Code: 200, Data: result})
|
||||
}
|
||||
|
||||
// ListDaemonSetsNS lists DaemonSets in a namespace
|
||||
func (h *KubernetesHandler) ListDaemonSetsNS(c *gin.Context) {
|
||||
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
|
||||
if clusterID == 0 { c.JSON(500, models.Response{Code: 500, Message: "cluster not found"}); return }
|
||||
client, _, err := h.getK8sClient(clusterID)
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return }
|
||||
ds, err := client.ListDaemonSets()
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return }
|
||||
ns := c.Param("namespace")
|
||||
type DsSummary struct {
|
||||
Namespace string `json:"namespace"`
|
||||
Name string `json:"name"`
|
||||
Desired int `json:"desired"`
|
||||
Current int `json:"current"`
|
||||
Ready int `json:"ready"`
|
||||
Image string `json:"image"`
|
||||
}
|
||||
result := []DsSummary{}
|
||||
for _, d := range ds.Items {
|
||||
if d.Metadata.Namespace != ns { continue }
|
||||
image := ""; if len(d.Spec.Template.Spec.Containers) > 0 { image = d.Spec.Template.Spec.Containers[0].Image }
|
||||
result = append(result, DsSummary{
|
||||
Namespace: d.Metadata.Namespace,
|
||||
Name: d.Metadata.Name,
|
||||
Desired: d.Status.DesiredNumberScheduled,
|
||||
Current: d.Status.CurrentNumberScheduled,
|
||||
Ready: d.Status.NumberReady,
|
||||
Image: image,
|
||||
})
|
||||
}
|
||||
c.JSON(200, models.Response{Code: 200, Data: result})
|
||||
}
|
||||
// ============================================================================
|
||||
// Pod Metrics (kubectl top pods) — per-container CPU/Memory usage via metrics-server
|
||||
// ============================================================================
|
||||
|
||||
// GetPodMetrics returns pod-level CPU/Memory metrics
|
||||
func (h *KubernetesHandler) GetPodMetrics(c *gin.Context) {
|
||||
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
|
||||
if clusterID == 0 { c.JSON(500, models.Response{Code: 500, Message: "cluster not found"}); return }
|
||||
client, _, err := h.getK8sClient(clusterID)
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return }
|
||||
|
||||
ns := c.Query("namespace")
|
||||
metrics, err := client.ListPodMetrics(ns)
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: "metrics API error: " + err.Error()}); return }
|
||||
|
||||
type ContainerMetric struct {
|
||||
Name string `json:"name"`
|
||||
CPU string `json:"cpu"`
|
||||
Memory string `json:"memory"`
|
||||
}
|
||||
type PodMetric struct {
|
||||
Namespace string `json:"namespace"`
|
||||
Name string `json:"name"`
|
||||
Containers []ContainerMetric `json:"containers"`
|
||||
}
|
||||
result := []PodMetric{}
|
||||
for _, pm := range metrics.Items {
|
||||
containers := []ContainerMetric{}
|
||||
for _, c := range pm.Containers {
|
||||
containers = append(containers, ContainerMetric{c.Name, c.Usage["cpu"], c.Usage["memory"]})
|
||||
}
|
||||
result = append(result, PodMetric{pm.Metadata.Namespace, pm.Metadata.Name, containers})
|
||||
}
|
||||
c.JSON(200, models.Response{Code: 200, Data: result})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Events (kubectl get events)
|
||||
// ============================================================================
|
||||
|
||||
// ListEvents returns cluster events (optionally filtered by namespace and type)
|
||||
func (h *KubernetesHandler) ListEvents(c *gin.Context) {
|
||||
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
|
||||
if clusterID == 0 { c.JSON(500, models.Response{Code: 500, Message: "cluster not found"}); return }
|
||||
client, _, err := h.getK8sClient(clusterID)
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return }
|
||||
|
||||
ns := c.Query("namespace")
|
||||
eventType := c.Query("type") // Normal or Warning
|
||||
events, err := client.ListEvents(ns)
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return }
|
||||
|
||||
type EventSummary struct {
|
||||
Namespace string `json:"namespace"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Reason string `json:"reason"`
|
||||
Message string `json:"message"`
|
||||
Object string `json:"object"`
|
||||
Count int `json:"count"`
|
||||
FirstTime string `json:"first_time"`
|
||||
LastTime string `json:"last_time"`
|
||||
}
|
||||
result := []EventSummary{}
|
||||
for _, e := range events.Items {
|
||||
if eventType != "" && e.Type != eventType { continue }
|
||||
object := fmt.Sprintf("%s/%s", e.InvolvedObject.Kind, e.InvolvedObject.Name)
|
||||
if e.InvolvedObject.Namespace != "" {
|
||||
object = fmt.Sprintf("%s/%s/%s", e.InvolvedObject.Namespace, e.InvolvedObject.Kind, e.InvolvedObject.Name)
|
||||
}
|
||||
firstTime := ""; if !e.FirstTimestamp.IsZero() { firstTime = e.FirstTimestamp.Format(time.RFC3339) }
|
||||
lastTime := ""; if !e.LastTimestamp.IsZero() { lastTime = e.LastTimestamp.Format(time.RFC3339) }
|
||||
result = append(result, EventSummary{
|
||||
Namespace: e.Metadata.Namespace, Name: e.Metadata.Name, Type: e.Type, Reason: e.Reason,
|
||||
Message: e.Message, Object: object, Count: int(e.Count),
|
||||
FirstTime: firstTime, LastTime: lastTime,
|
||||
})
|
||||
}
|
||||
c.JSON(200, models.Response{Code: 200, Data: result})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Capacity Analysis — CPU/Memory request/limit per Deployment
|
||||
// ============================================================================
|
||||
|
||||
// GetCapacityAnalysis returns resource analysis for all deployments
|
||||
func (h *KubernetesHandler) GetCapacityAnalysis(c *gin.Context) {
|
||||
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
|
||||
if clusterID == 0 { c.JSON(500, models.Response{Code: 500, Message: "cluster not found"}); return }
|
||||
client, _, err := h.getK8sClient(clusterID)
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return }
|
||||
|
||||
ns := c.Query("namespace")
|
||||
deps, err := client.ListDeployments()
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return }
|
||||
|
||||
type ContainerResource struct {
|
||||
Name string `json:"name"`
|
||||
CPUReq string `json:"cpu_request"`
|
||||
CPULim string `json:"cpu_limit"`
|
||||
MemReq string `json:"mem_request"`
|
||||
MemLim string `json:"mem_limit"`
|
||||
}
|
||||
type CapacityRow struct {
|
||||
Namespace string `json:"namespace"`
|
||||
Name string `json:"name"`
|
||||
Replicas int `json:"replicas"`
|
||||
Containers []ContainerResource `json:"containers"`
|
||||
}
|
||||
result := []CapacityRow{}
|
||||
for _, d := range deps.Items {
|
||||
if ns != "" && d.Metadata.Namespace != ns { continue }
|
||||
containers := []ContainerResource{}
|
||||
for _, c := range d.Spec.Template.Spec.Containers {
|
||||
cpuReq := c.Resources.Requests["cpu"]
|
||||
cpuLim := c.Resources.Limits["cpu"]
|
||||
memReq := c.Resources.Requests["memory"]
|
||||
memLim := c.Resources.Limits["memory"]
|
||||
containers = append(containers, ContainerResource{c.Name, cpuReq, cpuLim, memReq, memLim})
|
||||
}
|
||||
result = append(result, CapacityRow{d.Metadata.Namespace, d.Metadata.Name, d.Spec.Replicas, containers})
|
||||
}
|
||||
c.JSON(200, models.Response{Code: 200, Data: result})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// YAML Apply (kubectl apply -f)
|
||||
// ============================================================================
|
||||
|
||||
// ApplyYAML applies a YAML manifest to create or update a resource
|
||||
func (h *KubernetesHandler) ApplyYAML(c *gin.Context) {
|
||||
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
|
||||
if clusterID == 0 { c.JSON(500, models.Response{Code: 500, Message: "cluster not found"}); return }
|
||||
client, _, err := h.getK8sClient(clusterID)
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return }
|
||||
|
||||
var req struct {
|
||||
YAML string `json:"yaml"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, models.Response{Code: 400, Message: "参数错误: " + err.Error()}); return }
|
||||
if req.YAML == "" { c.JSON(400, models.Response{Code: 400, Message: "YAML 内容不能为空"}); return }
|
||||
|
||||
result, err := client.ApplyYAML(req.YAML)
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: "Apply 失败: " + err.Error()}); return }
|
||||
|
||||
c.JSON(200, models.Response{Code: 200, Message: "资源已应用", Data: result})
|
||||
}
|
||||
@@ -68,6 +68,10 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
||||
k8sHandler := handlers.NewKubernetesHandler(db)
|
||||
r.GET("/ws/kubernetes-exec/:id/namespaces/:namespace/pods/:podName", middleware.JWTAuth(&cfg.JWT), k8sHandler.ExecTerminal)
|
||||
|
||||
// WebSocket kubectl 命令行终端 // Logan.Li
|
||||
kubectlShellHandler := handlers.NewKubectlShellHandler(db)
|
||||
r.GET("/ws/kubectl", middleware.JWTAuth(&cfg.JWT), kubectlShellHandler.Terminal)
|
||||
|
||||
// 公开路由
|
||||
authHandler := handlers.NewUserHandler(db, cfg)
|
||||
api.POST("/auth/login", authHandler.Login)
|
||||
@@ -320,6 +324,31 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
||||
|
||||
// Namespace list
|
||||
kubernetesRouter.GET("/:clusterCode/namespaces", k8sHandler.ListNamespaces)
|
||||
// All-namespace listing (no ns filter)
|
||||
kubernetesRouter.GET("/:clusterCode/deployments", k8sHandler.ListDeploymentsAllNS)
|
||||
kubernetesRouter.GET("/:clusterCode/pods", k8sHandler.ListPodsAllNS)
|
||||
kubernetesRouter.GET("/:clusterCode/hpas", k8sHandler.ListHPAsAllNS)
|
||||
|
||||
// 🆕 Pod Metrics (kubectl top pods)
|
||||
kubernetesRouter.GET("/:clusterCode/metrics/pods", k8sHandler.GetPodMetrics)
|
||||
// 🆕 Events
|
||||
kubernetesRouter.GET("/:clusterCode/events", k8sHandler.ListEvents)
|
||||
// 🆕 Capacity Analysis
|
||||
kubernetesRouter.GET("/:clusterCode/capacity", k8sHandler.GetCapacityAnalysis)
|
||||
// 🆕 YAML Apply
|
||||
kubernetesRouter.POST("/:clusterCode/yaml/apply", k8sHandler.ApplyYAML)
|
||||
|
||||
// PVC (存储)
|
||||
kubernetesRouter.GET("/:clusterCode/namespace/:namespace/pvcs", k8sHandler.ListPVCsNS)
|
||||
kubernetesRouter.DELETE("/:clusterCode/namespace/:namespace/pvcs/:name", k8sHandler.DeletePVCNS)
|
||||
|
||||
// StatefulSet / DaemonSet
|
||||
kubernetesRouter.GET("/:clusterCode/namespace/:namespace/statefulsets", k8sHandler.ListStatefulSetsNS)
|
||||
kubernetesRouter.GET("/:clusterCode/namespace/:namespace/daemonsets", k8sHandler.ListDaemonSetsNS)
|
||||
|
||||
// YAML view/edit
|
||||
kubernetesRouter.GET("/:clusterCode/namespace/:namespace/:resource/:name/yaml", k8sHandler.GetResourceYAMLNS)
|
||||
kubernetesRouter.PUT("/:clusterCode/namespace/:namespace/:resource/:name/yaml", k8sHandler.UpdateResourceYAMLNS)
|
||||
}
|
||||
|
||||
// 告警路由
|
||||
|
||||
@@ -151,6 +151,37 @@ func (c *Client) get(path string, result interface{}) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// getRaw performs a GET request returning raw bytes with custom Accept header
|
||||
func (c *Client) getRaw(path string, accept string) ([]byte, error) {
|
||||
req, err := http.NewRequest("GET", c.BaseURL+path, nil)
|
||||
if err != nil { return nil, err }
|
||||
req.Header.Set("Accept", accept)
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil { return nil, fmt.Errorf("GET %s: %w", path, err) }
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil { return nil, fmt.Errorf("read body: %w", err) }
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("K8s API error %d: %s", resp.StatusCode, string(body[:min(len(body), 200)]))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// putRaw performs a PUT request with raw body and custom Content-Type
|
||||
func (c *Client) putRaw(path string, contentType string, data []byte) error {
|
||||
req, err := http.NewRequest("PUT", c.BaseURL+path, strings.NewReader(string(data)))
|
||||
if err != nil { return err }
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil { return fmt.Errorf("PUT %s: %w", path, err) }
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("K8s API error %d: %s", resp.StatusCode, string(body[:min(len(body), 300)]))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- K8s API response types ---
|
||||
|
||||
// NodeList response
|
||||
@@ -212,7 +243,14 @@ type PodSpec struct {
|
||||
}
|
||||
|
||||
type Container struct {
|
||||
Image string `json:"image"`
|
||||
Name string `json:"name"`
|
||||
Image string `json:"image"`
|
||||
Resources ContainerRes `json:"resources"`
|
||||
}
|
||||
|
||||
type ContainerRes struct {
|
||||
Requests map[string]string `json:"requests"`
|
||||
Limits map[string]string `json:"limits"`
|
||||
}
|
||||
|
||||
type PodStatus struct {
|
||||
@@ -945,3 +983,336 @@ func (c *Client) ListSecrets(namespace string) (*SecretList, error) {
|
||||
}
|
||||
return &SecretList{Items: items}, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PersistentVolumeClaim (存储)
|
||||
// ============================================================================
|
||||
|
||||
type PVCList struct {
|
||||
Items []PVCItem `json:"items"`
|
||||
}
|
||||
|
||||
type PVCItem struct {
|
||||
Metadata ObjectMeta `json:"metadata"`
|
||||
Spec PVCSpec `json:"spec"`
|
||||
Status struct {
|
||||
Phase string `json:"phase"`
|
||||
Capacity map[string]string `json:"capacity"`
|
||||
} `json:"status"`
|
||||
}
|
||||
|
||||
type PVCSpec struct {
|
||||
AccessModes []string `json:"accessModes"`
|
||||
Resources struct {
|
||||
Requests map[string]string `json:"requests"`
|
||||
} `json:"resources"`
|
||||
StorageClassName string `json:"storageClassName"`
|
||||
}
|
||||
|
||||
// ListPVCs fetches PersistentVolumeClaims in a namespace
|
||||
func (c *Client) ListPVCs(namespace string) (*PVCList, error) {
|
||||
var result PVCList
|
||||
path := fmt.Sprintf("/api/v1/namespaces/%s/persistentvolumeclaims", namespace)
|
||||
if err := c.get(path, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// DeletePVC deletes a PersistentVolumeClaim
|
||||
func (c *Client) DeletePVC(namespace, name string) error {
|
||||
path := fmt.Sprintf("/api/v1/namespaces/%s/persistentvolumeclaims/%s", namespace, name)
|
||||
return c.delete(path)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// StatefulSet
|
||||
// ============================================================================
|
||||
|
||||
type StatefulSetList struct {
|
||||
Items []StatefulSetItem `json:"items"`
|
||||
}
|
||||
|
||||
type StatefulSetItem struct {
|
||||
Metadata ObjectMeta `json:"metadata"`
|
||||
Spec StatefulSetSpec `json:"spec"`
|
||||
Status struct {
|
||||
Replicas int `json:"replicas"`
|
||||
ReadyReplicas int `json:"readyReplicas"`
|
||||
} `json:"status"`
|
||||
}
|
||||
|
||||
type StatefulSetSpec struct {
|
||||
Replicas int `json:"replicas"`
|
||||
Template struct {
|
||||
Spec struct {
|
||||
Containers []struct {
|
||||
Name string `json:"name"`
|
||||
Image string `json:"image"`
|
||||
} `json:"containers"`
|
||||
} `json:"spec"`
|
||||
} `json:"template"`
|
||||
}
|
||||
|
||||
// ListStatefulSets fetches StatefulSets across all namespaces
|
||||
func (c *Client) ListStatefulSets() (*StatefulSetList, error) {
|
||||
var result StatefulSetList
|
||||
if err := c.get("/apis/apps/v1/statefulsets", &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DaemonSet
|
||||
// ============================================================================
|
||||
|
||||
type DaemonSetList struct {
|
||||
Items []DaemonSetItem `json:"items"`
|
||||
}
|
||||
|
||||
type DaemonSetItem struct {
|
||||
Metadata ObjectMeta `json:"metadata"`
|
||||
Spec DaemonSetSpec `json:"spec"`
|
||||
Status struct {
|
||||
DesiredNumberScheduled int `json:"desiredNumberScheduled"`
|
||||
CurrentNumberScheduled int `json:"currentNumberScheduled"`
|
||||
NumberReady int `json:"numberReady"`
|
||||
} `json:"status"`
|
||||
}
|
||||
|
||||
type DaemonSetSpec struct {
|
||||
Template struct {
|
||||
Spec struct {
|
||||
Containers []struct {
|
||||
Name string `json:"name"`
|
||||
Image string `json:"image"`
|
||||
} `json:"containers"`
|
||||
} `json:"spec"`
|
||||
} `json:"template"`
|
||||
}
|
||||
|
||||
// ListDaemonSets fetches DaemonSets across all namespaces
|
||||
func (c *Client) ListDaemonSets() (*DaemonSetList, error) {
|
||||
var result DaemonSetList
|
||||
if err := c.get("/apis/apps/v1/daemonsets", &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// NodeMetrics (metrics-server, optional)
|
||||
// ============================================================================
|
||||
|
||||
type NodeMetricsList struct {
|
||||
Items []NodeMetricsItem `json:"items"`
|
||||
}
|
||||
|
||||
type NodeMetricsItem struct {
|
||||
Metadata ObjectMeta `json:"metadata"`
|
||||
Usage map[string]string `json:"usage"` // cpu, memory
|
||||
}
|
||||
|
||||
// GetNodeMetrics fetches node CPU/memory usage from metrics-server
|
||||
func (c *Client) GetNodeMetrics() (*NodeMetricsList, error) {
|
||||
var result NodeMetricsList
|
||||
if err := c.get("/apis/metrics.k8s.io/v1beta1/nodes", &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PodMetrics (metrics-server, per-container CPU/Memory)
|
||||
// ============================================================================
|
||||
|
||||
type PodMetricsList struct {
|
||||
Items []PodMetricsItem `json:"items"`
|
||||
}
|
||||
|
||||
type PodMetricsItem struct {
|
||||
Metadata ObjectMeta `json:"metadata"`
|
||||
Containers []ContainerMetrics `json:"containers"`
|
||||
}
|
||||
|
||||
type ContainerMetrics struct {
|
||||
Name string `json:"name"`
|
||||
Usage map[string]string `json:"usage"` // cpu, memory
|
||||
}
|
||||
|
||||
// ListPodMetrics fetches pod CPU/memory usage, optionally filtered by namespace
|
||||
func (c *Client) ListPodMetrics(namespace string) (*PodMetricsList, error) {
|
||||
var result PodMetricsList
|
||||
path := "/apis/metrics.k8s.io/v1beta1/pods"
|
||||
if namespace != "" {
|
||||
path = fmt.Sprintf("/apis/metrics.k8s.io/v1beta1/namespaces/%s/pods", namespace)
|
||||
}
|
||||
if err := c.get(path, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Events
|
||||
// ============================================================================
|
||||
|
||||
type EventList struct {
|
||||
Items []EventItem `json:"items"`
|
||||
}
|
||||
|
||||
type EventItem struct {
|
||||
Metadata ObjectMeta `json:"metadata"`
|
||||
InvolvedObject InvolvedObject `json:"involvedObject"`
|
||||
Type string `json:"type"`
|
||||
Reason string `json:"reason"`
|
||||
Message string `json:"message"`
|
||||
Count int32 `json:"count"`
|
||||
FirstTimestamp time.Time `json:"firstTimestamp"`
|
||||
LastTimestamp time.Time `json:"lastTimestamp"`
|
||||
EventTime time.Time `json:"eventTime"`
|
||||
}
|
||||
|
||||
type InvolvedObject struct {
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Namespace string `json:"namespace"`
|
||||
}
|
||||
|
||||
// ListEvents fetches cluster events, optionally filtered by namespace
|
||||
func (c *Client) ListEvents(namespace string) (*EventList, error) {
|
||||
var result EventList
|
||||
path := "/api/v1/events"
|
||||
if namespace != "" {
|
||||
path = fmt.Sprintf("/api/v1/namespaces/%s/events", namespace)
|
||||
}
|
||||
if err := c.get(path, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// YAML Apply (Dynamic resource apply)
|
||||
// ============================================================================
|
||||
|
||||
// ApplyYAML applies a raw YAML manifest using the K8s discovery API
|
||||
func (c *Client) ApplyYAML(yamlContent string) (map[string]interface{}, error) {
|
||||
return c.applyRaw(yamlContent)
|
||||
}
|
||||
|
||||
// GetResourceYAML fetches raw YAML for any K8s resource by API path
|
||||
func (c *Client) GetResourceYAML(apiPath string) ([]byte, error) {
|
||||
return c.getRaw(apiPath, "application/yaml")
|
||||
}
|
||||
|
||||
// UpdateResourceYAML applies raw YAML to update a K8s resource
|
||||
func (c *Client) UpdateResourceYAML(apiPath string, yamlData []byte) error {
|
||||
return c.putRaw(apiPath, "application/yaml", yamlData)
|
||||
}
|
||||
|
||||
// applyRaw parses YAML and applies it via create-or-update pattern
|
||||
func (c *Client) applyRaw(yamlContent string) (map[string]interface{}, error) {
|
||||
// Parse YAML to extract resource metadata
|
||||
var obj struct {
|
||||
APIVersion string `yaml:"apiVersion"`
|
||||
Kind string `yaml:"kind"`
|
||||
Metadata struct {
|
||||
Name string `yaml:"name"`
|
||||
Namespace string `yaml:"namespace"`
|
||||
} `yaml:"metadata"`
|
||||
}
|
||||
if err := yaml.Unmarshal([]byte(yamlContent), &obj); err != nil {
|
||||
return nil, fmt.Errorf("YAML 解析失败: %w", err)
|
||||
}
|
||||
if obj.Kind == "" || obj.Metadata.Name == "" {
|
||||
return nil, fmt.Errorf("YAML 中缺少 kind 或 name")
|
||||
}
|
||||
|
||||
// Build API path from kind
|
||||
apiPath, isNamespaced := c.resourceAPIPath(obj.APIVersion, obj.Kind, obj.Metadata.Namespace, obj.Metadata.Name)
|
||||
if apiPath == "" {
|
||||
return nil, fmt.Errorf("不支持的资源类型: %s/%s", obj.APIVersion, obj.Kind)
|
||||
}
|
||||
|
||||
// Try GET first — if 200, it exists, do PUT. If 404, do POST.
|
||||
parentPath := apiPath[:strings.LastIndex(apiPath, "/")] // without /name suffix
|
||||
_, getErr := c.getRaw(apiPath, "application/yaml")
|
||||
if getErr == nil {
|
||||
// Resource exists → PUT update
|
||||
if err := c.putRaw(apiPath, "application/yaml", []byte(yamlContent)); err != nil {
|
||||
return nil, fmt.Errorf("更新资源失败: %w", err)
|
||||
}
|
||||
return map[string]interface{}{"action": "updated", "path": apiPath}, nil
|
||||
}
|
||||
|
||||
// Resource doesn't exist → POST create (for namespaced) or POST to parent
|
||||
var result map[string]interface{}
|
||||
if isNamespaced && obj.Metadata.Namespace == "" {
|
||||
obj.Metadata.Namespace = "default"
|
||||
parentPath = strings.Replace(parentPath, "/namespaces//", "/namespaces/default/", 1)
|
||||
}
|
||||
if err := c.post(parentPath, []byte(yamlContent), &result); err != nil {
|
||||
return nil, fmt.Errorf("创建资源失败: %w", err)
|
||||
}
|
||||
return map[string]interface{}{"action": "created", "path": parentPath, "result": result}, nil
|
||||
}
|
||||
|
||||
// resourceAPIPath returns the K8s REST API path for a given resource kind
|
||||
func (c *Client) resourceAPIPath(apiVersion, kind, namespace, name string) (string, bool) {
|
||||
resource := strings.ToLower(kind) + "s"
|
||||
switch kind {
|
||||
case "Endpoints": resource = "endpoints"
|
||||
case "PodMetrics": resource = "pods"
|
||||
}
|
||||
|
||||
var groupPrefix string
|
||||
if apiVersion != "" && apiVersion != "v1" {
|
||||
parts := strings.SplitN(apiVersion, "/", 2)
|
||||
if len(parts) == 2 {
|
||||
groupPrefix = "/apis/" + parts[0] + "/" + parts[1]
|
||||
} else {
|
||||
groupPrefix = "/apis/" + parts[0]
|
||||
}
|
||||
}
|
||||
|
||||
isNamespaced := true
|
||||
switch kind {
|
||||
case "Namespace", "Node", "ClusterRole", "ClusterRoleBinding", "PersistentVolume":
|
||||
isNamespaced = false
|
||||
}
|
||||
|
||||
if !isNamespaced {
|
||||
if groupPrefix != "" {
|
||||
return groupPrefix + "/" + resource + "/" + name, false
|
||||
}
|
||||
return "/api/v1/" + resource + "/" + name, false
|
||||
}
|
||||
|
||||
ns := namespace
|
||||
if ns == "" { ns = "default" }
|
||||
|
||||
if groupPrefix != "" {
|
||||
switch kind {
|
||||
case "Deployment", "StatefulSet", "DaemonSet", "ReplicaSet":
|
||||
return groupPrefix + "/namespaces/" + ns + "/" + resource + "/" + name, true
|
||||
case "HorizontalPodAutoscaler":
|
||||
return groupPrefix + "/namespaces/" + ns + "/" + strings.ToLower(kind) + "s/" + name, true
|
||||
case "Ingress":
|
||||
return groupPrefix + "/namespaces/" + ns + "/" + resource + "/" + name, true
|
||||
default:
|
||||
return groupPrefix + "/namespaces/" + ns + "/" + resource + "/" + name, true
|
||||
}
|
||||
}
|
||||
|
||||
// Core v1 API
|
||||
coreResources := map[string]string{
|
||||
"Pod": "pods", "Service": "services", "ConfigMap": "configmaps",
|
||||
"Secret": "secrets", "PersistentVolumeClaim": "persistentvolumeclaims",
|
||||
}
|
||||
if r, ok := coreResources[kind]; ok {
|
||||
return "/api/v1/namespaces/" + ns + "/" + r + "/" + name, true
|
||||
}
|
||||
return "/api/v1/namespaces/" + ns + "/" + resource + "/" + name, true
|
||||
}
|
||||
Reference in new issue
Block a user