package handlers import ( "encoding/json" "fmt" "math" "net/http" "strconv" "strings" "time" "ezops/internal/models" ezredis "ezops/pkg/redis" "github.com/gin-gonic/gin" "gorm.io/gorm" "gorm.io/gorm/clause" ) // AgentHandler 处理 Agent 上报数据 // Logan.Li type AgentHandler struct { db *gorm.DB cache ezredis.Cache // 可选 Redis 缓存(用于热数据存储) hubMetrics *MetricsHub // WebSocket 广播 metrics 更新 } func NewAgentHandler(db *gorm.DB, cache ezredis.Cache, hub *MetricsHub) *AgentHandler { return &AgentHandler{db: db, cache: cache, hubMetrics: hub} } // ReceiveReport 接收 Agent 上报的指标数据 // POST /api/v1/agent/report func (h *AgentHandler) ReceiveReport(c *gin.Context) { var req models.AgentReportRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求格式错误: " + err.Error()}) return } // 解析上报时间 reportTime := time.Now() if req.Timestamp != "" { if t, err := time.Parse(time.RFC3339, req.Timestamp); err == nil { reportTime = t } } // 序列化磁盘和端口信息 diskJSON, _ := json.Marshal(req.Disks) portJSON, _ := json.Marshal(req.Ports) netJSON, _ := json.Marshal(req.Network) dioJSON, _ := json.Marshal(req.DiskIO) // 存储到数据库 report := models.AgentReport{ Hostname: req.Hostname, CPUUsage: req.CPU.UsagePercent, CPUCores: req.CPU.CoreCount, Load1: req.CPU.Load1, Load5: req.CPU.Load5, Load15: req.CPU.Load15, MemTotal: req.Memory.TotalGB, MemUsed: req.Memory.UsedGB, MemUsage: req.Memory.UsagePercent, DiskInfo: string(diskJSON), PortInfo: string(portJSON), NetInfo: string(netJSON), DiskIOInfo: string(dioJSON), ReportTime: reportTime, } if err := h.db.Create(&report).Error; err != nil { c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "保存失败: " + err.Error()}) return } // 同步更新 servers 表的实时指标(三级匹配:hostname → client IP → hostname LIKE) diskAvg := avgDiskUsage(req.Disks) // 计算硬件规格 totalDiskGB := 0.0 for _, d := range req.Disks { totalDiskGB += d.TotalGB } memMB := int64(req.Memory.TotalGB * 1024) // GB → MB clientIP := c.ClientIP() // 三级匹配:优先用 Agent 上报的 IP,其次用请求源 IP,最后用报文的 hostname matchIP := req.IP if matchIP == "" { matchIP = clientIP } // 直接 SQL 更新,避免 GORM soft delete 自动添加 deleted_at 条件干扰 updateSQL := `UPDATE servers SET cpu_usage = ?, memory_usage = ?, disk_usage = ?, cpu = ?, memory = ?, disk = ?, status = ?, agent_status = ?, agent_version = ?, hostname = CASE WHEN hostname != ? AND ip = ? THEN ? ELSE hostname END, updated_at = NOW()` args := []interface{}{ req.CPU.UsagePercent, req.Memory.UsagePercent, diskAvg, req.CPU.CoreCount, memMB, int64(totalDiskGB), "running", "online", req.AgentVersion, // CASE WHEN hostname != ? AND ip = ? THEN ? ELSE hostname END req.Hostname, matchIP, req.Hostname, } if req.System != nil && req.System.OSVersion != "" { updateSQL += ", os = ?" args = append(args, req.System.OSVersion) } updateSQL += " WHERE hostname = ? OR ip = ?" args = append(args, req.Hostname, matchIP) result := h.db.Exec(updateSQL, args...) // 如果 hostname 和 IP 都没匹配到,尝试用 hostname 反向匹配 IP 字段 if result.RowsAffected == 0 && req.Hostname != matchIP { updateSQL2 := updateSQL + " OR ip = ?" args2 := append(args, req.Hostname) result = h.db.Exec(updateSQL2, args2...) } // 如果还是没匹配到,记录日志(Agent 上报了,但服务器列表里没有对应条目) if result.RowsAffected == 0 { fmt.Printf("[agent] 上报主机 %q (IP: %s) 未匹配任何 servers 条目,指标仅写入 agent_reports 表\n", req.Hostname, matchIP) } // WebSocket 广播:向所有前端客户端推送该主机的最新指标 // Logan.Li if h.hubMetrics != nil && result.RowsAffected > 0 { h.hubMetrics.Broadcast(MetricsUpdate{ Type: "metrics_update", Hostname: req.Hostname, CPU: req.CPU.UsagePercent, Memory: req.Memory.UsagePercent, Disk: diskAvg, Status: "running", TCPEstablished: req.Network.TCPEstablished, TCPTimeWait: req.Network.TCPTimeWait, NetRxBPS: req.Network.NetRxBPS, NetTxBPS: req.Network.NetTxBPS, DiskReadBPS: req.DiskIO.ReadBPS, DiskWriteBPS: req.DiskIO.WriteBPS, }) } // 写入 Redis 有序集合(热数据,保留7天) if h.cache != nil { timestamp := float64(reportTime.Unix()) ttl := 7 * 24 * time.Hour // 7天 // 使用 reportTime.UnixNano() 确保 member 唯一(避免相同值碰撞) h.writeHotMetric(c, "metrics:cpu:"+req.Hostname, req.CPU.UsagePercent, timestamp, ttl, reportTime) h.writeHotMetric(c, "metrics:mem:"+req.Hostname, req.Memory.UsagePercent, timestamp, ttl, reportTime) h.writeHotMetric(c, "metrics:load1:"+req.Hostname, req.CPU.Load1, timestamp, ttl, reportTime) } // 保存成功 c.JSON(http.StatusOK, models.Response{Code: 200, Message: "ok"}) } // writeHotMetric 将单个指标写入 Redis 有序集合并维护 TTL 和过期数据清理 func (h *AgentHandler) writeHotMetric(c *gin.Context, key string, value float64, score float64, ttl time.Duration, reportTime time.Time) { ctx := c.Request.Context() // member 格式: "value:nano_timestamp" 保证唯一性 member := fmt.Sprintf("%.4f:%d", value, reportTime.UnixNano()) if err := h.cache.ZAdd(ctx, key, ezredis.SortedSetMember{ Score: score, Member: member, }); err != nil { // 静默失败,不影响主流程 return } // 设置 key 过期时间 _ = h.cache.Expire(ctx, key, ttl) // 清理 7 天前的旧数据 cutoff := float64(time.Now().Add(-ttl).Unix()) _ = h.cache.ZRemRangeByScore(ctx, key, "-inf", fmt.Sprintf("%.0f", cutoff)) } // GetLatestReports 获取各主机最新上报(仅 3 分钟内活跃的 hostname) // GET /api/v1/agent/latest func (h *AgentHandler) GetLatestReports(c *gin.Context) { var results []models.AgentLatestResponse threeMinAgo := time.Now().Add(-3 * time.Minute) h.db.Raw(` SELECT a.hostname, COALESCE(s.ip, '') as ip, a.cpu_usage, a.cpu_cores, a.mem_usage, a.mem_total, a.mem_used, COALESCE(s.disk_usage, 0) as disk_usage, a.disk_info, a.net_info, a.report_time, COALESCE(s.agent_version, '') as agent_version, COALESCE(s.agent_status, 'offline') as agent_status, COALESCE(s.os, '') as os, COALESCE(r.name, '') as region FROM agent_reports a LEFT JOIN servers s ON s.hostname = a.hostname LEFT JOIN regions r ON r.id = s.region_id WHERE a.id IN ( SELECT MAX(id) FROM agent_reports WHERE report_time >= ? GROUP BY hostname ) AND s.id IS NOT NULL -- 只显示匹配 servers 表的主机(首次上线后自动同步 hostname) ORDER BY a.report_time DESC `, threeMinAgo).Scan(&results) if results == nil { results = []models.AgentLatestResponse{} } c.JSON(http.StatusOK, models.Response{ Code: 200, Message: "success", Data: results, }) } // GetHostHistory 获取指定主机的历史指标 // GET /api/v1/agent/history/:hostname?hours=1 func (h *AgentHandler) GetHostHistory(c *gin.Context) { hostname := c.Param("hostname") hours := 1 if v := c.Query("hours"); v != "" { fmt.Sscanf(v, "%d", &hours) } since := time.Now().Add(-time.Duration(hours) * time.Hour) var reports []models.AgentReport h.db.Where("hostname = ? AND report_time >= ?", hostname, since). Order("report_time ASC"). Find(&reports) c.JSON(http.StatusOK, models.Response{ Code: 200, Message: "success", Data: reports, }) } // AggregateMetrics 聚合 Redis 热数据到 MySQL klines // POST /api/v1/agent/aggregate func (h *AgentHandler) AggregateMetrics(c *gin.Context) { var req models.AggregateKlineReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求格式错误: " + err.Error()}) return } if h.cache == nil { c.JSON(http.StatusServiceUnavailable, models.Response{Code: 503, Message: "Redis 缓存不可用"}) return } // 支持的聚合间隔及对应的回溯时长 intervals := []struct { name string duration time.Duration }{ {"1m", 1 * time.Minute}, {"5m", 5 * time.Minute}, {"3m", 3 * time.Minute}, {"1h", 1 * time.Hour}, {"6h", 6 * time.Hour}, {"1d", 24 * time.Hour}, } redisKey := fmt.Sprintf("metrics:%s:%s", req.Metric, req.Hostname) ctx := c.Request.Context() var totalUpserted int64 for _, iv := range intervals { // 回溯窗口:取 interval × 200 倍,确保覆盖足够多的数据点 lookback := iv.duration * 200 now := time.Now() since := now.Add(-lookback) minScore := fmt.Sprintf("%d", since.Unix()) maxScore := fmt.Sprintf("%d", now.Unix()+1) members, err := h.cache.ZRangeByScore(ctx, redisKey, minScore, maxScore) if err != nil || len(members) == 0 { continue } // 按间隔分桶 buckets := make(map[int64][]float64) // bucketTime (unix) → values for _, m := range members { ts := int64(m.Score) bucketStart := (ts / int64(iv.duration.Seconds())) * int64(iv.duration.Seconds()) val := parseMemberValue(m.Member) buckets[bucketStart] = append(buckets[bucketStart], val) } // 为每个桶计算 OHLC + Avg for bucketTs, values := range buckets { if len(values) == 0 { continue } open := values[0] closeV := values[len(values)-1] high := values[0] low := values[0] sum := 0.0 for _, v := range values { sum += v if v > high { high = v } if v < low { low = v } } avg := sum / float64(len(values)) kline := models.MetricsKline{ Hostname: req.Hostname, Metric: req.Metric, Interval: iv.name, Open: math.Round(open*100) / 100, High: math.Round(high*100) / 100, Low: math.Round(low*100) / 100, Close: math.Round(closeV*100) / 100, Avg: math.Round(avg*100) / 100, Count: len(values), BucketTime: time.Unix(bucketTs, 0), } // Upsert: hostname + metric + interval + bucket_time 唯一 result := h.db.Clauses(clause.OnConflict{ Columns: []clause.Column{ {Name: "hostname"}, {Name: "metric"}, {Name: "interval"}, {Name: "bucket_time"}, }, DoUpdates: clause.AssignmentColumns([]string{ "open", "high", "low", "close", "avg", "count", }), }).Create(&kline) if result.Error == nil { totalUpserted++ } } } c.JSON(http.StatusOK, models.Response{ Code: 200, Message: fmt.Sprintf("聚合完成,写入/更新 %d 条记录", totalUpserted), Data: gin.H{"upserted": totalUpserted}, }) } // GetKline 查询 K 线数据 // GET /api/v1/agent/kline?hostname=&metric=&interval=&from=&to= func (h *AgentHandler) GetKline(c *gin.Context) { var req models.KlineQueryReq if err := c.ShouldBindQuery(&req); err != nil { c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误: " + err.Error()}) return } from, err := time.Parse(time.RFC3339, req.From) if err != nil { c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "from 格式错误,需 RFC3339: " + err.Error()}) return } to, err := time.Parse(time.RFC3339, req.To) if err != nil { c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "to 格式错误,需 RFC3339: " + err.Error()}) return } var klines []models.MetricsKline result := h.db.Where("hostname = ? AND metric = ? AND `interval` = ? AND bucket_time >= ? AND bucket_time <= ?", req.Hostname, req.Metric, req.Interval, from, to). Order("bucket_time ASC"). Find(&klines) if result.Error != nil { c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "查询失败: " + result.Error.Error()}) return } if klines == nil { klines = []models.MetricsKline{} } c.JSON(http.StatusOK, models.Response{ Code: 200, Message: "success", Data: klines, }) } // parseMemberValue 从 member 字符串 "45.2300:1234567890123456789" 中提取浮点值 func parseMemberValue(member string) float64 { idx := strings.Index(member, ":") if idx < 0 { v, _ := strconv.ParseFloat(member, 64) return v } v, _ := strconv.ParseFloat(member[:idx], 64) return v } // avgDiskUsage 计算磁盘平均使用率 func avgDiskUsage(disks []models.DiskMetric) float64 { if len(disks) == 0 { return 0 } var sum float64 for _, d := range disks { sum += d.UsagePercent } return math.Round(sum/float64(len(disks))*100) / 100 }