Files
aiops/backend/controller/aiops_alert_analysis.go
T
attacker ca036a85ca feat(k8s): F7 集群详情EventsTab真实后端对接 + go vet修复
1. EventsTab.tsx: DEMO_EVENTS硬编码→kubernetesApi.getEvents真实K8s事件
   - 复用 normalizeK8sEvent/formatEventRelative 工具(与 EventsPage 一致)
   - 新增 loading/empty 状态 + 刷新按钮
   - Warning事件优先排序 + 限100条防溢出
   - KubernetesClusterDetail 传入 clusterCode prop
   - 修复 Q28 组件拆分时遗留的 demo 数据未对接缺口

2. aiops_alert_analysis.go: log.Printf %s→%d 修复 go vet 失败
   - AlertID 类型为 uint, %s 格式符不匹配致 go test 构建失败
   - go vet ./... 全量零错误

验证: tsc -b=0, npm build✓ (18.00s), go build=0, go vet=0, 17/17测试通过
Events API 返回5条真实事件 (type=Normal, reason=Pulled, count=5487)
2026-07-16 21:23:19 +08:00

453 lines
15 KiB
Go

package controller
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"ezops/middleware"
"ezops/model"
"ezops/pkg/agentcli/llm"
"ezops/pkg/aiopscontext"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
)
// AIOpsAlertAnalysisHandler 基于上下文包执行告警 RCA。 // Logan.Li
type AIOpsAlertAnalysisHandler struct {
db *gorm.DB
context *aiopscontext.Service
}
// NewAIOpsAlertAnalysisHandler 创建告警分析处理器。 // Logan.Li
func NewAIOpsAlertAnalysisHandler(db *gorm.DB) *AIOpsAlertAnalysisHandler {
return &AIOpsAlertAnalysisHandler{db: db, context: aiopscontext.NewService(db)}
}
// AlertAnalysisRequest 告警分析请求,前端传告警 ID 和少量兜底字段。 // Logan.Li
type AlertAnalysisRequest struct {
AlertID uint `json:"alert_id"`
Name string `json:"name"`
Severity string `json:"severity"`
Target string `json:"target"`
Metric string `json:"metric"`
CurrentValue string `json:"current_value"`
Threshold string `json:"threshold"`
Duration string `json:"duration"`
Description string `json:"description"`
TimeRange string `json:"time_range"`
}
// AlertAnalysisResponse 告警 RCA 结构化响应。 // Logan.Li
type AlertAnalysisResponse struct {
TraceID string `json:"trace_id"`
SpanID string `json:"span_id"`
RootCause string `json:"root_cause"`
Impact string `json:"impact"`
Recommendations []string `json:"recommendations"`
AutoFixScript string `json:"auto_fix_script,omitempty"`
SimilarCases int `json:"similar_cases"`
Confidence int `json:"confidence"`
Evidence []aiopscontext.EvidenceItem `json:"evidence"`
ContextPack *aiopscontext.ContextPack `json:"context_pack,omitempty"`
Model string `json:"model"`
Source string `json:"source"`
GeneratedAt time.Time `json:"generated_at"`
}
// Analyze POST /api/v1/aiops/alert-analysis。 // Logan.Li
func (h *AIOpsAlertAnalysisHandler) Analyze(c *gin.Context) {
start := time.Now()
traceID := uuid.NewString()
spanID := uuid.NewString()
userID := middleware.GetUserID(c)
var req AlertAnalysisRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求参数错误: " + err.Error()})
return
}
timeRange := req.TimeRange
if strings.TrimSpace(timeRange) == "" {
timeRange = "24h"
}
metrics := metricsForAlert(req.Metric)
pack, err := h.context.BuildPack(c.Request.Context(), aiopscontext.ContextPackRequest{
AlertID: req.AlertID, Target: req.Target, Metrics: metrics, TimeRange: timeRange, Limit: 12,
})
if err != nil {
h.saveAnalysisTrace(c.Request.Context(), analysisTraceInput{
TraceID: traceID, SpanID: spanID, UserID: userID, Feature: "alert_analysis",
Target: req.Target, AlertID: req.AlertID, Status: "error", Source: "context-pack",
Duration: time.Since(start).Milliseconds(), Error: err.Error(),
Result: gin.H{"error": "构建上下文失败: " + err.Error()},
})
c.JSON(http.StatusOK, model.Response{Code: 500, Message: "构建上下文失败: " + err.Error()})
return
}
fallback := fallbackAlertAnalysis(req, pack)
attachTrace(fallback, traceID, spanID)
var cfg model.LLMConfig
if err := h.db.First(&cfg).Error; err != nil || !cfg.Enabled {
h.saveAnalysisTrace(c.Request.Context(), analysisTraceInput{
TraceID: traceID, SpanID: spanID, UserID: userID, Feature: "alert_analysis",
Target: firstText(req.Target, pack.Scope), AlertID: req.AlertID, Provider: cfg.Provider,
Model: fallback.Model, Source: fallback.Source, Status: "fallback", Pack: pack, Result: fallback,
Duration: time.Since(start).Milliseconds(),
})
c.JSON(http.StatusOK, model.Response{Code: 200, Data: fallback})
return
}
result, usage, err := h.callAlertRCA(c.Request.Context(), cfg, req, pack, fallback)
if err != nil {
fallback.Source = "rule-fallback: " + err.Error()
attachTrace(fallback, traceID, spanID)
h.saveAnalysisTrace(c.Request.Context(), analysisTraceInput{
TraceID: traceID, SpanID: spanID, UserID: userID, Feature: "alert_analysis",
Target: firstText(req.Target, pack.Scope), AlertID: req.AlertID, Provider: cfg.Provider,
Model: cfg.Model, Source: "rule-fallback", Status: "fallback", Pack: pack, Result: fallback,
Duration: time.Since(start).Milliseconds(), Error: err.Error(),
})
c.JSON(http.StatusOK, model.Response{Code: 200, Data: fallback})
return
}
attachTrace(result, traceID, spanID)
h.saveAnalysisTrace(c.Request.Context(), analysisTraceInput{
TraceID: traceID, SpanID: spanID, UserID: userID, Feature: "alert_analysis",
Target: firstText(req.Target, pack.Scope), AlertID: req.AlertID, Provider: cfg.Provider,
Model: result.Model, Source: result.Source, Status: "success", Pack: pack, Result: result,
PromptTokens: usage.PromptTokens, CompletionTokens: usage.CompletionTokens, TotalTokens: usage.TotalTokens,
Duration: time.Since(start).Milliseconds(),
})
c.JSON(http.StatusOK, model.Response{Code: 200, Data: result})
}
func (h *AIOpsAlertAnalysisHandler) callAlertRCA(ctx context.Context, cfg model.LLMConfig, req AlertAnalysisRequest, pack *aiopscontext.ContextPack, fallback *AlertAnalysisResponse) (*AlertAnalysisResponse, llm.Usage, error) {
packJSON, _ := json.MarshalIndent(pack, "", " ")
prompt := fmt.Sprintf(`你是 AIOps 平台的企业级告警 RCA 分析器。请只基于【告警基本信息】和【AIOps Context Pack】做判断,不要编造不存在的数据。
【告警基本信息】
- ID: %d
- 名称: %s
- 级别: %s
- 目标: %s
- 指标: %s
- 当前值: %s
- 阈值: %s
- 持续: %s
- 描述: %s
【AIOps Context Pack】
%s
请返回严格 JSON,不要 markdown,不要代码块,字段如下:
{
"root_cause": "根因分析,2-4句,必须引用证据",
"impact": "影响范围,1-2句",
"recommendations": ["建议1", "建议2", "建议3"],
"auto_fix_script": "仅限只读诊断脚本;没有则为空字符串",
"similar_cases": 0,
"confidence": 0-100
}`, req.AlertID, req.Name, req.Severity, req.Target, req.Metric, req.CurrentValue, req.Threshold, req.Duration, req.Description, string(packJSON))
resp, err := buildLLMClientFromConfig(cfg).Complete(ctx, &llm.CompletionRequest{
Messages: []llm.Message{
{Role: "system", Content: "你是 AIOps 平台的告警根因分析专家,输出必须是可解析 JSON。"},
{Role: "user", Content: prompt},
},
Temperature: 0.2,
MaxTokens: cfg.MaxTokens,
})
if err != nil {
return nil, llm.Usage{}, err
}
parsed := *fallback
if err := parseAlertAnalysisJSON(resp.Message.Content, &parsed); err != nil {
parsed.RootCause = strings.TrimSpace(resp.Message.Content)
}
parsed.ContextPack = pack
parsed.Evidence = pack.Evidence
parsed.Model = cfg.Model
parsed.Source = "llm"
parsed.GeneratedAt = time.Now()
return &parsed, resp.Usage, nil
}
// ListAnalysisTraces 查询 AI 分析审计记录,普通用户只看自己的记录。 // Logan.Li
func (h *AIOpsAlertAnalysisHandler) ListAnalysisTraces(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if pageSize <= 0 || pageSize > 100 {
pageSize = 20
}
q := h.db.Model(&model.AIAnalysisTrace{})
if !middleware.GetIsAdmin(c) {
q = q.Where("user_id = ?", middleware.GetUserID(c))
}
if feature := strings.TrimSpace(c.Query("feature")); feature != "" {
q = q.Where("feature = ?", feature)
}
if target := strings.TrimSpace(c.Query("target")); target != "" {
q = q.Where("target LIKE ?", "%"+target+"%")
}
if status := strings.TrimSpace(c.Query("status")); status != "" {
q = q.Where("status = ?", status)
}
var total int64
q.Count(&total)
var rows []model.AIAnalysisTrace
if err := q.Select("id, trace_id, span_id, parent_span_id, user_id, feature, target, alert_id, provider, model, source, status, evidence_count, prompt_tokens, completion_tokens, total_tokens, duration, error, created_at, updated_at").
Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&rows).Error; err != nil {
c.JSON(http.StatusOK, model.Response{Code: 500, Message: "查询失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"list": rows, "total": total}})
}
// GetAnalysisTrace 按 trace_id 回放某次 AI 分析看到的上下文包和输出结论。 // Logan.Li
func (h *AIOpsAlertAnalysisHandler) GetAnalysisTrace(c *gin.Context) {
traceID := strings.TrimSpace(c.Param("trace_id"))
q := h.db.Where("trace_id = ?", traceID)
if !middleware.GetIsAdmin(c) {
q = q.Where("user_id = ?", middleware.GetUserID(c))
}
var row model.AIAnalysisTrace
if err := q.Order("id ASC").First(&row).Error; err != nil {
c.JSON(http.StatusOK, model.Response{Code: 404, Message: "分析记录不存在"})
return
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{
"trace": row,
"context_pack": decodeJSONField(row.ContextPack),
"result": decodeJSONField(row.Result),
}})
}
func parseAlertAnalysisJSON(raw string, out *AlertAnalysisResponse) error {
clean := strings.TrimSpace(raw)
clean = strings.TrimPrefix(clean, "```json")
clean = strings.TrimPrefix(clean, "```")
clean = strings.TrimSuffix(clean, "```")
clean = strings.TrimSpace(clean)
var tmp struct {
RootCause string `json:"root_cause"`
Impact string `json:"impact"`
Recommendations []string `json:"recommendations"`
AutoFixScript string `json:"auto_fix_script"`
SimilarCases int `json:"similar_cases"`
Confidence int `json:"confidence"`
}
if err := json.Unmarshal([]byte(clean), &tmp); err != nil {
return err
}
if strings.TrimSpace(tmp.RootCause) != "" {
out.RootCause = tmp.RootCause
}
if strings.TrimSpace(tmp.Impact) != "" {
out.Impact = tmp.Impact
}
if len(tmp.Recommendations) > 0 {
out.Recommendations = tmp.Recommendations
}
out.AutoFixScript = tmp.AutoFixScript
out.SimilarCases = tmp.SimilarCases
if tmp.Confidence > 0 {
if tmp.Confidence > 100 {
tmp.Confidence = 100
}
out.Confidence = tmp.Confidence
}
return nil
}
func fallbackAlertAnalysis(req AlertAnalysisRequest, pack *aiopscontext.ContextPack) *AlertAnalysisResponse {
evidenceText := "暂无足够平台证据"
if len(pack.Evidence) > 0 {
evidenceText = pack.Evidence[0].Summary
}
target := firstText(req.Target, pack.Scope, "未知目标")
metric := firstText(req.Metric, "unknown")
recs := []string{
fmt.Sprintf("先查看 %s 的近期指标趋势和告警时间线,确认是否持续异常", target),
"结合变更、发布、定时任务和日志错误进一步缩小根因范围",
"修复动作前先执行只读诊断,涉及重启、清理、扩容等动作需走确认或审批",
}
return &AlertAnalysisResponse{
RootCause: fmt.Sprintf("告警 %s 在目标 %s 上触发,指标 %s 当前值 %s,阈值 %s。基于当前上下文,最直接证据是:%s。",
firstText(req.Name, "未命名告警"), target, metric, req.CurrentValue, req.Threshold, evidenceText),
Impact: fmt.Sprintf("该告警级别为 %s,可能影响目标 %s 的稳定性,需要结合业务等级判断优先级。", firstText(req.Severity, "unknown"), target),
Recommendations: recs,
SimilarCases: 0,
Confidence: fallbackConfidence(pack),
Evidence: pack.Evidence,
ContextPack: pack,
Model: "rule-fallback",
Source: "rule-fallback",
GeneratedAt: time.Now(),
}
}
func firstText(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func metricsForAlert(metric string) []string {
m := strings.ToLower(metric)
switch {
case strings.Contains(m, "mem"):
return []string{"mem_usage", "load1"}
case strings.Contains(m, "load"):
return []string{"load1", "load5", "cpu_usage"}
case strings.Contains(m, "io"):
return []string{"cpu_iowait", "load1"}
default:
return []string{"cpu_usage", "mem_usage", "load1"}
}
}
func fallbackConfidence(pack *aiopscontext.ContextPack) int {
score := 45
if len(pack.Alerts) > 0 {
score += 10
}
if len(pack.Metrics) > 0 {
score += 15
}
if len(pack.Assets) > 0 {
score += 5
}
if score > 80 {
score = 80
}
return score
}
type analysisTraceInput struct {
TraceID string
SpanID string
ParentSpanID string
UserID uint
Feature string
Target string
AlertID uint
Provider string
Model string
Source string
Status string
Pack *aiopscontext.ContextPack
Result interface{}
PromptTokens int
CompletionTokens int
TotalTokens int
Duration int64
Error string
}
func attachTrace(resp *AlertAnalysisResponse, traceID, spanID string) {
if resp == nil {
return
}
resp.TraceID = traceID
resp.SpanID = spanID
}
func (h *AIOpsAlertAnalysisHandler) saveAnalysisTrace(ctx context.Context, in analysisTraceInput) {
packJSON := marshalTraceJSON(in.Pack)
resultJSON := marshalTraceJSON(traceResultForStorage(in.Result))
evidenceCount := 0
if in.Pack != nil {
evidenceCount = len(in.Pack.Evidence)
}
row := model.AIAnalysisTrace{
TraceID: in.TraceID,
SpanID: in.SpanID,
ParentSpanID: in.ParentSpanID,
UserID: in.UserID,
Feature: firstText(in.Feature, "unknown"),
Target: in.Target,
AlertID: in.AlertID,
Provider: in.Provider,
Model: in.Model,
Source: trimTraceText(in.Source, 64),
Status: firstText(in.Status, "unknown"),
ContextPack: packJSON,
Result: resultJSON,
EvidenceCount: evidenceCount,
PromptTokens: in.PromptTokens,
CompletionTokens: in.CompletionTokens,
TotalTokens: in.TotalTokens,
Duration: in.Duration,
Error: in.Error,
}
_ = h.db.WithContext(ctx).Create(&row).Error
}
func marshalTraceJSON(v interface{}) string {
if v == nil {
return ""
}
raw, err := json.Marshal(v)
if err != nil {
return ""
}
return string(raw)
}
func traceResultForStorage(v interface{}) interface{} {
switch r := v.(type) {
case *AlertAnalysisResponse:
if r == nil {
return nil
}
cp := *r
cp.ContextPack = nil
return cp
case AlertAnalysisResponse:
r.ContextPack = nil
return r
default:
return v
}
}
func decodeJSONField(raw string) interface{} {
if strings.TrimSpace(raw) == "" {
return nil
}
var out interface{}
if err := json.Unmarshal([]byte(raw), &out); err != nil {
return raw
}
return out
}
func trimTraceText(s string, max int) string {
if max <= 0 {
return s
}
runes := []rune(s)
if len(runes) <= max {
return s
}
return string(runes[:max])
}