Files
Logan.Li 610402d47f fix(dashboard): 修复大屏主机列表恒为空 —— 区域表名错误且错误被静默吞掉
现象:/servers 资产里有 31 台,/dashboards 大屏一台都不显示。

根因:GetLatestReports 的 SQL 中 JOIN 了不存在的表 asset_regions,
实际表名是 asset_cloud_regions(见 model/server.go 的 Region.TableName())。
查询直接报 Error 1146,但 Scan() 的错误未校验,函数静默返回空数组,
接口始终 200 + data:[],因此前端无任何报错可循。

- agent.go: JOIN 改为 asset_cloud_regions,并补上错误校验与 500 响应
- agent_report_test.go: 移除手工创建的 asset_regions,
  改由 AutoMigrate(&model.Server{}) 级联建立 asset_cloud_regions,
  避免测试用错误表名掩盖生产问题

验证:/agent/latest 由 0 台恢复为 31 台(含未装 Agent 的 not_installed 主机)。

// Logan.Li
2026-09-14 08:29:56 +00:00

796 lines
24 KiB
Go
Raw Permalink 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 controller
import (
"context"
"encoding/json"
"fmt"
"log"
"math"
"net/http"
"sort"
"strconv"
"strings"
"time"
"ezops/middleware"
"ezops/model"
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}
}
// resolveAgentAsset 优先按上报主机名或内网 IP 关联现有资产,避免云实例名与系统 hostname 不一致。 // Logan.Li
func (h *AgentHandler) resolveAgentAsset(hostname, reportIP, clientIP string) (model.Server, string, bool) {
matchIP := strings.TrimSpace(reportIP)
if matchIP == "" || matchIP == "unknown" {
matchIP = strings.TrimSpace(clientIP)
}
var server model.Server
query := h.db.Where("hostname = ?", hostname)
if matchIP != "" {
query = h.db.Where("hostname = ? OR ip = ?", hostname, matchIP)
}
if err := query.Take(&server).Error; err == nil {
return server, matchIP, true
}
return server, matchIP, false
}
// ReceiveReport 接收 Agent 上报的指标数据
// POST /api/v1/agent/report
func (h *AgentHandler) ReceiveReport(c *gin.Context) {
var req model.AgentReportRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求格式错误: " + err.Error()})
return
}
if !middleware.RequireAgentHostname(c, req.Hostname) {
return
}
// 解析上报时间
reportTime := time.Now()
if req.Timestamp != "" {
if t, err := time.Parse(time.RFC3339, req.Timestamp); err == nil {
reportTime = t
}
}
// Agent 上报时间统一存储为 UTC,避免 SQLite 按带时区文本比较时误判。 // Logan.Li
reportTime = reportTime.UTC()
matchedServer, matchIP, assetMatched := h.resolveAgentAsset(req.Hostname, req.IP, c.ClientIP())
canonicalHostname := req.Hostname
if assetMatched {
canonicalHostname = matchedServer.Hostname
}
// 序列化磁盘和端口信息
diskJSON, _ := json.Marshal(req.Disks)
portJSON, _ := json.Marshal(req.Ports)
netJSON, _ := json.Marshal(req.Network)
dioJSON, _ := json.Marshal(req.DiskIO)
// v2.0 新增
swapJSON, _ := json.Marshal(req.Swap)
processTopJSON, _ := json.Marshal(req.ProcessTop)
fdJSON, _ := json.Marshal(req.FD)
csJSON, _ := json.Marshal(req.ContextSwitches)
heartbeatJSON, _ := json.Marshal(req.Heartbeat)
// 存储到数据库
report := model.AgentReport{
Hostname: canonicalHostname,
CPUUsage: req.CPU.UsagePercent,
CPUIOwait: req.CPU.IOWait,
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,
MemAvailable: req.Memory.AvailableGB,
MemSwapUsed: req.Memory.SwapUsedGB,
DiskInfo: string(diskJSON),
PortInfo: string(portJSON),
NetInfo: string(netJSON),
DiskIOInfo: string(dioJSON),
// v2.0 新增
SwapInfo: string(swapJSON),
ProcessTopInfo: string(processTopJSON),
FDInfo: string(fdJSON),
CSInfo: string(csJSON),
HeartbeatInfo: string(heartbeatJSON),
ReportTime: reportTime,
}
if err := h.db.Create(&report).Error; err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "保存失败: " + err.Error()})
return
}
// 同步更新 servers 表的实时指标(三级匹配:hostname → client IP → hostname LIKE)
diskAvg := avgDiskUsage(req.Disks)
// 计算硬件规格(四舍五入到整数,避免 31.34→31GB 的显示偏差)
totalDiskGB := 0.0
for _, d := range req.Disks {
totalDiskGB += d.TotalGB
}
memGB := math.Ceil(req.Memory.TotalGB) // 31.34 → 32, 62.79 → 63(物理内存向上取整)
memMB := int64(memGB * 1024) // GB → MB
diskGB := int64(totalDiskGB + 0.5) // 39.99 → 40
// 使用 map 按主键更新,避免可选系统字段导致原始 SQL 占位参数错位。 // Logan.Li
updates := map[string]interface{}{
"cpu_usage": req.CPU.UsagePercent, "memory_usage": req.Memory.UsagePercent,
"disk_usage": diskAvg,
"cpu": req.CPU.CoreCount, "memory": memMB, "disk": diskGB,
"status": "running", "agent_status": "online", "updated_at": time.Now(),
}
if req.AgentVersion != "" {
updates["agent_version"] = req.AgentVersion
}
if req.System != nil {
if req.System.OSVersion != "" {
updates["os"] = req.System.OSVersion
}
if req.System.Kernel != "" {
updates["kernel"] = req.System.Kernel
}
if req.System.Uptime > 0 {
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)
} else {
result = h.db.Model(&model.Server{}).Where("hostname = ? OR ip = ?", req.Hostname, matchIP).Updates(updates)
}
// 如果还是没匹配到 —— 按 hostname 显式查重后再注册 // Logan.Li
// asset_servers.hostname 无 UNIQUE 索引,OnConflict 不可靠,故先 First 查重,
// 避免 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 {
// 已自动注册过 → 仅更新实时指标,不重复创建
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{
Hostname: req.Hostname,
IP: matchIP,
Port: 22,
Username: "root",
AuthType: "password",
OS: osName,
CPU: req.CPU.CoreCount,
Memory: memMB,
Disk: diskGB,
Status: "running",
AgentStatus: "online",
AgentVersion: req.AgentVersion,
Kernel: kernel,
Uptime: uptime,
CPUUsage: req.CPU.UsagePercent,
MemoryUsage: req.Memory.UsagePercent,
DiskUsage: diskAvg,
}
if err := h.db.Create(&newServer).Error; err != nil {
fmt.Printf("[agent] 自动注册主机 %q (IP: %s) 失败: %v\n", req.Hostname, matchIP, err)
} else {
fmt.Printf("[agent] 自动注册新主机: %q (IP: %s, CPU=%d核, 内存=%dMB, 磁盘=%dGB, OS=%q)\n",
req.Hostname, matchIP, req.CPU.CoreCount, memMB, diskGB, osName)
}
}
}
// WebSocket 广播:向所有前端客户端推送该主机的最新指标 // Logan.Li
if h.hubMetrics != nil && result.RowsAffected > 0 {
h.hubMetrics.Broadcast(MetricsUpdate{
Type: "metrics_update",
Hostname: canonicalHostname,
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 有序集合热数据(redka/local 无 ZSet,数据已在 monitor_agent_reports)// Logan.Li
if h.cache != nil && h.cache.Capabilities().SortedSet {
timestamp := float64(reportTime.Unix())
ttl := 7 * 24 * time.Hour
h.writeHotMetric(c, "metrics:cpu:"+canonicalHostname, req.CPU.UsagePercent, timestamp, ttl, reportTime)
h.writeHotMetric(c, "metrics:mem:"+canonicalHostname, req.Memory.UsagePercent, timestamp, ttl, reportTime)
h.writeHotMetric(c, "metrics:load1:"+canonicalHostname, req.CPU.Load1, timestamp, ttl, reportTime)
}
// 保存成功 — 响应中携带探针配置给 Agent
resp := h.makeProbeConfigResponse(canonicalHostname, 200, "ok", req.ProbeResults)
c.JSON(http.StatusOK, resp)
}
// 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 获取所有服务器的 Agent 最新状态(含未安装 Agent 的主机)
// GET /api/v1/agent/latest
func (h *AgentHandler) GetLatestReports(c *gin.Context) {
var results []model.AgentLatestResponse
// 查询边界与 Agent 上报的 UTC 时间保持一致。 // Logan.Li
threeMinAgo := time.Now().UTC().Add(-3 * time.Minute)
// 以 servers 表为主,LEFT JOIN 最近 3 分钟内的 Agent 上报
// 未装 Agent 的主机也会显示(agent_status='not_installed'),打破"不显示→无法安装"的循环
// 必须检查错误:此前未校验导致 asset_regions 表名错误被静默吞掉,
// 接口稳定返回空数组,表现为"资产有主机、大屏没主机"却无任何报错。 // Logan.Li
if err := h.db.Raw(`
SELECT
s.id as server_id,
s.hostname,
COALESCE(s.ip, '') as ip,
COALESCE(a.cpu_usage, 0) as cpu_usage,
COALESCE(a.cpu_cores, 0) as cpu_cores,
COALESCE(a.mem_usage, 0) as mem_usage,
COALESCE(a.mem_total, 0) as mem_total,
COALESCE(a.mem_used, 0) as mem_used,
COALESCE(s.disk_usage, 0) as disk_usage,
a.disk_info,
a.port_info,
a.net_info,
a.disk_io_info,
a.swap_info,
a.process_top_info,
a.fd_info,
a.cs_info,
a.heartbeat_info,
a.report_time,
COALESCE(s.agent_version, '') as agent_version,
CASE
WHEN a.hostname IS NOT NULL THEN COALESCE(s.agent_status, 'online')
ELSE 'not_installed'
END as agent_status,
COALESCE(s.os, '') as os,
COALESCE(s.kernel, '') as kernel,
COALESCE(s.uptime, 0) as uptime,
COALESCE(s.disk, 0) as disk_total,
COALESCE(r.name, '') as region,
CASE WHEN s.password != '' OR s.ssh_key != '' THEN true ELSE false END as has_auth
FROM asset_servers s
LEFT JOIN (
SELECT * FROM monitor_agent_reports
WHERE id IN (
SELECT MAX(id) FROM monitor_agent_reports
WHERE report_time >= ?
GROUP BY hostname
)
) a ON a.hostname = s.hostname
LEFT JOIN asset_cloud_regions r ON r.id = s.region_id
ORDER BY a.report_time DESC, s.id ASC
`, threeMinAgo).Scan(&results).Error; err != nil {
log.Printf("[GetLatestReports] 查询主机最新上报失败: %v", err)
c.JSON(http.StatusInternalServerError, model.Response{
Code: 500, Message: "查询主机列表失败: " + err.Error()})
return
}
if results == nil {
results = []model.AgentLatestResponse{}
}
c.JSON(http.StatusOK, model.Response{
Code: 200,
Message: "success",
Data: results,
})
}
// parseFlexibleTime 解析前端传入的时间(ISO / datetime-local) // Logan.Li
func parseFlexibleTime(s string) (time.Time, error) {
s = strings.TrimSpace(s)
layouts := []string{
time.RFC3339,
"2006-01-02T15:04:05Z07:00",
"2006-01-02 15:04:05",
"2006-01-02T15:04",
"2006-01-02 15:04",
}
for _, layout := range layouts {
if t, err := time.ParseInLocation(layout, s, time.Local); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("无法解析时间: %s", s)
}
// resolveHostHistoryRange 解析历史查询时间范围(minutes / hours / start+end) // Logan.Li
func resolveHostHistoryRange(c *gin.Context) (since, until time.Time, err error) {
until = time.Now()
if startStr := strings.TrimSpace(c.Query("start")); startStr != "" {
endStr := strings.TrimSpace(c.Query("end"))
if endStr == "" {
return since, until, fmt.Errorf("自定义范围缺少 end 参数")
}
since, err = parseFlexibleTime(startStr)
if err != nil {
return since, until, err
}
until, err = parseFlexibleTime(endStr)
if err != nil {
return since, until, err
}
if until.Before(since) {
since, until = until, since
}
return since, until, nil
}
if minStr := strings.TrimSpace(c.Query("minutes")); minStr != "" {
mins, _ := strconv.Atoi(minStr)
if mins <= 0 {
mins = 60
}
if mins > 43200 {
mins = 43200
}
since = until.Add(-time.Duration(mins) * time.Minute)
return since, until, nil
}
hours := 1
if v := strings.TrimSpace(c.Query("hours")); v != "" {
hours, _ = strconv.Atoi(v)
if hours <= 0 {
hours = 1
}
if hours > 720 {
hours = 720
}
}
since = until.Add(-time.Duration(hours) * time.Hour)
return since, until, nil
}
// GetHostHistory 获取指定主机的历史指标
// GET /api/v1/agent/history/:hostname?minutes=60 | ?hours=1 | ?start=&end=
func (h *AgentHandler) GetHostHistory(c *gin.Context) {
hostname := c.Param("hostname")
since, until, err := resolveHostHistoryRange(c)
if err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
return
}
var reports []model.AgentReport
// 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)
c.JSON(http.StatusOK, model.Response{
Code: 200,
Message: "success",
Data: reports,
})
}
// AggregateMetrics 聚合指标到 monitor_metrics_aggregate(Redis ZSet 或 DB 直读)// Logan.Li
// POST /api/v1/agent/aggregate(需要控制台 JWT) // Logan.Li
func (h *AgentHandler) AggregateMetrics(c *gin.Context) {
var req model.AggregateReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求格式错误: " + err.Error()})
return
}
var totalUpserted int64
var err error
if h.cache != nil && h.cache.Capabilities().SortedSet {
totalUpserted, err = h.aggregateFromRedis(c.Request.Context(), req)
} else {
totalUpserted, err = h.aggregateFromReports(req)
}
if err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: err.Error()})
return
}
c.JSON(http.StatusOK, model.Response{
Code: 200,
Message: fmt.Sprintf("聚合完成,写入/更新 %d 条记录", totalUpserted),
Data: gin.H{"upserted": totalUpserted, "source": aggregateSource(h.cache)},
})
}
func aggregateSource(cache ezredis.Cache) string {
if cache != nil && cache.Capabilities().SortedSet {
return "redis"
}
return "database"
}
func (h *AgentHandler) aggregateFromRedis(ctx context.Context, req model.AggregateReq) (int64, error) {
if h.cache == nil {
return 0, fmt.Errorf("Redis 缓存不可用")
}
intervals := aggregateIntervals()
redisKey := fmt.Sprintf("metrics:%s:%s", req.Metric, req.Hostname)
var total int64
for _, iv := range intervals {
lookback := iv.duration * 200
now := time.Now()
since := now.Add(-lookback)
members, err := h.cache.ZRangeByScore(ctx, redisKey,
fmt.Sprintf("%d", since.Unix()), fmt.Sprintf("%d", now.Unix()+1))
if err != nil || len(members) == 0 {
continue
}
buckets := make(map[int64][]float64)
for _, m := range members {
ts := int64(m.Score)
bucketStart := (ts / int64(iv.duration.Seconds())) * int64(iv.duration.Seconds())
buckets[bucketStart] = append(buckets[bucketStart], parseMemberValue(m.Member))
}
n, err := h.upsertMetricBuckets(req, iv.name, buckets)
if err != nil {
return total, err
}
total += n
}
return total, nil
}
// aggregateFromReports local 模式:从 monitor_agent_reports 聚合(无需 Redis ZSet)// Logan.Li
func (h *AgentHandler) aggregateFromReports(req model.AggregateReq) (int64, error) {
intervals := aggregateIntervals()
var total int64
for _, iv := range intervals {
lookback := iv.duration * 200
// 本地 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 {
return total, err
}
if len(reports) == 0 {
continue
}
buckets := make(map[int64][]float64)
sec := int64(iv.duration.Seconds())
for _, r := range reports {
ts := r.ReportTime.Unix()
bucketStart := (ts / sec) * sec
buckets[bucketStart] = append(buckets[bucketStart], metricValueFromReport(r, req.Metric))
}
n, err := h.upsertMetricBuckets(req, iv.name, buckets)
if err != nil {
return total, err
}
total += n
}
return total, nil
}
func aggregateIntervals() []struct {
name string
duration time.Duration
} {
return []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},
}
}
func metricValueFromReport(r model.AgentReport, metric string) float64 {
switch metric {
case "cpu":
return r.CPUUsage
case "mem":
return r.MemUsage
case "load1":
return r.Load1
default:
return 0
}
}
func (h *AgentHandler) upsertMetricBuckets(req model.AggregateReq, interval string, buckets map[int64][]float64) (int64, error) {
var total int64
for bucketTs, values := range buckets {
if len(values) == 0 {
continue
}
maxV, minV, avg, p95 := calcAggregateStats(values)
agg := model.MetricsAggregate{
Hostname: req.Hostname,
Metric: req.Metric,
Interval: interval,
Avg: math.Round(avg*100) / 100,
MaxV: math.Round(maxV*100) / 100,
MinV: math.Round(minV*100) / 100,
P95: math.Round(p95*100) / 100,
Count: len(values),
BucketTime: time.Unix(bucketTs, 0),
}
result := h.db.Clauses(clause.OnConflict{
Columns: []clause.Column{
{Name: "hostname"},
{Name: "metric"},
{Name: "interval"},
{Name: "bucket_time"},
},
DoUpdates: clause.AssignmentColumns([]string{
"avg", "max_v", "min_v", "p95", "count",
}),
}).Create(&agg)
if result.Error != nil {
return total, result.Error
}
total++
}
return total, nil
}
func calcAggregateStats(values []float64) (maxV, minV, avg, p95 float64) {
maxV = values[0]
minV = values[0]
sum := 0.0
for _, v := range values {
sum += v
if v > maxV {
maxV = v
}
if v < minV {
minV = v
}
}
avg = sum / float64(len(values))
p95 = maxV
if len(values) >= 20 {
sorted := make([]float64, len(values))
copy(sorted, values)
sort.Float64s(sorted)
idx := int(float64(len(sorted)) * 0.95)
if idx >= len(sorted) {
idx = len(sorted) - 1
}
p95 = sorted[idx]
}
return
}
// GetAggregate 查询聚合数据(原 GetKline)
// GET /api/v1/agent/aggregate?hostname=&metric=&interval=&from=&to=
func (h *AgentHandler) GetAggregate(c *gin.Context) {
var req model.AggregateQueryReq
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "参数错误: " + err.Error()})
return
}
from, err := time.Parse(time.RFC3339, req.From)
if err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "from 格式错误,需 RFC3339: " + err.Error()})
return
}
to, err := time.Parse(time.RFC3339, req.To)
if err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "to 格式错误,需 RFC3339: " + err.Error()})
return
}
var items []model.MetricsAggregate
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(&items)
if result.Error != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "查询失败: " + result.Error.Error()})
return
}
if items == nil {
items = []model.MetricsAggregate{}
}
c.JSON(http.StatusOK, model.Response{
Code: 200,
Message: "success",
Data: items,
})
}
// 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 []model.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
}
// ── 端口监控策略 ── // Logan.Li
// GetPortPolicyForAgent Agent 拉取本机端口策略(无需鉴权,通过 hostname 查询)
// GET /api/v1/agent/port-policy?hostname=xxx
func (h *AgentHandler) GetPortPolicyForAgent(c *gin.Context) {
hostname := c.Query("hostname")
if !middleware.RequireAgentHostname(c, hostname) {
return
}
if hostname == "" {
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "success", Data: model.AgentPortPolicyDTO{
PortFilterMode: "whitelist",
PortWhitelist: []int{},
}})
return
}
var policy model.AgentPortPolicy
if err := h.db.Where("hostname = ?", hostname).First(&policy).Error; err != nil {
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "success", Data: model.AgentPortPolicyDTO{
Hostname: hostname,
PortFilterMode: "whitelist",
PortWhitelist: []int{},
}})
return
}
var whitelist []int
json.Unmarshal([]byte(policy.PortWhitelist), &whitelist)
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "success", Data: model.AgentPortPolicyDTO{
Hostname: policy.Hostname,
PortFilterMode: policy.PortFilterMode,
PortWhitelist: whitelist,
}})
}
// GetPortPolicy 前端获取主机的端口监控策略
// GET /api/v1/agent/port-policies/:hostname
func (h *AgentHandler) GetPortPolicy(c *gin.Context) {
hostname := c.Param("hostname")
var policy model.AgentPortPolicy
var whitelist []int
if err := h.db.Where("hostname = ?", hostname).First(&policy).Error; err != nil {
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "success", Data: model.AgentPortPolicyDTO{
Hostname: hostname,
PortFilterMode: "whitelist",
PortWhitelist: []int{},
}})
return
}
json.Unmarshal([]byte(policy.PortWhitelist), &whitelist)
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "success", Data: model.AgentPortPolicyDTO{
Hostname: policy.Hostname,
PortFilterMode: policy.PortFilterMode,
PortWhitelist: whitelist,
}})
}
// SavePortPolicy 更新/创建主机的端口监控策略
// PUT /api/v1/agent/port-policies/:hostname
func (h *AgentHandler) SavePortPolicy(c *gin.Context) {
hostname := c.Param("hostname")
var dto model.AgentPortPolicyDTO
if err := c.ShouldBindJSON(&dto); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求格式错误: " + err.Error()})
return
}
whitelistJSON := "[]"
if len(dto.PortWhitelist) > 0 {
b, _ := json.Marshal(dto.PortWhitelist)
whitelistJSON = string(b)
}
policy := model.AgentPortPolicy{
Hostname: hostname,
PortFilterMode: dto.PortFilterMode,
PortWhitelist: whitelistJSON,
}
if err := h.db.Where(model.AgentPortPolicy{Hostname: hostname}).
Assign(policy).
FirstOrCreate(&policy).Error; err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "保存失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "端口策略已保存"})
}