F1 告警静默规则 [done]: - 后端 alert_silence.go: CRUD + Toggle (onetime/daily/weekly 三模式) - 告警引擎 isSilenced 集成 (alert_engine.go) - 前端 SilenceRuleTab + SilenceDialog - 路由 /alerts/silences 已注册 F2 审计日志导出 [done]: - 重写 ExportAuditLogs: 真正的 CSV 导出 (encoding/csv + UTF-8 BOM) - 支持 module/action/username/startTime/endTime 筛选 - 导出限制 10000 条防溢出,自动记录审计日志 - JSON 格式向后兼容 (format=json) - 前端 exportLogs 传递查询参数 同时包含累积的前期工作: - 目录重构 internal/ -> controller/model/router (v0.55-v0.57) - 后端分层架构 dto/repository/service/utils - 审计模块全功能 (终端审计/操作审计/登录日志) - asciicast v2 录制格式 - SFTP 文件浏览器 - StatStrip 紧凑统计条 - 在线会话自动刷新 - LiveMonitor WebSocket 诊断修复 验证: go build ✅ | npm run build ✅ | Docker 部署 ✅ | 10 回归端点 ✅ | CSV 导出 ✅ | 静默 CRUD ✅
207 lines
7.1 KiB
Go
207 lines
7.1 KiB
Go
package controller
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"path"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"ezops/model"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
func (h *KubernetesHandler) ListPodFilesNS(c *gin.Context) {
|
|
h.listPodFilesWithNS(c, c.Param("namespace"))
|
|
}
|
|
func (h *KubernetesHandler) listPodFilesWithNS(c *gin.Context, ns string) {
|
|
clusterID, _ := strconv.Atoi(c.Param("id"))
|
|
if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) }
|
|
podName := c.Param("podName")
|
|
var req struct {
|
|
Path string `json:"path"`
|
|
Container string `json:"container"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, model.Response{Code: 400, Message: "参数错误: " + err.Error()}); return }
|
|
if req.Path == "" { req.Path = "/" }
|
|
client, _, err := h.getK8sClient(clusterID)
|
|
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
|
|
// ls -la — BusyBox ignores --time-style, use default "Mon DD HH:MM" format
|
|
cmd := []string{"ls", "-la", req.Path}
|
|
result, err := client.ExecInPod(ns, podName, req.Container, cmd)
|
|
if err != nil { c.JSON(500, model.Response{Code: 500, Message: "执行 ls 失败: " + err.Error()}); return }
|
|
|
|
type FileInfo struct {
|
|
Name string `json:"name"`
|
|
Size string `json:"size"`
|
|
Mode string `json:"mode"`
|
|
ModTime string `json:"mod_time"`
|
|
IsDir bool `json:"is_dir"`
|
|
}
|
|
files := []FileInfo{}
|
|
output := result.Stdout // WebSocket output is already clean text
|
|
for _, line := range strings.Split(output, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "total ") { continue }
|
|
fields := strings.Fields(line)
|
|
if len(fields) < 7 { continue }
|
|
mode := fields[0]
|
|
if len(mode) == 0 || (mode[0] != 'd' && mode[0] != '-' && mode[0] != 'l') { continue }
|
|
size := fields[4]
|
|
// BusyBox ls date format: "Jun 23 02:17" (3 fields: month day time)
|
|
// GNU ls --time-style: "2026-06-23T02:17:00" (1 field)
|
|
// Detect format: if fields[5] contains ":" it's time-only (BusyBox style)
|
|
var name, modTime string
|
|
if strings.Contains(fields[5], ":") || strings.Contains(fields[5], "T") {
|
|
// GNU style: single timestamp field
|
|
modTime = fields[5]
|
|
name = strings.Join(fields[6:], " ")
|
|
} else {
|
|
// BusyBox style: "Mon DD HH:MM" = 3 fields starting at index 5
|
|
modTime = strings.Join(fields[5:8], " ")
|
|
name = strings.Join(fields[8:], " ")
|
|
}
|
|
if name == "" || name == "." || name == ".." { continue }
|
|
files = append(files, FileInfo{
|
|
Name: name,
|
|
Size: size,
|
|
Mode: mode,
|
|
ModTime: modTime,
|
|
IsDir: mode[0] == 'd',
|
|
})
|
|
}
|
|
c.JSON(200, model.Response{Code: 200, Message: "success", Data: gin.H{"path": req.Path, "files": files}})
|
|
}
|
|
|
|
// DownloadPodFileNS 下载 Pod 内指定路径的文件(通过 exec cat)
|
|
func (h *KubernetesHandler) DownloadPodFileNS(c *gin.Context) {
|
|
h.downloadPodFileWithNS(c, c.Param("namespace"))
|
|
}
|
|
func (h *KubernetesHandler) downloadPodFileWithNS(c *gin.Context, ns string) {
|
|
clusterID, _ := strconv.Atoi(c.Param("id"))
|
|
if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) }
|
|
podName := c.Param("podName")
|
|
filePath := c.Query("path")
|
|
container := c.Query("container")
|
|
if filePath == "" { c.JSON(400, model.Response{Code: 400, Message: "path 参数不能为空"}); return }
|
|
client, _, err := h.getK8sClient(clusterID)
|
|
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
|
|
cmd := []string{"cat", filePath}
|
|
result, err := client.ExecInPod(ns, podName, container, cmd)
|
|
if err != nil { c.JSON(500, model.Response{Code: 500, Message: "读取文件失败: " + err.Error()}); return }
|
|
content := result.Stdout
|
|
fileName := path.Base(filePath)
|
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileName))
|
|
c.Header("Content-Type", "application/octet-stream")
|
|
c.String(200, content)
|
|
}
|
|
|
|
// UploadPodFileNS 上传文件到 Pod 容器内指定目录(通过 exec base64 分块写入)
|
|
func (h *KubernetesHandler) UploadPodFileNS(c *gin.Context) {
|
|
h.uploadPodFileWithNS(c, c.Param("namespace"))
|
|
}
|
|
func (h *KubernetesHandler) uploadPodFileWithNS(c *gin.Context, ns string) {
|
|
clusterID, _ := strconv.Atoi(c.Param("id"))
|
|
if clusterID == 0 {
|
|
clusterID = h.resolveClusterCode(c.Param("clusterCode"))
|
|
}
|
|
podName := c.Param("podName")
|
|
container := c.PostForm("container")
|
|
targetDir := c.PostForm("path")
|
|
if targetDir == "" {
|
|
targetDir = "/"
|
|
}
|
|
|
|
// 读取上传的文件
|
|
file, header, err := c.Request.FormFile("file")
|
|
if err != nil {
|
|
c.JSON(400, model.Response{Code: 400, Message: "文件上传失败: " + err.Error()})
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
// 限制文件大小 10MB
|
|
const maxUploadSize = 10 << 20 // 10MB
|
|
if header.Size > maxUploadSize {
|
|
c.JSON(400, model.Response{Code: 400, Message: fmt.Sprintf("文件过大: %d bytes, 上限 %d bytes", header.Size, maxUploadSize)})
|
|
return
|
|
}
|
|
|
|
content, err := io.ReadAll(file)
|
|
if err != nil {
|
|
c.JSON(500, model.Response{Code: 500, Message: "读取文件失败: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
client, _, err := h.getK8sClient(clusterID)
|
|
if err != nil {
|
|
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
|
|
return
|
|
}
|
|
|
|
// 目标完整路径
|
|
targetFile := strings.TrimRight(targetDir, "/") + "/" + header.Filename
|
|
|
|
// 确保目标目录存在
|
|
_, _ = client.ExecInPod(ns, podName, container, []string{"mkdir", "-p", targetDir})
|
|
|
|
// base64 分块写入:每块 3000 字节 base64 (~2250 bytes 二进制),保证 URL 长度安全
|
|
encoded := base64.StdEncoding.EncodeToString(content)
|
|
const chunkSize = 3000
|
|
first := true
|
|
for i := 0; i < len(encoded); i += chunkSize {
|
|
end := i + chunkSize
|
|
if end > len(encoded) {
|
|
end = len(encoded)
|
|
}
|
|
chunk := encoded[i:end]
|
|
var cmd []string
|
|
if first {
|
|
// 首块:创建/覆盖文件
|
|
cmd = []string{"sh", "-c", fmt.Sprintf("echo -n '%s' | base64 -d > '%s'", chunk, targetFile)}
|
|
first = false
|
|
} else {
|
|
// 后续块:追加
|
|
cmd = []string{"sh", "-c", fmt.Sprintf("echo -n '%s' | base64 -d >> '%s'", chunk, targetFile)}
|
|
}
|
|
result, err := client.ExecInPod(ns, podName, container, cmd)
|
|
if err != nil {
|
|
stderr := ""
|
|
if result != nil {
|
|
stderr = result.Stderr
|
|
}
|
|
c.JSON(500, model.Response{Code: 500, Message: fmt.Sprintf("写入文件失败 (chunk %d): %s\n%s", i/chunkSize, err.Error(), stderr)})
|
|
return
|
|
}
|
|
}
|
|
|
|
// 验证文件大小
|
|
verifyResult, verifyErr := client.ExecInPod(ns, podName, container, []string{"stat", "-c", "%s", targetFile})
|
|
writtenSize := ""
|
|
if verifyErr == nil && verifyResult != nil {
|
|
writtenSize = strings.TrimSpace(verifyResult.Stdout)
|
|
}
|
|
|
|
log.Printf("[UploadPodFile] %s/%s → %s (%d bytes, %d chunks)", ns, podName, targetFile, len(content), (len(encoded)+chunkSize-1)/chunkSize)
|
|
|
|
c.JSON(200, model.Response{
|
|
Code: 200,
|
|
Message: "上传成功",
|
|
Data: gin.H{
|
|
"path": targetFile,
|
|
"name": header.Filename,
|
|
"size": len(content),
|
|
"verify": writtenSize,
|
|
},
|
|
})
|
|
}
|
|
|
|
// ============================================================================
|
|
// M13: 多集群资源对比看板 (Multi-Cluster Comparison Dashboard) // Logan.Li
|
|
// ============================================================================
|
|
|
|
// GetMultiClusterComparison 聚合所有集群的资源数据进行对比
|