Files
aiops/backend/internal/api/handlers/agent.go
T
attacker 4e3f8cb306 docs: update README, API reference, dev-log, project structure with Agent monitoring + quick-alarm + latest fixes
- README.md: React 19, default pwd 123456, Docker Compose deploy, correct module list
- api-reference.md: add Agent monitoring section (report/latest/rules/quick-setup/alerts/tasks/kline)
- dev-log.md: new entry documenting hostname/IP fix, system collector, offline detection, quick alarm
- project-structure.md: add agent/ directory, dev-log.md link, React 19
2026-06-18 16:11:13 +00:00

431 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}