diff --git a/agent/main.go b/agent/main.go index d72e1ee..9e569cf 100644 --- a/agent/main.go +++ b/agent/main.go @@ -116,12 +116,11 @@ func main() { sysInfo := collector.CollectSystem() logger.Printf("系统信息: OS=%q Kernel=%q", sysInfo.OSVersion, sysInfo.Kernel) + agentStartedAt := time.Now() localIP := detectLocalIP() logger.Printf("探测本机 IP: %s", localIP) - sysReportCount := 0 - taskExecutor := task.New(logger) taskClient := task.NewClient(httpCli, cfg.Hostname, logger) @@ -135,20 +134,17 @@ func main() { var probeConfigs []model.ProbeConfig collectAndReport := func() { + currentUptime := sysInfo.UptimeSeconds + int64(time.Since(agentStartedAt).Seconds()) report := &model.Report{ Hostname: cfg.Hostname, IP: localIP, Timestamp: time.Now().UTC().Format(time.RFC3339), AgentVersion: version, - } - - if sysReportCount < 3 && (sysInfo.OSVersion != "" || sysInfo.Kernel != "") { - report.System = &model.SystemStat{ + System: &model.SystemStat{ OSVersion: sysInfo.OSVersion, Kernel: sysInfo.Kernel, - Uptime: sysInfo.UptimeSeconds, - } - sysReportCount++ + Uptime: currentUptime, + }, } if stat, err := cpuCollector.Collect(); err != nil { @@ -211,7 +207,7 @@ func main() { runtime.ReadMemStats(&memStats) report.Heartbeat = model.AgentHeartbeat{ Version: version, - UptimeSec: sysInfo.UptimeSeconds, + UptimeSec: currentUptime, SelfCPU: 0, SelfRSSMB: float64(memStats.Alloc) / 1024.0 / 1024.0, ReportSeq: reportSeq, diff --git a/backend/controller/agent.go b/backend/controller/agent.go index 2f738da..f25754c 100644 --- a/backend/controller/agent.go +++ b/backend/controller/agent.go @@ -150,6 +150,10 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) { updates["uptime"] = req.System.Uptime } } + // 兼容旧版 Agent:系统信息缺席时使用心跳中的运行时长补齐资产元数据。 // Logan.Li + if _, ok := updates["uptime"]; !ok && req.Heartbeat.UptimeSec > 0 { + updates["uptime"] = req.Heartbeat.UptimeSec + } var result *gorm.DB if assetMatched { result = h.db.Model(&model.Server{}).Where("id = ?", matchedServer.ID).Updates(updates) @@ -162,19 +166,32 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) { // 避免 Agent 每次上报(10s)都新建记录导致资产越来越多。 if result.Error == nil && result.RowsAffected == 0 { osName := "" + kernel := "" + uptime := req.Heartbeat.UptimeSec if req.System != nil { osName = req.System.OSVersion + kernel = req.System.Kernel + if req.System.Uptime > 0 { + uptime = req.System.Uptime + } } var existing model.Server if h.db.Where("hostname = ?", req.Hostname).Take(&existing).Error == nil { // 已自动注册过 → 仅更新实时指标,不重复创建 - h.db.Model(&existing).Updates(map[string]interface{}{ + existingUpdates := map[string]interface{}{ "ip": matchIP, "os": osName, "cpu": req.CPU.CoreCount, "memory": memMB, "disk": diskGB, "status": "running", "agent_status": "online", "agent_version": req.AgentVersion, "cpu_usage": req.CPU.UsagePercent, "memory_usage": req.Memory.UsagePercent, "disk_usage": diskAvg, - }) + } + if kernel != "" { + existingUpdates["kernel"] = kernel + } + if uptime > 0 { + existingUpdates["uptime"] = uptime + } + h.db.Model(&existing).Updates(existingUpdates) } else { // 真正的新主机 → 注册,hostname 无唯一索引时不使用 OnConflict。 // Logan.Li newServer := model.Server{ @@ -190,6 +207,8 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) { Status: "running", AgentStatus: "online", AgentVersion: req.AgentVersion, + Kernel: kernel, + Uptime: uptime, CPUUsage: req.CPU.UsagePercent, MemoryUsage: req.Memory.UsagePercent, DiskUsage: diskAvg, diff --git a/backend/controller/agent_report_test.go b/backend/controller/agent_report_test.go index a35c374..a3a5793 100644 --- a/backend/controller/agent_report_test.go +++ b/backend/controller/agent_report_test.go @@ -79,6 +79,51 @@ func TestReceiveReportMatchesExistingAssetByIP(t *testing.T) { } } +func TestReceiveReportUsesHeartbeatUptimeWithoutSystemPayload(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open("file:agent_report_heartbeat_uptime?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{}, &model.HealthProbe{}); err != nil { + t.Fatalf("迁移测试表失败: %v", err) + } + + server := model.Server{Hostname: "c-monitor", IP: "172.19.16.113", Status: "running"} + if err := db.Create(&server).Error; err != nil { + t.Fatalf("创建测试资产失败: %v", err) + } + + body := []byte(`{ + "hostname":"c-monitor", + "ip":"172.19.16.113", + "agent_version":"v2.1.0", + "cpu":{"usage_percent":1.5,"core_count":4}, + "memory":{"total_gb":15.1,"used_gb":1.8,"available_gb":13.3,"usage_percent":11.9}, + "heartbeat":{"uptime_sec":7200} + }`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/report", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = req + + NewAgentHandler(db, nil, nil).ReceiveReport(c) + if recorder.Code != http.StatusOK { + t.Fatalf("上报返回码 = %d, body=%s", recorder.Code, recorder.Body.String()) + } + + var gotServer model.Server + if err := db.Take(&gotServer, server.ID).Error; err != nil { + t.Fatalf("读取资产失败: %v", err) + } + if gotServer.Uptime != 7200 { + t.Fatalf("心跳运行时长未写入资产: got=%d want=7200", gotServer.Uptime) + } +} + 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{ diff --git a/frontend/src/pages/monitor/host-monitor/MetricChartCard.tsx b/frontend/src/pages/monitor/host-monitor/MetricChartCard.tsx index 9d8ee5d..2a4ff15 100644 --- a/frontend/src/pages/monitor/host-monitor/MetricChartCard.tsx +++ b/frontend/src/pages/monitor/host-monitor/MetricChartCard.tsx @@ -33,6 +33,8 @@ export function MetricChartCard({ const [zoomed, setZoomed] = useState(false); const chartData = data.map((d) => ({ ...d, value: d[aggType] })); const shortTitle = title.replace(/\(.+\)/, '').trim(); + // 当前值可能已包含 %、GB、KB/s 等单位,避免卡片重复拼接。 // Logan.Li + const displayUnit = /[%a-zA-Z/]/.test(value) ? '' : unit; return ( @@ -52,7 +54,7 @@ export function MetricChartCard({
{value} - {unit && {unit}} + {displayUnit && {displayUnit}}
); -} \ No newline at end of file +}