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
This commit is contained in:
1 parent
286d7cd906
commit
539db36f2a
18 files changed
+1704
-3
No files matched your search
@@ -162,6 +162,20 @@ func (e *AlertEngine) evalSiteRule(rule model.MonitorAlertRule, task model.SiteT
|
||||
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
|
||||
@@ -318,6 +332,16 @@ func (e *AlertEngine) siteRuleStillTriggered(rule model.MonitorAlertRule, task m
|
||||
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
|
||||
|
||||
@@ -128,6 +128,58 @@ func (h *SiteHandler) UpdateTask(c *gin.Context) {
|
||||
if v, ok := raw["expected_status_code"].(float64); ok {
|
||||
updates["expected_status"] = int(v)
|
||||
}
|
||||
|
||||
// 内容监控配置。归一化规则一旦改动,旧基线就失去意义,
|
||||
// 必须重置基线让下一轮自动重建,否则会立刻产生一条"变更"。 // Logan.Li
|
||||
contentConfigChanged := false
|
||||
if v, ok := raw["content_check_mode"].(string); ok {
|
||||
mode := normalizeContentMode(v)
|
||||
if mode != task.ContentCheckMode {
|
||||
contentConfigChanged = true
|
||||
if mode == "off" {
|
||||
// 关闭内容监控时同时清空基线状态,避免残留待确认变更。
|
||||
updates["current_baseline_id"] = 0
|
||||
updates["pending_change_count"] = 0
|
||||
}
|
||||
}
|
||||
updates["content_check_mode"] = mode
|
||||
}
|
||||
if v, ok := raw["content_paths"].(string); ok {
|
||||
if v != task.ContentPaths {
|
||||
contentConfigChanged = true
|
||||
}
|
||||
updates["content_paths"] = v
|
||||
}
|
||||
if v, ok := raw["content_ignore_rules"].(string); ok {
|
||||
if v != task.ContentIgnoreRules {
|
||||
contentConfigChanged = true
|
||||
}
|
||||
updates["content_ignore_rules"] = v
|
||||
}
|
||||
if v, ok := raw["content_min_length"].(float64); ok {
|
||||
if int(v) != task.ContentMinLength {
|
||||
contentConfigChanged = true
|
||||
}
|
||||
updates["content_min_length"] = int(v)
|
||||
}
|
||||
if v, ok := raw["content_confirm_times"].(float64); ok {
|
||||
n := int(v)
|
||||
if n <= 0 {
|
||||
n = 2
|
||||
}
|
||||
updates["content_confirm_times"] = n
|
||||
}
|
||||
if v, ok := raw["content_sort_lines"].(bool); ok {
|
||||
if v != task.ContentSortLines {
|
||||
contentConfigChanged = true
|
||||
}
|
||||
updates["content_sort_lines"] = v
|
||||
}
|
||||
if contentConfigChanged {
|
||||
updates["current_baseline_id"] = 0
|
||||
updates["pending_change_count"] = 0
|
||||
}
|
||||
|
||||
if v, ok := raw["enabled"].(bool); ok {
|
||||
if v {
|
||||
updates["status"] = model.SiteTaskStatusRunning
|
||||
@@ -151,6 +203,13 @@ func (h *SiteHandler) deleteSiteTaskCascade(taskID uint) error {
|
||||
if err := tx.Where("task_id = ?", taskID).Delete(&model.SiteAlert{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 内容监控的子表一并清理,避免任务删除后残留孤立基线/变更。 // Logan.Li
|
||||
if err := tx.Where("task_id = ?", taskID).Delete(&model.SiteContentChange{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("task_id = ?", taskID).Delete(&model.SiteBaseline{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
tx.Where("category = ? AND target_id = ?", model.MonitorAlertSite, taskID).Delete(&model.MonitorAlertRule{})
|
||||
return tx.Delete(&model.SiteTask{}, taskID).Error
|
||||
})
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"ezops/middleware"
|
||||
"ezops/model"
|
||||
"ezops/pkg/siteprobe"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetTaskContent 返回某任务的内容监控视图:当前基线 + 待确认变更 + 结构化 diff。
|
||||
//
|
||||
// 前端详情页用这一个接口即可渲染"基线 / 待确认 / 并排对比"三块。 // Logan.Li
|
||||
// GET /api/v1/site/tasks/:id/content
|
||||
func (h *SiteHandler) GetTaskContent(c *gin.Context) {
|
||||
taskID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || taskID == 0 {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "任务 ID 无效"})
|
||||
return
|
||||
}
|
||||
|
||||
var task model.SiteTask
|
||||
if err := h.db.First(&task, taskID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "任务不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
var baseline model.SiteBaseline
|
||||
hasBaseline := false
|
||||
if task.CurrentBaselineID > 0 {
|
||||
if err := h.db.First(&baseline, task.CurrentBaselineID).Error; err == nil {
|
||||
hasBaseline = true
|
||||
}
|
||||
}
|
||||
|
||||
var pending model.SiteContentChange
|
||||
hasPending := false
|
||||
if err := h.db.Where("task_id = ? AND status = ?", task.ID, model.ContentChangePending).
|
||||
Order("id DESC").Take(&pending).Error; err == nil {
|
||||
hasPending = true
|
||||
}
|
||||
|
||||
var baselineCount int64
|
||||
h.db.Model(&model.SiteBaseline{}).Where("task_id = ?", task.ID).Count(&baselineCount)
|
||||
var pendingCount int64
|
||||
h.db.Model(&model.SiteContentChange{}).
|
||||
Where("task_id = ? AND status = ?", task.ID, model.ContentChangePending).Count(&pendingCount)
|
||||
|
||||
data := gin.H{
|
||||
"task_id": task.ID,
|
||||
"enabled": siteprobe.ContentCheckEnabled(task.ContentCheckMode),
|
||||
"mode": task.ContentCheckMode,
|
||||
"content_paths": task.ContentPaths,
|
||||
"content_ignore": task.ContentIgnoreRules,
|
||||
"min_length": task.ContentMinLength,
|
||||
"confirm_times": task.ContentConfirmTimes,
|
||||
"sort_lines": task.ContentSortLines,
|
||||
"baseline_count": baselineCount,
|
||||
"pending_change_count": pendingCount,
|
||||
"has_baseline": hasBaseline,
|
||||
"has_pending": hasPending,
|
||||
}
|
||||
if hasBaseline {
|
||||
data["baseline"] = baseline
|
||||
}
|
||||
if hasPending {
|
||||
data["pending"] = pending
|
||||
}
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "success", Data: data})
|
||||
}
|
||||
|
||||
// ListBaselines 内容基线版本历史。 // Logan.Li
|
||||
// GET /api/v1/site/tasks/:id/baselines
|
||||
func (h *SiteHandler) ListBaselines(c *gin.Context) {
|
||||
taskID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || taskID == 0 {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "任务 ID 无效"})
|
||||
return
|
||||
}
|
||||
var items []model.SiteBaseline
|
||||
h.db.Where("task_id = ?", taskID).Order("version DESC").Find(&items)
|
||||
if items == nil {
|
||||
items = []model.SiteBaseline{}
|
||||
}
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "success", Data: items})
|
||||
}
|
||||
|
||||
// ListContentChanges 内容变更记录(支持 status 过滤)。 // Logan.Li
|
||||
// GET /api/v1/site/tasks/:id/changes
|
||||
func (h *SiteHandler) ListContentChanges(c *gin.Context) {
|
||||
taskID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || taskID == 0 {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "任务 ID 无效"})
|
||||
return
|
||||
}
|
||||
q := h.db.Where("task_id = ?", taskID)
|
||||
if status := c.Query("status"); status != "" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
var items []model.SiteContentChange
|
||||
q.Order("id DESC").Limit(100).Find(&items)
|
||||
if items == nil {
|
||||
items = []model.SiteContentChange{}
|
||||
}
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "success", Data: items})
|
||||
}
|
||||
|
||||
// AcceptContentChange 接受某次变更作为新基线。
|
||||
//
|
||||
// 必须人工确认:真实上游变更与"我方采集配置错误"需要人来判断,
|
||||
// 全自动接受会让告警彻底失去意义。 // Logan.Li
|
||||
// POST /api/v1/site/changes/:id/accept
|
||||
func (h *SiteHandler) AcceptContentChange(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "变更 ID 无效"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Note string `json:"note"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
|
||||
user := middleware.GetUsername(c)
|
||||
if err := siteprobe.AcceptChange(h.db, uint(id), user, req.Note); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "已接受为新基线"})
|
||||
}
|
||||
|
||||
// IgnoreContentChange 将变更标记为误报忽略(不更新基线)。 // Logan.Li
|
||||
// POST /api/v1/site/changes/:id/ignore
|
||||
func (h *SiteHandler) IgnoreContentChange(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "变更 ID 无效"})
|
||||
return
|
||||
}
|
||||
user := middleware.GetUsername(c)
|
||||
if err := siteprobe.IgnoreChange(h.db, uint(id), user); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "已忽略该变更"})
|
||||
}
|
||||
@@ -219,5 +219,29 @@ func parseCreateTaskRequest(raw map[string]interface{}) model.SiteTask {
|
||||
if v, ok := raw["dns_resolver"].(string); ok {
|
||||
task.DNSResolver = strings.TrimSpace(v)
|
||||
}
|
||||
// 内容监控配置 // Logan.Li
|
||||
task.ContentCheckMode = normalizeContentMode(getStr("content_check_mode"))
|
||||
task.ContentPaths = getStr("content_paths")
|
||||
task.ContentIgnoreRules = getStr("content_ignore_rules")
|
||||
task.ContentMinLength = getInt(0, "content_min_length")
|
||||
task.ContentConfirmTimes = getInt(2, "content_confirm_times")
|
||||
if task.ContentConfirmTimes <= 0 {
|
||||
task.ContentConfirmTimes = 2
|
||||
}
|
||||
if v, ok := raw["content_sort_lines"].(bool); ok {
|
||||
task.ContentSortLines = v
|
||||
}
|
||||
return task
|
||||
}
|
||||
|
||||
// normalizeContentMode 把前端传来的模式归一化,非法值一律回退为 off。 // Logan.Li
|
||||
func normalizeContentMode(mode string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case "text":
|
||||
return siteprobe.ContentModeText
|
||||
case "json":
|
||||
return siteprobe.ContentModeJSON
|
||||
default:
|
||||
return siteprobe.ContentModeOff
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user