Files
aiops/backend/controller/alert_engine_monitor.go
T
Logan.Li 539db36f2a
AIOps CI / Backend (test, vet, build) (push) Canceled after 0s
AIOps CI / Agent (test, vet, build) (push) Canceled after 0s
AIOps CI / Frontend (typecheck, lint, build) (push) Canceled after 0s
AIOps CI / API integration (SQLite + redka) (push) Canceled after 0s
feat(site): 站点拨测支持接口返回内容监控(文本 + JSON 字段)
需求:监控接口返回的数据本身是否变化(如 Cloudflare ips-v4 的 IP 段清单),
而不是只看服务是否可用。两者是不同的问题:服务 200 OK 但内容变了,拨测发现不了。

后端
- SiteTask 新增 content_check_mode(off/text/json)、content_paths(JSONPath)、
  content_ignore_rules、content_min_length、content_confirm_times、content_sort_lines
- 新增 monitor_site_baselines(基线多版本)与 monitor_site_content_changes(变更记录)
- pkg/siteprobe/content.go:零依赖 JSONPath($.a.b[0]、$.a[*].b)、文本/JSON 规范化、
  sha256 哈希、LCS 行级 diff、字段级 diff
- 核心判定区分「采集异常」与「内容变更」:状态码非 2xx、空响应、短于最小长度、
  非合法 JSON 均不算变更,避免 CDN 拦截页造成误报
- 连续 N 次(默认 2)不一致才落变更记录,抑制抖动
- 基线首次自动建立(用户选择自动接受),之后变更需人工「接受为新基线」并留审计
- 补齐历史遗留死字段:expected_content(正则断言)与 expected_length 此前
  只有表结构、执行器从未校验,现已实现
- 告警引擎新增 content_changed 指标,人工处理后自动恢复
- 迁移 0006 用 AutoMigrate 实现幂等(空库 0000 已建表,静态 ALTER 会重复列)

前端
- 任务表单新增内容监控配置区(模式/JSONPath/忽略规则/最小长度/确认次数/排序)
- 详情页新增内容监控面板:当前基线、待确认变更、并排 diff、版本历史、
  接受/忽略操作

验证
- 9 项单元测试(JSONPath、时间戳忽略、顺序无关、采集异常、字段级监控、diff)
- 端到端:Cloudflare ips-v4 建基线 v1 → 模拟变更 → 第 2 轮告警 → 接受为 v2
- JSON 模式实测快照仅含指定字段:{"$.db_driver":"mysql","$.version":"v1.0.2"}

// Logan.Li
2026-10-18 21:41:00 +00:00

412 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package controller
import (
"encoding/json"
"fmt"
"time"
"ezops/model"
"ezops/pkg/siteprobe"
)
// checkMiddlewareAlerts 中间件指标告警 // Logan.Li
func (e *AlertEngine) checkMiddlewareAlerts() {
var rules []model.MonitorAlertRule
e.db.Where("enabled = ? AND category = ?", true, model.MonitorAlertMiddleware).Find(&rules)
if len(rules) == 0 {
return
}
var targets []model.MonitorTarget
e.db.Where("type = ?", model.TargetTypeMiddleware).Find(&targets)
targetMap := make(map[uint]model.MonitorTarget)
for _, t := range targets {
targetMap[t.ID] = t
}
for _, rule := range rules {
checkTargets := targets
if rule.TargetID > 0 {
if t, ok := targetMap[rule.TargetID]; ok {
checkTargets = []model.MonitorTarget{t}
} else {
continue
}
}
for _, target := range checkTargets {
e.evalMiddlewareRule(rule, target)
}
}
}
func (e *AlertEngine) evalMiddlewareRule(rule model.MonitorAlertRule, target model.MonitorTarget) {
if rule.Metric == "middleware_offline" {
if target.Status == model.TargetStatusOffline || target.Status == model.TargetStatusUnknown {
e.fireMiddlewareAlert(rule, target, 1, 1, "中间件不可达或采集失败")
}
return
}
value, ok := e.getLatestMetricValue(target.ID, rule.Metric)
if !ok {
return
}
if !e.compareOperator(rule.Operator, value, rule.Threshold) {
return
}
msg := fmt.Sprintf("%s %s %.2f %s %.2f", target.Name, rule.Metric, value, rule.Operator, rule.Threshold)
e.fireMiddlewareAlert(rule, target, value, rule.Threshold, msg)
}
func (e *AlertEngine) getLatestMetricValue(targetID uint, metric string) (float64, bool) {
var hist model.MetricHistory
e.db.Where("target_id = ? AND metric_name = ?", targetID, metric).
Order("timestamp DESC").First(&hist)
if hist.ID == 0 {
return 0, false
}
return hist.Value, true
}
func (e *AlertEngine) fireMiddlewareAlert(rule model.MonitorAlertRule, target model.MonitorTarget, value, threshold float64, msg string) {
tenMinAgo := time.Now().Add(-10 * time.Minute)
var existing model.AgentAlert
e.db.Where("rule_name = ? AND hostname = ? AND resolved = ? AND created_at > ?",
rule.Name, target.Name, false, tenMinAgo).First(&existing)
if existing.ID > 0 {
return
}
if e.isSilenced(rule.Name, target.Name, rule.ID) {
return
}
sev := rule.Severity
if sev == "" {
sev = "warning"
}
alert := model.AgentAlert{
RuleName: rule.Name, Hostname: target.Name, Metric: rule.Metric,
Severity: sev, Message: msg, Value: value, Threshold: threshold,
}
e.db.Create(&alert)
e.notifyAlertRule("middleware", rule.Name, target.Name, rule.Metric, sev, msg, value, threshold)
now := time.Now()
e.db.Model(&rule).Update("last_triggered", now)
}
// checkSiteAlerts 站点拨测告警 // Logan.Li
func (e *AlertEngine) checkSiteAlerts() {
var rules []model.MonitorAlertRule
e.db.Where("enabled = ? AND category = ?", true, model.MonitorAlertSite).Find(&rules)
if len(rules) == 0 {
return
}
var tasks []model.SiteTask
e.db.Find(&tasks)
taskMap := make(map[uint]model.SiteTask)
for _, t := range tasks {
taskMap[t.ID] = t
}
for _, rule := range rules {
checkTasks := tasks
if rule.TargetID > 0 {
if t, ok := taskMap[rule.TargetID]; ok {
checkTasks = []model.SiteTask{t}
} else {
continue
}
}
for _, task := range checkTasks {
if task.Status != model.SiteTaskStatusRunning {
continue
}
e.evalSiteRule(rule, task)
}
}
}
func (e *AlertEngine) evalSiteRule(rule model.MonitorAlertRule, task model.SiteTask) {
var value float64
var triggered bool
var msg string
switch rule.Metric {
case "probe_fail":
triggered = task.LastResult == "fail" || task.LastResult == "timeout"
value = 1
if triggered {
msg = fmt.Sprintf("拨测任务 %s 失败: %s", task.Name, task.LastResult)
}
case "probe_timeout":
triggered = task.LastResult == "timeout"
value = 1
if triggered {
msg = fmt.Sprintf("拨测任务 %s 超时", task.Name)
}
case "response_time":
value = task.AvgResponse
triggered = e.compareOperator(rule.Operator, value, rule.Threshold)
if triggered {
msg = fmt.Sprintf("拨测 %s 延迟 %.0fms 超过阈值 %.0fms", task.Name, value, rule.Threshold)
}
case "availability":
value = task.SuccessRate
triggered = e.compareOperator(rule.Operator, value, rule.Threshold)
if triggered {
msg = fmt.Sprintf("拨测 %s 可用率 %.1f%% 低于阈值 %.1f%%", task.Name, value, rule.Threshold)
}
case "http_status_error":
triggered = task.LastResult == "fail"
value = 1
if triggered {
msg = fmt.Sprintf("拨测 %s HTTP 状态码异常", task.Name)
}
case "content_changed":
// 内容监控:服务可达但返回数据变了,与"拨测失败"是完全不同的问题。 // Logan.Li
if !siteprobe.ContentCheckEnabled(task.ContentCheckMode) {
return
}
var pending int64
e.db.Model(&model.SiteContentChange{}).
Where("task_id = ? AND status = ?", task.ID, model.ContentChangePending).
Count(&pending)
triggered = pending > 0
value = float64(pending)
if triggered {
msg = fmt.Sprintf("接口 %s 返回内容已变更,待确认 %d 项", task.Name, pending)
}
case "ssl_expiry_days":
if !siteprobe.IsSSLProbeTask(task) {
return
}
var result model.SiteResult
e.db.Where("task_id = ? AND ssl_cert_expiry IS NOT NULL", task.ID).
Order("checked_at DESC").First(&result)
if result.ID == 0 {
return
}
value = float64(result.SSLDaysRemaining)
triggered = e.compareOperator(rule.Operator, value, rule.Threshold)
if triggered {
expiryStr := ""
if result.SSLCertExpiry != nil {
expiryStr = result.SSLCertExpiry.Format("2006-01-02")
}
if value < 0 {
msg = fmt.Sprintf("SSL 拨测 %s 证书已过期", task.Name)
} else if expiryStr != "" {
msg = fmt.Sprintf("SSL 拨测 %s 证书剩余 %.0f 天(到期 %s)", task.Name, value, expiryStr)
} else {
msg = fmt.Sprintf("SSL 拨测 %s 证书剩余 %.0f 天", task.Name, value)
}
}
default:
return
}
if !triggered {
e.resolveActiveSiteAlerts(task.ID, rule.Metric)
return
}
tenMinAgo := time.Now().Add(-10 * time.Minute)
var existing model.SiteAlert
e.db.Where("task_id = ? AND title = ? AND status = ? AND started_at > ?",
task.ID, rule.Name, model.SiteAlertStatusActive, tenMinAgo).First(&existing)
if existing.ID > 0 {
return
}
sev := model.SiteAlertWarning
if rule.Severity == "critical" || (rule.Metric == "ssl_expiry_days" && value <= 7) {
sev = model.SiteAlertCritical
}
now := time.Now()
siteAlert := model.SiteAlert{
TaskID: task.ID, Severity: sev, Status: model.SiteAlertStatusActive,
Title: rule.Name, Message: msg, TriggerType: rule.Metric,
Threshold: rule.Threshold, ResponseTime: task.AvgResponse,
StartedAt: now,
}
e.db.Create(&siteAlert)
e.notifyAlertRule("site", rule.Name, task.Name, rule.Metric, string(sev), msg, value, rule.Threshold)
nt := time.Now()
e.db.Model(&rule).Update("last_triggered", nt)
}
// extractHostMetricValue 从 Agent 上报提取扩展指标值 // Logan.Li
func extractHostMetricValue(report model.AgentReport, metric string) (float64, bool) {
switch metric {
case "cpu":
return report.CPUUsage, true
case "cpu_iowait":
return report.CPUIOwait, true
case "mem":
return report.MemUsage, true
case "mem_available":
return report.MemAvailable, true
case "load1":
return report.Load1, true
case "disk":
var disks []model.DiskMetric
json.Unmarshal([]byte(report.DiskInfo), &disks)
max := 0.0
for _, d := range disks {
if d.UsagePercent > max {
max = d.UsagePercent
}
}
return max, true
case "disk_inode":
var disks []model.DiskMetric
json.Unmarshal([]byte(report.DiskInfo), &disks)
max := 0.0
for _, d := range disks {
if d.InodeUsagePct > max {
max = d.InodeUsagePct
}
}
return max, true
case "disk_io_util":
var dio model.DiskIOStatModel
json.Unmarshal([]byte(report.DiskIOInfo), &dio)
return dio.Util, true
case "swap":
var swap model.SwapMetric
json.Unmarshal([]byte(report.SwapInfo), &swap)
return swap.UsagePercent, true
case "tcp_time_wait":
var netStat model.NetworkStatModel
json.Unmarshal([]byte(report.NetInfo), &netStat)
return float64(netStat.TCPTimeWait), true
case "tcp_close_wait":
var netStat model.NetworkStatModel
json.Unmarshal([]byte(report.NetInfo), &netStat)
return float64(netStat.TCPCloseWait), true
case "tcp_established":
var netStat model.NetworkStatModel
json.Unmarshal([]byte(report.NetInfo), &netStat)
return float64(netStat.TCPEstablished), true
case "net_rx_bps":
var netStat model.NetworkStatModel
json.Unmarshal([]byte(report.NetInfo), &netStat)
return netStat.NetRxBPS, true
case "net_tx_bps":
var netStat model.NetworkStatModel
json.Unmarshal([]byte(report.NetInfo), &netStat)
return netStat.NetTxBPS, true
case "net_rx_errors":
var netStat model.NetworkStatModel
json.Unmarshal([]byte(report.NetInfo), &netStat)
return float64(netStat.NetRxErrors), true
case "fd":
var fdStat model.FDMetric
json.Unmarshal([]byte(report.FDInfo), &fdStat)
return fdStat.UsagePct, true
default:
return 0, false
}
}
// resolveActiveSiteAlerts 指标恢复正常时关闭对应活跃告警 // Logan.Li
func (e *AlertEngine) resolveActiveSiteAlerts(taskID uint, triggerType string) {
now := time.Now()
e.db.Model(&model.SiteAlert{}).
Where("task_id = ? AND status = ? AND trigger_type = ?", taskID, model.SiteAlertStatusActive, triggerType).
Updates(map[string]interface{}{
"status": model.SiteAlertStatusResolved, "resolved_at": now,
})
}
// siteRuleStillTriggered 判断任务是否仍满足告警规则 // Logan.Li
func (e *AlertEngine) siteRuleStillTriggered(rule model.MonitorAlertRule, task model.SiteTask) bool {
switch rule.Metric {
case "probe_fail":
return task.LastResult == "fail" || task.LastResult == "timeout"
case "probe_timeout":
return task.LastResult == "timeout"
case "http_status_error":
return task.LastResult == "fail"
case "response_time":
return e.compareOperator(rule.Operator, task.AvgResponse, rule.Threshold)
case "availability":
return e.compareOperator(rule.Operator, task.SuccessRate, rule.Threshold)
case "content_changed":
// 仍有待确认变更 → 告警持续;人工接受/忽略后自动恢复。 // Logan.Li
if !siteprobe.ContentCheckEnabled(task.ContentCheckMode) {
return false
}
var pending int64
e.db.Model(&model.SiteContentChange{}).
Where("task_id = ? AND status = ?", task.ID, model.ContentChangePending).
Count(&pending)
return pending > 0
case "ssl_expiry_days":
if !siteprobe.IsSSLProbeTask(task) {
return false
}
var result model.SiteResult
e.db.Where("task_id = ? AND ssl_cert_expiry IS NOT NULL", task.ID).
Order("checked_at DESC").First(&result)
if result.ID == 0 {
return false
}
return e.compareOperator(rule.Operator, float64(result.SSLDaysRemaining), rule.Threshold)
default:
return true
}
}
// autoResolveSiteAlerts 扫描活跃拨测告警,指标已恢复则自动关闭(含 SSL)// Logan.Li
func (e *AlertEngine) autoResolveSiteAlerts() {
var alerts []model.SiteAlert
e.db.Where("status = ?", model.SiteAlertStatusActive).Find(&alerts)
if len(alerts) == 0 {
return
}
taskCache := map[uint]model.SiteTask{}
ruleCache := map[string]model.MonitorAlertRule{}
now := time.Now()
for _, alert := range alerts {
task, ok := taskCache[alert.TaskID]
if !ok {
var t model.SiteTask
if e.db.First(&t, alert.TaskID).Error != nil {
continue
}
task = t
taskCache[alert.TaskID] = task
}
rule, ok := ruleCache[alert.Title]
if !ok {
var r model.MonitorAlertRule
e.db.Where("category = ? AND name = ?", model.MonitorAlertSite, alert.Title).First(&r)
if r.ID == 0 {
r.Metric = alert.TriggerType
}
rule = r
ruleCache[alert.Title] = rule
}
if rule.Metric == "" {
rule.Metric = alert.TriggerType
}
if !e.siteRuleStillTriggered(rule, task) {
e.db.Model(&alert).Updates(map[string]interface{}{
"status": model.SiteAlertStatusResolved, "resolved_at": now,
})
}
}
}
// isMiddlewareMetric 判断 AgentAlert 是否属于中间件 // Logan.Li
func isMiddlewareMetric(metric string) bool {
prefixes := []string{"redis_", "mysql_", "middleware_"}
for _, p := range prefixes {
if len(metric) >= len(p) && metric[:len(p)] == p {
return true
}
}
return false
}