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

**kubectl WebShell 修复:**
- 引入 github.com/creack/pty 库实现真正的伪终端
- 替换 bash --noediting + TERM=dumb 方案为 PTY-backed bash
- 支持 xterm-256color、窗口自适应、readline 交互
- 修复键盘输入无响应问题

**侧边栏优化:**
- 独立菜单项高度从 py-1.5 改为 h-8,与父菜单对齐
- 独立菜单图标从 h-3 w-3 改为 h-3.5 w-3.5,统一 14px

**构建优化:**
- run.sh docker up 增加 docker image prune -f 清理悬空镜像
- 每次部署自动清理 builder cache + dangling images

**认证修复(历史):**
- Token 存储:Zustand + localStorage 双写,http.ts 双源 fallback
- YamlViewer/DeploymentDetail token 来源统一为 Zustand store
This commit is contained in:
attacker committed 2026-06-19 11:22:45 +00:00
1 parent cac1414917
commit a3cd0d3910
29 files changed
+2954 -180

No files matched your search

@@ -0,0 +1,191 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"strconv"
"syscall"
"github.com/creack/pty"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"gorm.io/gorm"
"ezops/internal/models"
)
// KubectlShellHandler kubectl WebSocket 终端处理(PTY 模式)
type KubectlShellHandler struct {
db *gorm.DB
}
// NewKubectlShellHandler creates handler
func NewKubectlShellHandler(db *gorm.DB) *KubectlShellHandler {
return &KubectlShellHandler{db: db}
}
// wsWriter wraps a websocket.Conn as an io.Writer (each write = one WSMessage)
type wsWriter struct {
conn *websocket.Conn
}
func (w wsWriter) Write(p []byte) (int, error) {
err := w.conn.WriteJSON(WSMessage{Status: 0, Data: string(p)})
if err != nil {
return 0, err
}
return len(p), nil
}
// Terminal starts a PTY-backed bash session with the cluster's kubeconfig injected
func (h *KubectlShellHandler) Terminal(c *gin.Context) {
clusterID := c.Query("id")
if clusterID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing cluster id"})
return
}
id, err := strconv.Atoi(clusterID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid cluster id"})
return
}
var cluster models.KubernetesCluster
if err := h.db.First(&cluster, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "cluster not found"})
return
}
if cluster.KubeConfig == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "no kubeconfig configured"})
return
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Printf("[KubectlShell] WebSocket upgrade failed: %v", err)
return
}
defer conn.Close()
// Write kubeconfig to a temp file (isolated per session)
tmpFile, err := os.CreateTemp("", "kubeconfig-*.yaml")
if err != nil {
conn.WriteJSON(WSMessage{Status: 0, Data: "\r\n\x1b[31mFailed to create temp kubeconfig\x1b[0m\r\n"})
return
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.WriteString(cluster.KubeConfig); err != nil {
conn.WriteJSON(WSMessage{Status: 0, Data: "\r\n\x1b[31mFailed to write kubeconfig\x1b[0m\r\n"})
return
}
tmpFile.Close()
// 启动 bash -i(TERM=dumb,无 PS1 ANSI 码,不回显混乱)
// 注意:不加 --noediting,否则无 TTY 时 bash 不回显输入
cmd := exec.CommandContext(c.Request.Context(), "bash", "--norc", "-i")
cmd.Env = append(os.Environ(),
"KUBECONFIG="+tmpFile.Name(),
"HOME=/root",
"TERM=xterm-256color",
fmt.Sprintf("PS1=\x1b[32m[%s]\x1b[0m $ ", cluster.Code),
"COLUMNS=120",
"LINES=30",
)
cmd.SysProcAttr = &syscall.SysProcAttr{
Setsid: true,
Setctty: true,
}
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: 120, Rows: 30})
if err != nil {
conn.WriteJSON(WSMessage{Status: 0, Data: "\r\n\x1b[31mFailed to start shell (bash not available?)\x1b[0m\r\n"})
return
}
defer func() {
ptmx.Close()
// Kill the process group so child processes (kubectl, etc.) are cleaned up
if cmd.Process != nil {
syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
cmd.Wait()
}()
// Welcome message (already on PTY so bash will show PS1 right after)
welcome := fmt.Sprintf("\r\n\x1b[32m=== kubectl shell — %s (%s) ===\x1b[0m\r\n", cluster.Name, cluster.Code)
welcome += "\x1b[36mKUBECONFIG 已预配 | 输入 'exit' 退出\x1b[0m\r\n\r\n"
conn.WriteJSON(WSMessage{Status: 0, Data: welcome})
// Use a cancellable context for the two goroutines
ctx, cancel := context.WithCancel(c.Request.Context())
defer cancel()
// PTY → WebSocket (single goroutine via io.Copy)
go func() {
defer cancel() // unblock main goroutine when PTY closes
n, err := io.Copy(wsWriter{conn: conn}, ptmx)
if err != nil && err != io.EOF {
log.Printf("[KubectlShell] PTY copy ended: %v (bytes=%d)", err, n)
}
}()
// WebSocket → PTY (read JSON messages, write raw to PTY)
go func() {
defer ptmx.Close() // close PTY to stop bash and unblock Copy goroutine
for {
msgType, data, err := conn.ReadMessage()
if err != nil {
if !websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
// Normal close — client disconnected
} else {
log.Printf("[KubectlShell] WS read error: %v", err)
}
return
}
if msgType == websocket.TextMessage || msgType == websocket.BinaryMessage {
var msg WSMessage
if err := json.Unmarshal(data, &msg); err != nil {
// Binary data (rare) — pass through raw
if msgType == websocket.BinaryMessage {
ptmx.Write(data)
}
continue
}
switch msg.Status {
case 0:
// Keyboard input — write raw bytes to PTY
if _, err := ptmx.Write([]byte(msg.Data)); err != nil {
log.Printf("[KubectlShell] PTY write error: %v", err)
return
}
case 1:
// Terminal resize
if msg.Cols > 0 && msg.Rows > 0 {
if err := pty.Setsize(ptmx, &pty.Winsize{
Cols: uint16(msg.Cols),
Rows: uint16(msg.Rows),
}); err != nil {
log.Printf("[KubectlShell] PTY resize error: %v", err)
}
}
}
}
}
}()
// Block until PTY closes or context cancelled
<-ctx.Done()
log.Printf("[KubectlShell] session ended for cluster %s", cluster.Name)
}
func init() {
// ensure kubectl is available
if _, err := exec.LookPath("kubectl"); err != nil {
log.Printf("[KubectlShell] WARNING: kubectl not found in PATH — kubectl commands will not work")
}
}
+573 -14
View File
@@ -305,7 +305,14 @@ func (h *KubernetesHandler) ListNodes(c *gin.Context) {
return
}
// 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})
}
+29
View File
@@ -68,6 +68,10 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
k8sHandler := handlers.NewKubernetesHandler(db)
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)
}
// 告警路由