需求:监控接口返回的数据本身是否变化(如 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
248 lines
6.8 KiB
Go
248 lines
6.8 KiB
Go
package controller
|
|
|
|
import (
|
|
"strings"
|
|
"time"
|
|
|
|
"ezops/model"
|
|
"ezops/pkg/siteprobe"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// SiteTaskView 前端拨测任务视图(字段与前端对齐)// Logan.Li
|
|
type SiteTaskView struct {
|
|
ID uint `json:"id"`
|
|
Name string `json:"name"`
|
|
URL string `json:"url"`
|
|
Type string `json:"type"`
|
|
FrequencySec int `json:"frequency_sec"`
|
|
TimeoutSec int `json:"timeout_sec"`
|
|
ExpectedStatusCode int `json:"expected_status_code"`
|
|
Enabled bool `json:"enabled"`
|
|
LastStatus string `json:"last_status,omitempty"`
|
|
LastResponseTimeMs float64 `json:"last_response_time_ms,omitempty"`
|
|
LastCheckedAt *string `json:"last_checked_at,omitempty"`
|
|
SuccessRate float64 `json:"success_rate"`
|
|
AlertStatus string `json:"alert_status"`
|
|
Status string `json:"status"`
|
|
CreatedAt string `json:"created_at"`
|
|
SSLDaysRemaining *int `json:"ssl_days_remaining,omitempty"`
|
|
SSLCertExpiry *string `json:"ssl_cert_expiry,omitempty"`
|
|
}
|
|
|
|
func toSiteTaskView(task model.SiteTask) SiteTaskView {
|
|
taskType := normalizeTaskType(task)
|
|
enabled := task.Status == model.SiteTaskStatusRunning
|
|
lastStatus := "unknown"
|
|
switch task.LastResult {
|
|
case "success":
|
|
lastStatus = "up"
|
|
case "fail", "timeout":
|
|
lastStatus = "down"
|
|
}
|
|
var lastChecked *string
|
|
if task.LastCheckAt != nil {
|
|
s := task.LastCheckAt.Format("2006-01-02T15:04:05Z07:00")
|
|
lastChecked = &s
|
|
}
|
|
return SiteTaskView{
|
|
ID: task.ID,
|
|
Name: task.Name,
|
|
URL: task.URL,
|
|
Type: taskType,
|
|
FrequencySec: task.Frequency,
|
|
TimeoutSec: task.Timeout,
|
|
ExpectedStatusCode: task.ExpectedStatus,
|
|
Enabled: enabled,
|
|
LastStatus: lastStatus,
|
|
LastResponseTimeMs: task.AvgResponse,
|
|
LastCheckedAt: lastChecked,
|
|
SuccessRate: task.SuccessRate,
|
|
AlertStatus: "normal",
|
|
Status: string(task.Status),
|
|
CreatedAt: task.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
}
|
|
}
|
|
|
|
// enrichSiteTaskViewsAlert 批量附加活跃告警状态 // Logan.Li
|
|
func enrichSiteTaskViewsAlert(db *gorm.DB, views []SiteTaskView) {
|
|
if len(views) == 0 {
|
|
return
|
|
}
|
|
// 列表加载时清理已恢复任务的陈旧 active 告警(与 down_count 对齐)// Logan.Li
|
|
for _, v := range views {
|
|
if v.LastStatus == "up" {
|
|
siteprobe.ResolveRecoveredProbeAlerts(db, v.ID, "success")
|
|
}
|
|
}
|
|
ids := make([]uint, 0, len(views))
|
|
for _, v := range views {
|
|
ids = append(ids, v.ID)
|
|
}
|
|
type row struct {
|
|
TaskID uint
|
|
}
|
|
var rows []row
|
|
db.Model(&model.SiteAlert{}).
|
|
Select("DISTINCT task_id").
|
|
Where("task_id IN ? AND status = ?", ids, model.SiteAlertStatusActive).
|
|
Find(&rows)
|
|
alerting := make(map[uint]bool, len(rows))
|
|
for _, r := range rows {
|
|
alerting[r.TaskID] = true
|
|
}
|
|
for i := range views {
|
|
if !alerting[views[i].ID] {
|
|
continue
|
|
}
|
|
// 最近一次拨测成功则显示正常(与 down_count 一致;SSL 证书预警走行内徽章)// Logan.Li
|
|
if views[i].LastStatus == "up" {
|
|
views[i].AlertStatus = "normal"
|
|
continue
|
|
}
|
|
views[i].AlertStatus = "alerting"
|
|
}
|
|
}
|
|
|
|
func normalizeTaskType(task model.SiteTask) string {
|
|
t := strings.ToLower(string(task.TaskType))
|
|
if t == "https" {
|
|
return "HTTP"
|
|
}
|
|
if t != "" {
|
|
return strings.ToUpper(t)
|
|
}
|
|
return strings.ToUpper(siteprobe.InferTaskTypePublic(task))
|
|
}
|
|
|
|
type siteSSLInfo struct {
|
|
DaysRemaining int
|
|
CertExpiry *time.Time
|
|
}
|
|
|
|
// enrichSiteTaskViewsSSL 批量附加 SSL 拨测任务的证书到期信息 // Logan.Li
|
|
func enrichSiteTaskViewsSSL(db *gorm.DB, views []SiteTaskView) {
|
|
if len(views) == 0 {
|
|
return
|
|
}
|
|
ids := make([]uint, 0, len(views))
|
|
for _, v := range views {
|
|
if strings.ToUpper(v.Type) != "SSL" {
|
|
continue
|
|
}
|
|
ids = append(ids, v.ID)
|
|
}
|
|
if len(ids) == 0 {
|
|
return
|
|
}
|
|
type row struct {
|
|
TaskID uint
|
|
SSLDaysRemaining int
|
|
SSLCertExpiry *time.Time
|
|
}
|
|
var rows []row
|
|
db.Model(&model.SiteResult{}).
|
|
Select("task_id, ssl_days_remaining, ssl_cert_expiry").
|
|
Where("task_id IN ? AND ssl_cert_expiry IS NOT NULL", ids).
|
|
Order("checked_at DESC").
|
|
Find(&rows)
|
|
seen := map[uint]bool{}
|
|
sslMap := make(map[uint]siteSSLInfo, len(rows))
|
|
for _, r := range rows {
|
|
if seen[r.TaskID] {
|
|
continue
|
|
}
|
|
seen[r.TaskID] = true
|
|
sslMap[r.TaskID] = siteSSLInfo{DaysRemaining: r.SSLDaysRemaining, CertExpiry: r.SSLCertExpiry}
|
|
}
|
|
for i := range views {
|
|
if ssl, ok := sslMap[views[i].ID]; ok {
|
|
d := ssl.DaysRemaining
|
|
views[i].SSLDaysRemaining = &d
|
|
if ssl.CertExpiry != nil {
|
|
s := ssl.CertExpiry.Format("2006-01-02T15:04:05Z07:00")
|
|
views[i].SSLCertExpiry = &s
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func parseCreateTaskRequest(raw map[string]interface{}) model.SiteTask {
|
|
getStr := func(keys ...string) string {
|
|
for _, k := range keys {
|
|
if v, ok := raw[k]; ok {
|
|
if s, ok := v.(string); ok {
|
|
return strings.TrimSpace(s)
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
getInt := func(def int, keys ...string) int {
|
|
for _, k := range keys {
|
|
if v, ok := raw[k]; ok {
|
|
switch n := v.(type) {
|
|
case float64:
|
|
return int(n)
|
|
case int:
|
|
return n
|
|
}
|
|
}
|
|
}
|
|
return def
|
|
}
|
|
|
|
taskType := strings.ToLower(getStr("type", "task_type"))
|
|
switch taskType {
|
|
case "https":
|
|
taskType = "http"
|
|
case "":
|
|
taskType = "http"
|
|
}
|
|
|
|
task := model.SiteTask{
|
|
Name: getStr("name"),
|
|
URL: getStr("url"),
|
|
TaskType: model.SiteTaskType(taskType),
|
|
Method: model.SiteTaskMethodGET,
|
|
Timeout: getInt(10, "timeout_sec", "timeout"),
|
|
Frequency: siteprobe.NormalizeProbeFrequency(taskType, getInt(0, "frequency_sec", "frequency")),
|
|
ExpectedStatus: getInt(200, "expected_status_code", "expected_status"),
|
|
FollowRedirect: true,
|
|
VerifySSL: true,
|
|
Status: model.SiteTaskStatusRunning,
|
|
}
|
|
if v, ok := raw["enabled"].(bool); ok && !v {
|
|
task.Status = model.SiteTaskStatusPaused
|
|
}
|
|
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
|
|
}
|
|
}
|