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
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ezops/model"
|
||||
"ezops/pkg/database"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -91,6 +92,20 @@ func All() []Migration {
|
||||
return executeDialectSQL(db, "0005_agent_settings")
|
||||
},
|
||||
},
|
||||
{
|
||||
Version: "0006_site_content_monitor",
|
||||
Description: "站点拨测内容监控(基线 + 变更)",
|
||||
Apply: func(db *gorm.DB) error {
|
||||
// 用 AutoMigrate 而非静态 SQL:它是幂等的。
|
||||
// 空库上 0000_baseline_schema 已经建好这些列与表,静态 ALTER 会因
|
||||
// duplicate column 失败;旧库则正好在这里补齐缺失的列/表。 // Logan.Li
|
||||
return db.AutoMigrate(
|
||||
&model.SiteTask{},
|
||||
&model.SiteBaseline{},
|
||||
&model.SiteContentChange{},
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,24 @@ type SiteTask struct {
|
||||
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
|
||||
DeletedAt *time.Time `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"`
|
||||
|
||||
// ── 内容监控(Content Monitor)────────────────────────────────
|
||||
// 区别于"可用性拨测":服务活着但返回内容变了,这里负责发现。 // Logan.Li
|
||||
ContentCheckMode string `gorm:"type:varchar(8);default:'off';comment:内容检查模式 off/text/json" json:"content_check_mode"`
|
||||
// ContentPaths JSON 模式要参与比对字段的 JSONPath,如 ["$.data.status","$.data.ips"]
|
||||
ContentPaths string `gorm:"type:text;comment:JSONPath字段列表(JSON数组)" json:"content_paths,omitempty"`
|
||||
// ContentIgnoreRules 忽略规则:文本按正则替换为空,避免时间戳/nonce 造成误报
|
||||
ContentIgnoreRules string `gorm:"type:text;comment:忽略规则(JSON数组,正则)" json:"content_ignore_rules,omitempty"`
|
||||
// ContentMinLength 小于该长度判为"采集异常"而非"内容变更",防止错误页误报
|
||||
ContentMinLength int `gorm:"default:0;comment:最小有效长度(字节)" json:"content_min_length"`
|
||||
// ContentConfirmTimes 连续 N 次不一致才判定变更,抑制抖动
|
||||
ContentConfirmTimes int `gorm:"default:2;comment:连续确认次数" json:"content_confirm_times"`
|
||||
// ContentSortLines 纯文本是否排序后比对(顺序无关的清单类接口应开启)
|
||||
ContentSortLines bool `gorm:"default:false;comment:文本排序后比对" json:"content_sort_lines"`
|
||||
// CurrentBaselineID 当前生效的基线版本
|
||||
CurrentBaselineID uint `gorm:"default:0;comment:当前基线ID" json:"current_baseline_id"`
|
||||
// PendingChangeCount 待确认的变更次数(用于防抖计数)
|
||||
PendingChangeCount int `gorm:"default:0;comment:连续变更计数" json:"pending_change_count"`
|
||||
|
||||
// 关联关系
|
||||
Results []SiteResult `gorm:"foreignKey:TaskID" json:"results,omitempty"`
|
||||
Alerts []SiteAlert `gorm:"foreignKey:TaskID" json:"alerts,omitempty"`
|
||||
@@ -265,3 +283,55 @@ type SiteResultQuery struct {
|
||||
Limit int `json:"limit" form:"limit"`
|
||||
Offset int `json:"offset" form:"offset"`
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 内容监控:基线版本 + 变更记录
|
||||
// ============================================
|
||||
|
||||
// SiteBaseline 内容基线(一个任务的多版本快照)。
|
||||
//
|
||||
// 首次采集自动建立 v1(source=auto),之后每次"接受新基线"新增一版(source=manual)。
|
||||
// 保留完整规范化内容,用于并排 diff 与回溯。 // Logan.Li
|
||||
type SiteBaseline struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
TaskID uint `gorm:"not null;index:idx_baseline_task;comment:任务ID" json:"task_id"`
|
||||
Version int `gorm:"not null;comment:版本号(从1递增)" json:"version"`
|
||||
ContentHash string `gorm:"type:varchar(64);not null;comment:规范化内容sha256" json:"content_hash"`
|
||||
ContentText string `gorm:"type:longtext;comment:规范化后的内容快照" json:"content_text,omitempty"`
|
||||
Source string `gorm:"type:varchar(16);default:'auto';comment:来源 auto/manual" json:"source"`
|
||||
AcceptedBy string `gorm:"type:varchar(128);comment:确认人" json:"accepted_by"`
|
||||
Note string `gorm:"type:varchar(512);comment:备注" json:"note"`
|
||||
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
|
||||
}
|
||||
|
||||
// TableName 指定表名 // Logan.Li
|
||||
func (SiteBaseline) TableName() string { return "monitor_site_baselines" }
|
||||
|
||||
// SiteContentChange 内容变更记录。
|
||||
//
|
||||
// Status 流转:pending(已发现,待处理)→ accepted(接受为新基线)/ ignored(误报忽略)。
|
||||
// DiffPayload 存结构化差异,前端并排视图直接渲染。 // Logan.Li
|
||||
type SiteContentChange struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
TaskID uint `gorm:"not null;index:idx_change_task" json:"task_id"`
|
||||
FromBaselineID uint `gorm:"not null;comment:变更前基线" json:"from_baseline_id"`
|
||||
FromVersion int `gorm:"comment:变更前版本号" json:"from_version"`
|
||||
ContentHash string `gorm:"type:varchar(64);comment:新内容hash" json:"content_hash"`
|
||||
ContentText string `gorm:"type:longtext;comment:新内容快照" json:"content_text,omitempty"`
|
||||
DiffType string `gorm:"type:varchar(16);comment:text_line/json_field" json:"diff_type"`
|
||||
DiffPayload string `gorm:"type:longtext;comment:结构化diff(JSON)" json:"diff_payload,omitempty"`
|
||||
Status string `gorm:"type:varchar(16);default:'pending';index;comment:状态" json:"status"`
|
||||
HandledBy string `gorm:"type:varchar(128);comment:处理人" json:"handled_by,omitempty"`
|
||||
DetectedAt time.Time `gorm:"not null;comment:发现时间" json:"detected_at"`
|
||||
HandledAt *time.Time `json:"handled_at,omitempty"`
|
||||
}
|
||||
|
||||
// TableName 指定表名 // Logan.Li
|
||||
func (SiteContentChange) TableName() string { return "monitor_site_content_changes" }
|
||||
|
||||
// 内容变更状态常量 // Logan.Li
|
||||
const (
|
||||
ContentChangePending = "pending"
|
||||
ContentChangeAccepted = "accepted"
|
||||
ContentChangeIgnored = "ignored"
|
||||
)
|
||||
@@ -171,6 +171,8 @@ func BootstrapSchema(db *gorm.DB) error {
|
||||
&model.SiteTask{}, // 站点拨测任务 // Logan.Li
|
||||
&model.SiteResult{}, // 拨测结果 // Logan.Li
|
||||
&model.SiteAlert{}, // 拨测告警 // Logan.Li
|
||||
&model.SiteBaseline{}, // 内容监控基线 // Logan.Li
|
||||
&model.SiteContentChange{}, // 内容变更记录 // Logan.Li
|
||||
&model.MonitorAlertRule{}, // 中间件/站点拨测告警规则 // Logan.Li
|
||||
&model.PrometheusDataSource{}, // Prometheus 数据源配置 // Logan.Li
|
||||
&model.ZabbixDataSource{}, // Zabbix 数据源配置 // Logan.Li
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
// 内容监控核心:规范化 → 哈希 → 差异计算。
|
||||
//
|
||||
// 设计目标:判断"接口返回的数据是否发生变化",而不是"服务是否可用"。
|
||||
// 因此必须先区分两种情况:
|
||||
// - 采集异常(状态码非 2xx、空响应、错误页)→ 不应报"内容变更"
|
||||
// - 内容变更(拿到了有效数据但与基线不同)→ 才告警
|
||||
//
|
||||
// 规范化是关键:时间戳、nonce、trace_id 这类每次必变的字段必须先剔除,
|
||||
// 否则会产生大量误报。 // Logan.Li
|
||||
package siteprobe
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ContentCheckMode 内容检查模式。 // Logan.Li
|
||||
const (
|
||||
ContentModeOff = "off"
|
||||
ContentModeText = "text"
|
||||
ContentModeJSON = "json"
|
||||
)
|
||||
|
||||
// PathValue JSONPath 提取出的单个值。 // Logan.Li
|
||||
type PathValue struct {
|
||||
Path string
|
||||
Value any
|
||||
}
|
||||
|
||||
// ContentSnapshot 规范化后的内容快照。 // Logan.Li
|
||||
type ContentSnapshot struct {
|
||||
// Canonical 用于计算哈希的规范化文本
|
||||
Canonical string
|
||||
// Hash 规范化文本的 sha256
|
||||
Hash string
|
||||
// Mode 产生该快照的模式
|
||||
Mode string
|
||||
// Invalid 非空表示采集异常(此时不应参与变更比对)
|
||||
Invalid string
|
||||
}
|
||||
|
||||
// BuildSnapshot 按任务配置把原始响应体规范化为可哈希的快照。
|
||||
//
|
||||
// 返回 Invalid 非空表示"采集异常",调用方应将其视为拨测失败而不是内容变更。 // Logan.Li
|
||||
func BuildSnapshot(mode, body string, paths, ignoreRules []string, minLength int, sortLines bool) ContentSnapshot {
|
||||
snap := ContentSnapshot{Mode: mode}
|
||||
|
||||
if mode == ContentModeOff {
|
||||
snap.Invalid = "内容检查未启用"
|
||||
return snap
|
||||
}
|
||||
trimmed := strings.TrimSpace(body)
|
||||
if trimmed == "" {
|
||||
snap.Invalid = "响应体为空"
|
||||
return snap
|
||||
}
|
||||
// 最小长度用于挡住 CDN 拦截页、网关错误页这类"200 但内容不是数据"的情况。 // Logan.Li
|
||||
if minLength > 0 && len(trimmed) < minLength {
|
||||
snap.Invalid = fmt.Sprintf("响应体长度 %d 小于最小有效长度 %d", len(trimmed), minLength)
|
||||
return snap
|
||||
}
|
||||
|
||||
var canonical string
|
||||
var err error
|
||||
switch mode {
|
||||
case ContentModeJSON:
|
||||
canonical, err = normalizeJSON(trimmed, paths, ignoreRules)
|
||||
default:
|
||||
canonical = normalizeText(trimmed, ignoreRules, sortLines)
|
||||
}
|
||||
if err != nil {
|
||||
snap.Invalid = "内容规范化失败: " + err.Error()
|
||||
return snap
|
||||
}
|
||||
if strings.TrimSpace(canonical) == "" {
|
||||
snap.Invalid = "规范化后内容为空"
|
||||
return snap
|
||||
}
|
||||
|
||||
snap.Canonical = canonical
|
||||
sum := sha256.Sum256([]byte(canonical))
|
||||
snap.Hash = hex.EncodeToString(sum[:])
|
||||
return snap
|
||||
}
|
||||
|
||||
// normalizeText 文本模式规范化:
|
||||
// 1. 统一换行、逐行去首尾空白、丢弃空行
|
||||
// 2. 按忽略规则做正则替换(时间戳/nonce 等)
|
||||
// 3. 可选排序(顺序无关的清单类接口,如 IP 段列表) // Logan.Li
|
||||
func normalizeText(body string, ignoreRules []string, sortLines bool) string {
|
||||
body = strings.ReplaceAll(body, "\r\n", "\n")
|
||||
body = strings.ReplaceAll(body, "\r", "\n")
|
||||
|
||||
for _, rule := range ignoreRules {
|
||||
rule = strings.TrimSpace(rule)
|
||||
if rule == "" {
|
||||
continue
|
||||
}
|
||||
if re, err := regexp.Compile(rule); err == nil {
|
||||
body = re.ReplaceAllString(body, "")
|
||||
}
|
||||
}
|
||||
|
||||
lines := make([]string, 0, 64)
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
if sortLines {
|
||||
sort.Strings(lines)
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// normalizeJSON JSON 模式规范化:只保留 JSONPath 指定的字段,其余一律不参与比对。
|
||||
//
|
||||
// 这比"忽略规则"更可靠——没被点名的字段根本进不了哈希。 // Logan.Li
|
||||
func normalizeJSON(body string, paths, ignoreRules []string) (string, error) {
|
||||
var root any
|
||||
if err := json.Unmarshal([]byte(body), &root); err != nil {
|
||||
return "", fmt.Errorf("非法 JSON: %w", err)
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return "", fmt.Errorf("JSON 模式必须至少配置一个 JSONPath")
|
||||
}
|
||||
|
||||
flat := map[string]any{}
|
||||
for _, p := range paths {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
values, err := EvalPath(root, p)
|
||||
if err != nil {
|
||||
// 字段不存在不算致命错误,但要体现在快照里,
|
||||
// 这样"字段从有到无"也能被识别为变更。 // Logan.Li
|
||||
flat[p] = "<missing>"
|
||||
continue
|
||||
}
|
||||
if len(values) == 1 {
|
||||
flat[p] = scrubValue(values[0], ignoreRules)
|
||||
} else {
|
||||
list := make([]any, 0, len(values))
|
||||
for _, v := range values {
|
||||
list = append(list, scrubValue(v, ignoreRules))
|
||||
}
|
||||
flat[p] = list
|
||||
}
|
||||
}
|
||||
|
||||
// json.Marshal 对 map 的 key 自动排序,天然得到稳定输出。 // Logan.Li
|
||||
out, err := json.Marshal(flat)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// scrubValue 对提取出的字段值应用忽略规则(字符串值按正则替换)。 // Logan.Li
|
||||
func scrubValue(v any, ignoreRules []string) any {
|
||||
s, ok := v.(string)
|
||||
if !ok || len(ignoreRules) == 0 {
|
||||
return v
|
||||
}
|
||||
for _, rule := range ignoreRules {
|
||||
rule = strings.TrimSpace(rule)
|
||||
if rule == "" {
|
||||
continue
|
||||
}
|
||||
if re, err := regexp.Compile(rule); err == nil {
|
||||
s = re.ReplaceAllString(s, "")
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ─────────────────────── JSONPath(轻量实现)───────────────────────
|
||||
//
|
||||
// 支持语法(覆盖绝大多数接口监控场景):
|
||||
// $.data.status 取字段
|
||||
// $.data.ips[0] 取数组下标
|
||||
// $.data.ips[*].ip 通配遍历数组
|
||||
// $[0] 根为数组时取下标
|
||||
//
|
||||
// 不支持过滤表达式 [?()]、递归下降 ..、切片 [1:3]。
|
||||
// 刻意不引第三方库:实现只有几十行,且避免为一个小功能增加依赖风险。 // Logan.Li
|
||||
|
||||
// EvalPath 计算 JSONPath,返回匹配到的所有值。 // Logan.Li
|
||||
func EvalPath(root any, path string) ([]any, error) {
|
||||
segs, err := parsePath(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current := []any{root}
|
||||
for _, seg := range segs {
|
||||
next := make([]any, 0, len(current))
|
||||
for _, node := range current {
|
||||
switch seg.kind {
|
||||
case segField:
|
||||
if m, ok := node.(map[string]any); ok {
|
||||
if v, exists := m[seg.name]; exists {
|
||||
next = append(next, v)
|
||||
}
|
||||
}
|
||||
case segIndex:
|
||||
if arr, ok := node.([]any); ok && seg.index >= 0 && seg.index < len(arr) {
|
||||
next = append(next, arr[seg.index])
|
||||
}
|
||||
case segWildcard:
|
||||
if arr, ok := node.([]any); ok {
|
||||
next = append(next, arr...)
|
||||
} else if m, ok := node.(map[string]any); ok {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
next = append(next, m[k])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(next) == 0 {
|
||||
return nil, fmt.Errorf("路径未命中: %s", path)
|
||||
}
|
||||
current = next
|
||||
}
|
||||
return current, nil
|
||||
}
|
||||
|
||||
const (
|
||||
segField = iota
|
||||
segIndex
|
||||
segWildcard
|
||||
)
|
||||
|
||||
type pathSegment struct {
|
||||
kind int
|
||||
name string
|
||||
index int
|
||||
}
|
||||
|
||||
// parsePath 把 "$.a.b[0].c" 解析成段序列。 // Logan.Li
|
||||
func parsePath(path string) ([]pathSegment, error) {
|
||||
p := strings.TrimSpace(path)
|
||||
if p == "" {
|
||||
return nil, fmt.Errorf("JSONPath 为空")
|
||||
}
|
||||
if strings.HasPrefix(p, "$") {
|
||||
p = p[1:]
|
||||
}
|
||||
segs := []pathSegment{}
|
||||
i := 0
|
||||
for i < len(p) {
|
||||
switch p[i] {
|
||||
case '.':
|
||||
i++
|
||||
start := i
|
||||
for i < len(p) && p[i] != '.' && p[i] != '[' {
|
||||
i++
|
||||
}
|
||||
if i > start {
|
||||
segs = append(segs, pathSegment{kind: segField, name: p[start:i]})
|
||||
}
|
||||
case '[':
|
||||
end := strings.IndexByte(p[i:], ']')
|
||||
if end < 0 {
|
||||
return nil, fmt.Errorf("JSONPath 方括号未闭合: %s", path)
|
||||
}
|
||||
inner := strings.TrimSpace(p[i+1 : i+end])
|
||||
if inner == "*" {
|
||||
segs = append(segs, pathSegment{kind: segWildcard})
|
||||
} else if n, err := strconv.Atoi(inner); err == nil {
|
||||
segs = append(segs, pathSegment{kind: segIndex, index: n})
|
||||
} else {
|
||||
// ["key"] / ['key'] 写法
|
||||
inner = strings.Trim(inner, `"'`)
|
||||
segs = append(segs, pathSegment{kind: segField, name: inner})
|
||||
}
|
||||
i += end + 1
|
||||
default:
|
||||
// 省略前导点的写法(如 "data.status")
|
||||
start := i
|
||||
for i < len(p) && p[i] != '.' && p[i] != '[' {
|
||||
i++
|
||||
}
|
||||
segs = append(segs, pathSegment{kind: segField, name: p[start:i]})
|
||||
}
|
||||
}
|
||||
if len(segs) == 0 {
|
||||
return nil, fmt.Errorf("无效 JSONPath: %s", path)
|
||||
}
|
||||
return segs, nil
|
||||
}
|
||||
|
||||
// ─────────────────────── 差异计算 ───────────────────────
|
||||
|
||||
// TextDiffLine 文本行级差异(供并排视图渲染)。 // Logan.Li
|
||||
type TextDiffLine struct {
|
||||
Type string `json:"type"` // same / add / del
|
||||
Old string `json:"old,omitempty"`
|
||||
New string `json:"new,omitempty"`
|
||||
}
|
||||
|
||||
// FieldDiff 字段级差异(JSON 模式)。 // Logan.Li
|
||||
type FieldDiff struct {
|
||||
Path string `json:"path"`
|
||||
Old string `json:"old"`
|
||||
New string `json:"new"`
|
||||
Kind string `json:"kind"` // changed / added / removed
|
||||
}
|
||||
|
||||
// DiffText 基于 LCS 的行级差异,规模受行数平方限制,
|
||||
// 对接口响应这种量级(通常几十到几千行)足够。 // Logan.Li
|
||||
func DiffText(oldText, newText string) []TextDiffLine {
|
||||
oldLines := splitLines(oldText)
|
||||
newLines := splitLines(newText)
|
||||
|
||||
n, m := len(oldLines), len(newLines)
|
||||
// 行数过大时退化为整体替换,避免 O(n*m) 内存爆掉。 // Logan.Li
|
||||
if n*m > 4_000_000 {
|
||||
out := make([]TextDiffLine, 0, n+m)
|
||||
for _, l := range oldLines {
|
||||
out = append(out, TextDiffLine{Type: "del", Old: l})
|
||||
}
|
||||
for _, l := range newLines {
|
||||
out = append(out, TextDiffLine{Type: "add", New: l})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
lcs := make([][]int, n+1)
|
||||
for i := range lcs {
|
||||
lcs[i] = make([]int, m+1)
|
||||
}
|
||||
for i := n - 1; i >= 0; i-- {
|
||||
for j := m - 1; j >= 0; j-- {
|
||||
if oldLines[i] == newLines[j] {
|
||||
lcs[i][j] = lcs[i+1][j+1] + 1
|
||||
} else if lcs[i+1][j] >= lcs[i][j+1] {
|
||||
lcs[i][j] = lcs[i+1][j]
|
||||
} else {
|
||||
lcs[i][j] = lcs[i][j+1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out := []TextDiffLine{}
|
||||
i, j := 0, 0
|
||||
for i < n && j < m {
|
||||
switch {
|
||||
case oldLines[i] == newLines[j]:
|
||||
out = append(out, TextDiffLine{Type: "same", Old: oldLines[i], New: newLines[j]})
|
||||
i++
|
||||
j++
|
||||
case lcs[i+1][j] >= lcs[i][j+1]:
|
||||
out = append(out, TextDiffLine{Type: "del", Old: oldLines[i]})
|
||||
i++
|
||||
default:
|
||||
out = append(out, TextDiffLine{Type: "add", New: newLines[j]})
|
||||
j++
|
||||
}
|
||||
}
|
||||
for ; i < n; i++ {
|
||||
out = append(out, TextDiffLine{Type: "del", Old: oldLines[i]})
|
||||
}
|
||||
for ; j < m; j++ {
|
||||
out = append(out, TextDiffLine{Type: "add", New: newLines[j]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DiffJSONFields 字段级差异:两份规范化 JSON 逐 key 比对。 // Logan.Li
|
||||
func DiffJSONFields(oldText, newText string) []FieldDiff {
|
||||
oldMap := map[string]any{}
|
||||
newMap := map[string]any{}
|
||||
_ = json.Unmarshal([]byte(oldText), &oldMap)
|
||||
_ = json.Unmarshal([]byte(newText), &newMap)
|
||||
|
||||
keys := map[string]struct{}{}
|
||||
for k := range oldMap {
|
||||
keys[k] = struct{}{}
|
||||
}
|
||||
for k := range newMap {
|
||||
keys[k] = struct{}{}
|
||||
}
|
||||
sorted := make([]string, 0, len(keys))
|
||||
for k := range keys {
|
||||
sorted = append(sorted, k)
|
||||
}
|
||||
sort.Strings(sorted)
|
||||
|
||||
out := []FieldDiff{}
|
||||
for _, k := range sorted {
|
||||
ov, oOK := oldMap[k]
|
||||
nv, nOK := newMap[k]
|
||||
os := stringifyValue(ov)
|
||||
ns := stringifyValue(nv)
|
||||
switch {
|
||||
case oOK && !nOK:
|
||||
out = append(out, FieldDiff{Path: k, Old: os, New: "", Kind: "removed"})
|
||||
case !oOK && nOK:
|
||||
out = append(out, FieldDiff{Path: k, Old: "", New: ns, Kind: "added"})
|
||||
case os != ns:
|
||||
out = append(out, FieldDiff{Path: k, Old: os, New: ns, Kind: "changed"})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func splitLines(s string) []string {
|
||||
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(s, "\n")
|
||||
}
|
||||
|
||||
// stringifyValue 把任意值转成便于展示的字符串。 // Logan.Li
|
||||
func stringifyValue(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// ParseRules 把前端传来的 JSON 数组或换行分隔文本解析成规则列表,
|
||||
// 兼容用户直接粘贴多行正则的写法。 // Logan.Li
|
||||
func ParseRules(raw string) []string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
var arr []string
|
||||
if strings.HasPrefix(raw, "[") && json.Unmarshal([]byte(raw), &arr) == nil {
|
||||
return arr
|
||||
}
|
||||
out := []string{}
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
if line = strings.TrimSpace(line); line != "" {
|
||||
out = append(out, line)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
// 内容监控的落库逻辑:基线建立、变更判定、防抖、人工处理。
|
||||
//
|
||||
// 判定原则(避免误报的核心):
|
||||
// 1. 拨测本身失败(连不上/超时/非预期状态码)→ 完全不参与内容比对
|
||||
// 2. 拿到响应但规范化判定为"采集异常"(空/过短/非 JSON)→ 也不参与比对
|
||||
// 3. 只有"拿到了有效数据且与基线不同"才算内容变更
|
||||
package siteprobe
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"ezops/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// ApplyContentCheck 对一次成功拨测的响应体执行内容比对。
|
||||
//
|
||||
// 返回 changed=true 表示本次达到确认阈值、已写入变更记录(应触发告警)。 // Logan.Li
|
||||
func ApplyContentCheck(db *gorm.DB, task *model.SiteTask, body string, probeOK bool) (bool, error) {
|
||||
if task == nil || !ContentCheckEnabled(task.ContentCheckMode) {
|
||||
return false, nil
|
||||
}
|
||||
// 拨测本身失败时不做内容判定:否则网络抖动会被误报成"内容变更"。 // Logan.Li
|
||||
if !probeOK {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
snap := BuildSnapshot(
|
||||
task.ContentCheckMode,
|
||||
body,
|
||||
ParseRules(task.ContentPaths),
|
||||
ParseRules(task.ContentIgnoreRules),
|
||||
task.ContentMinLength,
|
||||
task.ContentSortLines,
|
||||
)
|
||||
if snap.Invalid != "" {
|
||||
// 采集异常:既不算变更,也不覆盖基线,等下次恢复。
|
||||
return false, fmt.Errorf("内容采集异常: %s", snap.Invalid)
|
||||
}
|
||||
|
||||
// 无基线 → 自动建立 v1(按用户选择采用"自动接受",否则新任务永远跑不动)。
|
||||
// 必须用行锁串行化:RunTaskNow 与后台 worker 可能同时拨测,
|
||||
// 不加锁会各自看到 current_baseline_id=0 并各建一条基线(实测出现 v1/v2 重复)。 // Logan.Li
|
||||
if task.CurrentBaselineID == 0 {
|
||||
return false, db.Transaction(func(tx *gorm.DB) error {
|
||||
var locked model.SiteTask
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&locked, task.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if locked.CurrentBaselineID != 0 {
|
||||
// 已被其它协程建立,本次直接返回。 // Logan.Li
|
||||
task.CurrentBaselineID = locked.CurrentBaselineID
|
||||
return nil
|
||||
}
|
||||
baseline, err := CreateBaseline(tx, task.ID, snap, "auto", "", "首次采集自动建立基线")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&model.SiteTask{}).Where("id = ?", task.ID).
|
||||
Updates(map[string]interface{}{
|
||||
"current_baseline_id": baseline.ID,
|
||||
"pending_change_count": 0,
|
||||
}).Error
|
||||
})
|
||||
}
|
||||
|
||||
var baseline model.SiteBaseline
|
||||
if err := db.First(&baseline, task.CurrentBaselineID).Error; err != nil {
|
||||
// 基线丢了(被清理/迁移),重新建立而不是刷变更。 // Logan.Li
|
||||
nb, cerr := CreateBaseline(db, task.ID, snap, "auto", "", "基线缺失,自动重建")
|
||||
if cerr != nil {
|
||||
return false, cerr
|
||||
}
|
||||
db.Model(&model.SiteTask{}).Where("id = ?", task.ID).
|
||||
Update("current_baseline_id", nb.ID)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// 内容与基线一致 → 重置连续计数,说明之前的"变更"是抖动。
|
||||
if snap.Hash == baseline.ContentHash {
|
||||
if task.PendingChangeCount > 0 {
|
||||
db.Model(&model.SiteTask{}).Where("id = ?", task.ID).
|
||||
Update("pending_change_count", 0)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// 内容不同 → 累计连续次数,达到阈值才落变更记录并告警。 // Logan.Li
|
||||
confirmTimes := task.ContentConfirmTimes
|
||||
if confirmTimes <= 0 {
|
||||
confirmTimes = 2
|
||||
}
|
||||
pending := task.PendingChangeCount + 1
|
||||
if pending < confirmTimes {
|
||||
db.Model(&model.SiteTask{}).Where("id = ?", task.ID).
|
||||
Update("pending_change_count", pending)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// 同一 hash 的 pending 变更已存在时不重复创建,避免连续两轮各写一条。 // Logan.Li
|
||||
var existing model.SiteContentChange
|
||||
if err := db.Where("task_id = ? AND status = ? AND content_hash = ?",
|
||||
task.ID, model.ContentChangePending, snap.Hash).
|
||||
Order("id DESC").Take(&existing).Error; err == nil {
|
||||
db.Model(&model.SiteTask{}).Where("id = ?", task.ID).
|
||||
Update("pending_change_count", 0)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
diffType := "text_line"
|
||||
var payload any
|
||||
if task.ContentCheckMode == ContentModeJSON {
|
||||
diffType = "json_field"
|
||||
payload = DiffJSONFields(baseline.ContentText, snap.Canonical)
|
||||
} else {
|
||||
payload = DiffText(baseline.ContentText, snap.Canonical)
|
||||
}
|
||||
diffJSON, _ := json.Marshal(payload)
|
||||
|
||||
change := model.SiteContentChange{
|
||||
TaskID: task.ID,
|
||||
FromBaselineID: baseline.ID,
|
||||
FromVersion: baseline.Version,
|
||||
ContentHash: snap.Hash,
|
||||
ContentText: snap.Canonical,
|
||||
DiffType: diffType,
|
||||
DiffPayload: string(diffJSON),
|
||||
Status: model.ContentChangePending,
|
||||
DetectedAt: time.Now(),
|
||||
}
|
||||
if err := db.Create(&change).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
db.Model(&model.SiteTask{}).Where("id = ?", task.ID).
|
||||
Update("pending_change_count", 0)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// CreateBaseline 追加一个新版本基线。 // Logan.Li
|
||||
func CreateBaseline(db *gorm.DB, taskID uint, snap ContentSnapshot, source, acceptedBy, note string) (*model.SiteBaseline, error) {
|
||||
var maxVersion int
|
||||
db.Model(&model.SiteBaseline{}).Where("task_id = ?", taskID).
|
||||
Select("COALESCE(MAX(version), 0)").Scan(&maxVersion)
|
||||
|
||||
baseline := model.SiteBaseline{
|
||||
TaskID: taskID,
|
||||
Version: maxVersion + 1,
|
||||
ContentHash: snap.Hash,
|
||||
ContentText: snap.Canonical,
|
||||
Source: source,
|
||||
AcceptedBy: acceptedBy,
|
||||
Note: note,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := db.Create(&baseline).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &baseline, nil
|
||||
}
|
||||
|
||||
// AcceptChange 把某次变更接受为新基线。
|
||||
//
|
||||
// 只有人工确认才走这里:真实变更(上游确实改了)与误报(我方配置问题)需人工判断,
|
||||
// 自动接受会让告警失去意义。 // Logan.Li
|
||||
func AcceptChange(db *gorm.DB, changeID uint, acceptedBy, note string) error {
|
||||
var change model.SiteContentChange
|
||||
if err := db.First(&change, changeID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if change.Status != model.ContentChangePending {
|
||||
return fmt.Errorf("该变更已处理(当前状态 %s)", change.Status)
|
||||
}
|
||||
|
||||
var task model.SiteTask
|
||||
if err := db.First(&task, change.TaskID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
snap := ContentSnapshot{
|
||||
Mode: task.ContentCheckMode,
|
||||
Canonical: change.ContentText,
|
||||
Hash: change.ContentHash,
|
||||
}
|
||||
baseline, err := CreateBaseline(db, change.TaskID, snap, "manual", acceptedBy, note)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&model.SiteContentChange{}).Where("id = ?", change.ID).
|
||||
Updates(map[string]interface{}{
|
||||
"status": model.ContentChangeAccepted,
|
||||
"handled_by": acceptedBy,
|
||||
"handled_at": now,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 同一基线的其它 pending 变更一并作废,避免重复要求确认。
|
||||
if err := tx.Model(&model.SiteContentChange{}).
|
||||
Where("task_id = ? AND status = ? AND from_baseline_id = ?",
|
||||
change.TaskID, model.ContentChangePending, change.FromBaselineID).
|
||||
Updates(map[string]interface{}{
|
||||
"status": model.ContentChangeAccepted,
|
||||
"handled_by": acceptedBy,
|
||||
"handled_at": now,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&model.SiteTask{}).Where("id = ?", change.TaskID).
|
||||
Updates(map[string]interface{}{
|
||||
"current_baseline_id": baseline.ID,
|
||||
"pending_change_count": 0,
|
||||
}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// IgnoreChange 把变更标记为误报忽略(不更新基线)。
|
||||
//
|
||||
// 忽略后如果内容仍未恢复,下一轮会再次累积到阈值并重新产生变更记录,
|
||||
// 这是期望行为——避免"忽略一次就永久静默"。 // Logan.Li
|
||||
func IgnoreChange(db *gorm.DB, changeID uint, handledBy string) error {
|
||||
var change model.SiteContentChange
|
||||
if err := db.First(&change, changeID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if change.Status != model.ContentChangePending {
|
||||
return fmt.Errorf("该变更已处理(当前状态 %s)", change.Status)
|
||||
}
|
||||
now := time.Now()
|
||||
return db.Model(&model.SiteContentChange{}).Where("id = ?", change.ID).
|
||||
Updates(map[string]interface{}{
|
||||
"status": model.ContentChangeIgnored,
|
||||
"handled_by": handledBy,
|
||||
"handled_at": now,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// ContentCheckEnabled 判断任务是否启用了内容监控。 // Logan.Li
|
||||
func ContentCheckEnabled(mode string) bool {
|
||||
return mode == ContentModeText || mode == ContentModeJSON
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package siteprobe
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestEvalPath 覆盖接口监控最常见的几种 JSONPath 写法。 // Logan.Li
|
||||
func TestEvalPath(t *testing.T) {
|
||||
doc := map[string]any{
|
||||
"data": map[string]any{
|
||||
"status": "ok",
|
||||
"ips": []any{
|
||||
map[string]any{"ip": "1.1.1.1"},
|
||||
map[string]any{"ip": "2.2.2.2"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{"$.data.status", "ok"},
|
||||
{"data.status", "ok"},
|
||||
{"$.data.ips[0].ip", "1.1.1.1"},
|
||||
{"$['data']['status']", "ok"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := EvalPath(doc, c.path)
|
||||
if err != nil {
|
||||
t.Fatalf("%s 解析失败: %v", c.path, err)
|
||||
}
|
||||
if len(got) != 1 || got[0] != c.want {
|
||||
t.Fatalf("%s 期望 %q,实际 %v", c.path, c.want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// 通配
|
||||
got, err := EvalPath(doc, "$.data.ips[*].ip")
|
||||
if err != nil || len(got) != 2 {
|
||||
t.Fatalf("通配解析失败: %v %v", got, err)
|
||||
}
|
||||
|
||||
// 未命中应返回错误
|
||||
if _, err := EvalPath(doc, "$.data.nope"); err == nil {
|
||||
t.Fatal("未命中路径应返回错误")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeTextIgnoresVolatileFields 时间戳变化不应触发内容变更。 // Logan.Li
|
||||
func TestNormalizeTextIgnoresVolatileFields(t *testing.T) {
|
||||
a := "173.245.48.0/20\nupdated_at: 2026-09-14T10:00:00Z"
|
||||
b := "173.245.48.0/20\nupdated_at: 2026-09-15T22:31:09Z"
|
||||
rules := []string{`updated_at:.*`}
|
||||
|
||||
sa := BuildSnapshot(ContentModeText, a, nil, rules, 0, false)
|
||||
sb := BuildSnapshot(ContentModeText, b, nil, rules, 0, false)
|
||||
if sa.Invalid != "" || sb.Invalid != "" {
|
||||
t.Fatalf("快照构建失败: %s / %s", sa.Invalid, sb.Invalid)
|
||||
}
|
||||
if sa.Hash != sb.Hash {
|
||||
t.Fatalf("仅时间戳不同,哈希应一致: %s != %s", sa.Canonical, sb.Canonical)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeTextSortLines IP 段顺序变化不应视为变更。 // Logan.Li
|
||||
func TestNormalizeTextSortLines(t *testing.T) {
|
||||
a := "103.21.244.0/22\n173.245.48.0/20"
|
||||
b := "173.245.48.0/20\n103.21.244.0/22"
|
||||
|
||||
unsortedA := BuildSnapshot(ContentModeText, a, nil, nil, 0, false)
|
||||
unsortedB := BuildSnapshot(ContentModeText, b, nil, nil, 0, false)
|
||||
if unsortedA.Hash == unsortedB.Hash {
|
||||
t.Fatal("未排序时顺序不同应视为不同内容")
|
||||
}
|
||||
|
||||
sortedA := BuildSnapshot(ContentModeText, a, nil, nil, 0, true)
|
||||
sortedB := BuildSnapshot(ContentModeText, b, nil, nil, 0, true)
|
||||
if sortedA.Hash != sortedB.Hash {
|
||||
t.Fatalf("排序后应一致: %s / %s", sortedA.Canonical, sortedB.Canonical)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildSnapshotInvalid 采集异常不应被当成内容变更。 // Logan.Li
|
||||
func TestBuildSnapshotInvalid(t *testing.T) {
|
||||
// 空响应
|
||||
if s := BuildSnapshot(ContentModeText, " ", nil, nil, 0, false); s.Invalid == "" {
|
||||
t.Fatal("空响应应判为采集异常")
|
||||
}
|
||||
// 过短(模拟 CDN 拦截页)
|
||||
if s := BuildSnapshot(ContentModeText, "Access denied", nil, nil, 100, false); s.Invalid == "" {
|
||||
t.Fatal("短于最小长度应判为采集异常")
|
||||
}
|
||||
// JSON 模式拿到 HTML
|
||||
if s := BuildSnapshot(ContentModeJSON, "<html>error</html>", []string{"$.a"}, nil, 0, false); s.Invalid == "" {
|
||||
t.Fatal("非 JSON 应判为采集异常")
|
||||
}
|
||||
// 正常
|
||||
if s := BuildSnapshot(ContentModeText, "1.1.1.1\n2.2.2.2", nil, nil, 5, false); s.Invalid != "" {
|
||||
t.Fatalf("正常内容不应报错: %s", s.Invalid)
|
||||
}
|
||||
}
|
||||
|
||||
// TestJSONModeOnlyWatchesSelectedFields 未被点名的字段变化不应影响哈希。 // Logan.Li
|
||||
func TestJSONModeOnlyWatchesSelectedFields(t *testing.T) {
|
||||
a := `{"data":{"status":"ok","trace_id":"aaa"},"ts":1}`
|
||||
b := `{"data":{"status":"ok","trace_id":"bbb"},"ts":2}`
|
||||
|
||||
sa := BuildSnapshot(ContentModeJSON, a, []string{"$.data.status"}, nil, 0, false)
|
||||
sb := BuildSnapshot(ContentModeJSON, b, []string{"$.data.status"}, nil, 0, false)
|
||||
if sa.Hash != sb.Hash {
|
||||
t.Fatalf("只监控 $.data.status 时 trace_id/ts 变化不应影响: %s / %s", sa.Canonical, sb.Canonical)
|
||||
}
|
||||
|
||||
// status 变化必须被识别
|
||||
c := `{"data":{"status":"degraded","trace_id":"aaa"},"ts":1}`
|
||||
sc := BuildSnapshot(ContentModeJSON, c, []string{"$.data.status"}, nil, 0, false)
|
||||
if sc.Hash == sa.Hash {
|
||||
t.Fatal("status 变化应改变哈希")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiffText 行级差异。 // Logan.Li
|
||||
func TestDiffText(t *testing.T) {
|
||||
old := "a\nb\nc"
|
||||
new := "a\nc\nd"
|
||||
diff := DiffText(old, new)
|
||||
var adds, dels int
|
||||
for _, d := range diff {
|
||||
switch d.Type {
|
||||
case "add":
|
||||
adds++
|
||||
case "del":
|
||||
dels++
|
||||
}
|
||||
}
|
||||
if adds != 1 || dels != 1 {
|
||||
t.Fatalf("期望 1 增 1 删,实际 add=%d del=%d: %+v", adds, dels, diff)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiffJSONFields 字段级差异。 // Logan.Li
|
||||
func TestDiffJSONFields(t *testing.T) {
|
||||
old := `{"$.data.status":"ok","$.data.level":1}`
|
||||
new := `{"$.data.status":"degraded","$.data.level":1,"$.data.extra":"x"}`
|
||||
diff := DiffJSONFields(old, new)
|
||||
|
||||
var changed, added int
|
||||
for _, d := range diff {
|
||||
if d.Kind == "changed" && d.Path == "$.data.status" {
|
||||
changed++
|
||||
if d.Old != "ok" || d.New != "degraded" {
|
||||
t.Fatalf("字段值错误: %+v", d)
|
||||
}
|
||||
}
|
||||
if d.Kind == "added" && d.Path == "$.data.extra" {
|
||||
added++
|
||||
}
|
||||
}
|
||||
if changed != 1 || added != 1 {
|
||||
t.Fatalf("期望 1 改 1 增,实际: %+v", diff)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseRules 兼容 JSON 数组与多行文本两种输入。 // Logan.Li
|
||||
func TestParseRules(t *testing.T) {
|
||||
if r := ParseRules(`["a","b"]`); len(r) != 2 {
|
||||
t.Fatalf("JSON 数组解析失败: %v", r)
|
||||
}
|
||||
if r := ParseRules("a\nb\n"); len(r) != 2 {
|
||||
t.Fatalf("多行解析失败: %v", r)
|
||||
}
|
||||
if r := ParseRules(" "); r != nil {
|
||||
t.Fatalf("空输入应返回 nil: %v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeTextDropsBlankLines 空行与首尾空白不应影响比对。 // Logan.Li
|
||||
func TestNormalizeTextDropsBlankLines(t *testing.T) {
|
||||
a := "1.1.1.1\n\n2.2.2.2\n"
|
||||
b := " 1.1.1.1 \n2.2.2.2"
|
||||
sa := BuildSnapshot(ContentModeText, a, nil, nil, 0, false)
|
||||
sb := BuildSnapshot(ContentModeText, b, nil, nil, 0, false)
|
||||
if sa.Hash != sb.Hash {
|
||||
t.Fatalf("空行/空白不应影响: %q vs %q", sa.Canonical, sb.Canonical)
|
||||
}
|
||||
if strings.Contains(sa.Canonical, "\n\n") {
|
||||
t.Fatal("规范化后不应保留空行")
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
@@ -28,6 +29,8 @@ type ProbeOutcome struct {
|
||||
SSLCertIssuer string
|
||||
SSLCertSubject string
|
||||
SSLDaysRemaining int
|
||||
// Body 响应体(内容监控用,按上限截断;非 HTTP 拨测为空) // Logan.Li
|
||||
Body string
|
||||
}
|
||||
|
||||
// Execute 按任务类型执行拨测 // Logan.Li
|
||||
@@ -133,11 +136,64 @@ func probeHTTP(ctx context.Context, task model.SiteTask) ProbeOutcome {
|
||||
ErrorMessage: fmt.Sprintf("状态码 %d 不符合期望 %d", resp.StatusCode, task.ExpectedStatus),
|
||||
}
|
||||
}
|
||||
|
||||
// 读取响应体:内容监控与 expected_content/expected_length 都依赖它。
|
||||
// 上限 4MB,避免误配大文件接口把内存打满。 // Logan.Li
|
||||
const maxBodyBytes = 4 << 20
|
||||
raw, readErr := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
|
||||
if readErr != nil {
|
||||
return ProbeOutcome{
|
||||
Status: model.SiteResultFail,
|
||||
HTTPStatus: resp.StatusCode,
|
||||
ResponseTime: elapsed,
|
||||
TotalTime: elapsed,
|
||||
ErrorMessage: "读取响应体失败: " + readErr.Error(),
|
||||
}
|
||||
}
|
||||
body := string(raw)
|
||||
|
||||
// 此前这两个字段只有表结构、执行器从未校验,属于"定义了但没实现"。
|
||||
// 内容监控依赖它们做正向断言与错误页拦截,此处一并补齐。 // Logan.Li
|
||||
if task.ExpectedLength > 0 && len(raw) != task.ExpectedLength {
|
||||
return ProbeOutcome{
|
||||
Status: model.SiteResultFail,
|
||||
HTTPStatus: resp.StatusCode,
|
||||
ResponseTime: elapsed,
|
||||
TotalTime: elapsed,
|
||||
Body: body,
|
||||
ErrorMessage: fmt.Sprintf("响应长度 %d 不符合期望 %d", len(raw), task.ExpectedLength),
|
||||
}
|
||||
}
|
||||
if exp := strings.TrimSpace(task.ExpectedContent); exp != "" {
|
||||
re, reErr := regexp.Compile(exp)
|
||||
if reErr != nil {
|
||||
return ProbeOutcome{
|
||||
Status: model.SiteResultFail,
|
||||
HTTPStatus: resp.StatusCode,
|
||||
ResponseTime: elapsed,
|
||||
TotalTime: elapsed,
|
||||
Body: body,
|
||||
ErrorMessage: "expected_content 正则无效: " + reErr.Error(),
|
||||
}
|
||||
}
|
||||
if !re.MatchString(body) {
|
||||
return ProbeOutcome{
|
||||
Status: model.SiteResultFail,
|
||||
HTTPStatus: resp.StatusCode,
|
||||
ResponseTime: elapsed,
|
||||
TotalTime: elapsed,
|
||||
Body: body,
|
||||
ErrorMessage: "响应内容未匹配 expected_content",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ProbeOutcome{
|
||||
Status: model.SiteResultSuccess,
|
||||
HTTPStatus: resp.StatusCode,
|
||||
ResponseTime: elapsed,
|
||||
TotalTime: elapsed,
|
||||
Body: body,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -124,4 +124,17 @@ func (w *Worker) probeTask(task model.SiteTask) {
|
||||
})
|
||||
// 拨测恢复后即时关闭探测类告警,与统计栏 down_count 同步 // Logan.Li
|
||||
ResolveRecoveredProbeAlerts(w.db, task.ID, lastResult)
|
||||
|
||||
// 内容监控:只有在拨测成功时才参与内容比对,
|
||||
// 避免网络抖动被误报成"接口返回内容变了"。 // Logan.Li
|
||||
if ContentCheckEnabled(task.ContentCheckMode) {
|
||||
changed, err := ApplyContentCheck(w.db, &task, outcome.Body, outcome.Status == model.SiteResultSuccess)
|
||||
switch {
|
||||
case err != nil:
|
||||
// 采集异常(空响应/过短/非JSON)走这里,不算变更。
|
||||
log.Printf("[siteprobe] 任务 %d 内容检查跳过: %v", task.ID, err)
|
||||
case changed:
|
||||
log.Printf("[siteprobe] 任务 %d 检测到内容变更,已生成待确认记录", task.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1104,6 +1104,13 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
||||
siteGroup.POST("/tasks/:id/probe", siteHandler.ProbeNow)
|
||||
siteGroup.GET("/results", siteHandler.ListResults)
|
||||
siteGroup.GET("/stats", siteHandler.GetSiteStats)
|
||||
|
||||
// 内容监控:基线版本 / 变更记录 / 人工确认 // Logan.Li
|
||||
siteGroup.GET("/tasks/:id/content", siteHandler.GetTaskContent)
|
||||
siteGroup.GET("/tasks/:id/baselines", siteHandler.ListBaselines)
|
||||
siteGroup.GET("/tasks/:id/changes", siteHandler.ListContentChanges)
|
||||
siteGroup.POST("/changes/:id/accept", siteHandler.AcceptContentChange)
|
||||
siteGroup.POST("/changes/:id/ignore", siteHandler.IgnoreContentChange)
|
||||
}
|
||||
siteProbeWorker.Start(10)
|
||||
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
|
||||
import { http } from '@/utils/request';
|
||||
|
||||
// 内容监控配置(随任务一起提交) // Logan.Li
|
||||
export interface ContentCheckPayload {
|
||||
content_check_mode?: 'off' | 'text' | 'json';
|
||||
content_paths?: string;
|
||||
content_ignore_rules?: string;
|
||||
content_min_length?: number;
|
||||
content_confirm_times?: number;
|
||||
content_sort_lines?: boolean;
|
||||
}
|
||||
|
||||
export const siteApi = {
|
||||
getStats: async () => http.get('/site/stats'),
|
||||
getTasks: async () => http.get<{ list: unknown[]; total: number }>('/site/tasks'),
|
||||
@@ -9,12 +19,12 @@ export const siteApi = {
|
||||
name: string; url: string; type: string;
|
||||
frequency_sec?: number; timeout_sec?: number; expected_status_code?: number;
|
||||
enabled?: boolean;
|
||||
}) => http.post('/site/tasks', data),
|
||||
} & ContentCheckPayload) => http.post('/site/tasks', data),
|
||||
updateTask: async (id: number | string, data: {
|
||||
name?: string; url?: string; type?: string;
|
||||
frequency_sec?: number; timeout_sec?: number; expected_status_code?: number;
|
||||
enabled?: boolean;
|
||||
}) => http.put(`/site/tasks/${id}`, data),
|
||||
} & ContentCheckPayload) => http.put(`/site/tasks/${id}`, data),
|
||||
deleteTask: async (id: number | string) => http.delete(`/site/tasks/${id}`),
|
||||
toggleTask: async (id: number | string, enabled: boolean) =>
|
||||
http.put(`/site/tasks/${id}`, { enabled }),
|
||||
@@ -27,4 +37,19 @@ export const siteApi = {
|
||||
limit: opts?.limit ?? 500,
|
||||
...(opts?.hours ? { hours: opts.hours } : {}),
|
||||
}),
|
||||
};
|
||||
|
||||
// ── 内容监控 ──────────────────────────────────────────────
|
||||
/** 当前基线 + 待确认变更 + 配置,一次拿全供详情页渲染 */
|
||||
getTaskContent: async (id: number | string) => http.get(`/site/tasks/${id}/content`),
|
||||
/** 基线版本历史 */
|
||||
getBaselines: async (id: number | string) => http.get(`/site/tasks/${id}/baselines`),
|
||||
/** 变更记录,可按 status 过滤 */
|
||||
getContentChanges: async (id: number | string, status?: string) =>
|
||||
http.get(`/site/tasks/${id}/changes`, status ? { status } : undefined),
|
||||
/** 接受为新基线(人工确认) */
|
||||
acceptChange: async (changeId: number | string, note?: string) =>
|
||||
http.post(`/site/changes/${changeId}/accept`, { note: note ?? '' }),
|
||||
/** 标记为误报忽略 */
|
||||
ignoreChange: async (changeId: number | string) =>
|
||||
http.post(`/site/changes/${changeId}/ignore`),
|
||||
};
|
||||
@@ -33,11 +33,24 @@ import {
|
||||
interface CreateTaskForm {
|
||||
name: string; url: string; type: ProbeType;
|
||||
frequency_sec: number; timeout_sec: number; expected_status_code: number;
|
||||
// 内容监控 // Logan.Li
|
||||
content_check_mode: 'off' | 'text' | 'json';
|
||||
content_paths: string;
|
||||
content_ignore_rules: string;
|
||||
content_min_length: number;
|
||||
content_confirm_times: number;
|
||||
content_sort_lines: boolean;
|
||||
}
|
||||
|
||||
const emptyForm: CreateTaskForm = {
|
||||
name: '', url: '', type: 'HTTP',
|
||||
frequency_sec: defaultProbeFrequency('HTTP'), timeout_sec: 10, expected_status_code: 200,
|
||||
content_check_mode: 'off',
|
||||
content_paths: '',
|
||||
content_ignore_rules: '',
|
||||
content_min_length: 0,
|
||||
content_confirm_times: 2,
|
||||
content_sort_lines: false,
|
||||
};
|
||||
|
||||
const FILTER_TYPES: Array<ProbeType | 'ALL'> = ['ALL', ...PROBE_TYPE_OPTIONS];
|
||||
@@ -124,6 +137,13 @@ export function SiteMonitor() {
|
||||
frequency_sec: task.frequency_sec || 60,
|
||||
timeout_sec: task.timeout_sec || 10,
|
||||
expected_status_code: task.expected_status_code || 200,
|
||||
// 回填内容监控配置,否则编辑保存会把已配好的规则清空 // Logan.Li
|
||||
content_check_mode: (task.content_check_mode as CreateTaskForm['content_check_mode']) || 'off',
|
||||
content_paths: task.content_paths || '',
|
||||
content_ignore_rules: task.content_ignore_rules || '',
|
||||
content_min_length: task.content_min_length || 0,
|
||||
content_confirm_times: task.content_confirm_times || 2,
|
||||
content_sort_lines: Boolean(task.content_sort_lines),
|
||||
});
|
||||
setFormOpen(true);
|
||||
};
|
||||
@@ -144,6 +164,13 @@ export function SiteMonitor() {
|
||||
frequency_sec: Number(form.frequency_sec) || 60,
|
||||
timeout_sec: Number(form.timeout_sec) || 10,
|
||||
expected_status_code: Number(form.expected_status_code) || 200,
|
||||
// 内容监控:off 时后端会忽略其余字段并清空基线 // Logan.Li
|
||||
content_check_mode: form.content_check_mode,
|
||||
content_paths: form.content_paths,
|
||||
content_ignore_rules: form.content_ignore_rules,
|
||||
content_min_length: Number(form.content_min_length) || 0,
|
||||
content_confirm_times: Number(form.content_confirm_times) || 2,
|
||||
content_sort_lines: form.content_sort_lines,
|
||||
};
|
||||
try {
|
||||
if (editingTaskId) { await siteApi.updateTask(editingTaskId, payload); toast.success(i18n.t('monitor.sitemonitortsx.se01498ee')); }
|
||||
@@ -388,6 +415,76 @@ export function SiteMonitor() {
|
||||
onChange={(e) => setForm({ ...form, expected_status_code: Number(e.target.value) || 200 })} />
|
||||
</div>
|
||||
)}
|
||||
{form.type === 'HTTP' && (
|
||||
<>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>内容变化监控</Label>
|
||||
<Select
|
||||
value={form.content_check_mode}
|
||||
onValueChange={(v) => setForm({ ...form, content_check_mode: v as CreateTaskForm['content_check_mode'] })}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="off">关闭(只检查可用性)</SelectItem>
|
||||
<SelectItem value="text">文本内容(如 IP 段清单)</SelectItem>
|
||||
<SelectItem value="json">JSON 字段(如 $.data.status)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)]">
|
||||
开启后,返回内容与基线不同时告警。只有拨测成功才参与比对,避免错误页被误报成"内容变更"。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{form.content_check_mode === 'json' && (
|
||||
<div className="grid gap-1.5">
|
||||
<Label>监控字段(JSONPath,每行一个)</Label>
|
||||
<textarea
|
||||
className="w-full min-h-[64px] bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 py-1.5 text-xs text-[var(--dg-text-primary)] font-mono"
|
||||
value={form.content_paths}
|
||||
onChange={(e) => setForm({ ...form, content_paths: e.target.value })}
|
||||
placeholder={'$.data.status\n$.data.ips[*].ip'}
|
||||
/>
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)]">只有列出的字段参与比对,其余字段变化不会告警。</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.content_check_mode !== 'off' && (
|
||||
<>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>忽略规则(正则,每行一条)</Label>
|
||||
<textarea
|
||||
className="w-full min-h-[52px] bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 py-1.5 text-xs text-[var(--dg-text-primary)] font-mono"
|
||||
value={form.content_ignore_rules}
|
||||
onChange={(e) => setForm({ ...form, content_ignore_rules: e.target.value })}
|
||||
placeholder={'"updated_at":\\s*"[^"]*"'}
|
||||
/>
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)]">用于剔除时间戳、nonce、trace_id 等每次都变的字段。</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-1.5">
|
||||
<Label>最小有效长度</Label>
|
||||
<Input type="number" min={0} value={form.content_min_length}
|
||||
onChange={(e) => setForm({ ...form, content_min_length: Number(e.target.value) || 0 })} />
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)]">低于此值判为采集异常。</p>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>连续确认次数</Label>
|
||||
<Input type="number" min={1} max={10} value={form.content_confirm_times}
|
||||
onChange={(e) => setForm({ ...form, content_confirm_times: Number(e.target.value) || 2 })} />
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)]">连续 N 次不同才告警。</p>
|
||||
</div>
|
||||
</div>
|
||||
{form.content_check_mode === 'text' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={form.content_sort_lines}
|
||||
onCheckedChange={(v) => setForm({ ...form, content_sort_lines: v })} />
|
||||
<Label>忽略行顺序(清单类接口建议开启)</Label>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => { setFormOpen(false); setEditingTaskId(null); }} disabled={saving}> {i18n.t('rbac.jobRole.cancel')} </Button>
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
// 站点拨测 — 内容监控面板:当前基线 / 待确认变更 / 并排 diff / 版本历史。 // Logan.Li
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Loader2, GitCompare, Check, EyeOff, History, RefreshCw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { siteApi } from '@/api';
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
interface TextDiffLine { type: 'same' | 'add' | 'del'; old?: string; new?: string }
|
||||
interface FieldDiff { path: string; old: string; new: string; kind: 'changed' | 'added' | 'removed' }
|
||||
|
||||
interface Baseline {
|
||||
id: number; version: number; content_hash: string;
|
||||
source: string; accepted_by?: string; note?: string; created_at: string;
|
||||
}
|
||||
interface ContentChange {
|
||||
id: number; from_version: number; diff_type: string;
|
||||
diff_payload: string; status: string; detected_at: string;
|
||||
}
|
||||
|
||||
interface ContentView {
|
||||
enabled: boolean;
|
||||
mode: string;
|
||||
baseline?: Baseline;
|
||||
pending?: ContentChange;
|
||||
baseline_count: number;
|
||||
pending_change_count: number;
|
||||
has_baseline: boolean;
|
||||
has_pending: boolean;
|
||||
}
|
||||
|
||||
const fmtTime = (iso?: string) => {
|
||||
if (!iso) return '--';
|
||||
const d = new Date(iso);
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
};
|
||||
|
||||
export function SiteContentPanel({ taskId }: { taskId: number }) {
|
||||
const [data, setData] = useState<ContentView | null>(null);
|
||||
const [baselines, setBaselines] = useState<Baseline[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [acting, setActing] = useState(false);
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const view = (await siteApi.getTaskContent(taskId)) as ContentView;
|
||||
setData(view);
|
||||
if (view?.enabled) {
|
||||
const list = (await siteApi.getBaselines(taskId)) as Baseline[];
|
||||
setBaselines(Array.isArray(list) ? list : []);
|
||||
}
|
||||
} catch {
|
||||
/* 面板为增强信息,失败时不打断详情页 */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [taskId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const act = async (fn: () => Promise<unknown>, okMsg: string) => {
|
||||
setActing(true);
|
||||
try {
|
||||
await fn();
|
||||
toast.success(okMsg);
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.error((e as JsonData)?.message as string || '操作失败');
|
||||
} finally {
|
||||
setActing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="py-6 text-center text-[var(--dg-text-muted)]">
|
||||
<Loader2 className="h-4 w-4 animate-spin mx-auto" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 未启用内容监控时整块隐藏,避免干扰普通拨测任务的详情页。
|
||||
if (!data?.enabled) return null;
|
||||
|
||||
const diffLines: TextDiffLine[] = (() => {
|
||||
if (data.pending?.diff_type !== 'text_line' || !data.pending?.diff_payload) return [];
|
||||
try { return JSON.parse(data.pending.diff_payload) as TextDiffLine[]; } catch { return []; }
|
||||
})();
|
||||
const fieldDiffs: FieldDiff[] = (() => {
|
||||
if (data.pending?.diff_type !== 'json_field' || !data.pending?.diff_payload) return [];
|
||||
try { return JSON.parse(data.pending.diff_payload) as FieldDiff[]; } catch { return []; }
|
||||
})();
|
||||
|
||||
return (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardHeader className="pb-3 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<GitCompare className="h-4 w-4" />
|
||||
内容监控
|
||||
<Badge variant="outline" className="text-[10px]">{data.mode === 'json' ? 'JSON 字段' : '文本'}</Badge>
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" className="h-7" onClick={() => setShowHistory((v) => !v)}>
|
||||
<History className="h-3.5 w-3.5 mr-1" />
|
||||
基线 v{data.baseline?.version ?? '-'}({data.baseline_count} 版)
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7" onClick={load}>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{!data.has_baseline && (
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">
|
||||
尚未采集到基线,下一轮拨测完成后会自动建立 v1。
|
||||
</p>
|
||||
)}
|
||||
|
||||
{data.has_baseline && !data.has_pending && (
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">
|
||||
内容与基线一致(v{data.baseline?.version},采集于 {fmtTime(data.baseline?.created_at)})。
|
||||
</p>
|
||||
)}
|
||||
|
||||
{data.has_pending && data.pending && (
|
||||
<>
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="text-xs">
|
||||
<span className="text-[var(--dg-text-primary)] font-medium">
|
||||
检测到内容变更
|
||||
</span>
|
||||
<span className="text-[var(--dg-text-muted)] ml-2">
|
||||
v{data.pending.from_version} → 当前 · {fmtTime(data.pending.detected_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" className="h-7" disabled={acting}
|
||||
onClick={() => act(() => siteApi.acceptChange(data.pending!.id), '已接受为新基线')}>
|
||||
<Check className="h-3.5 w-3.5 mr-1" />接受为新基线
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-7" disabled={acting}
|
||||
onClick={() => act(() => siteApi.ignoreChange(data.pending!.id), '已忽略该变更')}>
|
||||
<EyeOff className="h-3.5 w-3.5 mr-1" />忽略
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{fieldDiffs.length > 0 && (
|
||||
<div className="rounded border border-[var(--dg-border)] overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-8 text-xs">字段</TableHead>
|
||||
<TableHead className="h-8 text-xs">变更前</TableHead>
|
||||
<TableHead className="h-8 text-xs">变更后</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{fieldDiffs.map((f, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell className="py-1 text-xs font-mono">{f.path}</TableCell>
|
||||
<TableCell className="py-1 text-xs font-mono text-[var(--dg-text-muted)] break-all">
|
||||
{f.old || <span className="italic">(无)</span>}
|
||||
</TableCell>
|
||||
<TableCell className="py-1 text-xs font-mono dg-text-yellow break-all">
|
||||
{f.new || <span className="italic">(已删除)</span>}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{diffLines.length > 0 && (
|
||||
<div className="rounded border border-[var(--dg-border)] overflow-x-auto max-h-[380px] overflow-y-auto">
|
||||
<table className="w-full text-xs font-mono">
|
||||
<tbody>
|
||||
{diffLines.map((d, i) => {
|
||||
const isAdd = d.type === 'add';
|
||||
const isDel = d.type === 'del';
|
||||
return (
|
||||
<tr key={i}
|
||||
className={isAdd ? 'bg-[var(--dg-green)]/10' : isDel ? 'bg-[var(--dg-red)]/10' : ''}>
|
||||
<td className="w-6 text-center select-none text-[var(--dg-text-muted)]">
|
||||
{isAdd ? '+' : isDel ? '−' : ''}
|
||||
</td>
|
||||
<td className={`px-2 py-0.5 whitespace-pre-wrap break-all ${isDel ? 'dg-text-red line-through' : ''}`}>
|
||||
{isDel ? d.old : d.new}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{showHistory && (
|
||||
<div className="rounded border border-[var(--dg-border)] overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-8 text-xs">版本</TableHead>
|
||||
<TableHead className="h-8 text-xs">来源</TableHead>
|
||||
<TableHead className="h-8 text-xs">确认人</TableHead>
|
||||
<TableHead className="h-8 text-xs">时间</TableHead>
|
||||
<TableHead className="h-8 text-xs">备注</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{baselines.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-3 text-center text-xs text-[var(--dg-text-muted)]">暂无基线</TableCell>
|
||||
</TableRow>
|
||||
) : baselines.map((b) => (
|
||||
<TableRow key={b.id}>
|
||||
<TableCell className="py-1 text-xs">
|
||||
v{b.version}
|
||||
{b.id === data.baseline?.id && (
|
||||
<Badge variant="outline" className="ml-1 text-[9px]">当前</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="py-1 text-xs">{b.source === 'manual' ? '人工接受' : '自动建立'}</TableCell>
|
||||
<TableCell className="py-1 text-xs">{b.accepted_by || '-'}</TableCell>
|
||||
<TableCell className="py-1 text-xs">{fmtTime(b.created_at)}</TableCell>
|
||||
<TableCell className="py-1 text-xs text-[var(--dg-text-muted)]">{b.note || '-'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import type { SiteTask } from './helpers';
|
||||
import { fmtFrequency, normalizeProbeType } from './helpers';
|
||||
import { SiteResultsPanel } from './SiteResultsPanel';
|
||||
import { SiteContentPanel } from './SiteContentPanel';
|
||||
|
||||
interface Props {
|
||||
task: SiteTask;
|
||||
@@ -33,6 +34,9 @@ export function SiteTaskDetailPage({ task, onBack, onProbed }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内容监控:仅在任务启用时渲染,未启用时组件内部返回 null // Logan.Li */}
|
||||
<SiteContentPanel taskId={task.id} />
|
||||
|
||||
<SiteResultsPanel task={task} onProbed={onProbed} fullPage />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -21,6 +21,15 @@ export interface SiteTask {
|
||||
created_at: string;
|
||||
ssl_days_remaining?: number;
|
||||
ssl_cert_expiry?: string;
|
||||
// 内容监控 // Logan.Li
|
||||
content_check_mode?: string;
|
||||
content_paths?: string;
|
||||
content_ignore_rules?: string;
|
||||
content_min_length?: number;
|
||||
content_confirm_times?: number;
|
||||
content_sort_lines?: boolean;
|
||||
current_baseline_id?: number;
|
||||
pending_change_count?: number;
|
||||
}
|
||||
|
||||
export interface SiteResult {
|
||||
|
||||
Reference in new issue
Block a user