This commit is contained in:
Logan committed 2026-07-09 14:16:56 +08:00
1 parent 0690285147
commit 5618cd2738
102 files changed
+3336 -1476

No files matched your search

+208 -36
View File
@@ -19,6 +19,7 @@ import (
"ezops/model"
"ezops/pkg/agentcli"
"ezops/pkg/agentcli/core"
"ezops/pkg/agentcli/guardrail"
"ezops/pkg/agentcli/llm"
"ezops/pkg/agentcli/safety"
"ezops/pkg/agentcli/tools"
@@ -86,7 +87,7 @@ type agentConn struct {
server model.Server
sshCfg remoteexec.SSHConfig
kubernetesCtx *kubernetesAgentContext
session *model.AgentCLISession
session *model.AIRun
taskCtx context.Context
cancel context.CancelFunc
totalTokens int
@@ -97,6 +98,9 @@ type agentConn struct {
convHistory []llm.Message
running bool
runningMu sync.Mutex
pendingSpans map[string]string
seq int
seqMu sync.Mutex
}
// Run WebSocket 入口 // Logan.Li
@@ -144,7 +148,7 @@ func (h *AgentCLIHandler) runHostWS(c *gin.Context) {
ac := &agentConn{
conn: conn, db: h.db, mode: agentcli.RunModeHost,
userID: middleware.GetUserID(c), server: server, sshCfg: sshCfg,
taskCtx: context.Background(), confirms: make(map[string]chan bool),
taskCtx: context.Background(), confirms: make(map[string]chan bool), pendingSpans: make(map[string]string),
}
ac.write(wsEvent{Type: "session", SessionID: 0,
Message: fmt.Sprintf("已连接主机 %s(%s),LLM: %s。请描述要执行的任务。", server.Hostname, server.IP, llmCfg.Model)})
@@ -181,7 +185,7 @@ func (h *AgentCLIHandler) runKubernetesWS(c *gin.Context) {
ac := &agentConn{
conn: conn, db: h.db, mode: agentcli.RunModeKubernetes,
userID: middleware.GetUserID(c), kubernetesCtx: kCtx,
taskCtx: context.Background(), confirms: make(map[string]chan bool),
taskCtx: context.Background(), confirms: make(map[string]chan bool), pendingSpans: make(map[string]string),
}
target := buildKubernetesTargetInfo(kCtx)
ac.write(wsEvent{Type: "session", SessionID: 0,
@@ -194,7 +198,9 @@ func (ac *agentConn) wsReadLoop() {
for {
_, data, err := ac.conn.ReadMessage()
if err != nil {
if ac.isRunning() {
ac.abort()
}
return
}
var ctrl wsControl
@@ -256,20 +262,27 @@ func (ac *agentConn) startTask(query, safetyMode string) {
if r := []rune(title); len(r) > 60 {
title = string(r[:60]) + "..."
}
sess := &model.AgentCLISession{
UserID: ac.userID, Mode: ac.mode, Title: title, Status: "running",
sess := &model.AIRun{
RunID: guardrail.NewTraceID(), UserID: ac.userID, Feature: "agent_cli",
Mode: ac.mode, Title: title, Status: "running",
Provider: llmCfg.Provider, Model: llmCfg.Model,
SafetyMode: safetyMode, StartedAt: time.Now(),
}
if ac.mode == agentcli.RunModeKubernetes && ac.kubernetesCtx != nil {
sess.ClusterID = ac.kubernetesCtx.cluster.ID
sess.ClusterName = ac.kubernetesCtx.cluster.Name
sess.Namespace = ac.kubernetesCtx.namespace
sess.PodName = ac.kubernetesCtx.podName
sess.Container = ac.kubernetesCtx.container
sess.SubjectType = "k8s_cluster"
if ac.kubernetesCtx.podName != "" {
sess.SubjectType = "pod"
} else if ac.kubernetesCtx.namespace != "" {
sess.SubjectType = "namespace"
}
sess.SubjectID = fmt.Sprintf("%d", ac.kubernetesCtx.cluster.ID)
sess.SubjectName = ac.kubernetesCtx.cluster.Name
sess.SubjectScope = ac.kubernetesCtx.namespace
} else {
sess.ServerID = ac.server.ID
sess.ServerName = ac.server.Hostname
sess.ServerHost = ac.server.IP
sess.SubjectType = "host"
sess.SubjectID = fmt.Sprintf("%d", ac.server.ID)
sess.SubjectName = ac.server.Hostname
sess.SubjectScope = ac.server.IP
}
if err := ac.db.Create(sess).Error; err != nil {
ac.write(wsEvent{Type: "error", Message: "创建会话失败: " + err.Error()})
@@ -287,15 +300,15 @@ func (ac *agentConn) resumeSession(sessionID uint) {
ac.write(wsEvent{Type: "error", Message: "任务进行中,请等待完成或停止"})
return
}
var sess model.AgentCLISession
var sess model.AIRun
q := ac.db.Where("user_id = ?", ac.userID)
if ac.mode == agentcli.RunModeKubernetes {
q = q.Where("mode IN ?", []string{agentcli.RunModeKubernetes, "k8s"})
if ac.kubernetesCtx != nil {
q = q.Where("cluster_id = ? AND namespace = ?", ac.kubernetesCtx.cluster.ID, ac.kubernetesCtx.namespace)
q = q.Where("subject_id = ? AND subject_scope = ?", fmt.Sprintf("%d", ac.kubernetesCtx.cluster.ID), ac.kubernetesCtx.namespace)
}
} else {
q = q.Where("mode = ? AND server_id = ?", ac.mode, ac.server.ID)
q = q.Where("mode = ? AND subject_type = ? AND subject_id = ?", ac.mode, "host", fmt.Sprintf("%d", ac.server.ID))
}
if err := q.First(&sess, sessionID).Error; err != nil {
ac.write(wsEvent{Type: "error", Message: "会话不存在或无权访问"})
@@ -305,8 +318,8 @@ func (ac *agentConn) resumeSession(sessionID uint) {
ac.write(wsEvent{Type: "error", Message: "该会话仍在执行中,无法恢复"})
return
}
var msgs []model.AgentCLIMessage
ac.db.Where("session_id = ?", sessionID).Order("id ASC").Find(&msgs)
var msgs []model.AIRunMessage
ac.db.Where("run_id = ?", sess.RunID).Order("seq ASC, id ASC").Find(&msgs)
ac.session = &sess
ac.convHistory = buildConvHistoryFromMessages(msgs)
ac.setTotalTokens(sess.TotalTokens)
@@ -340,7 +353,7 @@ func (ac *agentConn) continueTask(query, safetyMode string) {
}
// runTask 执行一轮 ReAct(新建或追问共用) // Logan.Li
func (ac *agentConn) runTask(sess *model.AgentCLISession, query, safetyMode string, llmCfg model.LLMConfig, isContinue bool) {
func (ac *agentConn) runTask(sess *model.AIRun, query, safetyMode string, llmCfg model.LLMConfig, isContinue bool) {
if ac.isRunning() {
ac.write(wsEvent{Type: "error", Message: "任务进行中,请等待完成或停止"})
return
@@ -393,9 +406,9 @@ func (ac *agentConn) runTask(sess *model.AgentCLISession, query, safetyMode stri
ac.taskCtx = ctx
ac.cancel = cancel
ac.setRunning(true)
ac.db.Model(sess).Update("status", "running")
ac.db.Model(sess).Updates(map[string]interface{}{"status": "running", "safety_mode": safetyMode})
ac.persistMsg(sess.ID, 0, "user", "user", query, "", "", "", "", false, "", 0)
ac.persistMsg(sess.RunID, 0, "user", "user", query, "", "", "", "", false, "", 0)
targetInfo := ac.buildTargetInfo()
@@ -457,6 +470,9 @@ func (ac *agentConn) onEvent(e core.Event) {
if e.Type == core.EventProgress && e.Tokens > 0 {
ac.setTotalTokens(e.Tokens)
}
if e.Type == core.EventDone || e.Type == core.EventError {
ac.setRunning(false)
}
ev := wsEvent{
Type: string(e.Type), Phase: e.Phase, Summary: e.Summary,
@@ -474,23 +490,25 @@ func (ac *agentConn) onEvent(e core.Event) {
if ac.session == nil {
return
}
sid := ac.session.ID
runID := ac.session.RunID
switch e.Type {
case core.EventStatus:
// status 事件不改 DB schema:duration 字段在 type=status 时复用为 phase。
ac.persistMsg(sid, e.Round, "status", "system", e.Summary, e.ToolName, "", "", e.Risk, e.Phase != core.PhaseError && e.Phase != core.PhaseBlocked, e.Phase, e.Tokens)
ac.persistMsg(runID, e.Round, "status", "system", e.Summary, e.ToolName, "", "", e.Risk, e.Phase != core.PhaseError && e.Phase != core.PhaseBlocked, e.Phase, e.Tokens)
case core.EventThought:
ac.persistMsg(sid, e.Round, "thought", "assistant", e.Thought, "", "", "", "", false, "", 0)
ac.persistMsg(runID, e.Round, "thought", "assistant", e.Thought, "", "", "", "", false, "", 0)
case core.EventToolCall:
argsJSON, _ := json.Marshal(e.ToolArgs)
ac.persistMsg(sid, e.Round, "tool_call", "tool", "", e.ToolName, string(argsJSON), "", e.Risk, false, "", 0)
ac.persistMsg(runID, e.Round, "tool_call", "tool", string(argsJSON), e.ToolName, string(argsJSON), "", e.Risk, false, "", 0)
ac.startToolSpan(e, string(argsJSON))
case core.EventToolResult:
ac.persistMsg(sid, e.Round, "tool_result", "tool", e.Output, e.ToolName, "", "", "", e.Success, e.Duration, 0)
ac.persistMsg(runID, e.Round, "tool_result", "tool", e.Output, e.ToolName, "", "", "", e.Success, e.Duration, 0)
ac.finishToolSpan(e)
case core.EventDone:
ac.setTotalTokens(e.Tokens)
ac.persistMsg(sid, e.Round, "done", "assistant", e.Answer, "", "", "", "", true, "", e.Tokens)
ac.persistMsg(runID, e.Round, "done", "assistant", e.Answer, "", "", "", "", true, "", e.Tokens)
case core.EventError:
ac.persistMsg(sid, e.Round, "error", "assistant", e.Message, "", "", "", "", false, "", 0)
ac.persistMsg(runID, e.Round, "error", "assistant", e.Message, "", "", "", "", false, "", 0)
}
}
@@ -519,6 +537,7 @@ func (ac *agentConn) confirmFn(toolName string, args map[string]interface{}, ris
select {
case approved := <-ch:
ac.recordConfirmSpan(toolName, args, risk, approved)
return approved, nil
case <-ac.taskCtx.Done():
ac.delConfirm(confirmID)
@@ -529,6 +548,25 @@ func (ac *agentConn) confirmFn(toolName string, args map[string]interface{}, ris
}
}
func (ac *agentConn) recordConfirmSpan(toolName string, args map[string]interface{}, risk tools.RiskLevel, approved bool) {
if ac.session == nil {
return
}
decision := "rejected"
status := "skipped"
if approved {
decision = "approved"
status = "success"
}
argsJSON, _ := json.Marshal(args)
span := &model.AIRunSpan{
RunID: ac.session.RunID, SpanID: guardrail.NewSpanID(), Kind: "confirm", Name: toolName,
ToolSource: ac.toolSource(toolName), Input: guardrail.SanitizeJSON(string(argsJSON)),
Risk: risk.String(), Decision: decision, Status: status, StartedAt: time.Now(), CreatedAt: time.Now(),
}
_ = ac.db.Create(span).Error
}
// resolveConfirm 前端回传确认结果 // Logan.Li
func (ac *agentConn) resolveConfirm(confirmID string, approved bool) {
ac.cfMu.Lock()
@@ -571,13 +609,13 @@ func (ac *agentConn) lastRound() int {
return 0
}
var maxRound int
ac.db.Model(&model.AgentCLIMessage{}).Where("session_id = ?", ac.session.ID).
ac.db.Model(&model.AIRunMessage{}).Where("run_id = ?", ac.session.RunID).
Select("COALESCE(MAX(round),0)").Scan(&maxRound)
return maxRound
}
// finish 结束本轮执行并更新会话 DB // Logan.Li
func (ac *agentConn) finish(sess *model.AgentCLISession, status, errMsg string, rounds int, dur time.Duration) {
func (ac *agentConn) finish(sess *model.AIRun, status, errMsg string, rounds int, dur time.Duration) {
ac.setRunning(false)
tokens := ac.getTotalTokens()
// done/error 已由引擎 onEvent 推送;此处仅 aborted 需补推 WS(引擎初始化失败在 runTask 内单独推送) // Logan.Li
@@ -588,6 +626,10 @@ func (ac *agentConn) finish(sess *model.AgentCLISession, status, errMsg string,
"status": status, "rounds": rounds, "duration": dur.Milliseconds(),
"total_tokens": tokens,
}
if status == "done" || status == "error" || status == "aborted" {
now := time.Now()
updates["finished_at"] = &now
}
if errMsg != "" {
updates["error"] = errMsg
}
@@ -602,15 +644,145 @@ func (ac *agentConn) write(ev wsEvent) {
_ = ac.conn.WriteMessage(websocket.TextMessage, b)
}
// persistMsg 持久化一条消息 // Logan.Li
func (ac *agentConn) persistMsg(sid uint, round int, typ, role, content, toolName, args, output, risk string, success bool, dur string, tokens int) {
msg := &model.AgentCLIMessage{
SessionID: sid, Round: round, Type: typ, Role: role, Content: content,
ToolName: toolName, ToolArgs: args, ToolOutput: output, Risk: risk,
Success: success, Duration: dur, Tokens: tokens,
// persistMsg 持久化一条 AI Run 回放消息。 // Logan.Li
func (ac *agentConn) persistMsg(runID string, round int, typ, role, content, toolName, args, output, risk string, success bool, dur string, tokens int) {
content = guardrail.SanitizeText(content)
if typ == "tool_call" {
content = guardrail.SanitizeJSON(content)
}
msg := &model.AIRunMessage{
RunID: runID, Round: round, Seq: ac.nextSeq(),
Type: typ, Role: role, Content: content,
ToolName: toolName, Tokens: tokens,
}
if err := ac.db.Create(msg).Error; err != nil {
// 持久化失败不阻断主流程
_ = err
}
}
func (ac *agentConn) nextSeq() int {
ac.seqMu.Lock()
defer ac.seqMu.Unlock()
ac.seq++
return ac.seq
}
func (ac *agentConn) startToolSpan(e core.Event, argsJSON string) {
if ac.session == nil {
return
}
spanID := guardrail.NewSpanID()
key := ac.pendingSpanKey(e.Round, e.ToolName)
ac.runningMu.Lock()
ac.pendingSpans[key] = spanID
ac.runningMu.Unlock()
span := &model.AIRunSpan{
RunID: ac.session.RunID, SpanID: spanID, Kind: "tool", Name: e.ToolName,
ToolSource: ac.toolSource(e.ToolName), Input: guardrail.SanitizeJSON(argsJSON),
Risk: e.Risk, Decision: "auto", Status: "running",
StartedAt: time.Now(),
}
_ = ac.db.Create(span).Error
}
func (ac *agentConn) finishToolSpan(e core.Event) {
if ac.session == nil {
return
}
key := ac.pendingSpanKey(e.Round, e.ToolName)
ac.runningMu.Lock()
spanID := ac.pendingSpans[key]
delete(ac.pendingSpans, key)
ac.runningMu.Unlock()
if spanID == "" {
return
}
status := "success"
if !e.Success {
status = "error"
}
now := time.Now()
ac.db.Model(&model.AIRunSpan{}).Where("span_id = ?", spanID).Updates(map[string]interface{}{
"output": guardrail.SanitizeText(e.Output), "output_size": e.OutputSize,
"duration": parseDurationMillis(e.Duration), "status": status, "ended_at": &now,
})
ac.recordOpsCommand(e, spanID, status)
}
func (ac *agentConn) pendingSpanKey(round int, toolName string) string {
return fmt.Sprintf("%d:%s", round, toolName)
}
func (ac *agentConn) toolSource(toolName string) string {
switch toolName {
case "ssh_execute":
return "shell"
case "kubernetes_diagnose", "kubernetes_get", "pod_logs", "pod_execute", "kubernetes_action":
return "kubernetes"
case "aiops_asset_search", "aiops_alert_list", "aiops_metric_summary":
return "aiops"
default:
return "builtin"
}
}
func (ac *agentConn) recordOpsCommand(e core.Event, spanID, status string) {
if ac.session == nil {
return
}
command := e.ToolName
if e.ToolArgs != nil {
if cmd, ok := e.ToolArgs["command"].(string); ok && cmd != "" {
command = cmd
} else if action, ok := e.ToolArgs["action"]; ok {
command = fmt.Sprintf("%v %v", action, e.ToolArgs["name"])
}
}
targetType, targetID := ac.currentTarget()
cmd := &model.OpsCommand{
CommandID: guardrail.NewSpanID(), RunID: ac.session.RunID, SpanID: spanID,
UserID: ac.userID, ActorType: "ai", TargetType: targetType, TargetID: targetID,
CommandType: ac.commandType(e.ToolName), Command: guardrail.SanitizeText(command),
Risk: e.Risk, Decision: "auto", Status: status, OutputSize: e.OutputSize,
Duration: parseDurationMillis(e.Duration), ExecutedAt: time.Now(),
}
if !e.Success {
cmd.Error = guardrail.SanitizeText(e.Output)
}
_ = ac.db.Create(cmd).Error
}
func (ac *agentConn) currentTarget() (string, string) {
if ac.mode == agentcli.RunModeKubernetes && ac.kubernetesCtx != nil {
if ac.kubernetesCtx.podName != "" {
return "pod", fmt.Sprintf("%d/%s/%s", ac.kubernetesCtx.cluster.ID, ac.kubernetesCtx.namespace, ac.kubernetesCtx.podName)
}
return "k8s_cluster", fmt.Sprintf("%d", ac.kubernetesCtx.cluster.ID)
}
return "host", fmt.Sprintf("%d", ac.server.ID)
}
func (ac *agentConn) commandType(toolName string) string {
switch toolName {
case "ssh_execute", "pod_execute":
return "shell"
case "kubernetes_diagnose", "kubernetes_action", "kubernetes_get", "pod_logs":
return "kubernetes"
case "aiops_asset_search", "aiops_alert_list", "aiops_metric_summary":
return "api"
default:
return "tool"
}
}
func parseDurationMillis(raw string) int64 {
if raw == "" {
return 0
}
d, err := time.ParseDuration(raw)
if err != nil {
return 0
}
return d.Milliseconds()
}
+13 -11
View File
@@ -26,7 +26,7 @@ func (h *AgentCLIHandler) ListSessions(c *gin.Context) {
pageSize = 20
}
q := h.db.Model(&model.AgentCLISession{}).Where("user_id = ?", uid)
q := h.db.Model(&model.AIRun{}).Where("user_id = ? AND feature = ?", uid, "agent_cli")
if mode := c.Query("mode"); mode != "" {
mode = agentcli.NormalizeRunMode(mode)
if mode == agentcli.RunModeKubernetes {
@@ -36,18 +36,18 @@ func (h *AgentCLIHandler) ListSessions(c *gin.Context) {
}
}
if sid := c.Query("server_id"); sid != "" {
q = q.Where("server_id = ?", sid)
q = q.Where("subject_type = ? AND subject_id = ?", "host", sid)
}
if cid := c.Query("cluster_id"); cid != "" {
q = q.Where("cluster_id = ?", cid)
q = q.Where("subject_id = ?", cid)
}
if ns := c.Query("namespace"); ns != "" {
q = q.Where("namespace = ?", ns)
q = q.Where("subject_scope = ?", ns)
}
var total int64
q.Count(&total)
var sessions []model.AgentCLISession
var sessions []model.AIRun
if err := q.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&sessions).Error; err != nil {
c.JSON(http.StatusOK, model.Response{Code: 500, Message: "查询失败: " + err.Error()})
return
@@ -60,13 +60,13 @@ func (h *AgentCLIHandler) ListSessions(c *gin.Context) {
func (h *AgentCLIHandler) GetSession(c *gin.Context) {
uid := middleware.GetUserID(c)
id := c.Param("id")
var sess model.AgentCLISession
var sess model.AIRun
if err := h.db.Where("user_id = ?", uid).First(&sess, id).Error; err != nil {
c.JSON(http.StatusOK, model.Response{Code: 404, Message: "会话不存在"})
return
}
var msgs []model.AgentCLIMessage
h.db.Where("session_id = ?", id).Order("id ASC").Find(&msgs)
var msgs []model.AIRunMessage
h.db.Where("run_id = ?", sess.RunID).Order("seq ASC, id ASC").Find(&msgs)
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"session": sess, "messages": msgs}})
}
@@ -75,16 +75,18 @@ func (h *AgentCLIHandler) GetSession(c *gin.Context) {
func (h *AgentCLIHandler) DeleteSession(c *gin.Context) {
uid := middleware.GetUserID(c)
id := c.Param("id")
var sess model.AgentCLISession
var sess model.AIRun
if err := h.db.Where("user_id = ?", uid).First(&sess, id).Error; err != nil {
c.JSON(http.StatusOK, model.Response{Code: 404, Message: "会话不存在"})
return
}
if err := h.db.Where("session_id = ?", id).Delete(&model.AgentCLIMessage{}).Error; err != nil {
if err := h.db.Where("run_id = ?", sess.RunID).Delete(&model.AIRunMessage{}).Error; err != nil {
c.JSON(http.StatusOK, model.Response{Code: 500, Message: "删除消息失败: " + err.Error()})
return
}
if err := h.db.Delete(&model.AgentCLISession{}, id).Error; err != nil {
_ = h.db.Where("run_id = ?", sess.RunID).Delete(&model.AIRunSpan{}).Error
_ = h.db.Where("run_id = ?", sess.RunID).Delete(&model.OpsCommand{}).Error
if err := h.db.Delete(&model.AIRun{}, id).Error; err != nil {
c.JSON(http.StatusOK, model.Response{Code: 500, Message: "删除会话失败: " + err.Error()})
return
}
+2 -5
View File
@@ -10,7 +10,7 @@ import (
)
// buildConvHistoryFromMessages 将 DB 消息重建为追问用的 prior 历史(不含 system) // Logan.Li
func buildConvHistoryFromMessages(msgs []model.AgentCLIMessage) []llm.Message {
func buildConvHistoryFromMessages(msgs []model.AIRunMessage) []llm.Message {
var out []llm.Message
var pendingThought string
var pendingToolCalls []llm.ToolCall
@@ -40,7 +40,7 @@ func buildConvHistoryFromMessages(msgs []model.AgentCLIMessage) []llm.Message {
case "thought":
pendingThought = m.Content
case "tool_call":
args := validateToolArgsJSON(m.ToolArgs)
args := validateToolArgsJSON(m.Content)
tcID := fmt.Sprintf("hist-%d", m.ID)
pendingToolCalls = append(pendingToolCalls, llm.ToolCall{
ID: tcID, Type: "function",
@@ -56,9 +56,6 @@ func buildConvHistoryFromMessages(msgs []model.AgentCLIMessage) []llm.Message {
pendingToolCallIDs = pendingToolCallIDs[1:]
}
content := m.Content
if content == "" {
content = m.ToolOutput
}
out = append(out, llm.Message{
Role: "tool", Name: m.ToolName, Content: content, ToolCallID: toolID,
})
-2
View File
@@ -473,5 +473,3 @@ func (h *AlertHandler) NotifyRecovery(ruleName, hostname, metric, message string
}
}
}
+21 -7
View File
@@ -60,11 +60,21 @@ func (h *AlertHandler) UpdateContact(c *gin.Context) {
return
}
updates := map[string]interface{}{}
if req.Name != "" { updates["name"] = req.Name }
if req.Phone != "" { updates["phone"] = req.Phone }
if req.Email != "" { updates["email"] = req.Email }
if req.ChannelType != "" { updates["channel_type"] = req.ChannelType }
if req.WebhookURL != "" { updates["webhook_url"] = req.WebhookURL }
if req.Name != "" {
updates["name"] = req.Name
}
if req.Phone != "" {
updates["phone"] = req.Phone
}
if req.Email != "" {
updates["email"] = req.Email
}
if req.ChannelType != "" {
updates["channel_type"] = req.ChannelType
}
if req.WebhookURL != "" {
updates["webhook_url"] = req.WebhookURL
}
h.db.Model(&contact).Updates(updates)
h.db.First(&contact, id)
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "更新成功", Data: contact})
@@ -127,8 +137,12 @@ func (h *AlertHandler) UpdateContactGroup(c *gin.Context) {
return
}
updates := map[string]interface{}{}
if req.GroupName != "" { updates["group_name"] = req.GroupName }
if req.Notes != "" { updates["notes"] = req.Notes }
if req.GroupName != "" {
updates["group_name"] = req.GroupName
}
if req.Notes != "" {
updates["notes"] = req.Notes
}
if len(req.ContactIDs) > 0 {
idsJSON, _ := json.Marshal(req.ContactIDs)
updates["contact_ids"] = string(idsJSON)
-1
View File
@@ -199,4 +199,3 @@ func (h *CloudAccountHandler) DeleteCloudAccount(c *gin.Context) {
Message: "删除成功",
})
}
-1
View File
@@ -227,4 +227,3 @@ func (h *DomainHandler) UpdateDomain(c *gin.Context) {
Data: domain,
})
}
-1
View File
@@ -129,4 +129,3 @@ func (h *PlatformHandler) DeletePlatform(c *gin.Context) {
}
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "删除成功"})
}
+53 -12
View File
@@ -227,10 +227,28 @@ func (h *CloudSyncHandler) ImportInstances(c *gin.Context) {
ip = inst.InstanceID
}
// 检查是否已存在(按 IP 或 hostname)
var count int64
h.db.Model(&model.Server{}).Where("ip = ? OR hostname = ?", ip, inst.Hostname).Count(&count)
if count > 0 {
hostname := cloudImportHostname(inst)
alias := cloudImportAlias(inst)
// 检查是否已存在(优先实例 ID,其次 IP/hostname),重复导入只补空别名,避免覆盖人工命名。 // Logan.Li
var existing model.Server
existingQuery := h.db.Where("ip = ? OR hostname = ?", ip, hostname)
if inst.InstanceID != "" {
existingQuery = h.db.Where("cloud_instance_id = ? OR ip = ? OR hostname = ?", inst.InstanceID, ip, hostname)
}
if err := existingQuery.Take(&existing).Error; err == nil {
updates := map[string]interface{}{
"cloud_instance_name": inst.InstanceName,
"cloud_instance_id": inst.InstanceID,
"description": "从 " + account.Provider + " 导入 (" + inst.InstanceID + ")",
}
if existing.Alias == "" && alias != "" {
updates["alias"] = alias
}
h.db.Model(&existing).Updates(updates)
skipped++
continue
} else if err != gorm.ErrRecordNotFound {
skipped++
continue
}
@@ -264,18 +282,13 @@ func (h *CloudSyncHandler) ImportInstances(c *gin.Context) {
if ipAddr == "" {
ipAddr = inst.InstanceID
}
hostname := inst.Hostname
if hostname == "" {
hostname = inst.InstanceName
}
if hostname == "" {
hostname = inst.InstanceID
}
// 注意:servers 表的 cloud_id 外键引用的是 clouds 表(非 cloud_accounts),
// region_id 外键引用 regions 表。未找到关联时设为 NULL 避免外键约束失败。
server := model.Server{
Hostname: hostname,
Alias: alias,
CloudInstanceName: inst.InstanceName,
CloudInstanceID: inst.InstanceID,
IP: ipAddr,
PublicIP: inst.PublicIP,
Port: defaultPort,
@@ -317,3 +330,31 @@ func (h *CloudSyncHandler) ImportInstances(c *gin.Context) {
},
})
}
func cloudImportHostname(inst cloud.CloudInstance) string {
// hostname 保留技术主机名;展示友好名称写入 alias/cloud_instance_name。 // Logan.Li
if inst.Hostname != "" && inst.Hostname != inst.InstanceID {
return inst.Hostname
}
if inst.InstanceName != "" && inst.InstanceName != inst.InstanceID {
return inst.InstanceName
}
if inst.PrivateIP != "" {
return inst.PrivateIP
}
if inst.PublicIP != "" {
return inst.PublicIP
}
if inst.InstanceName != "" {
return inst.InstanceName
}
return inst.InstanceID
}
func cloudImportAlias(inst cloud.CloudInstance) string {
// 云导入初始别名优先使用控制台实例名称,后续人工别名不被同步覆盖。 // Logan.Li
if inst.InstanceName != "" && inst.InstanceName != inst.InstanceID {
return inst.InstanceName
}
return ""
}
-258
View File
@@ -1,258 +0,0 @@
package controller
import (
"encoding/json"
"net/http"
"strconv"
ezops "ezops/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// CustomDashboardHandler 自定义监控仪表盘 handler // Logan.Li
type CustomDashboardHandler struct {
db *gorm.DB
}
// NewCustomDashboardHandler 创建 handler
func NewCustomDashboardHandler(db *gorm.DB) *CustomDashboardHandler {
return &CustomDashboardHandler{db: db}
}
// ============================================
// DTO
// ============================================
type CreateDashboardReq struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Datasource string `json:"datasource"`
DatasourceURL string `json:"datasource_url"`
Panels string `json:"panels"` // JSON string
IsDefault bool `json:"is_default"`
}
type UpdateDashboardReq struct {
Name *string `json:"name"`
Description *string `json:"description"`
Datasource *string `json:"datasource"`
DatasourceURL *string `json:"datasource_url"`
Panels *string `json:"panels"`
IsDefault *bool `json:"is_default"`
IsFavorite *bool `json:"is_favorite"`
}
// ============================================
// CRUD
// ============================================
// List 仪表盘列表
// GET /api/v1/custom-dashboards
func (h *CustomDashboardHandler) List(c *gin.Context) {
var dashboards []ezops.CustomDashboard
if err := h.db.Order("is_default DESC, updated_at DESC").Find(&dashboards).Error; err != nil {
c.JSON(http.StatusInternalServerError, ezops.Response{Code: 500, Message: "查询失败: " + err.Error()})
return
}
// 转换为前端期望的格式(id 转为 string,panels JSON 解析为数组)
result := make([]gin.H, 0, len(dashboards))
for _, d := range dashboards {
item := gin.H{
"id": strconv.Itoa(int(d.ID)),
"name": d.Name,
"description": d.Description,
"datasource": d.Datasource,
"datasourceUrl": d.DatasourceURL,
"datasource_url": d.DatasourceURL,
"createdAt": d.CreatedAt,
"updatedAt": d.UpdatedAt,
"createdBy": d.CreatedBy,
"isDefault": d.IsDefault,
"is_default": d.IsDefault,
}
// 解析 panels JSON
var panels []interface{}
if d.Panels != "" {
if err := json.Unmarshal([]byte(d.Panels), &panels); err == nil {
item["panels"] = panels
} else {
item["panels"] = []interface{}{}
}
} else {
item["panels"] = []interface{}{}
}
result = append(result, item)
}
c.JSON(http.StatusOK, ezops.Response{Code: 200, Data: gin.H{
"list": result,
"total": len(result),
}})
}
// Get 获取单个仪表盘
// GET /api/v1/custom-dashboards/:id
func (h *CustomDashboardHandler) Get(c *gin.Context) {
id := c.Param("id")
var d ezops.CustomDashboard
if err := h.db.First(&d, id).Error; err != nil {
c.JSON(http.StatusNotFound, ezops.Response{Code: 404, Message: "仪表盘不存在"})
return
}
var panels []interface{}
if d.Panels != "" {
json.Unmarshal([]byte(d.Panels), &panels)
}
if panels == nil {
panels = []interface{}{}
}
c.JSON(http.StatusOK, ezops.Response{Code: 200, Data: gin.H{
"id": strconv.Itoa(int(d.ID)),
"name": d.Name,
"description": d.Description,
"datasource": d.Datasource,
"datasourceUrl": d.DatasourceURL,
"datasource_url": d.DatasourceURL,
"panels": panels,
"createdAt": d.CreatedAt,
"updatedAt": d.UpdatedAt,
"createdBy": d.CreatedBy,
"isDefault": d.IsDefault,
"is_default": d.IsDefault,
}})
}
// Create 创建仪表盘
// POST /api/v1/custom-dashboards
func (h *CustomDashboardHandler) Create(c *gin.Context) {
var req CreateDashboardReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, ezops.Response{Code: 400, Message: "参数错误: " + err.Error()})
return
}
if req.Datasource == "" {
req.Datasource = "prometheus"
}
if req.Panels == "" {
req.Panels = "[]"
}
// 如果设为默认,先清除其他默认
if req.IsDefault {
h.db.Model(&ezops.CustomDashboard{}).Where("is_default = ?", true).Update("is_default", false)
}
// 从 JWT 获取用户名
username := "admin"
if u, exists := c.Get("username"); exists {
if s, ok := u.(string); ok {
username = s
}
}
d := ezops.CustomDashboard{
Name: req.Name,
Description: req.Description,
Datasource: req.Datasource,
DatasourceURL: req.DatasourceURL,
Panels: req.Panels,
CreatedBy: username,
IsDefault: req.IsDefault,
}
if err := h.db.Create(&d).Error; err != nil {
c.JSON(http.StatusInternalServerError, ezops.Response{Code: 500, Message: "创建失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, ezops.Response{Code: 200, Message: "创建成功", Data: gin.H{
"success": true,
"id": strconv.Itoa(int(d.ID)),
}})
}
// Update 更新仪表盘
// PUT /api/v1/custom-dashboards/:id
func (h *CustomDashboardHandler) Update(c *gin.Context) {
id := c.Param("id")
var d ezops.CustomDashboard
if err := h.db.First(&d, id).Error; err != nil {
c.JSON(http.StatusNotFound, ezops.Response{Code: 404, Message: "仪表盘不存在"})
return
}
var req UpdateDashboardReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, ezops.Response{Code: 400, Message: "参数错误: " + err.Error()})
return
}
updates := map[string]interface{}{}
if req.Name != nil {
updates["name"] = *req.Name
}
if req.Description != nil {
updates["description"] = *req.Description
}
if req.Datasource != nil {
updates["datasource"] = *req.Datasource
}
if req.DatasourceURL != nil {
updates["datasource_url"] = *req.DatasourceURL
}
if req.Panels != nil {
updates["panels"] = *req.Panels
}
if req.IsFavorite != nil {
updates["is_favorite"] = *req.IsFavorite
}
if req.IsDefault != nil && *req.IsDefault {
// 设为默认前先清除其他默认
h.db.Model(&ezops.CustomDashboard{}).Where("is_default = ? AND id != ?", true, d.ID).Update("is_default", false)
updates["is_default"] = true
}
if len(updates) > 0 {
if err := h.db.Model(&d).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, ezops.Response{Code: 500, Message: "更新失败: " + err.Error()})
return
}
}
c.JSON(http.StatusOK, ezops.Response{Code: 200, Message: "更新成功", Data: gin.H{"success": true}})
}
// Delete 删除仪表盘
// DELETE /api/v1/custom-dashboards/:id
func (h *CustomDashboardHandler) Delete(c *gin.Context) {
id := c.Param("id")
if err := h.db.Delete(&ezops.CustomDashboard{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, ezops.Response{Code: 500, Message: "删除失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, ezops.Response{Code: 200, Message: "删除成功", Data: gin.H{"success": true}})
}
// SetDefault 设为默认仪表盘
// POST /api/v1/custom-dashboards/:id/default
func (h *CustomDashboardHandler) SetDefault(c *gin.Context) {
id := c.Param("id")
var d ezops.CustomDashboard
if err := h.db.First(&d, id).Error; err != nil {
c.JSON(http.StatusNotFound, ezops.Response{Code: 404, Message: "仪表盘不存在"})
return
}
// 清除其他默认
h.db.Model(&ezops.CustomDashboard{}).Where("is_default = ? AND id != ?", true, d.ID).Update("is_default", false)
// 设置当前为默认
h.db.Model(&d).Update("is_default", true)
c.JSON(http.StatusOK, ezops.Response{Code: 200, Message: "设置成功", Data: gin.H{"success": true}})
}
-164
View File
@@ -1,164 +0,0 @@
package controller
import (
"net/http"
"ezops/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// DeployHandler 发布系统处理器
type DeployHandler struct {
db *gorm.DB
}
func NewDeployHandler(db *gorm.DB) *DeployHandler {
return &DeployHandler{db: db}
}
// ==================== Git 仓库认证 ====================
func (h *DeployHandler) ListRepos(c *gin.Context) {
var items []model.PipelineRepo
h.db.Order("updated_at DESC").Find(&items)
c.JSON(http.StatusOK, model.Response{Code: 200, Data: model.ListResponse{List: items, Total: int64(len(items))}})
}
func (h *DeployHandler) GetRepo(c *gin.Context) {
var item model.PipelineRepo
if err := h.db.First(&item, c.Param("id")); err.Error != nil {
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "不存在"})
return
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: item})
}
func (h *DeployHandler) CreateRepo(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
URL string `json:"url" binding:"required"`
AuthType model.RepoAuthType `json:"auth_type"`
CredentialID *uint `json:"credential_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
return
}
if req.AuthType == "" { req.AuthType = model.RepoAuthToken }
item := model.PipelineRepo{
Name: req.Name, Description: req.Description, URL: req.URL,
AuthType: req.AuthType, CredentialID: req.CredentialID,
CreatedBy: c.GetString("username"),
}
h.db.Create(&item)
c.JSON(http.StatusOK, model.Response{Code: 200, Data: item})
}
func (h *DeployHandler) UpdateRepo(c *gin.Context) {
var item model.PipelineRepo
if err := h.db.First(&item, c.Param("id")); err.Error != nil {
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "不存在"})
return
}
var req struct {
Name *string `json:"name"`
Description *string `json:"description"`
URL *string `json:"url"`
AuthType *model.RepoAuthType `json:"auth_type"`
CredentialID *uint `json:"credential_id"`
Enabled *bool `json:"enabled"`
}
c.ShouldBindJSON(&req)
updates := map[string]interface{}{}
if req.Name != nil { updates["name"] = *req.Name }
if req.Description != nil { updates["description"] = *req.Description }
if req.URL != nil { updates["url"] = *req.URL }
if req.AuthType != nil { updates["auth_type"] = *req.AuthType }
if req.Enabled != nil { updates["enabled"] = *req.Enabled }
if req.CredentialID != nil { updates["credential_id"] = req.CredentialID }
h.db.Model(&item).Updates(updates)
h.db.First(&item, item.ID)
c.JSON(http.StatusOK, model.Response{Code: 200, Data: item})
}
func (h *DeployHandler) DeleteRepo(c *gin.Context) {
h.db.Delete(&model.PipelineRepo{}, c.Param("id"))
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "已删除"})
}
// ==================== 镜像仓库认证 ====================
func (h *DeployHandler) ListRegistries(c *gin.Context) {
var items []model.PipelineRegistry
h.db.Order("updated_at DESC").Find(&items)
c.JSON(http.StatusOK, model.Response{Code: 200, Data: model.ListResponse{List: items, Total: int64(len(items))}})
}
func (h *DeployHandler) GetRegistry(c *gin.Context) {
var item model.PipelineRegistry
if err := h.db.First(&item, c.Param("id")); err.Error != nil {
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "不存在"})
return
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: item})
}
func (h *DeployHandler) CreateRegistry(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
RegistryURL string `json:"registry_url" binding:"required"`
Namespace string `json:"namespace"`
AuthType model.RegistryAuthType `json:"auth_type"`
CredentialID *uint `json:"credential_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
return
}
if req.AuthType == "" { req.AuthType = model.RegistryAuthPassword }
item := model.PipelineRegistry{
Name: req.Name, Description: req.Description, RegistryURL: req.RegistryURL,
Namespace: req.Namespace, AuthType: req.AuthType, CredentialID: req.CredentialID,
CreatedBy: c.GetString("username"),
}
h.db.Create(&item)
c.JSON(http.StatusOK, model.Response{Code: 200, Data: item})
}
func (h *DeployHandler) UpdateRegistry(c *gin.Context) {
var item model.PipelineRegistry
if err := h.db.First(&item, c.Param("id")); err.Error != nil {
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "不存在"})
return
}
var req struct {
Name *string `json:"name"`
Description *string `json:"description"`
RegistryURL *string `json:"registry_url"`
Namespace *string `json:"namespace"`
AuthType *model.RegistryAuthType `json:"auth_type"`
CredentialID *uint `json:"credential_id"`
Enabled *bool `json:"enabled"`
}
c.ShouldBindJSON(&req)
updates := map[string]interface{}{}
if req.Name != nil { updates["name"] = *req.Name }
if req.Description != nil { updates["description"] = *req.Description }
if req.RegistryURL != nil { updates["registry_url"] = *req.RegistryURL }
if req.Namespace != nil { updates["namespace"] = *req.Namespace }
if req.AuthType != nil { updates["auth_type"] = *req.AuthType }
if req.Enabled != nil { updates["enabled"] = *req.Enabled }
if req.CredentialID != nil { updates["credential_id"] = req.CredentialID }
h.db.Model(&item).Updates(updates)
h.db.First(&item, item.ID)
c.JSON(http.StatusOK, model.Response{Code: 200, Data: item})
}
func (h *DeployHandler) DeleteRegistry(c *gin.Context) {
h.db.Delete(&model.PipelineRegistry{}, c.Param("id"))
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "已删除"})
}
+32 -12
View File
@@ -35,7 +35,9 @@ func (h *PipelineHandler) CreateEnvironment(c *gin.Context) {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
return
}
if req.Color == "" { req.Color = typeDefaultColor(req.Type) }
if req.Color == "" {
req.Color = typeDefaultColor(req.Type)
}
item := model.DeployEnvironment{
Name: req.Name, DisplayName: req.DisplayName,
Type: model.EnvironmentType(req.Type), Color: req.Color,
@@ -67,13 +69,27 @@ func (h *PipelineHandler) UpdateEnvironment(c *gin.Context) {
}
c.ShouldBindJSON(&req)
updates := map[string]interface{}{}
if req.Name != nil { updates["name"] = *req.Name }
if req.DisplayName != nil { updates["display_name"] = *req.DisplayName }
if req.Type != nil { updates["type"] = *req.Type }
if req.Color != nil { updates["color"] = *req.Color }
if req.SortOrder != nil { updates["sort_order"] = *req.SortOrder }
if req.Description != nil { updates["description"] = *req.Description }
if req.Enabled != nil { updates["enabled"] = *req.Enabled }
if req.Name != nil {
updates["name"] = *req.Name
}
if req.DisplayName != nil {
updates["display_name"] = *req.DisplayName
}
if req.Type != nil {
updates["type"] = *req.Type
}
if req.Color != nil {
updates["color"] = *req.Color
}
if req.SortOrder != nil {
updates["sort_order"] = *req.SortOrder
}
if req.Description != nil {
updates["description"] = *req.Description
}
if req.Enabled != nil {
updates["enabled"] = *req.Enabled
}
h.db.Model(&item).Updates(updates)
h.db.First(&item, id)
c.JSON(http.StatusOK, model.Response{Code: 200, Data: item})
@@ -87,10 +103,14 @@ func (h *PipelineHandler) DeleteEnvironment(c *gin.Context) {
func typeDefaultColor(t string) string {
switch t {
case "production": return "#EF4444"
case "staging": return "#F59E0B"
case "testing": return "#10B981"
default: return "#3B82F6" // development
case "production":
return "#EF4444"
case "staging":
return "#F59E0B"
case "testing":
return "#10B981"
default:
return "#3B82F6" // development
}
}
+6 -2
View File
@@ -53,8 +53,12 @@ func (h *PipelineHandler) UpdateGroup(c *gin.Context) {
}
c.ShouldBindJSON(&req)
updates := map[string]interface{}{}
if req.Name != nil { updates["name"] = *req.Name }
if req.Description != nil { updates["description"] = *req.Description }
if req.Name != nil {
updates["name"] = *req.Name
}
if req.Description != nil {
updates["description"] = *req.Description
}
h.db.Model(&group).Updates(updates)
h.db.First(&group, id)
c.JSON(http.StatusOK, model.Response{Code: 200, Data: group})
+2 -2
View File
@@ -320,7 +320,7 @@ func (h *KubernetesHandler) SyncCluster(c *gin.Context) {
// Sync nodes
if nodes, err := client.ListNodes(); err == nil {
// Use raw SQL for reliability (avoid GORM soft-delete / auto-timestamp issues)
h.db.Exec("DELETE FROM kubernetes_nodes WHERE cluster_id = ?", cluster.ID)
h.db.Exec("DELETE FROM k8s_nodes WHERE cluster_id = ?", cluster.ID)
for _, n := range nodes.Items {
role := "worker"
if _, ok := n.Metadata.Labels["node-role.kubernetes.io/master"]; ok {
@@ -358,7 +358,7 @@ func (h *KubernetesHandler) SyncCluster(c *gin.Context) {
memUsagePct = float64(memMi) / float64(memMB) * 100
}
}
err2 := h.db.Exec(`INSERT INTO kubernetes_nodes (cluster_id, name, ip, role, status, os, kubelet_version, cpu, memory, max_pods, cpu_usage, memory_usage, labels, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
err2 := h.db.Exec(`INSERT INTO k8s_nodes (cluster_id, name, ip, role, status, os, kubelet_version, cpu, memory, max_pods, cpu_usage, memory_usage, labels, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
cluster.ID, n.Metadata.Name, ip, role, nodeStatus, n.Status.NodeInfo.OSImage, n.Status.NodeInfo.KubeletVersion,
parseIntResource(n.Status.Capacity["cpu"]), parseMemResource(n.Status.Capacity["memory"]), parseIntResource(n.Status.Capacity["pods"]),
cpuUsagePct, memUsagePct, string(labelsJSON), now, now).Error
+11 -3
View File
@@ -28,7 +28,7 @@ func (h *KubernetesHandler) GetMultiClusterComparison(c *gin.Context) {
APIServer string `json:"api_server"`
LastSyncAt *time.Time `json:"last_sync_at"`
// Node-level aggregated data from kubernetes_nodes table
// Node-level aggregated data from k8s_nodes table
TotalCPUCores int `json:"total_cpu_cores"`
TotalMemoryMB int64 `json:"total_memory_mb"`
MasterCount int `json:"master_count"`
@@ -64,8 +64,16 @@ func (h *KubernetesHandler) GetMultiClusterComparison(c *gin.Context) {
agg.TotalCPU += n.CPU
agg.TotalMemory += n.Memory
if n.CPUUsage > 0 || n.MemoryUsage > 0 {
if agg.AvgCPUUsage == 0 { agg.AvgCPUUsage = n.CPUUsage } else { agg.AvgCPUUsage = (agg.AvgCPUUsage*float64(agg.NodeCount-1) + n.CPUUsage) / float64(agg.NodeCount) }
if agg.AvgMemUsage == 0 { agg.AvgMemUsage = n.MemoryUsage } else { agg.AvgMemUsage = (agg.AvgMemUsage*float64(agg.NodeCount-1) + n.MemoryUsage) / float64(agg.NodeCount) }
if agg.AvgCPUUsage == 0 {
agg.AvgCPUUsage = n.CPUUsage
} else {
agg.AvgCPUUsage = (agg.AvgCPUUsage*float64(agg.NodeCount-1) + n.CPUUsage) / float64(agg.NodeCount)
}
if agg.AvgMemUsage == 0 {
agg.AvgMemUsage = n.MemoryUsage
} else {
agg.AvgMemUsage = (agg.AvgMemUsage*float64(agg.NodeCount-1) + n.MemoryUsage) / float64(agg.NodeCount)
}
}
if n.Role == "master" {
agg.MasterCount++
+15 -4
View File
@@ -14,14 +14,23 @@ import (
func (h *KubernetesHandler) ListEvents(c *gin.Context) {
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return }
if clusterID == 0 {
c.JSON(500, model.Response{Code: 500, Message: "cluster not found"})
return
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err != nil {
c.JSON(500, model.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, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()})
return
}
type EventSummary struct {
Namespace string `json:"namespace"`
@@ -40,7 +49,9 @@ func (h *KubernetesHandler) ListEvents(c *gin.Context) {
}
result := []EventSummary{}
for _, e := range events.Items {
if eventType != "" && e.Type != eventType { continue }
if eventType != "" && e.Type != eventType {
continue
}
objectKind := e.InvolvedObject.Kind
objectName := e.InvolvedObject.Name
object := fmt.Sprintf("%s/%s", objectKind, objectName)
+146 -41
View File
@@ -246,11 +246,19 @@ func (h *KubernetesHandler) DeleteCronJobNS(c *gin.Context) {
}
func (h *KubernetesHandler) listHPAsWithNS(c *gin.Context, ns string) {
clusterID, _ := strconv.Atoi(c.Param("id"))
if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) }
if clusterID == 0 {
clusterID = h.resolveClusterCode(c.Param("clusterCode"))
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
hpas, err := client.ListHPAs()
if err != nil { c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()})
return
}
type HPASummary struct {
Namespace string `json:"namespace"`
Name string `json:"name"`
@@ -264,12 +272,19 @@ func (h *KubernetesHandler) listHPAsWithNS(c *gin.Context, ns string) {
}
result := []HPASummary{}
for _, h := range hpas.Items {
if h.Metadata.Namespace != ns { continue }
cpu := 0; mem := 0
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 }
if m.Resource.Name == "cpu" {
cpu = m.Resource.Target.AverageUtilization
}
if m.Resource.Name == "memory" {
mem = m.Resource.Target.AverageUtilization
}
}
}
result = append(result, HPASummary{
@@ -288,80 +303,165 @@ func (h *KubernetesHandler) listHPAsWithNS(c *gin.Context, ns string) {
}
func (h *KubernetesHandler) createHPAWithNS(c *gin.Context, ns string) {
clusterID, _ := strconv.Atoi(c.Param("id"))
if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) }
if clusterID == 0 {
clusterID = h.resolveClusterCode(c.Param("clusterCode"))
}
var req k8sclient.CreateHPARequest
if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, model.Response{Code: 400, Message: "参数错误"}); return }
if req.Namespace == "" { req.Namespace = ns }
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, model.Response{Code: 400, Message: "参数错误"})
return
}
if req.Namespace == "" {
req.Namespace = ns
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
hpa, err := client.CreateHPA(req)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: "创建失败: " + err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: "创建失败: " + err.Error()})
return
}
c.JSON(200, model.Response{Code: 200, Data: hpa})
}
func (h *KubernetesHandler) deleteHPAWithNS(c *gin.Context, ns string) {
clusterID, _ := strconv.Atoi(c.Param("id"))
if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) }
if clusterID == 0 {
clusterID = h.resolveClusterCode(c.Param("clusterCode"))
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err := client.DeleteHPA(ns, c.Param("name")); err != nil { c.JSON(500, model.Response{Code: 500, Message: "删除失败: " + err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
if err := client.DeleteHPA(ns, c.Param("name")); err != nil {
c.JSON(500, model.Response{Code: 500, Message: "删除失败: " + err.Error()})
return
}
c.JSON(200, model.Response{Code: 200, Message: "已删除"})
}
func (h *KubernetesHandler) listCronJobsWithNS(c *gin.Context, ns string) {
clusterID, _ := strconv.Atoi(c.Param("id"))
if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) }
if clusterID == 0 {
clusterID = h.resolveClusterCode(c.Param("clusterCode"))
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
cjs, err := client.ListCronJobs()
if err != nil { c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()})
return
}
filtered := []k8sclient.CronJobItem{}
for _, cj := range cjs.Items { if cj.Metadata.Namespace == ns { filtered = append(filtered, cj) } }
for _, cj := range cjs.Items {
if cj.Metadata.Namespace == ns {
filtered = append(filtered, cj)
}
}
c.JSON(200, model.Response{Code: 200, Data: filtered})
}
func (h *KubernetesHandler) createCronJobWithNS(c *gin.Context, ns string) {
clusterID, _ := strconv.Atoi(c.Param("id"))
if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) }
if clusterID == 0 {
clusterID = h.resolveClusterCode(c.Param("clusterCode"))
}
var req k8sclient.CreateCronJobRequest
if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, model.Response{Code: 400, Message: "参数错误"}); return }
if req.Namespace == "" { req.Namespace = ns }
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, model.Response{Code: 400, Message: "参数错误"})
return
}
if req.Namespace == "" {
req.Namespace = ns
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
cj, err := client.CreateCronJob(req)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: "创建失败: " + err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: "创建失败: " + err.Error()})
return
}
c.JSON(200, model.Response{Code: 200, Data: cj})
}
func (h *KubernetesHandler) suspendCronJobWithNS(c *gin.Context, ns string) {
clusterID, _ := strconv.Atoi(c.Param("id"))
if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) }
var req struct { Suspend bool `json:"suspend"` }; c.ShouldBindJSON(&req)
if clusterID == 0 {
clusterID = h.resolveClusterCode(c.Param("clusterCode"))
}
var req struct {
Suspend bool `json:"suspend"`
}
c.ShouldBindJSON(&req)
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err := client.SuspendCronJob(ns, c.Param("name"), req.Suspend); err != nil { c.JSON(500, model.Response{Code: 500, Message: "操作失败: " + err.Error()}); return }
action := "恢复"; if req.Suspend { action = "暂停" }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
if err := client.SuspendCronJob(ns, c.Param("name"), req.Suspend); err != nil {
c.JSON(500, model.Response{Code: 500, Message: "操作失败: " + err.Error()})
return
}
action := "恢复"
if req.Suspend {
action = "暂停"
}
c.JSON(200, model.Response{Code: 200, Message: "CronJob 已" + action})
}
func (h *KubernetesHandler) triggerCronJobWithNS(c *gin.Context, ns string) {
clusterID, _ := strconv.Atoi(c.Param("id"))
if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) }
if clusterID == 0 {
clusterID = h.resolveClusterCode(c.Param("clusterCode"))
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err := client.TriggerCronJob(ns, c.Param("name")); err != nil { c.JSON(500, model.Response{Code: 500, Message: "触发失败: " + err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
if err := client.TriggerCronJob(ns, c.Param("name")); err != nil {
c.JSON(500, model.Response{Code: 500, Message: "触发失败: " + err.Error()})
return
}
c.JSON(200, model.Response{Code: 200, Message: "手动触发成功"})
}
func (h *KubernetesHandler) deleteCronJobWithNS(c *gin.Context, ns string) {
clusterID, _ := strconv.Atoi(c.Param("id"))
if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) }
if clusterID == 0 {
clusterID = h.resolveClusterCode(c.Param("clusterCode"))
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err := client.DeleteCronJob(ns, c.Param("name")); err != nil { c.JSON(500, model.Response{Code: 500, Message: "删除失败: " + err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
if err := client.DeleteCronJob(ns, c.Param("name")); err != nil {
c.JSON(500, model.Response{Code: 500, Message: "删除失败: " + err.Error()})
return
}
c.JSON(200, model.Response{Code: 200, Message: "已删除"})
}
func (h *KubernetesHandler) ListHPAsAllNS(c *gin.Context) {
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return }
if clusterID == 0 {
c.JSON(500, model.Response{Code: 500, Message: "cluster not found"})
return
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
hpas, err := client.ListHPAs()
if err != nil { c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()})
return
}
type HPASummary struct {
Namespace string `json:"namespace"`
Name string `json:"name"`
@@ -375,11 +475,16 @@ func (h *KubernetesHandler) ListHPAsAllNS(c *gin.Context) {
}
result := []HPASummary{}
for _, h := range hpas.Items {
cpu := 0; mem := 0
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 }
if m.Resource.Name == "cpu" {
cpu = m.Resource.Target.AverageUtilization
}
if m.Resource.Name == "memory" {
mem = m.Resource.Target.AverageUtilization
}
}
}
result = append(result, HPASummary{
+9 -3
View File
@@ -181,21 +181,27 @@ func (h *KubernetesHandler) GetNamespaceDetailHandler(c *gin.Context) {
if deps, err := client.ListDeployments(); err == nil {
count := 0
for _, d := range deps.Items {
if d.Metadata.Namespace == nsName { count++ }
if d.Metadata.Namespace == nsName {
count++
}
}
nd.ResourceCounts["deployments"] = count
}
if pods, err := client.ListPods(); err == nil {
count := 0
for _, p := range pods.Items {
if p.Metadata.Namespace == nsName { count++ }
if p.Metadata.Namespace == nsName {
count++
}
}
nd.ResourceCounts["pods"] = count
}
if svcs, err := client.ListServices(); err == nil {
count := 0
for _, s := range svcs.Items {
if s.Metadata.Namespace == nsName { count++ }
if s.Metadata.Namespace == nsName {
count++
}
}
nd.ResourceCounts["services"] = count
}
+15 -4
View File
@@ -397,13 +397,22 @@ func parseMemToMi(val string) int64 {
// @Router /kubernetes-clusters/{id}/pods [get]
func (h *KubernetesHandler) GetCapacityAnalysis(c *gin.Context) {
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return }
if clusterID == 0 {
c.JSON(500, model.Response{Code: 500, Message: "cluster not found"})
return
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
ns := c.Query("namespace")
deps, err := client.ListDeployments()
if err != nil { c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()})
return
}
type ContainerResource struct {
Name string `json:"name"`
@@ -420,7 +429,9 @@ func (h *KubernetesHandler) GetCapacityAnalysis(c *gin.Context) {
}
result := []CapacityRow{}
for _, d := range deps.Items {
if ns != "" && d.Metadata.Namespace != ns { continue }
if ns != "" && d.Metadata.Namespace != ns {
continue
}
containers := []ContainerResource{}
for _, c := range d.Spec.Template.Spec.Containers {
cpuReq := c.Resources.Requests["cpu"]
+128 -37
View File
@@ -354,11 +354,19 @@ func (h *KubernetesHandler) ExecInPodNS(c *gin.Context) {
// Internal helpers that resolve clusterCode and inject namespace
func (h *KubernetesHandler) listPodsWithNS(c *gin.Context, ns string) {
clusterID, _ := strconv.Atoi(c.Param("id"))
if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) }
if clusterID == 0 {
clusterID = h.resolveClusterCode(c.Param("clusterCode"))
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
pods, err := client.ListPods()
if err != nil { c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()})
return
}
// M2: inject pod metrics (best-effort) // Logan.Li
podMetrics := buildPodMetricsMap(client, ns)
type PodSummary struct {
@@ -375,9 +383,17 @@ func (h *KubernetesHandler) listPodsWithNS(c *gin.Context, ns string) {
}
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 }
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
}
ps := PodSummary{
Namespace: p.Metadata.Namespace,
Name: p.Metadata.Name,
@@ -398,7 +414,9 @@ func (h *KubernetesHandler) listPodsWithNS(c *gin.Context, ns string) {
}
func (h *KubernetesHandler) getPodLogsWithNS(c *gin.Context, ns string) {
clusterID, _ := strconv.Atoi(c.Param("id"))
if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) }
if clusterID == 0 {
clusterID = h.resolveClusterCode(c.Param("clusterCode"))
}
podName := c.Param("podName")
stream := c.Query("stream") == "true"
// 日志增强参数:container / sinceTime / previous / tailLines(兼容 tail) // Logan.Li
@@ -416,34 +434,61 @@ func (h *KubernetesHandler) getPodLogsWithNS(c *gin.Context, ns string) {
Previous: c.Query("previous") == "true",
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
if stream {
body, err := client.GetPodLogsStreamWithOptions(ns, podName, opts)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: "获取日志流失败: " + err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: "获取日志流失败: " + err.Error()})
return
}
defer body.Close()
c.Header("Content-Type", "text/plain; charset=utf-8")
c.Header("Cache-Control", "no-cache")
c.Stream(func(w io.Writer) bool {
buf := make([]byte, 4096); n, err := body.Read(buf)
if n > 0 { w.Write(buf[:n]); return true }
buf := make([]byte, 4096)
n, err := body.Read(buf)
if n > 0 {
w.Write(buf[:n])
return true
}
return err == nil
})
return
}
logs, err := client.GetPodLogsWithOptions(ns, podName, opts)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: "获取日志失败: " + err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: "获取日志失败: " + err.Error()})
return
}
c.JSON(200, model.Response{Code: 200, Message: "success", Data: gin.H{"logs": logs, "pod": podName, "namespace": ns}})
}
func (h *KubernetesHandler) execInPodWithNS(c *gin.Context, ns string) {
clusterID, _ := strconv.Atoi(c.Param("id"))
if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) }
if clusterID == 0 {
clusterID = h.resolveClusterCode(c.Param("clusterCode"))
}
podName := c.Param("podName")
var req struct { Command []string `json:"command"`; Container string `json:"container"` }
if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, model.Response{Code: 400, Message: "参数错误"}); return }
var req struct {
Command []string `json:"command"`
Container string `json:"container"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, model.Response{Code: 400, Message: "参数错误"})
return
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
result, err := client.ExecInPod(ns, podName, req.Container, req.Command)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: "执行失败: " + err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: "执行失败: " + err.Error()})
return
}
c.JSON(200, model.Response{Code: 200, Data: result})
}
@@ -454,11 +499,20 @@ func (h *KubernetesHandler) execInPodWithNS(c *gin.Context, ns string) {
// ListDeploymentsAllNS lists all deployments across all namespaces using clusterCode.
func (h *KubernetesHandler) ListPodsAllNS(c *gin.Context) {
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return }
if clusterID == 0 {
c.JSON(500, model.Response{Code: 500, Message: "cluster not found"})
return
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
pods, err := client.ListPods()
if err != nil { c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()})
return
}
// M2: inject pod metrics (best-effort) // Logan.Li
podMetrics := buildPodMetricsMap(client, "")
type PodSummary struct {
@@ -474,8 +528,14 @@ func (h *KubernetesHandler) ListPodsAllNS(c *gin.Context) {
}
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 }
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
}
ps := PodSummary{
Namespace: p.Metadata.Namespace,
Name: p.Metadata.Name,
@@ -559,58 +619,80 @@ func parseMemResource(val string) int64 {
// formatCpuFromNano converts K8s metrics CPU (nanocores "80410173n") to millicores "80m"
func formatCpuFromNano(raw string) string {
if raw == "" { return "" }
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 }
if milli < 1 {
milli = 1
}
return fmt.Sprintf("%dm", milli)
}
// parseCpuNanoToMilli converts K8s metrics CPU (nanocores "100354992n") to millicores (int) // Logan.Li
func parseCpuNanoToMilli(raw string) int {
if raw == "" { return 0 }
if raw == "" {
return 0
}
val := strings.TrimSuffix(raw, "n")
var nano int64
fmt.Sscanf(val, "%d", &nano)
milli := nano / 1000000
if milli < 1 { milli = 1 }
if milli < 1 {
milli = 1
}
return int(milli)
}
// formatMemFromKi converts K8s metrics memory (Ki "2154292Ki") to Mi "2105Mi"
func formatMemFromKi(raw string) string {
if raw == "" { return "" }
if raw == "" {
return ""
}
val := strings.TrimSuffix(raw, "Ki")
var ki int64
fmt.Sscanf(val, "%d", &ki)
mi := ki / 1024
if mi < 1 { mi = 1 }
if mi < 1 {
mi = 1
}
return fmt.Sprintf("%dMi", mi)
}
// parseMemKiToMi converts K8s metrics memory (Ki "2154292Ki") to Mi (int) // Logan.Li
func parseMemKiToMi(raw string) int {
if raw == "" { return 0 }
if raw == "" {
return 0
}
val := strings.TrimSuffix(raw, "Ki")
var ki int64
fmt.Sscanf(val, "%d", &ki)
return int(ki / 1024)
}
// GetResourceYAMLNS returns the YAML of a K8s resource
func (h *KubernetesHandler) GetPodMetrics(c *gin.Context) {
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return }
if clusterID == 0 {
c.JSON(500, model.Response{Code: 500, Message: "cluster not found"})
return
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
ns := c.Query("namespace")
metrics, err := client.ListPodMetrics(ns)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: "metrics API error: " + err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: "metrics API error: " + err.Error()})
return
}
type ContainerMetric struct {
Name string `json:"name"`
@@ -643,13 +725,22 @@ func (h *KubernetesHandler) DeletePodNS(c *gin.Context) {
}
func (h *KubernetesHandler) deletePodWithNS(c *gin.Context, ns string) {
clusterID, _ := strconv.Atoi(c.Param("id"))
if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) }
if clusterID == 0 {
clusterID = h.resolveClusterCode(c.Param("clusterCode"))
}
podName := c.Param("podName")
if podName == "" { c.JSON(400, model.Response{Code: 400, Message: "缺少 podName"}); return }
if podName == "" {
c.JSON(400, model.Response{Code: 400, Message: "缺少 podName"})
return
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
if err := client.DeletePod(ns, podName); err != nil {
c.JSON(500, model.Response{Code: 500, Message: "删除 Pod 失败: " + err.Error()}); return
c.JSON(500, model.Response{Code: 500, Message: "删除 Pod 失败: " + err.Error()})
return
}
c.JSON(200, model.Response{Code: 200, Message: fmt.Sprintf("Pod %s 已删除", podName)})
}
+45 -13
View File
@@ -19,20 +19,33 @@ func (h *KubernetesHandler) ListPodFilesNS(c *gin.Context) {
}
func (h *KubernetesHandler) listPodFilesWithNS(c *gin.Context, ns string) {
clusterID, _ := strconv.Atoi(c.Param("id"))
if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) }
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 = "/" }
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 }
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 }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: "执行 ls 失败: " + err.Error()})
return
}
type FileInfo struct {
Name string `json:"name"`
@@ -45,11 +58,17 @@ func (h *KubernetesHandler) listPodFilesWithNS(c *gin.Context, ns string) {
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 }
if line == "" || strings.HasPrefix(line, "total ") {
continue
}
fields := strings.Fields(line)
if len(fields) < 7 { continue }
if len(fields) < 7 {
continue
}
mode := fields[0]
if len(mode) == 0 || (mode[0] != 'd' && mode[0] != '-' && mode[0] != 'l') { continue }
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)
@@ -64,7 +83,9 @@ func (h *KubernetesHandler) listPodFilesWithNS(c *gin.Context, ns string) {
modTime = strings.Join(fields[5:8], " ")
name = strings.Join(fields[8:], " ")
}
if name == "" || name == "." || name == ".." { continue }
if name == "" || name == "." || name == ".." {
continue
}
files = append(files, FileInfo{
Name: name,
Size: size,
@@ -82,16 +103,27 @@ func (h *KubernetesHandler) DownloadPodFileNS(c *gin.Context) {
}
func (h *KubernetesHandler) downloadPodFileWithNS(c *gin.Context, ns string) {
clusterID, _ := strconv.Atoi(c.Param("id"))
if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) }
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 }
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 }
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 }
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))
+56 -17
View File
@@ -223,7 +223,6 @@ func (h *KubernetesHandler) SaveTopology(c *gin.Context) {
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "保存成功"})
}
// SyncCluster 同步集群资源(连接真实K8s API拉取数据)
func (h *KubernetesHandler) ListConfigMaps(c *gin.Context) {
clusterID, _ := strconv.Atoi(c.Param("id"))
@@ -313,11 +312,19 @@ func (h *KubernetesHandler) ListIngressesNS(c *gin.Context) {
}
func (h *KubernetesHandler) listServicesWithNS(c *gin.Context, ns string) {
clusterID, _ := strconv.Atoi(c.Param("id"))
if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) }
if clusterID == 0 {
clusterID = h.resolveClusterCode(c.Param("clusterCode"))
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
svcs, err := client.ListServices()
if err != nil { c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()})
return
}
type SvcPortDetail struct {
Name string `json:"name,omitempty"`
Port int `json:"port"`
@@ -336,7 +343,9 @@ func (h *KubernetesHandler) listServicesWithNS(c *gin.Context, ns string) {
}
result := []SvcSummary{}
for _, s := range svcs.Items {
if s.Metadata.Namespace != ns { continue }
if s.Metadata.Namespace != ns {
continue
}
ports := []SvcPortDetail{}
for _, p := range s.Spec.Ports {
ports = append(ports, SvcPortDetail{
@@ -358,11 +367,19 @@ func (h *KubernetesHandler) listServicesWithNS(c *gin.Context, ns string) {
}
func (h *KubernetesHandler) listIngressesWithNS(c *gin.Context, ns string) {
clusterID, _ := strconv.Atoi(c.Param("id"))
if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) }
if clusterID == 0 {
clusterID = h.resolveClusterCode(c.Param("clusterCode"))
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
ings, err := client.ListIngresses()
if err != nil { c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()})
return
}
type PathDetail struct {
Path string `json:"path"`
PathType string `json:"pathType,omitempty"`
@@ -383,11 +400,15 @@ func (h *KubernetesHandler) listIngressesWithNS(c *gin.Context, ns string) {
}
result := []IngSummary{}
for _, ing := range ings.Items {
if ing.Metadata.Namespace != ns { continue }
if ing.Metadata.Namespace != ns {
continue
}
hosts := []string{}
rules := []RuleDetail{}
for _, rule := range ing.Spec.Rules {
if rule.Host != "" { hosts = append(hosts, rule.Host) }
if rule.Host != "" {
hosts = append(hosts, rule.Host)
}
rd := RuleDetail{Host: rule.Host, Paths: []PathDetail{}}
if rule.HTTP != nil {
for _, p := range rule.HTTP.Paths {
@@ -453,12 +474,21 @@ func (h *KubernetesHandler) ListIngressClasses(c *gin.Context) {
func (h *KubernetesHandler) ListPVCsNS(c *gin.Context) {
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return }
if clusterID == 0 {
c.JSON(500, model.Response{Code: 500, Message: "cluster not found"})
return
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
ns := c.Param("namespace")
pvcs, err := client.ListPVCs(ns)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()})
return
}
type PVCSummary struct {
Namespace string `json:"namespace"`
Name string `json:"name"`
@@ -470,7 +500,9 @@ func (h *KubernetesHandler) ListPVCsNS(c *gin.Context) {
for _, p := range pvcs.Items {
size := ""
if p.Status.Capacity != nil {
if s, ok := p.Status.Capacity["storage"]; ok { size = s }
if s, ok := p.Status.Capacity["storage"]; ok {
size = s
}
}
result = append(result, PVCSummary{
Namespace: p.Metadata.Namespace,
@@ -486,11 +518,18 @@ func (h *KubernetesHandler) ListPVCsNS(c *gin.Context) {
// 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, model.Response{Code: 500, Message: "cluster not found"}); return }
if clusterID == 0 {
c.JSON(500, model.Response{Code: 500, Message: "cluster not found"})
return
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
if err := client.DeletePVC(c.Param("namespace"), c.Param("name")); err != nil {
c.JSON(500, model.Response{Code: 500, Message: "删除失败: " + err.Error()}); return
c.JSON(500, model.Response{Code: 500, Message: "删除失败: " + err.Error()})
return
}
c.JSON(200, model.Response{Code: 200, Message: "已删除"})
}
+357 -98
View File
@@ -208,15 +208,25 @@ func (h *KubernetesHandler) GetDeploymentNS(c *gin.Context) {
ns := c.Param("namespace")
name := c.Param("name")
clusterID, _ := strconv.Atoi(c.Param("id"))
if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) }
if clusterID == 0 {
clusterID = h.resolveClusterCode(c.Param("clusterCode"))
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
deps, err := client.ListDeployments()
if err != nil { c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()})
return
}
for _, d := range deps.Items {
if d.Metadata.Namespace == ns && d.Metadata.Name == name {
image := ""
if len(d.Spec.Template.Spec.Containers) > 0 { image = d.Spec.Template.Spec.Containers[0].Image }
if len(d.Spec.Template.Spec.Containers) > 0 {
image = d.Spec.Template.Spec.Containers[0].Image
}
c.JSON(200, model.Response{Code: 200, Data: map[string]interface{}{
"namespace": d.Metadata.Namespace,
"name": d.Metadata.Name,
@@ -248,11 +258,19 @@ func (h *KubernetesHandler) ResumeDeploymentNS(c *gin.Context) {
}
func (h *KubernetesHandler) listDeploymentsWithNS(c *gin.Context, ns string) {
clusterID, _ := strconv.Atoi(c.Param("id"))
if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) }
if clusterID == 0 {
clusterID = h.resolveClusterCode(c.Param("clusterCode"))
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
deps, err := client.ListDeployments()
if err != nil { c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()})
return
}
type DepSummary struct {
Namespace string `json:"namespace"`
Name string `json:"name"`
@@ -265,8 +283,13 @@ func (h *KubernetesHandler) listDeploymentsWithNS(c *gin.Context, ns string) {
}
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 }
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{
Namespace: d.Metadata.Namespace,
Name: d.Metadata.Name,
@@ -275,63 +298,131 @@ func (h *KubernetesHandler) listDeploymentsWithNS(c *gin.Context, ns string) {
ReadyReplicas: d.Status.ReadyReplicas,
Image: image,
Paused: d.Spec.Paused,
Selector: func() map[string]string { if d.Spec.Selector != nil { return d.Spec.Selector.MatchLabels }; return nil }(),
Selector: func() map[string]string {
if d.Spec.Selector != nil {
return d.Spec.Selector.MatchLabels
}
return nil
}(),
})
}
c.JSON(200, model.Response{Code: 200, Data: result})
}
func (h *KubernetesHandler) scaleDeploymentWithNS(c *gin.Context, ns string) {
clusterID, _ := strconv.Atoi(c.Param("id"))
if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) }
var req struct { Replicas int `json:"replicas"` }
if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, model.Response{Code: 400, Message: "参数错误"}); return }
if clusterID == 0 {
clusterID = h.resolveClusterCode(c.Param("clusterCode"))
}
var req struct {
Replicas int `json:"replicas"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, model.Response{Code: 400, Message: "参数错误"})
return
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err := client.ScaleDeployment(ns, c.Param("name"), req.Replicas); err != nil { c.JSON(500, model.Response{Code: 500, Message: "扩缩容失败: " + err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
if err := client.ScaleDeployment(ns, c.Param("name"), req.Replicas); err != nil {
c.JSON(500, model.Response{Code: 500, Message: "扩缩容失败: " + err.Error()})
return
}
c.JSON(200, model.Response{Code: 200, Message: fmt.Sprintf("已设置为 %d 副本", req.Replicas)})
}
func (h *KubernetesHandler) restartDeploymentWithNS(c *gin.Context, ns string) {
clusterID, _ := strconv.Atoi(c.Param("id"))
if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) }
if clusterID == 0 {
clusterID = h.resolveClusterCode(c.Param("clusterCode"))
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err := client.RestartDeployment(ns, c.Param("name")); err != nil { c.JSON(500, model.Response{Code: 500, Message: "重启失败: " + err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
if err := client.RestartDeployment(ns, c.Param("name")); err != nil {
c.JSON(500, model.Response{Code: 500, Message: "重启失败: " + err.Error()})
return
}
c.JSON(200, model.Response{Code: 200, Message: "重启指令已下发"})
}
func (h *KubernetesHandler) updateDeploymentImageWithNS(c *gin.Context, ns string) {
clusterID, _ := strconv.Atoi(c.Param("id"))
if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) }
var req struct { Image string `json:"image"`; ContainerName string `json:"container_name"` }
if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, model.Response{Code: 400, Message: "参数错误"}); return }
if strings.TrimSpace(req.Image) == "" { c.JSON(400, model.Response{Code: 400, Message: "image 不能为空"}); return }
if clusterID == 0 {
clusterID = h.resolveClusterCode(c.Param("clusterCode"))
}
var req struct {
Image string `json:"image"`
ContainerName string `json:"container_name"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, model.Response{Code: 400, Message: "参数错误"})
return
}
if strings.TrimSpace(req.Image) == "" {
c.JSON(400, model.Response{Code: 400, Message: "image 不能为空"})
return
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err := client.UpdateDeploymentImage(ns, c.Param("name"), req.ContainerName, req.Image); err != nil { c.JSON(500, model.Response{Code: 500, Message: "更新镜像失败: " + err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
if err := client.UpdateDeploymentImage(ns, c.Param("name"), req.ContainerName, req.Image); err != nil {
c.JSON(500, model.Response{Code: 500, Message: "更新镜像失败: " + err.Error()})
return
}
c.JSON(200, model.Response{Code: 200, Message: fmt.Sprintf("镜像已更新为 %s", req.Image)})
}
func (h *KubernetesHandler) pauseDeploymentWithNS(c *gin.Context, ns string) {
clusterID, _ := strconv.Atoi(c.Param("id"))
if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) }
if clusterID == 0 {
clusterID = h.resolveClusterCode(c.Param("clusterCode"))
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err := client.PauseDeployment(ns, c.Param("name")); err != nil { c.JSON(500, model.Response{Code: 500, Message: "暂停更新失败: " + err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
if err := client.PauseDeployment(ns, c.Param("name")); err != nil {
c.JSON(500, model.Response{Code: 500, Message: "暂停更新失败: " + err.Error()})
return
}
c.JSON(200, model.Response{Code: 200, Message: "已暂停滚动更新"})
}
func (h *KubernetesHandler) resumeDeploymentWithNS(c *gin.Context, ns string) {
clusterID, _ := strconv.Atoi(c.Param("id"))
if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) }
if clusterID == 0 {
clusterID = h.resolveClusterCode(c.Param("clusterCode"))
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err := client.ResumeDeployment(ns, c.Param("name")); err != nil { c.JSON(500, model.Response{Code: 500, Message: "恢复更新失败: " + err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
if err := client.ResumeDeployment(ns, c.Param("name")); err != nil {
c.JSON(500, model.Response{Code: 500, Message: "恢复更新失败: " + err.Error()})
return
}
c.JSON(200, model.Response{Code: 200, Message: "已恢复滚动更新"})
}
func (h *KubernetesHandler) ListDeploymentsAllNS(c *gin.Context) {
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return }
if clusterID == 0 {
c.JSON(500, model.Response{Code: 500, Message: "cluster not found"})
return
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
deps, err := client.ListDeployments()
if err != nil { c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()})
return
}
type DepSummary struct {
Namespace string `json:"namespace"`
Name string `json:"name"`
@@ -342,7 +433,10 @@ func (h *KubernetesHandler) ListDeploymentsAllNS(c *gin.Context) {
}
result := []DepSummary{}
for _, d := range deps.Items {
image := ""; if len(d.Spec.Template.Spec.Containers) > 0 { image = d.Spec.Template.Spec.Containers[0].Image }
image := ""
if len(d.Spec.Template.Spec.Containers) > 0 {
image = d.Spec.Template.Spec.Containers[0].Image
}
result = append(result, DepSummary{
Namespace: d.Metadata.Namespace,
Name: d.Metadata.Name,
@@ -358,11 +452,20 @@ func (h *KubernetesHandler) ListDeploymentsAllNS(c *gin.Context) {
// ListPodsAllNS lists all pods across all namespaces using clusterCode.
func (h *KubernetesHandler) ListStatefulSetsNS(c *gin.Context) {
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return }
if clusterID == 0 {
c.JSON(500, model.Response{Code: 500, Message: "cluster not found"})
return
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
sts, err := client.ListStatefulSets()
if err != nil { c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()})
return
}
ns := c.Param("namespace")
type StsSummary struct {
Namespace string `json:"namespace"`
@@ -373,8 +476,13 @@ func (h *KubernetesHandler) ListStatefulSetsNS(c *gin.Context) {
}
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 }
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,
@@ -389,11 +497,20 @@ func (h *KubernetesHandler) ListStatefulSetsNS(c *gin.Context) {
// 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, model.Response{Code: 500, Message: "cluster not found"}); return }
if clusterID == 0 {
c.JSON(500, model.Response{Code: 500, Message: "cluster not found"})
return
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
ds, err := client.ListDaemonSets()
if err != nil { c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()})
return
}
ns := c.Param("namespace")
type DsSummary struct {
Namespace string `json:"namespace"`
@@ -405,8 +522,13 @@ func (h *KubernetesHandler) ListDaemonSetsNS(c *gin.Context) {
}
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 }
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,
@@ -418,18 +540,31 @@ func (h *KubernetesHandler) ListDaemonSetsNS(c *gin.Context) {
}
c.JSON(200, model.Response{Code: 200, Data: result})
}
// ScaleStatefulSetNS scales a StatefulSet (Kuboard code route)
func (h *KubernetesHandler) ScaleStatefulSetNS(c *gin.Context) {
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return }
if clusterID == 0 {
c.JSON(500, model.Response{Code: 500, Message: "cluster not found"})
return
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
ns := c.Param("namespace")
name := c.Param("name")
var req struct{ Replicas int `json:"replicas"` }
if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, model.Response{Code: 400, Message: "参数错误"}); return }
var req struct {
Replicas int `json:"replicas"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, model.Response{Code: 400, Message: "参数错误"})
return
}
if err := client.ScaleStatefulSet(ns, name, req.Replicas); err != nil {
c.JSON(500, model.Response{Code: 500, Message: "扩缩容失败: " + err.Error()}); return
c.JSON(500, model.Response{Code: 500, Message: "扩缩容失败: " + err.Error()})
return
}
c.JSON(200, model.Response{Code: 200, Message: "StatefulSet 副本数已更新为 " + strconv.Itoa(req.Replicas)})
}
@@ -437,16 +572,30 @@ func (h *KubernetesHandler) ScaleStatefulSetNS(c *gin.Context) {
// ScaleStatefulSet scales a StatefulSet (ID route)
func (h *KubernetesHandler) ScaleStatefulSet(c *gin.Context) {
clusterID, _ := strconv.Atoi(c.Param("id"))
if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) }
if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return }
if clusterID == 0 {
clusterID = h.resolveClusterCode(c.Param("clusterCode"))
}
if clusterID == 0 {
c.JSON(500, model.Response{Code: 500, Message: "cluster not found"})
return
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
ns := c.Param("namespace")
name := c.Param("name")
var req struct{ Replicas int `json:"replicas"` }
if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, model.Response{Code: 400, Message: "参数错误"}); return }
var req struct {
Replicas int `json:"replicas"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, model.Response{Code: 400, Message: "参数错误"})
return
}
if err := client.ScaleStatefulSet(ns, name, req.Replicas); err != nil {
c.JSON(500, model.Response{Code: 500, Message: "扩缩容失败: " + err.Error()}); return
c.JSON(500, model.Response{Code: 500, Message: "扩缩容失败: " + err.Error()})
return
}
c.JSON(200, model.Response{Code: 200, Message: "StatefulSet 副本数已更新为 " + strconv.Itoa(req.Replicas)})
}
@@ -454,13 +603,20 @@ func (h *KubernetesHandler) ScaleStatefulSet(c *gin.Context) {
// RestartStatefulSetNS restarts a StatefulSet (Kuboard code route)
func (h *KubernetesHandler) RestartStatefulSetNS(c *gin.Context) {
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return }
if clusterID == 0 {
c.JSON(500, model.Response{Code: 500, Message: "cluster not found"})
return
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
ns := c.Param("namespace")
name := c.Param("name")
if err := client.RestartStatefulSet(ns, name); err != nil {
c.JSON(500, model.Response{Code: 500, Message: "重启失败: " + err.Error()}); return
c.JSON(500, model.Response{Code: 500, Message: "重启失败: " + err.Error()})
return
}
c.JSON(200, model.Response{Code: 200, Message: "StatefulSet 滚动重启已触发"})
}
@@ -468,14 +624,23 @@ func (h *KubernetesHandler) RestartStatefulSetNS(c *gin.Context) {
// RestartStatefulSet restarts a StatefulSet (ID route)
func (h *KubernetesHandler) RestartStatefulSet(c *gin.Context) {
clusterID, _ := strconv.Atoi(c.Param("id"))
if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) }
if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return }
if clusterID == 0 {
clusterID = h.resolveClusterCode(c.Param("clusterCode"))
}
if clusterID == 0 {
c.JSON(500, model.Response{Code: 500, Message: "cluster not found"})
return
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
ns := c.Param("namespace")
name := c.Param("name")
if err := client.RestartStatefulSet(ns, name); err != nil {
c.JSON(500, model.Response{Code: 500, Message: "重启失败: " + err.Error()}); return
c.JSON(500, model.Response{Code: 500, Message: "重启失败: " + err.Error()})
return
}
c.JSON(200, model.Response{Code: 200, Message: "StatefulSet 滚动重启已触发"})
}
@@ -483,13 +648,20 @@ func (h *KubernetesHandler) RestartStatefulSet(c *gin.Context) {
// RestartDaemonSetNS restarts a DaemonSet (Kuboard code route)
func (h *KubernetesHandler) RestartDaemonSetNS(c *gin.Context) {
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return }
if clusterID == 0 {
c.JSON(500, model.Response{Code: 500, Message: "cluster not found"})
return
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
ns := c.Param("namespace")
name := c.Param("name")
if err := client.RestartDaemonSet(ns, name); err != nil {
c.JSON(500, model.Response{Code: 500, Message: "重启失败: " + err.Error()}); return
c.JSON(500, model.Response{Code: 500, Message: "重启失败: " + err.Error()})
return
}
c.JSON(200, model.Response{Code: 200, Message: "DaemonSet 滚动重启已触发"})
}
@@ -497,14 +669,23 @@ func (h *KubernetesHandler) RestartDaemonSetNS(c *gin.Context) {
// RestartDaemonSet restarts a DaemonSet (ID route)
func (h *KubernetesHandler) RestartDaemonSet(c *gin.Context) {
clusterID, _ := strconv.Atoi(c.Param("id"))
if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) }
if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return }
if clusterID == 0 {
clusterID = h.resolveClusterCode(c.Param("clusterCode"))
}
if clusterID == 0 {
c.JSON(500, model.Response{Code: 500, Message: "cluster not found"})
return
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
ns := c.Param("namespace")
name := c.Param("name")
if err := client.RestartDaemonSet(ns, name); err != nil {
c.JSON(500, model.Response{Code: 500, Message: "重启失败: " + err.Error()}); return
c.JSON(500, model.Response{Code: 500, Message: "重启失败: " + err.Error()})
return
}
c.JSON(200, model.Response{Code: 200, Message: "DaemonSet 滚动重启已触发"})
}
@@ -512,26 +693,44 @@ func (h *KubernetesHandler) RestartDaemonSet(c *gin.Context) {
// GetStatefulSetDetailNS returns a single StatefulSet detail (Kuboard code route)
func (h *KubernetesHandler) GetStatefulSetDetailNS(c *gin.Context) {
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return }
if clusterID == 0 {
c.JSON(500, model.Response{Code: 500, Message: "cluster not found"})
return
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
ns := c.Param("namespace")
name := c.Param("name")
sts, err := client.GetStatefulSet(ns, name)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()})
return
}
c.JSON(200, model.Response{Code: 200, Data: sts})
}
// GetDaemonSetDetailNS returns a single DaemonSet detail (Kuboard code route)
func (h *KubernetesHandler) GetDaemonSetDetailNS(c *gin.Context) {
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return }
if clusterID == 0 {
c.JSON(500, model.Response{Code: 500, Message: "cluster not found"})
return
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
ns := c.Param("namespace")
name := c.Param("name")
ds, err := client.GetDaemonSet(ns, name)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()})
return
}
c.JSON(200, model.Response{Code: 200, Data: ds})
}
@@ -545,11 +744,19 @@ func (h *KubernetesHandler) ListReplicaSetsNS(c *gin.Context) {
}
func (h *KubernetesHandler) listReplicaSetsWithNS(c *gin.Context, ns string) {
clusterID, _ := strconv.Atoi(c.Param("id"))
if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) }
if clusterID == 0 {
clusterID = h.resolveClusterCode(c.Param("clusterCode"))
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
items, err := client.ListReplicaSetsRaw(ns)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()})
return
}
type RSSummary struct {
Namespace string `json:"namespace"`
Name string `json:"name"`
@@ -564,11 +771,16 @@ func (h *KubernetesHandler) listReplicaSetsWithNS(c *gin.Context, ns string) {
result := []RSSummary{}
for _, rs := range items {
image := ""
if len(rs.Spec.Template.Spec.Containers) > 0 { image = rs.Spec.Template.Spec.Containers[0].Image }
if len(rs.Spec.Template.Spec.Containers) > 0 {
image = rs.Spec.Template.Spec.Containers[0].Image
}
revision := rs.Metadata.Annotations["deployment.kubernetes.io/revision"]
owner := ""
for _, o := range rs.Metadata.OwnerReferences {
if o.Kind == "Deployment" { owner = o.Name; break }
if o.Kind == "Deployment" {
owner = o.Name
break
}
}
result = append(result, RSSummary{
Namespace: rs.Metadata.Namespace,
@@ -591,13 +803,24 @@ func (h *KubernetesHandler) ListDeploymentRevisionsNS(c *gin.Context) {
}
func (h *KubernetesHandler) listDeploymentRevisionsWithNS(c *gin.Context, ns string) {
clusterID, _ := strconv.Atoi(c.Param("id"))
if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) }
if clusterID == 0 {
clusterID = h.resolveClusterCode(c.Param("clusterCode"))
}
deploymentName := c.Param("name")
if deploymentName == "" { c.JSON(400, model.Response{Code: 400, Message: "缺少 deployment name"}); return }
if deploymentName == "" {
c.JSON(400, model.Response{Code: 400, Message: "缺少 deployment name"})
return
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
items, err := client.ListReplicaSetsRaw(ns)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()})
return
}
type Revision struct {
Revision string `json:"revision"`
ReplicaSet string `json:"replica_set"`
@@ -610,11 +833,18 @@ func (h *KubernetesHandler) listDeploymentRevisionsWithNS(c *gin.Context, ns str
for _, rs := range items {
isOwner := false
for _, o := range rs.Metadata.OwnerReferences {
if o.Kind == "Deployment" && o.Name == deploymentName { isOwner = true; break }
if o.Kind == "Deployment" && o.Name == deploymentName {
isOwner = true
break
}
}
if !isOwner {
continue
}
if !isOwner { continue }
image := ""
if len(rs.Spec.Template.Spec.Containers) > 0 { image = rs.Spec.Template.Spec.Containers[0].Image }
if len(rs.Spec.Template.Spec.Containers) > 0 {
image = rs.Spec.Template.Spec.Containers[0].Image
}
result = append(result, Revision{
Revision: rs.Metadata.Annotations["deployment.kubernetes.io/revision"],
ReplicaSet: rs.Metadata.Name,
@@ -639,28 +869,44 @@ func (h *KubernetesHandler) RollbackDeploymentNS(c *gin.Context) {
}
func (h *KubernetesHandler) rollbackDeploymentWithNS(c *gin.Context, ns string) {
clusterID, _ := strconv.Atoi(c.Param("id"))
if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) }
if clusterID == 0 {
clusterID = h.resolveClusterCode(c.Param("clusterCode"))
}
deploymentName := c.Param("name")
if deploymentName == "" { c.JSON(400, model.Response{Code: 400, Message: "缺少 deployment name"}); return }
if deploymentName == "" {
c.JSON(400, model.Response{Code: 400, Message: "缺少 deployment name"})
return
}
var req struct {
Revision int `json:"revision"`
ReplicaSet string `json:"replicaSet"`
}
_ = c.ShouldBindJSON(&req) // 允许空 body(但下面会校验)
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
targetRS := req.ReplicaSet
// 通过 revision 定位 ReplicaSet
if targetRS == "" && req.Revision > 0 {
items, err := client.ListReplicaSetsRaw(ns)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: "获取 ReplicaSet 列表失败: " + err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: "获取 ReplicaSet 列表失败: " + err.Error()})
return
}
for _, rs := range items {
isOwner := false
for _, o := range rs.Metadata.OwnerReferences {
if o.Kind == "Deployment" && o.Name == deploymentName { isOwner = true; break }
if o.Kind == "Deployment" && o.Name == deploymentName {
isOwner = true
break
}
}
if !isOwner {
continue
}
if !isOwner { continue }
if rs.Metadata.Annotations["deployment.kubernetes.io/revision"] == strconv.Itoa(req.Revision) {
targetRS = rs.Metadata.Name
break
@@ -671,18 +917,31 @@ func (h *KubernetesHandler) rollbackDeploymentWithNS(c *gin.Context, ns string)
return
}
}
if targetRS == "" { c.JSON(400, model.Response{Code: 400, Message: "需要 revision 或 replicaSet 参数"}); return }
if targetRS == "" {
c.JSON(400, model.Response{Code: 400, Message: "需要 revision 或 replicaSet 参数"})
return
}
// 取 ReplicaSet 的 template,patch 到 Deployment.spec.template 实现回滚
rsJSON, err := client.GetReplicaSet(ns, targetRS)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: "获取 ReplicaSet 失败: " + err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: "获取 ReplicaSet 失败: " + err.Error()})
return
}
specMap, ok := rsJSON["spec"].(map[string]interface{})
if !ok { c.JSON(500, model.Response{Code: 500, Message: "ReplicaSet 缺少 spec"}); return }
if !ok {
c.JSON(500, model.Response{Code: 500, Message: "ReplicaSet 缺少 spec"})
return
}
template, ok := specMap["template"].(map[string]interface{})
if !ok { c.JSON(500, model.Response{Code: 500, Message: "ReplicaSet 缺少 template"}); return }
if !ok {
c.JSON(500, model.Response{Code: 500, Message: "ReplicaSet 缺少 template"})
return
}
if err := client.PatchDeploymentSpecTemplate(ns, deploymentName, template); err != nil {
c.JSON(500, model.Response{Code: 500, Message: "回滚失败: " + err.Error()}); return
c.JSON(500, model.Response{Code: 500, Message: "回滚失败: " + err.Error()})
return
}
c.JSON(200, model.Response{
Code: 200,
+64 -18
View File
@@ -12,9 +12,15 @@ import (
func (h *KubernetesHandler) GetResourceYAMLNS(c *gin.Context) {
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return }
if clusterID == 0 {
c.JSON(500, model.Response{Code: 500, Message: "cluster not found"})
return
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
ns := c.Param("namespace")
resource := c.Param("resource")
// Fallback: specific routes (statefulsets/:name/yaml, daemonsets/:name/yaml) have no :resource param
@@ -62,10 +68,14 @@ func (h *KubernetesHandler) GetResourceYAMLNS(c *gin.Context) {
case "rolebindings", "rolebinding":
apiPath = fmt.Sprintf("/apis/rbac.authorization.k8s.io/v1/namespaces/%s/rolebindings/%s", ns, name)
default:
c.JSON(400, model.Response{Code: 400, Message: "unsupported resource type: " + resource}); return
c.JSON(400, model.Response{Code: 400, Message: "unsupported resource type: " + resource})
return
}
yamlBytes, err := client.GetResourceYAML(apiPath)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: "获取YAML失败: " + err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: "获取YAML失败: " + err.Error()})
return
}
c.Header("Content-Type", "application/yaml")
c.String(200, string(yamlBytes))
}
@@ -73,9 +83,15 @@ func (h *KubernetesHandler) GetResourceYAMLNS(c *gin.Context) {
// 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, model.Response{Code: 500, Message: "cluster not found"}); return }
if clusterID == 0 {
c.JSON(500, model.Response{Code: 500, Message: "cluster not found"})
return
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
ns := c.Param("namespace")
resource := c.Param("resource")
// Fallback: specific routes (statefulsets/:name/yaml, daemonsets/:name/yaml) have no :resource param
@@ -90,7 +106,10 @@ func (h *KubernetesHandler) UpdateResourceYAMLNS(c *gin.Context) {
}
name := c.Param("name")
yamlData, err := io.ReadAll(c.Request.Body)
if err != nil { c.JSON(400, model.Response{Code: 400, Message: "读取body失败"}); return }
if err != nil {
c.JSON(400, model.Response{Code: 400, Message: "读取body失败"})
return
}
var apiPath string
switch resource {
case "deployments", "deployment":
@@ -124,10 +143,12 @@ func (h *KubernetesHandler) UpdateResourceYAMLNS(c *gin.Context) {
case "rolebindings", "rolebinding":
apiPath = fmt.Sprintf("/apis/rbac.authorization.k8s.io/v1/namespaces/%s/rolebindings/%s", ns, name)
default:
c.JSON(400, model.Response{Code: 400, Message: "unsupported resource type: " + resource}); return
c.JSON(400, model.Response{Code: 400, Message: "unsupported resource type: " + resource})
return
}
if err := client.UpdateResourceYAML(apiPath, yamlData); err != nil {
c.JSON(500, model.Response{Code: 500, Message: "更新失败: " + err.Error()}); return
c.JSON(500, model.Response{Code: 500, Message: "更新失败: " + err.Error()})
return
}
c.JSON(200, model.Response{Code: 200, Message: "YAML已应用"})
}
@@ -173,16 +194,26 @@ func resourceAPIPath(ns, resource, name string) (string, error) {
// DeleteResourceYAMLNS deletes a K8s resource (Service/Ingress/ConfigMap etc.)
func (h *KubernetesHandler) DeleteResourceYAMLNS(c *gin.Context) {
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return }
if clusterID == 0 {
c.JSON(500, model.Response{Code: 500, Message: "cluster not found"})
return
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
ns := c.Param("namespace")
resource := c.Param("resource")
name := c.Param("name")
apiPath, err := resourceAPIPath(ns, resource, name)
if err != nil { c.JSON(400, model.Response{Code: 400, Message: err.Error()}); return }
if err != nil {
c.JSON(400, model.Response{Code: 400, Message: err.Error()})
return
}
if err := client.DeleteResource(apiPath); err != nil {
c.JSON(500, model.Response{Code: 500, Message: "删除失败: " + err.Error()}); return
c.JSON(500, model.Response{Code: 500, Message: "删除失败: " + err.Error()})
return
}
c.JSON(200, model.Response{Code: 200, Message: "资源已删除"})
}
@@ -190,18 +221,33 @@ func (h *KubernetesHandler) DeleteResourceYAMLNS(c *gin.Context) {
// getK8sClient builds a K8s API client from cluster's stored kubeconfig
func (h *KubernetesHandler) ApplyYAML(c *gin.Context) {
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return }
if clusterID == 0 {
c.JSON(500, model.Response{Code: 500, Message: "cluster not found"})
return
}
client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
return
}
var req struct {
YAML string `json:"yaml"`
}
if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, model.Response{Code: 400, Message: "参数错误: " + err.Error()}); return }
if req.YAML == "" { c.JSON(400, model.Response{Code: 400, Message: "YAML 内容不能为空"}); return }
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, model.Response{Code: 400, Message: "参数错误: " + err.Error()})
return
}
if req.YAML == "" {
c.JSON(400, model.Response{Code: 400, Message: "YAML 内容不能为空"})
return
}
result, err := client.ApplyYAML(req.YAML)
if err != nil { c.JSON(500, model.Response{Code: 500, Message: "Apply 失败: " + err.Error()}); return }
if err != nil {
c.JSON(500, model.Response{Code: 500, Message: "Apply 失败: " + err.Error()})
return
}
c.JSON(200, model.Response{Code: 200, Message: "资源已应用", Data: result})
}
+1 -1
View File
@@ -15,8 +15,8 @@ import (
"ezops/model"
"ezops/pkg/systask"
_ "github.com/go-sql-driver/mysql"
"github.com/gin-gonic/gin"
_ "github.com/go-sql-driver/mysql"
"gorm.io/gorm"
)
+92 -36
View File
@@ -6,8 +6,8 @@ import (
"strconv"
"time"
"ezops/model"
"ezops/middleware"
"ezops/model"
ezredis "ezops/pkg/redis"
"ezops/pkg/toolinstaller"
@@ -123,7 +123,7 @@ func (h *PipelineHandler) ListPipelines(c *gin.Context) {
}
// 子查询:每个 pipeline_id 的最新 run id
var latestRuns []model.PipelineRun
h.db.Where("id IN (SELECT MAX(id) FROM ops_pipeline_runs WHERE pipeline_id IN ? AND status IN ('success','failed','running','pending') GROUP BY pipeline_id)", pids).
h.db.Where("id IN (SELECT MAX(id) FROM deploy_pipeline_runs WHERE pipeline_id IN ? AND status IN ('success','failed','running','pending') GROUP BY pipeline_id)", pids).
Find(&latestRuns)
runMap := map[uint]*model.PipelineRun{}
for i := range latestRuns {
@@ -230,8 +230,6 @@ func (h *PipelineHandler) CreatePipeline(c *gin.Context) {
K8sNamespace: req.K8sNamespace,
K8sDeployment: req.K8sDeployment,
K8sContainerName: req.K8sContainerName,
RepoID: req.RepoID,
RegistryID: req.RegistryID,
GitCredentialID: req.GitCredentialID,
RegistryCredentialID: req.RegistryCredentialID,
EnvironmentID: req.EnvironmentID,
@@ -301,38 +299,96 @@ func (h *PipelineHandler) UpdatePipeline(c *gin.Context) {
}
updates := map[string]interface{}{}
if req.Name != "" { updates["name"] = req.Name }
if req.JobName != "" { updates["job_name"] = req.JobName }
if req.Description != "" { updates["description"] = req.Description }
if req.Type != "" { updates["type"] = req.Type }
if req.Source != "" { updates["source"] = req.Source }
if req.Branch != "" { updates["branch"] = req.Branch }
if req.Dockerfile != "" { updates["dockerfile"] = req.Dockerfile }
if req.Stages != "" { updates["stages"] = req.Stages }
if req.Variables != "" { updates["variables"] = req.Variables }
if req.DeployMode != "" { updates["deploy_mode"] = req.DeployMode }
if req.DeployTarget != "" { updates["deploy_target"] = req.DeployTarget }
if req.ServerIDs != "" { updates["server_ids"] = req.ServerIDs }
if req.DeployRemotePath != "" { updates["deploy_remote_path"] = req.DeployRemotePath }
if req.DeployScript != "" { updates["deploy_script"] = req.DeployScript }
if req.K8sNamespace != "" { updates["k8s_namespace"] = req.K8sNamespace }
if req.K8sDeployment != "" { updates["k8s_deployment"] = req.K8sDeployment }
if req.K8sContainerName != "" { updates["k8s_container_name"] = req.K8sContainerName }
if req.K8sClusterID != nil { updates["k8s_cluster_id"] = req.K8sClusterID }
if req.RepoID != nil { updates["repo_id"] = req.RepoID }
if req.RegistryID != nil { updates["registry_id"] = req.RegistryID }
if req.GitCredentialID != nil { updates["git_credential_id"] = req.GitCredentialID }
if req.RegistryCredentialID != nil { updates["registry_credential_id"] = req.RegistryCredentialID }
if req.EnvironmentID != nil { updates["environment_id"] = req.EnvironmentID }
if req.TriggerMode != "" { updates["trigger_mode"] = req.TriggerMode }
if req.WatchBranches != "" { updates["watch_branches"] = req.WatchBranches }
if req.ExcludeBranch != "" { updates["exclude_branch"] = req.ExcludeBranch }
if req.CronExpr != "" { updates["cron_expr"] = req.CronExpr }
if req.WebhookToken != "" { updates["webhook_token"] = req.WebhookToken }
if req.CachePaths != "" { updates["cache_paths"] = req.CachePaths }
if req.Enabled != nil { updates["enabled"] = req.Enabled }
if req.MaxExecutors > 0 { updates["max_executors"] = req.MaxExecutors }
if req.BuildNodeID != nil { updates["build_node_id"] = req.BuildNodeID }
if req.Name != "" {
updates["name"] = req.Name
}
if req.JobName != "" {
updates["job_name"] = req.JobName
}
if req.Description != "" {
updates["description"] = req.Description
}
if req.Type != "" {
updates["type"] = req.Type
}
if req.Source != "" {
updates["source"] = req.Source
}
if req.Branch != "" {
updates["branch"] = req.Branch
}
if req.Dockerfile != "" {
updates["dockerfile"] = req.Dockerfile
}
if req.Stages != "" {
updates["stages"] = req.Stages
}
if req.Variables != "" {
updates["variables"] = req.Variables
}
if req.DeployMode != "" {
updates["deploy_mode"] = req.DeployMode
}
if req.DeployTarget != "" {
updates["deploy_target"] = req.DeployTarget
}
if req.ServerIDs != "" {
updates["server_ids"] = req.ServerIDs
}
if req.DeployRemotePath != "" {
updates["deploy_remote_path"] = req.DeployRemotePath
}
if req.DeployScript != "" {
updates["deploy_script"] = req.DeployScript
}
if req.K8sNamespace != "" {
updates["k8s_namespace"] = req.K8sNamespace
}
if req.K8sDeployment != "" {
updates["k8s_deployment"] = req.K8sDeployment
}
if req.K8sContainerName != "" {
updates["k8s_container_name"] = req.K8sContainerName
}
if req.K8sClusterID != nil {
updates["k8s_cluster_id"] = req.K8sClusterID
}
if req.GitCredentialID != nil {
updates["git_credential_id"] = req.GitCredentialID
}
if req.RegistryCredentialID != nil {
updates["registry_credential_id"] = req.RegistryCredentialID
}
if req.EnvironmentID != nil {
updates["environment_id"] = req.EnvironmentID
}
if req.TriggerMode != "" {
updates["trigger_mode"] = req.TriggerMode
}
if req.WatchBranches != "" {
updates["watch_branches"] = req.WatchBranches
}
if req.ExcludeBranch != "" {
updates["exclude_branch"] = req.ExcludeBranch
}
if req.CronExpr != "" {
updates["cron_expr"] = req.CronExpr
}
if req.WebhookToken != "" {
updates["webhook_token"] = req.WebhookToken
}
if req.CachePaths != "" {
updates["cache_paths"] = req.CachePaths
}
if req.Enabled != nil {
updates["enabled"] = req.Enabled
}
if req.MaxExecutors > 0 {
updates["max_executors"] = req.MaxExecutors
}
if req.BuildNodeID != nil {
updates["build_node_id"] = req.BuildNodeID
}
if err := h.db.Model(&pipeline).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, model.Response{
+10 -9
View File
@@ -545,13 +545,10 @@ func (h *PipelineHandler) runDeploy(workspace string, pipeline model.Pipeline, t
// resolvePipelineImage 解析流水线构建镜像全名(与镜像打包阶段一致) // Logan.Li
func (h *PipelineHandler) resolvePipelineImage(pipeline model.Pipeline, tag string) string {
var registryURL, imageName string
if pipeline.RegistryID != nil {
var reg model.PipelineRegistry
if err := h.db.First(&reg, *pipeline.RegistryID).Error; err == nil {
registryURL = reg.RegistryURL
if reg.Namespace != "" {
imageName = reg.Namespace + "/"
}
if pipeline.RegistryCredentialID != nil {
var credential model.Credential
if err := h.db.Take(&credential, *pipeline.RegistryCredentialID).Error; err == nil {
registryURL = credential.Summary
}
}
appName := pipeline.Code
@@ -909,9 +906,13 @@ func (h *PipelineHandler) dequeueNext(pipelineID uint) {
// 加载 pipeline + run
var pipeline model.Pipeline
if err := h.db.First(&pipeline, pipelineID).Error; err != nil { return }
if err := h.db.First(&pipeline, pipelineID).Error; err != nil {
return
}
var run model.PipelineRun
if err := h.db.First(&run, nextRunID).Error; err != nil { return }
if err := h.db.First(&run, nextRunID).Error; err != nil {
return
}
nodeID, canNode := h.tryAcquireBuildNode(pipeline)
if !canNode {
+14 -2
View File
@@ -94,8 +94,8 @@ func (h *ServerHandler) ListServers(c *gin.Context) {
// 关键词搜索
if query.Keyword != "" {
db = db.Where("hostname LIKE ? OR ip LIKE ? OR public_ip LIKE ?",
"%"+query.Keyword+"%", "%"+query.Keyword+"%", "%"+query.Keyword+"%")
db = db.Where("alias LIKE ? OR cloud_instance_name LIKE ? OR hostname LIKE ? OR ip LIKE ? OR public_ip LIKE ? OR cloud_instance_id LIKE ?",
"%"+query.Keyword+"%", "%"+query.Keyword+"%", "%"+query.Keyword+"%", "%"+query.Keyword+"%", "%"+query.Keyword+"%", "%"+query.Keyword+"%")
}
// 状态过滤
@@ -195,6 +195,9 @@ func (h *ServerHandler) CreateServer(c *gin.Context) {
server := model.Server{
Hostname: req.Hostname,
Alias: req.Alias,
CloudInstanceName: req.CloudInstanceName,
CloudInstanceID: req.CloudInstanceID,
IP: req.IP,
PublicIP: req.PublicIP,
Port: req.Port,
@@ -295,6 +298,15 @@ func (h *ServerHandler) UpdateServer(c *gin.Context) {
if req.Hostname != "" {
updates["hostname"] = req.Hostname
}
if req.Alias != nil {
updates["alias"] = *req.Alias
}
if req.CloudInstanceName != "" {
updates["cloud_instance_name"] = req.CloudInstanceName
}
if req.CloudInstanceID != "" {
updates["cloud_instance_id"] = req.CloudInstanceID
}
if req.IP != "" {
updates["ip"] = req.IP
}
+1 -1
View File
@@ -67,7 +67,7 @@ func (h *SessionCommandHandler) ListSessionCommands(c *gin.Context) {
// 联表带出会话录制大小,前端可判断能否回放 // Logan.Li
var items []model.SessionCommandItem
listQuery := h.db.Table("sys_session_commands AS sc").
listQuery := h.db.Table("audit_session_commands AS sc").
Select("sc.*, COALESCE(ts.record_size, rs.record_size, 0) AS session_record_size").
Joins("LEFT JOIN sys_terminal_sessions AS ts ON ts.id = sc.session_id AND COALESCE(sc.session_type, 'terminal') = 'terminal'").
Joins("LEFT JOIN sys_rdp_sessions AS rs ON rs.id = sc.session_id AND sc.session_type = 'rdp'")
+25 -14
View File
@@ -4,8 +4,8 @@ import (
"net/http"
"time"
"ezops/middleware"
"ezops/config"
"ezops/middleware"
"ezops/model"
"github.com/gin-gonic/gin"
@@ -62,20 +62,31 @@ func parseUserAgent(ua string) (browser, os string) {
}
// Browser
switch {
case containsAny(ua, "Edg/"): browser = "Edge"
case containsAny(ua, "Chrome"): browser = "Chrome"
case containsAny(ua, "Safari") && !containsAny(ua, "Chrome"): browser = "Safari"
case containsAny(ua, "Firefox"): browser = "Firefox"
case containsAny(ua, "curl"): browser = "curl"
case containsAny(ua, "Python"): browser = "Python"
case containsAny(ua, "Edg/"):
browser = "Edge"
case containsAny(ua, "Chrome"):
browser = "Chrome"
case containsAny(ua, "Safari") && !containsAny(ua, "Chrome"):
browser = "Safari"
case containsAny(ua, "Firefox"):
browser = "Firefox"
case containsAny(ua, "curl"):
browser = "curl"
case containsAny(ua, "Python"):
browser = "Python"
}
// OS
switch {
case containsAny(ua, "Windows"): os = "Windows"
case containsAny(ua, "Mac OS"): os = "macOS"
case containsAny(ua, "Linux") || containsAny(ua, "X11"): os = "Linux"
case containsAny(ua, "Android"): os = "Android"
case containsAny(ua, "iPhone") || containsAny(ua, "iPad"): os = "iOS"
case containsAny(ua, "Windows"):
os = "Windows"
case containsAny(ua, "Mac OS"):
os = "macOS"
case containsAny(ua, "Linux") || containsAny(ua, "X11"):
os = "Linux"
case containsAny(ua, "Android"):
os = "Android"
case containsAny(ua, "iPhone") || containsAny(ua, "iPad"):
os = "iOS"
}
return
}
@@ -363,7 +374,7 @@ func collectUserPermissionCodes(db *gorm.DB, userID uint, isAdmin bool) []string
if isAdmin {
return []string{"*"}
}
// raw SQL: user → iam_user_roles → iam_roles → role_permissions → iam_permissions
// raw SQL: user → iam_user_roles → iam_roles → iam_role_permissions → iam_permissions
// 逐层 JOIN,任一层缺失自然返回空
type permRow struct {
Code string
@@ -373,7 +384,7 @@ func collectUserPermissionCodes(db *gorm.DB, userID uint, isAdmin bool) []string
SELECT DISTINCT p.code
FROM iam_user_roles ur
JOIN iam_roles r ON r.id = ur.role_id AND r.status = 1
JOIN role_permissions rp ON rp.role_id = r.id
JOIN iam_role_permissions rp ON rp.role_id = r.id
JOIN iam_permissions p ON p.id = rp.permission_id AND p.status = 1
WHERE ur.user_id = ?
`, userID).Scan(&rows).Error
+1
View File
@@ -396,6 +396,7 @@ func (h *UserHandler) ListRoles(c *gin.Context) {
Code: 200, Data: model.ListResponse{List: roles, Total: int64(len(roles))},
})
}
// GET /api/v1/admin/users/import-template
func (h *UserHandler) ImportTemplate(c *gin.Context) {
// UTF-8 BOM for Excel compatibility
+3 -1
View File
@@ -273,7 +273,9 @@ func (h *UserHandler) UpdateApiAccessKey(c *gin.Context) {
// ExtendApiAccessKey 延长有效期(兼容旧接口)// Logan.Li
func (h *UserHandler) ExtendApiAccessKey(c *gin.Context) {
userID := middleware.GetUserID(c)
var req struct{ DurationMonths int `json:"duration_months"` }
var req struct {
DurationMonths int `json:"duration_months"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "参数错误"})
return
+2 -2
View File
@@ -9,8 +9,8 @@ import (
"time"
"ezops/middleware"
"ezops/pkg/wshub"
ezredis "ezops/pkg/redis"
"ezops/pkg/wshub"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
@@ -137,7 +137,7 @@ func (h *WSPipelineLogHandler) GetLogHistory(c *gin.Context) {
// 查 DB(已完成)
var logs string
h.db.Table("ops_pipeline_runs").Where("id = ?", uint(runID)).Select("logs").Scan(&logs)
h.db.Table("deploy_pipeline_runs").Where("id = ?", uint(runID)).Select("logs").Scan(&logs)
if logs == "" {
logs = "[]"
}
+36 -12
View File
@@ -113,24 +113,48 @@ func (h *ZabbixDSHandler) Update(c *gin.Context) {
}
updates := map[string]interface{}{}
if req.Name != nil { updates["name"] = *req.Name }
if req.URL != nil { updates["url"] = *req.URL }
if req.Username != nil { updates["username"] = *req.Username }
if req.Password != nil && *req.Password != "" { updates["password"] = *req.Password }
if req.TrendsAfter != nil { updates["trends_after"] = *req.TrendsAfter }
if req.TrendsRange != nil { updates["trends_range"] = *req.TrendsRange }
if req.TrendsCacheTTL != nil { updates["trends_cache_ttl"] = *req.TrendsCacheTTL }
if req.TrendsTimeout != nil { updates["trends_timeout"] = *req.TrendsTimeout }
if req.DirectDB != nil { updates["direct_db"] = *req.DirectDB }
if req.DisableAcknowledges != nil { updates["disable_acknowledges"] = *req.DisableAcknowledges }
if req.DisableDataAlignment != nil { updates["disable_data_alignment"] = *req.DisableDataAlignment }
if req.Name != nil {
updates["name"] = *req.Name
}
if req.URL != nil {
updates["url"] = *req.URL
}
if req.Username != nil {
updates["username"] = *req.Username
}
if req.Password != nil && *req.Password != "" {
updates["password"] = *req.Password
}
if req.TrendsAfter != nil {
updates["trends_after"] = *req.TrendsAfter
}
if req.TrendsRange != nil {
updates["trends_range"] = *req.TrendsRange
}
if req.TrendsCacheTTL != nil {
updates["trends_cache_ttl"] = *req.TrendsCacheTTL
}
if req.TrendsTimeout != nil {
updates["trends_timeout"] = *req.TrendsTimeout
}
if req.DirectDB != nil {
updates["direct_db"] = *req.DirectDB
}
if req.DisableAcknowledges != nil {
updates["disable_acknowledges"] = *req.DisableAcknowledges
}
if req.DisableDataAlignment != nil {
updates["disable_data_alignment"] = *req.DisableDataAlignment
}
if req.IsDefault != nil {
updates["is_default"] = *req.IsDefault
if *req.IsDefault {
h.db.Model(&model.ZabbixDataSource{}).Where("is_default = ? AND id != ?", true, id).Update("is_default", false)
}
}
if req.Status != nil { updates["status"] = *req.Status }
if req.Status != nil {
updates["status"] = *req.Status
}
if len(updates) > 0 {
if err := h.db.Model(&ds).Updates(updates).Error; err != nil {
+7
View File
@@ -68,6 +68,13 @@ func setupFrontend(r *gin.Engine) {
return
}
if strings.HasPrefix(cleanPath, "assets/") {
// 构建哈希资源缺失时返回真实 404,避免 SPA fallback 把 index.html 当 JS 模块返回。 // Logan.Li
c.Header("Cache-Control", "no-store, no-cache, must-revalidate")
c.String(http.StatusNotFound, "static asset not found")
return
}
// SPA fallback → 直接返回 index.html 字节(避免 http.FileServer 的 301 redirect)
indexBytes, err := fs.ReadFile(staticFS, "index.html")
if err != nil {
+1 -1
View File
@@ -46,7 +46,7 @@ func collectPermissionCodes(db *gorm.DB, userID uint) []string {
SELECT DISTINCT p.code AS code
FROM iam_user_roles ur
JOIN iam_roles r ON r.id = ur.role_id AND r.status = 1
JOIN role_permissions rp ON rp.role_id = r.id
JOIN iam_role_permissions rp ON rp.role_id = r.id
JOIN iam_permissions p ON p.id = rp.permission_id AND p.status = 1
WHERE ur.user_id = ?
`, userID).Scan(&rows)
+1 -2
View File
@@ -205,7 +205,7 @@ type AgentPortPolicy struct {
}
func (AgentPortPolicy) TableName() string {
return "monitor_agent_port_policy"
return "monitor_agent_port_policies"
}
// AgentPortPolicyDTO 前端交互用的端口策略格式(含已解析的数组)
@@ -214,4 +214,3 @@ type AgentPortPolicyDTO struct {
PortFilterMode string `json:"port_filter_mode"`
PortWhitelist []int `json:"port_whitelist"`
}
-55
View File
@@ -1,55 +0,0 @@
// agent_cli.go - Agent CLI 会话与消息持久化模型 // Logan.Li
// 记录 AI 运维 Agent 的每次任务(关联主机 + 用户)及其 ReAct 推理过程
// (思考/工具调用/输出/结果),供前端历史回放与审计。
package model
import "time"
// AgentCLISession Agent CLI 任务会话 // Logan.Li
type AgentCLISession struct {
ID uint `gorm:"primarykey" json:"id"`
UserID uint `gorm:"index" json:"user_id"` // 发起用户
Mode string `gorm:"size:16;default:host;index" json:"mode"` // host | kubernetes
ServerID uint `gorm:"index" json:"server_id"` // 主机模式目标主机
ServerName string `gorm:"size:128" json:"server_name"` // 主机名快照
ServerHost string `gorm:"size:64" json:"server_host"` // 主机 IP 快照
ClusterID uint `gorm:"index" json:"cluster_id"` // K8s 模式集群 ID
ClusterName string `gorm:"size:128" json:"cluster_name"` // 集群名快照
Namespace string `gorm:"size:128" json:"namespace"` // K8s 命名空间
PodName string `gorm:"size:128" json:"pod_name"` // 可选目标 Pod
Container string `gorm:"size:128" json:"container"` // 可选目标容器
Title string `gorm:"size:255" json:"title"` // 任务标题(query 截断)
Status string `gorm:"size:32;default:running" json:"status"` // running/done/aborted/error
Provider string `gorm:"size:50" json:"provider"` // LLM 提供商
Model string `gorm:"size:100" json:"model"` // 模型名
Rounds int `json:"rounds"` // ReAct 轮次
TotalTokens int `json:"total_tokens"` // token 消耗
Duration int64 `json:"duration"` // 耗时(毫秒)
Error string `gorm:"type:text" json:"error,omitempty"` // 错误信息
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName 表名 // Logan.Li
func (AgentCLISession) TableName() string { return "agent_cli_sessions" }
// AgentCLIMessage Agent CLI 任务消息(ReAct 推理过程的每一步) // Logan.Li
type AgentCLIMessage struct {
ID uint `gorm:"primarykey" json:"id"`
SessionID uint `gorm:"index;not null" json:"session_id"`
Round int `json:"round"` // ReAct 轮次
Type string `gorm:"size:32" json:"type"` // thought/tool_call/tool_result/done/error/session
Role string `gorm:"size:32" json:"role"` // user/assistant/tool
Content string `gorm:"type:text" json:"content"` // 思考文本/最终答案
ToolName string `gorm:"size:64" json:"tool_name"` // 工具名
ToolArgs string `gorm:"type:text" json:"tool_args"` // 工具参数 JSON
ToolOutput string `gorm:"type:text" json:"tool_output"` // 工具输出
Risk string `gorm:"size:32" json:"risk"` // 风险等级
Success bool `json:"success"`
Duration string `gorm:"size:32" json:"duration"` // 工具耗时
Tokens int `json:"tokens"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
}
// TableName 表名 // Logan.Li
func (AgentCLIMessage) TableName() string { return "agent_cli_messages" }
-1
View File
@@ -2,7 +2,6 @@ package model
import (
"time"
)
// AgentTask Agent 远程任务 // Logan.Li
+172
View File
@@ -0,0 +1,172 @@
// ai_runtime.go - AI 运行、工具能力和执行审计统一模型 // Logan.Li
package model
import "time"
// AIRun 统一记录 Agent CLI、告警分析、智能助手、MCP/技能调用发起的 AI 任务。 // Logan.Li
type AIRun struct {
ID uint `gorm:"primarykey" json:"id"`
RunID string `gorm:"size:64;uniqueIndex;not null" json:"run_id"` // 对外 trace/run 标识 // Logan.Li
ParentRunID string `gorm:"size:64;index" json:"parent_run_id"` // 子任务关联 // Logan.Li
UserID uint `gorm:"index" json:"user_id"` // 发起用户 // Logan.Li
Feature string `gorm:"size:64;index;not null" json:"feature"` // agent_cli/alert_analysis/assistant/mcp // Logan.Li
Mode string `gorm:"size:32;index" json:"mode"` // host/kubernetes/aiops/general // Logan.Li
Title string `gorm:"size:255" json:"title"` // 任务标题 // Logan.Li
Status string `gorm:"size:32;index;default:running" json:"status"` // running/done/error/aborted // Logan.Li
SubjectType string `gorm:"size:64;index" json:"subject_type"` // host/k8s_cluster/namespace/pod/aiops_module/mcp_server/skill // Logan.Li
SubjectID string `gorm:"size:128;index" json:"subject_id"` // 资源 ID 或稳定键 // Logan.Li
SubjectName string `gorm:"size:255" json:"subject_name"` // 资源快照名称 // Logan.Li
SubjectScope string `gorm:"size:255;index" json:"subject_scope"` // prod/ns/app 等范围键 // Logan.Li
Provider string `gorm:"size:50" json:"provider"` // LLM 提供商 // Logan.Li
Model string `gorm:"size:100" json:"model"` // 模型名 // Logan.Li
SafetyMode string `gorm:"size:32" json:"safety_mode"` // strict/balanced/permissive // Logan.Li
ContextPackID string `gorm:"size:64;index" json:"context_pack_id"` // 上下文包引用 // Logan.Li
PromptTokens int `json:"prompt_tokens"` // 输入 token // Logan.Li
CompletionTokens int `json:"completion_tokens"` // 输出 token // Logan.Li
TotalTokens int `json:"total_tokens"` // 总 token // Logan.Li
Rounds int `json:"rounds"` // Agent 轮次 // Logan.Li
Duration int64 `json:"duration"` // 毫秒 // Logan.Li
Error string `gorm:"type:text" json:"error,omitempty"` // 错误摘要 // Logan.Li
StartedAt time.Time `gorm:"index" json:"started_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (AIRun) TableName() string { return "ai_runs" }
// AIRunMessage 统一 AI 对话消息,负责回放,不承载工具审计明细。 // Logan.Li
type AIRunMessage struct {
ID uint `gorm:"primarykey" json:"id"`
RunID string `gorm:"size:64;index;not null" json:"run_id"`
Round int `gorm:"index" json:"round"`
Seq int `gorm:"index" json:"seq"`
Role string `gorm:"size:32;index" json:"role"` // system/user/assistant/tool // Logan.Li
Type string `gorm:"size:32;index" json:"type"` // message/thought/tool_call/tool_result/done/error/status // Logan.Li
Content string `gorm:"type:text" json:"content"` // 脱敏内容 // Logan.Li
ToolName string `gorm:"size:128;index" json:"tool_name"` // 可选工具名 // Logan.Li
ToolCallID string `gorm:"size:128;index" json:"tool_call_id"` // LLM 工具调用 ID // Logan.Li
SpanID string `gorm:"size:64;index" json:"span_id"` // 关联 span // Logan.Li
Tokens int `json:"tokens"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
}
func (AIRunMessage) TableName() string { return "ai_run_messages" }
// AIRunSpan 统一 AI trace span,记录 LLM、工具、guardrail、确认、审批等事件。 // Logan.Li
type AIRunSpan struct {
ID uint `gorm:"primarykey" json:"id"`
RunID string `gorm:"size:64;index;not null" json:"run_id"`
SpanID string `gorm:"size:64;uniqueIndex;not null" json:"span_id"`
ParentSpanID string `gorm:"size:64;index" json:"parent_span_id"`
Kind string `gorm:"size:32;index;not null" json:"kind"` // llm/tool/guardrail/confirm/approval/context // Logan.Li
Name string `gorm:"size:128;index" json:"name"` // 工具名或步骤名 // Logan.Li
CapabilityID uint `gorm:"index" json:"capability_id"` // 能力注册表 ID // Logan.Li
ToolSource string `gorm:"size:32;index" json:"tool_source"` // builtin/mcp/skill/shell/kubernetes/aiops // Logan.Li
Input string `gorm:"type:text" json:"input"` // 脱敏输入 // Logan.Li
Output string `gorm:"type:text" json:"output"` // 脱敏输出摘要 // Logan.Li
OutputSize int `json:"output_size"` // 脱敏后输出长度 // Logan.Li
Risk string `gorm:"size:32;index" json:"risk"` // safe/low/medium/high/critical // Logan.Li
Decision string `gorm:"size:32;index" json:"decision"` // auto/approved/rejected/denied/escalated // Logan.Li
Status string `gorm:"size:32;index" json:"status"` // running/success/error/skipped // Logan.Li
Error string `gorm:"type:text" json:"error"` // 错误摘要 // Logan.Li
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
Duration int64 `json:"duration"` // 毫秒 // Logan.Li
StartedAt time.Time `gorm:"index" json:"started_at"`
EndedAt *time.Time `json:"ended_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
func (AIRunSpan) TableName() string { return "ai_run_spans" }
// AICapability 注册 AIOps 内置工具、MCP 工具、Skill、Shell/K8s 工具的统一能力目录。 // Logan.Li
type AICapability struct {
ID uint `gorm:"primarykey" json:"id"`
Name string `gorm:"size:128;uniqueIndex;not null" json:"name"` // 稳定工具名 // Logan.Li
DisplayName string `gorm:"size:128" json:"display_name"`
Source string `gorm:"size:32;index;not null" json:"source"` // builtin/mcp/skill/shell/kubernetes/aiops // Logan.Li
Category string `gorm:"size:64;index" json:"category"` // asset/k8s/monitor/alert/change/security // Logan.Li
RiskLevel string `gorm:"size:32;index;default:safe" json:"risk_level"`
Schema string `gorm:"type:text" json:"schema"` // JSON Schema // Logan.Li
Config string `gorm:"type:text" json:"config"` // MCP server/skill 配置快照 // Logan.Li
Description string `gorm:"type:text" json:"description"`
Enabled bool `gorm:"default:true;index" json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (AICapability) TableName() string { return "ai_capabilities" }
// AICapabilityPolicy 描述工具能力在用户、角色、资源范围上的准入和审批策略。 // Logan.Li
type AICapabilityPolicy struct {
ID uint `gorm:"primarykey" json:"id"`
Name string `gorm:"size:128;not null" json:"name"`
CapabilityID uint `gorm:"index" json:"capability_id"`
SubjectType string `gorm:"size:32;index" json:"subject_type"` // user/role/all // Logan.Li
SubjectID string `gorm:"size:64;index" json:"subject_id"`
ResourceType string `gorm:"size:64;index" json:"resource_type"` // host/k8s_cluster/namespace/pod/aiops_module/mcp_server/skill // Logan.Li
ResourcePattern string `gorm:"size:255" json:"resource_pattern"` // 正则或通配符 // Logan.Li
Effect string `gorm:"size:16;index;default:allow" json:"effect"` // allow/deny // Logan.Li
RequireConfirm bool `gorm:"default:false" json:"require_confirm"`
RequireApproval bool `gorm:"default:false" json:"require_approval"`
MaxRisk string `gorm:"size:32;default:high" json:"max_risk"`
Enabled bool `gorm:"default:true;index" json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (AICapabilityPolicy) TableName() string { return "ai_capability_policies" }
// OpsSession 统一人机执行会话,覆盖 SSH/RDP/Pod/kubectl/Agent CLI。 // Logan.Li
type OpsSession struct {
ID uint `gorm:"primarykey" json:"id"`
SessionID string `gorm:"size:64;uniqueIndex;not null" json:"session_id"`
RunID string `gorm:"size:64;index" json:"run_id"` // AI 发起时关联 ai_runs // Logan.Li
UserID uint `gorm:"index" json:"user_id"`
SessionType string `gorm:"size:32;index;not null" json:"session_type"` // ssh/rdp/pod_exec/kubectl/agent_cli // Logan.Li
ActorType string `gorm:"size:32;index;default:user" json:"actor_type"` // user/ai/system // Logan.Li
TargetType string `gorm:"size:64;index" json:"target_type"` // host/k8s_cluster/pod // Logan.Li
TargetID string `gorm:"size:128;index" json:"target_id"`
TargetName string `gorm:"size:255" json:"target_name"`
ClientIP string `gorm:"size:64" json:"client_ip"`
Status string `gorm:"size:32;index" json:"status"` // active/closed/failed // Logan.Li
RecordRef string `gorm:"size:255" json:"record_ref"` // 录制对象存储或分片引用 // Logan.Li
RecordSize int `json:"record_size"`
Duration int64 `json:"duration"` // 秒 // Logan.Li
Error string `gorm:"type:text" json:"error,omitempty"`
StartedAt time.Time `gorm:"index" json:"started_at"`
EndedAt *time.Time `json:"ended_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (OpsSession) TableName() string { return "ai_ops_sessions" }
// OpsCommand 统一命令/动作审计,覆盖人执行和 AI 工具执行。 // Logan.Li
type OpsCommand struct {
ID uint `gorm:"primarykey" json:"id"`
CommandID string `gorm:"size:64;uniqueIndex;not null" json:"command_id"`
SessionID string `gorm:"size:64;index" json:"session_id"`
RunID string `gorm:"size:64;index" json:"run_id"`
SpanID string `gorm:"size:64;index" json:"span_id"`
UserID uint `gorm:"index" json:"user_id"`
ActorType string `gorm:"size:32;index;default:user" json:"actor_type"` // user/ai/system // Logan.Li
TargetType string `gorm:"size:64;index" json:"target_type"`
TargetID string `gorm:"size:128;index" json:"target_id"`
CommandType string `gorm:"size:32;index" json:"command_type"` // shell/k8s_action/mcp/skill/api // Logan.Li
Command string `gorm:"type:text" json:"command"` // 脱敏命令或动作参数 // Logan.Li
Risk string `gorm:"size:32;index" json:"risk"`
Decision string `gorm:"size:32;index" json:"decision"`
Status string `gorm:"size:32;index" json:"status"`
ExitCode int `gorm:"default:-1" json:"exit_code"`
OutputRef string `gorm:"size:255" json:"output_ref"` // 大输出引用 // Logan.Li
OutputSize int `json:"output_size"`
Duration int64 `json:"duration"` // 毫秒 // Logan.Li
Error string `gorm:"type:text" json:"error"`
ExecutedAt time.Time `gorm:"index" json:"executed_at"`
CreatedAt time.Time `json:"created_at"`
}
func (OpsCommand) TableName() string { return "ai_ops_commands" }
+2 -1
View File
@@ -333,7 +333,7 @@ type NotifyChannel struct {
// TableName 表名
func (NotifyChannel) TableName() string {
return "monitor_notify_channels"
return "monitor_alert_channels"
}
// MetricData 监控指标数据
@@ -499,6 +499,7 @@ type FeishuCardContent struct {
Title string `json:"title"`
Content [][]FeishuCardElement `json:"content"`
}
// FeishuCardElement 飞书卡片元素
type FeishuCardElement struct {
Tag string `json:"tag"`
+1 -2
View File
@@ -2,7 +2,6 @@ package model
import (
"time"
)
// ApprovalTemplate 审批模板模型
@@ -22,7 +21,7 @@ type ApprovalTemplate struct {
// TableName 表名
func (ApprovalTemplate) TableName() string {
return "sys_approval_templates"
return "approval_templates"
}
// ApprovalProcess 审批流程模型
+2 -2
View File
@@ -17,7 +17,7 @@ type ApprovalWorkflow struct {
}
func (ApprovalWorkflow) TableName() string {
return "sys_approval_workflows"
return "approval_workflows"
}
// ApprovalWorkflowStep 审批流程步骤
@@ -36,7 +36,7 @@ type ApprovalWorkflowStep struct {
}
func (ApprovalWorkflowStep) TableName() string {
return "sys_approval_workflow_steps"
return "approval_workflow_steps"
}
// CreateWorkflowRequest 创建审批流程请求
+1 -1
View File
@@ -25,7 +25,7 @@ type AuditLog struct {
// TableName 表名
func (AuditLog) TableName() string {
return "sys_audit_logs"
return "audit_operation_logs"
}
// AuditLogQuery 审计日志查询
+1 -1
View File
@@ -20,7 +20,7 @@ type AuditSettings struct {
UpdatedAt time.Time `json:"updated_at"`
}
func (AuditSettings) TableName() string { return "sys_audit_settings" }
func (AuditSettings) TableName() string { return "audit_settings" }
// AllowedAuditRetentionDays 允许的日志保留天数 // Logan.Li
var AllowedAuditRetentionDays = []int{7, 15, 30, 90, 180, 365}
-1
View File
@@ -2,7 +2,6 @@ package model
import (
"time"
)
// CronJob 定时任务模型
+2 -3
View File
@@ -2,7 +2,6 @@ package model
import (
"time"
)
// CloudBilling 云账单模型 - 每日成本记录
@@ -23,7 +22,7 @@ type CloudBilling struct {
}
func (CloudBilling) TableName() string {
return "asset_cloud_billings"
return "asset_cloud_billing_records"
}
// BillingAlertRule 账单预警规则
@@ -42,7 +41,7 @@ type BillingAlertRule struct {
}
func (BillingAlertRule) TableName() string {
return "monitor_billing_alert_rules"
return "asset_cloud_billing_alert_rules"
}
// BillingStats 账单统计
+1 -1
View File
@@ -32,7 +32,7 @@ type BuildMasterConfig struct {
UpdatedAt time.Time `json:"updated_at"`
}
func (BuildMasterConfig) TableName() string { return "deploy_build_master_config" }
func (BuildMasterConfig) TableName() string { return "deploy_build_master_configs" }
// UpdateBuildMasterRequest 更新 Master 并发数 // Logan.Li
type UpdateBuildMasterRequest struct {
+6 -17
View File
@@ -2,7 +2,6 @@ package model
import (
"time"
)
// Pipeline CI/CD流水线模型
@@ -36,16 +35,12 @@ type Pipeline struct {
DeployScript string `gorm:"type:text;comment:SSH部署后执行命令" json:"deploy_script,omitempty"`
// K8s 部署目标
K8sClusterID *uint `json:"k8s_cluster_id,omitempty"` // FK → kubernetes_clusters
K8sClusterID *uint `json:"k8s_cluster_id,omitempty"` // FK → k8s_clusters
K8sNamespace string `gorm:"size:128;comment:K8s部署命名空间" json:"k8s_namespace,omitempty"`
K8sDeployment string `gorm:"size:128;comment:K8s Deployment 名称" json:"k8s_deployment,omitempty"`
K8sContainerName string `gorm:"size:128;comment:K8s更新镜像目标容器" json:"k8s_container_name,omitempty"`
// 仓库和镜像仓库关联(旧字段,待废弃)
RepoID *uint `json:"repo_id"` // FK → deploy_pipeline_repos
RegistryID *uint `json:"registry_id"` // FK → deploy_pipeline_registries
// 凭证关联(直接关联凭证 ID)
// 凭证关联(直接关联统一凭证 ID)// Logan.Li
GitCredentialID *uint `json:"git_credential_id"` // FK → iam_credentials (Git Token)
RegistryCredentialID *uint `json:"registry_credential_id"` // FK → iam_credentials (Docker Password)
@@ -71,14 +66,12 @@ type Pipeline struct {
UpdatedAt time.Time `json:"updated_at"`
// 关联
Repo *PipelineRepo `json:"repo,omitempty" gorm:"foreignKey:RepoID"`
Registry *PipelineRegistry `json:"registry,omitempty" gorm:"foreignKey:RegistryID"`
Environment *DeployEnvironment `json:"environment,omitempty" gorm:"foreignKey:EnvironmentID"`
}
// TableName 表名
func (Pipeline) TableName() string {
return "ops_pipelines"
return "deploy_pipelines"
}
// PipelineRun 流水线运行记录(CI 执行)
@@ -111,7 +104,7 @@ type PipelineRun struct {
// TableName 表名
func (PipelineRun) TableName() string {
return "ops_pipeline_runs"
return "deploy_pipeline_runs"
}
// AppRelease 应用发布模型(CD:从 CI 产物部署到目标环境)
@@ -170,9 +163,7 @@ type CreatePipelineRequest struct {
K8sDeployment string `json:"k8s_deployment"`
K8sContainerName string `json:"k8s_container_name"`
RepoID *uint `json:"repo_id"`
RegistryID *uint `json:"registry_id"`
// 凭证关联
// 凭证关联 // Logan.Li
GitCredentialID *uint `json:"git_credential_id"`
RegistryCredentialID *uint `json:"registry_credential_id"`
EnvironmentID *uint `json:"environment_id"`
@@ -214,9 +205,7 @@ type UpdatePipelineRequest struct {
K8sNamespace string `json:"k8s_namespace"`
K8sDeployment string `json:"k8s_deployment"`
K8sContainerName string `json:"k8s_container_name"`
RepoID *uint `json:"repo_id"`
RegistryID *uint `json:"registry_id"`
// 凭证关联
// 凭证关联 // Logan.Li
GitCredentialID *uint `json:"git_credential_id"`
RegistryCredentialID *uint `json:"registry_credential_id"`
EnvironmentID *uint `json:"environment_id"`
-31
View File
@@ -1,31 +0,0 @@
package model
import (
"time"
)
// CustomDashboard 自定义监控仪表盘(面板配置存 JSON) // Logan.Li
type CustomDashboard struct {
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
Name string `gorm:"type:varchar(128);not null;comment:仪表盘名称" json:"name"`
Description string `gorm:"type:varchar(512);comment:描述" json:"description"`
Datasource string `gorm:"type:varchar(32);default:'prometheus';comment:数据源类型" json:"datasource"`
DatasourceURL string `gorm:"type:varchar(512);comment:数据源URL(留空则用默认Prometheus)" json:"datasource_url"`
// Panels 存储面板配置 JSON 数组: [{id,title,type,query,width,height,x,y,options}]
Panels string `gorm:"type:longtext;comment:面板配置JSON" json:"panels"`
// Tags 标签(JSON 数组字符串)
Tags string `gorm:"type:varchar(512);comment:标签JSON" json:"tags,omitempty"`
// 归属
CreatedBy string `gorm:"type:varchar(64);comment:创建人" json:"created_by"`
IsDefault bool `gorm:"default:false;index;comment:是否默认仪表盘" json:"is_default"`
IsFavorite bool `gorm:"default:false;comment:是否收藏" json:"is_favorite"`
// 时间戳
CreatedAt time.Time `gorm:"not null;index;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
DeletedAt *time.Time `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"`
}
// TableName 指定表名
func (CustomDashboard) TableName() string {
return "monitor_custom_dashboards"
}
-64
View File
@@ -2,70 +2,6 @@ package model
import "time"
// ============================================
// PipelineRepo Git 仓库认证配置
// ============================================
// RepoAuthType 仓库认证方式
type RepoAuthType string
const (
RepoAuthToken RepoAuthType = "token" // Personal Access Token
RepoAuthSSH RepoAuthType = "ssh" // SSH Key
RepoAuthPassword RepoAuthType = "password" // 用户名+密码
)
// PipelineRepo Git 仓库认证配置表
type PipelineRepo struct {
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
Name string `gorm:"type:varchar(128);not null;comment:配置名称" json:"name"`
Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"`
URL string `gorm:"type:varchar(512);not null;comment:仓库 URL" json:"url"`
AuthType RepoAuthType `gorm:"type:varchar(16);default:'token';comment:认证方式: token/ssh/password" json:"auth_type"`
CredentialID *uint `gorm:"index;comment:凭据ID(关联 iam_credentials)" json:"credential_id,omitempty"`
Enabled bool `gorm:"default:true;comment:是否启用" json:"enabled"`
CreatedBy string `gorm:"type:varchar(64);comment:创建人" json:"created_by,omitempty"`
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
}
// TableName 表名
func (PipelineRepo) TableName() string {
return "deploy_pipeline_repos"
}
// ============================================
// PipelineRegistry 镜像仓库认证配置
// ============================================
// RegistryAuthType 镜像仓库认证方式
type RegistryAuthType string
const (
RegistryAuthToken RegistryAuthType = "token" // Access Token
RegistryAuthPassword RegistryAuthType = "password" // 用户名+密码
)
// PipelineRegistry 镜像仓库认证配置表
type PipelineRegistry struct {
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
Name string `gorm:"type:varchar(128);not null;comment:配置名称" json:"name"`
Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"`
RegistryURL string `gorm:"type:varchar(512);not null;comment:镜像仓库地址" json:"registry_url"`
Namespace string `gorm:"type:varchar(128);comment:默认命名空间" json:"namespace,omitempty"`
AuthType RegistryAuthType `gorm:"type:varchar(16);default:'password';comment:认证方式: token/password" json:"auth_type"`
CredentialID *uint `gorm:"index;comment:凭据ID(关联 iam_credentials)" json:"credential_id,omitempty"`
Enabled bool `gorm:"default:true;comment:是否启用" json:"enabled"`
CreatedBy string `gorm:"type:varchar(64);comment:创建人" json:"created_by,omitempty"`
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
}
// TableName 表名
func (PipelineRegistry) TableName() string {
return "deploy_pipeline_registries"
}
// ============================================
// PipelineStage 流水线阶段定义(JSON)
// ============================================
+2 -2
View File
@@ -3,8 +3,8 @@ package model
import "time"
// ============================================
// DeployTool 工具配置(懒加载/预装/禁用)
// 按 deploy_ 前缀,与 PipelineRepo/Registry 同属发布配置域
// DeployTool 工具配置(懒加载/预装/禁用)// Logan.Li
// 按 deploy_ 前缀归属发布配置域。// Logan.Li
// ============================================
// ToolInstallMethod 安装方式
+7 -8
View File
@@ -2,7 +2,6 @@ package model
import (
"time"
)
// KubernetesCluster K8s集群模型
@@ -33,7 +32,7 @@ type KubernetesCluster struct {
// TableName 表名
func (KubernetesCluster) TableName() string {
return "kubernetes_clusters"
return "k8s_clusters"
}
// KubernetesNode K8s节点模型
@@ -64,7 +63,7 @@ type KubernetesNode struct {
// TableName 表名
func (KubernetesNode) TableName() string {
return "kubernetes_nodes"
return "k8s_nodes"
}
// KubernetesPod K8s Pod模型
@@ -90,7 +89,7 @@ type KubernetesPod struct {
// TableName 表名
func (KubernetesPod) TableName() string {
return "kubernetes_pods"
return "k8s_pods"
}
// KubernetesDeployment K8s Deployment模型
@@ -114,7 +113,7 @@ type KubernetesDeployment struct {
// TableName 表名
func (KubernetesDeployment) TableName() string {
return "kubernetes_deployments"
return "k8s_deployments"
}
// KubernetesService K8s Service模型
@@ -138,7 +137,7 @@ type KubernetesService struct {
// TableName 表名
func (KubernetesService) TableName() string {
return "kubernetes_services"
return "k8s_services"
}
// KubernetesIngress K8s Ingress模型
@@ -161,7 +160,7 @@ type KubernetesIngress struct {
// TableName 表名
func (KubernetesIngress) TableName() string {
return "kubernetes_ingresses"
return "k8s_ingresses"
}
// CreateKubernetesClusterRequest 创建K8s集群请求
@@ -201,5 +200,5 @@ type TopologyLayout struct {
// TableName 指定表名
func (TopologyLayout) TableName() string {
return "kubernetes_topology_layouts"
return "k8s_topology_layouts"
}
+5 -5
View File
@@ -18,19 +18,19 @@ type LogScopeRole struct {
UpdatedAt time.Time `json:"updated_at"`
}
func (LogScopeRole) TableName() string { return "monitor_log_scope_roles" }
func (LogScopeRole) TableName() string { return "iam_log_scope_roles" }
// UserLogScopeRole 用户 ↔ 日志范围权限 // Logan.Li
type UserLogScopeRole struct {
ID uint `gorm:"primarykey" json:"id"`
UserID uint `gorm:"uniqueIndex:idx_user_log_scope;not null" json:"user_id"`
LogScopeRoleID uint `gorm:"uniqueIndex:idx_user_log_scope;not null" json:"log_scope_role_id"`
UserID uint `gorm:"uniqueIndex:idx_iam_user_log_scope_role;not null" json:"user_id"`
LogScopeRoleID uint `gorm:"uniqueIndex:idx_iam_user_log_scope_role;not null" json:"log_scope_role_id"`
CreatedAt time.Time `json:"created_at"`
LogScopeRole *LogScopeRole `json:"log_scope_role,omitempty" gorm:"foreignKey:LogScopeRoleID"`
LogScopeRole *LogScopeRole `json:"log_scope_role,omitempty" gorm:"foreignKey:LogScopeRoleID;references:ID"`
}
func (UserLogScopeRole) TableName() string { return "monitor_user_log_scope_roles" }
func (UserLogScopeRole) TableName() string { return "iam_user_log_scope_roles" }
// CreateLogScopeRoleRequest 创建日志权限策略 // Logan.Li
type CreateLogScopeRoleRequest struct {
+1 -1
View File
@@ -20,7 +20,7 @@ type LoginLog struct {
}
func (LoginLog) TableName() string {
return "sys_login_logs"
return "audit_login_logs"
}
// LoginLogQuery 登录日志查询
+1 -1
View File
@@ -19,7 +19,7 @@ type MetricsAggregate struct {
}
func (MetricsAggregate) TableName() string {
return "monitor_metrics_aggregate"
return "monitor_metric_aggregates"
}
// AggregateReq 聚合触发请求
+2 -3
View File
@@ -147,7 +147,7 @@ type MetricValue struct {
// TableName 指定表名
func (MetricValue) TableName() string {
return "metric_values"
return "monitor_metric_values"
}
// MetricHistory 指标历史数据(简化版,供监控查询使用)
@@ -314,7 +314,7 @@ type DashboardPanel struct {
// TableName 指定表名
func (DashboardPanel) TableName() string {
return "dashboard_panels"
return "monitor_dashboard_panels"
}
// PanelQueryConfig 面板查询配置
@@ -422,4 +422,3 @@ type HealthProbeResult struct {
StatusCode int `json:"status_code,omitempty"` // HTTP 状态码
Message string `json:"message,omitempty"` // 错误/附加信息
}
+1 -1
View File
@@ -31,4 +31,4 @@ type MonitorAlertRule struct {
UpdatedAt time.Time `json:"updated_at"`
}
func (MonitorAlertRule) TableName() string { return "monitor_alert_rules" }
func (MonitorAlertRule) TableName() string { return "monitor_probe_alert_rules" }
+1 -2
View File
@@ -2,7 +2,6 @@ package model
import (
"time"
)
// NavigationGroup 导航分组模型
@@ -45,7 +44,7 @@ type NavigationLink struct {
// TableName 表名
func (NavigationLink) TableName() string {
return "navigation_links"
return "sys_navigation_links"
}
// CreateNavigationGroupRequest 创建导航分组请求
-1
View File
@@ -2,7 +2,6 @@ package model
import (
"time"
)
// AnsiblePlaybook Playbook 模型 // Logan.Li
+1 -1
View File
@@ -25,7 +25,7 @@ type RDPSession struct {
}
func (RDPSession) TableName() string {
return "sys_rdp_sessions"
return "audit_rdp_sessions"
}
// RDPSessionQuery Windows RDP 图形会话查询 // Logan.Li
-1
View File
@@ -2,7 +2,6 @@ package model
import (
"time"
)
// ResourceModel 资源模型 (CMDB 模型定义)
+11 -2
View File
@@ -8,6 +8,9 @@ import (
type Server struct {
ID uint `gorm:"primarykey" json:"id"`
Hostname string `gorm:"size:128;not null" json:"hostname"`
Alias string `gorm:"size:128" json:"alias"` // 人工可读别名,展示优先级高于 hostname // Logan.Li
CloudInstanceName string `gorm:"size:128" json:"cloud_instance_name"` // 云平台实例名称,低于人工别名高于 hostname 展示 // Logan.Li
CloudInstanceID string `gorm:"size:128;index" json:"cloud_instance_id"` // 云平台实例 ID,仅做关联/去重,不作为首选展示名 // Logan.Li
IP string `gorm:"size:64;not null" json:"ip"`
PublicIP string `gorm:"size:64" json:"public_ip"`
Port int `gorm:"default:22" json:"port"`
@@ -66,7 +69,7 @@ type Cloud struct {
// TableName 表名
func (Cloud) TableName() string {
return "asset_clouds"
return "asset_cloud_platforms"
}
// CloudAccount 云账号模型
@@ -106,7 +109,7 @@ type Region struct {
// TableName 表名
func (Region) TableName() string {
return "asset_regions"
return "asset_cloud_regions"
}
// Domain 域名模型
@@ -132,6 +135,9 @@ func (Domain) TableName() string {
// CreateServerRequest 创建服务器请求
type CreateServerRequest struct {
Hostname string `json:"hostname" binding:"required"`
Alias string `json:"alias"`
CloudInstanceName string `json:"cloud_instance_name"`
CloudInstanceID string `json:"cloud_instance_id"`
IP string `json:"ip" binding:"required,ip"`
PublicIP string `json:"public_ip" binding:"omitempty,ip"`
Port int `json:"port" binding:"omitempty,min=1,max=65535"`
@@ -157,6 +163,9 @@ type CreateServerRequest struct {
// UpdateServerRequest 更新服务器请求
type UpdateServerRequest struct {
Hostname string `json:"hostname"`
Alias *string `json:"alias"`
CloudInstanceName string `json:"cloud_instance_name"`
CloudInstanceID string `json:"cloud_instance_id"`
IP string `json:"ip" binding:"omitempty,ip"`
PublicIP string `json:"public_ip" binding:"omitempty,ip"`
Port int `json:"port" binding:"omitempty,min=1,max=65535"`
+1 -1
View File
@@ -18,7 +18,7 @@ type SessionCommand struct {
}
func (SessionCommand) TableName() string {
return "sys_session_commands"
return "audit_session_commands"
}
// SessionCommandQuery 会话命令查询 // Logan.Li
+3 -3
View File
@@ -83,7 +83,7 @@ type SiteTask struct {
// TableName 指定表名
func (SiteTask) TableName() string {
return "ops_site_tasks"
return "monitor_site_tasks"
}
// ============================================
@@ -139,7 +139,7 @@ type SiteResult struct {
// TableName 指定表名
func (SiteResult) TableName() string {
return "ops_site_results"
return "monitor_site_results"
}
// ============================================
@@ -199,7 +199,7 @@ type SiteAlert struct {
// TableName 指定表名
func (SiteAlert) TableName() string {
return "ops_site_alerts"
return "monitor_site_alerts"
}
// SiteTaskCreateRequest 拨测任务创建请求
+5 -5
View File
@@ -92,7 +92,7 @@ type SNMPDevice struct {
// TableName 指定表名
func (SNMPDevice) TableName() string {
return "ops_snmp_devices"
return "monitor_snmp_devices"
}
// ============================================
@@ -120,7 +120,7 @@ type SNMPTemplate struct {
// TableName 指定表名
func (SNMPTemplate) TableName() string {
return "ops_snmp_templates"
return "monitor_snmp_templates"
}
// SNMPTemplateOID OID模板条目表
@@ -142,7 +142,7 @@ type SNMPTemplateOID struct {
// TableName 指定表名
func (SNMPTemplateOID) TableName() string {
return "ops_snmp_template_oids"
return "monitor_snmp_template_oids"
}
// ============================================
@@ -170,7 +170,7 @@ type SNMPMetric struct {
// TableName 指定表名
func (SNMPMetric) TableName() string {
return "ops_snmp_metrics"
return "monitor_snmp_samples"
}
// ============================================
@@ -199,7 +199,7 @@ type SNMPInterface struct {
// TableName 指定表名
func (SNMPInterface) TableName() string {
return "ops_snmp_interfaces"
return "monitor_snmp_interfaces"
}
// ============================================
+1 -1
View File
@@ -13,4 +13,4 @@ type SystemTaskStatus struct {
UpdatedAt time.Time `json:"updated_at"`
}
func (SystemTaskStatus) TableName() string { return "sys_system_task_status" }
func (SystemTaskStatus) TableName() string { return "sys_system_task_statuses" }
+1 -1
View File
@@ -23,7 +23,7 @@ type TerminalSession struct {
// TableName 表名
func (TerminalSession) TableName() string {
return "sys_terminal_sessions"
return "audit_terminal_sessions"
}
// TerminalSessionQuery 终端会话查询 // Logan.Li
+1 -2
View File
@@ -2,7 +2,6 @@ package model
import (
"time"
)
// User 用户模型
@@ -51,7 +50,7 @@ type Role struct {
UpdatedAt time.Time `json:"updated_at"`
// 关联
Permissions []Permission `gorm:"many2many:role_permissions;" json:"permissions,omitempty"`
Permissions []Permission `gorm:"many2many:iam_role_permissions;" json:"permissions,omitempty"`
}
// TableName 表名
+1
View File
@@ -96,6 +96,7 @@ func NewEngine(cfg EngineConfig) (*Engine, error) {
return nil, fmt.Errorf("Kubernetes 模式缺少集群客户端")
}
kt := cfg.Kubernetes
registry.Register(tools.NewKubernetesDiagnoseTool(kt.Client, kt.Namespace))
registry.Register(tools.NewKubernetesGetTool(kt.Client, kt.Namespace))
registry.Register(tools.NewPodLogsTool(kt.Client, kt.Namespace, kt.PodName, kt.Container))
registry.Register(tools.NewPodExecuteTool(kt.Client, kt.Namespace, kt.PodName, kt.Container, coreCfg.ToolTimeout))
+131 -23
View File
@@ -116,6 +116,7 @@ type Agent struct {
evolver *evolver.EvolverEngine
callback EventCallback
totalTokens int
cache map[string]string
mu sync.Mutex
}
@@ -130,7 +131,7 @@ func NewAgent(client llm.Client, registry *tools.Registry, cfg *Config, sc *safe
if cfg.MaxContextTokens <= 0 {
cfg.MaxContextTokens = 8000
}
return &Agent{llm: client, registry: registry, config: cfg, safetyCtl: sc, evolver: ev}
return &Agent{llm: client, registry: registry, config: cfg, safetyCtl: sc, evolver: ev, cache: make(map[string]string)}
}
// SetCallback 注入事件回调 // Logan.Li
@@ -293,6 +294,14 @@ func (a *Agent) processToolCalls(ctx context.Context, calls []llm.ToolCall, hist
continue
}
if msg := a.suppressBroadKubernetesGet(tc.Function.Name, args, history); msg != "" {
a.emit(Event{Type: EventToolCall, Phase: PhaseToolRunning, Round: round, ToolName: tc.Function.Name, ToolArgs: args, Risk: tools.RiskSafe.Display(), Summary: "工具执行中"})
a.emit(Event{Type: EventToolResult, Phase: PhaseObserving, Round: round, ToolName: tc.Function.Name,
Output: msg, Success: true, Duration: "0s", OutputSize: len(msg)})
obs = append(obs, llm.Message{Role: "tool", ToolCallID: tc.ID, Name: tc.Function.Name, Content: msg})
continue
}
risk := a.toolRisk(tool, args)
a.emit(Event{Type: EventStatus, Phase: PhaseSafety, Round: round, ToolName: tc.Function.Name, ToolArgs: args, Risk: risk.Display(), Summary: "安全检查"})
@@ -315,6 +324,16 @@ func (a *Agent) processToolCalls(ctx context.Context, calls []llm.ToolCall, hist
// 执行
a.emit(Event{Type: EventToolCall, Phase: PhaseToolRunning, Round: round, ToolName: tc.Function.Name, ToolArgs: args, Risk: risk.Display(), Summary: "工具执行中"})
if cached, ok := a.cachedToolResult(tc.Function.Name, args); ok {
a.emit(Event{Type: EventToolResult, Phase: PhaseObserving, Round: round, ToolName: tc.Function.Name,
Output: cached, Success: true, Duration: "0s", OutputSize: len(cached)})
records = append(records, evolver.ToolCallRecord{
ToolName: tc.Function.Name, Args: args, Output: cached,
Duration: 0, Success: true, RiskLevel: int(risk),
})
obs = append(obs, llm.Message{Role: "tool", ToolCallID: tc.ID, Name: tc.Function.Name, Content: cached})
continue
}
toolStart := time.Now()
toolCtx, cancel := context.WithTimeout(ctx, a.config.ToolTimeout)
result, execErr := tool.Execute(toolCtx, args)
@@ -346,6 +365,7 @@ func (a *Agent) processToolCalls(ctx context.Context, calls []llm.ToolCall, hist
a.emit(Event{Type: EventToolResult, Phase: PhaseObserving, Round: round, ToolName: tc.Function.Name,
Output: output, Success: success, Duration: dur.Truncate(time.Millisecond).String(), OutputSize: len(output)})
a.storeToolResult(tc.Function.Name, args, output, success)
records = append(records, evolver.ToolCallRecord{
ToolName: tc.Function.Name, Args: args, Output: output,
@@ -369,6 +389,82 @@ func (a *Agent) processToolCalls(ctx context.Context, calls []llm.ToolCall, hist
return obs, records, failInc
}
// cachedToolResult 返回同一任务内只读工具的缓存结果,避免 LLM 重复消耗 token。 // Logan.Li
func (a *Agent) cachedToolResult(toolName string, args map[string]interface{}) (string, bool) {
if !cacheableTool(toolName) {
return "", false
}
key := toolCacheKey(toolName, args)
if key == "" {
return "", false
}
if out, ok := a.cache[key]; ok {
return "已复用本轮任务缓存结果;不要重复查询同一对象,请基于首次结果总结,或改用更具体的 name/status_filter/pod 参数补查。\n\n" + out, true
}
return "", false
}
// storeToolResult 缓存成功的只读工具输出。 // Logan.Li
func (a *Agent) storeToolResult(toolName string, args map[string]interface{}, output string, success bool) {
if !success || !cacheableTool(toolName) {
return
}
key := toolCacheKey(toolName, args)
if key == "" {
return
}
a.cache[key] = output
}
func cacheableTool(toolName string) bool {
switch toolName {
case "kubernetes_diagnose", "kubernetes_get", "pod_logs", "aiops_asset_search", "aiops_alert_list", "aiops_metric_summary":
return true
default:
return false
}
}
func toolCacheKey(toolName string, args map[string]interface{}) string {
b, err := json.Marshal(args)
if err != nil {
return ""
}
return toolName + ":" + string(b)
}
// suppressBroadKubernetesGet 在聚合诊断后拦截全量 K8s 查询,迫使模型按异常对象下钻。 // Logan.Li
func (a *Agent) suppressBroadKubernetesGet(toolName string, args map[string]interface{}, history map[string]int) string {
if a.config.Mode != "kubernetes" && a.config.Mode != "k8s" {
return ""
}
if toolName != "kubernetes_get" || !hasDiagnoseHistory(history) {
return ""
}
resource := strings.ToLower(strings.TrimSpace(fmt.Sprint(args["resource"])))
name := strings.TrimSpace(fmt.Sprint(args["name"]))
labelSelector := strings.TrimSpace(fmt.Sprint(args["label_selector"]))
statusFilter := strings.TrimSpace(fmt.Sprint(args["status_filter"]))
if name != "" && name != "<nil>" || labelSelector != "" && labelSelector != "<nil>" || statusFilter != "" && statusFilter != "<nil>" {
return ""
}
switch resource {
case "pods", "pod", "deployments", "deployment", "deploy", "services", "service", "svc", "nodes", "node", "events", "event":
return "已拦截全量 Kubernetes 查询:本轮已经执行过 kubernetes_diagnose,里面包含异常 Workload、异常 Pod、Top Events 和节点摘要。请不要再次拉全量资源;如需补证据,请按诊断结果中的具体对象调用 kubernetes_get(name/status_filter) 或 pod_logs(pod)。"
default:
return ""
}
}
func hasDiagnoseHistory(history map[string]int) bool {
for key, count := range history {
if count > 0 && strings.HasPrefix(key, "kubernetes_diagnose:") {
return true
}
}
return false
}
// triggerEvolve 异步触发进化(带超时 + panic 恢复,绝不阻塞主循环) // Logan.Li
func (a *Agent) triggerEvolve(ctx context.Context, query, serverHost, answer string, records []evolver.ToolCallRecord, success bool, start time.Time) {
if a.evolver == nil {
@@ -522,34 +618,46 @@ func (a *Agent) trimMessages(msgs []llm.Message) []llm.Message {
return ensureToolMessageIntegrity(result)
}
// ensureToolMessageIntegrity 确保 tool 消息前面有对应 assistant(tool_calls) // Logan.Li
// ensureToolMessageIntegrity 确保 assistant(tool_calls) 后紧跟完整 tool 结果组 // Logan.Li
func ensureToolMessageIntegrity(msgs []llm.Message) []llm.Message {
validIDs := make(map[string]bool)
for _, m := range msgs {
if m.Role == "assistant" {
for _, tc := range m.ToolCalls {
validIDs[tc.ID] = true
}
}
}
out := make([]llm.Message, 0, len(msgs))
for i, m := range msgs {
for i := 0; i < len(msgs); i++ {
m := msgs[i]
if m.Role == "tool" {
if !validIDs[m.ToolCallID] || i == 0 || msgs[i-1].Role != "assistant" {
continue
}
matched := false
for _, tc := range msgs[i-1].ToolCalls {
if tc.ID == m.ToolCallID {
matched = true
break
}
}
if !matched {
continue
}
}
if m.Role != "assistant" || len(m.ToolCalls) == 0 {
out = append(out, m)
continue
}
required := make(map[string]bool, len(m.ToolCalls))
for _, tc := range m.ToolCalls {
if tc.ID != "" {
required[tc.ID] = true
}
}
if len(required) == 0 {
continue
}
seen := make(map[string]bool, len(required))
tools := make([]llm.Message, 0, len(required))
j := i + 1
for ; j < len(msgs) && msgs[j].Role == "tool"; j++ {
tm := msgs[j]
if !required[tm.ToolCallID] || seen[tm.ToolCallID] {
continue
}
seen[tm.ToolCallID] = true
tools = append(tools, tm)
}
if len(seen) == len(required) {
out = append(out, m)
out = append(out, tools...)
}
i = j - 1
}
return out
}
+98
View File
@@ -0,0 +1,98 @@
package core
import (
"testing"
"ezops/pkg/agentcli/llm"
)
func TestEnsureToolMessageIntegrityKeepsCompleteMultiToolGroup(t *testing.T) {
msgs := []llm.Message{
{Role: "system", Content: "sys"},
{Role: "user", Content: "查看异常副本"},
{Role: "assistant", Content: "并行检查", ToolCalls: []llm.ToolCall{
{ID: "call-1", Type: "function", Function: llm.FunctionCall{Name: "kubernetes_get", Arguments: "{}"}},
{ID: "call-2", Type: "function", Function: llm.FunctionCall{Name: "kubernetes_get", Arguments: "{}"}},
{ID: "call-3", Type: "function", Function: llm.FunctionCall{Name: "kubernetes_get", Arguments: "{}"}},
}},
{Role: "tool", ToolCallID: "call-1", Name: "kubernetes_get", Content: "deployments"},
{Role: "tool", ToolCallID: "call-2", Name: "kubernetes_get", Content: "statefulsets"},
{Role: "tool", ToolCallID: "call-3", Name: "kubernetes_get", Content: "pods"},
}
got := ensureToolMessageIntegrity(msgs)
if len(got) != len(msgs) {
t.Fatalf("完整多工具结果组应全部保留,got=%d want=%d", len(got), len(msgs))
}
for i := 3; i < len(got); i++ {
if got[i].Role != "tool" || got[i].ToolCallID == "" {
t.Fatalf("第 %d 条工具结果异常: %+v", i, got[i])
}
}
}
func TestEnsureToolMessageIntegrityDropsIncompleteToolGroup(t *testing.T) {
msgs := []llm.Message{
{Role: "system", Content: "sys"},
{Role: "assistant", Content: "并行检查", ToolCalls: []llm.ToolCall{
{ID: "call-1", Type: "function", Function: llm.FunctionCall{Name: "kubernetes_get", Arguments: "{}"}},
{ID: "call-2", Type: "function", Function: llm.FunctionCall{Name: "kubernetes_get", Arguments: "{}"}},
}},
{Role: "tool", ToolCallID: "call-1", Name: "kubernetes_get", Content: "deployments"},
{Role: "user", Content: "继续"},
}
got := ensureToolMessageIntegrity(msgs)
if len(got) != 2 {
t.Fatalf("不完整工具结果组应整体丢弃,got=%d want=2: %+v", len(got), got)
}
if got[0].Role != "system" || got[1].Role != "user" {
t.Fatalf("剩余消息异常: %+v", got)
}
}
func TestSuppressBroadKubernetesGetAfterDiagnose(t *testing.T) {
agent := NewAgent(nil, nil, &Config{Mode: "kubernetes"}, nil, nil)
history := map[string]int{`kubernetes_diagnose:{"namespace":"astralx"}`: 1}
msg := agent.suppressBroadKubernetesGet("kubernetes_get", map[string]interface{}{"resource": "pods"}, history)
if msg == "" {
t.Fatal("expected broad pods query to be suppressed after diagnose")
}
}
func TestSuppressBroadKubernetesGetBlocksAllPodAliasesAfterDiagnose(t *testing.T) {
agent := NewAgent(nil, nil, &Config{Mode: "kubernetes"}, nil, nil)
history := map[string]int{`kubernetes_diagnose:{"namespace":"astralx","focus":"all"}`: 1}
for _, resource := range []string{"pods", "pod"} {
msg := agent.suppressBroadKubernetesGet("kubernetes_get", map[string]interface{}{"resource": resource}, history)
if msg == "" {
t.Fatalf("expected broad %s query to be suppressed after diagnose", resource)
}
}
}
func TestSuppressBroadKubernetesGetAllowsFilteredQuery(t *testing.T) {
agent := NewAgent(nil, nil, &Config{Mode: "kubernetes"}, nil, nil)
history := map[string]int{`kubernetes_diagnose:{"namespace":"astralx"}`: 1}
msg := agent.suppressBroadKubernetesGet("kubernetes_get", map[string]interface{}{
"resource": "pods", "status_filter": "NotRunning",
}, history)
if msg != "" {
t.Fatalf("expected filtered pods query to pass, got %q", msg)
}
}
func TestSuppressBroadKubernetesGetAllowsStatusFilteredPodsAfterDiagnose(t *testing.T) {
agent := NewAgent(nil, nil, &Config{Mode: "kubernetes"}, nil, nil)
history := map[string]int{`kubernetes_diagnose:{"namespace":"astralx","focus":"pods"}`: 1}
cases := []map[string]interface{}{
{"resource": "pods", "status_filter": "NotRunning"},
{"resource": "pod", "status_filter": "CrashLoopBackOff"},
}
for _, args := range cases {
msg := agent.suppressBroadKubernetesGet("kubernetes_get", args, history)
if msg != "" {
t.Fatalf("expected status_filter query to pass for args=%v, got %q", args, msg)
}
}
}
+108
View File
@@ -0,0 +1,108 @@
// Package guardrail 提供 Agent CLI 输入输出治理能力。 // Logan.Li
package guardrail
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"regexp"
"strings"
)
var sensitivePatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)(bearer\s+)[a-z0-9._~+/=-]{16,}`),
regexp.MustCompile(`(?i)((?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|secret[_-]?key)\s*[:=]\s*)[^\s'"&]+`),
regexp.MustCompile(`(?i)((?:mysql|postgres|postgresql|redis|mongodb)://[^:\s/@]+:)[^@\s]+(@)`),
regexp.MustCompile(`(?is)-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----`),
regexp.MustCompile(`(?i)((?:Cookie|Set-Cookie):\s*)[^\r\n]+`),
regexp.MustCompile(`(?i)(AKIA[0-9A-Z]{16})`),
}
var sensitiveKeys = map[string]bool{
"password": true, "passwd": true, "pwd": true, "secret": true,
"token": true, "api_key": true, "apikey": true, "access_key": true,
"secret_key": true, "private_key": true, "cookie": true,
}
// SanitizeText 脱敏工具输出、LLM 文本和错误信息,避免敏感信息进入前端/DB/记忆。 // Logan.Li
func SanitizeText(input string) string {
if input == "" {
return ""
}
out := input
for _, re := range sensitivePatterns {
out = re.ReplaceAllStringFunc(out, func(match string) string {
if strings.Contains(match, "PRIVATE KEY") {
return "[REDACTED_PRIVATE_KEY]"
}
sub := re.FindStringSubmatch(match)
if len(sub) >= 3 {
return sub[1] + "[REDACTED]" + sub[2]
}
if len(sub) >= 2 {
return sub[1] + "[REDACTED]"
}
return "[REDACTED]"
})
}
return out
}
// SanitizeJSON 脱敏 JSON 文本;非法 JSON 时退回文本脱敏。 // Logan.Li
func SanitizeJSON(raw string) string {
if strings.TrimSpace(raw) == "" {
return raw
}
var v interface{}
if err := json.Unmarshal([]byte(raw), &v); err != nil {
return SanitizeText(raw)
}
v = sanitizeValue(v)
b, err := json.Marshal(v)
if err != nil {
return SanitizeText(raw)
}
return string(b)
}
func sanitizeValue(v interface{}) interface{} {
switch val := v.(type) {
case map[string]interface{}:
out := make(map[string]interface{}, len(val))
for k, item := range val {
if sensitiveKeys[strings.ToLower(strings.ReplaceAll(k, "-", "_"))] {
out[k] = "[REDACTED]"
continue
}
out[k] = sanitizeValue(item)
}
return out
case []interface{}:
for i := range val {
val[i] = sanitizeValue(val[i])
}
return val
case string:
return SanitizeText(val)
default:
return val
}
}
// NewTraceID 生成短 trace id,便于串联一次 Agent 任务。 // Logan.Li
func NewTraceID() string {
return "tr-" + randomHex(12)
}
// NewSpanID 生成短 span id,便于串联工具和消息事件。 // Logan.Li
func NewSpanID() string {
return "sp-" + randomHex(8)
}
func randomHex(n int) string {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "0000000000000000"
}
return hex.EncodeToString(buf)
}
@@ -0,0 +1,31 @@
package guardrail
import (
"strings"
"testing"
)
func TestSanitizeTextMasksCommonSecrets(t *testing.T) {
input := "Authorization: Bearer abcdefghijklmnopqrstuvwxyz password=secret123 mysql://root:pass@db/prod"
got := SanitizeText(input)
for _, leak := range []string{"abcdefghijklmnopqrstuvwxyz", "secret123", "root:pass@db"} {
if strings.Contains(got, leak) {
t.Fatalf("敏感信息未脱敏: %s in %s", leak, got)
}
}
if !strings.Contains(got, "[REDACTED]") {
t.Fatalf("脱敏标记缺失: %s", got)
}
}
func TestSanitizeJSONMasksSensitiveKeys(t *testing.T) {
got := SanitizeJSON(`{"command":"echo ok","password":"p1","nested":{"api_key":"k1"}}`)
for _, leak := range []string{"p1", "k1"} {
if strings.Contains(got, leak) {
t.Fatalf("JSON 敏感字段未脱敏: %s in %s", leak, got)
}
}
if !strings.Contains(got, `"command":"echo ok"`) {
t.Fatalf("非敏感字段不应被改写: %s", got)
}
}
+5 -4
View File
@@ -51,9 +51,10 @@ func BuildSystemPrompt(mode, targetInfo, toolDescs, evolveContext string) string
sb.WriteString("1. 破坏性命令(rm -rf /、mkfs、dd 覆写磁盘等)被严格禁止,绝不执行。\n")
sb.WriteString("2. 高危操作(删除 Pod、重启 Deployment、扩缩容、换镜像等)需要用户确认后才能执行。\n")
if mode == runModeKubernetes {
sb.WriteString("3. 排障时优先使用只读工具:kubernetes_get 查资源状态、pod_logs 看日志;确认根因后再 pod_execute 或 kubernetes_action。\n")
sb.WriteString("4. 容器内可能没有 systemctl、宿主机路径;不要假设节点级命令可用。\n")
sb.WriteString("5. 未选定 Pod 时,用 kubernetes_get 和 pod_logs(带 pod 参数)排障;选定 Pod 后可直接 pod_execute。\n")
sb.WriteString("3. 排障时优先使用只读工具;涉及异常副本、集群健康、Pod 异常、ImagePullBackOff、CrashLoopBackOff、探针失败时,第一步必须调用 kubernetes_diagnose。\n")
sb.WriteString("4. kubernetes_diagnose 已聚合 Workload/Pod/Event/Node,不要重复拉全量 pods/deployments/events;只针对异常对象补查 pod_logs 或 kubernetes_get name。\n")
sb.WriteString("5. 容器内可能没有 systemctl、宿主机路径;不要假设节点级命令可用。\n")
sb.WriteString("6. 未选定 Pod 时,用 kubernetes_diagnose 和必要的 pod_logs(带 pod 参数)排障;选定 Pod 后可直接 pod_execute。\n")
} else {
sb.WriteString("3. 排障时优先使用只读命令(df/ps/cat/grep/tail/systemctl status/journalctl)收集信息,确认根因后再动手修复。\n")
sb.WriteString("4. 不要执行任何你不确定后果的命令;宁可多问用户,也不盲目操作。\n")
@@ -70,7 +71,7 @@ func BuildSystemPrompt(mode, targetInfo, toolDescs, evolveContext string) string
sb.WriteString("你采用「思考-行动-观察」循环解决任务:\n")
sb.WriteString("1. 思考:分析当前状态,决定下一步。在回复中先用简短文字说明你的判断和计划。\n")
if mode == runModeKubernetes {
sb.WriteString("2. 行动:优先 kubernetes_get / pod_logs;需要容器内信息时用 pod_execute;变更用 kubernetes_action(一次一步)。\n")
sb.WriteString("2. 行动:健康巡检/异常副本先用 kubernetes_diagnose;需要细节时对异常对象调用 kubernetes_get / pod_logs;需要容器内信息时用 pod_execute;变更用 kubernetes_action(一次一步)。\n")
sb.WriteString("3. 观察:仔细阅读工具输出;pod_execute 输出含 [exit_code=0] 表示成功。\n")
} else {
sb.WriteString("2. 行动:调用 ssh_execute 执行命令(一次只做关键的、可验证的一步)。\n")
@@ -0,0 +1,601 @@
// kubernetes_diagnose.go - Kubernetes 异常副本与健康诊断聚合工具 // Logan.Li
package tools
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
k8sclient "ezops/pkg/kubernetesclient"
)
const defaultDiagnoseLimit = 8
// KubernetesDiagnoseTool 聚合查询异常 Workload、Pod、Event 和节点健康,减少 LLM 反复拉全量资源。 // Logan.Li
type KubernetesDiagnoseTool struct {
client *k8sclient.Client
namespace string
}
// NewKubernetesDiagnoseTool 创建 Kubernetes 聚合诊断工具。 // Logan.Li
func NewKubernetesDiagnoseTool(client *k8sclient.Client, namespace string) *KubernetesDiagnoseTool {
return &KubernetesDiagnoseTool{client: client, namespace: namespace}
}
func (t *KubernetesDiagnoseTool) Name() string { return "kubernetes_diagnose" }
func (t *KubernetesDiagnoseTool) Description() string {
return "一键诊断 Kubernetes 命名空间健康和异常副本(只读)。优先用于“异常副本、集群健康、Pod 异常、Deployment 不可用、ImagePullBackOff、CrashLoopBackOff”等任务。" +
"工具会聚合 Deployment/StatefulSet/DaemonSet/Pod/Event/Node,只返回异常摘要、可能根因和建议补充检查,避免反复查询全量资源。"
}
func (t *KubernetesDiagnoseTool) Parameters() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"namespace": map[string]interface{}{
"type": "string",
"description": "命名空间;空则使用会话绑定命名空间",
},
"focus": map[string]interface{}{
"type": "string",
"description": "关注点:replicas|pods|events|image|crash|probe|all,默认 all",
},
"limit": map[string]interface{}{
"type": "integer",
"description": "每类最多返回数量,默认 8,最大 20",
},
},
}
}
func (t *KubernetesDiagnoseTool) RiskLevel() RiskLevel { return RiskSafe }
func (t *KubernetesDiagnoseTool) Execute(ctx context.Context, args map[string]interface{}) (*Result, error) {
ns := strings.TrimSpace(StringParam(args, "namespace"))
if ns == "" {
ns = t.namespace
}
if ns == "" {
return &Result{Success: false, Error: "namespace 不能为空"}, nil
}
limit := IntParam(args, "limit")
if limit <= 0 {
limit = defaultDiagnoseLimit
}
if limit > 20 {
limit = 20
}
focus := strings.ToLower(strings.TrimSpace(StringParam(args, "focus")))
if focus == "" {
focus = "all"
}
report, err := t.buildReport(ns, focus, limit)
if err != nil {
return &Result{Success: false, Error: err.Error(), Output: err.Error()}, nil
}
b, err := json.MarshalIndent(report, "", " ")
if err != nil {
return nil, fmt.Errorf("诊断结果 JSON 序列化失败: %w", err)
}
return &Result{Success: true, Output: string(b)}, nil
}
type k8sDiagnoseReport struct {
Namespace string `json:"namespace"`
Focus string `json:"focus"`
Summary k8sDiagnoseSummary `json:"summary"`
AbnormalWorkloads []k8sWorkloadFinding `json:"abnormal_workloads"`
AbnormalPods []k8sPodFinding `json:"abnormal_pods"`
TopEvents []k8sEventFinding `json:"top_events"`
NodeHealth []k8sNodeFinding `json:"node_health,omitempty"`
LikelyRootCauses []string `json:"likely_root_causes"`
RecommendedActions []string `json:"recommended_actions"`
NextTools []map[string]interface{} `json:"next_tools"`
}
type k8sDiagnoseSummary struct {
Deployments int `json:"deployments"`
StatefulSets int `json:"statefulsets"`
DaemonSets int `json:"daemonsets"`
Pods int `json:"pods"`
AbnormalWorkloads int `json:"abnormal_workloads"`
AbnormalPods int `json:"abnormal_pods"`
WarningEvents int `json:"warning_events"`
NotReadyNodes int `json:"not_ready_nodes"`
}
type k8sWorkloadFinding struct {
Kind string `json:"kind"`
Namespace string `json:"namespace"`
Name string `json:"name"`
Desired int `json:"desired"`
Ready int `json:"ready"`
Available int `json:"available,omitempty"`
Reason string `json:"reason"`
Image string `json:"image,omitempty"`
}
type k8sPodFinding struct {
Namespace string `json:"namespace"`
Name string `json:"name"`
Status string `json:"status"`
Reason string `json:"reason"`
Restarts int `json:"restarts"`
Node string `json:"node"`
IP string `json:"ip,omitempty"`
Image string `json:"image,omitempty"`
Owner string `json:"owner,omitempty"`
Messages []string `json:"messages,omitempty"`
}
type k8sEventFinding struct {
Type string `json:"type"`
Reason string `json:"reason"`
Object string `json:"object"`
Count int32 `json:"count"`
LastSeen string `json:"last_seen,omitempty"`
Message string `json:"message"`
}
type k8sNodeFinding struct {
Name string `json:"name"`
Ready string `json:"ready"`
Reason string `json:"reason,omitempty"`
Message string `json:"message,omitempty"`
}
func (t *KubernetesDiagnoseTool) buildReport(ns, focus string, limit int) (*k8sDiagnoseReport, error) {
pods, err := t.client.ListPods()
if err != nil {
return nil, fmt.Errorf("获取 Pod 失败: %w", err)
}
deployments, err := t.client.ListDeployments()
if err != nil {
return nil, fmt.Errorf("获取 Deployment 失败: %w", err)
}
statefulsets, err := t.client.ListStatefulSets()
if err != nil {
return nil, fmt.Errorf("获取 StatefulSet 失败: %w", err)
}
daemonsets, err := t.client.ListDaemonSets()
if err != nil {
return nil, fmt.Errorf("获取 DaemonSet 失败: %w", err)
}
events, err := t.client.ListEvents(ns)
if err != nil {
return nil, fmt.Errorf("获取 Event 失败: %w", err)
}
nodes, _ := t.client.ListNodes()
report := &k8sDiagnoseReport{Namespace: ns, Focus: focus}
report.AbnormalWorkloads = diagnoseWorkloads(ns, deployments.Items, statefulsets.Items, daemonsets.Items)
report.AbnormalPods = diagnosePods(ns, pods.Items, deployments.Items)
report.TopEvents = diagnoseEvents(events.Items, limit)
report.NodeHealth = diagnoseNodes(nodes)
report.Summary = k8sDiagnoseSummary{
Deployments: countDeployments(ns, deployments.Items), StatefulSets: countStatefulSets(ns, statefulsets.Items),
DaemonSets: countDaemonSets(ns, daemonsets.Items), Pods: countPods(ns, pods.Items),
AbnormalWorkloads: len(report.AbnormalWorkloads), AbnormalPods: len(report.AbnormalPods),
WarningEvents: countWarningEvents(events.Items), NotReadyNodes: len(report.NodeHealth),
}
report.LikelyRootCauses = inferK8sRootCauses(report.AbnormalPods, report.TopEvents)
report.RecommendedActions = recommendK8sActions(report.AbnormalPods, report.AbnormalWorkloads)
report.NextTools = recommendK8sNextTools(report.AbnormalPods, report.TopEvents, limit)
report.AbnormalWorkloads = limitWorkloadFindings(report.AbnormalWorkloads, limit)
report.AbnormalPods = limitPodFindings(report.AbnormalPods, limit)
return report, nil
}
func diagnoseWorkloads(ns string, deployments []k8sclient.DeploymentItem, statefulsets []k8sclient.StatefulSetItem, daemonsets []k8sclient.DaemonSetItem) []k8sWorkloadFinding {
var out []k8sWorkloadFinding
for _, d := range deployments {
if d.Metadata.Namespace != ns || d.Spec.Replicas == 0 {
continue
}
if d.Status.ReadyReplicas < d.Spec.Replicas || d.Status.AvailableReplicas < d.Spec.Replicas {
out = append(out, k8sWorkloadFinding{
Kind: "Deployment", Namespace: d.Metadata.Namespace, Name: d.Metadata.Name,
Desired: d.Spec.Replicas, Ready: d.Status.ReadyReplicas, Available: d.Status.AvailableReplicas,
Reason: "ready/available replicas less than desired", Image: firstDeploymentImage(d),
})
}
}
for _, s := range statefulsets {
if s.Metadata.Namespace != ns || s.Spec.Replicas == 0 {
continue
}
if s.Status.ReadyReplicas < s.Spec.Replicas {
out = append(out, k8sWorkloadFinding{
Kind: "StatefulSet", Namespace: s.Metadata.Namespace, Name: s.Metadata.Name,
Desired: s.Spec.Replicas, Ready: s.Status.ReadyReplicas,
Reason: "ready replicas less than desired", Image: firstStatefulSetImage(s),
})
}
}
for _, d := range daemonsets {
if d.Metadata.Namespace != ns || d.Status.DesiredNumberScheduled == 0 {
continue
}
if d.Status.NumberReady < d.Status.DesiredNumberScheduled {
out = append(out, k8sWorkloadFinding{
Kind: "DaemonSet", Namespace: d.Metadata.Namespace, Name: d.Metadata.Name,
Desired: d.Status.DesiredNumberScheduled, Ready: d.Status.NumberReady,
Reason: "ready pods less than desired scheduled pods", Image: firstDaemonSetImage(d),
})
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].Kind == out[j].Kind {
return out[i].Name < out[j].Name
}
return out[i].Kind < out[j].Kind
})
return out
}
func diagnosePods(ns string, pods []k8sclient.PodItem, deployments []k8sclient.DeploymentItem) []k8sPodFinding {
var out []k8sPodFinding
for _, p := range pods {
if p.Metadata.Namespace != ns {
continue
}
status, reason, restarts, messages := podDiagnosisStatus(p)
if !isAbnormalPod(status, reason, restarts) {
continue
}
out = append(out, k8sPodFinding{
Namespace: p.Metadata.Namespace, Name: p.Metadata.Name, Status: status, Reason: reason,
Restarts: restarts, Node: p.Spec.NodeName, IP: p.Status.PodIP, Image: firstPodImage(p),
Owner: inferDeploymentOwner(p, deployments), Messages: messages,
})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Reason == out[j].Reason {
return out[i].Name < out[j].Name
}
return podReasonRank(out[i].Reason) < podReasonRank(out[j].Reason)
})
return out
}
func podDiagnosisStatus(p k8sclient.PodItem) (string, string, int, []string) {
status := p.Status.Phase
reason := ""
restarts := 0
var messages []string
for _, cs := range p.Status.ContainerStatuses {
if cs.RestartCount > restarts {
restarts = cs.RestartCount
}
if cs.State.Waiting != nil && cs.State.Waiting.Reason != "" {
status = cs.State.Waiting.Reason
reason = cs.State.Waiting.Reason
if cs.State.Waiting.Message != "" {
messages = append(messages, truncateK8sText(cs.State.Waiting.Message, 220))
}
}
if cs.LastState.Terminated != nil && cs.LastState.Terminated.Reason != "" {
if reason == "" {
reason = cs.LastState.Terminated.Reason
}
msg := fmt.Sprintf("last terminated: %s exit=%d", cs.LastState.Terminated.Reason, cs.LastState.Terminated.ExitCode)
messages = append(messages, msg)
}
}
for _, c := range p.Status.Conditions {
if c.Type == "Ready" && c.Status != "True" {
if reason == "" && c.Reason != "" {
reason = c.Reason
}
if c.Message != "" {
messages = append(messages, truncateK8sText(c.Message, 220))
}
}
}
if reason == "" {
reason = status
}
return status, reason, restarts, dedupeStrings(messages)
}
func diagnoseEvents(events []k8sclient.EventItem, limit int) []k8sEventFinding {
sort.Slice(events, func(i, j int) bool {
return eventTime(events[i]).After(eventTime(events[j]))
})
var out []k8sEventFinding
seen := make(map[string]bool)
for _, e := range events {
if e.Type != "Warning" && !isImportantEventReason(e.Reason) {
continue
}
key := fmt.Sprintf("%s/%s/%s/%s", e.InvolvedObject.Kind, e.InvolvedObject.Name, e.Reason, e.Message)
if seen[key] {
continue
}
seen[key] = true
out = append(out, k8sEventFinding{
Type: e.Type, Reason: e.Reason,
Object: fmt.Sprintf("%s/%s", e.InvolvedObject.Kind, e.InvolvedObject.Name),
Count: e.Count, LastSeen: eventTime(e).Format(time.RFC3339),
Message: truncateK8sText(e.Message, 260),
})
if len(out) >= limit {
break
}
}
return out
}
func diagnoseNodes(nodes *k8sclient.NodeList) []k8sNodeFinding {
if nodes == nil {
return nil
}
var out []k8sNodeFinding
for _, n := range nodes.Items {
for _, c := range n.Status.Conditions {
if c.Type == "Ready" && c.Status != "True" {
out = append(out, k8sNodeFinding{Name: n.Metadata.Name, Ready: c.Status, Reason: c.Reason, Message: truncateK8sText(c.Message, 220)})
break
}
}
}
return out
}
func inferK8sRootCauses(pods []k8sPodFinding, events []k8sEventFinding) []string {
flags := make(map[string]bool)
for _, p := range pods {
r := strings.ToLower(p.Reason + " " + p.Status)
switch {
case strings.Contains(r, "imagepull") || strings.Contains(r, "errimagepull"):
flags["存在镜像拉取失败,优先检查镜像 tag、Harbor 仓库、imagePullSecrets 和节点到仓库网络。"] = true
case strings.Contains(r, "crashloop"):
flags["存在容器反复崩溃,优先查看 previous logs、启动参数、依赖连接和最近变更。"] = true
case strings.Contains(r, "oom"):
flags["存在 OOMKilled 风险,优先检查内存 limit、最近内存趋势和应用内存泄漏。"] = true
case strings.Contains(r, "containersnotready") || strings.Contains(r, "unready"):
flags["存在容器未就绪,需结合 readiness/liveness probe、事件和应用日志确认。"] = true
case strings.Contains(r, "pending") || strings.Contains(r, "failedscheduling"):
flags["存在调度失败或 Pending,优先检查节点资源、污点、亲和性和 PVC。"] = true
}
}
for _, e := range events {
msg := strings.ToLower(e.Reason + " " + e.Message)
if strings.Contains(msg, "liveness probe") || strings.Contains(msg, "readiness probe") {
flags["事件显示健康探针失败,需检查探针路径、超时时间、应用响应耗时和依赖服务。"] = true
}
}
return sortedMapKeys(flags)
}
func recommendK8sActions(pods []k8sPodFinding, workloads []k8sWorkloadFinding) []string {
var out []string
if len(workloads) > 0 {
out = append(out, "先处理 ready/available 副本不足的 Workload,再回查副本是否恢复。")
}
if hasPodReason(pods, "ImagePullBackOff") || hasPodReason(pods, "ErrImagePull") {
out = append(out, "对镜像拉取失败的 Pod,确认镜像 tag 是否存在、仓库权限是否有效、节点能否访问镜像仓库。")
}
if hasPodReason(pods, "CrashLoopBackOff") {
out = append(out, "对 CrashLoopBackOff 的 Pod,补查 previous logs 和启动命令,避免只看当前空日志。")
}
if hasPodReason(pods, "ContainersNotReady") {
out = append(out, "对未就绪 Pod,查看事件中的 readiness/liveness probe 失败原因和应用健康端点耗时。")
}
if len(out) == 0 {
out = append(out, "当前聚合诊断未发现明显异常;如仍有业务问题,继续按服务名查看日志、Ingress、Service selector 和指标。")
}
return out
}
func recommendK8sNextTools(pods []k8sPodFinding, events []k8sEventFinding, limit int) []map[string]interface{} {
var out []map[string]interface{}
for _, p := range pods {
if len(out) >= limit {
break
}
if strings.Contains(strings.ToLower(p.Reason), "imagepull") {
out = append(out, map[string]interface{}{"tool": "kubernetes_get", "args": map[string]interface{}{"resource": "events", "name": p.Name}})
continue
}
if p.Status == "CrashLoopBackOff" || p.Restarts >= 5 || strings.Contains(strings.ToLower(p.Reason), "notready") {
out = append(out, map[string]interface{}{"tool": "pod_logs", "args": map[string]interface{}{"namespace": p.Namespace, "pod": p.Name, "tail": 120}})
}
}
if len(out) == 0 && len(events) > 0 {
out = append(out, map[string]interface{}{"tool": "kubernetes_get", "args": map[string]interface{}{"resource": "events"}})
}
return out
}
func isAbnormalPod(status, reason string, restarts int) bool {
s := strings.ToLower(status + " " + reason)
if status != "Running" && status != "Succeeded" && status != "PodCompleted" && status != "Completed" {
return true
}
if strings.Contains(s, "notready") || strings.Contains(s, "crashloop") || strings.Contains(s, "imagepull") || strings.Contains(s, "oom") {
return true
}
return restarts >= 10
}
func podReasonRank(reason string) int {
r := strings.ToLower(reason)
switch {
case strings.Contains(r, "imagepull"):
return 1
case strings.Contains(r, "crashloop"):
return 2
case strings.Contains(r, "oom"):
return 3
case strings.Contains(r, "notready"):
return 4
default:
return 9
}
}
func inferDeploymentOwner(p k8sclient.PodItem, deployments []k8sclient.DeploymentItem) string {
for _, d := range deployments {
if d.Metadata.Namespace != p.Metadata.Namespace || d.Spec.Selector == nil {
continue
}
if labelsMatch(p.Metadata.Labels, d.Spec.Selector.MatchLabels) {
return "Deployment/" + d.Metadata.Name
}
}
return ""
}
func firstDeploymentImage(d k8sclient.DeploymentItem) string {
if len(d.Spec.Template.Spec.Containers) == 0 {
return ""
}
return d.Spec.Template.Spec.Containers[0].Image
}
func firstStatefulSetImage(s k8sclient.StatefulSetItem) string {
if len(s.Spec.Template.Spec.Containers) == 0 {
return ""
}
return s.Spec.Template.Spec.Containers[0].Image
}
func firstDaemonSetImage(d k8sclient.DaemonSetItem) string {
if len(d.Spec.Template.Spec.Containers) == 0 {
return ""
}
return d.Spec.Template.Spec.Containers[0].Image
}
func firstPodImage(p k8sclient.PodItem) string {
if len(p.Spec.Containers) == 0 {
return ""
}
return p.Spec.Containers[0].Image
}
func eventTime(e k8sclient.EventItem) time.Time {
if !e.LastTimestamp.IsZero() {
return e.LastTimestamp
}
if !e.EventTime.IsZero() {
return e.EventTime
}
return e.FirstTimestamp
}
func isImportantEventReason(reason string) bool {
r := strings.ToLower(reason)
return strings.Contains(r, "failed") || strings.Contains(r, "backoff") || strings.Contains(r, "unhealthy")
}
func countDeployments(ns string, items []k8sclient.DeploymentItem) int {
n := 0
for _, it := range items {
if it.Metadata.Namespace == ns {
n++
}
}
return n
}
func countStatefulSets(ns string, items []k8sclient.StatefulSetItem) int {
n := 0
for _, it := range items {
if it.Metadata.Namespace == ns {
n++
}
}
return n
}
func countDaemonSets(ns string, items []k8sclient.DaemonSetItem) int {
n := 0
for _, it := range items {
if it.Metadata.Namespace == ns {
n++
}
}
return n
}
func countPods(ns string, items []k8sclient.PodItem) int {
n := 0
for _, it := range items {
if it.Metadata.Namespace == ns {
n++
}
}
return n
}
func countWarningEvents(items []k8sclient.EventItem) int {
n := 0
for _, it := range items {
if it.Type == "Warning" {
n++
}
}
return n
}
func hasPodReason(pods []k8sPodFinding, reason string) bool {
needle := strings.ToLower(reason)
for _, p := range pods {
if strings.Contains(strings.ToLower(p.Reason+" "+p.Status), needle) {
return true
}
}
return false
}
func limitWorkloadFindings(items []k8sWorkloadFinding, limit int) []k8sWorkloadFinding {
if len(items) <= limit {
return items
}
return items[:limit]
}
func limitPodFindings(items []k8sPodFinding, limit int) []k8sPodFinding {
if len(items) <= limit {
return items
}
return items[:limit]
}
func truncateK8sText(s string, max int) string {
s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " "))
if len([]rune(s)) <= max {
return s
}
r := []rune(s)
return string(r[:max]) + "..."
}
func dedupeStrings(items []string) []string {
seen := make(map[string]bool)
var out []string
for _, item := range items {
if item == "" || seen[item] {
continue
}
seen[item] = true
out = append(out, item)
}
return out
}
func sortedMapKeys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
@@ -0,0 +1,64 @@
package tools
import (
"testing"
k8sclient "ezops/pkg/kubernetesclient"
)
func TestPodDiagnosisStatusDetectsWaitingReason(t *testing.T) {
pod := k8sclient.PodItem{
Status: k8sclient.PodStatus{
Phase: "Pending",
ContainerStatuses: []k8sclient.ContainerStatus{{
Name: "app",
State: k8sclient.ContainerStatusState{Waiting: &k8sclient.ContainerStateWaiting{
Reason: "ImagePullBackOff", Message: "Back-off pulling image",
}},
}},
},
}
status, reason, restarts, messages := podDiagnosisStatus(pod)
if status != "ImagePullBackOff" || reason != "ImagePullBackOff" {
t.Fatalf("unexpected status=%s reason=%s", status, reason)
}
if restarts != 0 {
t.Fatalf("unexpected restarts=%d", restarts)
}
if len(messages) != 1 {
t.Fatalf("expected waiting message, got %v", messages)
}
if !isAbnormalPod(status, reason, restarts) {
t.Fatal("ImagePullBackOff should be abnormal")
}
}
func TestPodDiagnosisStatusDetectsOOMKilled(t *testing.T) {
pod := k8sclient.PodItem{
Status: k8sclient.PodStatus{
Phase: "Running",
ContainerStatuses: []k8sclient.ContainerStatus{{
Name: "app", Ready: true, RestartCount: 3,
LastState: k8sclient.ContainerStatusState{Terminated: &k8sclient.ContainerStateTerminated{
Reason: "OOMKilled", ExitCode: 137,
}},
}},
},
}
status, reason, restarts, _ := podDiagnosisStatus(pod)
if status != "Running" || reason != "OOMKilled" || restarts != 3 {
t.Fatalf("unexpected status=%s reason=%s restarts=%d", status, reason, restarts)
}
if !isAbnormalPod(status, reason, restarts) {
t.Fatal("OOMKilled should be abnormal")
}
}
func TestIsAbnormalPodByRestartThreshold(t *testing.T) {
if !isAbnormalPod("Running", "Running", 10) {
t.Fatal("high restart pod should be abnormal")
}
if isAbnormalPod("Running", "Running", 2) {
t.Fatal("healthy running pod should not be abnormal")
}
}
+38 -11
View File
@@ -96,7 +96,6 @@ func AutoMigrate(db *gorm.DB) error {
&model.AlertNotifyPolicy{}, // 告警集合通知策略 // Logan.Li
&model.AlertNotifySettings{}, // 告警消息模版 // Logan.Li
&model.NotifyChannel{},
&model.MetricData{},
&model.Pipeline{},
&model.PipelineRun{},
&model.BuildNode{},
@@ -121,11 +120,10 @@ func AutoMigrate(db *gorm.DB) error {
&model.AgentTask{},
&model.AgentRule{},
&model.AgentAlert{},
&model.AgentPortPolicy{}, // Agent 端口策略 // Logan.Li
&model.AnsiblePlaybook{},
&model.AnsibleJob{},
&model.AnsibleInventory{},
&model.LogSource{},
&model.LogEntry{},
&model.TopologyLayout{}, // 拓扑布局保存 // Logan.Li
&model.TerminalSession{}, // 终端会话录制(终端审计) // Logan.Li
&model.RDPSession{}, // RDP 图形会话审计 // Logan.Li
@@ -136,7 +134,6 @@ func AutoMigrate(db *gorm.DB) error {
&model.MetricsAggregate{}, // 指标时序聚合(Redis → MySQL)// Logan.Li
&model.MonitorTarget{}, // 监控目标(主机/中间件/设备/站点) // Logan.Li
&model.MonitorMetric{}, // 监控指标定义 // Logan.Li
&model.MetricValue{}, // 指标当前值 // Logan.Li
&model.MetricHistory{}, // 指标历史时序 // Logan.Li
&model.HostGroup{}, // 主机组 // Logan.Li
&model.SNMPDevice{}, // SNMP设备 // Logan.Li
@@ -152,8 +149,6 @@ func AutoMigrate(db *gorm.DB) error {
&model.ZabbixDataSource{}, // Zabbix 数据源配置 // Logan.Li
&model.LogDataSource{}, // Loki / ES 日志数据源 // Logan.Li
&model.Credential{}, // 凭据管理(SSH密钥/API Token) // Logan.Li
&model.PipelineRepo{}, // Git 仓库认证 // Logan.Li
&model.PipelineRegistry{}, // 镜像仓库认证 // Logan.Li
&model.DeployEnvironment{}, // 发布环境分组 // Logan.Li
&model.DeployPipelineMember{}, // 流水线成员 // Logan.Li
&model.DeployUserGroup{}, // 用户组 // Logan.Li
@@ -172,9 +167,13 @@ func AutoMigrate(db *gorm.DB) error {
&model.HealthProbe{}, // 健康探针配置 // Logan.Li
&model.ApprovalWorkflow{}, // 审批流程定义 // Logan.Li
&model.ApprovalWorkflowStep{}, // 审批流程步骤 // Logan.Li
&model.AgentCLISession{}, // Agent CLI 任务会话 // Logan.Li
&model.AgentCLIMessage{}, // Agent CLI 推理消息 // Logan.Li
&model.CustomDashboard{}, // 自定义监控仪表盘 // Logan.Li
&model.AIRun{}, // AI 任务统一运行记录 // Logan.Li
&model.AIRunMessage{}, // AI 任务回放消息 // Logan.Li
&model.AIRunSpan{}, // AI 工具/LLM/Guardrail Trace Span // Logan.Li
&model.AICapability{}, // AI 内置/MCP/技能能力注册 // Logan.Li
&model.AICapabilityPolicy{}, // AI 工具能力策略 // Logan.Li
&model.OpsSession{}, // 人机统一执行会话 // Logan.Li
&model.OpsCommand{}, // 人机统一命令审计 // Logan.Li
&model.TraceDataSource{}, // 链路追踪数据源 Jaeger/Tempo // Logan.Li
}
@@ -231,8 +230,10 @@ func applySchemaPatches(db *gorm.DB) {
}
}
}
ensureTable(&model.LogScopeRole{}, "monitor_log_scope_roles")
ensureTable(&model.UserLogScopeRole{}, "monitor_user_log_scope_roles")
ensureTable(&model.LogScopeRole{}, "iam_log_scope_roles")
if !m.HasTable("iam_user_log_scope_roles") {
createUserLogScopeRolesTable(db)
}
// LDAP 目录类型列(openldap | ad) // Logan.Li
if m.HasTable(&model.LDAPConfig{}) && !m.HasColumn(&model.LDAPConfig{}, "server_type") {
@@ -252,6 +253,9 @@ func applySchemaPatches(db *gorm.DB) {
patchServerColumn("OSType", "os_type")
patchServerColumn("Protocol", "protocol")
patchServerColumn("RDPPort", "rdp_port")
patchServerColumn("Alias", "alias")
patchServerColumn("CloudInstanceName", "cloud_instance_name")
patchServerColumn("CloudInstanceID", "cloud_instance_id")
// 将历史仅填 os=windows 的记录补齐协议字段 // Logan.Li
if m.HasColumn(&model.Server{}, "os_type") && m.HasColumn(&model.Server{}, "protocol") {
@@ -263,3 +267,26 @@ func applySchemaPatches(db *gorm.DB) {
`).Error
}
}
func createUserLogScopeRolesTable(db *gorm.DB) {
sql := `CREATE TABLE IF NOT EXISTS iam_user_log_scope_roles (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
log_scope_role_id BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NULL,
UNIQUE KEY idx_iam_user_log_scope_role (user_id, log_scope_role_id),
KEY idx_iam_user_log_scope_role_role (log_scope_role_id)
)`
if db.Dialector.Name() == "sqlite" {
sql = `CREATE TABLE IF NOT EXISTS iam_user_log_scope_roles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
log_scope_role_id INTEGER NOT NULL,
created_at DATETIME,
UNIQUE(user_id, log_scope_role_id)
)`
}
if err := db.Exec(sql).Error; err != nil {
log.Printf("[DB] 创建 iam_user_log_scope_roles 失败: %v", err)
}
}
+2 -2
View File
@@ -51,12 +51,12 @@ func CleanupSNMPMetricsSQL(driver string, retentionDays int) string {
switch driver {
case "sqlite":
return fmt.Sprintf(
"DELETE FROM ops_snmp_metrics WHERE timestamp < CAST(strftime('%%s', 'now', '-%d days') AS INTEGER)",
"DELETE FROM monitor_snmp_samples WHERE timestamp < CAST(strftime('%%s', 'now', '-%d days') AS INTEGER)",
retentionDays,
)
default:
return fmt.Sprintf(
"DELETE FROM ops_snmp_metrics WHERE timestamp < UNIX_TIMESTAMP(DATE_SUB(NOW(), INTERVAL %d DAY))",
"DELETE FROM monitor_snmp_samples WHERE timestamp < UNIX_TIMESTAMP(DATE_SUB(NOW(), INTERVAL %d DAY))",
retentionDays,
)
}
+44 -8
View File
@@ -203,16 +203,22 @@ func (c *Client) get(path string, result interface{}) error {
// 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 }
if err != nil {
return nil, err
}
req.Header.Set("Accept", accept)
if c.BearerToken != "" {
req.Header.Set("Authorization", "Bearer "+c.BearerToken)
}
resp, err := c.HTTPClient.Do(req)
if err != nil { return nil, fmt.Errorf("GET %s: %w", path, err) }
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 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)]))
}
@@ -222,13 +228,17 @@ func (c *Client) getRaw(path string, accept string) ([]byte, error) {
// 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 }
if err != nil {
return err
}
req.Header.Set("Content-Type", contentType)
if c.BearerToken != "" {
req.Header.Set("Authorization", "Bearer "+c.BearerToken)
}
resp, err := c.HTTPClient.Do(req)
if err != nil { return fmt.Errorf("PUT %s: %w", path, err) }
if err != nil {
return fmt.Errorf("PUT %s: %w", path, err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
@@ -337,8 +347,30 @@ type PodCondition struct {
}
type ContainerStatus struct {
Name string `json:"name"`
RestartCount int `json:"restartCount"`
Ready bool `json:"ready"`
State ContainerStatusState `json:"state,omitempty"`
LastState ContainerStatusState `json:"lastState,omitempty"`
}
// ContainerStatusState 解析容器等待/终止状态,用于 AI 诊断 ImagePullBackOff、OOMKilled 等原因。 // Logan.Li
type ContainerStatusState struct {
Waiting *ContainerStateWaiting `json:"waiting,omitempty"`
Terminated *ContainerStateTerminated `json:"terminated,omitempty"`
}
// ContainerStateWaiting 容器等待状态。 // Logan.Li
type ContainerStateWaiting struct {
Reason string `json:"reason,omitempty"`
Message string `json:"message,omitempty"`
}
// ContainerStateTerminated 容器终止状态。 // Logan.Li
type ContainerStateTerminated struct {
Reason string `json:"reason,omitempty"`
Message string `json:"message,omitempty"`
ExitCode int `json:"exitCode,omitempty"`
}
// DeploymentList response
@@ -1801,8 +1833,10 @@ func (c *Client) applyRaw(yamlContent string) (map[string]interface{}, error) {
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"
case "Endpoints":
resource = "endpoints"
case "PodMetrics":
resource = "pods"
}
var groupPrefix string
@@ -1829,7 +1863,9 @@ func (c *Client) resourceAPIPath(apiVersion, kind, namespace, name string) (stri
}
ns := namespace
if ns == "" { ns = "default" }
if ns == "" {
ns = "default"
}
if groupPrefix != "" {
switch kind {
+3 -3
View File
@@ -27,9 +27,9 @@ var (
// GetUserLogScopeRoles 获取用户已启用的日志权限策略 // Logan.Li
func GetUserLogScopeRoles(db *gorm.DB, userID uint) ([]model.LogScopeRole, error) {
var roles []model.LogScopeRole
err := db.Table("monitor_log_scope_roles").
Joins("JOIN monitor_user_log_scope_roles ON monitor_user_log_scope_roles.log_scope_role_id = monitor_log_scope_roles.id").
Where("monitor_user_log_scope_roles.user_id = ? AND monitor_log_scope_roles.status = 1", userID).
err := db.Table("iam_log_scope_roles").
Joins("JOIN iam_user_log_scope_roles ON iam_user_log_scope_roles.log_scope_role_id = iam_log_scope_roles.id").
Where("iam_user_log_scope_roles.user_id = ? AND iam_log_scope_roles.status = 1", userID).
Find(&roles).Error
return roles, err
}
-30
View File
@@ -668,24 +668,6 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
releases.POST("/:id/rollback", pipelineHandler.RollbackRelease)
}
// 仓库认证 & 镜像仓库认证路由
deployHandler := controller.NewDeployHandler(db)
{
repos := authorized.Group("/deploy/repos")
repos.GET("", deployHandler.ListRepos)
repos.POST("", deployHandler.CreateRepo)
repos.GET("/:id", deployHandler.GetRepo)
repos.PUT("/:id", deployHandler.UpdateRepo)
repos.DELETE("/:id", deployHandler.DeleteRepo)
registries := authorized.Group("/deploy/registries")
registries.GET("", deployHandler.ListRegistries)
registries.POST("", deployHandler.CreateRegistry)
registries.GET("/:id", deployHandler.GetRegistry)
registries.PUT("/:id", deployHandler.UpdateRegistry)
registries.DELETE("/:id", deployHandler.DeleteRegistry)
}
// 工具配置路由(发布配置 Tab ④)// Logan.Li
toolConfigHandler := controller.NewToolConfigHandler(db, ti)
tools := authorized.Group("/deploy/tools")
@@ -1053,18 +1035,6 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
zabbixDSGroup.POST("/test", zabbixDSHandler.TestConnection)
}
// 自定义监控仪表盘 — CRUD + 设默认 // Logan.Li
customDashHandler := controller.NewCustomDashboardHandler(db)
customDashGroup := authorized.Group("/custom-dashboards")
{
customDashGroup.GET("", customDashHandler.List)
customDashGroup.GET("/:id", customDashHandler.Get)
customDashGroup.POST("", customDashHandler.Create)
customDashGroup.PUT("/:id", customDashHandler.Update)
customDashGroup.DELETE("/:id", customDashHandler.Delete)
customDashGroup.POST("/:id/default", customDashHandler.SetDefault)
}
// 链路追踪数据源管理 — CRUD + 测试连接 + 设默认 // Logan.Li
traceDSHandler := controller.NewTraceDSHandler(db)
traceProxyHandler := controller.NewTraceProxyHandler(traceDSHandler)
+1 -1
View File
@@ -1,8 +1,8 @@
package seed
import (
snmppkg "ezops/pkg/snmp"
"ezops/model"
snmppkg "ezops/pkg/snmp"
"go.uber.org/zap"
"gorm.io/gorm"
+1
View File
@@ -0,0 +1 @@
+395
View File
@@ -0,0 +1,395 @@
# 数据表与菜单域审计记录
> 审计范围:`backend/model`、`backend/pkg/database/database.go`、主要 controller/service/repository 引用、`backend/seed/seed.go` 默认菜单权限。
> 目标:按菜单域整理表清单,记录表名前缀、设计合理性、疑似旧表/无用表和后续整改建议。
## 重建版目标设计
测试环境允许直接重建时,建议采用 **Schema v2 全局统一方案**:按菜单域和平台基础域重新归类,旧表不做兼容 rename,直接调整 `TableName()`、AutoMigrate 清单、种子数据和相关查询引用,然后清空数据库重建。
### 全局命名原则
1. 表名统一使用:`<domain>_<resource_plural>`。
2. 领域前缀与菜单域保持一致,避免同一功能散落在 `sys_`、`ops_`、`monitor_`。
3. 关联表统一使用:`<left_domain>_<left>_<right>` 或同域 `<domain>_<left>_<right>`。
4. 配置类表可用单数语义但表名仍用复数,例如 `deploy_build_master_configs`。
5. 时序/日志大表必须明确后缀:`*_samples`、`*_histories`、`*_events`、`*_logs`。
6. 不再新增无前缀表。
### 目标前缀
| 前缀 | 菜单/领域 | 说明 |
| --- | --- | --- |
| `iam_` | 身份与权限 | 用户、角色、权限、凭据、LDAP、资源级授权 |
| `sys_` | 系统基础配置 | 系统任务、站内导航、全局配置 |
| `audit_` | 安全审计 | 操作审计、登录日志、终端/RDP 会话、命令审计 |
| `asset_` | 资产/云资源 | 服务器、云账号、云区域、域名、账单、资源型号 |
| `k8s_` | Kubernetes | 集群与同步缓存资源 |
| `deploy_` | 应用发布 | 流水线、运行记录、发布、环境、构建节点、工具 |
| `monitor_` | 监控告警 | 告警、指标、Agent、SNMP、站点拨测、中间件、数据源 |
| `ops_` | 自动化运维 | 定时任务、脚本、远程执行、Ansible 如保留 |
| `approval_` | 资源申请/审批 | 模板、流程、实例、步骤 |
| `ai_` | AIOps/AI | LLM、会话、分析轨迹、Agent CLI 运行记录 |
### 目标表清单
#### IAM / 系统基础
| 目标表 | 来源/处理 |
| --- | --- |
| `iam_users` | 保留 |
| `iam_roles` | 保留 |
| `iam_permissions` | 保留 |
| `iam_user_roles` | 保留 |
| `iam_role_permissions` | 替换 `role_permissions` |
| `iam_credentials` | 保留,作为全局凭据唯一主表 |
| `iam_ldap_configs` | 保留 |
| `iam_item_roles` | 保留 |
| `iam_user_item_roles` | 保留 |
| `iam_log_scope_roles` | 从 `monitor_log_scope_roles` 调整,日志查询权限本质是权限控制 |
| `iam_user_log_scope_roles` | 从 `monitor_user_log_scope_roles` 调整 |
| `sys_system_task_statuses` | 从 `sys_system_task_status` 调整为复数 |
| `sys_navigation_groups` | 保留 |
| `sys_navigation_links` | 替换 `navigation_links` |
#### 安全审计
| 目标表 | 来源/处理 |
| --- | --- |
| `audit_operation_logs` | 替换 `sys_audit_logs` |
| `audit_settings` | 替换 `sys_audit_settings` |
| `audit_login_logs` | 替换 `sys_login_logs` |
| `audit_terminal_sessions` | 替换 `sys_terminal_sessions` |
| `audit_rdp_sessions` | 替换 `sys_rdp_sessions` |
| `audit_session_commands` | 替换 `sys_session_commands` |
#### 资产 / 云资源
| 目标表 | 来源/处理 |
| --- | --- |
| `asset_servers` | 保留 |
| `asset_host_groups` | 保留 |
| `asset_cloud_platforms` | 替换 `asset_clouds` |
| `asset_cloud_accounts` | 保留,敏感字段统一转凭据 ID |
| `asset_cloud_regions` | 替换 `asset_regions` |
| `asset_domains` | 保留 |
| `asset_resource_models` | 保留 |
| `asset_cloud_billing_records` | 替换 `asset_cloud_billings`,语义更明确 |
| `asset_cloud_billing_alert_rules` | 替换 `monitor_billing_alert_rules`,账单告警属于资产账单域配置,触发事件再进监控事件 |
#### Kubernetes
| 目标表 | 来源/处理 |
| --- | --- |
| `k8s_clusters` | 替换 `kubernetes_clusters` |
| `k8s_nodes` | 替换 `kubernetes_nodes` |
| `k8s_pods` | 替换 `kubernetes_pods` |
| `k8s_deployments` | 替换 `kubernetes_deployments` |
| `k8s_services` | 替换 `kubernetes_services` |
| `k8s_ingresses` | 替换 `kubernetes_ingresses` |
| `k8s_topology_layouts` | 替换 `kubernetes_topology_layouts` |
#### 应用发布
| 目标表 | 来源/处理 |
| --- | --- |
| `deploy_pipelines` | 替换 `ops_pipelines` |
| `deploy_pipeline_runs` | 替换 `ops_pipeline_runs` |
| `deploy_app_releases` | 保留 |
| `deploy_environments` | 保留 |
| `deploy_build_nodes` | 保留 |
| `deploy_build_master_configs` | 替换 `deploy_build_master_config` |
| `deploy_pipeline_members` | 保留 |
| `deploy_user_groups` | 保留 |
| `deploy_user_group_members` | 保留 |
| `deploy_tools` | 保留 |
| 已删除 `deploy_pipeline_repos` | 发布配置直接使用 `iam_credentials` + Pipeline 凭据字段 |
| 已删除 `deploy_pipeline_registries` | 发布配置直接使用 `iam_credentials` + Pipeline 凭据字段 |
#### 监控告警
| 目标表 | 来源/处理 |
| --- | --- |
| `monitor_targets` | 保留 |
| `monitor_metrics` | 保留,作为指标定义表 |
| `monitor_metric_samples` | 合并/替换 `metric_values` 和 `monitor_metric_histories` |
| `monitor_metric_aggregates` | 替换 `monitor_metrics_aggregate` |
| `monitor_metric_tags` | 保留 |
| `monitor_alert_rules` | 保留但只保留一个模型定义 |
| `monitor_alert_conditions` | 保留 |
| `monitor_alert_events` | 保留 |
| `monitor_alert_channels` | 替换 `monitor_notify_channels` |
| `monitor_alert_contacts` | 保留 |
| `monitor_alert_contact_groups` | 保留 |
| `monitor_alert_notify_policies` | 保留 |
| `monitor_alert_notify_templates` | 保留 |
| `monitor_alert_notify_settings` | 保留 |
| `monitor_alert_upgrades` | 保留 |
| `monitor_alert_upgrade_records` | 保留 |
| `monitor_alert_silences` | 保留 |
| `monitor_agent_reports` | 保留 |
| `monitor_agent_tasks` | 保留 |
| `monitor_agent_rules` | 保留 |
| `monitor_agent_alerts` | 保留 |
| `monitor_agent_port_policies` | 替换 `monitor_agent_port_policy`,并加入迁移 |
| `monitor_health_probes` | 保留 |
| `monitor_prometheus_datasources` | 保留 |
| `monitor_zabbix_datasources` | 保留 |
| `monitor_log_datasources` | 保留 |
| `monitor_trace_datasources` | 保留 |
| `monitor_site_tasks` | 替换 `ops_site_tasks` |
| `monitor_site_results` | 替换 `ops_site_results` |
| `monitor_site_alerts` | 替换 `ops_site_alerts` |
| `monitor_snmp_devices` | 替换 `ops_snmp_devices` |
| `monitor_snmp_templates` | 替换 `ops_snmp_templates` |
| `monitor_snmp_template_oids` | 替换 `ops_snmp_template_oids` |
| `monitor_snmp_samples` | 替换 `ops_snmp_metrics`,删除 `snmp_history` |
| `monitor_snmp_interfaces` | 替换 `ops_snmp_interfaces` |
| `monitor_dashboards` | 保留一个大屏主表 |
| `monitor_dashboard_panels` | 替换 `dashboard_panels` |
| 已删除 `monitor_custom_dashboards` | 合并到 `monitor_dashboards`,用 `type/custom` 区分 |
| 删除 `monitor_metric_data` | 用 `monitor_metrics` 替代 |
#### 自动化运维
| 目标表 | 来源/处理 |
| --- | --- |
| `ops_cron_jobs` | 保留 |
| `ops_scripts` | 保留 |
| `ops_task_executions` | 保留 |
| `ops_remote_executions` | 建议新增,记录远程批量执行任务级审计 |
| `ops_ansible_playbooks` | 如保留 Ansible 页面则保留,否则删除 |
| `ops_ansible_jobs` | 同上 |
| `ops_ansible_inventories` | 同上 |
#### 审批
| 目标表 | 来源/处理 |
| --- | --- |
| `approval_templates` | 替换 `sys_approval_templates` |
| `approval_processes` | 保留 |
| `approval_steps` | 保留 |
| `approval_workflows` | 替换 `sys_approval_workflows` |
| `approval_workflow_steps` | 替换 `sys_approval_workflow_steps` |
建议:模板保留表单 schema,工作流表保留审批流定义,流程实例引用 `workflow_id`,逐步弱化 `flow_config` JSON。
#### AIOps / AI
| 目标表 | 来源/处理 |
| --- | --- |
| `ai_llm_configs` | 保留 |
| `ai_chat_histories` | 保留 |
| `ai_analysis_traces` | 保留 |
| `ai_runs` | 保留 |
| `ai_run_messages` | 保留 |
| `ai_run_spans` | 保留 |
| `ai_capabilities` | 保留 |
| `ai_capability_policies` | 保留 |
| `ai_ops_sessions` | 替换 `ops_sessions` |
| `ai_ops_commands` | 替换 `ops_commands` |
### 重建执行策略
1. 修改所有 `TableName()`,把目标表名一次性统一。
2. 修改 GORM many2many tag:`role_permissions` -> `iam_role_permissions`。
3. 修改硬编码 `db.Table(...)` 和 SQL 清理逻辑。
4. 精简 AutoMigrate:删除已合并/废弃模型,补上漏迁移模型。
5. 更新 seed:默认权限、审批模板、工具配置、演示数据按新表生成。
6. 删除测试库数据卷或 SQLite 文件,重新启动服务让 AutoMigrate 建表。
7. 跑 `go build`、`npx tsc --noEmit`、集成测试和关键页面验证。
## 总体结论
当前表名前缀没有形成统一分层,主要使用 `asset_`、`monitor_`、`ops_`、`deploy_`、`sys_`、`iam_`、`ai_`、`kubernetes_`。整体可以运行,但存在几类需要后续整改的问题:
1. **无前缀或前缀不一致表**:`approval_processes`、`approval_steps`、`navigation_links`、`metric_values`、`dashboard_panels`、`snmp_history`、`role_permissions`。
2. **同一职责多套表并存**:审批有 `sys_approval_templates / approval_processes / approval_steps` 与 `sys_approval_workflows / sys_approval_workflow_steps`;监控大屏有 `monitor_dashboards / dashboard_panels` 与 `monitor_custom_dashboards`;告警规则有 `AlertRule` 和 `MonitorAlertRule` 共用 `monitor_alert_rules`。
3. **表定义但迁移/引用不完整**:`SNMPHistory` 定义了 `snmp_history`,但未进入 AutoMigrate,代码实际查询 `ops_snmp_metrics`;`AgentPortPolicy` 有控制器引用,但未进入 AutoMigrate。
4. **疑似旧表/低价值表**:`sys_log_sources`、`sys_log_entries` 属于旧日志模块,与当前日志中心 `monitor_log_datasources` + 外部 Loki/ES 架构重复;`ops_ansible_*` 有种子数据但“远程执行”现已主要走 SSH/脚本执行,需要确认是否仍保留 Ansible 页面。
5. **设计一致性问题**:大量 JSON 字符串字段承载结构化配置,短期可接受,但要明确边界;高频时序表缺少统一保留策略和分区方案;部分关联表缺少显式外键/唯一约束命名规范。
## 现状前缀评估
> 本节是现状评估口径;重建整改时以“重建版目标设计”为准。
| 领域 | 当前/建议前缀 | 说明 |
| --- | --- | --- |
| IAM/权限/凭据 | `iam_` | 用户、角色、权限、LDAP、凭据、ItemRole |
| 系统基础配置 | `sys_` | 系统任务、站内导航、全局配置 |
| 安全审计 | `audit_` | 重建后从 `sys_` 拆出,避免系统配置与审计数据混在一起 |
| 资产/云资源 | `asset_` | 服务器、云账号、域名、账单、资源模型 |
| Kubernetes | `k8s_` | 重建后统一短前缀,替换当前 `kubernetes_` |
| 发布/部署 | `deploy_` | 环境、凭据、工具、发布记录、成员权限 |
| 自动化执行 | `ops_` | 定时任务、脚本、任务执行、远程执行记录 |
| 监控告警 | `monitor_` | 指标、目标、告警、数据源、站点拨测、SNMP、Agent |
| 审批 | `approval_` | 重建后模板、流程、实例统一归入审批域 |
| AI/AIOps | `ai_` | LLM 配置、会话、分析轨迹、能力策略 |
## 问题清单
| 编号 | 严重度 | 表/模型 | 问题 | 建议 |
| --- | --- | --- | --- | --- |
| DB-001 | 高 | `AlertRule` / `MonitorAlertRule` -> `monitor_alert_rules` | 两个模型返回同一张表,字段定义不同。AutoMigrate 会叠加字段,后续维护容易误删/误覆盖。 | 合并为一个模型或拆成 `monitor_alert_rules` 与 `monitor_probe_alert_rules`,迁移前确认线上字段使用。 |
| DB-002 | 高 | `AgentPortPolicy` -> `monitor_agent_port_policy` | 控制器有读写,但未加入 AutoMigrate,新库端口策略接口可能报“表不存在”。 | 加入 AutoMigrate;表名建议改为复数 `monitor_agent_port_policies`,如改名需迁移旧表。 |
| DB-003 | 高 | `SNMPHistory` -> `snmp_history` | 定义未迁移,代码未引用;清理逻辑清的是 `ops_snmp_metrics`。 | 若 `ops_snmp_metrics` 已兼任历史表,删除 `SNMPHistory`;否则新增迁移并把查询/清理改到 `monitor_snmp_histories`。 |
| DB-004 | 中 | `approval_processes`、`approval_steps` | 审批流程表无前缀,而模板表是 `sys_approval_templates`。 | 重建后统一为 `approval_templates`、`approval_processes`、`approval_steps`、`approval_workflows`。 |
| DB-005 | 中 | `sys_approval_workflows`、`sys_approval_workflow_steps` | 与 `sys_approval_templates.flow_config` 和 `approval_steps` 职责有重叠。 | 明确“模板流程配置”和“流程定义管理”的关系;如果新工作流已替代模板内 JSON,应迁移数据并下线旧字段。 |
| DB-006 | 中 | `navigation_links` | 导航组是 `sys_navigation_groups`,链接表无前缀。 | 重建后改为 `sys_navigation_links`。 |
| DB-007 | 中 | `metric_values`、`monitor_metric_histories`、`monitor_metrics_aggregate` | 监控时序有三套表:原始值、简化历史、聚合;当前控制器大量使用 `monitor_metric_histories`,`metric_values` 引用少。 | 重建后合并为 `monitor_metric_samples` + `monitor_metric_aggregates`。 |
| DB-008 | 已处理 | `monitor_dashboards`、`monitor_dashboard_panels` | 自定义大屏旧模型与路由已删除,统一使用 `monitor_dashboards` + `monitor_dashboard_panels`。 | 后续仅补齐新大屏 API/页面。 |
| DB-009 | 中 | `role_permissions` | GORM many2many 明确指定为 `role_permissions`,无 `iam_` 前缀。 | 重建后改为 `iam_role_permissions`。 |
| DB-010 | 中 | `sys_log_sources`、`sys_log_entries` | 旧日志采集/入库表,与当前日志中心外部数据源架构重复。 | 重建后删除,日志中心只保留 `monitor_log_datasources` 和外部查询结果。 |
| DB-011 | 中 | `ops_site_*`、`ops_snmp_*` | 站点拨测和 SNMP 属于监控域,但表名前缀使用 `ops_`。 | 重建后改为 `monitor_site_*`、`monitor_snmp_*`。 |
| DB-012 | 中 | `ai_runs`、`ops_sessions`、`ops_commands` | AI 运行表和人机执行会话跨 `ai_` / `ops_`,边界不清。 | 重建后改为 `ai_ops_sessions`、`ai_ops_commands`。 |
| DB-013 | 低 | `backend/model/monitor.go`、`deploy_tool.go` | 模型文件超过或接近项目红线,维护成本高。 | 后续按子域拆分:target/metric/dashboard/health_probe/tool_registry。 |
## 菜单域表清单
### 仪表盘
| 功能 | 表 | 评价 |
| --- | --- | --- |
| 首页统计 | 多域聚合,无独立主表 | 统计来自资产、告警、K8s、流水线等表,合理。 |
| 自定义监控仪表盘 | `monitor_dashboards`、`monitor_dashboard_panels` | 已统一到监控大屏主表和面板表。 |
### 资产管理 / 云资源
| 功能 | 表 | 评价 |
| --- | --- | --- |
| 服务器管理 | `asset_servers` | 前缀规范;字段较多但与 Agent/RDP/SSH 场景匹配。 |
| 主机组 | `asset_host_groups` | 放资产域合理。 |
| 云平台 | `asset_clouds` | 命名偏泛,实际是云平台类型/供应商。可考虑改为 `asset_cloud_platforms`。 |
| 云账号 | `asset_cloud_accounts` | 前缀规范;凭证字段需确认是否已统一走 `iam_credentials` 加密。 |
| 云区域 | `asset_regions` | 前缀规范;如果仅云区域,建议 `asset_cloud_regions` 更清晰。 |
| 域名管理 | `asset_domains` | 前缀规范。 |
| 资源型号 | `asset_resource_models` | 前缀规范。 |
| 云账单 | `asset_cloud_billings` | 前缀规范。 |
| 账单告警规则 | `monitor_billing_alert_rules` | 归入监控域合理,但与资产账单强关联,需文档化。 |
### Kubernetes
| 功能 | 表 | 评价 |
| --- | --- | --- |
| 集群管理 | `kubernetes_clusters` | 当前统一 `kubernetes_` 前缀,虽较长但一致。 |
| 节点缓存 | `kubernetes_nodes` | 从集群同步缓存,合理。 |
| Pod 缓存 | `kubernetes_pods` | 从集群同步缓存,合理。 |
| Deployment 缓存 | `kubernetes_deployments` | 从集群同步缓存,合理。 |
| Service 缓存 | `kubernetes_services` | 从集群同步缓存,合理。 |
| Ingress 缓存 | `kubernetes_ingresses` | 从集群同步缓存,合理。 |
| 拓扑布局 | `kubernetes_topology_layouts` | 用户布局数据,合理。 |
建议:重建后统一改为 `k8s_`,后续不再新增 `kubernetes_` 表。
### 应用发布 / CI/CD
| 功能 | 表 | 评价 |
| --- | --- | --- |
| 流水线 | `ops_pipelines` | 历史命名属 `ops_`,但该域现在已经有 `deploy_` 前缀,建议长期统一。 |
| 流水线运行 | `ops_pipeline_runs` | 同上;日志存在 `logs longtext`,数据增长需清理策略。 |
| 应用发布 | `deploy_app_releases` | 前缀规范。 |
| Git 仓库配置 | `deploy_pipeline_repos` | 可能与统一凭据 `iam_credentials` 存在职责重叠,当前通过 `credential_id` 关联可接受。 |
| 镜像仓库配置 | `deploy_pipeline_registries` | 同上。 |
| 发布环境 | `deploy_environments` | 前缀规范。 |
| 构建节点 | `deploy_build_nodes`、`deploy_build_master_config` | 前缀规范。 |
| 流水线成员 | `deploy_pipeline_members` | 前缀规范;直接用户 + 用户组双授权设计合理。 |
| 发布用户组 | `deploy_user_groups`、`deploy_user_group_members` | 前缀规范,但与 IAM 角色/组职责需边界清晰。 |
| ItemRole | `iam_item_roles`、`iam_user_item_roles` | 放 IAM 域合理,用于流水线正则授权。 |
| 工具配置 | `deploy_tools` | 前缀规范。 |
建议:重建后统一改为 `deploy_pipelines`、`deploy_pipeline_runs`。
### 监控告警
| 功能 | 表 | 评价 |
| --- | --- | --- |
| 告警规则/事件 | `monitor_alert_rules`、`monitor_alert_events` | 主表合理;但规则模型重复是高风险问题。 |
| 告警条件/模板/升级/静默 | `monitor_alert_conditions`、`monitor_alert_notify_templates`、`monitor_alert_upgrades`、`monitor_alert_upgrade_records`、`monitor_alert_silences` | 前缀规范;需确认哪些已接入 UI/引擎。 |
| 通知渠道/联系人 | `monitor_notify_channels`、`monitor_alert_contacts`、`monitor_alert_contact_groups`、`monitor_alert_notify_policies`、`monitor_alert_notify_settings` | 前缀规范;通知设置/策略/模板职责较多,建议后续画数据流。 |
| Agent 指标 | `monitor_agent_reports`、`monitor_metrics_aggregate` | 前缀规范;按 hostname 关联服务器,删除服务器时有清理。 |
| Agent 任务/规则/告警 | `monitor_agent_tasks`、`monitor_agent_rules`、`monitor_agent_alerts` | 前缀规范。 |
| Agent 端口策略 | `monitor_agent_port_policy` | 未迁移,且表名非复数。 |
| 监控目标/指标 | `monitor_targets`、`monitor_metrics`、`monitor_metric_histories`、`monitor_metric_tags`、`metric_values` | 需要统一时序表职责和命名前缀。 |
| 健康探针 | `monitor_health_probes` | 前缀规范;结果不独立入库,随 Agent 心跳更新。 |
| Prometheus/Zabbix/日志/链路数据源 | `monitor_prometheus_datasources`、`monitor_zabbix_datasources`、`monitor_log_datasources`、`monitor_trace_datasources` | 前缀规范。 |
| 站点拨测 | `ops_site_tasks`、`ops_site_results`、`ops_site_alerts` | 功能属监控,前缀却是 `ops_`。 |
| SNMP | `ops_snmp_devices`、`ops_snmp_templates`、`ops_snmp_template_oids`、`ops_snmp_metrics`、`ops_snmp_interfaces`、`snmp_history` | 功能属监控;`snmp_history` 未迁移/未引用。 |
| 指标目录旧表 | `monitor_metric_data` | 需确认是否仍有 UI/引擎依赖;可能与 `monitor_metrics` 重复。 |
### 自动化运维
| 功能 | 表 | 评价 |
| --- | --- | --- |
| 定时任务 | `ops_cron_jobs` | 前缀规范。 |
| 脚本库 | `ops_scripts` | 前缀规范。 |
| 执行记录 | `ops_task_executions` | 前缀规范。 |
| Ansible | `ops_ansible_playbooks`、`ops_ansible_jobs`、`ops_ansible_inventories` | 有模型和种子数据;需确认 UI 是否仍作为主功能。若已由 Go-native SSH 替代,可降级为 deprecated。 |
### AIOps / AI
| 功能 | 表 | 评价 |
| --- | --- | --- |
| LLM 配置 | `ai_llm_configs` | 前缀规范。 |
| 对话历史 | `ai_chat_histories` | 前缀规范。 |
| 告警分析轨迹 | `ai_analysis_traces` | 前缀规范。 |
| Agent CLI 运行 | `ai_runs`、`ai_run_messages`、`ai_run_spans` | 前缀规范。 |
| 能力注册/策略 | `ai_capabilities`、`ai_capability_policies` | 前缀规范。 |
| 人机执行会话 | `ops_sessions`、`ops_commands` | 与 AI Agent 强关联但用 `ops_`,边界需确认。 |
### 资源申请 / 审批
| 功能 | 表 | 评价 |
| --- | --- | --- |
| 审批模板 | `sys_approval_templates` | 前缀规范。 |
| 审批实例 | `approval_processes` | 无前缀。 |
| 审批步骤 | `approval_steps` | 无前缀。 |
| 审批工作流 | `sys_approval_workflows`、`sys_approval_workflow_steps` | 前缀规范,但与模板内 `flow_config` 和实例步骤存在职责重叠。 |
建议:先确认现行前端使用旧模板流程还是新 workflow;再决定迁移方向。
### 安全审计
| 功能 | 表 | 评价 |
| --- | --- | --- |
| 操作审计 | `sys_audit_logs` | 前缀规范。 |
| 审计策略 | `sys_audit_settings` | 前缀规范。 |
| 登录日志 | `sys_login_logs` | 前缀规范。 |
| 终端会话 | `sys_terminal_sessions` | 前缀规范。 |
| RDP 会话 | `sys_rdp_sessions` | 前缀规范。 |
| 会话命令 | `sys_session_commands` | 前缀规范。 |
### 系统管理 / IAM
| 功能 | 表 | 评价 |
| --- | --- | --- |
| 用户 | `iam_users` | 前缀规范。 |
| 角色 | `iam_roles` | 前缀规范。 |
| 权限 | `iam_permissions` | 前缀规范。 |
| 用户角色关系 | `iam_user_roles` | GORM many2many 指定,前缀规范。 |
| 角色权限关系 | `role_permissions` | GORM many2many 指定,无 `iam_` 前缀。 |
| LDAP | `iam_ldap_configs` | 前缀规范。 |
| 凭据 | `iam_credentials` | 前缀规范;建议继续作为全局凭据统一入口。 |
| 日志范围权限 | `monitor_log_scope_roles`、`monitor_user_log_scope_roles` | 属日志中心/监控域,放 `monitor_` 合理。 |
| 系统任务状态 | `sys_system_task_status` | 前缀规范。 |
| 站内导航 | `sys_navigation_groups`、`navigation_links` | 链接表无前缀。 |
## 疑似可清理候选
测试库重建时可直接删除;生产整改前仍需查数据量、最近写入时间、controller/frontend 是否仍有入口。
| 表/模型 | 判断依据 | 建议动作 |
| --- | --- | --- |
| `snmp_history` / `SNMPHistory` | 未 AutoMigrate,未被业务引用。 | 重建后删除模型。 |
| `sys_log_sources`、`sys_log_entries` | 旧日志入库模块,与日志中心外部数据源重复。 | 重建后删除。 |
| `monitor_custom_dashboards` | 与 `monitor_dashboards` 重复。 | 已删除模型与路由,重建后不再创建。 |
| `metric_values`、`monitor_metric_histories` | 时序职责重复。 | 重建后合并为 `monitor_metric_samples`。 |
| `ops_ansible_*` | 远程执行主路径已偏 Go-native SSH。 | 若确认无页面入口,重建后删除;否则保留在 `ops_`。 |
| `deploy_pipeline_repos`、`deploy_pipeline_registries` | Pipeline 已直接使用 `GitCredentialID` / `RegistryCredentialID`。 | 已删除模型、路由和前端旧配置入口,重建后不再创建。 |
## 后续整改顺序
1. **先统一命名和模型归属**:按 Schema v2 修改 `TableName()`、many2many tag、硬编码 SQL。
2. **处理重复模型**:合并 `AlertRule` / `MonitorAlertRule`、大屏表、指标时序表、审批 workflow/模板关系。
3. **删除废弃模型**:`SNMPHistory`、旧日志入库、旧仓库/镜像仓库配置、确认不用的 Ansible 表。
4. **重建测试库**:删除 SQLite 文件或 Docker 数据卷,让 AutoMigrate 从零建表。
5. **补充数据生命周期**:流水线日志、Agent 报告、指标样本、SNMP 样本、站点拨测结果、审计日志都需要保留期和清理任务。
+53 -1
View File
@@ -1,4 +1,4 @@
import { Suspense, lazy } from 'react';
import { Suspense, lazy, useEffect, useRef } from 'react';
import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from '@/hooks/useTheme';
@@ -10,6 +10,7 @@ import { PageTransition } from '@/components/layout/PageTransition';
import { ErrorBoundary } from '@/components/layout/ErrorBoundary';
import { Toaster } from '@/components/ui/sonner';
import i18n from '@/i18n';
import { toast } from 'sonner';
// Lazy load page components for code splitting
const Dashboard = lazy(() => import('@/components/dashboard/Dashboard').then(m => ({ default: m.Dashboard })));
@@ -104,12 +105,63 @@ const queryClient = new QueryClient({
},
});
function AssetVersionGuard() {
const currentVersionRef = useRef<string>('');
const notifiedRef = useRef(false);
useEffect(() => {
const readVersion = async () => {
try {
const resp = await fetch(`/version?t=${Date.now()}`, { cache: 'no-store' });
if (!resp.ok) return;
const data = await resp.json();
const nextVersion = [data.version, data.git_commit, data.build_time].filter(Boolean).join(':');
if (!nextVersion) return;
if (!currentVersionRef.current) {
currentVersionRef.current = nextVersion;
return;
}
if (currentVersionRef.current !== nextVersion && !notifiedRef.current) {
notifiedRef.current = true;
toast.info(i18n.t('common.appUpdated'), {
description: i18n.t('common.appUpdatedDesc'),
action: {
label: i18n.t('common.reloadPage'),
onClick: () => window.location.reload(),
},
duration: Infinity,
});
}
} catch {
// 版本检查失败不影响业务页面,后续定时器会重试。 // Logan.Li
}
};
void readVersion();
const timer = window.setInterval(readVersion, 60_000);
const handleFocus = () => void readVersion();
const handleVisibility = () => {
if (document.visibilityState === 'visible') void readVersion();
};
window.addEventListener('focus', handleFocus);
document.addEventListener('visibilitychange', handleVisibility);
return () => {
window.clearInterval(timer);
window.removeEventListener('focus', handleFocus);
document.removeEventListener('visibilitychange', handleVisibility);
};
}, []);
return null;
}
function App() {
return (
<ThemeProvider>
<QueryClientProvider client={queryClient}>
<ConfirmProvider>
<Toaster position="top-right" richColors closeButton />
<AssetVersionGuard />
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
+18 -15
View File
@@ -2,44 +2,47 @@
import { http } from '@/utils/request';
import { useUserStore } from '@/store';
// 会话与消息类型(与后端 model.AgentCLISession/Message 对应) // Logan.Li
// 会话与消息类型(与后端 model.AIRun/AIRunMessage 对应) // Logan.Li
export interface AgentCliSession {
id: number;
run_id: string;
parent_run_id: string;
user_id: number;
feature: string;
mode: string; // host | kubernetes
server_id: number;
server_name: string;
server_host: string;
cluster_id: number;
cluster_name: string;
namespace: string;
pod_name: string;
container: string;
subject_type: string;
subject_id: string;
subject_name: string;
subject_scope: string;
title: string;
status: string; // running/done/aborted/error
provider: string;
model: string;
safety_mode: string;
context_pack_id: string;
prompt_tokens: number;
completion_tokens: number;
rounds: number;
total_tokens: number;
duration: number;
error?: string;
started_at: string;
finished_at?: string;
created_at: string;
updated_at: string;
}
export interface AgentCliMessage {
id: number;
session_id: number;
run_id: string;
round: number;
seq: number;
type: string; // thought/tool_call/tool_result/done/error/user
role: string;
content: string;
tool_name: string;
tool_args: string;
tool_output: string;
risk: string;
success: boolean;
duration: string;
tool_call_id: string;
span_id: string;
tokens: number;
created_at: string;
}
-12
View File
@@ -3,18 +3,6 @@
import { http, type ListResponse } from '@/utils/request';
export const deployApi = {
// Git 仓库认证
getRepos: () => http.get<ListResponse>('/deploy/repos'),
getRepo: (id: number | string) => http.get(`/deploy/repos/${id}`),
createRepo: ( data: Record<string, unknown>) => http.post('/deploy/repos', data),
updateRepo: (id: number | string, data: Record<string, unknown>) => http.put(`/deploy/repos/${id}`, data),
deleteRepo: (id: number | string) => http.delete(`/deploy/repos/${id}`),
// 镜像仓库认证
getRegistries: () => http.get<ListResponse>('/deploy/registries'),
getRegistry: (id: number | string) => http.get(`/deploy/registries/${id}`),
createRegistry: ( data: Record<string, unknown>) => http.post('/deploy/registries', data),
updateRegistry: (id: number | string, data: Record<string, unknown>) => http.put(`/deploy/registries/${id}`, data),
deleteRegistry: (id: number | string) => http.delete(`/deploy/registries/${id}`),
// 环境管理
getEnvironments: () => http.get<ListResponse>('/deploy/environments'),
createEnvironment: ( data: Record<string, unknown>) => http.post('/deploy/environments', data),
@@ -54,17 +54,17 @@ export function messagesToChatItems(msgs: AgentCliMessage[]): ChatItem[] {
items.push({ id: nextId(), kind: 'thought', round: m.round, content: m.content });
break;
case 'status':
items.push({ id: nextId(), kind: 'status', round: m.round, phase: m.duration || undefined, content: m.content, toolName: m.tool_name, risk: m.risk });
items.push({ id: nextId(), kind: 'status', round: m.round, content: m.content, toolName: m.tool_name });
break;
case 'tool_call': {
let command = '';
try {
const args = JSON.parse(m.tool_args || '{}') as Record<string, unknown>;
command = String(args.command || '');
const args = JSON.parse(m.content || '{}') as Record<string, unknown>;
command = String(args.command || (args.action ? `${args.action} ${args.name || ''}` : ''));
} catch { /* 忽略解析失败 */ }
items.push({
id: nextId(), kind: 'tool', round: m.round,
toolName: m.tool_name, command, risk: m.risk, pending: true,
toolName: m.tool_name, command, pending: true,
});
break;
}
@@ -73,9 +73,8 @@ export function messagesToChatItems(msgs: AgentCliMessage[]): ChatItem[] {
if (items[i].kind === 'tool' && items[i].pending && items[i].toolName === m.tool_name) {
items[i] = {
...items[i],
output: m.content || m.tool_output,
success: m.success,
duration: m.duration,
output: m.content,
success: true,
pending: false,
};
break;
@@ -1,149 +0,0 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Plus, Key, Globe, Container } from 'lucide-react';
import { toast } from 'sonner';
import { credentialApi, deployApi } from '@/api';
import type { JsonData } from '@/types/common';
interface Repo {
id: number; name: string; url: string; credential_id: number | null;
}
interface Registry {
id: number; name: string; registry_url: string; namespace: string; credential_id: number | null;
}
interface Credential {
id: number; name: string; secret_type: string; summary: string;
}
interface Props {
open: boolean;
onOpenChange: (v: boolean) => void;
type: 'repo' | 'registry';
editId: number | null;
repos: Repo[];
registries: Registry[];
onSave: () => void;
}
export default function CredentialFormDialog({ open, onOpenChange, type, editId, repos, registries, onSave }: Props) {
const { t } = useTranslation();
const isRepo = type === 'repo';
const [name, setName] = useState('');
const [desc, setDesc] = useState('');
const [url, setUrl] = useState('');
const [namespace, setNamespace] = useState('');
const [credentialId, setCredentialId] = useState<number | string>('');
const [credentials, setCredentials] = useState<Credential[]>([]);
const [saving, setSaving] = useState(false);
// Docker 仓库 → docker 类型;Git 仓库 → api 或 docker 类型
const filteredCredentials = credentials.filter(c =>
isRepo ? true : c.secret_type === 'docker'
);
const loadCredentials = async () => {
try {
const res: JsonData = await credentialApi.list({ page_size: 100 });
setCredentials(res?.list || []);
} catch { /* silent */ }
};
useEffect(() => {
loadCredentials();
if (editId) {
if (isRepo) {
const r = repos.find(x => x.id === editId);
if (r) { setName(r.name); setDesc(''); setUrl(r.url); setCredentialId(r.credential_id || ''); }
} else {
const r = registries.find(x => x.id === editId);
if (r) { setName(r.name); setDesc(''); setUrl(r.registry_url); setNamespace(r.namespace || ''); setCredentialId(r.credential_id || ''); }
}
} else {
setName(''); setDesc(''); setUrl(''); setNamespace(''); setCredentialId('');
}
}, [editId, open, isRepo]);
const handleSave = async () => {
if (!name.trim() || !url.trim()) { toast.error(t('deploy.form.nameAddrRequired')); return; }
setSaving(true);
try {
const cid = credentialId ? Number(credentialId) : null;
const payload = isRepo
? { name: name.trim(), description: desc.trim(), url: url.trim(), credential_id: cid }
: { name: name.trim(), description: desc.trim(), registry_url: url.trim(), namespace: namespace.trim(), credential_id: cid };
if (editId) {
if (isRepo) await deployApi.updateRepo(editId, payload);
else await deployApi.updateRegistry(editId, payload);
toast.success(t('deploy.toast.updated'));
} else {
if (isRepo) await deployApi.createRepo(payload);
else await deployApi.createRegistry(payload);
toast.success(t('deploy.toast.created'));
}
onOpenChange(false);
onSave();
} catch { toast.error(t('deploy.toast.saveFailed')); }
finally { setSaving(false); }
};
const CredIcon = isRepo ? Globe : Container;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[480px] bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
<CredIcon className="h-5 w-5 text-[var(--dg-blue)]" />
{editId ? t('common.edit') : t('common.add')} {isRepo ? t('deploy.credForm.gitRepo') : t('deploy.credForm.imageRegistry')}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1"><Label>{t('deploy.form.nameStar')}</Label>
<Input value={name} onChange={e => setName(e.target.value)} placeholder={isRepo ? t('deploy.credForm.repoNamePlaceholder') : t('deploy.credForm.registryNamePlaceholder')} className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" /></div>
<div className="space-y-1"><Label>{isRepo ? t('deploy.form.repoUrlStar') : 'Registry URL *'}</Label>
<Input value={url} onChange={e => setUrl(e.target.value)} placeholder={isRepo ? 'http://10.10.10.72:9000/group/repo.git' : 'http://10.10.10.72'} className="font-mono text-sm bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" /></div>
{!isRepo && <div className="space-y-1"><Label>{t('deploy.form.defaultNamespace')}</Label>
<Input value={namespace} onChange={e => setNamespace(e.target.value)} placeholder={t('deploy.credForm.namespacePlaceholder')} className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" /></div>}
{/* 关联凭证 */}
<div className="space-y-1">
<Label>{t('deploy.cred.linkedCred')} <span className="text-xs text-[var(--dg-text-secondary)] opacity-70">{t('deploy.credForm.credHint')}</span></Label>
<Select value={String(credentialId)} onValueChange={v => setCredentialId(v === '__none' ? '' : v)}>
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<SelectValue placeholder={isRepo ? t('deploy.cred.selectApiCred') : t('deploy.cred.selectDockerPwd')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none">{t('deploy.cred.notLinkedCred')}</SelectItem>
{filteredCredentials.map(c => (
<SelectItem key={c.id} value={String(c.id)}>
<span className="flex items-center gap-1.5">
<Key className="h-3 w-3 opacity-60" />
{c.name} {c.summary && <span className="text-[10px] opacity-50">({c.summary})</span>}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
{filteredCredentials.length === 0 && (
<p className="text-xs dg-text-yellow flex items-center gap-1 mt-1">
<Plus className="h-3 w-3" />
{t('deploy.credForm.noCredHint', { type: isRepo ? '' : t('deploy.credForm.dockerPwd') })}
</p>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>{t('common.cancel')}</Button>
<Button onClick={handleSave} disabled={saving}>{saving ? t('deploy.pipeline.saving') : t('common.save')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, Pencil, Trash2, RefreshCw, GitBranch, Container, Globe, Settings2 } from 'lucide-react';
import { Plus, Pencil, Trash2, RefreshCw, Globe, Settings2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
@@ -12,24 +12,12 @@ import { useSearchParams } from 'react-router-dom';
import BuildNodesManager from './BuildNodesManager';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { toast } from 'sonner';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { deployApi } from '@/api';
import CredentialFormDialog from './CredentialFormDialog';
import ToolConfigTab from './ToolConfigTab';
import type { JsonData } from '@/types/common';
interface Repo {
id: number; name: string; description: string; url: string;
auth_type: string; credential_id: number | null; enabled: boolean;
created_by: string; updated_at: string;
}
interface Registry {
id: number; name: string; description: string; registry_url: string; namespace: string;
auth_type: string; credential_id: number | null; enabled: boolean;
created_by: string; updated_at: string;
}
interface Environment {
id: number; name: string; display_name: string; type: string; color: string;
sort_order: number; description: string; enabled: boolean;
@@ -74,7 +62,10 @@ function EnvFormDialog({ open, onClose, onSaved, editData }: { open: boolean; on
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent>
<DialogHeader><DialogTitle>{editData ? t('deploy.env.editEnv') : t('deploy.env.addEnv')}</DialogTitle></DialogHeader>
<DialogHeader>
<DialogTitle>{editData ? t('deploy.env.editEnv') : t('deploy.env.addEnv')}</DialogTitle>
<DialogDescription>{t('deploy.label.envManage')}</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1"><Label>{t('deploy.env.envLabel')}</Label><Input value={name} onChange={e => setName(e.target.value)} placeholder="production" /></div>
<div className="space-y-1"><Label>{t('deploy.table.displayName')}</Label><Input value={displayName} onChange={e => setDisplayName(e.target.value)} placeholder={t('deploy.env.prodEnv')} /></div>
@@ -102,46 +93,42 @@ function EnvFormDialog({ open, onClose, onSaved, editData }: { open: boolean; on
);
}
const CONFIG_TABS = ['repos', 'registries', 'environments', 'tools', 'build-nodes'] as const;
const CONFIG_TABS = ['environments', 'build-nodes', 'tools'] as const;
export default function DeployCredentials() {
const { t } = useTranslation();
const _confirm = useConfirm();
const confirm = useConfirm();
const [searchParams] = useSearchParams();
const tabFromUrl = searchParams.get('tab');
const [repos, setRepos] = useState<Repo[]>([]);
const [registries, setRegistries] = useState<Registry[]>([]);
const [environments, setEnvironments] = useState<Environment[]>([]);
const [loading, setLoading] = useState(false);
const [editorOpen, setEditorOpen] = useState(false);
const [editType, setEditType] = useState<'repo' | 'registry'>('repo');
const [editingId, setEditingId] = useState<number | null>(null);
const [envDialogOpen, setEnvDialogOpen] = useState(false);
const [editEnv, setEditEnv] = useState<Environment | null>(null);
const [activeTab, setActiveTab] = useState(
tabFromUrl && (CONFIG_TABS as readonly string[]).includes(tabFromUrl) ? tabFromUrl : 'repos',
tabFromUrl && (CONFIG_TABS as readonly string[]).includes(tabFromUrl) ? tabFromUrl : 'environments',
);
const fetchAll = async () => {
setLoading(true);
try {
const [rR, rReg, rEnv] = await Promise.all([deployApi.getRepos(), deployApi.getRegistries(), deployApi.getEnvironments()]);
setRepos((rR as JsonData)?.list || []);
setRegistries((rReg as JsonData)?.list || []);
setEnvironments((rEnv as JsonData)?.list || []);
const rEnv = await deployApi.getEnvironments();
setEnvironments((rEnv as { list?: Environment[] })?.list || []);
} catch { toast.error(t('deploy.toast.getDataFailed')); }
finally { setLoading(false); }
};
useEffect(() => { fetchAll(); }, []);
const handleDelete = async (type: 'repo' | 'registry' | 'env', item: JsonData) => {
const ok = await _confirm({ title: t('common.delete'), description: t('deploy.toast.deleteConfirm', { name: item.name || item.display_name }), confirmText: t('common.delete'), variant: 'destructive' });
const handleDeleteEnv = async (item: Environment) => {
const ok = await confirm({
title: t('common.delete'),
description: t('deploy.toast.deleteConfirm', { name: item.display_name || item.name }),
confirmText: t('common.delete'),
variant: 'destructive',
});
if (!ok) return;
try {
if (type === 'repo') await deployApi.deleteRepo(item.id);
else if (type === 'registry') await deployApi.deleteRegistry(item.id);
else await deployApi.deleteEnvironment(item.id);
await deployApi.deleteEnvironment(item.id);
toast.success(t('deploy.toast.deleted'));
fetchAll();
} catch { toast.error(t('deploy.toast.deleteFailed')); }
@@ -150,8 +137,6 @@ export default function DeployCredentials() {
const envTypeLabel: Record<string, string> = { production: t('deploy.env.production'), staging: t('deploy.env.staging'), testing: t('deploy.env.testing'), development: t('deploy.env.development') };
const pageTabs = [
{ id: 'repos', label: t('deploy.credForm.gitRepo'), badge: repos.length || undefined },
{ id: 'registries', label: t('deploy.credForm.imageRegistry'), badge: registries.length || undefined },
{ id: 'environments', label: t('deploy.label.envManage'), badge: environments.length || undefined },
{ id: 'build-nodes', label: t('deploy.deployNav.buildNodes') },
{ id: 'tools', label: t('deploy.tool.title') },
@@ -168,109 +153,25 @@ export default function DeployCredentials() {
activeTab={activeTab}
onTabChange={setActiveTab}
actions={activeTab === 'build-nodes' ? undefined : (
activeTab === 'environments' ? (
<>
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={fetchAll}>
<RefreshCw className="w-4 h-4 mr-1" />{t('common.refresh')}
</Button>
<Button size="sm" className="dg-btn-primary" onClick={() => { setEditType('repo'); setEditingId(null); setEditorOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />{t('deploy.credForm.newRepo')}
</Button>
<Button size="sm" variant="secondary" onClick={() => { setEditType('registry'); setEditingId(null); setEditorOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />{t('deploy.credForm.newRegistry')}
</Button>
</>
)}
contentClassName="space-y-4"
>
{activeTab === 'repos' && (
repos.length === 0 ? (
<PageEmpty icon={<GitBranch className="h-10 w-10" />} title={t('deploy.credForm.noRepo')} />
) : (
<ContentPanel noPadding>
<div className="overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
<tr>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.table.name')}</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] hidden md:table-cell">{t('deploy.table.repoUrl')}</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.cred.authMethod')}</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.table.status')}</th>
<th className="px-3 py-2 text-right font-medium text-[var(--dg-text-secondary)]">{t('common.actions')}</th>
</tr>
</thead>
<tbody>
{repos.map(r => (
<tr key={r.id} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
<td className="px-3 py-2 font-medium text-[var(--dg-text-primary)]">{r.name}</td>
<td className="px-3 py-2 text-[var(--dg-text-secondary)] hidden md:table-cell font-mono text-xs max-w-[300px] truncate">{r.url}</td>
<td className="px-3 py-2"><Badge variant="outline" className="text-xs">{r.auth_type}</Badge></td>
<td className="px-3 py-2">
<Badge className={r.enabled ? 'bg-emerald-500/20 dg-text-green border-emerald-500/30' : 'bg-[var(--dg-bg-active)]/20 dg-text-secondary border-[var(--dg-border)]'}>{r.enabled ? t('common.enable') : t('common.disable')}</Badge>
</td>
<td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditType('repo'); setEditingId(r.id); setEditorOpen(true); }}><Pencil className="w-3.5 h-3.5" /></Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500" onClick={() => handleDelete('repo', r)}><Trash2 className="w-3.5 h-3.5" /></Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</ContentPanel>
)
)}
{activeTab === 'registries' && (
registries.length === 0 ? (
<PageEmpty icon={<Container className="h-10 w-10" />} title={t('deploy.credForm.noRegistry')} />
) : (
<ContentPanel noPadding>
<div className="overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
<tr>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.table.name')}</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] hidden md:table-cell">Registry URL</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] hidden sm:table-cell">{t('deploy.credForm.namespace')}</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.cred.authMethod')}</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.table.status')}</th>
<th className="px-3 py-2 text-right font-medium text-[var(--dg-text-secondary)]">{t('common.actions')}</th>
</tr>
</thead>
<tbody>
{registries.map(r => (
<tr key={r.id} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
<td className="px-3 py-2 font-medium text-[var(--dg-text-primary)]">{r.name}</td>
<td className="px-3 py-2 text-[var(--dg-text-secondary)] hidden md:table-cell font-mono text-xs max-w-[250px] truncate">{r.registry_url}</td>
<td className="px-3 py-2 text-[var(--dg-text-secondary)] hidden sm:table-cell">{r.namespace || '-'}</td>
<td className="px-3 py-2"><Badge variant="outline" className="text-xs">{r.auth_type}</Badge></td>
<td className="px-3 py-2">
<Badge className={r.enabled ? 'bg-emerald-500/20 dg-text-green border-emerald-500/30' : 'bg-[var(--dg-bg-active)]/20 dg-text-secondary border-[var(--dg-border)]'}>{r.enabled ? t('common.enable') : t('common.disable')}</Badge>
</td>
<td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditType('registry'); setEditingId(r.id); setEditorOpen(true); }}><Pencil className="w-3.5 h-3.5" /></Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500" onClick={() => handleDelete('registry', r)}><Trash2 className="w-3.5 h-3.5" /></Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</ContentPanel>
)
)}
{activeTab === 'environments' && (
<>
<div className="flex items-center justify-end mb-3">
<Button size="sm" className="dg-btn-primary" onClick={() => { setEditEnv(null); setEnvDialogOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />新增环境
</Button>
</div>
</>
) : (
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={fetchAll}>
<RefreshCw className="w-4 h-4 mr-1" />{t('common.refresh')}
</Button>
)
)}
contentClassName="space-y-4"
>
{activeTab === 'environments' && (
<>
{environments.length === 0 ? (
<PageEmpty
icon={<Globe className="h-10 w-10" />}
@@ -316,7 +217,7 @@ export default function DeployCredentials() {
<td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditEnv(env); setEnvDialogOpen(true); }}><Pencil className="w-3.5 h-3.5" /></Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500" onClick={() => handleDelete('env', env)}><Trash2 className="w-3.5 h-3.5" /></Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500" onClick={() => handleDeleteEnv(env)}><Trash2 className="w-3.5 h-3.5" /></Button>
</div>
</td>
</tr>
@@ -333,11 +234,6 @@ export default function DeployCredentials() {
{activeTab === 'tools' && <ToolConfigTab />}
<CredentialFormDialog
open={editorOpen} onOpenChange={setEditorOpen} type={editType} editId={editingId}
repos={repos} registries={registries} onSave={fetchAll}
/>
<EnvFormDialog open={envDialogOpen} onClose={() => setEnvDialogOpen(false)} onSaved={fetchAll} editData={editEnv} />
</PageShell>
);
@@ -14,8 +14,6 @@ import {
Trash2,
ChevronDown,
ChevronRight,
Container,
Globe,
History,
Server,
Network,
@@ -54,8 +52,6 @@ export function PipelineCard({
const navigate = useNavigate();
const { t } = useTranslation();
const stages = parseStages(pipeline.stages);
const repo = pipeline.repo as Record<string, unknown> | undefined;
const registry = pipeline.registry as Record<string, unknown> | undefined;
const environment = pipeline.environment as Record<string, unknown> | undefined;
const code = (pipeline.code || pipeline.id) as string;
const deployTarget = String(pipeline.deploy_target || 'ssh');
@@ -151,8 +147,6 @@ export function PipelineCard({
</>
)}
</span>
{repo && <span className="flex items-center gap-1"><Globe className="h-3 w-3" />{String(repo.name)}</span>}
{registry && <span className="flex items-center gap-1"><Container className="h-3 w-3" />{String(registry.name)}</span>}
{pipeline.last_run_at && (
<span><Clock className="h-3 w-3 inline mr-0.5" />{new Date(pipeline.last_run_at as string).toLocaleString('zh-CN')}</span>
)}
@@ -15,6 +15,15 @@ interface State {
errorInfo: ErrorInfo | null;
}
function isChunkLoadError(error: Error | null) {
// 发版后旧入口引用已删除 chunk 时,浏览器会抛动态 import 加载失败。 // Logan.Li
const message = error?.message || '';
return message.includes('Failed to fetch dynamically imported module') ||
message.includes('Importing a module script failed') ||
message.includes('ChunkLoadError') ||
message.includes('Loading chunk');
}
/**
* ErrorBoundary - 全局错误边界组件
*
@@ -38,6 +47,10 @@ export class ErrorBoundary extends Component<Props, State> {
}
handleRetry = () => {
if (isChunkLoadError(this.state.error)) {
window.location.reload();
return;
}
this.setState({ hasError: false, error: null, errorInfo: null });
};
@@ -60,6 +73,7 @@ export class ErrorBoundary extends Component<Props, State> {
if (this.props.fallback) {
return this.props.fallback;
}
const chunkLoadError = isChunkLoadError(this.state.error);
return (
<div className="min-h-[60vh] flex items-center justify-center p-6">
@@ -75,7 +89,7 @@ export class ErrorBoundary extends Component<Props, State> {
{i18n.t('common.ebPageError')}
</h2>
<p className="text-sm text-[var(--dg-text-muted)] mt-2">
{i18n.t('common.ebDescription')}
{chunkLoadError ? i18n.t('common.ebChunkDescription') : i18n.t('common.ebDescription')}
</p>
</div>
@@ -116,7 +130,7 @@ export class ErrorBoundary extends Component<Props, State> {
onClick={this.handleRetry}
>
<RotateCcw className="h-4 w-4 mr-1.5" />
{i18n.t('common.reload')}
{chunkLoadError ? i18n.t('common.reloadPage') : i18n.t('common.reload')}
</Button>
<Button
variant="outline"
@@ -283,6 +283,13 @@ export function CloudServerImport({ open, onClose, initialAccountId }: CloudServ
r.code?.toLowerCase().includes(regionSearch.toLowerCase())
);
const getImportHostname = (server: any) => {
// 阿里云 HostName 常是 iZ 开头的系统名,导入预览优先展示控制台实例名称。 // Logan.Li
if (server.instance_name && server.instance_name !== server.instance_id) return server.instance_name;
if (server.hostname && server.hostname !== server.instance_id) return server.hostname;
return server.private_ip || server.public_ip || server.instance_name || server.instance_id || '-';
};
const steps = [
{ id: 1, title: t('server.cloudProvider'), icon: Cloud },
{ id: 2, title: t('server.cloudAccount'), icon: Key },
@@ -620,7 +627,7 @@ export function CloudServerImport({ open, onClose, initialAccountId }: CloudServ
<td className="px-3 py-2">
<Checkbox checked={selectedServers.includes(server.instance_id)} />
</td>
<td className="px-3 py-2 text-sm text-[var(--dg-text-primary)]">{server.instance_name || server.hostname || '-'}</td>
<td className="px-3 py-2 text-sm text-[var(--dg-text-primary)]">{getImportHostname(server)}</td>
<td className="px-3 py-2 text-sm text-[var(--dg-text-secondary)]">
{server.private_ip || server.public_ip || '-'}
</td>
@@ -33,6 +33,7 @@ import type { JsonData } from '@/types/common';
// 表单数据结构
export interface ServerFormData {
hostname: string;
alias: string;
ip: string;
public_ip: string;
port: string;
@@ -55,6 +56,7 @@ export interface ServerFormData {
export const EMPTY_FORM: ServerFormData = {
hostname: '',
alias: '',
ip: '',
public_ip: '',
port: '22',
@@ -183,6 +185,17 @@ export function ServerForm({
<div className="space-y-4 py-2">
{/* 基础信息 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">
{t('server.formAlias')}
</Label>
<Input
placeholder={t('server.aliasPlaceholder')}
value={formData.alias}
onChange={(e) => setFormData({ ...formData, alias: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">
{t('server.formHostname')} <span className="dg-text-red">*</span>
@@ -122,10 +122,13 @@ export function ServerManager() {
const servers = data?.list ?? [];
const total = data?.total ?? 0;
const getServerDisplayName = (server: JsonData) =>
server.alias || server.cloud_instance_name || server.hostname || server.ip || "";
const openTerminal = (server: JsonData) => {
setTerminalServer({
id: server.id,
name: server.hostname,
name: getServerDisplayName(server),
ip: server.ip,
hasAuth: server.has_auth ?? true,
});
@@ -133,12 +136,12 @@ export function ServerManager() {
};
const openRdp = (server: JsonData) => {
setRdpServer({ id: server.id, name: server.hostname, ip: server.ip });
setRdpServer({ id: server.id, name: getServerDisplayName(server), ip: server.ip });
setRdpOpen(true);
};
const openFileBrowser = (server: JsonData) => {
setFileBrowserServer({ id: server.id, name: server.hostname });
setFileBrowserServer({ id: server.id, name: getServerDisplayName(server) });
setFileBrowserOpen(true);
};
@@ -171,9 +174,13 @@ export function ServerManager() {
const handleDelete = async (id: string) => {
const server = servers.find((s) => String(s.id) === String(id));
const name = server ? getServerDisplayName(server) : id;
const ok = await confirm({
title: t("server.deleteTitle"),
description: `${server?.hostname || id}`,
description: t("server.deleteConfirm", {
name,
ip: server?.ip || "-",
}),
confirmText: t("common.delete"),
variant: "destructive",
});
@@ -192,7 +199,9 @@ export function ServerManager() {
if (selectedServers.length === 0) return;
const ok = await confirm({
title: t("server.batchDeleteTitle"),
description: `${selectedServers.length}`,
description: t("server.batchDeleteConfirm", {
count: selectedServers.length,
}),
confirmText: t("server.batchDelete"),
variant: "destructive",
});
@@ -224,6 +233,7 @@ export function ServerManager() {
setEditingId(server.id);
setFormData({
hostname: server.hostname || "",
alias: server.alias || "",
ip: server.ip || "",
public_ip: server.public_ip || "",
port: String(server.port || 22),
@@ -256,6 +266,7 @@ export function ServerManager() {
setFormSaving(true);
const payload: Record<string, unknown> = {
hostname: formData.hostname,
alias: formData.alias,
ip: formData.ip,
public_ip: formData.public_ip || undefined,
port: Number(formData.port) || 22,
@@ -306,6 +317,7 @@ export function ServerManager() {
return;
}
const headers = [
t("server.csv.name"),
t("server.csv.hostname"),
t("server.csv.ip"),
t("server.csv.publicIp"),
@@ -321,6 +333,7 @@ export function ServerManager() {
const memGB = s.memory ? Math.round(Number(s.memory) / 1024) : 0;
const tags = getTags(s).join(";");
return [
getServerDisplayName(s),
s.hostname || "",
s.ip || "",
s.public_ip || "",
@@ -482,7 +495,7 @@ export function ServerManager() {
/>
</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">
{t("server.hostname")}
{t("server.assetName")}
</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">
{t("server.ipAddress")}
@@ -52,6 +52,8 @@ export function ServerTableRow({
const tags = getTags(server);
const memGB = server.memory ? Math.round(server.memory / 1024) : 0;
const isRdp = isRdpServer(server);
const displayName = server.alias || server.cloud_instance_name || server.hostname || server.ip;
const secondaryName = server.hostname && server.hostname !== displayName ? server.hostname : server.os;
return (
<TableRow className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
@@ -66,9 +68,12 @@ export function ServerTableRow({
<ServerIcon className="h-4 w-4 text-[var(--dg-text-muted)]" />
<div>
<p className="font-medium text-[var(--dg-text-primary)]">
{server.hostname}
{displayName}
</p>
<p className="text-xs text-[var(--dg-text-muted)]">
{secondaryName}
{server.cloud_instance_id ? ` · ${server.cloud_instance_id}` : ""}
</p>
<p className="text-xs text-[var(--dg-text-muted)]">{server.os}</p>
</div>
</div>
</TableCell>
+10 -1
View File
@@ -16,6 +16,8 @@
"analyzing": "Analyzing",
"annotation": "Annotation",
"appName": "AIOps Platform",
"appUpdated": "System updated",
"appUpdatedDesc": "A new frontend version is available. Refresh the page before continuing.",
"apply": "Apply",
"applyFilter": "Apply Filter",
"approve": "Approve",
@@ -144,6 +146,7 @@
},
"ebCaughtError": "[ErrorBoundary] Caught error:",
"ebCopyError": "Copy Error",
"ebChunkDescription": "Page assets may have been updated or temporarily unavailable. Please refresh to load the latest version.",
"ebDescription": "Sorry, this page encountered an unexpected error. You can try reloading or returning to home.",
"ebErrorInfo": "[ErrorBoundary] Error info:",
"ebGoHome": "Back to Home",
@@ -759,8 +762,9 @@
"csvImport": "CSV Import",
"csvImportHint": "hostname + IP only",
"addServer": "Add Server",
"searchPlaceholder": "Search hostname or IP...",
"searchPlaceholder": "Search alias, cloud name, hostname or IP...",
"allStatus": "All Status",
"assetName": "Asset Name",
"hostname": "Hostname",
"ipAddress": "IP Address",
"publicIp": "Public IP",
@@ -774,8 +778,10 @@
"items": "items",
"totalItems": "{{count}} total",
"deleteTitle": "Delete Server",
"deleteConfirm": "Delete server \"{{name}}\" ({{ip}})? This action cannot be undone.",
"deleted": "Server deleted",
"batchDeleteTitle": "Batch Delete Servers",
"batchDeleteConfirm": "Delete the selected {{count}} servers? This action cannot be undone.",
"updated": "Server updated",
"created": "Server created",
"saveFailed": "Save failed, please retry",
@@ -785,6 +791,7 @@
"deleteFailed": "Delete failed",
"batchDeleteFailed": "Batch delete failed",
"csv": {
"name": "Asset Name",
"hostname": "Hostname",
"ip": "IP Address",
"publicIp": "Public IP",
@@ -1076,6 +1083,8 @@
"editServer": "Edit Server",
"editServerDesc": "Modify server asset information",
"addServerDesc": "Add a new server asset to CMDB",
"formAlias": "Alias",
"aliasPlaceholder": "e.g. prod-gateway, order-svc-01",
"formHostname": "Hostname",
"formIpAddress": "IP Address",
"osType": "OS Type",
+10 -1
View File
@@ -16,6 +16,8 @@
"analyzing": "分析中",
"annotation": "注解",
"appName": "AIOps平台",
"appUpdated": "系统已更新",
"appUpdatedDesc": "检测到前端资源已有新版本,建议刷新页面后继续操作。",
"apply": "应用",
"applyFilter": "应用筛选",
"approve": "通过",
@@ -144,6 +146,7 @@
},
"ebCaughtError": "[ErrorBoundary] 捕获到错误:",
"ebCopyError": "复制错误",
"ebChunkDescription": "页面资源可能已更新或短暂不可用,请刷新页面重新加载最新资源。",
"ebDescription": "抱歉,该页面遇到了意外错误。您可以尝试重新加载或返回首页。",
"ebErrorInfo": "[ErrorBoundary] 错误信息:",
"ebGoHome": "返回首页",
@@ -759,8 +762,9 @@
"csvImport": "CSV导入",
"csvImportHint": "hostname + IP 即可",
"addServer": "添加服务器",
"searchPlaceholder": "搜索主机名或IP...",
"searchPlaceholder": "搜索别名、云实例名、主机名或IP...",
"allStatus": "全部状态",
"assetName": "资产名称",
"hostname": "主机名",
"ipAddress": "IP地址",
"publicIp": "公网IP",
@@ -774,8 +778,10 @@
"items": "条",
"totalItems": "共 {{count}} 条",
"deleteTitle": "删除服务器",
"deleteConfirm": "确认删除服务器「{{name}}」({{ip}})?此操作不可撤销。",
"deleted": "服务器已删除",
"batchDeleteTitle": "批量删除服务器",
"batchDeleteConfirm": "确认删除选中的 {{count}} 台服务器?此操作不可撤销。",
"updated": "服务器已更新",
"created": "服务器已创建",
"saveFailed": "保存失败,请重试",
@@ -785,6 +791,7 @@
"deleteFailed": "删除失败",
"batchDeleteFailed": "批量删除失败",
"csv": {
"name": "资产名称",
"hostname": "主机名",
"ip": "IP地址",
"publicIp": "公网IP",
@@ -1076,6 +1083,8 @@
"editServer": "编辑服务器",
"editServerDesc": "修改服务器资产信息",
"addServerDesc": "录入新的服务器资产到 CMDB",
"formAlias": "别名",
"aliasPlaceholder": "如:生产网关、订单服务-01",
"formHostname": "主机名",
"formIpAddress": "IP地址",
"osType": "系统类型",
+5
View File
@@ -9,6 +9,11 @@ import App from './App.tsx'
// 首屏前应用主题,避免闪烁 // Logan.Li
applyThemeToDocument(getInitialTheme())
window.addEventListener('vite:preloadError', () => {
// 任一左侧子菜单懒加载资源在发版后失效时,统一整页刷新获取最新 index/chunk。 // Logan.Li
window.location.reload()
})
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff. Show more