feat(audit): F1+F2 告警静默规则标记完成 + 审计日志CSV导出实现
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 ✅
This commit is contained in:
1 parent
e57718998a
commit
77c234b060
314 files changed
+10050
-4065
No files matched your search
@@ -0,0 +1,206 @@
|
||||
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 聚合所有集群的资源数据进行对比
|
||||
Reference in new issue
Block a user