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
This commit is contained in:
attacker committed 2026-06-18 16:11:13 +00:00
1 parent c51d0b8b8b
commit 4e3f8cb306
20 files changed
+1315 -1344

No files matched your search

+50 -17
View File
@@ -74,7 +74,7 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) {
return
}
// 同步更新 servers 表的实时指标(通过 hostname 匹配)
// 同步更新 servers 表的实时指标(三级匹配:hostname → client IP → hostname LIKE)
diskAvg := avgDiskUsage(req.Disks)
// 计算硬件规格
@@ -84,19 +84,48 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) {
}
memMB := int64(req.Memory.TotalGB * 1024) // GB → MB
result := h.db.Model(&models.Server{}).
Where("hostname = ? OR ip = ?", req.Hostname, req.Hostname).
Updates(map[string]interface{}{
"cpu_usage": req.CPU.UsagePercent,
"memory_usage": req.Memory.UsagePercent,
"disk_usage": diskAvg,
"cpu": req.CPU.CoreCount,
"memory": memMB,
"disk": int64(totalDiskGB),
"status": "running",
"agent_status": "online",
"agent_version": req.AgentVersion,
})
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 {
@@ -154,10 +183,11 @@ func (h *AgentHandler) writeHotMetric(c *gin.Context, key string, value float64,
_ = h.cache.ZRemRangeByScore(ctx, key, "-inf", fmt.Sprintf("%.0f", cutoff))
}
// GetLatestReports 获取各主机最新上报
// 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,
@@ -179,10 +209,13 @@ func (h *AgentHandler) GetLatestReports(c *gin.Context) {
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 GROUP BY hostname
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
`).Scan(&results)
`, threeMinAgo).Scan(&results)
if results == nil {
results = []models.AgentLatestResponse{}
@@ -152,6 +152,82 @@ func (h *AgentTaskHandler) GetTask(c *gin.Context) {
// ===== Agent 告警规则 =====
// QuickSetupRules POST /api/v1/agent/rules/quick-setup
// 一键创建默认告警规则(CPU/内存/磁盘/网络带宽),作用于所有在线主机
func (h *AgentTaskHandler) QuickSetupRules(c *gin.Context) {
var req struct {
Type string `json:"type"` // "ecs" 或留空
}
c.ShouldBindJSON(&req)
// 获取所有在线主机
var onlineHosts []string
h.db.Model(&models.Server{}).
Where("agent_status = ?", "online").
Pluck("hostname", &onlineHosts)
if len(onlineHosts) == 0 {
// 如果没有在线主机,尝试从 agent_reports 中获取最近 3 分钟活跃的主机名
h.db.Model(&models.AgentReport{}).
Where("report_time >= ?", time.Now().Add(-3*time.Minute)).
Distinct("hostname").
Pluck("hostname", &onlineHosts)
}
if len(onlineHosts) == 0 {
c.JSON(400, models.Response{Code: 400, Message: "没有在线的主机,请先安装并启动 Agent"})
return
}
type ruleDef struct {
Name string
Metric string
Operator string
Threshold float64
Duration int
}
defaultRules := []ruleDef{
{Name: "CPU使用率(Warn)", Metric: "cpu", Operator: ">", Threshold: 80},
{Name: "内存使用率(Warn)", Metric: "mem", Operator: ">", Threshold: 80},
{Name: "磁盘使用率(Warn)", Metric: "disk", Operator: ">", Threshold: 80},
{Name: "网络带宽使用率(Warn)", Metric: "net", Operator: ">", Threshold: 80},
}
created := 0
skipped := 0
for _, host := range onlineHosts {
for _, def := range defaultRules {
// 检查是否已存在同名同主机的规则,避免重复创建
var existing models.AgentRule
h.db.Where("name = ? AND hostname = ?", def.Name, host).First(&existing)
if existing.ID > 0 {
skipped++
continue
}
rule := models.AgentRule{
Name: def.Name,
Hostname: host,
Metric: def.Metric,
Operator: def.Operator,
Threshold: def.Threshold,
Enabled: true,
}
if err := h.db.Create(&rule).Error; err != nil {
continue
}
created++
}
}
c.JSON(200, models.Response{
Code: 200,
Message: fmt.Sprintf("已为 %d 台主机创建 %d 条规则(跳过 %d 条已存在的)", len(onlineHosts), created, skipped),
Data: gin.H{"hosts": len(onlineHosts), "created": created, "skipped": skipped},
})
}
// ListRules GET /api/v1/agent/rules
func (h *AgentTaskHandler) ListRules(c *gin.Context) {
var rules []models.AgentRule
+53 -15
View File
@@ -95,36 +95,74 @@ func (e *AlertEngine) checkResourceAlerts() {
}
}
// 检查主机离线(超过 3 分钟无上报)— 内置规则,无需手动创建
// 检查主机离线(超过 3 分钟无上报)— 内置规则,无需手动创建。
// 以 servers 表为中心:遍历所有服务器,检查其 IP 上是否有 Agent 在最近 3 分钟内上报。
// 如果同一 IP 下有多个 Agent hostname,只要有一个在活跃上报,该服务器就算在线。
func (e *AlertEngine) checkOfflineAlerts() {
offlineThreshold := 3 * time.Minute
// 查所有曾上报的主机
var hostnames []string
e.db.Model(&models.AgentReport{}).Distinct("hostname").Pluck("hostname", &hostnames)
// 获取所有已登记的主机(去重 IP)
var servers []models.Server
e.db.Select("ip, hostname").Group("ip").Find(&servers)
for _, hostname := range hostnames {
var report models.AgentReport
e.db.Where("hostname = ?", hostname).Order("id DESC").First(&report)
if report.ID == 0 {
// 收集所有 agent_reports 中活跃上报的主机名(最近 3 分钟内有上报)
threeMinAgo := time.Now().Add(-offlineThreshold)
var activeHostnames []string
e.db.Model(&models.AgentReport{}).
Where("report_time >= ?", threeMinAgo).
Distinct("hostname").
Pluck("hostname", &activeHostnames)
// 对于每个已登记的服务器,检查是否有活跃 Agent
for _, s := range servers {
if s.IP == "" {
continue
}
isOffline := time.Since(report.ReportTime) > offlineThreshold
// 检查该 IP 对应的服务器是否有活跃 Agent 上报
hasActiveAgent := false
for _, h := range activeHostnames {
// 通过 hostname 精确匹配或 servers 表 IP 匹配
if h == s.Hostname {
hasActiveAgent = true
break
}
// 检查该 hostname 的最新上报——其源 IP 是否匹配此 server IP
// (通过 servers 表中查找 hostname 对应的 IP)
var matchedServer models.Server
e.db.Where("hostname = ?", h).First(&matchedServer)
if matchedServer.ID > 0 && matchedServer.IP == s.IP {
hasActiveAgent = true
break
}
}
// 检查是否有未恢复的离线告警
if hasActiveAgent {
continue // 有活跃 Agent,在线
}
// 该服务器离线:检查是否已有未恢复的离线告警
var existing models.AgentAlert
e.db.Where("hostname = ? AND metric = ? AND resolved = ?",
hostname, "offline", false).First(&existing)
s.Hostname, "offline", false).First(&existing)
if existing.ID == 0 {
// 获取最后一次上报时间计算离线时长
var lastReport models.AgentReport
e.db.Order("id DESC").First(&lastReport)
offlineMinutes := 3.0
if lastReport.ID > 0 {
offlineMinutes = time.Since(lastReport.ReportTime).Minutes()
}
if isOffline && existing.ID == 0 {
alert := models.AgentAlert{
RuleName: "主机离线",
Hostname: hostname,
Hostname: s.Hostname,
Metric: "offline",
Severity: "critical",
Message: fmt.Sprintf("主机 %s 已超过 3 分钟无数据上报,可能已离线", hostname),
Value: time.Since(report.ReportTime).Minutes(),
Message: fmt.Sprintf("主机 %s (%s) 已超过 3 分钟无数据上报,可能已离线", s.Hostname, s.IP),
Value: offlineMinutes,
Threshold: 3,
}
e.db.Create(&alert)
+1
View File
@@ -443,6 +443,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
// 告警规则
agentQuery.GET("/rules", taskHandler.ListRules)
agentQuery.POST("/rules/quick-setup", taskHandler.QuickSetupRules)
agentQuery.POST("/rules", taskHandler.CreateRule)
agentQuery.PUT("/rules/:id", taskHandler.UpdateRule)
agentQuery.DELETE("/rules/:id", taskHandler.DeleteRule)