feat: Phase 2-5 监控中心全栈实现 — 指标浏览器/SNMP/站点拨测/中间件
Phase 2 指标监控: - 修复 /metrics 路由(原指向 AlertManager → 现指向 MetricsExplorer) - 新建 MetricsHandler (4 API): targets/list/query/names - 新建 MetricsExplorer 前端(170行): 目标选择器+指标分类树+多指标叠加SVG图表 Phase 3 SNMP设备: - 新建 SNMPHandler (9 API): devices CRUD/templates/stats - SNMPMonitor 接入真实API(428行): 设备表格+添加对话框+删除 Phase 4 站点拨测: - 新建 SiteHandler (8 API): tasks CRUD/results/stats - SiteMonitor 接入真实API(542行): 任务表+添加对话框+拨测类型网格 - 新增 siteApi 到 api.ts Phase 5 中间件采集: - Agent 新增 collector/middleware.go: Redis INFO + MySQL handshake 采集 - Agent CLI 新增 -redis/-redis-auth/-mysql/-mysql-user/-mysql-pass - MiddlewareMonitor 接入真实API(301行): 实例卡片+指标面板 基础设施: - AutoMigrate 新增 23 张监控表 - 前后端容器构建部署验证通过
This commit is contained in:
1 parent
94dbc89b9b
commit
2e71371257
15 files changed
+2616
-137
No files matched your search
@@ -0,0 +1,132 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"ezops/internal/models"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// MetricsHandler 指标浏览器 handler // Logan.Li
|
||||
type MetricsHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewMetricsHandler 创建指标 handler
|
||||
func NewMetricsHandler(db *gorm.DB) *MetricsHandler {
|
||||
return &MetricsHandler{db: db}
|
||||
}
|
||||
|
||||
// ListTargets 列出监控目标(按类型过滤)
|
||||
// GET /api/v1/metrics/targets?type=host&status=online
|
||||
func (h *MetricsHandler) ListTargets(c *gin.Context) {
|
||||
var targets []models.MonitorTarget
|
||||
q := h.db.Model(&models.MonitorTarget{})
|
||||
|
||||
if t := c.Query("type"); t != "" {
|
||||
q = q.Where("type = ?", t)
|
||||
}
|
||||
if s := c.Query("status"); s != "" {
|
||||
q = q.Where("status = ?", s)
|
||||
}
|
||||
if kw := c.Query("keyword"); kw != "" {
|
||||
q = q.Where("name LIKE ? OR ip LIKE ?", "%"+kw+"%", "%"+kw+"%")
|
||||
}
|
||||
|
||||
// 分页
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
|
||||
var total int64
|
||||
q.Count(&total)
|
||||
q.Offset((page - 1) * pageSize).Limit(pageSize).Order("name ASC").Find(&targets)
|
||||
|
||||
if targets == nil {
|
||||
targets = []models.MonitorTarget{}
|
||||
}
|
||||
// 附带最新指标值
|
||||
type TargetWithMetrics struct {
|
||||
models.MonitorTarget
|
||||
LatestMetrics map[string]float64 `json:"latest_metrics"`
|
||||
}
|
||||
result := make([]TargetWithMetrics, len(targets))
|
||||
for i, t := range targets {
|
||||
result[i] = TargetWithMetrics{MonitorTarget: t, LatestMetrics: make(map[string]float64)}
|
||||
// 查询该目标最近 10 分钟内的最新指标
|
||||
var histories []models.MetricHistory
|
||||
h.db.Where("target_id = ? AND timestamp > ?", t.ID, now()-600).
|
||||
Order("timestamp DESC").Limit(10).Find(&histories)
|
||||
for _, mh := range histories {
|
||||
if _, ok := result[i].LatestMetrics[mh.MetricName]; !ok {
|
||||
result[i].LatestMetrics[mh.MetricName] = mh.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: gin.H{
|
||||
"list": result,
|
||||
"total": total,
|
||||
}})
|
||||
}
|
||||
|
||||
// ListMetrics 列出系统中所有指标元数据
|
||||
// GET /api/v1/metrics/list
|
||||
func (h *MetricsHandler) ListMetrics(c *gin.Context) {
|
||||
var metrics []models.MonitorMetric
|
||||
h.db.Order("category ASC, name ASC").Find(&metrics)
|
||||
if metrics == nil {
|
||||
metrics = []models.MonitorMetric{}
|
||||
}
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: metrics})
|
||||
}
|
||||
|
||||
// QueryValues 查询指标时序数据
|
||||
// GET /api/v1/metrics/query?target_id=N&metric_name=cpu_usage&from=0&to=0&limit=100
|
||||
func (h *MetricsHandler) QueryValues(c *gin.Context) {
|
||||
var histories []models.MetricHistory
|
||||
q := h.db.Model(&models.MetricHistory{})
|
||||
|
||||
if tid := c.Query("target_id"); tid != "" {
|
||||
id, _ := strconv.ParseUint(tid, 10, 64)
|
||||
q = q.Where("target_id = ?", id)
|
||||
}
|
||||
if mn := c.Query("metric_name"); mn != "" {
|
||||
q = q.Where("metric_name = ?", mn)
|
||||
}
|
||||
if from := c.Query("from"); from != "" {
|
||||
ts, _ := strconv.ParseInt(from, 10, 64)
|
||||
q = q.Where("timestamp >= ?", ts)
|
||||
}
|
||||
if to := c.Query("to"); to != "" {
|
||||
ts, _ := strconv.ParseInt(to, 10, 64)
|
||||
q = q.Where("timestamp <= ?", ts)
|
||||
}
|
||||
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "200"))
|
||||
q.Order("timestamp ASC").Limit(limit).Find(&histories)
|
||||
|
||||
if histories == nil {
|
||||
histories = []models.MetricHistory{}
|
||||
}
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: histories})
|
||||
}
|
||||
|
||||
// ListMetricNames 列出所有可用的指标名称(去重)
|
||||
// GET /api/v1/metrics/names
|
||||
func (h *MetricsHandler) ListMetricNames(c *gin.Context) {
|
||||
var names []string
|
||||
h.db.Model(&models.MetricHistory{}).
|
||||
Distinct("metric_name").
|
||||
Order("metric_name ASC").
|
||||
Pluck("metric_name", &names)
|
||||
if names == nil {
|
||||
names = []string{}
|
||||
}
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: names})
|
||||
}
|
||||
|
||||
func now() int64 {
|
||||
return time.Now().Unix()
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"ezops/internal/models"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SiteHandler 站点拨测管理 handler // Logan.Li
|
||||
type SiteHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewSiteHandler 创建站点拨测 handler
|
||||
func NewSiteHandler(db *gorm.DB) *SiteHandler {
|
||||
return &SiteHandler{db: db}
|
||||
}
|
||||
|
||||
// ========== Site Task CRUD ==========
|
||||
|
||||
// ListTasks 列出拨测任务
|
||||
// GET /api/v1/site/tasks?page=1&page_size=20&type=&status=
|
||||
func (h *SiteHandler) ListTasks(c *gin.Context) {
|
||||
var tasks []models.SiteTask
|
||||
q := h.db.Model(&models.SiteTask{})
|
||||
|
||||
if t := c.Query("type"); t != "" {
|
||||
q = q.Where("type = ?", t)
|
||||
}
|
||||
if s := c.Query("status"); s != "" {
|
||||
q = q.Where("status = ?", s)
|
||||
}
|
||||
if kw := c.Query("keyword"); kw != "" {
|
||||
q = q.Where("name LIKE ? OR url LIKE ?", "%"+kw+"%", "%"+kw+"%")
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
var total int64
|
||||
q.Count(&total)
|
||||
q.Offset((page - 1) * pageSize).Limit(pageSize).Order("name ASC").Find(&tasks)
|
||||
|
||||
if tasks == nil {
|
||||
tasks = []models.SiteTask{}
|
||||
}
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: gin.H{"list": tasks, "total": total}})
|
||||
}
|
||||
|
||||
// CreateTask 创建拨测任务
|
||||
// POST /api/v1/site/tasks
|
||||
func (h *SiteHandler) CreateTask(c *gin.Context) {
|
||||
var task models.SiteTask
|
||||
if err := c.ShouldBindJSON(&task); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.db.Create(&task).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: task, Message: "创建成功"})
|
||||
}
|
||||
|
||||
// UpdateTask 更新拨测任务
|
||||
// PUT /api/v1/site/tasks/:id
|
||||
func (h *SiteHandler) UpdateTask(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var task models.SiteTask
|
||||
if err := h.db.First(&task, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "任务不存在"})
|
||||
return
|
||||
}
|
||||
if err := c.ShouldBindJSON(&task); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
h.db.Save(&task)
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: task, Message: "更新成功"})
|
||||
}
|
||||
|
||||
// DeleteTask 删除拨测任务
|
||||
// DELETE /api/v1/site/tasks/:id
|
||||
func (h *SiteHandler) DeleteTask(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := h.db.Delete(&models.SiteTask{}, id).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "删除成功"})
|
||||
}
|
||||
|
||||
// BatchDeleteTasks 批量删除
|
||||
// DELETE /api/v1/site/tasks/batch-delete
|
||||
func (h *SiteHandler) BatchDeleteTasks(c *gin.Context) {
|
||||
var req models.IDsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
h.db.Where("id IN ?", req.IDs).Delete(&models.SiteTask{})
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "批量删除成功"})
|
||||
}
|
||||
|
||||
// ========== Site Results ==========
|
||||
|
||||
// ListResults 查询拨测结果
|
||||
// GET /api/v1/site/results?task_id=N&from=&to=&limit=100
|
||||
func (h *SiteHandler) ListResults(c *gin.Context) {
|
||||
var results []models.SiteResult
|
||||
q := h.db.Model(&models.SiteResult{})
|
||||
|
||||
if tid := c.Query("task_id"); tid != "" {
|
||||
q = q.Where("task_id = ?", tid)
|
||||
}
|
||||
if from := c.Query("from"); from != "" {
|
||||
q = q.Where("created_at >= ?", from)
|
||||
}
|
||||
if to := c.Query("to"); to != "" {
|
||||
q = q.Where("created_at <= ?", to)
|
||||
}
|
||||
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "100"))
|
||||
q.Order("created_at DESC").Limit(limit).Find(&results)
|
||||
|
||||
if results == nil {
|
||||
results = []models.SiteResult{}
|
||||
}
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: results})
|
||||
}
|
||||
|
||||
// ========== Site Statistics ==========
|
||||
|
||||
// GetSiteStats 获取拨测概览统计
|
||||
// GET /api/v1/site/stats
|
||||
func (h *SiteHandler) GetSiteStats(c *gin.Context) {
|
||||
var total, normal, abnormal int64
|
||||
h.db.Model(&models.SiteTask{}).Count(&total)
|
||||
h.db.Model(&models.SiteTask{}).Where("status = ?", "active").Count(&normal)
|
||||
h.db.Model(&models.SiteTask{}).Where("status = ?", "error").Count(&abnormal)
|
||||
|
||||
// Calculate availability rate from recent results
|
||||
var availRate float64
|
||||
if total > 0 {
|
||||
var successCount int64
|
||||
h.db.Model(&models.SiteResult{}).
|
||||
Where("created_at > datetime('now', '-1 hour') AND status = 'success'").
|
||||
Count(&successCount)
|
||||
var totalRecent int64
|
||||
h.db.Model(&models.SiteResult{}).
|
||||
Where("created_at > datetime('now', '-1 hour')").
|
||||
Count(&totalRecent)
|
||||
if totalRecent > 0 {
|
||||
availRate = float64(successCount) / float64(totalRecent) * 100
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: gin.H{
|
||||
"total": total,
|
||||
"normal": normal,
|
||||
"abnormal": abnormal,
|
||||
"avail_rate": availRate,
|
||||
}})
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"ezops/internal/models"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SNMPHandler SNMP 设备管理 handler // Logan.Li
|
||||
type SNMPHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewSNMPHandler 创建 SNMP handler
|
||||
func NewSNMPHandler(db *gorm.DB) *SNMPHandler {
|
||||
return &SNMPHandler{db: db}
|
||||
}
|
||||
|
||||
// ========== SNMP Device CRUD ==========
|
||||
|
||||
// ListDevices 列出 SNMP 设备
|
||||
// GET /api/v1/snmp/devices?page=1&page_size=20&keyword=
|
||||
func (h *SNMPHandler) ListDevices(c *gin.Context) {
|
||||
var devices []models.SNMPDevice
|
||||
q := h.db.Model(&models.SNMPDevice{})
|
||||
|
||||
if kw := c.Query("keyword"); kw != "" {
|
||||
q = q.Where("name LIKE ? OR ip LIKE ?", "%"+kw+"%", "%"+kw+"%")
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
var total int64
|
||||
q.Count(&total)
|
||||
q.Offset((page - 1) * pageSize).Limit(pageSize).Order("name ASC").Find(&devices)
|
||||
|
||||
if devices == nil {
|
||||
devices = []models.SNMPDevice{}
|
||||
}
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: gin.H{"list": devices, "total": total}})
|
||||
}
|
||||
|
||||
// CreateDevice 创建 SNMP 设备
|
||||
// POST /api/v1/snmp/devices
|
||||
func (h *SNMPHandler) CreateDevice(c *gin.Context) {
|
||||
var device models.SNMPDevice
|
||||
if err := c.ShouldBindJSON(&device); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.db.Create(&device).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: device, Message: "创建成功"})
|
||||
}
|
||||
|
||||
// UpdateDevice 更新 SNMP 设备
|
||||
// PUT /api/v1/snmp/devices/:id
|
||||
func (h *SNMPHandler) UpdateDevice(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var device models.SNMPDevice
|
||||
if err := h.db.First(&device, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "设备不存在"})
|
||||
return
|
||||
}
|
||||
if err := c.ShouldBindJSON(&device); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
h.db.Save(&device)
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: device, Message: "更新成功"})
|
||||
}
|
||||
|
||||
// DeleteDevice 删除 SNMP 设备
|
||||
// DELETE /api/v1/snmp/devices/:id
|
||||
func (h *SNMPHandler) DeleteDevice(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := h.db.Delete(&models.SNMPDevice{}, id).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "删除成功"})
|
||||
}
|
||||
|
||||
// BatchDeleteDevices 批量删除
|
||||
// DELETE /api/v1/snmp/devices/batch-delete
|
||||
func (h *SNMPHandler) BatchDeleteDevices(c *gin.Context) {
|
||||
var req models.IDsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
h.db.Where("id IN ?", req.IDs).Delete(&models.SNMPDevice{})
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "批量删除成功"})
|
||||
}
|
||||
|
||||
// ========== SNMP Template CRUD ==========
|
||||
|
||||
// ListTemplates 列出 OID 模板
|
||||
// GET /api/v1/snmp/templates
|
||||
func (h *SNMPHandler) ListTemplates(c *gin.Context) {
|
||||
var templates []models.SNMPTemplate
|
||||
h.db.Order("name ASC").Find(&templates)
|
||||
if templates == nil {
|
||||
templates = []models.SNMPTemplate{}
|
||||
}
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: templates})
|
||||
}
|
||||
|
||||
// CreateTemplate 创建 OID 模板
|
||||
// POST /api/v1/snmp/templates
|
||||
func (h *SNMPHandler) CreateTemplate(c *gin.Context) {
|
||||
var template models.SNMPTemplate
|
||||
if err := c.ShouldBindJSON(&template); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.db.Create(&template).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: template, Message: "创建成功"})
|
||||
}
|
||||
|
||||
// ========== SNMP Statistics ==========
|
||||
|
||||
// GetSNMPStats 获取 SNMP 概览统计
|
||||
// GET /api/v1/snmp/stats
|
||||
func (h *SNMPHandler) GetSNMPStats(c *gin.Context) {
|
||||
var total, online int64
|
||||
h.db.Model(&models.SNMPDevice{}).Count(&total)
|
||||
h.db.Model(&models.SNMPDevice{}).Where("status = ?", "online").Count(&online)
|
||||
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: gin.H{
|
||||
"total": total,
|
||||
"online": online,
|
||||
"abnormal": total - online,
|
||||
}})
|
||||
}
|
||||
@@ -465,6 +465,43 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
||||
aiopsGroup.DELETE("/chat-history", llmHandler.ClearChatHistory)
|
||||
}
|
||||
|
||||
// 指标浏览器 — 监控指标元数据 + 时序查询 // Logan.Li
|
||||
metricsHandler := handlers.NewMetricsHandler(db)
|
||||
metricsGroup := authorized.Group("/metrics")
|
||||
{
|
||||
metricsGroup.GET("/targets", metricsHandler.ListTargets)
|
||||
metricsGroup.GET("/list", metricsHandler.ListMetrics)
|
||||
metricsGroup.GET("/query", metricsHandler.QueryValues)
|
||||
metricsGroup.GET("/names", metricsHandler.ListMetricNames)
|
||||
}
|
||||
|
||||
// SNMP 设备管理 — 网络设备/服务器硬件 // Logan.Li
|
||||
snmpHandler := handlers.NewSNMPHandler(db)
|
||||
snmpGroup := authorized.Group("/snmp")
|
||||
{
|
||||
snmpGroup.GET("/devices", snmpHandler.ListDevices)
|
||||
snmpGroup.POST("/devices", snmpHandler.CreateDevice)
|
||||
snmpGroup.PUT("/devices/:id", snmpHandler.UpdateDevice)
|
||||
snmpGroup.DELETE("/devices/:id", snmpHandler.DeleteDevice)
|
||||
snmpGroup.DELETE("/devices/batch-delete", snmpHandler.BatchDeleteDevices)
|
||||
snmpGroup.GET("/templates", snmpHandler.ListTemplates)
|
||||
snmpGroup.POST("/templates", snmpHandler.CreateTemplate)
|
||||
snmpGroup.GET("/stats", snmpHandler.GetSNMPStats)
|
||||
}
|
||||
|
||||
// 站点拨测 — HTTP/TCP/DNS/PING 主动监控 // Logan.Li
|
||||
siteHandler := handlers.NewSiteHandler(db)
|
||||
siteGroup := authorized.Group("/site")
|
||||
{
|
||||
siteGroup.GET("/tasks", siteHandler.ListTasks)
|
||||
siteGroup.POST("/tasks", siteHandler.CreateTask)
|
||||
siteGroup.PUT("/tasks/:id", siteHandler.UpdateTask)
|
||||
siteGroup.DELETE("/tasks/:id", siteHandler.DeleteTask)
|
||||
siteGroup.DELETE("/tasks/batch-delete", siteHandler.BatchDeleteTasks)
|
||||
siteGroup.GET("/results", siteHandler.ListResults)
|
||||
siteGroup.GET("/stats", siteHandler.GetSiteStats)
|
||||
}
|
||||
|
||||
// 启动告警引擎(每 30 秒检查一次)
|
||||
alertEngine := handlers.NewAlertEngine(db)
|
||||
go func() {
|
||||
|
||||
Reference in new issue
Block a user