diff --git a/backend/controller/agent.go b/backend/controller/agent.go index 01da678..2f738da 100644 --- a/backend/controller/agent.go +++ b/backend/controller/agent.go @@ -64,6 +64,8 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) { reportTime = t } } + // Agent 上报时间统一存储为 UTC,避免 SQLite 按带时区文本比较时误判。 // Logan.Li + reportTime = reportTime.UTC() matchedServer, matchIP, assetMatched := h.resolveAgentAsset(req.Hostname, req.IP, c.ClientIP()) canonicalHostname := req.Hostname @@ -260,7 +262,8 @@ func (h *AgentHandler) writeHotMetric(c *gin.Context, key string, value float64, // GET /api/v1/agent/latest func (h *AgentHandler) GetLatestReports(c *gin.Context) { 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 上报 // 未装 Agent 的主机也会显示(agent_status='not_installed'),打破"不显示→无法安装"的循环 h.db.Raw(` @@ -394,7 +397,8 @@ func (h *AgentHandler) GetHostHistory(c *gin.Context) { } 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"). Find(&reports) @@ -479,7 +483,8 @@ func (h *AgentHandler) aggregateFromReports(req model.AggregateReq) (int64, erro for _, iv := range intervals { lookback := iv.duration * 200 - since := time.Now().Add(-lookback) + // 本地 SQLite 模式下使用 UTC 边界查询 Agent 报告。 // Logan.Li + since := time.Now().UTC().Add(-lookback) var reports []model.AgentReport if err := h.db.Where("hostname = ? AND report_time >= ?", req.Hostname, since). Order("report_time ASC").Find(&reports).Error; err != nil { diff --git a/backend/controller/agent_report_test.go b/backend/controller/agent_report_test.go index e0e35c9..a35c374 100644 --- a/backend/controller/agent_report_test.go +++ b/backend/controller/agent_report_test.go @@ -2,9 +2,11 @@ package controller import ( "bytes" + "encoding/json" "net/http" "net/http/httptest" "testing" + "time" "ezops/model" @@ -76,3 +78,68 @@ func TestReceiveReportMatchesExistingAssetByIP(t *testing.T) { 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) + } +} diff --git a/backend/controller/agent_task.go b/backend/controller/agent_task.go index 0e93425..a81f8f1 100644 --- a/backend/controller/agent_task.go +++ b/backend/controller/agent_task.go @@ -167,9 +167,9 @@ func (h *AgentTaskHandler) QuickSetupRules(c *gin.Context) { Pluck("hostname", &onlineHosts) if len(onlineHosts) == 0 { - // 如果没有在线主机,尝试从 agent_reports 中获取最近 3 分钟活跃的主机名 + // 如果没有在线主机,使用 UTC 边界从 Agent 报告中查找最近 3 分钟活跃主机。 // Logan.Li 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"). Pluck("hostname", &onlineHosts) } diff --git a/backend/controller/alert_engine.go b/backend/controller/alert_engine.go index 57866ec..6b71c81 100644 --- a/backend/controller/alert_engine.go +++ b/backend/controller/alert_engine.go @@ -227,7 +227,8 @@ func (e *AlertEngine) checkOfflineAlerts() { e.db.Select("ip, hostname").Group("ip").Find(&servers) // 收集所有 agent_reports 中活跃上报的主机名(最近 3 分钟内有上报) - threeMinAgo := time.Now().Add(-offlineThreshold) + // Agent 报告统一使用 UTC,避免 SQLite 时区文本比较导致误报离线。 // Logan.Li + threeMinAgo := time.Now().UTC().Add(-offlineThreshold) var activeHostnames []string e.db.Model(&model.AgentReport{}). diff --git a/backend/controller/dashboard.go b/backend/controller/dashboard.go index 103297c..89fbf7d 100644 --- a/backend/controller/dashboard.go +++ b/backend/controller/dashboard.go @@ -366,8 +366,8 @@ func (h *DashboardHandler) GetMetrics(c *gin.Context) { } } - // 从 agent_reports 表查最近 N 小时的真实数据(整数分组,可用索引) - since := time.Now().Add(-time.Duration(hours) * time.Hour) + // 从 agent_reports 表按 UTC 边界查最近 N 小时的真实数据。 // Logan.Li + since := time.Now().UTC().Add(-time.Duration(hours) * time.Hour) type avgPoint struct { BucketTS int64 AvgCPU float64 diff --git a/backend/pkg/aiopscontext/service.go b/backend/pkg/aiopscontext/service.go index 3343a8c..fb0822f 100644 --- a/backend/pkg/aiopscontext/service.go +++ b/backend/pkg/aiopscontext/service.go @@ -144,7 +144,8 @@ func (s *Service) MetricSummary(ctx context.Context, target, metric, timeRange s } since := sinceByRange(timeRange, time.Hour) 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 { return nil, err }