fix: 修复Agent指标时区查询

- 统一Agent报告存储与查询的UTC时间边界
- 修复监控大屏、历史图表及离线判断取不到最新数据
- 增加UTC与UTC+8场景回归测试
This commit is contained in:
Logan.Li committed 2026-07-25 16:17:47 +08:00
1 parent d9106dde6e
commit 7bc416d22a
6 files changed
+83 -9

No files matched your search

+8 -3
View File
@@ -64,6 +64,8 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) {
reportTime = t reportTime = t
} }
} }
// Agent 上报时间统一存储为 UTC,避免 SQLite 按带时区文本比较时误判。 // Logan.Li
reportTime = reportTime.UTC()
matchedServer, matchIP, assetMatched := h.resolveAgentAsset(req.Hostname, req.IP, c.ClientIP()) matchedServer, matchIP, assetMatched := h.resolveAgentAsset(req.Hostname, req.IP, c.ClientIP())
canonicalHostname := req.Hostname canonicalHostname := req.Hostname
@@ -260,7 +262,8 @@ func (h *AgentHandler) writeHotMetric(c *gin.Context, key string, value float64,
// GET /api/v1/agent/latest // GET /api/v1/agent/latest
func (h *AgentHandler) GetLatestReports(c *gin.Context) { func (h *AgentHandler) GetLatestReports(c *gin.Context) {
var results []model.AgentLatestResponse var results []model.AgentLatestResponse
threeMinAgo := time.Now().Add(-3 * time.Minute) // 查询边界与 Agent 上报的 UTC 时间保持一致。 // Logan.Li
threeMinAgo := time.Now().UTC().Add(-3 * time.Minute)
// 以 servers 表为主,LEFT JOIN 最近 3 分钟内的 Agent 上报 // 以 servers 表为主,LEFT JOIN 最近 3 分钟内的 Agent 上报
// 未装 Agent 的主机也会显示(agent_status='not_installed'),打破"不显示→无法安装"的循环 // 未装 Agent 的主机也会显示(agent_status='not_installed'),打破"不显示→无法安装"的循环
h.db.Raw(` h.db.Raw(`
@@ -394,7 +397,8 @@ func (h *AgentHandler) GetHostHistory(c *gin.Context) {
} }
var reports []model.AgentReport var reports []model.AgentReport
h.db.Where("hostname = ? AND report_time >= ? AND report_time <= ?", hostname, since, until). // SQLite 会按时间文本比较,查询边界需统一转换为 UTC。 // Logan.Li
h.db.Where("hostname = ? AND report_time >= ? AND report_time <= ?", hostname, since.UTC(), until.UTC()).
Order("report_time ASC"). Order("report_time ASC").
Find(&reports) Find(&reports)
@@ -479,7 +483,8 @@ func (h *AgentHandler) aggregateFromReports(req model.AggregateReq) (int64, erro
for _, iv := range intervals { for _, iv := range intervals {
lookback := iv.duration * 200 lookback := iv.duration * 200
since := time.Now().Add(-lookback) // 本地 SQLite 模式下使用 UTC 边界查询 Agent 报告。 // Logan.Li
since := time.Now().UTC().Add(-lookback)
var reports []model.AgentReport var reports []model.AgentReport
if err := h.db.Where("hostname = ? AND report_time >= ?", req.Hostname, since). if err := h.db.Where("hostname = ? AND report_time >= ?", req.Hostname, since).
Order("report_time ASC").Find(&reports).Error; err != nil { Order("report_time ASC").Find(&reports).Error; err != nil {
+67
View File
@@ -2,9 +2,11 @@ package controller
import ( import (
"bytes" "bytes"
"encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"time"
"ezops/model" "ezops/model"
@@ -76,3 +78,68 @@ func TestReceiveReportMatchesExistingAssetByIP(t *testing.T) {
t.Fatalf("上报后产生重复资产: count=%d", serverCount) t.Fatalf("上报后产生重复资产: count=%d", serverCount)
} }
} }
func TestGetLatestReportsUsesUTCTimeBoundary(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open("file:agent_latest_utc?mode=memory&cache=shared"), &gorm.Config{
Logger: gormlogger.Default.LogMode(gormlogger.Silent),
})
if err != nil {
t.Fatalf("打开测试数据库失败: %v", err)
}
if err := db.AutoMigrate(&model.Server{}, &model.AgentReport{}); err != nil {
t.Fatalf("迁移测试表失败: %v", err)
}
if err := db.Exec("CREATE TABLE asset_regions (id INTEGER PRIMARY KEY, name TEXT)").Error; err != nil {
t.Fatalf("创建区域测试表失败: %v", err)
}
server := model.Server{
Hostname: "c-redis-cluster-3",
IP: "172.19.16.59",
AgentVersion: "v2.1.0",
AgentStatus: "online",
DiskUsage: 5.56,
}
if err := db.Create(&server).Error; err != nil {
t.Fatalf("创建测试资产失败: %v", err)
}
// 模拟 Agent 以 UTC 上报,同时应用运行在 UTC+8 时区。 // Logan.Li
report := model.AgentReport{
Hostname: server.Hostname,
CPUUsage: 12.5,
MemUsage: 39.7,
CPUCores: 4,
ReportTime: time.Now().UTC().Add(-time.Minute),
}
if err := db.Create(&report).Error; err != nil {
t.Fatalf("创建 Agent 报告失败: %v", err)
}
originalLocal := time.Local
time.Local = time.FixedZone("UTC+8", 8*60*60)
defer func() { time.Local = originalLocal }()
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/agent/latest", nil)
NewAgentHandler(db, nil, nil).GetLatestReports(c)
if recorder.Code != http.StatusOK {
t.Fatalf("查询返回码 = %d, body=%s", recorder.Code, recorder.Body.String())
}
var response struct {
Code int `json:"code"`
Data []model.AgentLatestResponse `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
if len(response.Data) != 1 {
t.Fatalf("主机数量 = %d, body=%s", len(response.Data), recorder.Body.String())
}
got := response.Data[0]
if got.AgentStatus != "online" || got.CPUUsage != 12.5 || got.MemUsage != 39.7 {
t.Fatalf("最新指标未命中: status=%q cpu=%.1f mem=%.1f", got.AgentStatus, got.CPUUsage, got.MemUsage)
}
}
+2 -2
View File
@@ -167,9 +167,9 @@ func (h *AgentTaskHandler) QuickSetupRules(c *gin.Context) {
Pluck("hostname", &onlineHosts) Pluck("hostname", &onlineHosts)
if len(onlineHosts) == 0 { if len(onlineHosts) == 0 {
// 如果没有在线主机,尝试从 agent_reports 中获取最近 3 分钟活跃的主机名 // 如果没有在线主机,使用 UTC 边界从 Agent 报告中查找最近 3 分钟活跃主机。 // Logan.Li
h.db.Model(&model.AgentReport{}). h.db.Model(&model.AgentReport{}).
Where("report_time >= ?", time.Now().Add(-3*time.Minute)). Where("report_time >= ?", time.Now().UTC().Add(-3*time.Minute)).
Distinct("hostname"). Distinct("hostname").
Pluck("hostname", &onlineHosts) Pluck("hostname", &onlineHosts)
} }
+2 -1
View File
@@ -227,7 +227,8 @@ func (e *AlertEngine) checkOfflineAlerts() {
e.db.Select("ip, hostname").Group("ip").Find(&servers) e.db.Select("ip, hostname").Group("ip").Find(&servers)
// 收集所有 agent_reports 中活跃上报的主机名(最近 3 分钟内有上报) // 收集所有 agent_reports 中活跃上报的主机名(最近 3 分钟内有上报)
threeMinAgo := time.Now().Add(-offlineThreshold) // Agent 报告统一使用 UTC,避免 SQLite 时区文本比较导致误报离线。 // Logan.Li
threeMinAgo := time.Now().UTC().Add(-offlineThreshold)
var activeHostnames []string var activeHostnames []string
e.db.Model(&model.AgentReport{}). e.db.Model(&model.AgentReport{}).
+2 -2
View File
@@ -366,8 +366,8 @@ func (h *DashboardHandler) GetMetrics(c *gin.Context) {
} }
} }
// 从 agent_reports 表查最近 N 小时的真实数据(整数分组,可用索引) // 从 agent_reports 表按 UTC 边界查最近 N 小时的真实数据。 // Logan.Li
since := time.Now().Add(-time.Duration(hours) * time.Hour) since := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
type avgPoint struct { type avgPoint struct {
BucketTS int64 BucketTS int64
AvgCPU float64 AvgCPU float64
+2 -1
View File
@@ -144,7 +144,8 @@ func (s *Service) MetricSummary(ctx context.Context, target, metric, timeRange s
} }
since := sinceByRange(timeRange, time.Hour) since := sinceByRange(timeRange, time.Hour)
var reports []model.AgentReport var reports []model.AgentReport
if err := s.db.WithContext(ctx).Where("hostname = ? AND report_time >= ?", hostname, since). // Agent 指标时间统一为 UTC,确保 SQLite 按时间文本比较能命中数据。 // Logan.Li
if err := s.db.WithContext(ctx).Where("hostname = ? AND report_time >= ?", hostname, since.UTC()).
Order("report_time ASC").Limit(limit).Find(&reports).Error; err != nil { Order("report_time ASC").Limit(limit).Find(&reports).Error; err != nil {
return nil, err return nil, err
} }